Skip to main content

Expected goals on an NHL rink

A shot from the slot goes in far more often than one from the point. Expected goals (xG) puts a number on that: the chance an average shooter scores from where, and how, a shot was taken. This recipe maps the 2025-26 NHL regular season onto sportypy's rink with three SportsDataverse packages:

  • sportsdataverse-py loads fastRhockey's play-by-play, whose xg column is fastRhockey's expected goals model,
  • sportypy draws the rink and bins the shots with heatmap,
  • sdvplot supplies team colors and logos.

To run it yourself: pip install sportypy sportsdataverse "sdvplot[surfaces]", or from a clone of the repository, uv run --group cookbook jupyter lab.

Open in Colab · View the notebook on GitHub

import matplotlib.pyplot as plt
import numpy as np
import polars as pl
import sportsdataverse.nhl as nhl
from matplotlib.colors import TwoSlopeNorm

import sdvplot
from sportypy.surfaces.hockey import NHLRink

# print tables as markdown, so the docs site shows them as tables
pl.Config.set_tbl_formatting("ASCII_MARKDOWN")
pl.Config.set_tbl_hide_column_data_types(True)
pl.Config.set_tbl_hide_dataframe_shape(True)
SEASON = 2026 # this loader names a season by the year it ends: 2026 is 2025-26

1. Every unblocked shot, at one net​

fastRhockey's x_fixed and y_fixed are in feet on the NHL rink's frame, the one sportypy draws, with the origin at center ice. Shots on goal, misses and goals are the unblocked attempts (a blocked shot is recorded where it was blocked, not where it was taken). Empty-net goals are left out: they say nothing about shooting. Mirroring the shots at the left net (flipping both signs) puts every attempt at the right one.

pbp = nhl.load_nhl_pbp_lite(seasons=[SEASON])
shots = (
pbp.filter(
pl.col("event_type").is_in(["SHOT", "MISSED_SHOT", "GOAL"])
& (pl.col("season_type") == "R")
& ~pl.col("empty_net")
& pl.col("xg").is_not_null()
)
.with_columns(
x=pl.col("x_fixed").abs(),
y=pl.when(pl.col("x_fixed") < 0).then(-pl.col("y_fixed")).otherwise(pl.col("y_fixed")),
goal=pl.col("event_type") == "GOAL",
)
.filter(pl.col("x") > 25) # the offensive zone (the blue line is 25 ft from center)
)
games = pbp.filter(pl.col("season_type") == "R")["game_id"].n_unique()
print(f"{shots.height:,} unblocked shots and {shots['goal'].sum():,} goals in {games:,} games")
106,733 unblocked shots and 7,390 goals in 1,312 games

2. Where shots come from, and where goals come from​

Two views of the same shots on the attacking half (display_range="offense"). On the left, heatmap counts the attempts in 3-foot squares. On the right, statistic="mean" with values=goal turns each square into a shooting percentage, with squares of fewer than 25 attempts left blank. Shots come from everywhere in the zone; goals come from the slot and the crease.

fig, axes = plt.subplots(1, 2, figsize=(12, 5.5))
rink = NHLRink()

rink.draw(ax=axes[0], display_range="offense")
counts = rink.heatmap(shots["x"], shots["y"], binsize=3, cmap="Blues", ax=axes[0])
fig.colorbar(counts, ax=axes[0], shrink=0.7, pad=0.02, label="Unblocked attempts")
axes[0].set_title("Where shots come from", loc="left", fontweight="bold")

rink.draw(ax=axes[1], display_range="offense")
rate = rink.heatmap(shots["x"], shots["y"], values=shots["goal"].cast(pl.Float64), binsize=3,
statistic=lambda g: g.mean() if len(g) >= 25 else np.nan,
cmap="Reds", vmin=0, vmax=0.3, ax=axes[1])
fig.colorbar(rate, ax=axes[1], shrink=0.7, pad=0.02, label="Shooting percentage")
axes[1].set_title("Where goals come from", loc="left", fontweight="bold")
fig.text(0.99, 0.02, "Data: fastRhockey via sportsdataverse-py", ha="right", fontsize=8, color="grey")
plt.show()

NHL expected goals: Where shots come from, and where goals come from

3. Is the expected goals model calibrated?​

A good xG model is honest: of the shots it gives a 10% chance, about 10% should go in. Group the shots by their xg into ten equal-sized deciles and compare the average expected goal with the share that actually scored.

calibration = (
shots.with_columns(decile=(pl.col("xg").rank("ordinal") * 10 / pl.len()).ceil().cast(pl.Int8))
.group_by("decile")
.agg(shots=pl.len(), expected=pl.col("xg").mean(), actual=pl.col("goal").mean())
.sort("decile")
)
with pl.Config(float_precision=3):
print(calibration)
print(f"Season: {shots['xg'].sum():,.0f} expected goals, {shots['goal'].sum():,} scored")
decileshotsexpectedactual
1106730.0070.007
2106730.0150.012
3106730.0240.017
4106740.0360.027
5106730.0490.038
6106730.0640.053
7106740.0840.077
8106730.1090.101
9106730.1470.137
10106740.2370.224
Season: 8,243 expected goals, 7,390 scored

