Analytics Reportsc.
00

What’s it worth?

[01]

What’s it worth?

A listing asks ₺1.54M. Too much? The model+year median — same model, same year, look at the median — misses by ₺191K. The model misses by ₺110K: 42% better.

₺110K6.49% MAPE· model+year median ₺191K42% better

Looking at the model+year median misses by ₺191K; the model misses by ₺110K. The remaining ₺81K is closed by what lies beyond model+year: km, damage, engine. The model isn’t a lookup table.

This baseline prices every car (falls back to model, then global, when no comp exists): 97.48% at model+year (₺178K), 2.01% model-fallback (₺561K), 0.51% global (₺1,075K). Without a comp the baseline collapses; the model stays at ₺110K everywhere. Full breakdown → 03.

One consequence: age and km are separate but correlated axes, and a low-km old car is where they diverge — it paid the age penalty but not the km one, so it stays systematically underpriced. Money on the table the model sees, beyond just pricing → 02 · how km and age bite.

A range, not a point

The reader cuts both ways. Over-estimating costs the buyer, under-estimating costs the seller; one model, two readers. So the output is a 90% conformal range, not one number. The range stops holding on cheap cars (Q1 under-covers the target); the rationale is in 03.

Where it breaks
  • Price rare and edge cars by hand; the model scatters there and the range stops holding.
  • Widen the range on cheap cars; Q1 under-covers the target, so a point estimate misleads.
  • Retrain monthly; the market moved +5.3% in 5 months and the model is time-blind.
What it doesn’t say
  • ·The sale price. The model predicts the asking price, not what the car sells for; the haggling margin sits inside the target.
  • ·Anything about brand. The BMW–Audi gap is too small to act on → 02.
  • ·Causation. These are controlled associations; it won’t say “repaint it and the price drops”.
  • ·The real value of a damaged car. “Damaged” spans a scratch to a rebuilt wreck, yet they all price into one low cluster the model can’t tell apart — so its single number for a specific one isn’t reliable → 03.
  • ·Trim and modifications. A loaded or modified car looks the same to the model as a base one of the same specs — it can’t see the extras; part of the spread among identical-spec cars → 03.
// This page uses the structural fields: age, km, engine, body. The listing text is a separate analysis → text analysis. Controlled associations; two premium brands · Turkey · 5 months.
01

Is this data any good?

[01]

Is this data any good?

Scraped marketplace data. Duplication is a leakage risk, missingness isn’t random; both settled before pricing.

On scraped marketplace data, duplication isn’t housekeeping, it’s a leakage risk: the precondition for the number in 00. What was broken on arrival, and what’s left:

Scale

45,159 snapshots → 29,988 listings. The 15,171 rows between are the same ad re-scraped: scrape residue, not data. Source: real TR-registered used-car detail pages (BMW + Audi), 4 snapshots (18 Jan – 27 Jun 2026), latest snapshot per ad_id. Of 117 raw columns, 15 are unusable → 05.

FieldValue
Unique listings29,988
Raw rows (snapshots)45,159
Snapshots4 · 18 Jan – 27 Jun
Features used25
BrandsBMW 17,896 · Audi 12,092
Targetprice · log1p

Median asking price ₺1.54M, ranging ₺0.84M–₺3.42M (P10–P90). Price is right-skewed; why the model trains on log-price → 05 · Target and preprocessing.

1 · Structural
age · km · engine power/size · body · fuel · transmission · drivetrain · segment
2 · Damage / inspection
13 panels × {changed, painted, local} + tramer fee + heavy damage
3 · Free text
seller description → separate analysis (claims · equipment · anomaly)

Leakage

Dedup runs on ad_id, before the CV split. Evaluation is 5-fold out-of-fold: every listing is predicted exactly once, by a model that never saw it.

The real risk is the one ad_id can’t see: the same car entering as two separate listings. That is a separate check — content-based duplication: 137 rows (0.46%) with every distinguishing field identical, 209 (0.70%) under the loosest definition → 05 · Content-based duplication. So the real repeats that could straddle folds sit under 1%.

Missingness isn’t random

15 columns drop together; co-missing correlation 1.00. This isn’t “missing data”, it’s listings where catalog matching collapsed: standard models match, niche variants don’t, and all their specs go blank at once. One block, one cause, one decision → 05 · What I dropped, and why.

