1.42× the noise floor
Before judging any model on the used-car data, I measured how much of the error is impossible to remove: 19.2% of listings are the same car at a different price.
I grouped 29,988 car listings on five columns — model, year, mileage, horsepower and body type — and kept every group with more than one row. That left 2,577 groups covering 5,767 listings, 19.2% of the data. Within each group, every listing describes the same car as far as my features are concerned. The median standard deviation of price inside those groups is ₺77,817, which works out to an MAE floor of ₺77,316.
That number changed how I read every result that came after it.
What the twins are and what they are not
They are not duplicates. Deduplication ran first, and it is where 45,159 scraped rows became 29,988. These are distinct listings, distinct sellers, distinct cars, that happen to be identical on everything I recorded.
What separates them is everything I did not record: the state of the interior, whether the tyres are new, how the photographs were taken, which city it is parked in, whether the seller needs the money this month. None of that is in the columns, and no amount of gradient boosting recovers it. Two identical rows, two different prices, and the model has to pick one.
So ₺77,316 is a floor. A model scoring below it on this data would be reading something that isn't there.
One caveat before I lean on it. The floor is measured on the 19.2% of rows that happen to have a twin, and I then apply it to all 29,988. Whether cars without a twin disagree more or less is something I did not test. I use it as a reference point, not as a proof.
The table, with a column I don't usually see
Every model below was evaluated on the same leak-free 5-fold split, out-of-fold, then refit on all the data. Three variants, one split, one comparison.
| MAE | MAPE | R² | × floor | |
|---|---|---|---|---|
| LightGBM · TF-IDF+SVD | ₺109,874 | 6.49% | 0.9747 | 1.42× |
| CatBoost · TF-IDF+SVD | ₺118,208 | 6.90% | 0.9717 | 1.53× |
| CatBoost · native text | ₺121,867 | 7.07% | 0.9700 | 1.58× |
| Model–year median | ₺190,552 | 11.17% | 0.9248 | 2.46× |
| Dealer baseline | ₺192,424 | 11.27% | 0.9220 | 2.49× |
| Naive baseline | ₺204,177 | 11.66% | 0.9196 | 2.64× |
The R² column separates the top three models by three thousandths — the kind of number that makes people write "essentially equivalent" and move on. The × floor column says something more useful: the winner sits 42% above the point where the data stops containing the answer, and the runner-up sits 53% above it. The same gap, read against a fixed reference instead of against each other.
It also flattens the bottom of the table. The baselines are not three different bad ideas; they land within 8% of each other at roughly 2.5× the floor, because they are all doing a version of the same thing.
The baseline I actually trust
Two of those baselines are placeholders — I keep them because they are conventional, not because they are informative. The third I built deliberately, and it is the one the report uses: model_yil_medyani, the model–year median. Same model, same year, take the median price. That is what a person in the market does. They look up comparables.
It has to be computed carefully. The medians are calculated inside each fold, on that fold's training rows only. Compute them once over the full dataset and every test row contributes to the median it is later scored against — a leak that never surfaces as an error, only as a baseline that looks better than it is.
Written honestly it needs a ladder, because sometimes there are no comparables:
(model, year)median(model)median across all years- global median
And the ladder is where it gets interesting:
| rung | rows | share | MAPE | MAE | R² |
|---|---|---|---|---|---|
| (model, year) | 29,232 | 97.48% | 10.68% | ₺178,273 | 0.945 |
| (model) | 603 | 2.01% | 26.48% | ₺561,301 | 0.634 |
| global | 153 | 0.51% | 46.16% | ₺1,075,349 | −0.25 |
The baseline does not degrade. It collapses. On the last rung its R² goes negative — you would have done better guessing the mean — and it collapses exactly where a dealer standing in a lot would also be guessing: the car with nothing to compare it to.
The conclusion is where I want to be careful. It is tempting to write that the model earns its keep on those 756 rows, and I have seen that sentence written. I did not measure it. The breakdown above is the baseline's error by rung; I never computed the model's out-of-fold error on the same three populations. What the table does license is narrower and still worth having: on 2.52% of cars, "42% better than baseline" is arithmetic against a method that has already given up, so on those rows the comparison stops carrying information. Splitting the model's residuals the same way is the obvious next run.
One lira
The other thing measurement changed my mind about was brand.
| features | MAPE | MAE |
|---|---|---|
| brand only | 7.16% | ₺124,737 |
| series + model | 6.49% | ₺110,154 |
| brand + series + model | 6.49% | ₺110,153 |
Adding brand on top of series and model improves MAE by one lira. A 320i is a BMW, and the model column has been saying so the whole time. On its own the column carries real signal — 7.16% MAPE is not nothing — which is exactly why it looks worth keeping. Conditional on what is already there, it carries none.
I left it in the feature set; it costs nothing and it makes the schema readable. I stopped listing it as a reason the model works.
The weakness I published
The served prediction ships a fixed ±6.6% band, targeting 90% coverage. By price quartile, the coverage it actually achieves is:
| Q1 | Q2 | Q3 | Q4 |
|---|---|---|---|
| 81.7% | 91.6% | 92.9% | 93.9% |
Averaged, that rounds to something respectable. Split, it says the band is too narrow on the cheapest quarter of the market and comfortably wide on the rest. The model is less certain about cheap cars, and a fixed band has no way to express that — which is what "fixed" means. The honest fix is a conditional interval, and until that ships the number stays on the report.
It is the same failure mode as the baseline paragraph above: an aggregate that looks fine because a non-flat distribution got averaged. "90% coverage" and "42% better than baseline" hide the same kind of thing.
What the remaining ₺32,558 might be
The model sits ₺32,558 above the floor. I can name three candidates for that gap; I cannot tell you how it divides between them, because that decomposition does not exist yet.
Condition. The largest missing variable. Damage enters as 13 body panels in four states each, derived from the listing form — but "no damage recorded" and "genuinely mint" are the same input. The hedonic model puts damage at −11.4% on price, so the sensitivity is real. The resolution is not.
Trim and options. Two cars can match on all five twin columns and differ by a package worth more than the error bar. What lives in the free text does not live in the columns.
Time. Prices moved +5.3% across the last three snapshots, and the KS test against the January reference turns significant by 27 June (KS 0.031, p = 0.000043). The model carries no time feature — deliberately, since it has to price tomorrow's car — so drift is monitored rather than modelled.
None of those reaches ₺77,316, and I do not think anything does. Closing even half the remaining gap would put the model at 1.21× the floor, on a dataset where the same car listed twice differs by more than that. Past some point the number stops being a modelling problem and becomes a fact about the market: what is left is how badly the seller wants out, and no listing field records it.
The system is live. There is a prediction endpoint you can type a car into, a full report with the figures behind everything above, and a market dashboard. Every number on this site is read from the analysis export at build time, including the ones in this post.