Every decile scores more often than the one before it, so the model ranks shots well. But almost every decile comes in under its expected rate: over 2025-26 the model ran about 10% high, 8,243 expected goals for 7,390 scored. The goals above expected in section 5 are measured against it, so a league-average finisher sits a little below zero.

4. One team's shot profile against the league​

Which spots does a team shoot from more often than the league? heatmap sums its values, so one call can take the difference: give each of the team's shots +1 / its games and each league shot -1 / every team's games, and each square shows how many more (red) or fewer (blue) attempts per game the team takes there than an average team. The team is the one with the most expected goals, its rink's boards painted in its sdvplot color.

team_xg = shots.group_by("event_team_abbr").agg(xg=pl.col("xg").sum()).sort("xg", descending=True)
team = team_xg["event_team_abbr"][0]
team_games = pbp.filter(
(pl.col("season_type") == "R") & ((pl.col("home_abbr") == team) | (pl.col("away_abbr") == team))
)["game_id"].n_unique()

mine = shots.filter(pl.col("event_team_abbr") == team)
x = pl.concat([mine["x"], shots["x"]])
y = pl.concat([mine["y"], shots["y"]])
weights = np.concatenate([np.full(mine.height, 1 / team_games), np.full(shots.height, -1 / (2 * games))])

primary = sdvplot.team_colors("nhl", team)
rink = NHLRink(color_updates={"boards": primary})
fig, ax = plt.subplots(figsize=(8, 6.5))
rink.draw(ax=ax, display_range="offense")
diff = rink.heatmap(x, y, values=weights, binsize=8, cmap="RdBu_r", norm=TwoSlopeNorm(0, -0.4, 0.4), ax=ax)
fig.colorbar(diff, ax=ax, shrink=0.6, pad=0.02, label="Attempts per game above the league average")
sdvplot.add_logos(ax, [12], [0], [team], league="nhl", height=0.16, zorder=30)
ax.set_title(f"{team}: the NHL's most expected goals in 2025-26 ({team_xg['xg'][0] / team_games:.2f} per game)",
loc="left", fontweight="bold")
fig.text(0.99, 0.01, "Data: fastRhockey via sportsdataverse-py", ha="right", fontsize=8, color="grey")
plt.show()

NHL expected goals: One team&#39;s shot profile against the league

5. The season's best finisher​

Goals above expected is a player's goals minus the expected goals of their shots: how many more they scored than an average shooter would have from the same chances. The leaders, with at least 100 unblocked shots:

finishers = (
shots.group_by("event_player_1_name", "event_team_abbr")
.agg(shots=pl.len(), goals=pl.col("goal").sum(), xg=pl.col("xg").sum())
.filter(pl.col("shots") >= 100)
.with_columns(above=pl.col("goals") - pl.col("xg"))
.sort("above", descending=True)
)
with pl.Config(float_precision=1):
print(finishers.head(8))
event_player_1_nameevent_team_abbrshotsgoalsxgabove
Anthony ManthaPIT2023218.113.9
Morgan GeekieBOS2623824.713.3
Filip ForsbergNSH3733726.510.5
Cole CaufieldMTL3675040.29.8
Sidney CrosbyPIT2202820.08.0
Wyatt JohnstonDAL2924436.17.9
Adrian KempeLAK3053426.27.8
Pavel ZachaBOS1912820.27.8

His shots, each sized by its expected goal and the goals filled in his team's primary color:

top = finishers.row(0, named=True)
his = shots.filter(pl.col("event_player_1_name") == top["event_player_1_name"])
color = sdvplot.team_colors("nhl", top["event_team_abbr"])

rink = NHLRink()
fig, ax = plt.subplots(figsize=(8, 6.5))
rink.draw(ax=ax, display_range="offense")
for scored, face, label in ((False, "none", "No goal"), (True, color, "Goal")):
group = his.filter(pl.col("goal") == scored)
rink.scatter(group["x"], group["y"], s=group["xg"] * 600 + 10, facecolors=face, edgecolors=color if scored else "#555555",
linewidths=0.8, alpha=0.8, label=f"{label} ({group.height})", ax=ax)
sdvplot.add_logos(ax, [12], [0], [top["event_team_abbr"]], league="nhl", height=0.16, zorder=30)
ax.legend(loc="upper center", bbox_to_anchor=(0.5, 0.02), ncols=2, frameon=False)
ax.set_title(f"{top['event_player_1_name']}: {top['goals']} goals from {top['xg']:.1f} expected, 2025-26",
loc="left", fontweight="bold")
fig.text(0.99, 0.01, "Data: fastRhockey via sportsdataverse-py", ha="right", fontsize=8, color="grey")
plt.show()

NHL expected goals: The season&#39;s best finisher

Where to go next​

  • The NHLRink reference lists every feature color_updates can repaint, and the plotting reference every option of heatmap, hexbin, contour and scatter.
  • sportypy draws the other leagues' rinks too (AHLRink, PHFRink, IIHFRink, NCAARink and more); their frames match, so the recipe needs only a different rink and data source.
  • sdvplot's surfaces and shot charts cookbook draws a single player's goals on a team-colored rink in one call.