Redundancy

Series is a coarsened view of model — a derived column, not independent information. Theil’s U (directional dependence): U(series | model) = 1.00 (model fully determines series), but U(model | series) = 0.39 (series leaves model ambiguous). The asymmetry gives direction. Cramér’s V is symmetric and couldn’t show this; the direction is the finding. The full 8×8 matrix → 05 · Leakage and redundancy checks.

Theil’s U — model ↔ series
modelseries
U=1.00 determines
seriesmodel
U=0.39 ambiguous

Model fully determines series; series leaves model ambiguous. Series is a coarsened view of model — it carries no separate information.

A separate issue — data corruption: gb_segment

The raw feed’s “G” segment isn’t real: nearly all its rows are MPV bodies (Active / Gran Tourer). This is neither a gap nor a redundancy — it’s a corrupt source. So raw gb_segment was dropped and segment re-derived from series; the MPV signal kept in body_type. Counts and heatmap → 05 · Leakage and redundancy checks.

What the rest does

What’s left is the structural fields: age, km, engine, body. The listing text isn’t here → text analysis. How well they price → 03 · How much to trust the number.

02

How this market builds a price

[01]

Three kinds of car in this market

The market’s shape — not a model input · KMeans k=3 · how they were found → 05

Unsupervised clustering splits the market into 3 groups; each name comes from the axis that actually separates it (age+km, then engine or damage) — no “premium/economy” value words. This is where you see which cars the model is pricing.

Older, high-km · bigger engine8,970
₺1.33M
14 yr · 252k km · 177 hp · dmg 4%
mileage fender_local engine_cc_val
Newer, low-km15,964
₺1.95M
9 yr · 128k km · 150 hp · dmg 2%
mileage vehicle_age engine_cc_val
Older, high-km · damaged5,054
₺1.06M
14 yr · 247k km · 150 hp · dmg 14%
door_painted fender_painted fender_changed
Median by body style
Median price by segment
// Body and segment complete the clusters: the cards carry the age/km/damage axis, these two carry the body/size axis. Coupe is priciest, hatchback cheapest; segment climbs monotonically from B to F/S. Segment is derived from the series, not the raw feed (why → 05).
// What the clusters do and don’t add: age and km are continuous, so k=3 is a discretisation of them — the market isn’t really made of 3 discrete “kinds”; k was chosen by silhouette (→ 05). Nor do they add a new price driver: the hedonic model already measures the very axes they split on (age, km, damage), with confidence intervals. Their value is this: the damage signal surfaces independently in the hedonic model, PCA and KMeans — three methods, same answer.
[02]

Which features carry the price

Hedonic OLS · confirmed by LOFO ΔRMSE

The hedonic regression gives each driver's *controlled* effect on price (all else equal) — R² 0.931, n 29,554. Coefficients carry bootstrap confidence intervals; every 95% CI EXCLUDES zero → each driver is reliably significant.

Bootstrap coefficients (point + 95% CI)
Term% effect95% CI (bootstrap)Signif.
age-7.1%[-0.076, -0.072]
age²+0.1%[0.001, 0.001]
km (100K)-14.6%[-0.168, -0.147]
km²+1.7%[0.014, 0.020]
age×km-0.7%[-0.008, -0.005]
heavy damage-11.4%[-0.129, -0.111]
painted-1.1%[-0.011, -0.010]
changed-3.1%[-0.034, -0.029]
+100 HP+21.3%[0.175, 0.211]
+1 litre+7.7%[0.050, 0.099]
+100 HP effect
+21.3%
+1 litre effect
+7.7%
cc–HP correlation · by fuel
FuelPearsonSpearmancc/HPn
Petrol0.8060.4079.814,693
Diesel0.8360.69411.112,783
LPG & petrol0.9000.80513.91,199
Hybrid0.4290.30810879
VIF · multicollinearity check
TermVIF
age2.57
km2.36
heavy damage1.05
painted1.22
changed1.11
+100 HP2.88
engine (L)3.19
// All VIF < 5 → low multicollinearity; the coefficients are separable (engine power ↔ size are related but the inflation is bounded).
Time effect (controlled by the hedonic model)
01-27
+1.5%
cumulative
03-21
+3.2%
cumulative
06-27
+5.3%
cumulative
// These are the hedonic model’s snapshot-dummy coefficients — they hold time inside the regression, while the LightGBM predictor is time-agnostic. Whether the market itself is drifting (shape vs level) is a separate question → 04 · How long you can trust it.
// Assumption tests: Breusch-Pagan (homoskedasticity p=<0.001) and Jarque-Bera (normality p=<0.001) are violated → inference uses HC3 robust SEs + 1,000× bootstrap, not the bare OLS p-value.
// Raw (no log) cc + HP: per-unit interpretation. 1000 bootstrap iterations, each with HC3 robust SEs. cc and HP are correlated but the ratio varies by fuel (diesel highest). All coefficients are solid.
LOFO — ΔRMSE when a feature is removed (a second method, same ranking)
// The hedonic coefficient and LOFO are two separate methods that say the same thing: km and age dominate. That the two produce the same ranking is the finding.
[03]

