How We Rebuilt Our Expected Goals Model

2026-10-20 · xG · methodology · validation

Measured on shots it had never seen, our old expected goals model was not as good as we thought. We found where the data and the model were wrong and rebuilt both. Here is what changed, why, and how much better the new model is.

Figures computed 2026-10-19, against the models as they stood that day. We change them — see the methodology page for where they are now.

Expected goals sit underneath almost everything on this site. Player ratings, goalie GSAx, team shot quality, power rankings and projections all start from the same question: how likely was this shot to go in?

So when we tested our own expected goals model properly, on shots it had never seen, the answer mattered, and it was not flattering. This post explains what we changed and why, and how the new model, which we are calling v3, compares with the old one.


TL;DR


Our objective

We set one rule before changing anything: every change has to win on data the model has never seen.

That rules out the easy way to look good. A model graded on the shots it learned from always looks better than it is, and ours did. So every model number in this post is held out, in one of two ways:

Testing is one thing; what the site shows is another. The model on the site is trained on every regular season through 2025-26, so its values for past seasons are fitted to those seasons, as is usual for a published xG model. This season’s values are true predictions: the model has never seen these shots.

We also used three player-level tests that are standard in public hockey analytics:

  1. Same season: does a player’s xG per 60 track his actual goals per 60 in the same season? We use the out-of-sample version.
  2. Next season: does this season’s xG per 60 predict next season’s goals per 60? This rewards real signal over noise.
  3. Split half: does the first half of a season predict the second?

What we changed in the model

Who we learned from

We borrowed several ideas from Post Hockey’s published method, and we are glad to say so:

The changes

ChangeWhy
Season environment, walk-forwardLeague scoring drifts; this re-anchors each season using only shots already taken
Calibration fitted on a held-out seasonFixes the high-danger tail, which calibrating on the training shots gets backwards
Attacking-direction wing (on or off wing, from handedness)The old stored version ignored which way the team was attacking, which made it noise
Rebound angle changeHow far the shooting angle swung since the team’s own shot a moment earlier
Shooter’s time into his shiftTired legs are worth something. The shift must start before the shot, or the number leaks the goal; we caught that
A separate empty-net modelEmpty-net shots follow different rules entirely

What we tested and did not ship

Each of these was scored by the same held-out tests. None earned a place:

IdeaResult
A separate model for the puck-tracking era (2023-24 on)Better shot by shot, but worse at predicting next season’s on-ice goals against (r .252 to .239). Switched off for now
Age curves on shooter talentWorse at every strength we tried
Home iceHelped one season, hurt the other
Shooter position (forward or defence)Fixed a small bias on defencemen, but xG should describe the shot, not who took it; xG(sh) handles the shooter
Redefining the rushNeither dropping the rush flag nor an alternative definition beat the stored flag on held-out seasons
Lateral puck movement before the shotNo held-out gain
A season environment that weights recent games moreNo held-out gain
Rink-by-rink coordinate correctionThe rink effect is real but has halved since 2023; correcting it did not help
Goalie talent in xGNo predictive gain, and it would cancel out the thing GSAx measures

Two numbers: xG and xG(sh)

xG answers “how likely was this shot to score?” It does not know who shot it. It is the headline number for shot quality, team xG and on-ice expected goals for and against.

xG(sh) adds a running estimate of the shooter’s finishing talent, updated after every shot using only his earlier shots. It is built to predict, and it is what GSAx uses: a goalie who stops an elite shooter deserves more credit than one who stops a fourth-liner. We label it wherever it appears.

Results

Shot by shot

Protocol: even-strength unblocked shots, regular season 2023-24 to 2025-26, the same 283,227 shots for both models. Leave one season out: each season is scored by a model trained on every other season. Forward only: trained only on earlier seasons, which is possible for 2023-24 and 2024-25.

Measurev2 (old)v3
AUC, leave one season out.768.788
AUC, forward only.761.776
Log loss, leave one season out.208.202
Calibration, leave one season out1.020.99

AUC is how well a model ranks goals above non-goals, where 0.5 is a coin flip. Log loss is lower when the probabilities themselves are closer to what happened. Calibration is total expected goals divided by actual goals, where 1.00 is perfect. v3 wins every season under both tests. On empty nets, v2 as it ran on the site had a calibration of .48: it predicted fewer than half the goals.

Player level

Protocol: 5v5 regular season, 2023-24 to 2025-26, skaters with 500 or more minutes, out-of-sample models. The numbers are correlations (r) between xG per 60 and goals per 60.

Testv2 (old)v3
Same season: individual goals.804.828
Same season: on-ice goals for.662.711
Same season: on-ice goals against.513.553
Next season: individual goals.735.756
Next season: on-ice goals for.382.444
Next season: on-ice goals against.222.250
Next season with xG(sh): individual goals / on-ice goals for.770 / .508

Playoffs

We did not build a separate playoff model, because the regular-season model did not need one. Protocol: playoff shots from 2022-23 to 2025-26, which the model never trained on, scored with that season’s end-of-season environment. v3’s AUC is .789 against v2’s .765, and its calibration is 1.01 against .98.

Rebuilding every season

