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 with scrape_bilbasen.py first.

What this does

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:

  • (A) Statistical (primary). Per brand we fit a log-price model 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.
  • (B) Direct 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}}\).

Load & clean

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

(A) Statistical annual depreciation

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)")
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)

(B) Direct nypris cross-check

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")
}
Direct nypris-based depreciation (cross-check)
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

(C) First-year cliff vs. ongoing annual rate

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)")
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:

  • First-year > ongoing for most brands is the cliff the diagnostics predicted.
  • 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.
  • Important scope caveat: even an “age-0” listing is already a used car, so this first-year drop is measured within the used market. The genuine off-the-lot cliff (nypris → first resale) happens before age 0 and is not captured here — treat first_yr_pct as “first observed year on the used market,” not the full new-car hit.

Price-vs-age curves

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)

(D) Case study: the same car, two badges — Solterra vs bZ4X

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:

  • The bZ4X Touring — the larger European wagon/SUV variant introduced in 2026 — is a distinct model on bilbasen (its own slug 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.
  • “Touring” does appear in the Solterra rows — but there it is a Subaru trim name (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)")
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)")
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.

Caveats

  • Cross-sectional, not longitudinal: different cars of different ages at one point in time, not the same car aging. Brand model-mix shifts (e.g. more EVs recently) partly confound the age signal.
  • “modelår” here = first-registration date (month/year from bilbasen), used as a continuous age. Registration day is unknown, so we assume mid-month.
  • Asking prices, not transaction prices; dealer stock skews newer.
  • Low-price / very old outliers are filtered above to stabilise the log fit.
  • Two scrape snapshots merged. Listings come from a June and a September 2026 scrape; cars present in both are de-duplicated to their most recent listing, but asking prices still span ~3 months of vintage. Negligible for the slopes, which depend on registration date rather than scrape date.

Appendix: regression diagnostics

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.)

Diagnostics table

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")
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.

Residuals vs fitted (one panel per brand)

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)

Normal Q-Q (one panel per brand)

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)