How km and age actually bite

Median + mean curves · the controlled effects are above

The hedonic model says km and age dominate; these two curves show the shape of that dominance. The drop is steep in the early years then flattens. Age costs 7.1% a year, km 14.6% per 100k km — two separate but correlated axes. A low-km old car is where the two diverge: it has paid the age penalty but not the km one → systematically under-priced, that’s where the arbitrage is.

Price by age (median + mean)
Price by mileage (median + mean)
// Median = the typical price; mean = outlier-influenced. The gap between the two curves is the price skew (mean sits above median). The %/year and %/100k quoted above are the hedonic controlled effects, not the raw drop drawn here.
[04]

Should brand affect price? No.

Brand carries no information; `model` does. Brand is already inside `model` — it adds nothing on top.

Brand says nothing separate because it already lives inside `model` (and `series`): both fully determine it. I measured this directly, not with a distributional test — adding brand on top of series+model doesn’t move the error.

Brand ablation — MAE (₺, lower better)
brand only
₺125K7.16%
↳ +series+model: −₺14,583 (real gain)
series + model
₺110K6.49%
↳ +brand: −₺1 (≈0, adds nothing)
brand + series + model
₺110K6.49%

Numerics (km · age · damage · hp) are held constant across all three; only the identity column changes — which is why series+model already reaches the full model’s ₺110K. The last two bars are identical: adding brand to series+model moves MAE by ₺1 and MAPE by 0.00 pts. The gain comes from series+model, not brand.

Theil’s U — what determines brand
modelbrand
U=1.00 determines
seriesbrand
U=1.00 determines

Both model and series fully determine brand (U=1.00) → brand sits inside both, no separate information. The ablation says the same.

Brand · distributional test + ablation table
Median price: BMW vs Audi
Cliff’s δ
0.066
effect size (negligible)
Mann–Whitney p
1.7e-22
significant yet immaterial
// The big-n trap: with enough data every gap turns “significant”; the effect size (δ) tells the truth — the gap is immaterial. The distributional test shows brand is immaterial; the ablation measures it directly.
VariantMAPEMAE
brand only7.16%124,7370.9687
series + model6.49%110,1540.9743
brand + series + model6.49%110,1530.9743
03

How much to trust the number

[01]

Is the model worth it?

3 variants + 1 median baseline (with fallback) · 5-fold OOF

3 model variants compared with 5-fold OOF (leak-free); final models trained on all data. Winner LightGBM · TF-IDF+SVD — MAPE 6.49%, 42% better than the model+year median (with fallback).

ModelMAPEMAEMedAERMSE
LightGBM · TF-IDF+SVD ★6.49%0.9747109,87474,776175,352
CatBoost · TF-IDF+SVD6.90%0.9717118,20881,203185,487
CatBoost · native7.07%0.9700121,86783,945191,074
Model+year median · with fallback11.17%0.9248190,552130,000302,511
// The 6.49% is 5-fold OOF cross-validation over 29,988 listings, not a single holdout. The median baseline runs on the same 5-fold, leak-free.
Model+year median — per-tier breakdown (fallback penalty)

Ladder: (model, year) median → (model) median across all years → global median. When a (model, year) cell is absent in train, the baseline drops a tier; each drop enlarges the error sharply — a car with no comp has a weak baseline to begin with.

