Polish used-car prices · 2022 snapshot · served by LightGBM
LightGBM reaches 8 612 PLN mean absolute error against RandomForest's 8 798, in an artifact 43× smaller — so the size↔quality trade-off this project was built around turned out not to exist.
Value a car — the model runs in your browser Source on GitHub The decisions, with their measurements Why this page quotes the code How the model got into the browser Why a price is never shown alone
A price model is easy to make look good and hard to make honest. Every valuation service
faces inputs it was never trained on — a make outside the data, a province spelled another
way, a diesel with no engine — and the default behaviour of the tools underneath is to
answer anyway: TargetEncoder substitutes the global target mean, a one-hot
encoder emits an all-zero row. Both come back as a confident number with a
200.
So every input domain here is closed, stamped into the artifact at
training time, served at GET /vocabulary, and enforced at the API boundary.
The table is measured, not illustrative: the right-hand column is what this pipeline
answers today when the rule is taken out of the way.
| Input | With the closed domain | Without it |
|---|---|---|
A make the model was never trained onmark=ferrari, model=f40 |
refused — not in the artifact's vocabulary | 34 093 PLN — and zzzz/qqqq returns 34 093 PLN Both encode to the global target mean, so every unknown car is the same car. |
A known make, capitalisedmark=Opel (the dataset spells it opel) |
normalised to 'opel' and priced: 33 576 PLN | 38 399 PLN (+14% against the same car) A spelling variant is normalised; a different word is refused. Not the same thing. |
A diesel with no enginefuel=Diesel, vol_engine=0 |
refused — zero displacement is only meaningful for an EV | 37 248 PLN (+11% against the same car with an engine) In this dataset zero displacement means 'missing', except on an EV where it is a fact. |
An unknown fuelfuel=Petrol (a different word for a known fuel) |
refused — not one of the 6 declared values | the encoder raises ValueError Under handle_unknown='ignore' this would have been an all-zero block and a confident price. |
An unknown provinceprovince=Berlin (an advert from outside Poland) |
refused — not one of the 16 declared values | the encoder raises ValueError Under handle_unknown='ignore' this would have been an all-zero block and a confident price. |
A province spelled the common wayprovince=Kujawsko-Pomorskie |
normalised to 'Kujawsko-pomorskie' and priced | an all-zero location block, priced as if the car had no province This shipped: the web form sent this spelling, and ~7 % of the market was valued with no location at all. |
Control car for every row: a 2015 Opel Combo,
1248 cm³ diesel, 139 568 km,
Mazowieckie — a real advert listed at 35 900 PLN. The API answers each
refusal with a 422 and names the field; tests/test_api.py holds
that to it.
The 186 PLN lead over RandomForest clears the folds' combined spread (108 PLN), so the ranking is not fold noise. It is still the best of 16 configurations scored on the same cross-validation, which makes the winner's own score a little optimistic — so the decision to serve it rests on the size difference, which selection noise cannot touch.
province into 16 slots, and charting those beside a
single age bar would say location matters least when it has merely been
divided 16 ways.
The unit is a log-price contribution, not złoty. The explainer runs on the
regressor inside the TransformedTargetRegressor, which is fit on
log1p(price): these values sum to log1p(prediction) and become
multiplicative under expm1. A waterfall reading "+8 400 PLN for age" would be
the exact error this project exists to avoid, on its most-viewed page — so there is no
waterfall.
Target and features. Every model trains on log1p(price) and
inverts with expm1 before any metric, so all numbers here are PLN.
age is derived from a fixed anchor rather than the raw model year, and
mark/model use out-of-fold target encoding — fitting that
encoder on the full dataset is the leakage this design exists to prevent.
Measurement. 5-fold cross-validation with pooled
out-of-fold predictions, reported with the fold-to-fold spread. A gap smaller than the
spread is not a better model. The served model is whichever one won that comparison;
train.py reads the winner from the measurement instead of hardcoding a name.
The artifact carries its own contract. Feature list, age anchor and every vocabulary are stamped into the saved bundle, and loading refuses a bundle that disagrees with the code. A stale artifact fails at load rather than at the first valuation.
Limits. The data is a single January 2022 snapshot, so every figure here is a 2022 price and is labelled as one rather than rescaled to today — Polish used-car prices fell roughly 10–24 % after it while general inflation ran +41 %, so "adjusting for inflation" would move the numbers the wrong way. The dataset carries no power, gearbox or condition, which is a ceiling on accuracy no model choice moves. And 9.8 % of the raw rows sit in exact-duplicate groups, and de-duplication removes 6 470 of them: with shuffled folds the same advert was landing in train and test, so metrics published before it were optimistic.
Each decision below is quoted from the module it lives in, read at build time. A paraphrase can go stale; a quotation cannot.
def drop_duplicate_adverts(df: pd.DataFrame) -> pd.DataFrame:
"""Drop repeated adverts, keeping one row per distinct advert.
9.8 % of the raw rows sit in exact-duplicate groups — 31 groups hold 22 identical
copies — consistent with a concatenation of per-model scrapes where some models were
ingested twice. With shuffled k-fold, identical adverts land in both the training and
the test fold, so every metric is optimistic. The inflation is not uniform across
models either: measured on this dataset, duplicates flatter RandomForest by 256 PLN of
MAE (``min_samples_leaf=3`` can form a pure leaf on three copies of one advert) against
LightGBM's 19 PLN, which is enough to distort a comparison between them.
Comparison is on the advert's own attributes; ``age`` is derived, so it is excluded.
The key is raw-row equality rather than equality over the modelled features: two adverts
differing only in `city` or `generation_name` are treated as two adverts, which leaves
~0.9 % of rows still identical in feature space. That residual is deliberate — collapsing
it would discard genuine repeated listings of similar cars, which is signal about how
common such a car is.
"""
subset = [c for c in df.columns if c != "age"]
return df.drop_duplicates(subset=subset).reset_index(drop=True)
def canonical_province(value: object) -> str | None:
"""Return the canonical spelling of a province, or ``None`` if it is not a Polish one.
``None`` covers everything outside the domain: foreign regions present in the raw data,
missing values, and non-string input.
"""
if not isinstance(value, str):
return None
return _PROVINCE_BY_KEY.get(_fold(value))
def has_plausible_displacement(fuel: object, vol_engine: float) -> bool:
"""Zero displacement is a fact for an EV and a missing value for anything that burns fuel.
The single statement of this rule. Training drops the rows that fail it and the API
refuses the requests that fail it — stated once because the two enforcing it live in
different modules, and a rule applied on only one side is worse than no rule: the model
then has no combustion-with-zero-displacement example to reason from, yet still answers.
"""
return vol_engine > 0 or fuel == config.ELECTRIC_FUEL
def build_preprocessor(random_state: int = config.RANDOM_STATE) -> ColumnTransformer:
"""Column transformer: OOF target encoding + one-hot + passthrough numerics.
``TargetEncoder`` performs internal cross-fitting; pandas output preserves feature
names downstream (for SHAP and to satisfy LightGBM).
The cross-fitting splitter is passed explicitly **with a seed**. Handing ``cv`` a
plain integer lets the encoder shuffle the folds from an unseeded RNG, so two fits on
identical data return different encodings — and every metric downstream inherits that
wobble even when the estimator itself is fully seeded. Measured on the full dataset,
the spread was tens of złoty of MAE: small, but the same order as a real model
improvement, which is exactly the size that misleads a comparison.
"""
preprocessor = ColumnTransformer(
transformers=[
(
"target_enc",
TargetEncoder(
target_type="continuous",
cv=KFold(n_splits=config.CV_FOLDS, shuffle=True, random_state=random_state),
),
list(config.HIGH_CARD_CATEGORICAL),
),
(
"onehot",
# The category domains are declared, not learned. Learned domains depend on
# what a given training sample happened to contain, so a fold missing a rare
# fuel would produce a different feature space — and the serving pipeline
# would inherit whichever one the final fit saw. Declaring them keeps the
# columns identical across folds, runs and the API.
#
# "error", not "ignore": an out-of-domain category under "ignore" becomes an
# all-zero block — a combination that occurs in no training row, so the trees
# extrapolate off-manifold and return a confident number with no signal that
# anything was dropped. That is the exact failure this vocabulary work exists
# to close, and it must not stay open for the next dataset that spells a fuel
# differently. Callers reach the model through `data.clean` (which closes the
# province domain) or the API (which validates both), so a raised error here
# means genuinely unseen input, not routine traffic.
OneHotEncoder(
categories=[
_ONEHOT_CATEGORIES[name] for name in config.LOW_CARD_CATEGORICAL
],
handle_unknown="error",
sparse_output=False,
),
list(config.LOW_CARD_CATEGORICAL),
),
("num", "passthrough", list(config.NUMERIC_FEATURES)),
]
)
preprocessor.set_output(transform="pandas")
return preprocessor
def load_model(models_dir: Path = config.MODELS_DIR) -> dict:
"""Load the persisted model bundle, refusing one that was fit on a different feature set.
Uses joblib (pickle) — only load model files you produced/trust; the served artifact
ships inside the Docker image, so its provenance is controlled.
The feature check exists because the failure it prevents is silent: the API builds its
input row from a hardcoded column list, so an artifact trained on a different set would
keep returning confident numbers computed from the wrong columns. A stale artifact must
fail at load, not at the first valuation.
"""
path = models_dir / MODEL_FILENAME
if not path.exists():
raise FileNotFoundError(f"No saved model at {path} — train first")
bundle = joblib.load(path)
saved = bundle.get("metadata", {}).get("features")
expected = list(features.FEATURE_COLUMNS)
if saved is None:
raise ValueError(
f"{path} carries no feature spec — it predates the check and cannot be "
f"verified against the current one ({expected}). Retrain."
)
if list(saved) != expected:
raise ValueError(
f"{path} was fit on {list(saved)}, but the code expects {expected}. Retrain."
)
# The anchor belongs to the inference contract as much as the column list does: the API
# derives `age = REFERENCE_YEAR - year`, so serving an artifact trained against a
# different anchor shifts every age by the difference — quietly, and in a direction the
# metrics never see.
saved_year = bundle.get("metadata", {}).get("reference_year")
if saved_year is None:
raise ValueError(
f"{path} carries no age anchor, so it cannot be checked against "
f"REFERENCE_YEAR={config.REFERENCE_YEAR}. Retrain."
)
if saved_year != config.REFERENCE_YEAR:
raise ValueError(
f"{path} was fit with REFERENCE_YEAR={saved_year}, but the code uses "
f"{config.REFERENCE_YEAR} — every derived age would be off by "
f"{abs(saved_year - config.REFERENCE_YEAR)} years. Retrain."
)
# The declared one-hot domains are part of the artifact too. Widening `config.PROVINCES`
# without retraining would leave the API validator accepting a province the pickled
# encoder then refuses — a 500 at valuation time instead of a clear message at load.
saved_vocabulary = bundle.get("metadata", {}).get("vocabulary", {})
declared_domains = {"fuel": list(config.KNOWN_FUELS), "province": list(config.PROVINCES)}
for column in (*config.HIGH_CARD_CATEGORICAL, *config.LOW_CARD_CATEGORICAL):
stamped = saved_vocabulary.get(column)
# Mandatory, like the two checks above. The serving path refuses an unknown make by
# consulting this list, so an artifact without it would let that guard pass silently
# — the failure mode being guarded against, reintroduced by an optional check.
if not stamped:
raise ValueError(f"{path} carries no {column} vocabulary. Retrain.")
declared = declared_domains.get(column)
if declared is not None and sorted(stamped) != sorted(declared):
raise ValueError(
f"{path} was fit with {column} domain {sorted(stamped)}, but the code "
f"declares {sorted(declared)}. Retrain."
)
return bundle