What every column means
Full transparency on every metric on the site — formulas, implementation choices, and the fit statistics behind the regressions. Updated live from the database.
The corpus
Everything on the site is computed from the same raw substrate. Here is what's currently loaded:
Loading…
Data comes from the public NHL API. Regular season and playoffs are kept apart: every table opens on the regular season, and the Playoffs switch shows the postseason on its own, never pooled into the season line. Playoff shots are scored by the same xG model, which is fitted on regular-season shots only, and they feed the talent estimates. Net Rating and GSAx+ are regular-season measures and read “-” for playoff games. A nightly timer scrapes and recomputes the games played so far, so the date above is the honest extent of what is loaded -- not necessarily last night, if the timer skipped a night or a game feed lagged.
Why only 2023-24 onward?
The site publishes 2023-24 onward. The database goes back to 2010-11 and the models are fit over all of it — but those older seasons are deliberately not served, and the reason is measurement, not storage.
The boundary is 2023-24, but not because the chips went in then. The NHL puts puck-and-player tracking at fully operational in 2021-22, and our own shot coordinates show no break at 2023-24 to contradict that — the number of distinct coordinate values and the average shot distance both drift rather than jump.
What changes in 2023-24 is what the league records. Missed shots go from a steady ~27% of all attempts through 2021-22 to 30.5% in 2023-24, 33.1% the year after and 33.7% now — and the extra ones are not systematically longer or shorter, so it is a matter of more attempts being written down rather than a different kind of attempt. That changes the population a shot model is fit on, which is reason enough to split it, and it is the honest reason rather than a tidier one about telemetry. Success Above Expected inherits the boundary directly — it is defined on xgf_shooter_adj − xga_shooter_adj, the headline xG pairing, whose underlying model is fit on shots from 2023-24 onward, so compute_sae.py refuses to compute it for earlier seasons. Publishing 2014-15 next to 2024-25 would put two different measurement regimes in the same column and invite comparisons the data does not support.
The older seasons still do real work: they train the pre-chip xG model, and they feed the multi-year history behind shooter and goalie talent posteriors. They are just not something we put in a sortable table next to the modern ones.
Corsi and on-ice goals
The classic on-ice possession proxies. While a player is on the ice at 5v5, every shot attempt the team takes is a Corsi-for (CF), every attempt the opponent takes is a Corsi-against (CA). Goals-for and goals-against (GF / GA) are the actual outcomes.
CF% = CF / (CF + CA) GF% = GF / (GF + GA)
Implementation note: a shift's "for" / "against" attribution uses the half-open interval (start, end]. A shift ending exactly at the moment of an event still counts as the outgoing line on for that event; an incoming line starting at the same instant does not. This avoids double-counting at line-change boundaries, which matters most for goals: a goal stops play, so its timestamp is at once the last second of the shifts of everyone on the ice and the recorded first second of the shifts of everyone coming over the boards. Measured against an independently-built on-ice matrix, league goals-for and goals-against each match truth to within 0.1%.
Strength state is parsed from the NHL situation code (4-digit string: away goalie, away skaters, home skaters, home goalie). Empty-net states are excluded from 5v5 and the special- teams buckets.
Expected Goals (xG)
A logistic regression fit on every Fenwick shot in the corpus. Target is goal vs. not-goal. Features are geometric and contextual: distance and signed angle to net — signed because a shot from behind the goal line is not the mirror image of one from the slot — their interaction, a 26-column tensor-spline surface over distance and angle (danger is a surface over the two, not a line in each), one-hot shot type, skater-pair strength state with its own geometry deviations, and prior-event context (93 features total: 67 base features plus the 26-column spline block). The fit gives a base logit per shot; on top of it sits E, the league scoring environment for that season, and then the four xG flavors layer player identity on via Extended Kalman Filter talent posteriors. E is not optional garnish — it is the anchor that keeps the shooter- and goalie-adjusted variants honest, and it is present in all four. "Unadjusted" means no talent adjustment, not no environment: raw xG is shot quality for an average shooter against an average goalie in that season's environment, which is why raw alone runs a few percent under the power play — power-play shooters are not average, and only the shooter-adjusted variant knows that.
xg_unadjusted = sigmoid(base_logit + E) xg_shooter_adj = sigmoid(base_logit + E + μ_shooter) xg_goalie_adj = sigmoid(base_logit + E − μ_goalie) xg_fully_adj = sigmoid(base_logit + E + μ_shooter − μ_goalie)
Base model diagnostics: test log-loss 0.2271, Brier 0.0612, ROC AUC 0.740, ECE 0.0069. Empty-net shots — which convert at roughly 55% and are trivially easy to separate from a shot facing a goaltender — are excluded from the model entirely, so this AUC reflects only the hard part of the problem rather than being inflated by the easiest calls. For the complete walk-through — features, calibration curves, feature ablation, EKF equations, and cross-site validation — see the Expected Goals writeup on the Writeups tab.
Shooter & goalie talent (Bayesian)
Per-player latent talent is tracked over time as a posterior on the logit scale, walked forward alongside the league environment term described above. After every shot the base model was trained on (the is_fenwick_for_xg mask — empty-net goals, shootout attempts and penalty shots do not update anyone, since the base model never saw them either) we update the shooter, the goalie, and Etogether. Block events are excluded from talent updates (a block never reaches the goalie). Between updates, variance inflates by a small per-shot drift to model real-world talent change.
Prior: β₀ ~ Normal(μ₀, σ²₀)
Walk: β_t ~ Normal(β_{t−1}, σ²_walk)
Update: p = sigmoid(base_logit + E + μ_shooter − μ_goalie)
w = p · (1 − p)
σ²_new = 1 / (1/σ² + w)
μ_new = μ + σ²_new · (y − p)Update for the goalie uses the opposite sign so positive goalie_talent means a better save-rate. Once per season boundary, shooter and goalie talent are re-centred — chance-weighted, in the odds domain, over the trailing year of shots — so the league's active shooters and goalies each net to zero. Whatever that centring removes is not discarded; it moves into E, so every already-computed probability is unchanged at the instant of the re-frame. Within a season the frame is fixed, so a player's in-season trajectory reflects only their own shots.
Hyperparameters
- μ₀ (shooter)
- −0.05 (a little below the chance-weighted average of active shooters)
- σ²₀ (shooter)
- 0.04 (SD ≈ 0.20 logits)
- σ²_walk (shooter)
- 1e-5 per shot taken
- μ₀ (goalie)
- −0.05 (replacement level, relative to the chance-weighted active league)
- σ²₀ (goalie)
- 0.0025 (SD ≈ 0.05 logits)
- σ²_walk (goalie)
- 3e-7 per shot faced (treats goalie talent as near-static across seasons)
- σ²_max
- 0.5 (variance cap)
The goalie prior anchors to the environment term E rather than an arbitrary zero, so μ₀ = −0.05 describes replacement level relative to the chance-weighted active league, not an absolute save rate. Prior mean, prior variance and walk are swept together, chosen on the pre-chip-tracking era and confirmed on the chip-tracking era, and tuned to maximize next-season predictive calibration — how well a season-end posterior forecasts next season's realized conversion rate — rather than only year-over-year rank stability. That is why both variances sit tight and both walks are small: a Bayesian posterior should be smoother than the raw rate it is drawn from, not noisier.
At each offseason boundary the raw posterior mean also shifts by a measured age-drift curve — fit from within-player season-over-season conversion deltas in the pre-chip era and checked against the chip era — applied at full strength for goalies and at half strength for shooters, since the filter already tracks a shooter's decline from their own shots and the full curve would double-correct. The shift lands before the offseason variance bump, so it moves a posterior that is still as tight as it was in April, not one already widened for the summer.
xGF & xGA: the shooter-adjusted flagship
Our headline shot-value stat, and — after three seasons of trying to improve on it — the plainest thing in the lineup. It is not a weighted blend of anything. It is expected goals, scored two different ways depending on which side of the ice you're looking at:
xGF = shooter-adjusted xG (credit for your own shooters) xGA = shooter-adjusted xG (debit for their shooters) xG% = xGF / (xGF + xGA) · 100
Those are variant selections, not coefficients — xGF and xGA each pick a different one of the underlying xG model's outputs, rather than summing several of them together. Both sides are shooter-adjusted, symmetric. The alternative — leaving the defensive side unadjusted, on the theory that adjusting xGA for opponents' shooter talent mostly imports strength of schedule rather than measuring defence — does not hold up empirically: shooter-adjusting the defensive side performs about the same at all situations and 5v5, and meaningfully better on the penalty kill. It also has a real hockey story behind it — a good defence works to deny dangerous shooters good looks, so someone worse ends up shooting, and shooter-adjusted xGA is the version that credits that suppression. All four variants also carry the season's scoring environment on top of the base shot-quality model, so a season with more (or less) offense league-wide is reflected the same way in every variant — the (sh) / (sv) / (sh+sv) suffixes describe talent adjustments only. Full numbers are in the writeup.
The choice of variant comes down to one question: whose personnel does this adjustment encode, and do they persist? Shooter adjustment on xGF encodes your own shooters; on xGA it encodes theirs. Goalie adjustment on xGA encodes your own goalie; on xGF it encodes theirs. For a predictive use, keep personnel that persists and drop personnel that turns over — goalies stick with a team, shooters and opponents don't. All four xG variants (shooter-adjusted, goalie-adjusted, fully-adjusted, and plain) stay on the site as opt-in columns; which one is right depends on what you're asking. Never judge a shooter against a baseline that contains his own finishing, and never judge a goalie against one that contains that goalie — both are circular.
One caveat worth keeping in view: a skater's own xGF is not purely a shot-generation number. Shooter adjustment is applied at the shot level using whoever actually took it, so a top line whose finisher scores on low-quality chances lifts the whole line's on-ice xGF — including the playmakers who never touched the puck after the entry. Some of what looks like shot creation is a linemate's finishing talent riding along.
The name is worth separating from the metric. Tom Tango proposed weighted shots in 2014 as goals plus a flat one-fifth credit for every other attempt — no shot quality at all. What ships under xGF and xGA here is not that: it is plain expected goals, unweighted, which outperforms every weighted-shots-style blend tested against it, including talent-adjusted versions built on top of this same xG model. For the full accounting, see the writeup on the Writeups tab.
Net Rating (Off_RTG / Def_RTG / Net_RTG)
A per-game Goals Above Average rating built directly from Dom Luszczyszyn's published 2019 formula plus his recent confirmation of the offense / defense scaling factors. For the full walk-through — formula derivation, position weights, worked example, and live decomposition charts — see the Net Rating writeup on the Writeups tab.
Goalies skip all of this. Their Net Rating is GSAx — goals saved above expected on the shooter-adjusted xG model, all strengths — summed per game and projected to 82, which is how Dom rates them too. It is on the same goals scale as the skater number but built from a different quantity, so a goalie's +20 and a centre's +20 are not the same claim.
Step 1 — split Game Score
Standard Luszczyszyn Game Score components are split into offense and defense. Faceoff differential (0.01 weight) is left out. We record every faceoff with both participants and publish faceoff percentage, but the term needs each player's wins minus losses per game, and we do not compute that. At a 0.01 weight the omission is small. A flat 0.01 per draw treats an offensive-zone and a defensive-zone draw alike, which matters in principle. Faceoffs are not ignored on the site: the Quick Draw badge rates a centre's draws against the opponents he faced, weighted by what each draw was worth, and that rating still correlates 0.97 with raw faceoff percentage, reordering almost nobody.
individual_off_gs = 0.75·G + 0.7·A1 + 0.55·A2 + 0.075·SOG + 0.15·iPenDrawn individual_def_gs = 0.05·iBLK − 0.15·iPenTaken
Step 2 — on-ice play-driving (5v5)
The 2019 overhaul replaced the 0.05·CF / 0.15·GF on-ice differentials with much heavier xGF / GF / xGA / GA terms, ramped per position. We measure each as the player's actual count this game minus what we'd expect for that TOI under a 50/50 mix of league and team rates per 60 (relative-to-average + relative-to-team):
expected_xgf = (0.5·league_xgf60 + 0.5·team_xgf60) · TOI/3600 rel_xgf = on-ice xGF this game − expected_xgf (and same for gf, xga, ga)
One asymmetry: expected_xga and expected_ga — the against-side pair only — carry a further ice-time pace factor on top of the team-pace blend above, raising the expectation for players who play heavier minutes than their position average. See “QoC/QoT, retired” below for the constants and why only the against side gets it.
Position-specific weights from the 2019 article (Forwards / Defensemen):
| Position | xGF | GF | xGA | GA |
|---|---|---|---|---|
| Forward | 0.625 | 0.625 | 1.75 | 0.4375 |
| Defense | 1.7 | 0.425 | 2.3 | 0.575 |
on_ice_off_gs(F) = 0.625·rel_xgf + 0.625·rel_gf on_ice_def_gs(F) = −1.75 ·rel_xga − 0.4375·rel_ga on_ice_off_gs(D) = 1.7 ·rel_xgf + 0.425 ·rel_gf on_ice_def_gs(D) = −2.3 ·rel_xga − 0.575 ·rel_ga
Step 3 — QoC/QoT, retired
Through 2026-08 this step was a per-game multiplier — credit nudged up when a player's opponents were heavier-minute than their teammates, trimmed when the opposite was true. The 2026-09 calibration sweep tested it three ways and found it predictively inert to slightly harmful, plus a face-validity failure (it charged stars for having good linemates). It has been removed. Full writeup, including what replaced it — a narrower ice-time pace adjustment folded into Step 2's expected_xga above, not a separate multiplier here — is in “QoC/QoT, retired” in the Net Rating writeup. That test was of our multiplier only. A second, pre-registered test in 2026-09, of an offense/defense context adjustment built a different way, is described in the same section of the writeup; the rating still carries no quality-of-competition adjustment.
Step 4 — final scaling
Subtract the position baseline (mean GS per game across F or D skaters with ≥10 GP that season), divide by total-GS-per-total-goals (≈ 2.6, computed per season), and apply Dom's offense / defense scaling factors:
Off_RTG (per game) = (off_gs_game − pos_off_avg) × 0.75 / (totalGS/totalGoals) Def_RTG (per game) = (def_gs_game − pos_def_avg) × 0.78 / (totalGS/totalGoals) Net_RTG/g = Off_RTG + Def_RTG Net_RTG (season) = Σ Net_RTG/g · 82 / GP
Per-game ratings live on computed_stats (strength="all" row), so a rating over any stretch of games is the SUM of its per-game values, and the rate toggle projects to 82-game scale via the standard ×82/GP path. The 82 is a rate convention, not the season's length: 2026–27 is an 84-game season under the new CBA, and a rating is still quoted per 82 so seasons compare.
Loading…
Caveats
- Defensemen run slightly over-corrected by the ice-time pace adjustment on
expected_xga— Def now correlates +0.13 with TOI for defensemen, versus 0.00 for forwards. Within noise; being watched, not yet re-fit. - Team pace (a team's overall xG production) is handled by the team-relative half of the
rel_*calculation in Step 2. Individual ice-time pace (a player's own minutes) is the separate against-side factor described in Step 3. They are two different adjustments; don't conflate them.
Estimated Net Rating
A forecast of a skater's Net Rating, made before the first game and moved by every game after it. It starts from the last three seasons (each aged to this one and weighted by its games), pulled toward the average of players with that much history, and it hands over to the season's own games at a rate set by how much talent actually moves between seasons. Two numbers come out of it, kept apart on purpose: the talent (the per-game rate we think he produces at, times 82) and the year-end projection (the games already played plus the talent for the rest). The cards and the lineup print the year-end number with its 80% band, and the band is the predictive one: it allows for the noise of the games still to come, and it was checked against realised seasons. For the derivation, the holdout against the obvious baselines, the level check and its known misses, see the Estimated Net Rating writeup.
Goalies are not forecast here; the lineup card gives them their next-season forecast GSAx, scaled to 82 games at the league's measured shots faced per game.
Shooting metrics
The Snipers report puts a shooter's goals next to their individual expected goals on each model and takes the difference:
- iXG
- Individual expected goals: the sum of xG over the player's own shots. The suffix names the model, same as on-ice xG. None for raw shot quality, (sh) shooter talent, (sv) goalie talent, (sh+sv) both.
- GAx
- Goals minus iXG on the raw model. Finishing above what shot quality alone predicts; a good shooter should run positive here for a career.
- GAx(sh)
- Goals minus iXG(sh). Finishing above what the shooter's OWN talent posterior and shot quality predict. This is the regression column: a large positive is a hot streak the model does not believe in, a large negative is a slump on a shooter it still rates.
- GAx(sv), GAx(sh+sv)
- The same difference against the goalie-adjusted and fully adjusted models, for readers who want the goalies faced accounted for.
GAx is not GSAx with the sign flipped: GSAx uses the on-ice xGA a goalie faced, GAx uses only the shots the skater took. The shooter's talent posterior itself, the thing GAx(sh) is measured against, is not a column on the Skaters table. It has its own dashboard, Shooter/saver talent, with its uncertainty and the year of shots behind it.
Goalie metrics
Goalie columns are split across three presets — Value, Stop rate and Tiers. As with xG, the suffix says what has been adjusted for: (sh) shooter talent controlled,(raw) nothing controlled.
- GSAx(sh)
- xGA on the shooter-adjusted xG model − GA. Positive = stopped more than the shot-quality-and-shooter-talent model expected. The headline goalie number.
- GSAx(raw)
- xGA on the raw (unadjusted) xG model − GA. The same idea without controlling for who was shooting.
- GSAx+
- IQ-scaled (mean 100, SD 15) version of GSAx(sh), computed across the full league of goalies for the season (≥10 GP). 100 is league average; useful for comparing goalies across seasons of different shot-quality environments.
- Save talent
- Latest Bayesian posterior mean for the goalie, on logit scale, relative to the active league and re-centred each season. Positive = above-average save rate given shot quality.
Goalies also get a per-player profile chart (Implied Save Talent) analogous to the shooter version, plotting the posterior mean ±1σ ribbon over the games they've faced.
Stop rate
Fenwick save percentage instead of the traditional shots-on-goal save percentage — it counts missed shots along with shots on goal, which is the more stable denominator and the one the rest of the site's on-ice numbers already use.
- Fsv%
- 1 − GA/FA. Actual Fenwick save rate, no model involved.
- xFsv%(sh)
- 1 − xGA(sh)/FA. What the shooter-adjusted xG model expected the goalie's Fenwick save rate to be, given the shots and shooters faced.
- dFsv%(sh)
- Fsv% − xFsv%(sh). The same signal as GSAx(sh), expressed as a rate instead of a count — positive means outperforming the shot-and-shooter-quality model.
- Fsv%(raw), xFsv%(raw), dFsv%(raw)
- The same three, built on the raw (unadjusted) xG model instead.
Tiers
The Save talent column is a posterior mean; the walker also carries a posterior SD, and the Tiers preset draws the two together. Each row gets a small graph on one shared logit axis: a thick bar for the mean ±1σ, a hairline for ±2σ, a dot at the mean and a vertical tick at zero, the league mean. The letter is read off the ±1σ interval [lo, hi] against that tick, where “mostly” means more than three-quarters of the interval on one side:
- A
- Interval entirely above the league mean: lo > 0.
- B
- Mostly above: lo ≤ 0 but the mean sits more than half an SD above zero (mean − σ/2 > 0).
- C
- Straddles: the mean is within half an SD of zero (|mean| ≤ σ/2).
- D
- Mostly below: the mean sits more than half an SD below zero, but hi ≥ 0.
- F
- Interval entirely below the league mean: hi < 0.
- SD
- The posterior standard deviation itself, on the same logit scale as Save talent.
A letter is a statement about confidence as much as ability: a starter with a long record and a C is genuinely average; a call-up with a C is unknown. The graph is there so the two are not confused.
Selke race
The Selke Trophy goes to the league's best defensive forward, and the case for a candidate is usually a mix of eyeball, faceoffs, and penalty-kill minutes — three things a box score reports and none of which is actually a defensive-impact number. This preset puts those alongside the one column on the site that is: Def_RTG, the defensive half of Net Rating.
Forwards only, sorted by Def_RTG. Faceoff win % (FO%, now filed under Extras rather than Identity) is draws won over draws taken, counted from the play-by-play at the table's strength, with the counts themselves beside it as FOW and FOL (none of it derived from Def_RTG or any xG model) — it is on this preset because voters look at it, not because we think it measures shutdown play. PK TOI is minutes played shorthanded, another usage signal rather than an impact one; it now sits right next to TOI and the new PP TOI column, since all three are the same kind of number (minutes played, sliced by strength) rather than a faceoff stat. PP TOI — minutes played on the power play — isn't part of the Selke case, but it's built the same way as PK TOI and lives in the same column group for symmetry. Def_RTG is the column that answers whether the ice actually tilted the right way while they were out there.
Quality of Competition / Teammates
There are two QoC/QoT measures on this site, and they answer different questions. Keep them apart.
Competition (usage) — how heavily the opposition is played
TOI-based: for each player, compute the average game-level TOI% of their on-ice teammates and opponents across all 5v5 shifts they played, weighted by shift TOI when rolling up to (player, season). This is a deployment proxy — it measures how much a coach trusts the players you are on the ice against, not how good they are.
toi_pct[player, game] = player_5v5_toi / sum(team skaters' 5v5 toi in game) shift_qoc = mean(toi_pct of opponents on ice) shift_qot = mean(toi_pct of teammates on ice) season = TOI-weighted mean of shift values
This is the usage measure that used to be folded into Net Rating as a per-game context multiplier, from launch through 2026-08 — see “QoC/QoT, retired” in the Net Rating writeup for why that stopped in 2026-09. The measure itself is unaffected and still exported as a CSV at backend/exports/qoc_qot_<YYYYMMDD>.csv for offline analysis.
Competition (Net_RTG) — how good the opposition is
A talent proxy, and the axis the Shift Performance dashboard slides along. For each shift, average the season Net Rating of the opposing skaters on the ice; do the same over the player's own skaters for teammates. Goalies are excluded from both.
rating[player, season] = mu + (net_rating - mu) * gp/(gp + 20) shift_qoc = mean(rating of opposing skaters on ice) shift_qot = mean(rating of own skaters on ice)
The input is plain Net Rating. Until 2026-09 it was a second column, net_rating_unadj, holding Net Rating with the QoC/QoT multiplier divided back out — defining QoC in terms of a number that already contained a QoC adjustment would have been circular. With that multiplier retired the two were the same number, so the second column is gone. Ratings are shrunk toward the season mean by gp/(gp+20) before being averaged, because season Net Rating is a per-82 projection: without shrinkage a four-game call-up projects to an absurd value and swings the QoC of every shift they appear in.
Stored per shift in shift_qoc_facts (5v5, ~12.4M rows), which is what lets the Shift Performance dashboard re-aggregate a whole stat line from an arbitrary slice of shifts instead of re-fitting a model. The dashboard filters these ratings by decile — ten equal-count bands of the season's 5v5 shifts, 1 the softest competition (or weakest teammates), 10 the hardest — while the xGAx and WPAx forecasts cut the same ratings into tercilesover every 5v5, power-play and penalty-kill shift; same numbers, two grains. Both the shrinkage constant and the ≥3-of-5 rated-opponent coverage rule are provisional.
Implied Purpose
A coach does not send five players over the boards without a reason. Down one with four minutes left you get your scorers; up one with thirty seconds you get your checkers. Implied purpose is an attempt to read that intent off the game state — what the bench was trying to do — so that a shift can be judged against its own brief rather than against a league-average one.
It is the filter on the Shift Performance dashboard, and it is an inference, not a record. Nobody publishes what the coach intended. One observable thing stands in for it: how lopsided the stakes are at the moment the shift starts.
wp = (dwp_for - |dwp_against|) / (dwp_for + |dwp_against|) score = wp + 0.05 * lineup tilt (z, clipped) + 0.03 * zone start (OZ +1, DZ -1) top third of all shifts -> attack; bottom third -> defend; middle -> balanced
Leverage. From the win-probability model: how much a goal for would help, set against how much a goal against would hurt, given the score, the clock, the strength state and any penalty time still to run. Late and trailing, a goal for is worth far more than a goal against costs, and the score goes positive. Protecting a lead inverts it. Tied in the first period the two are nearly equal and the score sits near zero.
Tiebreaks. Tied, the leverage term is exactly zero — for a third of all shifts — and thirds of a number that is mostly zero would just be the sign of the score. So two small terms break the tie: who the bench sent (the lineup’s offensive-minus-defensive tilt, as a league z-score) and where the puck was dropped. Together they are worth at most eight hundredths against a leverage term that runs from minus one to one, so they settle the label only where the game state does not.
Terciles. The three labels are the thirds of that score over every shift we have, all seasons together. The two cutoffs are fixed once and stored, so an attack shift in 2011 means the same thing as one in 2026, and any one season lands a little off a third each way depending on how many close games it had. An earlier version weighted lineup and zone start at 30 and 15 percent and thresholded at a fixed value that called nine shifts in ten balanced; line composition is a consequence of intent, not a cleaner measure of it, and zone start is mostly circumstance, so both are now tiebreaks rather than signals.
What it is not. It is not a measure of what happened — a shift labelled attack that gets scored on is still a shift that got scored on. It is context for judging the result, not the result. No outside source publishes coaching intent, so there is nothing to validate this against. Treat it as a lens, and a soft one.
Shift value: xGAx and WPAx
The naming convention. Ax means above expected. GSAx is the borrowed public name — goals saved above expected — and GAx, xGAx, WPAx and SAx follow it: goals, expected goals, win probability added and success, each above what the context said to expect.
xGAx is the plainer of the two shift-value numbers. Over the selected shifts, take the on-ice shooter-adjusted xG for minus against, and subtract what the league produces in the same contexts — its xGF and xGA per second for the shift’s season, strength, zone start, score state, and terciles of competition and teammate quality, times the shift’s length. Per 60 it reads as goals per hour above the league in those spots. It is the same forecast as fWPA below, on the same context cell with the same fallback, valued in goals rather than wins — which is what lets the Shift Performance page’s competition filter be read straight: a player facing top-tercile competition is scored against what the league does against top-tercile competition, not against the league’s average shift.
WPAx asks the question a bench actually cares about: what did the shift do to the team’s chance of winning, over what a league-average shift in the same spot would have done? Three numbers, all in wins, all summed over whatever shifts you select on the Shift Performance dashboard. They are on-ice numbers, like on-ice xGF: the team’s expected change in win probability while the player was out there, not divided among the five skaters who shared it.
WP0 = win probability at the start of the shift, from this bench xWP = expected WP at the end, integrating over every chance that occurred fWP = forecast WP at the end, integrating over the chances the context predicted xWPA = xWP - WP0 fWPA = fWP - WP0 WPAx = xWPA - fWPA
The chances, not the goals. Each Fenwick shot inside the shift is a coin weighted by its shooter-adjusted xG. The end of the shift depends only on how many of those coins came up for each side, so the expectation is exact: two Poisson-binomial counts and a double sum, no sampling, no first-order shortcut. A shift with no shots still moves WP, because the clock ran in whatever score state it ran in.
The forecast. fWP does the same integration with the league’s expected-goal rates for and against in the shift’s context — season, strength, zone start, score state, and the terciles of competition and teammate quality — over the shift’s actual length. That last choice matters: both halves are valued at the same moment, so the value of time passing cancels in WPAx and nobody is credited for how long the coach left them out.
Reading it. xWPA is leverage-weighted by design: a chance in a tied third period is worth more than the same chance in a 5–1 game, and the win-probability model behind it carries the score, the clock, penalty time still to run and home ice. WPAx is the part the player’s shifts added over what league-average shifts would have, and WPAx/60 puts it per hour of ice. Positive means the shifts beat their brief.
Error bars. Shift Performance shows each number with a standard error: the spread of the per-shift residuals divided by the square root of the shift count, rescaled to per 60 by the mean shift length (for SAx, the binomial error on the success share). It treats shifts as independent draws and the league baseline as known, so it measures sample noise, not repeatability.
Why the bars shrink. A raw standard error is only what a player’s own shifts can tell you, and 150 minutes of shifts cannot tell you much: the ±2 band on a fourth-liner reached the league leaders. So the number the page shows is a posterior. The league is the prior — the selection’s mean, and the spread of its raw values once sampling noise is subtracted out (τ² = var(raw) − mean(SE²), floored at 1% of the raw variance) — and each player is pulled toward it by B = τ²/(τ² + SE²), the share of his own number that survives. Posterior mean μ + B(raw − μ); posterior sd √B · SE. The standard normal-normal empirical-Bayes estimator, nothing cleverer. A full-season player keeps four fifths of his raw value; the 150-minute player keeps a third, and his band collapses toward average, because his shifts said little and the league said a lot. The prior is estimated separately for forwards and defencemen, each toward his own group’s mean and spread, from every skater in that group who faced the selected QoC/QoT/zone/score/purpose context with at least twenty shifts — the whole context, not just the rows the page happens to be showing. Filtering to forwards, to one team, or raising the minutes floor changes which rows are on screen and never what a player’s own number is: those are population filters, not context, and they are applied only after the shrinkage. The tooltip keeps the raw number.
Status. New, and next to SAx on purpose: the two are meant to be compared, and SAx (Success Above Expected, formerly SAE) is under review. Every number inherits the win-probability model’s calibration, which is checked by score state, period, strength and home ice before anything here is published.
Badges
A badge is a claim, not a compliment: that on this season’s shifts the player is one of the top ten percent of forwards, defencemen or goalies at the thing named. He earns it by standing in the top tenth of his position group on the posterior score, and only if the probability that he really is a top-decile player at it clears the keep line — so a badge is never awarded on evidence too thin to support it, and it is kept while that probability holds. A badge belongs to a season: a new season starts from nothing, and a player’s earlier seasons are listed on his page as history.
It was a probability line before a rank: a badge went to anyone at better than even odds of being top-decile. The probabilities were right — they sum to about a tenth of every group, in every season — but the share who cleared even odds tracked how precise the badge was rather than how many players deserved it. Minute Muncher, measured at 0.99, named a tenth of the league; Motor, resting on a component that replicates at 0.6, named four in a hundred. Same claim, same calibration, a badge that appeared three times rarer for a reason that had nothing to do with hockey. The rank came first from 2026-09-23, the probability stayed as the floor under it, and the earlier line is recorded here rather than hidden.
There are three kinds, and the difference is drawn on the seal itself, because they are not the same sort of statement:
- Skill · an ink disc
- A repeatable ability: something he does, that he does again. Every component behind a skill badge replicates at 0.5 or better when a season’s games are split in half at random, and that is the only kind gated on it. It is a claim about the player. It is not a claim that the thing helps: Beanbag holders block a lot of shots, reliably, and blocking runs with shots against.
- Role · a slate plate
- How he was deployed this season, or how he played it. A fact about a season rather than a claim about the man, so it is not gated on replication, and it claims nothing about whether it works. The deployment ones are measured against his own bench, because deployment is a coach dividing one team’s ice: Hard Minutes, Minute Muncher, Spearhead, Sentry and Iron Man all ask what share of something his team gave him. Enforcer is the exception, and it is the one that is style rather than deployment — fighting, hitting, sitting in the box and being big are things a player does and is on any roster, so they are scored against the league. Some of them point the wrong way on purpose: Enforcer holders are outscored the following season.
- Outcome · a hollow ring
- What happened with him on the ice, luck included. Not what he did, and not what he can do. An outcome badge is not expected to repeat — that is what makes it an outcome — so it is not gated on replication either. Scorer counts the goals; Finisher, a skill, is the claim about whether he should have had them. Ledger and Ice Tilter are the same pair in goals and in expected goals, and the gap between them is the luck.
Badge definitions did not load. Nothing else on this page depends on them.
The full account, with the validation tables and the correlations between badges, is in the badges writeup.
One state, no tiers. Earned or not, never a letter grade, never a percentile bar. The top three in each badge and position group wear gold, silver and bronze, which is a rank among holders, not a grade. A percentile compresses the tails and invites reading a rank into a number that doesn’t support one; a badge is a yes-or-no claim with the evidence attached, and the shrinkage does the ranking’s job honestly.
Thresholds. The share and the confidence floor are the only chosen numbers in the whole system. Everything upstream of them — the shrinkage, the posterior, the sample floor — is fit, not picked. The floor is where a player’s own evidence starts to outweigh the position-group prior: below it, the badge is mostly describing the league, not the player, so it stays off rather than claim more than the shifts support.
All Three Zones microstats
Hand-tracked by Corey Sznajder for a sample of each season’s games (about a third, 2021-22 onward). Licensed to us; shown only to active patrons of his Patreon, on the All Three Zones page. Every count is 5v5 and per player.
Two families, and they answer different questions. The expected box score predicts what a player’s real NHL line should look like, and is calibrated against it. Everything below it counts what Corey’s tracking recorded. The two are not interchangeable: a pass he did not record cannot be credited to anyone, so the tracking-derived xG created columns sit about a third below real assist totals by construction. That is a property of the data, not a flaw in it, and it is why these are separate sections rather than one.
- xG
- Expected goals from the player's own 5v5 shots, calibrated against goals actually scored.
- xA1
- Expected primary assists, calibrated against real primary assists.
- xA2
- Expected secondary assists. The noisiest of the family (season r 0.81) -- a secondary assist depends heavily on who else was on the ice.
- xA
- Expected assists, primary and secondary together.
- xP
- Expected points: goals plus both assists. The 80% band contains the player's real 5v5 point total about four times in five.
- xP1
- Expected primary points: goals plus primary assists, the version that survives secondary-assist noise.
The expected box score is a full-season projection, so it does not change with the tracked/estimated toggle. Each cell shows an 80% interval containing the player’s real total about four times in five; the point estimate is on hover and is what sorts. It is left blank below five tracked games, where it runs about 15% high — an empty cell rather than a number we know to be wrong.
- Entries
- Offensive-zone entries by the player, any type (carry, pass, dump).
- Carries
- Entries with possession: carried or passed across the blue line.
- Carries w/ chance
- Controlled entries that produced a scoring chance.
- Dump-ins w/ chance
- Dump-in entries that produced a scoring chance.
- Failed entries
- Entry attempts broken up at the line.
- Targets
- Entry attempts against the player as the defender.
- Denials
- Entry attempts the player broke up at the line.
- DZ retrievals
- Defensive-zone puck retrievals.
- Retrievals → exit
- Retrievals that led directly to a zone exit.
- Botched retrievals
- Retrievals the player lost.
- Recoveries
- Opponent dump-ins the player recovered on the forecheck.
- Exits
- Defensive-zone exits by the player, any type.
- Exits w/ possession
- Exits that kept possession: carried or passed out.
- Failed exits
- Exit attempts that turned the puck over in the zone.
- Shots
- Shot attempts, as Corey records them.
- Shot assists
- Primary passes that led to a shot attempt.
- Chance assists
- Primary passes that led to a scoring chance.
- xG created (1st pass)
- The expected goals on the unblocked shots the player set up with the primary pass, counted where Corey recorded him as the passer. Joined through the shot, not the pass -- the play-by-play records no passes at all. This is NOT expected assists: it runs about a third below real 5v5 primary assists, because a pass he did not record cannot be credited.
- xG created (2nd pass)
- The same idea one pass further back. Corey records a second passer on 37.7% of tracked 5v5 shots against 74.1% for the primary, so this is a thinner sample, a wider band, and further below real secondary assists.
- xG created (3rd pass)
- Expected goals on shots where the player made the third-last pass. No point is awarded for this in hockey. Recorded on 12.6% of tracked 5v5 shots and much the weakest of the three: on held-out tracked games a forward's season total correlates 0.56 with the truth, against 0.87 for the first pass, and his two halves of a split season agree at only 0.26. Read it as a wide-banded style indicator, not a ranking.
- Rush shots
- Shot attempts off the rush, as Corey tags them.
- Rush shot assists
- Primary passes that led to a shot off the rush.
- Forecheck & cycle shots
- Shot attempts off the forecheck or cycle, as Corey tags them.
- Forecheck & cycle shot assists
- Primary passes that led to a shot off the forecheck or cycle.
- Carries allowed
- Entries against the player that were carried in with possession.
- Passes allowed
- Entries against the player that were passed in.
- Carry chances allowed
- Carry-ins against the player that produced a scoring chance.
- Dump chances allowed
- Dump-ins against the player that produced a scoring chance.
- High-danger passes
- Primary passes into a high-danger area that produced a shot.
- Home-plate passes
- Primary passes into the home-plate area.
- Centre-lane passes
- Primary passes through the middle lane of the ice.
- Low-to-high passes
- Primary passes from below the dots back to the points.
- Behind-net passes
- Primary passes from behind the goal line.
- Neutral-zone passes
- Primary passes made from the neutral zone.
- Defensive-zone passes
- Primary passes made from the player's own zone.
- Carry %
- Share of the player's entries made with possession (carried or passed in).
- Entry → chance %
- Share of entries, controlled or dumped, that produced a scoring chance.
- Failed entry %
- Share of entry attempts broken up at the line.
- Denial %
- Share of entry attempts against the player that he denied.
- Carry-against %
- Share of entries against the player that came in with possession.
- Chance-against %
- Share of entries against the player that produced a scoring chance.
- Botched %
- Share of retrievals the player lost.
- Retrieval → exit %
- Share of retrievals that led straight to an exit.
- Possession exit %
- Share of exits that kept possession.
- Failed exit %
- Share of exit attempts that turned the puck over.
- Chance share %
- Share of the player's shot assists that set up a scoring chance.
- High-danger pass %
- Share of the player's shot assists that were high-danger passes.
- xG created per shot assist
- Average danger of the chances the player creates: expected primary assists over shot assists. Note the denominator counts every attempt he set up while the numerator counts only unblocked ones, so this reads lower than the danger of a pass that gets through.
- Rush share %
- Of the player's shots off the rush or off the forecheck and cycle, the share that came off the rush. A style number, not a quality one.
Tracked games are Corey’s raw counts over the games he tracked; per-60 and per-82 there are the player’s pace over those games only, and the Tracked and Cov % columns say how many that was. Estimated season adds a model’s expectation for the untracked games and reports an 80% interval; sorting uses the point estimate. Method and validation are in the tracking-data writeup.
Shift summaries (the foundation)
One row per (shift × player). The substrate the on-ice metrics are built on, and the foundation for upcoming features (implied- purpose scoring, net_rating_delta, win-probability-added). The schema:
shift_id, game_id, player_id, team_id, period, start_seconds, end_seconds, toi_seconds, strength_state, zone_start, score_diff_at_start, teammates_on_ice[], opponents_on_ice[], cf_for, ca, ff_for, fa, gf, ga, xgf_fully, xga_fully, xgf_*adj, xga_*adj, xgf_shooter_adj, xga_unadj, implied_purpose?, net_rating_delta? // future
zone_start resolves by matching the shift start (±2s) to the nearest faceoff event; if no matching faceoff, zone_start = "fly". On-the-fly changes are the majority (~57%); the rest split roughly evenly across NZ/OZ/DZ.
See backend/docs/shift-pipeline.md for the remaining planned extensions (net_rating_delta).
Colophon
Built on FastAPI + PostgreSQL, with the shot model fit by a hand-rolled penalised regression rather than an off-the-shelf one, and Next.js + Recharts on the front. Type set in Lora and Inter, with JetBrains Mono for tabular figures. Source available; this glossary renders live from the database, so the numbers above are the same ones the leaderboards use.