Data availability. The input file
bilbasen_data.csv— about 9,700 adverts — is not distributed with this repository. Republishing it would be a substantial re-utilisation of bilbasen.dk’s database, protected under the EU database directive (in Denmark, ophavsretsloven § 71). What is published is the derived statistics, the ~100-row single-model extract used in section 5 with its advert-pointer and seller-location columns removed, and this rendered note, which is the record of what the full dataset showed when it was pulled. To re-run the analysis, regenerate the input withscrape_bilbasen.pyfirst.
Estimates the average annual depreciation of different car brands from used-car listings scraped from bilbasen.dk. For each listing we use kontantpris (asking cash price) and the first-registration month/year, and define a continuous age in years
\[\text{age} = \frac{\text{today} - \text{registration date}}{365.25},\]
dating each car to the 15th of its registration month (mid-year for the ~0.4% of listings lacking a month).
Two estimates are produced:
lm(log(kontantpris) ~ age). The age coefficient \(b\) implies a constant proportional loss of \(1 - e^{b}\) per extra year of age. A second model adds log(mileage) to hold usage constant. This needs only price + year, which every listing has.nypris check. New price has no structured field on bilbasen and appears only sporadically in free text, so it is used merely as a sparse cross-check: \(\text{dep} = 1 - (\text{kontantpris}/\text{nypris})^{1/\text{age}}\).stopifnot(file.exists(CSV))
d <- read.csv(CSV, stringsAsFactors = FALSE, fileEncoding = "UTF-8")
# The CSV accumulates scrape snapshots (June + September 2026); a car listed in
# both appears more than once. Keep the most recent listing per external_id.
d <- d[order(d$external_id, d$scraped_at), ]
d <- d[!duplicated(d$external_id, fromLast = TRUE), ]
num <- function(x) suppressWarnings(as.numeric(x))
d$kontantpris <- num(d$kontantpris)
d$reg_year <- num(d$reg_year)
d$reg_month <- num(d$reg_month)
d$mileage_km <- num(d$mileage_km)
d$nypris <- num(d$nypris)
# Continuous age in years from the first-registration date (mid-month = day 15;
# the ~0.4% of rows without a month fall back to mid-year). This puts the right
# fraction of cars inside the first year, sharpening the first-year estimates.
mo <- ifelse(is.na(d$reg_month) | d$reg_month < 1 | d$reg_month > 12, 7, d$reg_month)
reg_date <- as.Date(sprintf("%04d-%02d-15", d$reg_year, mo))
d$age <- as.numeric(Sys.Date() - reg_date) / 365.25
d$age <- pmax(d$age, 1 / 365) # floor brand-new cars at ~1 day, not 0
d$age_eff <- d$age # already strictly positive
# keep genuine retail cars with sane values; drop scrap/project noise
keep <- with(d,
!is.na(kontantpris) & kontantpris >= 10000 & kontantpris <= 5e6 &
!is.na(reg_year) & reg_year >= 1990 & reg_year <= CUR_YEAR &
!is.na(age) & age <= 30 &
(is.na(price_type) | price_type == "Retail"))
d <- d[keep, ]
# group on canonical make, fall back to slug
d$brand <- ifelse(is.na(d$make) | d$make == "", d$brand_slug, d$make)
cat(sprintf("%d usable listings across %d brands.\n",
nrow(d), length(unique(d$brand))))
## 9032 usable listings across 25 brands.
knitr::kable(as.data.frame(table(Brand = d$brand)), col.names = c("Brand", "n"))
| Brand | n |
|---|---|
| Audi | 289 |
| BMW | 262 |
| Citroën | 286 |
| Dacia | 173 |
| Fiat | 289 |
| Ford | 263 |
| Honda | 154 |
| Hyundai | 702 |
| Kia | 290 |
| MINI | 298 |
| Mazda | 283 |
| Mercedes | 256 |
| Mitsubishi | 118 |
| Nissan | 285 |
| Opel | 276 |
| Peugeot | 284 |
| Renault | 285 |
| Seat | 293 |
| Skoda | 714 |
| Subaru | 81 |
| Suzuki | 284 |
| Tesla | 739 |
| Toyota | 701 |
| VW | 738 |
| Volvo | 689 |
For every brand with enough data we fit the log-price model and translate the age coefficient into a yearly depreciation percentage.
MIN_N <- 15 # min sample to fit a brand
brands <- sort(unique(d$brand))
rows <- list()
models <- list() # keep each brand's age-only fit for the diagnostics appendix
for (b in brands) {
sub <- d[d$brand == b, ]
if (nrow(sub) < MIN_N || length(unique(sub$age)) < 3) next
m1 <- lm(log(kontantpris) ~ age, data = sub)
dep_rate <- 1 - exp(coef(m1)[["age"]]) # fraction lost per year
models[[b]] <- m1
# control for mileage where available
sub2 <- sub[!is.na(sub$mileage_km) & sub$mileage_km > 0, ]
dep_rate_adj <- NA_real_
if (nrow(sub2) >= MIN_N && length(unique(sub2$age)) >= 3) {
m2 <- lm(log(kontantpris) ~ age + log(mileage_km), data = sub2)
dep_rate_adj <- 1 - exp(coef(m2)[["age"]])
}
rows[[b]] <- data.frame(
brand = b,
n = nrow(sub),
median_price = round(median(sub$kontantpris)),
median_age = round(median(sub$age), 1),
dep_pct_yr = round(100 * dep_rate, 1),
dep_pct_yr_adj = round(100 * dep_rate_adj, 1),
r2 = round(summary(m1)$r.squared, 2),
stringsAsFactors = FALSE)
}
res <- do.call(rbind, rows)
res <- res[order(res$dep_pct_yr), ]
rownames(res) <- NULL
write.csv(res, "depreciation_by_brand.csv", row.names = FALSE)
dep_pct_yr = % of value lost per year of age (log-price model); dep_pct_yr_adj = same, holding mileage constant.
knitr::kable(res, caption = "Annual depreciation by brand (slowest first)")
| brand | n | median_price | median_age | dep_pct_yr | dep_pct_yr_adj | r2 |
|---|---|---|---|---|---|---|
| Mercedes | 256 | 359850 | 2.7 | 5.5 | 6.0 | 0.23 |
| MINI | 298 | 169900 | 3.5 | 7.3 | 5.4 | 0.42 |
| Tesla | 739 | 244900 | 3.7 | 7.9 | 4.9 | 0.36 |
| Mazda | 283 | 149900 | 6.9 | 8.3 | 7.0 | 0.56 |
| BMW | 262 | 356200 | 2.8 | 9.2 | 8.4 | 0.44 |
| Subaru | 81 | 239800 | 5.3 | 9.3 | 10.0 | 0.75 |
| Volvo | 689 | 275000 | 3.4 | 9.8 | 9.7 | 0.56 |
| Fiat | 289 | 107800 | 5.1 | 10.8 | 9.8 | 0.85 |
| Audi | 289 | 369900 | 3.1 | 11.0 | 10.7 | 0.66 |
| Honda | 154 | 106950 | 10.5 | 11.1 | 9.0 | 0.69 |
| Dacia | 173 | 84900 | 7.0 | 11.2 | 11.7 | 0.62 |
| Peugeot | 284 | 127840 | 6.0 | 11.2 | 9.5 | 0.70 |
| Toyota | 701 | 129900 | 6.6 | 11.4 | 10.8 | 0.65 |
| Mitsubishi | 118 | 68300 | 9.6 | 11.5 | 10.2 | 0.51 |
| Nissan | 285 | 144900 | 6.8 | 11.8 | 11.0 | 0.70 |
| Opel | 276 | 119900 | 5.8 | 11.9 | 11.7 | 0.80 |
| Citroën | 286 | 96995 | 6.1 | 12.1 | 11.1 | 0.83 |
| Suzuki | 284 | 109700 | 7.1 | 12.5 | 11.4 | 0.81 |
| Renault | 285 | 116800 | 6.9 | 12.9 | 11.7 | 0.78 |
| VW | 738 | 239900 | 3.3 | 12.9 | 12.3 | 0.77 |
| Seat | 293 | 149800 | 6.9 | 13.8 | 14.2 | 0.63 |
| Ford | 263 | 149800 | 6.2 | 14.0 | 13.0 | 0.78 |
| Skoda | 714 | 234900 | 4.1 | 14.2 | 14.7 | 0.85 |
| Hyundai | 702 | 154900 | 4.5 | 14.6 | 14.9 | 0.80 |
| Kia | 290 | 174800 | 4.3 | 15.2 | 15.1 | 0.86 |
op <- par(mar = c(4, 7, 2, 1))
o <- order(res$dep_pct_yr)
barplot(res$dep_pct_yr[o], names.arg = res$brand[o], horiz = TRUE, las = 1,
col = "steelblue", xlab = "% depreciation per year",
main = "Annual depreciation by brand")
par(op)
dn <- d[!is.na(d$nypris) & d$nypris > d$kontantpris & d$age >= 1, ]
cat(sprintf("%d of %d listings (%.1f%%) quote a usable nypris.\n",
nrow(dn), nrow(d), 100 * nrow(dn) / max(nrow(d), 1)))
## 18 of 9032 listings (0.2%) quote a usable nypris.
if (nrow(dn) >= 10) {
dn$dep_direct <- 1 - (dn$kontantpris / dn$nypris)^(1 / dn$age_eff)
agg <- aggregate(dep_direct ~ brand, dn, function(x) round(100 * median(x), 1))
cnt <- aggregate(dep_direct ~ brand, dn, length)
agg <- merge(agg, cnt, by = "brand", suffixes = c("_pct_yr", "_n"))
names(agg) <- c("brand", "dep_pct_yr_direct", "n")
knitr::kable(agg[order(agg$dep_pct_yr_direct), ], row.names = FALSE,
caption = "Direct nypris-based depreciation (cross-check)")
} else {
cat("Too few nypris values for a per-brand direct estimate (expected).\n")
}
| brand | dep_pct_yr_direct | n |
|---|---|---|
| MINI | 11.1 | 3 |
| Ford | 12.2 | 2 |
| Mercedes | 13.2 | 1 |
| Skoda | 13.2 | 1 |
| Volvo | 13.7 | 2 |
| Tesla | 14.7 | 4 |
| Opel | 16.1 | 1 |
| Audi | 19.7 | 3 |
| Fiat | 24.0 | 1 |
The appendix p_curv test rejects a single constant rate for most brands: depreciation is steeper early and flatter later. We relax the straight line with a two-segment (“broken-stick”) log model, knot at age 1:
\[\log(\text{price}) = a + \beta_1\,\underbrace{\min(\text{age},1)}_{\text{first year}} + \beta_2\,\underbrace{\max(\text{age}-1,0)}_{\text{later years}}.\]
So \(1-e^{\beta_1}\) is the first-year drop and \(1-e^{\beta_2}\) the ongoing annual rate thereafter. CIs below are HC3 heteroscedasticity-robust (the appendix showed plain OLS SEs are unsafe here).
sp <- do.call(rbind, lapply(res$brand, function(b) {
s <- d[d$brand == b, ]
s$seg1 <- pmin(s$age, 1); s$seg2 <- pmax(s$age - 1, 0)
base <- data.frame(brand = b, n = nrow(s), n_lt1y = sum(s$age < 1),
first_yr_pct = NA_real_, first_yr_ci = "—",
ongoing_pct = res$dep_pct_yr[res$brand == b],
ongoing_ci = "(see A)", stringsAsFactors = FALSE)
if (length(unique(s$seg1)) < 2 || length(unique(s$seg2)) < 2) return(base)
m <- lm(log(kontantpris) ~ seg1 + seg2, data = s)
r1 <- robust_ci(m, "seg1"); r2 <- robust_ci(m, "seg2")
c1 <- dep_ci(r1[1], r1[2]); c2 <- dep_ci(r2[1], r2[2])
base$first_yr_pct <- round(100 * (1 - exp(coef(m)[["seg1"]])), 1)
base$first_yr_ci <- sprintf("[%.1f, %.1f]", c1[1], c1[2])
base$ongoing_pct <- round(100 * (1 - exp(coef(m)[["seg2"]])), 1)
base$ongoing_ci <- sprintf("[%.1f, %.1f]", c2[1], c2[2])
base
}))
sp <- sp[order(sp$ongoing_pct), ]
knitr::kable(sp, row.names = FALSE,
caption = "First-year drop vs. ongoing annual rate (HC3-robust 95% CIs)")
| brand | n | n_lt1y | first_yr_pct | first_yr_ci | ongoing_pct | ongoing_ci |
|---|---|---|---|---|---|---|
| Mercedes | 256 | 49 | 36.5 | [10.9, 54.8] | 5.1 | [2.9, 7.2] |
| MINI | 298 | 3 | 90.4 | [-683950.2, 100.0] | 7.3 | [5.4, 9.1] |
| Tesla | 739 | 13 | 17.4 | [-27.7, 46.5] | 7.9 | [6.5, 9.2] |
| Mazda | 283 | 36 | 34.7 | [16.8, 48.8] | 8.0 | [6.4, 9.6] |
| BMW | 262 | 23 | 26.5 | [-52.9, 64.7] | 9.1 | [6.9, 11.4] |
| Subaru | 81 | 8 | -5.0 | [-34.5, 18.0] | 9.4 | [7.5, 11.3] |
| Volvo | 689 | 42 | 28.6 | [15.5, 39.6] | 9.7 | [8.8, 10.5] |
| Fiat | 289 | 7 | -2.2 | [-63.1, 35.9] | 10.8 | [10.1, 11.4] |
| Audi | 289 | 59 | 9.7 | [-7.5, 24.2] | 11.0 | [9.9, 12.2] |
| Dacia | 173 | 4 | 66.2 | [-307921613766607680.0, 100.0] | 11.0 | [9.4, 12.6] |
| Honda | 154 | 4 | 13.1 | [-22271.7, 99.7] | 11.1 | [9.7, 12.5] |
| Peugeot | 284 | 6 | 50.1 | [20.3, 68.8] | 11.1 | [9.3, 12.9] |
| Toyota | 701 | 60 | 24.2 | [14.4, 32.9] | 11.2 | [10.1, 12.2] |
| Mitsubishi | 118 | 0 | NA | — | 11.5 | (see A) |
| Nissan | 285 | 31 | 9.5 | [-23.9, 33.9] | 11.8 | [9.9, 13.7] |
| Opel | 276 | 3 | 28.8 | [-131.6, 78.1] | 11.9 | [11.0, 12.8] |
| Citroën | 286 | 11 | 39.0 | [16.4, 55.4] | 11.9 | [11.2, 12.5] |
| Suzuki | 284 | 7 | -10.6 | [-67.7, 27.1] | 12.5 | [11.7, 13.3] |
| Renault | 285 | 9 | 44.6 | [-29.6, 76.3] | 12.7 | [11.9, 13.6] |
| VW | 738 | 104 | 20.6 | [10.1, 29.9] | 12.7 | [11.9, 13.6] |
| Seat | 293 | 0 | NA | — | 13.8 | (see A) |
| Ford | 263 | 10 | -3.4 | [-121.4, 51.7] | 14.1 | [12.6, 15.5] |
| Skoda | 714 | 105 | 2.0 | [-3.9, 7.5] | 14.5 | [13.9, 15.1] |
| Hyundai | 702 | 83 | 3.8 | [-9.4, 15.5] | 14.8 | [14.1, 15.5] |
| Kia | 290 | 69 | 20.7 | [8.0, 31.7] | 15.0 | [13.9, 16.1] |
sp2 <- sp[!is.na(sp$first_yr_pct), ]
sp2 <- sp2[order(sp2$ongoing_pct), ]
y <- seq_len(nrow(sp2))
op <- par(mar = c(4, 7, 3, 1))
plot(range(c(sp2$first_yr_pct, sp2$ongoing_pct)), c(1, nrow(sp2)), type = "n",
yaxt = "n", xlab = "% depreciation", ylab = "",
main = "First-year drop (red) vs. ongoing annual rate (blue)")
abline(v = 0, col = "grey85", lty = 3)
segments(sp2$ongoing_pct, y, sp2$first_yr_pct, y, col = "grey70")
points(sp2$ongoing_pct, y, pch = 16, col = "steelblue")
points(sp2$first_yr_pct, y, pch = 16, col = "firebrick")
axis(2, y, sp2$brand, las = 1, cex.axis = 0.8)
par(op)
Reading this:
n_lt1y is the count of cars under one year old driving the first-year estimate; when it is small the first-year figure has a wide CI — don’t over-read.first_yr_pct as “first observed year on the used market,” not the full new-car hit.Each panel: individual listings (points) with the fitted log-price curve (red).
op <- par(mfrow = c(4, 5), mar = c(3.2, 3.2, 2, 0.6), mgp = c(1.9, 0.6, 0))
for (b in res$brand) {
sub <- d[d$brand == b, ]
plot(jitter(sub$age), sub$kontantpris / 1000, pch = 16, cex = 0.4,
col = rgb(0, 0, 0, 0.3), xlab = "age (yrs)", ylab = "price (1000 kr)",
main = sprintf("%s (%.1f%%/yr)", b, res$dep_pct_yr[res$brand == b]))
ag <- seq(0, max(sub$age, na.rm = TRUE), length.out = 50)
m1 <- lm(log(kontantpris) ~ age, data = sub)
lines(ag, exp(predict(m1, data.frame(age = ag))) / 1000,
col = "firebrick", lwd = 2)
}
par(op)
The Subaru Solterra and Toyota bZ4X are the jointly developed Toyota–Subaru battery-EV twins: the same platform, factory and drivetrain, sold under two brands. That makes them a natural controlled experiment — if depreciation is driven by the car, they should shed value at the same rate; if brand perception matters, they should diverge.
Data are all current bilbasen listings for each model, pulled separately from the brand-level dataset by scrape_models.py into solterra_bz4x_data.csv. Two notes on scope:
bz4x_touring, 12 used listings). Our pull uses the bz4x slug, which returns only the standard model (zero Touring cars), so the Touring is excluded by construction, as requested.71 Touring), not the Toyota variant — so all Solterra listings are kept.sb <- read.csv("solterra_bz4x_data.csv", stringsAsFactors = FALSE,
fileEncoding = "UTF-8")
sb$kontantpris <- num(sb$kontantpris)
sb$reg_year <- num(sb$reg_year)
sb$reg_month <- num(sb$reg_month)
mo2 <- ifelse(is.na(sb$reg_month) | sb$reg_month < 1 | sb$reg_month > 12, 7,
sb$reg_month)
sb$age <- pmax(as.numeric(Sys.Date() -
as.Date(sprintf("%04d-%02d-15", sb$reg_year, mo2))) / 365.25, 1/365)
sb$model2 <- ifelse(sb$query == "subaru-solterra", "Solterra", "bZ4X")
sb <- sb[!is.na(sb$kontantpris) & sb$kontantpris >= 1e4 &
!is.na(sb$age) & sb$age <= 30, ]
tab <- do.call(rbind, lapply(c("Solterra", "bZ4X"), function(mm) {
s <- sb[sb$model2 == mm, ]
m <- lm(log(kontantpris) ~ age, data = s)
ci <- dep_ci(robust_ci(m, "age")[1], robust_ci(m, "age")[2])
data.frame(model = mm, n = nrow(s),
median_price = round(median(s$kontantpris)),
age_range = sprintf("%.1f–%.1f", min(s$age), max(s$age)),
dep_pct_yr = round(100 * (1 - exp(coef(m)[["age"]])), 1),
dep_ci = sprintf("[%.1f, %.1f]", ci[1], ci[2]),
r2 = round(summary(m)$r.squared, 2), stringsAsFactors = FALSE)
}))
knitr::kable(tab, row.names = FALSE,
caption = "Solterra vs bZ4X: annual depreciation (HC3-robust 95% CIs)")
| model | n | median_price | age_range | dep_pct_yr | dep_ci | r2 |
|---|---|---|---|---|---|---|
| Solterra | 25 | 264800 | 0.3–3.5 | 10.7 | [6.9, 14.4] | 0.69 |
| bZ4X | 78 | 299900 | 0.0–4.2 | 9.0 | [7.8, 10.1] | 0.74 |
To test the difference directly, pool both models and interact brand with age (Solterra is the baseline): the model2bZ4X term is the price-level gap and the age:model2bZ4X term is the difference in depreciation slope.
pooled <- lm(log(kontantpris) ~ age * model2, data = sb)
knitr::kable(round(summary(pooled)$coefficients, 4),
caption = "Pooled model: log(kontantpris) ~ age * model (Solterra = baseline)")
| Estimate | Std. Error | t value | Pr(>|t|) | |
|---|---|---|---|---|
| (Intercept) | 12.7459 | 0.0341 | 373.9321 | 0.0000 |
| age | -0.1132 | 0.0155 | -7.3000 | 0.0000 |
| model2bZ4X | -0.0295 | 0.0358 | -0.8256 | 0.4110 |
| age:model2bZ4X | 0.0192 | 0.0168 | 1.1456 | 0.2547 |
cols <- c(Solterra = "firebrick", "bZ4X" = "steelblue")
plot(sb$age, sb$kontantpris / 1000, pch = 16, cex = 0.9, col = cols[sb$model2],
xlab = "age (years)", ylab = "kontantpris (1000 kr)",
main = "Solterra vs bZ4X — price vs age")
for (mm in c("Solterra", "bZ4X")) {
s <- sb[sb$model2 == mm, ]; m <- lm(log(kontantpris) ~ age, data = s)
ag <- seq(min(s$age), max(s$age), length.out = 50)
lines(ag, exp(predict(m, data.frame(age = ag))) / 1000, col = cols[mm], lwd = 2)
}
legend("topright", legend = c("Solterra", "bZ4X"), col = cols,
pch = 16, lwd = 2, bty = "n")
Verdict. The two badges depreciate at statistically indistinguishable rates (~9–11%/yr, heavily overlapping CIs). In the pooled model neither the price-level term (model2bZ4X) nor the slope-difference term (age:model2bZ4X) comes close to significance — brand moves neither the price nor the depreciation rate once age is accounted for. For mechanically identical cars, that is the reassuring result.
Caveats specific to this comparison: small samples (Solterra ~25, bZ4X ~78) and very different age spreads — the bZ4X fleet skews almost new (many 2026 cars), the Solterra spans one-to-three years — so each single-rate slope leans on a different age window. As everywhere here these are asking prices, and an EV launched in 2022 has only a few years of resale history, so treat the levels as indicative rather than precise.
These diagnostics all refer to the primary age-only model lm(log(kontantpris) ~ age) reported in section (A) — one fit per brand. They quantify the uncertainty behind each headline rate and test the assumptions behind the log-linear form. (Base R only.)
diag <- do.call(rbind, lapply(names(models), function(b) {
m <- models[[b]]
s <- summary(m)
bcoef <- coef(m)[["age"]]
ci <- confint(m, "age") # OLS 95% CI for slope b
rci <- robust_ci(m, "age") # HC3-robust 95% CI for slope b
# depreciation rate = 1 - e^b is decreasing in b -> swap CI ends
dep <- 100 * (1 - exp(bcoef))
dep_lo <- 100 * (1 - exp(ci[2]))
dep_hi <- 100 * (1 - exp(ci[1]))
# functional form / curvature: significance of an added age^2 term
mf <- model.frame(m); y <- mf[[1]]; agev <- mf[["age"]]
p_curv <- tryCatch(
summary(lm(y ~ agev + I(agev^2)))$coefficients["I(agev^2)", "Pr(>|t|)"],
error = function(e) NA_real_)
# Breusch-Pagan heteroscedasticity (manual LM = n * R^2 ~ chisq_1)
bp <- summary(lm(I(resid(m)^2) ~ fitted(m)))
p_bp <- pchisq(length(resid(m)) * bp$r.squared, df = 1, lower.tail = FALSE)
# residual normality
p_norm <- tryCatch(shapiro.test(resid(m))$p.value, error = function(e) NA_real_)
data.frame(
brand = b,
n = nrow(mf),
dep_pct_yr = round(dep, 1),
ci95 = sprintf("[%.1f, %.1f]", dep_lo, dep_hi),
ci95_rob = sprintf("[%.1f, %.1f]", dep_ci(rci[1], rci[2])[1],
dep_ci(rci[1], rci[2])[2]),
adj_r2 = round(s$adj.r.squared, 2),
resid_se = round(s$sigma, 3),
p_curv = signif(p_curv, 2),
p_hetero = signif(p_bp, 2),
p_norm = signif(p_norm, 2),
stringsAsFactors = FALSE)
}))
diag <- diag[order(diag$dep_pct_yr), ]
knitr::kable(diag, row.names = FALSE,
caption = "Per-brand diagnostics for the model log(kontantpris) ~ age")
| brand | n | dep_pct_yr | ci95 | ci95_rob | adj_r2 | resid_se | p_curv | p_hetero | p_norm |
|---|---|---|---|---|---|---|---|---|---|
| Mercedes | 256 | 5.5 | [4.3, 6.7] | [3.5, 7.6] | 0.23 | 0.359 | 8.3e-02 | 3.2e-03 | 0.0e+00 |
| MINI | 298 | 7.3 | [6.4, 8.3] | [5.5, 9.1] | 0.42 | 0.311 | 2.9e-05 | 0.0e+00 | 0.0e+00 |
| Tesla | 739 | 7.9 | [7.2, 8.6] | [6.5, 9.3] | 0.36 | 0.232 | 0.0e+00 | 0.0e+00 | 0.0e+00 |
| Mazda | 283 | 8.3 | [7.4, 9.1] | [6.8, 9.7] | 0.56 | 0.480 | 0.0e+00 | 0.0e+00 | 3.1e-02 |
| BMW | 262 | 9.2 | [8.0, 10.4] | [7.1, 11.4] | 0.44 | 0.342 | 2.2e-02 | 1.2e-05 | 0.0e+00 |
| Subaru | 81 | 9.3 | [8.1, 10.4] | [7.5, 11.0] | 0.75 | 0.270 | 7.0e-07 | 7.6e-03 | 3.0e-02 |
| Volvo | 689 | 9.8 | [9.2, 10.4] | [9.0, 10.6] | 0.56 | 0.360 | 0.0e+00 | 1.0e-07 | 0.0e+00 |
| Fiat | 289 | 10.8 | [10.3, 11.3] | [10.1, 11.4] | 0.85 | 0.253 | 5.2e-04 | 3.5e-05 | 0.0e+00 |
| Audi | 289 | 11.0 | [10.1, 11.9] | [10.0, 12.0] | 0.66 | 0.301 | 1.5e-01 | 9.6e-04 | 0.0e+00 |
| Honda | 154 | 11.1 | [10.0, 12.2] | [9.8, 12.5] | 0.69 | 0.446 | 2.0e-01 | 1.7e-03 | 0.0e+00 |
| Dacia | 173 | 11.2 | [9.9, 12.4] | [9.9, 12.5] | 0.62 | 0.287 | 2.7e-03 | 1.2e-01 | 2.8e-01 |
| Peugeot | 284 | 11.2 | [10.4, 12.0] | [9.4, 13.0] | 0.70 | 0.383 | 0.0e+00 | 0.0e+00 | 0.0e+00 |
| Toyota | 701 | 11.4 | [10.8, 12.0] | [10.5, 12.3] | 0.65 | 0.417 | 0.0e+00 | 0.0e+00 | 0.0e+00 |
| Mitsubishi | 118 | 11.5 | [9.5, 13.4] | [9.2, 13.6] | 0.51 | 0.470 | 1.6e-02 | 2.9e-02 | 6.2e-03 |
| Nissan | 285 | 11.8 | [11.0, 12.7] | [10.2, 13.4] | 0.70 | 0.386 | 6.5e-03 | 0.0e+00 | 0.0e+00 |
| Opel | 276 | 11.9 | [11.2, 12.6] | [11.0, 12.8] | 0.80 | 0.298 | 1.6e-01 | 1.0e-06 | 1.6e-06 |
| Citroën | 286 | 12.1 | [11.5, 12.6] | [11.4, 12.7] | 0.83 | 0.269 | 1.6e-04 | 2.0e-07 | 1.9e-03 |
| Suzuki | 284 | 12.5 | [11.8, 13.2] | [11.7, 13.3] | 0.81 | 0.319 | 1.5e-01 | 2.7e-05 | 4.7e-06 |
| Renault | 285 | 12.9 | [12.1, 13.6] | [12.0, 13.7] | 0.78 | 0.272 | 4.8e-01 | 6.7e-05 | 1.8e-02 |
| VW | 738 | 12.9 | [12.4, 13.4] | [12.1, 13.7] | 0.77 | 0.249 | 5.3e-03 | 0.0e+00 | 0.0e+00 |
| Seat | 293 | 13.8 | [12.6, 14.9] | [12.3, 15.2] | 0.63 | 0.345 | 6.8e-05 | 1.6e-02 | 1.9e-01 |
| Ford | 263 | 14.0 | [13.2, 14.8] | [12.6, 15.4] | 0.78 | 0.353 | 1.2e-02 | 2.8e-05 | 0.0e+00 |
| Skoda | 714 | 14.2 | [13.8, 14.6] | [13.7, 14.7] | 0.85 | 0.233 | 5.8e-01 | 0.0e+00 | 2.5e-06 |
| Hyundai | 702 | 14.6 | [14.1, 15.1] | [14.0, 15.2] | 0.80 | 0.316 | 1.1e-03 | 1.6e-03 | 8.9e-05 |
| Kia | 290 | 15.2 | [14.5, 15.8] | [14.4, 16.0] | 0.86 | 0.271 | 4.6e-03 | 6.0e-04 | 1.7e-04 |
How to read the columns:
ci95 / ci95_rob — 95% confidence interval on the annual depreciation %, from OLS and from HC3 heteroscedasticity-robust SEs respectively. Where they diverge, trust ci95_rob (most brands fail the p_hetero test below). Note these intervals are mostly tight even when adj_r2 is low: with n≈290 the average slope is well-pinned; low r² just means age explains little of the within-brand price scatter (trim/spec/mileage do), not that the rate is fuzzy.adj_r2 / resid_se — fit quality. resid_se is in log units, i.e. the typical proportional price scatter around the line (≈0.30 means ±30%).p_curv — p-value of an added age² term. Small values reject the constant %/yr (straight log line) assumption: real depreciation bends — the first-year cliff then a flatter tail — so the single rate is a curvature-averaged summary.p_hetero — Breusch–Pagan. Small values mean residual variance still depends on the price level even in logs, so the OLS standard errors (and the CI) are approximate and would tighten/widen under heteroscedasticity-robust SEs.p_norm — Shapiro–Wilk on residuals. Small values mean non-normal residuals (usually heavy tails / a few underpriced project cars). Slopes stay unbiased; only the exact CI/p-values are affected.Look for structure: a curved red lowess line signals the functional-form issue flagged by p_curv; a fanning point cloud signals the heteroscedasticity flagged by p_hetero. A flat line through a constant-width band is what we want.
op <- par(mfrow = c(5, 5), mar = c(3, 3, 2, 0.6), mgp = c(1.8, 0.6, 0))
for (b in diag$brand) {
m <- models[[b]]
plot(fitted(m), resid(m), pch = 16, cex = 0.4, col = rgb(0, 0, 0, 0.3),
xlab = "fitted log(price)", ylab = "residual", main = b)
abline(h = 0, col = "grey60")
lines(lowess(fitted(m), resid(m)), col = "firebrick", lwd = 2)
}
par(op)
Points on the red line ⇒ normal residuals; S-shapes / departures at the ends ⇒ heavy tails (the p_norm flag).
op <- par(mfrow = c(5, 5), mar = c(3, 3, 2, 0.6), mgp = c(1.8, 0.6, 0))
for (b in diag$brand) {
r <- resid(models[[b]])
qqnorm(r, pch = 16, cex = 0.4, col = rgb(0, 0, 0, 0.3), main = b,
xlab = "theoretical", ylab = "residual")
qqline(r, col = "firebrick", lwd = 2)
}
par(op)