xJawn NHL expected goals

← Blog

Model v2: putting the players back in

v1 was deliberately talent-blind. v2 gives the model shooters, goalies, and teammates — measures what that buys, and ends up scoring the site.

Model v1 is deliberately blind to who is shooting. That is not an oversight — it is the only way finishing stays measurable. If the model already knows Auston Matthews is shooting, his talent gets baked into the prediction and vanishes from the residual, and goals-minus-xG stops meaning anything.

Updated 20 July 2026 — v2 now scores the site. When this was written, everything below sat beside the scoreboard: v1 scored the tables and v2 produced ratings surfaces. That changed. The third attempt described here — the joint fit — has an identity-blind half, and that half is now flat__joint_structure_xg__4f64c88b, the canonical model behind every xG number on this site. The yardstick is still talent-blind, so nothing about why v1 was built that way has been abandoned. The promotion section below has the reads, including the pre-registered bar it failed.

But blindness has a cost, and v2 is about measuring it. Two costs, actually:

  • The people axis goes unmeasured. Whether a chance converts is shaped by everyone on the ice — the shooter, the teammates who created the look, the defenders closing the lane, the goalie. v1 prices the location and calls it a day.
  • Blindness is not neutrality. A model fit without identity quietly absorbs whatever talent correlates with its features. Skilled shooters take a disproportionate share of their attempts from good spots, so the location terms get inflated by talent — and then under-credit exactly those shooters. A talent-blind baseline can never hand that credit back.

v2 attacks both, three different ways, and the honest headline is that two of the three failed. Everything below was pre-registered on 2026-07-14 — the bars, the gates, the comparators — before any v2 model was trained. That matters more than usual here, because this corpus is a plateau where reasonable variants tie to the third decimal, and without a bar fixed in advance you can always find a story.

Attempt 1: just put the names in the model

The obvious thing first. Hand the booster primary_player_id and goalie_id as ordinary categorical features and let it learn what it wants. The bar: beat the v1 baseline's held-out log-loss by at least 5×10-4, and improve in a majority of seasons.

It never came close. Fifteen identity models across the shooter and goalie axes, 240 season-level comparisons, zero wins. The best arm lost by 2.0×10-3 — four times the adoption bar, in the wrong direction. Log-loss and danger AUC agreed in every single row, so this is not one metric being difficult.

Two line panels showing every identity model's change in held-out log-loss versus the baseline, for each of sixteen seasons. Every line sits above zero, meaning worse.
Every identity arm, every season. The y-axis is change in out-of-fold log-loss against the v1 baseline, so above zero is worse; the dashed blue line below zero is the adoption bar the challenger had to clear. Left: shooter identity. Right: goalie identity. Nothing ever gets under the bar; nothing even gets under zero.

The tempting explanation is overfitting — thousands of shooters, many with a handful of shots. But the goalie axis kills that: roughly 103 goalies a season, each facing thousands of shots, the lowest-cardinality and highest-volume identity feature available. It lost by the same margin (−2.1×10-3). And the damage is additive — stacking shooter and goalie hurts about as much as the sum of hurting separately.

Raw identity, dropped in as a feature, does not help this model at any cardinality. That track is closed.

Attempt 2: a shrunk skill layer on top

Different shape. Leave the baseline alone, and fit a thin penalized layer on top of its predictions — a per-player offset in log-odds, with an explicit prior shrinking every player toward league average:

logit P(goal) = logit(baseline xg) + δshooter + Σ θoff − Σ θdef − ωgoalie

Four components: finishing (your own shots), supporting (your teammates' shots while you're on the ice), defending (shots against you), and saving (goalie, on-net shots faced). Signs are arranged so positive always means good. Finishing and supporting are fit on disjoint sets of shots, so "elite finisher" and "elite playmaker" are separate, non-competing measurements rather than one number fighting itself.

