Real NBA data · 10,624 games · 9 seasons

Predicting NBA games, and measuring what the features earn.

A gradient-boosted classifier trained on eight seasons and evaluated on the entire 2024-25 season, held out and never touched during development. The headline is not the accuracy — it is that the accuracy is reported against a baseline, and that every layer of the 71-feature set was ablated to see what it actually contributes.

Temporal holdout Isotonic calibration Seeded & reproducible nba_api · SQLite
Held-out season — 2024-25
Accuracy
66.4%
n=1,230 games
Always-home baseline
54.4%
the dumb comparator
ROC-AUC
0.727
train 0.727 — not overfit
Brier score
0.211
calibrated probabilities

12.0 percentage points of accuracy over always picking the home team. An accuracy figure without its baseline is uninterpretable, so the baseline ships beside it.

What each layer of the feature set earns

Holdout ROC-AUC by feature set

Same split, same tuning budget, progressively larger feature sets. Always-home is a constant predictor, so it sits exactly on chance — it cannot rank at all. The axis starts near chance because the whole question is how far above 0.50 each set gets.

One feature — the Elo differential — reaches AUC 0.7098. All 71 reach 0.7250. The other 70 features are worth +0.0152 AUC, which is smaller than the ±0.021 spread between my own cross-validation folds.

Read that as a reason to prefer the simpler model, not as proof the features are useless: the Brier score does improve with rolling form (0.2166 → 0.2112), so the extra features buy calibration rather than ranking. Ablation variants are tuned at 30 Optuna trials each for comparability; the headline model above uses 60, which is why its AUC differs slightly from the "all 71" row here.

Why 2020 games are down-weighted

Home win rate by season

Home-court advantage measurably collapsed when games were played without crowds. Highlighted: 2019-20 and 2020-21.

Bubble-era games carry a 0.6 sample weight in training — a domain judgement, made because the target's base rate genuinely shifted.

Permutation importance

Top 10 of 71, measured on the test set over 10 repeats — not gain-based, which is biased toward high-cardinality splits.

Permutation importance under-credits correlated features, and this set is collinear by construction (home / away / diff for ~12 stats). That is exactly why the ablation above measures the layers directly instead of trusting this chart alone.

Can the probabilities be trusted?

Calibration

Predicted win probability against the rate that actually happened, by decile of prediction. Equal-count bins, so no bin is decided by a handful of games.

Worst decile sits 8.4 percentage points off the diagonal. This is what the Brier score summarises — plotted, you can see whether “70% confident” really means 70%.

Accuracy by confidence

A model should be near chance on coin-flip games and strong on mismatches. If it is flat, the probabilities carry no information.

On the 254 games it called closest to a coin flip it scores 49.6% — chance, correctly. On the 377 it called clear favourites, 80.9%. It knows when it does not know.

Where it is wrong, and what drives it

Season wins — actual minus expected

Summing win probability across all 82 games gives each team an expected win total. Residual is what they actually did, minus that.

Mean absolute error 4.3 wins per team over 82 games. Biggest miss: OKC.

Elo through the season

Two best and two worst teams by actual wins. Elo is the feature the ablation showed carries the model, so this is what it is mostly reading.

Weekly means on one shared axis. Elo is computed forward through the season, so a rating on any given week uses only games before it.

Method

How leakage is prevented

The single most important line in the project.

Every rolling and EWM window is computed after a .shift(1), so a game's features can never contain that game's own result:

shifted = grp[stat_cols].shift(1)
roll    = shifted.rolling(window, min_periods=3).mean()

Validation is a temporal holdout — train seasons 1–8, test season 9 — with TimeSeriesSplit(7) for tuning, so no fold ever trains on its own future. Cross-validated AUC 0.682 ± 0.021.

Known limitations

Stated, not buried.
  1. The feature set is wider than the evidence justifies — see the ablation.
  2. Optuna tunes on unweighted CV folds while the final fit applies the bubble sample weights. A small train/tune inconsistency.
  3. CalibratedClassifierCV uses stratified internal folds, so calibration peeks across time even though the outer holdout does not.
  4. The natural next test is the completed 2025-26 season as a genuine out-of-time holdout — data that did not exist when this model was designed.

An earlier version of this project included a wagering backtest reporting +35.8% ROI. It was deleted rather than repaired: it priced every game at a constant −110 instead of real market odds, so the "edge" was measured against a market that does not exist. The model is the asset; that layer was not true.