Overview
sc.
The model, in one number

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.

6.49%
MAPE · OOF
mean abs. % error
0.9747
R² · OOF
variance explained
₺110K
MAE
₺75K median abs. error
29,988
listings
deduped · TR-plated
typical error vs. the dealer's reflex — MAE (₺)
Model+year median· baseline
₺191K
LightGBM · TF-IDF+SVD
₺110K

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.

[01]

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.

build · offline
Scraped listings
4 snapshots · Jan–Jun 2026
45,159
cars.duckdb
dedup · TR-plated · read-only
Railway Volume
LightGBM train
TF-IDF+SVD · log1p target
5-fold OOF
S3 bundle
serving/…svd.pkl
Railway S3
serve · online · request flow
Next.js client
sadikcoban.com
CORS
FastAPI v2.1.0
uvicorn · Railway
APIRouter
lgb_service
bundle in memory · thread-locked
boot-loaded
predict
expm1(clip) · ±6.6% band
≤15M cap
POST /api/predictGET /api/data-drift · KS + WassersteinGET /api/bi/meta · /aggGET /api/snapshotsGET /docs · OpenAPI

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.

[02]

Scores

Three variants share one leak-free 5-fold split; the median baseline runs on the same folds.

model comparison · 5-fold out-of-fold
VariantMAPEMAE ₺MedAE ₺RMSE ₺
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 · fallback11.17%0.9248190,552130,000302,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.

[03]

What the data actually says

Findings that change how you'd read a price, not just decorate the model.

01
Brand doesn't matter
Adding brand on top of series+model moves the error by ₺1. Brand is already inside model — a badge, not a price signal.
brand only7.16
series+model6.49
+ brand6.49
Theil's U = 1.00 · model already determines brand
02
km & age drive it — and diverge
Age −7.1%/yr, km −14.6%/100k km (correlated axes). A low-km old car paid the age penalty but not the km one → systematically underpriced.
km · per 100k−14.6%
age · per year−7.1%
hedonic R² 0.931 · every 95% CI excludes zero
03
The baseline collapses on rare cars
The model+year median prices 97.5% of cars — but a car with no comp falls back to model, then global, and blows up. The model stays flat everywhere.
model+year₺178K
model fb₺561K
global fb₺1,075K
97.48% comped · 2.52% fall back and blow up
04
Dirty data, handled before pricing
45,159 snapshots → 29,988 listings; 15 spec columns go missing together (catalog collapse); the raw "G" segment was a corrupt MPV mislabel, re-derived from series.
raw columns117
kept25
15 spec columns miss together · co-miss 1.00
05
The listing text holds what the columns never recorded
Free text describes damage the seller never entered on the form, engine swaps and conversions, fields that don’t line up with the form, and equipment that has no column at all. That’s the detail a price alone can’t carry — and why the text is analysed on its own terms.
disclosed in text only3,964
field clashes~1,500
conversions632
sunroof / panoramic roof appears in 35.9% of ads — and in zero columns
[04]

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.

scrape → dedup
45,159 raw snapshots
29,988 listings
latest per ad_id

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.

29,988
unique listings
45,159
raw snapshots
25
features used
₺1.54M
median price
17,896
BMW
12,092
Audi
target log1p(price)5-fold OOF · leak-freededup on ad_id before splitP10–P90 ₺0.84M – ₺3.42Mhedonic + LOFO + SHAPdrift KS · PSI · EMD
[05]

Stack & deep dives

FastAPIuvicornLightGBMscikit-learn · TF-IDF+SVDDuckDBboto3 · S3SciPypandas · NumPyRailwayNext.js