machine learning · benchmark

Price Engine
three claims, one stopwatch.

A 50,000-row house-price regression that measures what LightGBM is actually sold on — fast training, large tabular data, low-latency scoring — and publishes the numbers it got, not the numbers it wanted.

  • LightGBM
  • Gradient Boosting
  • Tabular
  • scikit-learn
  • NumPy
  • pytest
Claim 1 — trains fast

 

Claim 2 — handles the table

 

Claim 3 — scores fast

 

 

02

What it does

Price Engine builds a table of 50,000 houses from a price formula it knows — square footage, bedrooms, bathrooms, age, distance to the city, school rating, garage, lot size — then stirs in $25,000 of random market noise that no model can ever recover. It trains a LightGBM gradient-boosting model to predict the price, and because the true formula is known, you can check whether the model found the real drivers or just something that correlates.

The model is handed a budget of 2,000 decision trees and told to stop early. After every tree it checks its score on a held-back validation slice, and once 50 trees pass with no improvement it stops and throws the rest of the budget away — so the size of the final model is learned, not guessed.

Then it runs a race. The same data and the same tree budget are handed to scikit-learn's GradientBoostingRegressor, the classic implementation, and both are timed. Finally it prices 1,000 houses one at a time to measure what a single prediction actually costs in milliseconds — the number that matters if this model sits behind an API.

the honest part

Early stopping never sees the test set

Early stopping is a decision made from data, so whatever it watches, it has partly fitted to. It watches a validation slice carved out of the training data. The test set is opened exactly once, at final scoring, and never chooses anything. Two tests enforce this mechanically.

the honest part

The page shows what the run produced

Every figure on this page is rendered at load time from the run's JSON payload. Nothing is typed into the markup, the race verdict has a live branch for scikit-learn winning, and a test fails the build if the page and the results file disagree.

03

Architecture

Seven functions in one module, each taking its inputs and returning its measurements, plus a payload writer. That shape is what lets the test suite drive the whole pipeline at 5,000 rows without touching a single number in the full-size run.

generate
make_houses(n=50_000, seed=42)

Eight feature columns from default_rng, a documented linear price formula, then $25k of Gaussian market noise. Returns X, y.

split
make_splits(X, y)

80/20 train/test, then 15% of train becomes validation. The leakage rule lives in this docstring.

X_tr · 34,000

fits the trees

X_val · 6,000

watched by early stopping

X_test · 10,000

sealed until final scoring

train
train_lgbm(X_tr, y_tr, X_val, y_val)

2,000-tree budget, lr=0.05, num_leaves=31, early_stopping(50) on eval_set=[(X_val, y_val)]. Timed with perf_counter. Returns the model and its train seconds.

measure
score()

opens the test set once — MAE and R²

gain_importance()

booster split gain, normalised to 1

race_sklearn()

sklearn GBR capped at best_iteration_ trees, same lr, same rows

measure_latency()

1,000 single-row predicts, mean ms

publish
build_results(...) → results.json

One dict, JSON-safe (no NumPy scalars survive), holding every measurement plus 10 sample test rows. The single source of truth for this page.

tools/embed.py

copies the payloads and re-extracts the code excerpts below straight from the source files

site/app.js

reads the embedded payload at load and renders every number on this page

tests/test_price_engine.py

fails if this page and results.json disagree

Why a payload instead of a template. The site has no build step and no framework — Vercel serves three static files. The alternative to rendering from a JSON payload is writing numbers into HTML by hand, and hand-written numbers are exactly the failure this project is about. So the payload ships inside the page, the page renders itself from it, and a test asserts the two never drift.

04

Real output

Captured from an actual run on an Apple Silicon laptop CPU. Nothing below is illustrative: the transcript is the literal stdout of the command, and every chart is drawn from the JSON that same run wrote.

run date pending

Terminal — python price_engine.py --json

zsh ~/Projects/price-engine
loading transcript…

The race — same rows, same tree budget

Training time

seconds — lower is better

Test MAE

dollars of average error — lower is better

 

Feature importance — split gain, normalised

The generator leans hardest on square footage, then distance, schools and age. If the model had ranked has_garage at the top, the model would be wrong — this chart is a correctness check, not a decoration.

Ten houses it had never seen

sqftbedbath agemi to cityschool garagelot sqft true pricepredicted delta 

Delta is predicted − true: ▲ the model asked too much, ▼ it lowballed. The bar diverges from the centre line — right for over, left for under — so the sign reads without a second colour. Neither direction is the good direction; small is. Typical miss on the full test set is , and the $25,000 of market noise means no model drives that to zero.

