Model Development · Kept
Team model only; the player model is kept for display
Blending summed player predictions into the game score cost correct winners in every slice tested, so official forecasts now use the team model alone.
Two ways to get a final score
The project has two ways to reach a final score:
- Team model. Two gradient-boosted regressors, one for the home score and one for the away score, trained on the 21 team-level inputs.
- Player model. A separate model predicts points for each player, and the team total is the sum over the expected lineup.
The published forecast used to blend the two, along with a third model that predicted the margin directly. The question was whether the blend helped.
Test
Each layer was turned off in turn, and the forecasts were re-run out of sample on three slices: 116 games of 2026-27 and 411 games from February to June 2026, which split into February to April and the playoffs.
Winners picked: team model alone vs the published blend
Data table
| Team model only | Published blend | Difference | |
|---|---|---|---|
| 2026-27 | 65.5 | 62.9 | 2.6 |
| Feb–Apr | 75.2 | 74.6 | 0.6 |
| Playoffs | 59.5 | 54.8 | 4.7 |
Why the player model hurt
Summed player predictions came out 20 to 60 points below real team totals. The likely reason is that it predicts each expected player separately, so points from players outside that lineup, garbage time and overtime are missed. Blending it in helped only when that low bias happened to cancel a bias in the team model. That is luck, not signal.
Decision
Official forecasts use the team model only. The decision and the numbers live next to the switch:
# Game scores come from the team model alone (home/away score models). Layer-by-layer
# out-of-sample test (2026-11-05, 116 games of 2026-27 + 411 of Feb-Jun 2026): the plain
# team model picked the most winners in every slice (65.5% vs 62.9% published this
# season, 75.2% vs 74.6% Feb-Apr, 59.5% vs 54.8% playoffs). The player model's summed
# team totals run 20-60 pts low, so blending it in only helped when it happened to
# cancel a team-model bias. The direct margin model blend (70/30) cost winners too.
# Player predictions are still made and shown, just not blended into the game score.
# The playoff total drag in blend_game_total is kept (team totals run ~3.5 pts high).
TEAM_MODEL_ONLY = True Player-level information still matters. It now enters through the availability input instead of a separate model: the points per game of rotation players listed Out or Doubtful.
def compute_live_availability(team_id, game_date, out_names):
"""
Availability for an unplayed game: rotation players (same definition as training)
listed Out/Doubtful on the injury report.
Returns (missing_min, missing_pts, missing_player_names).
"""
if not out_names:
return 0.0, 0.0, []
from data.ingestion.fetch_official_injuries import season_for_date
box = _load_played_box_scores(before_date=game_date)
tb = box[box.team_id == team_id]
if tb.empty:
return 0.0, 0.0, []
last_team_by_date = box.sort_values('game_date')[['player_id', 'game_date', 'team_id']]
gdate = pd.Timestamp(game_date)
rot = _season_rotation(tb, season_for_date(gdate.date()), gdate, team_id,
last_team_by_date)
out = {normalize_player_name(n) for n in out_names}
absent = rot[rot['full_name'].map(normalize_player_name).isin(out)]
return (float(absent['mins'].astype(float).sum()), float(absent['pts'].sum()),
absent['full_name'].tolist()) The live version of the availability input. Training uses the same rotation definition, applied to each archived injury report.