How Our Expected Goals (xG) Model Works

2026-07-20 · methodology · expected goals · xG · model

A transparent look inside Hockey Alchemy's xG model: situation-specific XGBoost models, 53 features, 16 seasons and 1.6M shots — and how it stacks up against MoneyPuck on a matched-shot test.

TL;DR

An expected-goals (xG) model is only as good as the data underneath it — and in the NHL, that data keeps moving. The league's shot tracking has shifted over the years: how missed shots are logged, where shots get recorded from, which shot types exist. The game itself keeps changing too. And that's a problem, because an xG model frozen on old seasons quietly drifts out of calibration — it starts expecting goals modern hockey doesn't produce, and crediting goalies and shooters for the wrong things. Public models have run into exactly this: as the tracking drifted, their goal expectations drifted with it. Staying accurate isn't a one-time training job; it's a rebuild-and-retrain discipline.

That's the lens for this post. xG is the backbone of everything we publish — goalie work above expected (GSAx), on-ice shot quality, and the expected-goals component of GAR/WAR — so if the model drifts, all of it drifts with it. Here's how we keep ours honest: what it measures, the four separate models under the hood, the features it reads, how we validate it, and where it still falls short.

What xG measures

xG assigns every unblocked shot attempt a probability — from 0 to 1 — that it becomes a goal. A shot with xG = 0.15 is the kind of chance that historically scores about 15% of the time. We model P(goal | unblocked shot attempt), the Fenwick-based definition used across public hockey analytics — Evolving Hockey, MoneyPuck, and HockeyStats all score unblocked shots. Blocked shots are excluded because the NHL records a block at the blocker's location, not the shot's origin, so their coordinates can't be trusted. The probability comes entirely from the shot's context:

Why xG, not just goals?

Goals are the scoreboard truth — but as a measure of a player, they're noisy and slow. A goal total blends four different things into one number: the quality of the chances a player generated, how well they finished them, what their goalie and linemates did, and plain luck. Over a 20- or 30-goal season, the swing from puck luck alone can be several goals in either direction, which is why a shooter's goal count bounces around far more than their underlying play does.

xG pulls out the first piece — the quality of the chances, independent of whether they went in. Line the two up and the difference tells you the rest: finishing = goals − xG. A player who scores 30 on 22 xG is finishing +8; one who scores 18 on 24 xG is finishing −6. That gap is a real skill — some shooters genuinely beat their xG year after year — but it is a far less repeatable one than chance creation. Generating xG is sticky season to season; out-scoring your xG is much closer to a coin flip for most players, so it regresses hard.

Separating the two is the whole point. It lets us credit a shooter for the chances they create even through a cold streak, flag a hot finisher whose goals are about to regress, and — crucially — grade goalies on the same currency: a goalie's GSAx is simply the xG of the shots they faced minus the goals they allowed. And because xG strips the luck out, a team's or player's xG is a better predictor of future goals than their past goals are. Goals tell you what happened; xG tells you what usually happens next.

Why four models, not one

Scoring behaves very differently by game state. A power-play shot from the slot is a different animal than a 5-on-5 shot from the same spot — different passing lanes, different goalie movement, different rebound dynamics. Rather than force one model to average across all of it, we train four situation-specific models so each can learn its own patterns:

ModelSituationShots trained on
Even strengthEqual skaters — the bulk of the game~1.36M
Power playShooting team has the advantage~228K
ShorthandedShooting team is down a skater~43K
Empty netOpposing goalie pulled~15K (formula)

A unified model trained on all 1.6 million shots serves as a fallback when a situation-specific model isn't appropriate.

The features

The unified model reads 53 features per shot — a deliberately rich feature set (the situation-specific models use 44–47 of them). They fall into six groups:

