The Estimated Net Rating
One number per skater per season, made before the first game and moved by every game after it. It starts from what the last three seasons say, aged, and it is pulled toward the average of players with that much history; by midseason the season itself has the louder voice. It is printed with a band, and the band has been checked.
Two numbers, named apart
The table player_season_nr_forecast holds two quantities per skater and season, and they are kept under different names because they answer different questions. Talent (talent_net, with talent_sd) is the posterior mean of his per-game Net Rating, times 82: the rate we think he produces at, today. The year-end projection (ye_net, with ye_lo80 / ye_hi80) is what his season Net Rating will read when the schedule is done: the games already played, as they happened, plus the talent for the games that remain. The number on a card is the year-end projection, and the band under it is that projection's 80% predictive band; the talent's own sd is never drawn as a band. Before opening night the two are the same thing; after the last game the projection is the realised season exactly, which is the first check the stage runs on itself.
Both are built from the per-game offensive and defensive components that Net Rating stores (o_rating, d_rating: goals above the positional average per game, all strengths), regular season only, and each component is forecast on its own. Goalies are not in this table; the lineup card takes their number from the goalie forecast.
The season-start prior
The prior is a Marcel: the last three seasons' per-game means, each weighted by its game count and a season weight, plus a pull of P0 pseudo-games toward a reference mean. Two things are unusual about ours.
m = ( Σ_k w_k · n_k · a_k + P0 · μ[bucket] ) / ( Σ_k w_k · n_k + P0 ) a_k = mean_k + A(age_now) − A(age_k) k = 1, 2, 3 seasons back
The reference mean depends on how much history the player brings. A skater with fifteen games behind him is drawn from the fringe, not from the regulars, and regressing him to the league's regulars was a measured bias in the first fit. So μ is the mean of the bucket he sits in, by games in the last three seasons: entrant, 1–40, 41–100, 101–200, 201 and up. An entrant has nothing but his bucket's mean and P0 games of information. The entrant mean itself weights each past rookie season by 1 − exp(−n/τ), with τ of 30 games for forwards and 10 for defencemen: weighting rookies by their games let the ones who played full seasons, the good ones, dominate the mean, and a fresh entrant inherited a survivor-weighted number (defence entrants ran +1.34 high in the first fit). The τ pair was chosen walk-forward on entrant error with each position's bias held inside half a goal; a parameter-free alternative, equal weights on each rookie's shrunk mean, left defence at +1.16 and was not taken. One caveat: τ was chosen per position on the same walk-forward folds it is evaluated on, one parameter per position, so the entrant cells are mildly in-sample; the league-level numbers are not affected.
Aging is applied to each past season before it is averaged. A is a cumulative curve fitted on within-player year-over-year changes of each component (a polynomial in age, chosen by leave-one-season-out), so a 32-year-old's season at 29 is moved to what it would read at 32 before it counts. It is fitted here, on these components; it is not the shooter and goalie aging curve the xG walker uses. Survivorship is stated rather than corrected: a declining player drops below the pairs' 20-game floor and out of the fit, so the old end of the curve is flatter than the truth, and that shows up in the level check below.
On top of the Marcel there is a small ridge second stage (the arm the holdout chose is …): age band and history bucket unpenalised, prior-season 5v5 minutes per game and power-play share, and the prior's square and its interaction with age band, penalised. It is fitted only on players with a prior season; entrants keep the Marcel. It was kept because it beat the Marcel alone for both position groups on held-out seasons without materially widening the worst decile bias (a twentieth of a goal of slack), which was the rule set before the fit.
Badges were tried and are not in the model. Prior-season badge posteriors as extra features could only be tested on the one held-out season where a prior season of badges exists, and on it they did not help. The arm stays in the research script and reruns when a second season is available.
How fast the season takes over
A Marcel treats past games as draws of the current talent. They are not quite: talent moves between seasons. The prior's variance is therefore the sampling variance of the games behind it plus a fitted season-to-season drift, τ², estimated by maximum likelihood on season-start residuals. That drift is what sets the in-season weighting:
v0 = s² / I0 + τ² I_eff = s² / v0 (the prior's worth, in games) post = ( I_eff · m + n · run ) / ( I_eff + n ) var = s² / ( I_eff + n ) ye = ( n · run + (N − n) · post ) / N N = expected games this season
s² is the measured game-to-game variance of the component for the position group, n the games played, run the running mean over them. The offensive component drifts; the defensive one barely does, so a three-season history is worth a few dozen games of current offence and a couple of hundred of current defence.N is the games played plus the team's remaining games at the player's availability so far, and before opening night it is 82.
Does it beat the obvious?
Walk-forward, …: every parameter used for a season was fitted on the seasons before it, and the target is the realised season Net Rating of skaters with at least 20 games. Goals per 82.
Level check, and the known misses
Bias is mean(forecast − realised) at season start, by position and age band. The gate we set ourselves was half a goal per 82, and the table is printed whole rather than trimmed to the cells that pass.
Known misses, quoted from the holdout memo (backend/research/nr_forecast/holdout.md, the fit this page's data file comes from; goals per 82 at season start). Defencemen aged 34 and over are over-forecast by +0.96: the aging pairs only contain players who kept playing, and halving the pairs' game floor did not move it. Defencemen with 41 to 100 games of history are over-forecast by +0.60; entrants, after the change to the entrant mean above, sit at −0.34 (forwards) and −0.07 (defencemen), inside the gate. Across prior deciles there is a hump: the middle deciles run +0.5 to +1.0 high and the top decile −0.6 low, which is not explained and is recorded as open rather than corrected after the fact. Read all of it against the typical error of four goals per 82.
The band, and the lineup total
The band printed with a year-end number is the predictive 80% band: the talent's posterior variance plus the noise of the games still to come. The talent band alone covers only six or seven in ten realised seasons at season start, as it should, since a realised season carries its own noise; it is exposed under its own name and never drawn as the band. Coverage of the predictive band by games seen, and the scale applied where it fell short:
The lineup total on the team card is the sum of the dressed eighteen skaters' projections. Their errors are not independent: teammates share the ice and the on-ice expected goals in the components. Over … real team-games, the variance of the summed residual was … times the sum of the individual variances (a pairwise correlation of about …), and the total's band is widened by that factor rather than printed on an independence assumption.
Where it comes from, and what checks it
The fit is backend/research/nr_forecast_fit.py; its tables are research/nr_forecast/holdout.md and this page's data file. The parameters it chose go to scraper/nr_forecast_params.json with a fingerprint of the compute_net_rating.py they were fitted against, and the stage that writes the table, scraper.compute_net_rating_forecast, refuses to run against a different one. The arithmetic both use is scraper/nr_forecast_lib.py, one implementation, so what was validated is what runs.
Two checks run on every write: with no games in the season the projection must equal the talent, and with the season complete it must equal the realised mean; the stage raises if either gap is not zero. On the finished 2025–26 season the stage's year-end number matched player_ratings.net_rating to 4 × 10⁻¹⁰ on 715 skaters with twenty or more games, which is the identity holding, and the season-start prior for that season missed the realised value by 3.97 per 82 on average, in line with the holdout.