Chapter 60

Capstone - Complete Analytics System

Intermediate 30 min read 5 sections 10 code examples
0 of 60 chapters completed (0%)

Beyond Basic xG

Once you understand the fundamentals of Expected Goals, it's time to explore advanced concepts. Post-shot models, goalkeeper evaluation, xG model building, and understanding when xG breaks down are essential skills for serious analysts.

This chapter covers the cutting edge of xG analysis: how to evaluate finishing skill, assess goalkeeper performance, build your own xG model, and avoid common pitfalls that trap less sophisticated analysts.

What You'll Learn
  • Post-shot xG (PSxG/xGOT) and how it differs from pre-shot xG
  • Goalkeeper evaluation using Goals Saved Above Expected (GSAx)
  • Building your own xG model from scratch
  • Identifying true finishing skill vs. luck
  • When and why xG models fail

Post-Shot Expected Goals (PSxG)

Standard xG is calculated before the shot is taken—it doesn't know where the shot ends up. Post-shot xG (also called xGOT - Expected Goals on Target) incorporates shot placement.

Pre-Shot vs. Post-Shot xG

Pre-Shot xG

What it measures: Chance quality based on position

Features used:

  • Shot location (distance, angle)
  • Body part
  • Assist type
  • Game state
  • Defender positions (advanced)

Use for: Evaluating chance creation

Post-Shot xG (PSxG/xGOT)

What it measures: Shot quality including placement

Features added:

  • Where in the goal the shot was aimed
  • Shot speed (if available)
  • Goalkeeper position at shot

Use for: Evaluating shot quality & goalkeepers

Key Insight

A shot with 0.15 pre-shot xG placed perfectly in the top corner might have 0.85 PSxG. The same shot blazed over the bar has 0.00 PSxG. Post-shot xG captures finishing quality that pre-shot xG misses.

# Understanding PSxG vs xG from statsbombpy import sb import pandas as pd import numpy as np # Load match data events = sb.events(match_id=3869685) # Filter shots shots = events[events["type"] == "Shot"].copy() # Extract shot end location (where shot went) shots["end_x"] = shots["shot_end_location"].apply( lambda l: l[0] if isinstance(l, list) and len(l) > 0 else None) shots["end_y"] = shots["shot_end_location"].apply( lambda l: l[1] if isinstance(l, list) and len(l) > 1 else None) shots["end_z"] = shots["shot_end_location"].apply( lambda l: l[2] if isinstance(l, list) and len(l) > 2 else 1.0) # Shots on target only on_target = shots[shots["shot_outcome"].isin( ["Goal", "Saved", "Saved Off Target", "Saved to Post"])].copy() # Analyze shot placement # Goal center is approximately y=40 in StatsBomb coordinates on_target["y_from_center"] = abs(40 - on_target["end_y"]) on_target["z_height"] = on_target["end_z"].fillna(1.0) # Corner shots (harder to save) on_target["is_corner"] = (on_target["y_from_center"] > 2) & (on_target["z_height"] > 1.5) print("Shot Placement Analysis:") print(on_target[["player", "shot_statsbomb_xg", "shot_outcome", "y_from_center", "z_height", "is_corner"]].head(10)) # PSxG concept: same shot location, different placement = different save difficulty print("\nCorner shots are harder to save:") print(on_target.groupby("is_corner").agg( shots=("type", "count"), goals=("shot_outcome", lambda x: (x == "Goal").sum()), conversion=("shot_outcome", lambda x: (x == "Goal").mean() * 100) ).round(1))
# Understanding PSxG vs xG
library(dplyr)
library(StatsBombR)

# Load data with freeze frames (for GK position)
matches <- FreeMatches(FreeCompetitions() %>%
                        filter(competition_id == 43))
events <- get.matchFree(matches[1, ])

# StatsBomb freeze frames include shot end location
# We can approximate PSxG concepts

shots <- events %>%
  filter(type.name == "Shot") %>%
  select(player.name, shot.statsbomb_xg, shot.outcome.name,
         shot.end_location.x, shot.end_location.y, shot.end_location.z,
         location.x, location.y)

# Shots on target only have PSxG (off-target = 0)
shots_on_target <- shots %>%
  filter(shot.outcome.name %in% c("Goal", "Saved", "Saved Off Target",
                                   "Saved to Post"))