Tiern%MAPEMAE
model+year29,23297.48%10.68%178,2730.9450
model6032.01%26.48%561,3010.6343
global1530.51%46.16%1,075,349-0.2500
// Fallback OOF median baseline — medians computed only on train in each fold (leak-free, same 5-fold as the model). If the (model, year) cell is absent in train, the ladder descends: (model) all years → global. base = OOF metrics over all rows; per-tier breakdown = [tier, n, %, MAPE, MAE, R²] — error rises sharply on the fallback tiers. Model metrics for comparison; same protocol → the gap is real gain.
How much of the error is even the model’s?

Cars with identical specs (same model · year · km · hp · body) still list ₺77K apart — 5,767 rows, 2,577 groups. That is a floor: sellers price the same car differently (options and mods, urgency, haggling) and no model can go below it. The model sits at ₺110K, 1.42× the floor, so the entire remaining headroom is ₺33K. A hyperparameter search typically claims ~₺5K of that, which is why none was run.

Sample predictions · 3 price bands
EconomyC · 13 yr · 215k km
A3 Sportback 1.6 TDI Ambition
Actual
₺1.16M
LightGBM ★
₺1.18M
dev +1.7%OOF resid 0%
CatBoost ₺1.13Mnative ₺1.11M
MidD · 10 yr · 144k km
320i ED M Plus
Actual
₺1.76M
LightGBM ★
₺1.77M
dev +0.4%OOF resid 0%
CatBoost ₺1.83Mnative ₺1.83M
PremiumC · 4 yr · 100k km
A3 Sportback 35 TFSI Advanced
Actual
₺2.05M
LightGBM ★
₺2.03M
dev +0.7%OOF resid 0%
CatBoost ₺2.10Mnative ₺2.13M

Target: log1p(price) · 25 features

// Metrics are 5-fold OOF (leak-free). Final models were trained on all data.
[02]

Prediction calibration & residuals

OOF (leak-free) predictions vs actual — R² 0.975. Residual% centers on zero (mean -0.45%, std 9.20%) → no systematic bias.

Predicted vs Actual
Residual% vs Predicted
// Red rings are extreme points — largest |error| + price extremes — shown in both modes. To keep the page light a representative sample of the 30K OOF predictions is drawn (price-stratified, outliers guaranteed, deterministic): “Density” a 6,004-pt smoothed heatmap, “Points” a 1,198-pt body + all extreme points.
[03]

Where the model is weak

Quartile MAPE · sample-size reliability

The model’s weakness points to the same place from three angles: error by price quartile (harder on cheap cars), per-model sample size vs error (rare models scatter) and the best/worst predictions (edge cases). All of it: rare · edge · cheap.

Error by quartile (MAPE %)
Per-model sample size vs median error (log axis)
Best predictions
ModelAgekmActualPredErr
A3 Sportback 1.6 TDI Ambition13215,000₺1.16M₺1.16M0.0%
A3 Sportback 35 TFSI Advanced4100,000₺2.05M₺2.05M0.0%
520i 50th Year M Edition398,000₺3.60M₺3.60M0.0%
320i ED M Plus10144,000₺1.76M₺1.76M0.0%
1.6 TDI Attraction13153,000₺1.08M₺1.08M0.0%
Biggest misses
ModelAgekmActualPredErr
A4 Sedan 2.0 TDI20355,000₺0.64M₺1.52M135.4%
750i Long19271,000₺1.19M₺2.65M122.8%
M210153,000₺1.65M₺3.13M89.9%
S3 2.0 TFSI Quattro18190,000₺1.02M₺1.87M82.0%
535d xDrive Premium13325,000₺1.35M₺2.44M80.9%
520i Luxury Line8206,944₺1.39M₺2.47M77.5%
[04]

How wide a range should you quote?

Conformal · 90% target

The weakness above is why you quote a range at all. Actual coverage of the conformal intervals by price quartile; target 90%. The upper quartiles hit it; Q1 (cheap cars) falls below — which is where 00’s “widen the range on cheap cars” comes from.

Coverage % (target 90%)
// Q1 (cheap) under-coverage: the model is less certain on cheap cars.
04

How long you can trust it

[01]

When does this model go stale?

PSI, KS, forward test

