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.
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.
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 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.
Eight feature columns from default_rng, a documented linear price formula, then $25k of Gaussian market noise. Returns X, y.
80/20 train/test, then 15% of train becomes validation. The leakage rule lives in this docstring.
fits the trees
watched by early stopping
sealed until final scoring
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.
opens the test set once — MAE and R²
booster split gain, normalised to 1
sklearn GBR capped at best_iteration_ trees, same lr, same rows
1,000 single-row predicts, mean ms
One dict, JSON-safe (no NumPy scalars survive), holding every measurement plus 10 sample test rows. The single source of truth for this page.
copies the payloads and re-extracts the code excerpts below straight from the source files
reads the embedded payload at load and renders every number on this page
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
loading transcript…
The race — same rows, same tree budget
Training time
seconds — lower is betterTest 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
| sqft | bed | bath | age | mi to city | school | garage | lot sqft | true price | predicted | 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
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.
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.
With real housing data, "square footage matters most" is a guess; with a generator you wrote, it is a claim a test can check.
It caps R² by construction, so a score near 1.00 would be evidence of leakage rather than of skill.
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.
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.
GradientBoostingRegressor, not HistGradientBoostingRegressor
The claim under test is about histogram-based split finding — racing sklearn's own histogram implementation would measure something else.
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.
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.
-
Create an isolated environment. A bare
python3can resolve to some other project's virtualenv, so the project keeps its own.$ python3 -m venv .venv && source .venv/bin/activate -
Install the three runtime dependencies plus pytest.
$ pip install -r requirements.txt -
Run the benchmark. Prints the whole transcript shown above.
$ python price_engine.py -
Run it again with
--jsonto writeresults.json— the exact payload this page renders from.$ python price_engine.py --json -
Run the tests. — of them, a few seconds — they drive the real pipeline at 5,000 rows.
$ python -m pytest -v -
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 -
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.
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
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
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
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
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}`);
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.