# Approximate shot quality by end location
# Corners of goal are harder to save
shots_on_target <- shots_on_target %>%
  mutate(
    # Goal is at x=120, y=36-44 (8m wide), z=0-2.44m (8ft high)
    end_y = shot.end_location.y,
    end_z = shot.end_location.z,

    # Distance from goal center
    y_from_center = abs(40 - end_y),  # 40 is goal center
    z_height = ifelse(is.na(end_z), 1, end_z),

    # Corner shots are harder to save
    is_corner = (y_from_center > 2) & (z_height > 1.5),

    # Simple PSxG approximation
    psxg_estimate = case_when(
      is_corner & shot.outcome.name == "Goal" ~ 0.9,
      is_corner ~ 0.6,
      z_height > 2 ~ 0.5,  # High shots
      TRUE ~ shot.statsbomb_xg * 1.2  # Central = closer to pre-shot
    )
  )

print("Shot Placement Analysis:")
print(shots_on_target %>%
        select(player.name, shot.statsbomb_xg, shot.outcome.name,
               y_from_center, z_height, is_corner) %>%
        head(10))
chapter7-psxg-concept
Output
Understanding post-shot xG concepts

Comparing Providers' PSxG

Different providers calculate PSxG differently:

Provider PSxG Name Key Features
StatsBomb Post-Shot xG Shot placement, GK position from freeze frames
Opta/Stats Perform xGOT Shot end location in goal frame
FBref PSxG Uses StatsBomb's model
Understat N/A Pre-shot xG only

Goalkeeper Evaluation with PSxG

PSxG enables fair goalkeeper comparison by controlling for shot quality faced. The key metric is Goals Saved Above Expected (GSAx).

Goals Saved Above Expected (GSAx)

GSAx = Post-Shot xG Faced - Goals Conceded

Positive GSAx means the goalkeeper saved more than expected. Negative means they conceded more than expected.

# Calculate Goals Saved Above Expected import pandas as pd # Goalkeeper season data (simulated - use actual PSxG in practice) goalkeeper_data = pd.DataFrame({ "goalkeeper": ["Alisson", "Ederson", "De Gea", "Ramsdale", "Pickford"], "team": ["Liverpool", "Man City", "Man United", "Arsenal", "Everton"], "shots_faced": [95, 72, 130, 105, 145], "saves": [68, 52, 92, 78, 98], "goals_conceded": [27, 20, 38, 27, 47], "psxg_faced": [32.5, 24.8, 42.1, 31.2, 52.3] }) # Calculate GSAx goalkeeper_data["GSAx"] = goalkeeper_data["psxg_faced"] - goalkeeper_data["goals_conceded"] goalkeeper_data["GSAx_per_90"] = goalkeeper_data["GSAx"] / 38 # 38 match season goalkeeper_data["save_pct"] = goalkeeper_data["saves"] / goalkeeper_data["shots_faced"] * 100 print("Goalkeeper GSAx Rankings:") print(goalkeeper_data.sort_values("GSAx", ascending=False)[ ["goalkeeper", "team", "goals_conceded", "psxg_faced", "GSAx", "GSAx_per_90", "save_pct"] ])
# Calculate Goals Saved Above Expected
library(dplyr)

# For goalkeeper evaluation, we need shots AGAINST their team
# and the PSxG of those shots

# Simulate goalkeeper data (in real analysis, use actual PSxG)
goalkeeper_data <- data.frame(
  goalkeeper = c("Alisson", "Ederson", "De Gea", "Ramsdale", "Pickford"),
  team = c("Liverpool", "Man City", "Man United", "Arsenal", "Everton"),
  shots_faced = c(95, 72, 130, 105, 145),
  saves = c(68, 52, 92, 78, 98),
  goals_conceded = c(27, 20, 38, 27, 47),
  psxg_faced = c(32.5, 24.8, 42.1, 31.2, 52.3)
)

# Calculate GSAx
goalkeeper_data <- goalkeeper_data %>%
  mutate(
    # Goals Saved Above Expected
    GSAx = psxg_faced - goals_conceded,

    # Per 90 (assuming 38 matches)
    GSAx_per_90 = GSAx / 38,

    # Save percentage
    save_pct = saves / shots_faced * 100,

    # PSxG save percentage (saves / shots that should have been saved)
    psxg_save_pct = (shots_faced - goals_conceded) / shots_faced * 100
  ) %>%
  arrange(desc(GSAx))