GroupExamples
Core shot geometryDistance, angle, coordinates, shot type, period, time
Situation5v5 / PP / PK / 4v4 / 3v3, strength differential, empty net
Rebound & rushRebound (≤3s after a save), rush (shot within 4s of zone entry), shot speed
Prior eventType of the previous event, which team, time / distance / speed to the shot
Power-play timingSeconds elapsed since the power play or penalty kill began
Danger zone & shot sideHigh / medium / low-danger location (the slot "house"), off-wing vs on-wing

The prior-event, power-play-timing, and danger-zone groups are what set this apart from a basic distance-and-angle model — they capture how the play developed and where it ended up, not just how far the shot was. Distance and angle alone systematically misprice the extremes — they under-rate shots from in tight and from below the goal line, where the raw geometry lies about the danger — so we also let the model treat high-, medium-, and low-danger locations as their own categories. Notably, the model reads no score-state features (whether a team is leading or trailing). We tested them, found they inflate xG on desperation shots by leaking the game situation into the shot's grade, and removed them — an xG model should score the chance, not peek at the scoreboard.

How it's trained and validated

The models are XGBoost gradient-boosting classifiers trained on 16 NHL seasons (2010-11 through 2025-26) — more than 1.6 million unblocked shots and roughly 108,000 goals. That's more history than most public models train on, which matters for stable calibration across eras.

A model you can't measure is just a guess. So we evaluate with 5-fold cross-validation, stratified by outcome: the shots are split into five parts, and each one is scored by a model that never saw it in training — so every number below is out-of-sample, the only kind that proves a model works on shots it hasn't memorized. Four numbers do the judging, and each asks a different question:

Results

Cross-validated performance by model:

ModelLog lossBrierCalibration
Even strength0.16560.04500.999
Power play0.24140.06850.998
Shorthanded0.23970.06790.998
Unified (all shots)0.17830.04891.000

Calibration is the number we're proudest of: within a fraction of a percent of perfect at every strength, which means our xG totals can be trusted as goal-equivalents rather than a relative index that needs rescaling.

An honest caveat: special teams are our hardest states — both the power play and penalty kill post noticeably higher log loss than even strength (~0.24 vs 0.17), a reflection of how much special-teams danger is created by passing the public data can't see. They're the area we'd most like to improve.

How it stacks up against other public models

Before any of that, credit where it's due. Public hockey analytics exists because a handful of people chose to build it in the open — MoneyPuck, Evolving Hockey, and HockeyStats among them. Their models, their published methodology, and their willingness to share the hard-won details are the foundation this work stands on, ours very much included. What follows isn't a takedown of anyone — it's a measurement, held to the same public standard they set. We're better in places precisely because we got to start from what they gave the community.

With that said, the question is how it lines up against the field. Start with the methodology, where the differences are structural:

Hockey AlchemyTypical public model
Models4 models — EV / PP / SH (XGBoost) + empty-net formulausually one blended model
Training data16 seasons · 1.6M shots~7–8 seasons
Features53 (incl. prior-event, PP timing, danger-zone, shot-side)~15–20

That shows up when we put the models head to head. You can't just compare two models on different shots and call it a day, so we built a matched-shot test: take the exact same physical shot, get our expected-goals value and MoneyPuck's for it, and score both against what actually happened. Across ~112,000 matched shots in 2024-25, our AUC comes in a step ahead of MoneyPuck's at every strength but the power play — where MoneyPuck leads for a specific, honest reason we get to below:

SituationOur AUCMoneyPuck
Even strength0.8110.771
Shorthanded0.8190.781
Power play0.7190.846
Overall0.8030.778

It isn't a one-season fluke — our overall AUC has sat a step ahead of MoneyPuck's in every season from 2022-23 through 2025-26, with the even-strength gap the steadiest of all. The two models simply lead in different situations: ours across the run of play, MoneyPuck's on the power play.

