Used BMW & Audi prices, predicted within 6.5%.
A LightGBM model trained on 29,988 real Turkish listings, served behind a FastAPI backend on Railway. Every figure below is 5-fold out-of-fold — not a lucky holdout — and read from the same data the analysis reports.
The model cuts a dealer's "same model, same year" reflex by 42%. What it closes is everything beyond model and year: km, damage, engine.
How it's served
Two tracks. Offline, listings become a DuckDB file and a model bundle in object storage. Online, FastAPI loads that bundle into memory at boot — the raw 30K rows never leave the backend.
Bundle: {model, tfidf, cat_maps, feat_cols} — 6 categoricals + 8 numerics + free-text model & series → TF-IDF → TruncatedSVD (170 dims). Train and serve can’t drift apart: one pickle carries its own preprocessing.
Scores
Three variants share one leak-free 5-fold split; the median baseline runs on the same folds.
| Variant | MAPE | R² | MAE ₺ | MedAE ₺ | RMSE ₺ |
|---|---|---|---|---|---|
| LightGBM · TF-IDF+SVD ★ | 6.49% | 0.9747 | 109,874 | 74,776 | 175,352 |
| CatBoost · TF-IDF+SVD | 6.90% | 0.9717 | 118,208 | 81,203 | 185,487 |
| CatBoost · native | 7.07% | 0.9700 | 121,867 | 83,945 | 191,074 |
| Model+year median · fallback | 11.17% | 0.9248 | 190,552 | 130,000 | 302,511 |
Metrics are out-of-fold (leak-free); final models train on all data. Serving returns a point price plus a fixed ±6.6% band.
What the data actually says
Findings that change how you'd read a price, not just decorate the model.
Data & method
Real TR-registered used-car detail pages, four monthly snapshots, one listing per ad. A third of what the scraper returns is the same ad seen again.
The 15,171 rows between are the same ad re-scraped across four snapshots — scrape residue, not data. Dedup runs on ad_id before the CV split, so a repeat can't straddle folds and inflate the score.