print("Goalkeeper GSAx Rankings:")
print(goalkeeper_data %>%
        select(goalkeeper, team, goals_conceded, psxg_faced,
               GSAx, GSAx_per_90, save_pct))
chapter7-gsax
Output
Calculating Goals Saved Above Expected

Why GSAx Beats Save Percentage

Save Percentage Problems
  • Doesn't account for shot difficulty
  • Penalizes keepers facing hard shots
  • Rewards keepers behind good defenses
  • Low-block teams face easier saves
GSAx Advantages
  • Controls for shot quality faced
  • Fair comparison across teams
  • Rewards difficult saves appropriately
  • Isolates goalkeeper skill from defense
# Comprehensive goalkeeper analysis import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(10, 8)) # Scatter plot scatter = ax.scatter(goalkeeper_data["save_pct"], goalkeeper_data["GSAx"], s=goalkeeper_data["shots_faced"] * 2, alpha=0.7) # Labels for _, row in goalkeeper_data.iterrows(): ax.annotate(row["goalkeeper"], (row["save_pct"], row["GSAx"]), xytext=(5, 5), textcoords="offset points", fontsize=9) # Reference lines ax.axhline(0, linestyle="--", color="red", alpha=0.5) ax.axvline(goalkeeper_data["save_pct"].mean(), linestyle="--", color="blue", alpha=0.5) ax.set_xlabel("Save Percentage (%)", fontsize=12) ax.set_ylabel("Goals Saved Above Expected (GSAx)", fontsize=12) ax.set_title("Save Percentage vs GSAx\nPoint size = shots faced", fontsize=14) # Quadrant labels ax.text(75, 8, "Elite\n(High GSAx)", fontsize=9, color="darkgreen", ha="center") ax.text(65, -8, "Poor\n(Low GSAx)", fontsize=9, color="red", ha="center") plt.tight_layout() plt.show() # Shot quality faced goalkeeper_data["psxg_per_shot"] = goalkeeper_data["psxg_faced"] / goalkeeper_data["shots_faced"] print("\nShot Quality Faced (PSxG per shot):") print(goalkeeper_data.sort_values("psxg_per_shot", ascending=False)[ ["goalkeeper", "team", "shots_faced", "psxg_per_shot", "GSAx"]])
# Comprehensive goalkeeper analysis
library(ggplot2)

# Visualize save% vs GSAx
ggplot(goalkeeper_data, aes(x = save_pct, y = GSAx)) +
  geom_point(aes(size = shots_faced), alpha = 0.7) +
  geom_text(aes(label = goalkeeper), vjust = -1, size = 3) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
  geom_vline(xintercept = mean(goalkeeper_data$save_pct),
             linetype = "dashed", color = "blue") +
  labs(title = "Save Percentage vs Goals Saved Above Expected",
       subtitle = "Point size = shots faced",
       x = "Save Percentage (%)",
       y = "Goals Saved Above Expected (GSAx)",
       size = "Shots Faced") +
  theme_minimal() +
  annotate("text", x = 75, y = 8, label = "Elite\n(High GSAx, avg save%)",
           size = 3, color = "darkgreen") +
  annotate("text", x = 65, y = -8, label = "Poor\n(Low GSAx, low save%)",
           size = 3, color = "red")

# Shot quality faced comparison
print("\nShot Quality Faced (PSxG per shot):")
goalkeeper_data %>%
  mutate(psxg_per_shot = psxg_faced / shots_faced) %>%
  select(goalkeeper, team, shots_faced, psxg_per_shot, GSAx) %>%
  arrange(desc(psxg_per_shot))
chapter7-gk-analysis
Output
Visualizing goalkeeper performance

Identifying True Finishing Skill

The holy grail of xG analysis: separating elite finishers from lucky ones. This requires multi-season data and careful analysis.

The Finishing Skill Debate

Analysts disagree about how much finishing is skill vs. luck. The truth:

  • Some players are genuinely elite finishers - Messi, Lewandowski, Suarez consistently outperform xG
  • Most overperformance is noise - single-season overperformers usually regress
  • Shot selection matters - some players only shoot when confident
  • Sample size is critical - need 100+ shots for stable estimates
