← Notebook

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:

data/ingestion/team_strength.pylines 72–86
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. exp is 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_mult grows with the final margin, but with diminishing returns (the 0.8 power). The 0.006 * dr_winner term 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.

Elo ratings of every team through the 2025-26 season, regular season and playoffs 1200130014001500160017001800 NovDecJanFebMarAprMayJun HOU: ended at 1588LAL: ended at 1586GSW: ended at 1422MEM: ended at 1273NOP: ended at 1399ORL: ended at 1550MIA: ended at 1524ATL: ended at 1535TOR: ended at 1554CHI: ended at 1339DET: ended at 1646PHX: ended at 1531SAC: ended at 1326DAL: ended at 1350CHA: ended at 1649BKN: ended at 1253BOS: ended at 1635PHI: ended at 1531POR: ended at 1514MIN: ended at 1570MIL: ended at 1344CLE: ended at 1598UTA: ended at 1282LAC: ended at 1539DEN: ended at 1622IND: ended at 1332 NYK: ended at 1787SAS: ended at 1771OKC: ended at 1743WAS: ended at 1206 NYK 1787SAS 1771OKC 1743WAS 1206
Ratings come from replaying every game since October 2021 through the same 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.

Average actual home margin by pregame Elo difference -15-10-50+5+10+15+20 -300-1500+150+300+450 Elo gap about -257: home teams won by -10.3 on average (117 games)Elo gap about -182: home teams won by -9.3 on average (294 games)Elo gap about -109: home teams won by -5.8 on average (603 games)Elo gap about -35: home teams won by -2.9 on average (959 games)Elo gap about +40: home teams won by 1.1 on average (1,429 games)Elo gap about +110: home teams won by 3.8 on average (1,368 games)Elo gap about +185: home teams won by 6.5 on average (938 games)Elo gap about +259: home teams won by 11 on average (540 games)Elo gap about +330: home teams won by 12.5 on average (283 games)Elo gap about +404: home teams won by 17.6 on average (93 games) Home Elo + home advantage − away Elo Actual home margin, pts
Data table
Avg. Elo gapAvg. home marginGames
-257-10.3117
-182-9.3294
-109-5.8603
-35-2.9959
401.11,429
1103.81,368
1856.5938
25911540
33012.5283
40417.693

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:

data/ingestion/team_strength.pylines 59–70
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.

Share of the season margin input taken from this season and from last season, by games played 0%50%100% 010203040 this season last season Games played this season

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

model/train.pylines 25–56
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:

TestMetric42 features21 features
Walk-forward cross-validation, 3 seedsMargin error (mean absolute)12.0511.65
Winners picked63.8%66.1%
Held-out 2026-27 gamesMargin error (mean absolute)12.6311.64
Winners picked59.5%61.2%
Brier score0.2440.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.