Polish used-car prices · 2022 snapshot · served by LightGBM

A 13.9 MB model prices this market better than a 590 MB one

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

What the model refuses to price

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 on
mark=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, capitalised
mark=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 engine
fuel=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 fuel
fuel=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 province
province=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 way
province=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.

Which model, and by how much

Cross-validated error by model (PLN)LightGBM8 612 ± 72 · 13.9 MBRandomForest8 798 ± 81 · 590 MBRidge14 889 ± 51 · —
Pooled out-of-fold MAE over 5-fold cross-validation, in PLN, with the fold-to-fold spread as a whisker and the artifact size beside it. n = 111 018 adverts.

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.

What moves a valuation

What moves a valuation (mean |SHAP|, log-price)age0.592model0.205vol_engine0.161mark0.106mileage0.099fuel0.030province0.028
Mean |SHAP| per input column over 1 000 sampled adverts, aggregated back to the column the reader supplies — one-hot turns 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.

What a car loses with age

Median advert price by agePLN050 000100 000150 000166 295 PLN072 900 PLN534 900 PLN1016 900 PLN159 295 PLN208 900 PLN25years old
Median advert price per year of age across the cleaned data, up to 25 years; every point is a one-year bucket, and every fifth carries its exact median. Buckets under n = 100 are dropped rather than drawn, and the export refuses to publish if dropping one would leave the line bridging an age nobody measured. This is the data, not the model — it is the non-linearity a linear baseline cannot follow, which is why the linear model's error is roughly twice the tree ensembles'.

Method, and what it cannot tell you

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.

The rules, verbatim

Each decision below is quoted from the module it lives in, read at build time. A paraphrase can go stale; a quotation cannot.

data.drop_duplicate_adverts
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)
data.canonical_province
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))
data.has_plausible_displacement
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
features.build_preprocessor
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
model.load_model
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