# Analyze finishing skill over multiple seasons import pandas as pd # Multi-season data multi_season = pd.DataFrame({ "player": ["Haaland"]*3 + ["Salah"]*3 + ["Kane"]*3 + ["Son"]*3 + ["Rashford"]*3, "season": ["2021-22", "2022-23", "2023-24"] * 5, "shots": [82, 120, 110, 95, 88, 92, 105, 98, 85, 78, 82, 76, 88, 95, 72], "goals": [22, 36, 32, 23, 18, 20, 17, 30, 22, 23, 17, 15, 12, 17, 8], "xG": [18.5, 32.5, 28.2, 21.8, 17.8, 19.5, 20.2, 25.5, 18.8, 15.2, 14.5, 12.8, 14.3, 14.8, 10.2] }) # Aggregate by player finishing = multi_season.groupby("player").agg( seasons=("season", "count"), total_shots=("shots", "sum"), total_goals=("goals", "sum"), total_xG=("xG", "sum") ).reset_index() finishing["goals_minus_xG"] = finishing["total_goals"] - finishing["total_xG"] finishing["conversion"] = (finishing["total_goals"] / finishing["total_shots"] * 100).round(1) finishing["finishing_rate"] = (finishing["goals_minus_xG"] / finishing["total_xG"] * 100).round(1) # Consistency: seasons beating xG multi_season["beat_xG"] = multi_season["goals"] > multi_season["xG"] consistency = multi_season.groupby("player").agg( seasons_beat_xG=("beat_xG", "sum"), avg_over_xG=("goals", lambda x: (x - multi_season.loc[x.index, "xG"]).mean()) ).reset_index() finishing = finishing.merge(consistency, on="player") print("Multi-Season Finishing Analysis:") print(finishing.sort_values("goals_minus_xG", ascending=False)) print("\nInterpretation:") print("- Haaland: Elite finisher (consistent overperformance)") print("- Son: Good finisher (beats xG most seasons)") print("- Kane: High xG = good positions, average finishing") print("- Rashford: Below average finisher")
# Analyze finishing skill over multiple seasons
library(dplyr)

# Simulated multi-season data (use actual data in practice)
multi_season <- data.frame(
  player = rep(c("Haaland", "Salah", "Kane", "Son", "Rashford"), each = 3),
  season = rep(c("2021-22", "2022-23", "2023-24"), 5),
  shots = c(82, 120, 110,  # Haaland
            95, 88, 92,    # Salah
            105, 98, 85,   # Kane
            78, 82, 76,    # Son
            88, 95, 72),   # Rashford
  goals = c(22, 36, 32,    # Haaland
            23, 18, 20,    # Salah
            17, 30, 22,    # Kane
            23, 17, 15,    # Son
            12, 17, 8),    # Rashford
  xG = c(18.5, 32.5, 28.2,   # Haaland
         21.8, 17.8, 19.5,   # Salah
         20.2, 25.5, 18.8,   # Kane
         15.2, 14.5, 12.8,   # Son
         14.3, 14.8, 10.2)   # Rashford
)

# Calculate finishing metrics
finishing_analysis <- multi_season %>%
  group_by(player) %>%
  summarise(
    seasons = n(),
    total_shots = sum(shots),
    total_goals = sum(goals),
    total_xG = sum(xG),
    goals_minus_xG = total_goals - total_xG,
    conversion = total_goals / total_shots * 100,
    xG_per_shot = total_xG / total_shots,
    finishing_rate = goals_minus_xG / total_xG * 100  # % over/under xG
  )

# Consistency check: did they beat xG every season?
consistency <- multi_season %>%
  mutate(beat_xG = goals > xG) %>%
  group_by(player) %>%
  summarise(
    seasons_beat_xG = sum(beat_xG),
    avg_over_xG = mean(goals - xG)
  )

finishing_analysis <- finishing_analysis %>%
  left_join(consistency, by = "player") %>%
  arrange(desc(goals_minus_xG))

print("Multi-Season Finishing Analysis:")
print(finishing_analysis)

cat("\nInterpretation:\n")
cat("- Haaland: Elite finisher (consistent overperformance)\n")
cat("- Son: Good finisher (beats xG most seasons)\n")
cat("- Salah: Average finisher (fluctuates around xG)\n")
cat("- Kane: Average (high xG = good positions, not elite finishing)\n")
cat("- Rashford: Below average finisher\n")
chapter7-finishing-skill
Output
Analyzing multi-season finishing skill

The 100-Shot Rule

Sample Size Matters

Research suggests finishing skill only stabilizes after approximately 100 non-penalty shots. Before that threshold, most over/underperformance is noise.