Two gates had to be cleared to ship a component: it has to be persistent (a player's rating in one random half of the shots predicts the other half), and it has to predict (add held-out lift). Here is where each landed:

Two bar panels of persistence by component. Split-half reliability: finishing 0.367, supporting 0.173, defending 0.111, saving 0.381, with gate lines drawn on each bar. Year-over-year: 0.207, 0.105, 0.034, 0.169.
Is each component measuring a stable property of a person? Left: split-half reliability, with each component's pre-registered gate drawn as a dashed segment across its own bar. Right: year over year. Finishing (0.367) and saving (0.381) clear comfortably; defending (0.111) fails outright. Supporting clears persistence (0.173) but is grey in the ledger for a different reason — it adds no held-out lift, so it does not ship.

So the shipped layer is a reduction — finishing and saving only. Adding the on-ice components does not merely fail to help, it actively dilutes: held-out lift goes from +2.90×10-4 (reduction) to +2.88×10-4 (full design), and falls off a cliff if you loosen the on-ice prior at all.

The plain finding, stated as unhappily as it deserves: a skater's influence on whether a teammate's shot goes in is not detectable as held-out signal in 2.34 million public-data shots. That may be because the effect is genuinely tiny, or because what persistence does see in "supporting" is shared line and team context rather than the individual. Distinguishing those two is not another per-shot model's job.

Attempt 3: fit the structure and the people together

This one worked. If the problem with v1 is that its location terms silently absorbed talent, then don't fit them separately — alternate. Fit the booster with the current player offsets handed to it as a fixed head start, re-fit the player layer against the new booster, and repeat. This is a backfit toward a partially linear model, and it is cross-fitted at the game level throughout: no quantity a shot is scored with may derive from that shot's own game.

The absorption was real and it is now measured. As the loop converges, the amount of signal the player layer can find grows by 11.5–16% — that is credit the talent-blind structure had been holding onto, being handed back.

Two bar panels. Left, change in headline log-loss: intercepts-only 0.000, finishing-only −0.119, finishing plus saving −0.184 (times 1e-3). Right, change in danger AUC: +0.08, +1.63, +1.97 (times 1e-3).
What the shipped ratings actually buy, fold-honest. Adding finishing and saving improves held-out log-loss by 1.84×10-4 and danger AUC by 1.97×10-3, better in 16 of 16 seasons. The grey bar is the control: a layer with the same shape but no player information, which lands at exactly zero. That bar is the whole point — see below.

Small, real, and it survives contact with the danger AUC — the metric computed only on shots that reached the net, where the measurement artifacts below cannot reach.

The part where we were nearly fooled

Twice, the layer looked far better than it was, and both times the gain had nothing to do with players.

The wedge. The goalie block was keyed to the event's goalie id — which is null exactly when a shot is blocked. Blocked shots almost never go in, so a column that is "present iff unblocked" hands the model a free look at the outcome. It was worth +3.85×10-3, thirteen times the adoption bar, and none of it was skill. Fixing it meant re-keying saving to the pre-shot goalie — who was in net when the puck left the stick, whether or not the shot got there.

The clip. Nearly 10% of shots (224,592 deep-blocked attempts) are predicted below one-hundredth of a percent. Merely capping those predictions is worth −6.5×10-4 on its own, again for no reason involving a player. So every comparison in this post is against a baseline put through the same clip.

The grey zero-information bar in the figure above is the standing check that both are gone. Before the fix, that same bar was worth +3.85×10-3 of pure structure. This failure mode — a column whose presence encodes the outcome even when every value in it is innocent — showed up five separate times in v2, which is why the project now has a leak gate that refuses to fit rather than a diagnostic that prints a warning.

The other margin: RAPM

Everything above is about quality per attempt — the probability a given shot goes in. For most skaters that is not the main lever. The bigger one is volume: generating more attempts and conceding fewer. No per-shot model can see that, because it only ever looks at shots that happened.

So there is a second, structurally separate estimator. RAPM — regularized adjusted plus-minus — regresses on-ice expected-goal rates per 60 against who was on the ice, controlling for score state, zone starts, home/period, and penalty shadows. The unit is the stint: a maximal interval over which nobody changes and the strength state holds constant. There are 6.40 million of them, across 20,583 games and 20,908 hours of hockey.

Two design choices are load-bearing:

  • It regresses on expected goals, never actual goals. That structurally excludes finishing, shooting luck and goaltending from the rate coefficients — which is exactly what makes it addable to the finishing rating above instead of double-counting it. RAPM measures everything except conversion; the ratings layer measures only conversion.
  • Sum-to-zero group penalties mean every coefficient reads relative to league average by construction — there is no reference player.

And then the pre-registered reliability gate failed, in all 31 tests, and the verdict stands unedited:

Grouped bars by season: grey raw on-ice rates exceed orange single-season RAPM in all sixteen seasons; a black line for pooled three-season RAPM sits above every orange bar.
The registered gate, as measured. Grey = raw on-ice rates, orange = single-season RAPM, black line = RAPM pooled over three seasons. Raw rates are more stable than single-season RAPM in all sixteen seasons. Pooling recovers most of the gap (0.497 vs 0.362) but does not close it.

Which raises the obvious question: if raw on-ice rates are more stable, why adjust anything? Because stability is not the same as being right. Raw rates are stable partly because they carry the stable context — deployment, linemates, team system — that RAPM is deliberately removing. A player's team name alone would be perfectly "stable" and tell you nothing about the player.

So the failed gate was replaced by a harder one, registered before it was computed: forget split-halves, just predict next season's results, and check separately what happens to players who change teams. If an isolate is doing its job, that is precisely where it should pay.

Two panels predicting next-season on-ice results. For all players and for stayers, raw and shrunk-raw comparators beat RAPM. For movers, the RAPM bar is the tallest in both panels.
Predicting next season, out of time. For everyone, and for players who stayed put, raw rates win. For the 124 players a season who change teams, RAPM is the best predictor in both panels — 0.261 vs 0.237 on expected goals (better in 10 of 15 season pairs), 0.375 vs 0.320 on shot volume (13 of 15).

That is the whole case for the number, stated at its true size: modest signal that pays rent exactly when context changes. It ships as a three-season pooled figure, with shot volume given at least as much prominence as shot quality, because volume is measured considerably better (0.701 vs 0.497 pooled reliability). Single-season xG-RAPM is not shipped as a skill claim at all.

The promotion — and the bar it failed

On 20 July 2026 the joint fit's identity-blind structural half replaced v1 as the canonical model. That is the half with no player terms in it at all — the structure as re-estimated once the people were accounted for separately. The player-aware composite was not a candidate: put it in the tables and finishing stops being measurable, and it would double-count against the modeled shooting columns that ship alongside it.

The pre-registered bar was the same one everything else in this post was held to: beat the incumbent's held-out log-loss on the most recent season by 5×10-4, and improve in a majority of seasons. It failed both halves — +2.83×10-4, better in 5 of 16 seasons. It shipped anyway, as a recorded owner over-rule. Here is the case, including the parts that argue against it.

Bar chart of per-season log-loss improvement of the candidate over the incumbent; five bars are positive and eleven negative, with the most recent season positive.
The mechanical verdict, unmoved. Green is a season the new model wins. Five of sixteen is not a majority — though the most recent season, the one performance is always quoted from, is the last bar and it is green.

The interesting part is why it lost, because the naive comparison confounds three separate changes. Each can be isolated by comparing a different pair of models on the identical 2,344,674 shots:

Bar chart of three isolated effects on overall log-loss improvement: chassis −5.76, decontamination +5.20, calibration −0.35, net −0.90, in units of 1e-4.
Three effects pulling different ways. The idea being adopted works — decontaminating the structure is worth +5.20×10-4, an independent reproduction of the backfit's own claim and larger here than when it was first measured. It is almost exactly cancelled by the chassis it runs on: the joint fit is built on a single flat booster, and v1's nested cascade genuinely out-ranks one, at −5.76×10-4.

So the swap loses on architecture, not on the thing it was adopted for. That also says exactly where the outright win is — a nested backfit — which does not exist and is now the registered next question.

The read that actually decided it

None of the above is what settled the decision. This was: the structural model had been shipping uncalibrated, and nobody had noticed, because until now it was only ever consumed one shot at a time.

The joint fit's export correctly strips the composite's calibrator — that calibrator maps a different quantity — but never refit a replacement. Harmless for a per-shot surface. Not harmless for a canonical xG, whose main job on this site is to be summed. A level error in a summed xGF lands directly in goals-minus-xG and reads as league-wide finishing talent that does not exist. On the earlier structural export the deficit was 3.32% — roughly 3,700 phantom goals of leaguewide "finishing" across the corpus, which would have double-counted straight into the modeled shooting column shipping in the same week.

Left: reliability residual curves by predicted-xG bin for three arms. Right: horizontal bars of aggregate xG bias, showing the calibrated candidate at near zero.
Left: how far each model's predictions miss, by bin, as a percentage of what it predicted — plotted as the residual because at these levels everything looks perfect on a conventional reliability plot. Right: what a summed xGF inherits. Refit, the new canonical model's aggregate bias is +0.002%, against v1's −0.62%. The cost is −0.35×10-4 of log-loss: a hair of sharpness traded for an exact level, which is the right trade for a number that gets added up and the wrong one for almost nothing else this site does.

What the new model wins: calibration outright, the most recent season, and danger AUC (by a hair). What it loses, carried openly rather than argued away: pooled log-loss across all seasons, and 0.0022 of overall AUC. That last one is structural and permanent — calibration is monotone, so it cannot reorder shots, and no future calibrator will recover it. If this site ever leans on per-shot ranking as a product, that decision deserves re-reading.

Two practical notes. The standings do not move: 5v5 xGF% correlates 0.9991 between old and new across all 494 team-seasons, with a maximum shift of half a percentage point. And the real motivation was coherence — RAPM, player value, the player cards and the team talent ledger were already keyed to the joint lineage, so promoting the structural model collapses two model keys into one. Every surface on the site now reads from a single converged fit instead of two whose relationship needed a caveat.

What v2 changed, and what it didn't

The scoreboard is still talent-blind. That is the thing to hold onto. The canonical model changed identity — it is now v2's structural half rather than v1 — but not character: it has no player terms, so finishing remains measurable against it. v2's player-aware output stays what it always was, a set of ratings surfaces beside the scoreboard: the modeled-talent ledger on team pages, the finishing and saving strips on player cards, the RAPM usage and context panel.

Some things to keep in mind when reading any of it:

  • The ratings measure quality per attempt only. A rating is a piece of player value, never the whole of it.
  • RAPM is 5v5, both goalies on the ice. No power play, no penalty kill, nothing about penalties drawn or taken. Those are missing buckets, not errors — but they are missing.
  • Defense is measured much worse than offense, roughly half the reliability, in both estimators. Treat a defensive number as the softer claim it is.
  • A low-minutes player pinned near zero means "not enough evidence," never "confirmed average."
  • The shipped ratings artifact is a final refit on all rows, labeled in-sample — the held-out numbers in this post are the honest evaluation, not the displayed values.

Deeper reading

Everything above is the summary of living reports in the project repository, each with the exact commands that regenerate every figure: