Skip to contents

PWHL expected goals (xG) is the probability that a shot on goal becomes a goal, estimated from the shot’s location, type and the play just before it. The Professional Women’s Hockey League began in 2024, and its HockeyTech feed records a location for effectively every shot. That makes a coordinate xG model possible even with only a few seasons of data.

This article explains the model behind the xg column of load_pwhl_xg_pbp(): what it predicts, its features, how it is fit and evaluated, and its open issues. Numbers in code blocks are computed when the page is built. Numbers from model development are quoted from the official model card and labelled that way.

The NHL model is a different, gradient-boosted model. See the NHL expected goals article.

Which model are you running?

PWHL xG NHL xG
Where you get it load_pwhl_xg_pbp() only load_nhl_pbp(), or computed locally by nhl_game_pbp() / helper_nhl_calculate_xg()
Model type Logistic regression, with a per-strength (EV/PP/SH) Platt recalibration XGBoost, separate 5v5 and special-teams models
Shots scored Shots on goal (saved shots and goals) Unblocked attempts (shots on goal, misses and goals)
Fit by The sportsdataverse-py Python package, at publish time Committed model files, cached by fastRhockey
Computed locally by fastRhockey? No. fastRhockey loads the published values. Yes

shot_quality is not xG. The PWHL play-by-play from pwhl_pbp() and load_pwhl_pbp() has a shot_quality column. That is HockeyTech’s own shot label, not this model. The card also describes a “shot-quality tier” proxy kept in sportsdataverse-py for API stability. It is not the production model, and two of its tiers encode whether the shot was a goal, so do not use it as a predictor.

What does the model predict?

One number per shot on goal: the probability it is a goal. The published file has one row per shot, with the game, shooter, goalie, clock, x_coord / y_coord, shot_distance, shot_angle, shot type, power-play / shorthanded / empty-net / penalty-shot flags, the outcome (goal) and xg.

library(fastRhockey)
library(dplyr)
library(ggplot2)

seasons <- 2024:2026
pwhl_xg <- lapply(seasons, function(s) {
  load_pwhl_xg_pbp(seasons = s) %>% mutate(season = s)
}) %>%
  bind_rows()

glimpse(pwhl_xg)
#> Rows: 18,031
#> Columns: 22
#> $ game_id        <int> 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2…
#> $ game_season    <int> 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2…
#> $ game_date      <chr> "2024-01-01", "2024-01-01", "2024-01-01", "2024-01-01",…
#> $ team_id        <int> 4, 4, 4, 4, 4, 6, 6, 6, 6, 4, 4, 4, 4, 4, 4, 6, 6, 6, 6…
#> $ player_id      <int> 44, 36, 46, 86, 38, 128, 126, 101, 100, 34, 44, 147, 36…
#> $ goalie_id      <int> 64, 64, 64, 64, 64, 155, 155, 155, 155, 64, 64, 64, 64,…
#> $ period_of_game <chr> "1", "1", "1", "1", "1", "1", "1", "1", "1", "1", "1", …
#> $ sec_from_start <int> 89, 210, 216, 297, 402, 425, 552, 562, 628, 641, 643, 7…
#> $ clock          <chr> "18:31", "16:30", "16:24", "15:03", "13:18", "12:55", "…
#> $ x_coord        <dbl> 49.3333, 83.0000, 29.0000, 80.3333, 58.0000, -79.0000, …
#> $ y_coord        <dbl> 24.6500, -9.0667, -21.2500, 4.5333, 12.7500, -7.6500, -…
#> $ shot_distance  <dbl> 46.7019, 10.8722, 63.6519, 9.7807, 33.5196, 12.5906, 24…
#> $ shot_angle     <dbl> 31.8580, 56.5048, 19.5024, 27.6130, 22.3569, 37.4160, 2…
#> $ event_type     <chr> "Default", "Default", "Default", "Default", "Default", …
#> $ shot_quality   <chr> "Non quality on net", "Non quality on net", "Non qualit…
#> $ power_play     <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, 1, …
#> $ short_handed   <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, "0"…
#> $ empty_net      <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ penalty_shot   <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
#> $ goal           <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE,…
#> $ xg             <dbl> 0.04577047, 0.11360723, 0.03451331, 0.16666314, 0.08464…
#> $ season         <int> 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2024, 2…