For a typical striker taking 80 shots per season, you need 1.5-2 seasons of data for reliable finishing skill estimates.

Building Your Own xG Model

Understanding how to build an xG model deepens your understanding of what xG can and cannot measure.

Step 1: Prepare the Data

# Prepare shot data for xG modeling from statsbombpy import sb import pandas as pd import numpy as np # Load multiple matches matches = sb.matches(competition_id=43, season_id=106) # World Cup 2022 all_events = [] for mid in matches["match_id"]: events = sb.events(match_id=mid) all_events.append(events) events_df = pd.concat(all_events, ignore_index=True) # Filter shots and create features shots = events_df[events_df["type"] == "Shot"].copy() # Extract coordinates shots["x"] = shots["location"].apply(lambda l: l[0] if l else None) shots["y"] = shots["location"].apply(lambda l: l[1] if l else None) # Calculate derived features shots["distance"] = np.sqrt((120 - shots["x"])**2 + (40 - shots["y"])**2) shots["angle"] = np.degrees(np.arctan2(8, shots["distance"])) # Binary target shots["is_goal"] = (shots["shot_outcome"] == "Goal").astype(int) # Feature engineering shots["is_header"] = (shots["shot_body_part"] == "Head").astype(int) shots["is_penalty"] = (shots["shot_type"] == "Penalty").astype(int) shots["is_first_time"] = shots["shot_first_time"].fillna(False).astype(int) shots["is_one_on_one"] = shots["shot_one_on_one"].fillna(False).astype(int) # Select features for model features = ["x", "y", "distance", "angle", "is_header", "is_penalty", "is_first_time", "is_one_on_one"] target = "is_goal" model_data = shots[features + [target]].dropna() print(f"Training data: {len(model_data)} shots, {model_data[\"is_goal\"].mean()*100:.1f}% goals") print("\nFeature summary:") print(model_data.describe())
# Prepare shot data for xG modeling
library(dplyr)
library(StatsBombR)

# Load multiple matches for training data
comps <- FreeCompetitions() %>%
  filter(competition_id == 43)  # World Cups
matches <- FreeMatches(comps)
events <- free_allevents(MatchesDF = matches[1:50, ])

# Extract shot features
shots <- events %>%
  filter(type.name == "Shot") %>%
  mutate(
    # Target variable
    is_goal = as.numeric(shot.outcome.name == "Goal"),

    # Location features
    x = location.x,
    y = location.y,
    distance = sqrt((120 - x)^2 + (40 - y)^2),
    angle = atan2(8, distance) * 180 / pi,  # Simplified angle

    # Categorical features
    body_part = shot.body_part.name,
    shot_type = shot.type.name,
    technique = shot.technique.name,

    # Binary features
    is_header = as.numeric(body_part == "Head"),
    is_penalty = as.numeric(shot_type == "Penalty"),
    is_first_time = as.numeric(shot.first_time == TRUE),
    is_one_on_one = as.numeric(shot.one_on_one == TRUE),

    # Under pressure
    under_pressure = as.numeric(under_pressure == TRUE)
  ) %>%
  select(is_goal, x, y, distance, angle, is_header, is_penalty,
         is_first_time, is_one_on_one, under_pressure) %>%
  na.omit()

print(sprintf("Training data: %d shots, %.1f%% goals",
              nrow(shots),
              mean(shots$is_goal) * 100))

print("\nFeature summary:")
summary(shots)
chapter7-xg-model-prep
Output
Preparing shot data for xG modeling

Step 2: Train the Model

# Train xG model using logistic regression from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.metrics import log_loss, brier_score_loss import numpy as np # Prepare data X = model_data[features] y = model_data[target] # Split data X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y) # Train logistic regression xg_model = LogisticRegression(max_iter=1000, random_state=42) xg_model.fit(X_train, y_train) # Predictions train_pred = xg_model.predict_proba(X_train)[:, 1] test_pred = xg_model.predict_proba(X_test)[:, 1] # Evaluation train_logloss = log_loss(y_train, train_pred) test_logloss = log_loss(y_test, test_pred) brier = brier_score_loss(y_test, test_pred) print(f"Train Log Loss: {train_logloss:.4f}") print(f"Test Log Loss: {test_logloss:.4f}") print(f"Brier Score: {brier:.4f}") # Feature importance print("\nFeature Coefficients (exp = odds ratio):") for feat, coef in zip(features, xg_model.coef_[0]): print(f" {feat}: {coef:.3f} (OR: {np.exp(coef):.2f})") print("\nNote: Commercial models include defender/GK positions") print("from tracking data, which significantly improves accuracy.")
# Train xG model using logistic regression
library(caret)

