Capstone - Complete Analytics System
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
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
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
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-conceptUnderstanding post-shot xG conceptsComparing 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
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-gsaxCalculating Goals Saved Above ExpectedWhy GSAx Beats Save Percentage
- Doesn't account for shot difficulty
- Penalizes keepers facing hard shots
- Rewards keepers behind good defenses
- Low-block teams face easier saves
- Controls for shot quality faced
- Fair comparison across teams
- Rewards difficult saves appropriately
- Isolates goalkeeper skill from defense
# 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-analysisVisualizing goalkeeper performanceIdentifying 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
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-skillAnalyzing multi-season finishing skillThe 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
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-prepPreparing shot data for xG modelingStep 2: Train the Model
# 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-trainTraining a basic xG modelStep 3: Evaluate Calibration
A well-calibrated xG model should have predictions that match observed conversion rates:
# 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-calibrationChecking xG model calibrationWhen 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.
# 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).
# 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.
# 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