Test suite — python -m pytest -v

pytest
loading test log…

05

Key decisions

Eight of the calls that shaped the result, with the reason each was made. The full log is docs/decisions.md in the repo.

01
Validation carved out of train, never out of test

Early stopping is a decision made from data — letting it watch the test set would tune the tree count to the scoring set and make the reported MAE optimistic.

02
Synthetic data with a known price formula

With real housing data, "square footage matters most" is a guess; with a generator you wrote, it is a claim a test can check.

03
$25,000 of deliberately unlearnable noise

It caps R² by construction, so a score near 1.00 would be evidence of leakage rather than of skill.

04
sklearn capped at exactly best_iteration_ trees

A speedup number only means something if both libraries do the same amount of work; the only variable left is how each searches for a split.

05
max_depth=5 for sklearn, not its default 3

~2^5 leaves is the closest match to LightGBM's num_leaves=31; the shallower default would have handed LightGBM smaller trees and inflated the speedup.

06
Raced against GradientBoostingRegressor, not HistGradientBoostingRegressor

The claim under test is about histogram-based split finding — racing sklearn's own histogram implementation would measure something else.

07
No test asserts a wall-clock threshold

A test that fails on a slower laptop is a broken test, not a broken model; the suite checks timings are positive and finite and leaves the numbers to the run.

08
A test greps this page for typed-in metrics

Every number here must arrive from the JSON at render time, so the suite fails if someone hard-codes one into the markup.

06

How to run it

Python 3.10 or newer, three dependencies, about half a minute of CPU. If your numbers differ from the ones above, yours are the real ones — this page reports one machine's run, not a law of nature.

  1. Create an isolated environment. A bare python3 can resolve to some other project's virtualenv, so the project keeps its own.

    $ python3 -m venv .venv && source .venv/bin/activate
  2. Install the three runtime dependencies plus pytest.

    $ pip install -r requirements.txt
  3. Run the benchmark. Prints the whole transcript shown above.

    $ python price_engine.py
  4. Run it again with --json to write results.json — the exact payload this page renders from.

    $ python price_engine.py --json
  5. Run the tests. of them, a few seconds — they drive the real pipeline at 5,000 rows.

    $ python -m pytest -v
  6. Refresh this site from your own run: copies the payloads in and re-extracts the code excerpts from the source files.

    $ python tools/embed.py
  7. Preview the docs site locally — any static server will do, there is nothing to build.

    $ python -m http.server -d site 8000

07

Code tour

Five files carry this project. Every excerpt below is extracted from the real file by tools/embed.py, so it cannot describe code that is not there.

price_engine.py the benchmark — data, splits, training, race, latency

The whole pipeline in one import-safe module. This function is the one that makes the rest of the numbers trustworthy: it is where the test set is put beyond reach of every decision the model makes.

def make_splits — the leakage rule

def make_splits(X, y, seed: int = SEED):
    """
    Three-way split, in this order, on purpose.

    LEAKAGE RULE: early stopping is a decision made from data. Whatever it
    watches, it has partly fit to. So it watches a VALIDATION slice that is
    carved OUT OF TRAIN — never the test set. If early stopping peeked at
    test, the tree count would be tuned to test, and the final MAE would be
    an optimistic number reported on data the model had already been shaped
    around. The test set is opened exactly once, at final scoring, and is
    never used to choose anything.
    """
    X_train_full, X_test, y_train_full, y_test = train_test_split(
        X, y, test_size=0.20, random_state=seed
    )
    # 15% of TRAIN (not of the whole dataset) becomes the validation slice.
    X_tr, X_val, y_tr, y_val = train_test_split(
        X_train_full, y_train_full, test_size=0.15, random_state=seed
    )
    return X_tr, y_tr, X_val, y_val, X_test, y_test
price_engine.py training with early stopping

2,000 trees offered, a validation set to watch, and a callback that stops the moment 50 rounds go by without improvement. The comment on eval_set is load-bearing — a test greps for it.

def train_lgbm — the model chooses its own size

def train_lgbm(X_tr, y_tr, X_val, y_val, n_estimators: int = N_ESTIMATORS, seed: int = SEED):
    """Fit LightGBM against a validation slice and let it choose its own size."""
    model = LGBMRegressor(
        n_estimators=n_estimators,
        learning_rate=LEARNING_RATE,
        num_leaves=NUM_LEAVES,
        random_state=seed,
        verbose=-1,
    )
    t0 = time.perf_counter()
    model.fit(
        X_tr,
        y_tr,
        eval_set=[(X_val, y_val)],          # validation only. never test.
        eval_metric="l2",
        callbacks=[lgb.early_stopping(EARLY_STOPPING_ROUNDS, verbose=False)],
    )
    return model, time.perf_counter() - t0