# Split data
set.seed(42)
train_idx <- createDataPartition(shots$is_goal, p = 0.8, list = FALSE)
train_data <- shots[train_idx, ]
test_data <- shots[-train_idx, ]

# Train logistic regression
xg_model <- glm(is_goal ~ distance + angle + is_header + is_penalty +
                  is_first_time + is_one_on_one + under_pressure,
                data = train_data,
                family = binomial(link = "logit"))

# Model summary
print(summary(xg_model))

# Predictions
train_data$xG_pred <- predict(xg_model, train_data, type = "response")
test_data$xG_pred <- predict(xg_model, test_data, type = "response")

# Evaluate: Log loss and calibration
library(MLmetrics)
train_logloss <- LogLoss(train_data$xG_pred, train_data$is_goal)
test_logloss <- LogLoss(test_data$xG_pred, test_data$is_goal)

cat(sprintf("\nTrain Log Loss: %.4f\n", train_logloss))
cat(sprintf("Test Log Loss: %.4f\n", test_logloss))

# Compare to StatsBomb xG (if available)
cat("\nModel captures basic patterns, but commercial models include:")
cat("\n- Defender positions from freeze frames")
cat("\n- Goalkeeper position")
cat("\n- Pass leading to shot characteristics")
cat("\n- Historical player finishing rates")
chapter7-xg-model-train
Output
Training a basic xG model

Step 3: Evaluate Calibration

A well-calibrated xG model should have predictions that match observed conversion rates:

# Check model calibration import matplotlib.pyplot as plt # Create bins test_results = pd.DataFrame({ "xG_pred": test_pred, "is_goal": y_test.values }) test_results["xG_bin"] = pd.cut(test_results["xG_pred"], bins=np.arange(0, 1.1, 0.1)) calibration = test_results.groupby("xG_bin").agg( n=("xG_pred", "count"), avg_xG=("xG_pred", "mean"), actual_rate=("is_goal", "mean") ).reset_index() calibration = calibration[calibration["n"] >= 5] # Plot fig, ax = plt.subplots(figsize=(8, 8)) ax.scatter(calibration["avg_xG"], calibration["actual_rate"], s=calibration["n"] * 5, alpha=0.7) ax.plot([0, 1], [0, 1], "--", color="red", label="Perfect calibration") ax.set_xlabel("Predicted xG (binned average)", fontsize=12) ax.set_ylabel("Actual Conversion Rate", fontsize=12) ax.set_title("xG Model Calibration", fontsize=14) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.legend() plt.show() print("Calibration by xG bin:") print(calibration)
# Check model calibration
library(ggplot2)

# Bin predictions
test_data$xG_bin <- cut(test_data$xG_pred,
                        breaks = seq(0, 1, 0.1),
                        include.lowest = TRUE)

calibration <- test_data %>%
  group_by(xG_bin) %>%
  summarise(
    n = n(),
    avg_xG = mean(xG_pred),
    actual_rate = mean(is_goal)
  ) %>%
  filter(n >= 5)  # Minimum sample size

# Plot calibration
ggplot(calibration, aes(x = avg_xG, y = actual_rate)) +
  geom_point(aes(size = n)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "red") +
  scale_x_continuous(limits = c(0, 1)) +
  scale_y_continuous(limits = c(0, 1)) +
  labs(title = "xG Model Calibration",
       subtitle = "Perfect calibration = points on diagonal line",
       x = "Predicted xG (binned average)",
       y = "Actual Conversion Rate",
       size = "Shots") +
  theme_minimal()

print("Calibration by xG bin:")
print(calibration)
chapter7-xg-calibration
Output
Checking xG model calibration

When xG Fails

xG is powerful but not perfect. Understanding its limitations makes you a better analyst.

Common xG Pitfalls

Single matches have high variance. A team with 2.5 xG might score 0-5 goals. Only aggregate xG over many matches for reliable conclusions.

Rule of thumb: Need 10+ matches for team-level stability, 100+ shots for player finishing skill.

xG only measures shot quality. It misses:

  • Chances where no shot was taken (poor decision or good defending)
  • Quality of ball retention
  • Transition speed
  • Off-ball movement that creates space