Which features does it use?

As listed in the model card, the logistic regression uses:

Group Features
Shot geometry shot_distance, shot_angle, derived from the HockeyTech rink coordinates
Shot type wrist, snap, slap, backhand and tip indicators
Rebound whether the shot followed a previous shot closely
Previous event indicators for a previous shot, faceoff, hit, blocked shot or penalty
Pre-shot movement time_since_last, distance_from_last, last_x, last_y
Game state empty net, home team, power play (is_pp), shorthanded (is_sh)

As reported in the model card’s full-corpus fit, the largest standardised effects are distance (coefficient -0.0360 per foot), angle (-0.0124 per degree) and rebound (+1.0138 on the log-odds scale, so a rebound roughly multiplies the odds of a goal by 2.7 against a non-rebound shot from the same spot).

Because the model is a deterministic re-fit on the data it is given, there is no model file to download. Each published season is scored by a fit on the full corpus available at publish time.

You can see the geometry effect directly in the published values:

pwhl_xg %>%
  filter(shot_distance <= 80) %>%
  mutate(distance_bin = cut(shot_distance, breaks = seq(0, 80, 5))) %>%
  group_by(distance_bin) %>%
  summarise(`Mean xG` = mean(xg), `Goal rate` = mean(goal), shots = n(), .groups = "drop") %>%
  tidyr::pivot_longer(c(`Mean xG`, `Goal rate`)) %>%
  ggplot(aes(x = distance_bin, y = value, colour = name, group = name)) +
  geom_line() +
  geom_point() +
  scale_colour_manual(values = c("Mean xG" = "#531896", "Goal rate" = "#37C4E3")) +
  labs(
    title = "PWHL xG and goal rate by shot distance",
    subtitle = "Shots on goal, 2024 to 2026 published seasons",
    x = "Shot distance (ft)", y = NULL, colour = NULL,
    caption = "Data: fastRhockey::load_pwhl_xg_pbp()"
  ) +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Line chart of mean PWHL expected goals and actual goal rate by shot distance bin, all published seasons.

How well does it do?

The honest test: a season holdout

As reported in the model card, the model fit on the 2024 and 2025 seasons and scored on the 7,438 shots of 2026 has a log-loss of 0.2627 against 0.2808 for a constant-rate baseline, and a rank AUC of 0.7033. Scored leave-one-season-out (each season by a model fit on the others), the card reports goals divided by xG of 0.897 for 2024, 1.097 for 2025 and 0.844 for 2026, with binomial calibration z-scores of -2.28, 2.20 and -4.47. A calibrated season has |z| of about 2 or less, so a model fit on 2024 and 2025 over-predicts 2026 goals: the earlier seasons price 2026’s shots too high. Season-to-season level shifts are real in a young league.

The published values, scored in-sample

The published xg values were fit on the same seasons they score, so this is an in-sample check. It tells you how the column behaves, not how the model generalises.

rank_auc <- function(y, p) {
  r <- rank(p)
  n1 <- sum(y == 1)
  n0 <- sum(y == 0)
  (sum(r[y == 1]) - n1 * (n1 + 1) / 2) / (n1 * n0)
}
log_loss <- function(y, p) {
  p <- pmin(pmax(p, 1e-15), 1 - 1e-15)
  -mean(y * log(p) + (1 - y) * log(1 - p))
}

pub_eval <- pwhl_xg %>%
  mutate(goal = as.integer(goal)) %>%
  group_by(season) %>%
  summarise(
    shots = n(),
    goals = sum(goal),
    sum_xg = round(sum(xg), 1),
    goals_per_xg = round(goals / sum(xg), 3),
    log_loss = round(log_loss(goal, xg), 4),
    rank_auc = round(rank_auc(goal, xg), 4),
    .groups = "drop"
  )