Two lines of evidence give the same call. Distribution drift: the period curves nearly overlap, PSI stays <0.05 (shape stable), KS grows slightly (statistical — the big-n trap). Temporal backtest: train on an earlier period and test only on the next period’s NEW listings (leak-free); the cumulative strategy is more stable. Verdict: the market LEVEL shifted +5.3% but the SHAPE held → monthly retraining suffices.

Angular · no-KDE · frequency (committed style)
Smooth · KDE
PairKSpPSIn
01-18→01-270.00550.9960.000410,109
01-18→03-210.01730.0700.001520,560
01-18→06-270.03090.0000.004948,059
01-27→03-210.01610.1040.001115,717
01-27→06-270.03010.0000.003839,115
03-21→06-270.01570.1180.001728,620
// Drift table: all 6 snapshot pairs. KS = max distribution gap · PSI < 0.10 safe / > 0.25 retrain · p = KS-test significance.
OOF MAPE · per-snapshot vs cumulative
Train → TestSingle MAPECumul. MAPEn
01-18 → 01-276.58%6.58%2,960
01-18 → 03-216.80%6.80%8,182
01-18 → 06-277.55%7.55%10,529
01-27 → 03-216.62%6.55%7,413
01-27 → 06-277.31%7.35%10,313
03-21 → 06-277.06%6.96%9,099
// Backtest — single: train on one snapshot, predict forward. cumulative: train up to t. Only NEW ad_ids (leak-free).
05

How it was built

The decisions I took to work with dirty scraped data.

What I dropped, and why — 15 columns, all missing for the same reason

Feature selection

From 117 raw columns down to 25. Nothing dropped arbitrarily: constants, redundant kb/gb twins, leakage/identity, block-missing, collinearity, granular damage (aggregated) and an empirical audit.

modelseriesbrandbody_typedrivetrainsegmenttransmissionfuelroof_statehood_statetrunk_statevehicle_agemileagepower_hp_valengine_cc_valdoor_changeddoor_painteddoor_localfender_changedfender_paintedfender_localbumper_changedbumper_paintedbumper_localis_heavy_damaged
GroupReason~cols
AConstant variance~2
BRedundant kb/gb twins~12
B*Coverage difference1
CIdentity / leakage~8
DBlock-missing >40%~15
ESpec-missing ~26%~10
// Hierarchical imputation: missing values filled by series > segment > brand median (most-specific group first). torque_nm was dropped (27.6% missing); the rest are handled natively by the tree model.

Why 15 columns were dropped

Missingness isn’t random — it’s block-shaped: the high-missing columns below (spec/catalog + insurance, ~28–84%) sit empty together in the same listings. Because “being missing” is systematic, reliable imputation is impossible and leakage is a risk → these columns were dropped. (The 16 kept features are <2% missing — not shown here.)

Missing rate (%) · same colour = same rate (co-missing block)
Spec / catalog block
cols
15
avg miss
27.6%
co-miss
98.8%
weight_kgkb_fuel_tankgb_segmenttorque_nm
Spec / catalog block
cols
2
avg miss
31.4%
co-miss
100.0%
city_fuel_conshighway_fuel_cons
Spec / catalog block
cols
2
avg miss
29.9%
co-miss
100.0%
production_year_startproduction_year_end
Spec / catalog block
cols
2
avg miss
29.3%
co-miss
100.0%
rpm_maxrpm_min
gb_ / kb_ dual source — the populated side (kb) is used
FieldOverview (gb) empty %
Drivetrain74.0%
Avg. Traffic Insurance54.3%
Avg. Casco Insurance51.1%
Annual Vehicle Tax40.6%
// The same fields are largely populated in the QuickInfo (kb) tab → the populated side (kb) is used. E.g. drivetrain: gb 74% empty, kb nearly complete.
Co-missing correlation (spec block)1.000

This is MISSING correlation (presence/absence co-moves) — NOT value correlation (~0.59). Spec columns come from catalog matching: standard models match, special variants don’t → all specs go blank together. Each spec carries different information, but their presence/absence is tied to one source.

// Cause: spec data comes from model–catalog matching — standard models (“320i”) match, niche variants (“320i 50th Year M Edition”) don’t, so all their specs go blank at once. Excluded from the model: they’d always be missing on niche variants, and the model name (via TF-IDF) already captures that information.

Content-based duplication