Different providers give different xG values for the same shot:

  • Understat and StatsBomb can differ by 20%+ on the same shot
  • Some include freeze frame data, others don't
  • Training data quality varies

Solution: Pick one source and be consistent.

xG treats all 0.15 xG shots equally, but context matters:

  • A 0.15 xG shot at 0-0 vs. 5-0
  • League match vs. cup final
  • Home vs. away
  • Fresh vs. fatigued players

Practice Exercises

Apply your advanced xG knowledge with these exercises.

Exercise 7.1: Goalkeeper GSAx Analysis

Task: Using World Cup 2022 data, calculate GSAx for goalkeepers who faced at least 10 shots. Rank them by performance above/below expected.

script
# Solution 7.1: GSAx for World Cup goalkeepers
from statsbombpy import sb
import pandas as pd

# Load World Cup 2022 data
matches = sb.matches(competition_id=43, season_id=106)
all_events = pd.concat([sb.events(mid) for mid in matches["match_id"]])

# Get shots
shots = all_events[all_events["type"] == "Shot"].copy()

# Group by team being attacked (shots conceded perspective)
# We need shots against each team - group by opponent
team_gsax = shots.groupby("team").agg(
    shots_created=("type", "count"),
    goals_scored=("shot_outcome", lambda x: (x == "Goal").sum()),
    xG_created=("shot_statsbomb_xg", "sum")
).reset_index()

# For GSAx we need shots AGAINST each team
# Requires match context - simplified version:
print("GSAx Analysis (shots conceded perspective):")
print(team_gsax.sort_values("goals_scored"))

# Calculate over/underperformance
team_gsax["goals_minus_xG"] = team_gsax["goals_scored"] - team_gsax["xG_created"]
print("\nTeam finishing performance (Goals - xG):")
print(team_gsax.sort_values("goals_minus_xG", ascending=False))
# Solution 7.1: GSAx for World Cup goalkeepers
library(StatsBombR)
library(dplyr)

# Load World Cup 2022
comps <- FreeCompetitions() %>%
  filter(competition_id == 43, season_id == 106)
matches <- FreeMatches(comps)
events <- free_allevents(MatchesDF = matches)

# Get shots faced by each goalkeeper team
shots <- events %>%
  filter(type.name == "Shot") %>%
  mutate(defending_team = ifelse(team.name == home_team, away_team, home_team))

# Calculate GSAx by team (as proxy for goalkeeper)
team_gsax <- shots %>%
  group_by(team.name) %>%
  summarise(
    shots_created = n(),
    goals_scored = sum(shot.outcome.name == "Goal"),
    xG_created = sum(shot.statsbomb_xg, na.rm = TRUE),
    .groups = "drop"
  )

# Flip to get shots faced (defensive perspective)
gk_performance <- shots %>%
  group_by(possession_team.name = team.name) %>%
  summarise(
    shots_faced = n(),
    goals_conceded = sum(shot.outcome.name == "Goal"),
    psxg_faced = sum(shot.statsbomb_xg, na.rm = TRUE)
  ) %>%
  filter(shots_faced >= 10) %>%
  mutate(
    GSAx = round(psxg_faced - goals_conceded, 2),
    save_pct = round((shots_faced - goals_conceded) / shots_faced * 100, 1)
  ) %>%
  arrange(desc(GSAx))

print("Goalkeeper Team GSAx Rankings:")
print(gk_performance)
Exercise 7.2: Finishing Skill Identification

Task: Identify players with the highest Goals - xG (potential elite finishers). Then check how many shots they took (sample size reliability).

script
# Solution 7.2: Finishing skill analysis
shots = all_events[all_events["type"] == "Shot"].copy()

finishing = shots.groupby(["player", "team"]).agg(
    shots=("type", "count"),
    goals=("shot_outcome", lambda x: (x == "Goal").sum()),
    xG=("shot_statsbomb_xg", "sum")
).reset_index()

finishing = finishing[finishing["shots"] >= 5].copy()
finishing["goals_minus_xG"] = (finishing["goals"] - finishing["xG"]).round(2)
finishing["conversion"] = (finishing["goals"] / finishing["shots"] * 100).round(1)
finishing["sample_reliable"] = finishing["shots"].apply(
    lambda x: "Reliable" if x >= 20 else "Small sample")

