Model Development · Kept
Elo plus a season-margin blend, in a lean 21-feature model
Replacing a 42-feature set with Elo, a blended season margin and a short list of form, rest and availability inputs improved every headline metric out of sample.
The problem with 42 features
The earlier model had 42 inputs: shooting splits, free throw and rebound rates, home and road splits, scoring variance, and products of one team’s offense with the other’s defense. Most of them were different ways of saying the same thing, that one team is better than the other, and each one carried its own noise. Gradient boosting will happily split on noise, so a wide feature set was costing accuracy.
The idea was to measure team strength once, well, and then add only the inputs that describe something strength cannot: who is missing, how rested each team is, and the state of a playoff series.
Elo: one number per team, updated after every game
Elo gives each team a rating that starts at 1500. After every final game, the winner takes points from the loser. How many depends on how surprising the result was and how big the margin was. The update in the model:
def update(self, home_id, away_id, season, home_score, away_score):
self._roll_season(home_id, season)
self._roll_season(away_id, season)
margin = float(home_score) - float(away_score)
dr = self.elo[home_id] + ELO_HOME - self.elo[away_id]
exp = 1 / (1 + 10 ** (-dr / 400))
dr_winner = dr if margin > 0 else -dr
mov_mult = ((abs(margin) + 3) ** 0.8) / (7.5 + 0.006 * dr_winner)
delta = ELO_K * mov_mult * ((margin > 0) - exp)
self.elo[home_id] += delta
self.elo[away_id] -= delta
for tid, mg in ((home_id, margin), (away_id, -margin)):
st = self.season_stats.setdefault((tid, season), [0, 0.0])
st[0] += 1
st[1] += mg Three details matter:
- Expected result.
expis the home team’s win probability implied by the rating gap, with 70 Elo points of home advantage added. An expected win moves the ratings very little. An upset moves them a lot. - Margin of victory.
mov_multgrows with the final margin, but with diminishing returns (the 0.8 power). The0.006 * dr_winnerterm shrinks it when a heavy favorite wins big, so a strong team cannot run up its rating against weak opponents. - Season carry-over. Between seasons each rating keeps 75% of its distance from 1500 (
ELO_CARRY = 0.75), to allow for roster turnover.
Here is what that produces over a full season:
Elo rating of all 30 teams, 2025-26
Every final game moves both teams. Grey lines are the other 26 teams; 1500 is average.
TeamStrength class the model uses. Each line ends at the team's last game, so playoff teams run longer.The ratings carry real information about margins. Grouping every game since 2021-22 by its pregame Elo gap shows the average home margin rising almost in a straight line with the gap:
Does the Elo gap predict the margin?
6,624 games since 2021-22, grouped by pregame Elo difference; dot size is the number of games. The dashed line is 28 Elo points per point of margin.
Data table
| Avg. Elo gap | Avg. home margin | Games |
|---|---|---|
| -257 | -10.3 | 117 |
| -182 | -9.3 | 294 |
| -109 | -5.8 | 603 |
| -35 | -2.9 | 959 |
| 40 | 1.1 | 1,429 |
| 110 | 3.8 | 1,368 |
| 185 | 6.5 | 938 |
| 259 | 11 | 540 |
| 330 | 12.5 | 283 |
| 404 | 17.6 | 93 |
The points follow the reference line of 28 Elo points per point of margin (ELO_PER_POINT) closely in the middle, where most games are, and spread out a little at the extremes, where there are fewer games.
The season-margin blend
Elo is slow on purpose. To give the model a faster read on the current season, each team also gets its average point margin so far. Five games in, though, that average is mostly noise, so early in the season it is blended with last season’s average margin:
def features(self, home_id, away_id, season):
rec = {}
for side, tid in (('home', home_id), ('away', away_id)):
self._roll_season(tid, season)
rec[f'{side}_elo'] = self.elo[tid]
n, s = self.season_stats.get((tid, season), (0, 0.0))
w = min(n, BLEND_GAMES) / BLEND_GAMES
cur = s / n if n else 0.0
rec[f'{side}_season_margin'] = w * cur + (1 - w) * self.last_season_margin.get(tid, 0.0)
rec[f'{side}_games_played'] = n
rec['elo_diff'] = rec['home_elo'] + ELO_HOME - rec['away_elo']
return rec Season margin input: weight on each season
From BLEND_GAMES = 20: last season fades out linearly over the first 20 games.
After 20 games the input is entirely this season. Before that, last season fades out in a straight line. games_played is also passed to the model, so it can learn how far to trust the number.
The final feature list
FEATURE_COLS = [
# Team strength (data/ingestion/team_strength.py)
'home_elo',
'away_elo',
'elo_diff', # includes home advantage
'home_season_margin', # season-to-date margin, blended with last season early on
'away_season_margin',
'home_games_played',
'away_games_played',
# Recent form (last 10 games)
'home_off_rating',
'away_off_rating',
'home_def_rating',
'away_def_rating',
'combined_pace',
# Availability — rotation points/game listed Out/Doubtful on the official injury report
'home_missing_pts',
'away_missing_pts',
# Rest
'home_rest_days',
'away_rest_days',
'home_back_to_back',
'away_back_to_back',
# Playoff series state
'home_series_wins',
'is_elimination',
'series_pressure',
] Every input the score models see. The same list is used in training, in the daily forecast and in the backtest.
Results
Two separate tests compared the 21-feature set against the 42-feature set:
| Test | Metric | 42 features | 21 features |
|---|---|---|---|
| Walk-forward cross-validation, 3 seeds | Margin error (mean absolute) | 12.05 | 11.65 |
| Winners picked | 63.8% | 66.1% | |
| Held-out 2026-27 games | Margin error (mean absolute) | 12.63 | 11.64 |
| Winners picked | 59.5% | 61.2% | |
| Brier score | 0.244 | 0.223 |
The smaller set was better on every seed and every metric. A lower Brier score means better-calibrated win probabilities; 0.25 is what a coin flip scores.
Interpretation
Once team strength is measured well, shooting splits, matchup products and scoring variance add nothing. They mostly re-describe team strength with extra noise. The inputs that survived are ones Elo cannot see: injuries, rest and series state.