A check beyond ad_id-dedup: listings whose ad_id differs but whose every distinguishing feature (price, mileage, age, model, damage, engine) is identical. The rate is low — confirming clean collection; some are genuine re-posts, some coincidental overlaps among common models.

Strict-def dupes
137
0.46%
Loose-def dupes
209
0.70%
Duplicate groups
127
Def. columns
12
pricemileageyearbrandseriesmodelfuelis_heavy_damagedcount_paintedcount_changedpower_hp_upengine_cc_up
ModelRepeatsPriceYear
320i Sport Line4₺4.90M2025
318i Standart3₺0.72M2005
A3 Sportback 1.6 Ambition3₺0.89M2010
116d Joy Plus3₺1.04M2015
320i First Edition Sport Line3₺2.53M2019
// A content-based duplicate check beyond ad_id-dedup: ad_id differs but every distinguishing feature (price, km, age, model, damage, engine) matches. A low rate confirms clean collection.
Leakage and redundancy checks — U(segment | series) = 1.00; segment is derived

Categorical dependence (Cramér’s V + Theil’s U)

Cramér’s V gives association strength (symmetric); Theil’s U its direction (asymmetric) — both are for CATEGORICAL features: `model` (text), brand, series, segment, body, drivetrain, transmission, fuel. `model` almost fully determines the rest (U≈1) but not vice-versa → `model` is excluded from the hedonic OLS (too high-cardinality; it’s used in the LightGBM via TF-IDF text instead). Note: damage counters and engine specs (hp/cc) are NUMERIC, so they’re not here.

Theil’s U (directional · main)
Cramér’s V (symmetric · secondary)
Every series lands in exactly one segment — median price (series × segment)
// In the heatmap each row (series) is filled in exactly one column (segment). This is what U(segment | series) = 1.00 looks like: segment is a column derived from series, not independent information. The reverse doesn’t hold — a segment holds several series (U(series | segment) < 1).
G ≡ MPV — the derivation, concretely

The raw feed had a “G” segment, but it isn’t real: nearly all G rows are MPV bodies (Active/Gran Tourer). Segment is derived from the series, the MPV signal kept in body_type — the asymmetry above, made visible.

// Segment is derived deterministically from the series; the raw gb_segment was too dirty to use.

Numeric correlation (Pearson + Spearman)

Correlation among numeric features — the numeric counterpart to the categorical dependence above. Green = positive, red = negative. Pearson measures linear, Spearman monotonic association. |r|>0.5 pairs are flagged for collinearity (also checked via VIF, all <3).

Pearson
Spearman
High-correlation pairr
age · km0.737
HP · cc0.730
Door Painted · Fender Painted0.670
// Numeric-feature correlation (Cramér/Theil handle the categoricals). Pearson = linear, Spearman = monotonic. |r|>0.5 pairs stand out; collinearity was also checked via VIF (all <3).
How the segments were found — k=3, and elbow and silhouette agree

KMeans + PCA — method

k=3 was chosen by silhouette and corroborated with the PCA scatter: 3 groups separate along age × mileage × power, the damaged cluster on PC3. What the clusters are (name, median, damage) → 02. The damage signal appearing independently across the hedonic model, PCA and KMeans is a robustness check.

PCA — PC1 19.7% × PC2 12.4% (power)
PCA — PC1 19.7% × PC3 11.0% (damage)
k selection — Elbow + Silhouette
// k choice: silhouette points to k=3; 3 clusters are both optimal and interpretable. Chosen transparently, not blindly.
PC119.7%
mileage+0.46
vehicle_age+0.45
fender_painted+0.41
door_painted+0.41
PC212.4%
power_hp_val+0.65
engine_cc_val+0.61
fender_painted-0.24
door_painted-0.24
PC311.0%
fender_local+0.58
door_local+0.57
power_hp_val-0.28
bumper_local+0.22
Target and preprocessing — why it trains on log price

Why log price

Raw price is right-skewed (skew 1.62); a log transform pulls it toward symmetry (0.28). I trained on log1p(price): under squared loss the extremes were swallowing the whole error budget. A modelling decision, not a market finding — which is why it lives here.

Skew (raw)
1.62
Skew (log)
0.28
Median price
₺1.54M
Price histogram — all data (₺ · dashed line = median)
// Drift across periods is a separate question, and 04 owns it. The histogram here is all deduped data, not a time split.