price_engine.py the fair fight

The speedup headline lives or dies on this configuration: same rows, same learning rate, and a tree budget copied from LightGBM's own stopping point.

def race_sklearn — matched work, different split search

def race_sklearn(X_tr, y_tr, X_test, y_test, n_trees: int, seed: int = SEED):
    """
    Fair fight: sklearn is capped at exactly the tree count LightGBM's own
    early stopping settled on, gets the same learning rate, and trains on the
    identical rows. Same work, same data — the only difference left is HOW
    each library searches for splits.
    """
    sk = GradientBoostingRegressor(
        n_estimators=n_trees,
        learning_rate=LEARNING_RATE,
        max_depth=5,          # ~2^5 leaves, the closest match to num_leaves=31
        random_state=seed,
    )
    t0 = time.perf_counter()
    sk.fit(X_tr, y_tr)
    sk_train_s = time.perf_counter() - t0
    return sk, float(mean_absolute_error(y_test, sk.predict(X_test))), sk_train_s
tests/test_price_engine.py tests — behaviour, honesty, and doc drift

The leakage rule is enforced by machine, not by comment: one test proves the test rows appear nowhere else, and another reads the source so that a future edit putting test data into eval_set fails the suite.

the leakage tests

def test_test_set_appears_in_neither_train_nor_validation(small_run):
    test_rows = _rows(small_run["X_test"])
    assert not (test_rows & _rows(small_run["X_tr"]))
    assert not (test_rows & _rows(small_run["X_val"])), (
        "validation must be carved out of TRAIN — if it overlaps test, early "
        "stopping is tuning the tree count on the scoring set"
    )

def test_fit_is_only_ever_given_the_validation_slice():
    """Guards against someone 'helpfully' adding test to eval_set later."""
    src = (ROOT / "price_engine.py").read_text()
    eval_sets = re.findall(r"eval_set=\[([^\]]*)\]", src)
    assert eval_sets, "no eval_set found — did the fit call move?"
    for es in eval_sets:
        assert "X_val" in es and "y_val" in es
        assert "test" not in es
site/app.js the renderer — 0 dependencies

Reads the embedded payload and writes the page. The race verdict has a live branch for scikit-learn winning; it is not decorative, and if a run comes back the other way this page will say so.

the verdict — both outcomes are printable

// Both outcomes are printable. If scikit-learn wins on some machine, the page
// says so in the same place, with the same emphasis — a benchmark that can
// only report one result is an advertisement.
const maeGap = R.sk.mae - R.lgbm.mae;
const accuracy = Math.abs(maeGap) < 0.01 * R.lgbm.mae
  ? `Accuracy is a tie — ${usd(Math.abs(maeGap))} apart on a typical error of ${usd(R.lgbm.mae)}, which is noise.`
  : (maeGap > 0
    ? `LightGBM was also ${usd(maeGap)} more accurate.`
    : `scikit-learn edged it on accuracy by ${usd(-maeGap)}.`);

set("verdict", faster
  ? `<b>${R.speedup.toFixed(1)}× faster</b> on identical data and an identical
     ${R.best_iteration}-tree budget. The gap is histogram-based split finding: LightGBM
     buckets each feature into ~255 bins once and scans histograms, while scikit-learn's
     exact greedy search sorts and sweeps every candidate threshold at every node.
     ${accuracy}`
  : `<b>scikit-learn won this run</b> — LightGBM took ${(1 / R.speedup).toFixed(1)}× longer.
     Histogram binning has fixed overhead, and on a small, narrow, all-numeric table it
     does not always pay for itself. Printed anyway: the benchmark reports what ran.
     ${accuracy}`);
docs/decisions.md the decision log

Every judgement call made during the build, one line each, written as it happened. Section 04 above is rendered from its most interesting entries.

extract — splits and honesty

## Splits and honesty

- **Validation carved out of TRAIN, never out of test** — early stopping is a decision made
  from data, so whatever it watches it has partly fitted to; letting it watch test would
  tune the tree count to the scoring set and make the reported MAE optimistic.
- **80/20 train/test, then 15% of train to validation** — standard proportions, and stating
  the order matters: the 15% is of train, not of the whole dataset.
- **The test set is opened exactly once, at final scoring** — no threshold, no tree count,
  no feature choice is made with it.
- **Two tests enforce the leakage rule mechanically, not by comment** — one asserts the test
  rows appear in neither train nor validation, one greps the source so a future edit that
  puts test into `eval_set` fails the suite.