A new xG model changes everything built on it, so when we switched we rebuilt the whole site’s history: every season from 2010-11, every per-game number, the on-ice tables, RAPM, GAR and WAR, and the projections. We rehearsed the whole rebuild on a copy of the database first. The figures below compare the site just before the switch with the site just after. The rebuild also carries a cleanup of our shift data: the NHL’s HTML shift reports put a summary table under each player that we had been reading as about 20 extra shifts a game, and some games had been loaded twice. That inflated ice time and every on-ice rate, so these figures reflect both changes.

Protocol: team standings R² is the share of the spread in each team’s standings points explained by the summed GAR of its skaters and goalies, computed per season and averaged over the 15 regular seasons from 2010-11 to 2025-26, leaving out the 48-game 2012-13 season. Repeatability is the correlation of a skater’s RAPM-based GAR from one season to the next, skaters with 500 or more minutes in both, averaged over every pair of seasons.

Measurev2v3
Standings R², GAR from RAPM.762.781
Standings R², counting-stat GAR.835.900
Year-to-year repeatability, skater GAR.769.775

RAPM-based GAR improved in 8 of the 15 seasons and dipped in the other seven, by .005 to .044 of R². Six of those are 2018-19 or later, the seasons where the phantom shifts were removed. To separate the two, we rebuilt the copy a second time with the old model and the cleaned shifts. That scored .770, and v3 beat it in 12 of the 15 seasons, so the recent dips come from the shift cleanup, not from the new model.

For individual players the change is a refinement, not a reshuffle. In 2025-26, skater WAR under v3 correlates .993 with v2, and 19 of the top 20 stay in the top 20. Goalie GSAx correlates .972.

A goalie bug we found on the way

Rebuilding every season exposed a bug in how we total goalie GSAx, and it had nothing to do with the new model. To credit missed shots to the right goalie, we assign each one to the goalie who faced the last shot on goal. Shots into an empty net have no goalie, so they were being charged to the goalie who had just been pulled for the extra attacker, along with any empty-net goals. One version also counted shootout attempts.

Teams pull their goalie when they are losing, so this quietly mixed team results into goalie ratings, and it flattered the old model. Measured as the standings R² of each team’s goalie GSAx (same protocol as above), v2 scored .289 with the bug and .260 without it. Under v3, which prices empty-net shots correctly, the same bug pushed the other way. With it fixed, v3 scores .264 against v2’s .260. Empty-net and shootout attempts no longer count toward any goalie’s GSAx.

What this changes on the site

With v3 live, every xG-based number from 2010-11 on has updated together:

We keep every v2 value, so we can always show what changed. The moves you are most likely to notice, all from 2025-26:

Limitations

What is next

Data: NHL play-by-play, shift charts and situation codes. Methods credited to Post Hockey and CJ Turtoro where noted. Model tests are held out as described in each section. Model tests as of October 18, 2026; the rebuild figures compare the site before and after the switch on October 19, 2026.

Frequently Asked Questions

What is the difference between xG and xG(sh) on Hockey Alchemy?

xG is the chance a shot goes in, judged from the shot alone: where it was taken, how, and what happened just before. It does not know who took it. xG(sh) adds a running estimate of the shooter's finishing talent, built only from his earlier shots. xG is the headline number for shot quality; xG(sh) is what goalie GSAx uses.

How much better is the new xG model?

On 283,227 even-strength shots from 2023-24 to 2025-26, each scored by a model that had never seen that season, v3 ranked goals above non-goals with an AUC of .788 against the old model's .768, and its log loss fell from .208 to .202. Across all situations its total expected goals came within half a percent of actual goals.

Why did goalie GSAx change?

Two reasons. GSAx now uses xG(sh), which credits a goalie more for stopping better shooters. And rebuilding every season exposed a bug: shots into an empty net were being charged to the goalie who had been pulled for the extra attacker. Teams pull their goalie when they are losing, so that quietly mixed team results into goalie ratings. Empty-net and shootout attempts no longer count.

Which seasons use the new xG model?

Every season from 2010-11 on. Before that, NHL play-by-play lacks the event detail the model needs, so we no longer show expected goals, or anything built on them (GSAx, GAR, WAR), for 2007-08 to 2009-10.

More from the Notebook

Explaining Our Power Rankings

Readers asked why our power rankings still count last season eight games in. We replayed 13 seasons to find out what predicts the rest of a season: the record so far is the weakest option, dropping last season makes the rankings worse, and this season's 5v5 expected goals make them better. Here is the model, and what we changed.

Talent vs Production: Why the Box Score Misleads

Two wingers score 20 goals; only one of them will do it again. The gap between what a player produced and the process underneath it is the most useful idea in hockey analytics - and it is the split our expected-goals and finishing models are built to make.

How Good Is Our WAR Model? We Ran Four Tests and Lost Two

We tested our WAR model against HockeyStats and Evolving Hockey on the four questions an honest player-value model has to answer, matched to each benchmark's published protocol. Summed to a team it loses both tests - it forecasts next season worse than simply reusing last season's standings. Measured per player it wins both, repeating at 0.77 against 0.62 and 0.46.