xJawn NHL expected goals

← Blog

Blocked shots, part 2: putting them back where they came from

The NHL records a blocked shot where it died, not where it was born. How we recover the true origin from Edge tracking — and the two ways the fix fooled us before it worked.

Part 1 ended at the problem: blocked shots are worth scoring, but the league records them where they died — at the blocker — not where they were born. To score a blocked shot you need its origin, and the origin is simply not in the data. This post is how we get it back: where the ground truth comes from, the model fitted on it, and — because both stories are instructive — the two ways the fix fooled us before it worked.

The ground truth hiding in goal clips

The NHL's Edge tracking system watches every game — puck and skaters, frame by frame — but the raw feed is not public. What does surface is a window around each goal: positions for the puck and all skaters at roughly 10 frames a second, in the same coordinate frame the play-by-play uses, reaching back about 14 seconds before the goal. And when a blocked shot happens to fall inside that window, it comes with tracking attached — which means that for a slice of blocked shots, the true origin is sitting there, waiting to be read off.

Three seasons of goal clips (2023-24 through 2025-26) contain 3,190 such pre-goal blocks. For each one we extract an origin: find the frame where the puck reaches the recorded block location, find the release — the moment puck speed jumps to shot speed — and take the last frame the puck is still on the shooter's stick. That final step-back matters: at 10 frames a second, the first frame of flight is already about 8 feet downrange of the stick. Each extraction then has to pass quality gates — the puck genuinely approached the recorded block point, it was released toward the net, the origin is inside plausible shooting range — and 1,347 clean ground-truth origins survive: 771 forward shots, 576 defenseman shots.

Animated tracking clip of a forward carrying the puck in and shooting into a block. A green marker shows the extracted release point on the shooter's stick, and a gray X marks where the play-by-play recorded the block, much closer to the net.
The extraction, on one forward's carry-in. The puck is dark while on the stick and turns red the instant it flies. Green = the origin we extract, the stick at release; gray ✕ = where the play-by-play recorded the block. The gap between them is the whole problem this post solves.
Horizontal bar chart of extraction yield: 3,190 pre-goal blocks with a clip and feed, 2,866 where the extraction returned an origin, 1,347 passing all quality gates, split 771 forwards and 576 defensemen.
The yield funnel. Losing more than half the candidates to quality gates is the point, not a flaw — a supervised fit wants clean labels, and every rejection was flagged by a diagnostic rather than passed through silently.

The caveat, stated up front: pre-goal blocks are a biased sample — a goal follows within seconds, so they skew slightly closer to the net than blocks at large (median 21 vs 24 feet recorded). We fit the conditional relationship — origin given block location and shooter role — so that marginal skew does not bias the curve; and where far blocks are sparse (beyond ~50 feet) the model clamps rather than extrapolates.

The interpolation

Two empirical facts make the model almost embarrassingly simple. First, the true origin lies essentially on the ray from the net through the block point — the blocker stands between the net and the shooter, and the median angular deviation in the ground truth is about 4°. So the two-dimensional question collapses to one dimension: how far back along the ray? Second, that distance is monotone — the farther out the block was recorded, the farther out the shot originated. So the whole model is one monotone curve per shooter role, mapping block distance to origin distance, fit on the ground truth; the origin is placed at the predicted distance along the net-to-block ray.

Two rink diagrams, forwards and defensemen, with an arrow from each recorded block cell pointing to its imputed origin. Arrows point away from the net everywhere, and near-net blocks for defensemen are thrown back to the blue line.
"A block here originates from there." Dot = recorded block location, arrow tip = imputed origin. The dramatic part is the near-net pull-back on the defensemen's side: a D block recorded in the slot is thrown all the way back to the point — exactly the sparse-data, big-impact case that matters most.
Animated tracking clip of a defenseman point shot blocked near the net. A gray X marks the recorded block 12 feet out, a green dot the tracked origin 52 feet out at the blue line, and a blue diamond the model's predicted origin at 48 feet, close to the green one.
The curve on one real play — and this is the whole trick, because the model never sees the tracking. Gray ✕ = the block as recorded, 12 feet out. Blue diamond = where the imputation puts the origin knowing only that; green = the tracked truth it was never shown. 48 feet against 52. The remaining offset is lateral, not radial: the curve predicts distance along the net-to-block ray, and the ray assumption gives up the few degrees the real shot sat off it.

Validated held out against origins the fit never saw, the correction roughly halves the error: defensemen 32 → 17 feet RMSE against the no-correction baseline, forwards 27 → 19. The residual ~17–19 feet is irreducible per-shot scatter — block distance alone cannot pin an origin exactly — and the curve targets the conditional mean, which is what a location feature needs.