print("Top Finishers (Goals - xG):")
print(finishing.sort_values("goals_minus_xG", ascending=False).head(15))

print("\nNote: Sample size matters!")
print("Look for players with 20+ shots for reliability.")
# Solution 7.2: Finishing skill analysis
finishing_analysis <- events %>%
  filter(type.name == "Shot") %>%
  group_by(player.name, team.name) %>%
  summarise(
    shots = n(),
    goals = sum(shot.outcome.name == "Goal"),
    xG = sum(shot.statsbomb_xg, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  filter(shots >= 5) %>%
  mutate(
    goals_minus_xG = round(goals - xG, 2),
    conversion = round(goals / shots * 100, 1),
    avg_xG_per_shot = round(xG / shots, 3),
    sample_reliable = ifelse(shots >= 20, "Reliable", "Small sample")
  ) %>%
  arrange(desc(goals_minus_xG))

cat("Top Finishers (Goals - xG):\n")
print(head(finishing_analysis, 15))

cat("\nNote: Sample size matters!\n")
cat("Players with 5-10 shots may be lucky, not skilled.\n")
cat("Look for 20+ shots for more reliable estimates.\n")
Exercise 7.3: Build Simple xG Model

Task: Build a basic xG model using distance and angle. Compare its predictions to StatsBomb's xG.

script
# Solution 7.3: Build simple xG model
from sklearn.linear_model import LogisticRegression
import numpy as np

shots = all_events[all_events["type"] == "Shot"].copy()

# Feature engineering
shots["x"] = shots["location"].apply(lambda l: l[0] if l else None)
shots["y"] = shots["location"].apply(lambda l: l[1] if l else None)
shots = shots.dropna(subset=["x", "y"])

shots["distance"] = np.sqrt((120 - shots["x"])**2 + (40 - shots["y"])**2)
shots["angle"] = np.degrees(np.arctan2(8, shots["distance"]))
shots["is_goal"] = (shots["shot_outcome"] == "Goal").astype(int)
shots["is_header"] = (shots["shot_body_part"] == "Head").astype(int)

# Train model
X = shots[["distance", "angle", "is_header"]].dropna()
y = shots.loc[X.index, "is_goal"]

model = LogisticRegression(max_iter=1000)
model.fit(X, y)

shots.loc[X.index, "my_xG"] = model.predict_proba(X)[:, 1]

# Compare
print(f"My model total xG: {shots[\"my_xG\"].sum():.2f}")
print(f"StatsBomb xG total: {shots[\"shot_statsbomb_xg\"].sum():.2f}")
print(f"Correlation: {shots[\"my_xG\"].corr(shots[\"shot_statsbomb_xg\"]):.3f}")
print("\nStatsBomb includes defender positions, hence better accuracy")
# Solution 7.3: Build simple xG model
library(caret)

# Prepare data
model_data <- events %>%
  filter(type.name == "Shot", !is.na(location.x)) %>%
  mutate(
    is_goal = as.numeric(shot.outcome.name == "Goal"),
    distance = sqrt((120 - location.x)^2 + (40 - location.y)^2),
    angle = atan2(8, distance) * 180 / pi,
    is_header = as.numeric(shot.body_part.name == "Head")
  ) %>%
  filter(!is.na(distance))

# Train model
set.seed(42)
simple_model <- glm(is_goal ~ distance + angle + is_header,
                    data = model_data, family = binomial)

# Add predictions
model_data$my_xG <- predict(simple_model, type = "response")

# Compare to StatsBomb
comparison <- model_data %>%
  summarise(
    actual_goals = sum(is_goal),
    my_xG_total = sum(my_xG),
    sb_xG_total = sum(shot.statsbomb_xg, na.rm = TRUE),
    correlation = cor(my_xG, shot.statsbomb_xg, use = "complete.obs")
  )

cat("Model Comparison:\n")
print(comparison)
cat("\nStatsBomb includes more features (defender positions, etc.)\n")

Chapter Summary

Key Takeaways
  • Post-shot xG (PSxG) adds shot placement to the model
  • GSAx is the gold standard for goalkeeper evaluation
  • Finishing skill requires 100+ shots and multi-season data to assess
  • Building xG models requires location, body part, and context features
  • Calibration is key - predictions should match observed rates
  • xG has limitations - understand them to avoid misuse

Next: Expected Assists

Learn about xA, key passes, shot-creating actions, and how to evaluate creative players.

Continue to Expected Assists