pub_eval
#> # A tibble: 3 × 7
#>   season shots goals sum_xg goals_per_xg log_loss rank_auc
#>    <int> <int> <int>  <dbl>        <dbl>    <dbl>    <dbl>
#> 1   2024  4922   385   406.        0.949    0.258    0.686
#> 2   2025  5671   499   465.        1.07     0.278    0.699
#> 3   2026  7438   601   615.        0.978    0.257    0.724

Across all 18,031 published shots the in-sample rank AUC is 0.7058.

pwhl_xg %>%
  group_by(decile = ntile(xg, 10)) %>%
  summarise(mean_xg = mean(xg), goal_rate = mean(goal), .groups = "drop") %>%
  ggplot(aes(x = mean_xg, y = goal_rate)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = "grey50") +
  geom_line(colour = "#531896") +
  geom_point(colour = "#531896") +
  labs(
    title = "Published PWHL xG: calibration by decile (in-sample)",
    x = "Mean xG in decile", y = "Actual goal rate",
    caption = "Data: fastRhockey::load_pwhl_xg_pbp()"
  ) +
  theme_minimal()

Calibration plot of published PWHL expected goals by decile, actual goal rate against mean xG, with a dashed diagonal.

What are the known issues?

Other limits, as described in the card:

  • Strength coverage. The 2024 and 2025 play-by-play files carry no skater or strength columns; 2026 is about 99% covered. The power-play and shorthanded features, and the per-strength recalibration, are effectively active only for 2026 shots.
  • Small samples. Three seasons and about 18,000 shots. Player goals-above-expected is a descriptive ledger here, not a talent estimate.
  • No pre-shot passing data. One-timers and cross-ice passes are under-rated by construction.
  • Shots on goal only. Missed and blocked attempts get no xG, so PWHL xG totals are not comparable to NHL xG totals, which include missed shots.

Which players beat their expected goals?

A descriptive table for the latest season: goals, xG and goals above expected (GAX) by shooter.

pwhl_xg %>%
  filter(season == max(seasons)) %>%
  group_by(player_id) %>%
  summarise(shots = n(), goals = sum(goal), xg = round(sum(xg), 2), .groups = "drop") %>%
  mutate(gax = round(goals - xg, 2)) %>%
  arrange(desc(xg)) %>%
  head(10)
#> # A tibble: 10 × 5
#>    player_id shots goals    xg   gax
#>        <int> <int> <int> <dbl> <dbl>
#>  1        72   120    18 11.8   6.19
#>  2        32   141    10 11.2  -1.24
#>  3        25    99    14 10.3   3.67
#>  4        20    84    13  9.38  3.62
#>  5        15    84     5  9.13 -4.13
#>  6        36    95    14  9.08  4.92
#>  7        53    94     3  9.04 -6.04
#>  8       205   104     9  8.94  0.06
#>  9        34    89    11  8.51  2.49
#> 10        61    80    10  8.36  1.64

Join player_id to load_pwhl_rosters() or load_pwhl_skater_box() for names.

How do I reproduce this?

library(fastRhockey)

# published xG, one row per shot on goal
pwhl_xg <- load_pwhl_xg_pbp(seasons = 2024:2026)

# full event-level play-by-play, without xG
pwhl_pbp <- load_pwhl_pbp(seasons = 2026)

The model is fit in sportsdataverse-py and published from fastRhockey-pwhl-data, whose model card is docs/models/pwhl_xg.md.

Where to go next

Citation

@misc{howell_gilani_fastRhockey,
  author = {Ben Howell and Saiem Gilani},
  title = {fastRhockey: Functions to Access Professional Women's Hockey League and National Hockey League Play by Play Data.},
  url = {https://fastRhockey.sportsdataverse.org/},
  doi = {10.32614/CRAN.package.fastRhockey},
  year = {2026}
}