What it does at corpus scale

Distance histograms for forwards and defensemen: recorded block locations near the net in gray, imputed origins farther out in green with tall spikes, and the all-unblocked distribution shaded. Defensemen move from a 27-foot median to 51, against an unblocked median of 53.
Recorded block location (gray), imputed origin (green), and every unblocked shot (shaded), by role. Defensemen: recorded median 27 feet, imputed 51, against an unblocked median of 53 — the point shots go back to the point. The tall green spikes are the conditional-mean curve shown for reference; the shipped imputation draws from the conditional distribution instead, for reasons this post gets to below.

The defensemen land almost exactly on the unblocked distribution. The forwards look like a bug: imputed median 42 feet, when unblocked forward shots have a median of 26. We stared at that number for a while. It is real, and the resolution is the single most useful fact in this series for reading blocked-shot data:

Three panels: extracted true origin distance versus recorded block distance for forwards, showing far origins even for close blocks; model-imputed origins matching the extracted ground truth at medians 42 versus 40; and relative block propensity rising about 25-fold from the closest origin bin to the farthest.
Left: the ground truth itself — model-free Edge extractions — shows forward blocks originating far out even when the block was recorded close. Middle: the model's imputed origins (median 42) sit on top of the extracted truth (median 40); it is being faithful, not overshooting. Right: the mechanism — block propensity rises roughly 25× from the closest origins to the farthest.

A long shot has a long path through traffic, so the blocked population is enriched in far origins — comparing blocked forwards to all unblocked forwards is comparing two different populations. (Defensemen show only a ~1.6× rise: they shoot from the point regardless, so there is little dynamic range for selection to act on.) This is also part 1's selection-bias argument, now with its coefficient attached: blocks are about as far from a random sample of attempts as it is possible to be.

Twice fooled, twice fixed

Between fitting this model and trusting its output, the xG model downstream caught us twice. Both incidents are worth telling on ourselves, because they share a moral.

The fingerprint. The mean curve maps a whole band of block distances onto a narrow band of origins — those tall green spikes above. That collapsed thousands of blocked shots onto a thin manifold of coordinates that no real shot ever occupies. And since a blocked shot is never a goal, the xG model learned to recognize the manifold — from coordinates alone it could identify blocked shots at 0.87 AUC — and pushed their xg below the true danger at release, which is precisely the number we built all this to estimate. The fix: draw each origin from the conditional distribution instead of the mean — per-shot noise sized by the held-out error, seeded per shot so the pipeline still rebuilds bit-identically.

The center-line trough. Subtler and stranger: the model scored dead-center shots as almost never blocked — at 40 feet, straight down the middle, P(block) came out 0.07 while five feet to either side sat at ~0.56. Defenders do not part like a curtain on the center line; this was an artifact. The play-by-play logs coordinates on an integer-foot grid, and dead center is the modal lane, so real shots pile up at exactly y = 0 — while our continuous imputed origins landed there 140× less often. Imputed blocks were the corpus's only continuous coordinates, so the razor-thin bin at y = 0 was almost entirely unblocked shots, and the model concluded "dead center ⇒ never blocked."

Histogram of lateral shot coordinates: real shots spike on every integer foot with a tall pile at zero; continuous imputed origins are smooth and hollow at zero; grid-snapped imputed origins restore the integer spikes.
Real shots (dark) spike on the integer grid, tallest at dead center. The continuous imputed origins (red) are smooth — they never share a bin with real shots. Snapping the imputed origin to the same 1-foot grid (blue) puts blocked shots back where real shots live; the retrained model's center-line P(block) went from 0.07 to 0.50.

The shared moral: the imputation must not give blocked shots a signature. A gradient-boosted model will find any bookkeeping difference between blocked and unblocked coordinates — a too-regular manifold, a missing grid artifact — and read it as hockey. Both fixes are the same fix: make imputed coordinates statistically indistinguishable from recorded ones in every respect except the one thing being corrected.

Where this leaves us

Every blocked attempt in our tables is now scored from its imputed origin: a point shot blocked in the slot is priced as a point shot, drawn from the distribution the ground truth supports, on the grid the league records. That closes part 1's data problem — and it opens a door. Once the block stage of the game has honest geometry, you can start asking questions that were previously unanswerable: whether getting a shot through is its own skill, whether stopping one is, and what either does to the way we score a shot in the first place. How to incorporate that into the model is the work we are on now, and we will write it up when it holds.

Deeper reading