Two numbers, two tests — this is the part that trips people up. The 0.811 above is deliberately conservative: it's scored only on the shots MoneyPuck and we both priced — a harder, common subset, which is the price of a fair head-to-head. Measured on our own full holdout, across every even-strength shot, our AUC is ≈ 0.84. Same model — a different, more complete set of shots. That 0.84 is the fair figure to set beside the field's published full-season numbers — Evolving Hockey's 0.782 and HockeyStats' 0.800. It clears Evolving Hockey comfortably; against HockeyStats it's a slim edge on our own data and not a decisive one — neither they nor Evolving Hockey release per-shot values, so we can't run the matched test that would actually settle it. Read it as one top tier we all sit in, not a clean win over HockeyStats. We can't put that 0.84 straight against MoneyPuck, either, because it's a different set of shots — which is exactly why the matched-shot test exists: scoring both models on the identical shots is the only apples-to-apples way to compare them.

Two honest caveats. First, MoneyPuck grades higher on the power play (AUC ~0.85 to our ~0.72), and we read that as a difference in philosophy about what an expected-goals number should capture. Working from public pre-shot data alone, power-play discrimination tops out near 0.72 for anyone — the NHL's play-by-play records no passes, so the cross-ice setup that makes a power-play chance dangerous simply isn't in the feed. Pushing materially above that means incorporating information beyond the pre-shot chance itself, and that's a line we deliberately choose not to cross: we want our xG to represent the quality of the chance at the moment of release, nothing more. Where a model draws that line is a legitimate design choice, and MoneyPuck's is a perfectly defensible one — just different from ours. HockeyStats, another public model, shows the same shape in its published figures: a strong 0.800 even-strength AUC but about 0.695 on the power play. Second, privately-tracked models with camera and pre-shot-movement data remain a tier public models can't fully reach; that gap is real, even as public data improves.

Limitations

What it powers

Everything downstream. Expected goals feed GSAx (goalies saving above expected), on-ice xGF% and shot-quality metrics, and the expected-goals component of our GAR and WAR models. It's the single model that, when it improves, lifts the most of the site at once — which is why we keep it transparent and keep retraining it.

For the shorthand definitions, see the expected goals glossary entry; for how xG fits into the broader system, see our methodology.

Log loss, Brier, and calibration are 5-fold, outcome-stratified cross-validated results on 16 NHL seasons (2010-11 through 2025-26), ~1.6 million unblocked shot attempts across the situation models. AUC figures are from a matched-shot head-to-head vs MoneyPuck (2024-25, ~112k shared shots), scoring both models' expected-goal values against the same actual goals; MoneyPuck's per-shot xG is taken from their public dataset (moneypuck.com/data.htm). Point-in-time as of publication; the model is retrained as new data lands.

Frequently Asked Questions

What is an expected goals (xG) model?

Expected goals assigns every unblocked shot a probability from 0 to 1 of becoming a goal based on its context — distance, angle, shot type, whether it is a rebound or rush chance, and the play leading up to it. Hockey Alchemy's model is trained on 16 NHL seasons and 1.6 million shots.

How accurate is Hockey Alchemy's expected goals model?

Cross-validated across 16 seasons, the even-strength model posts a 0.166 log loss and calibration within a fraction of a percent of perfect. On a matched-shot head-to-head — the exact same shots scored by both models — it comes in ahead of MoneyPuck on discrimination at even strength (0.81 AUC to 0.77) and overall (0.80 to 0.78), and sits in the same top tier as HockeyStats.

What is GSAx?

Goals Saved Above Expected is a goalie's actual goals allowed versus what our expected-goals model predicted — the core measure of goaltender value.

More from The Lab

Distance and Angle Aren't Enough: Why Our xG Model Splits the Ice Into Zones

A pure distance-and-angle expected-goals model looks reasonable — and quietly misprices the whole ice. We map exactly where it goes wrong, then show how zone, rush, and prior-event features lift out-of-sample AUC from 0.70 to 0.84.

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.

Why Public Hockey Analytics Is Imperfect

An honest accounting of where public hockey analytics hits a wall — the data ceiling (the feed records no passes, tracking drifts, private camera models see more) and the bigger problem now: a discourse that shares cards without meaning, rewards certainty over nuance, and stopped teaching. With the Zach Hyman case as the tell.