import warnings
warnings.filterwarnings('ignore')
The code below imports the statsbombpy package and displays every WSL season statsbombpy has released for free.
#Session 1
from statsbombpy import sb
import pandas as pd
comps = sb.competitions()
wsl = comps[comps['competition_name'].str.contains("Women's Super League", case=False, na=False)]
print(wsl[['competition_id', 'season_id', 'season_name']]);
competition_id season_id season_name 25 37 281 2023/2024 26 37 90 2020/2021 27 37 42 2019/2020 28 37 4 2018/2019
The code below prints a table with one row per WSL match, columns like match_date, home_team, away_team, etc. and print the first 10 rows of data.
from statsbombpy import sb
import pandas as pd
matches = sb.matches(competition_id=37, season_id=281)
print(matches.shape)
matches.head();
(132, 55)
print(matches[['match_date', 'home_team', 'away_team', 'home_score', 'away_score']].head(10))
match_date home_team away_team \ 0 2023-11-05 Brighton & Hove Albion WFC Manchester United W 1 2024-03-23 Manchester City WFC Manchester United W 2 2024-04-21 Manchester United W Tottenham Hotspur Women 3 2024-04-28 Aston Villa W West Ham United LFC 4 2023-12-09 Manchester City WFC Aston Villa W 5 2024-04-17 Chelsea FCW Aston Villa W 6 2023-12-17 Aston Villa W Brighton & Hove Albion WFC 7 2024-03-16 Everton LFC Aston Villa W 8 2024-02-03 Aston Villa W Bristol City WFC 9 2023-10-08 Liverpool WFC Aston Villa W home_score away_score 0 2 2 1 3 1 2 2 2 3 1 1 4 2 1 5 3 0 6 1 0 7 1 2 8 2 2 9 2 0
The cell below filters to Chelsea's matches.
chelsea_matches = matches[
(matches['home_team'] == 'Chelsea FCW') | (matches['away_team'] == 'Chelsea FCW')
].copy()
print(chelsea_matches.shape)
chelsea_matches[['match_date', 'home_team', 'away_team', 'home_score', 'away_score']]
(22, 55)
| match_date | home_team | away_team | home_score | away_score | |
|---|---|---|---|---|---|
| 5 | 2024-04-17 | Chelsea FCW | Aston Villa W | 3 | 0 |
| 10 | 2024-02-04 | Chelsea FCW | Everton LFC | 3 | 0 |
| 11 | 2023-10-14 | Chelsea FCW | West Ham United LFC | 2 | 0 |
| 18 | 2024-05-05 | Chelsea FCW | Bristol City WFC | 8 | 0 |
| 24 | 2024-05-01 | Liverpool WFC | Chelsea FCW | 4 | 3 |
| 31 | 2024-05-15 | Tottenham Hotspur Women | Chelsea FCW | 0 | 1 |
| 39 | 2024-03-15 | Chelsea FCW | Arsenal WFC | 3 | 1 |
| 42 | 2024-02-16 | Chelsea FCW | Manchester City WFC | 0 | 1 |
| 45 | 2024-01-21 | Chelsea FCW | Manchester United W | 3 | 1 |
| 48 | 2023-12-17 | Bristol City WFC | Chelsea FCW | 0 | 3 |
| 52 | 2023-12-10 | Arsenal WFC | Chelsea FCW | 4 | 1 |
| 54 | 2023-11-26 | Chelsea FCW | Leicester City WFC | 5 | 2 |
| 59 | 2023-10-22 | Chelsea FCW | Brighton & Hove Albion WFC | 4 | 2 |
| 60 | 2023-11-04 | Aston Villa W | Chelsea FCW | 0 | 6 |
| 70 | 2023-10-08 | Manchester City WFC | Chelsea FCW | 1 | 1 |
| 73 | 2024-05-18 | Manchester United W | Chelsea FCW | 0 | 6 |
| 84 | 2024-03-24 | West Ham United LFC | Chelsea FCW | 0 | 2 |
| 87 | 2024-03-03 | Leicester City WFC | Chelsea FCW | 0 | 4 |
| 98 | 2024-01-27 | Brighton & Hove Albion WFC | Chelsea FCW | 0 | 3 |
| 115 | 2023-11-18 | Chelsea FCW | Liverpool WFC | 5 | 1 |
| 119 | 2023-11-12 | Everton LFC | Chelsea FCW | 0 | 3 |
| 127 | 2023-10-01 | Chelsea FCW | Tottenham Hotspur Women | 2 | 1 |
This saves the match list.
chelsea_matches.to_csv("chelsea_matches_2023_24.csv", index=False)
This pulls through all of Chelsea's matches and pulls the event data for each one. Should print "Pulled events for x matches" as a sanity check.
#Session 2
from statsbombpy import sb
import pandas as pd
import time
all_events = {}
for match_id in chelsea_matches['match_id']:
events = sb.events(match_id=match_id)
all_events[match_id] = events
time.sleep(0.5) # small pause to be polite to the API
print(f"Pulled events for {len(all_events)} matches");
Pulled events for 22 matches
Core logic - For a given match, figure out Chelsea's goals, opponent's goals, Chelsea's total xG, and the opponents total xG.
def summarize_match(match_id, match_row, events):
is_home = match_row['home_team'] == 'Chelsea FCW'
chelsea_team_name = 'Chelsea FCW'
opponent = match_row['away_team'] if is_home else match_row['home_team']
shots = events[events['type'] == 'Shot'].copy()
chelsea_shots = shots[shots['team'] == chelsea_team_name]
opponent_shots = shots[shots['team'] != chelsea_team_name]
chelsea_xg = chelsea_shots['shot_statsbomb_xg'].sum()
opponent_xg = opponent_shots['shot_statsbomb_xg'].sum()
chelsea_goals = match_row['home_score'] if is_home else match_row['away_score']
opponent_goals = match_row['away_score'] if is_home else match_row['home_score']
if chelsea_goals > opponent_goals:
result = 'W'
elif chelsea_goals < opponent_goals:
result = 'L'
else:
result = 'D'
return {
'match_id': match_id,
'date': match_row['match_date'],
'opponent': opponent,
'venue': 'Home' if is_home else 'Away',
'result': result,
'goals_for': chelsea_goals,
'goals_against': opponent_goals,
'xg_for': round(chelsea_xg, 2),
'xg_against': round(opponent_xg, 2),
'xg_diff': round(chelsea_xg - opponent_xg, 2)
};
Run the function across every match and build the final table.
summaries = []
for _, row in chelsea_matches.iterrows():
match_id = row['match_id']
events = all_events[match_id]
summary = summarize_match(match_id, row, events)
summaries.append(summary)
match_level_df = pd.DataFrame(summaries)
print(match_level_df.shape)
match_level_df
(22, 10)
| match_id | date | opponent | venue | result | goals_for | goals_against | xg_for | xg_against | xg_diff | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3913165 | 2024-04-17 | Aston Villa W | Home | W | 3 | 0 | 2.83 | 0.36 | 2.48 |
| 1 | 3913134 | 2024-02-04 | Everton LFC | Home | W | 3 | 0 | 3.08 | 0.56 | 2.52 |
| 2 | 3913069 | 2023-10-14 | West Ham United LFC | Home | W | 2 | 0 | 2.41 | 0.86 | 1.56 |
| 3 | 3913180 | 2024-05-05 | Bristol City WFC | Home | W | 8 | 0 | 4.56 | 1.41 | 3.15 |
| 4 | 3913173 | 2024-05-01 | Liverpool WFC | Away | L | 3 | 4 | 1.84 | 1.68 | 0.16 |
| 5 | 3913163 | 2024-05-15 | Tottenham Hotspur Women | Away | W | 1 | 0 | 1.43 | 0.56 | 0.87 |
| 6 | 3913152 | 2024-03-15 | Arsenal WFC | Home | W | 3 | 1 | 2.49 | 0.62 | 1.86 |
| 7 | 3913138 | 2024-02-16 | Manchester City WFC | Home | L | 0 | 1 | 1.47 | 1.45 | 0.02 |
| 8 | 3913121 | 2024-01-21 | Manchester United W | Home | W | 3 | 1 | 1.70 | 0.98 | 0.72 |
| 9 | 3913112 | 2023-12-17 | Bristol City WFC | Away | W | 3 | 0 | 2.81 | 1.03 | 1.78 |
| 10 | 3913105 | 2023-12-10 | Arsenal WFC | Away | L | 1 | 4 | 0.89 | 2.71 | -1.82 |
| 11 | 3913103 | 2023-11-26 | Leicester City WFC | Home | W | 5 | 2 | 2.49 | 1.25 | 1.24 |
| 12 | 3913077 | 2023-10-22 | Brighton & Hove Albion WFC | Home | W | 4 | 2 | 4.94 | 1.39 | 3.55 |
| 13 | 3913081 | 2023-11-04 | Aston Villa W | Away | W | 6 | 0 | 5.19 | 0.43 | 4.75 |
| 14 | 3912600 | 2023-10-08 | Manchester City WFC | Away | D | 1 | 1 | 2.17 | 0.67 | 1.50 |
| 15 | 3913187 | 2024-05-18 | Manchester United W | Away | W | 6 | 0 | 3.30 | 0.37 | 2.94 |
| 16 | 3913158 | 2024-03-24 | West Ham United LFC | Away | W | 2 | 0 | 1.01 | 1.10 | -0.09 |
| 17 | 3913145 | 2024-03-03 | Leicester City WFC | Away | W | 4 | 0 | 2.70 | 0.49 | 2.21 |
| 18 | 3913123 | 2024-01-27 | Brighton & Hove Albion WFC | Away | W | 3 | 0 | 2.30 | 0.14 | 2.16 |
| 19 | 3913093 | 2023-11-18 | Liverpool WFC | Home | W | 5 | 1 | 2.91 | 0.76 | 2.15 |
| 20 | 3913089 | 2023-11-12 | Everton LFC | Away | W | 3 | 0 | 1.16 | 0.98 | 0.18 |
| 21 | 3912597 | 2023-10-01 | Tottenham Hotspur Women | Home | W | 2 | 1 | 2.28 | 1.80 | 0.48 |
Save the clean dataset.
match_level_df.to_csv("chelsea_match_level_2023_24.csv", index=False)
Re-loading saved data
#Session 3
import pandas as pd
match_level_df = pd.read_csv("chelsea_match_level_2023_24.csv")
match_level_df.head()
| match_id | date | opponent | venue | result | goals_for | goals_against | xg_for | xg_against | xg_diff | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3913165 | 2024-04-17 | Aston Villa W | Home | W | 3 | 0 | 2.83 | 0.36 | 2.48 |
| 1 | 3913134 | 2024-02-04 | Everton LFC | Home | W | 3 | 0 | 3.08 | 0.56 | 2.52 |
| 2 | 3913069 | 2023-10-14 | West Ham United LFC | Home | W | 2 | 0 | 2.41 | 0.86 | 1.56 |
| 3 | 3913180 | 2024-05-05 | Bristol City WFC | Home | W | 8 | 0 | 4.56 | 1.41 | 3.15 |
| 4 | 3913173 | 2024-05-01 | Liverpool WFC | Away | L | 3 | 4 | 1.84 | 1.68 | 0.16 |
print(match_level_df['result'].value_counts())
print()
print(match_level_df[['goals_for', 'goals_against', 'xg_for', 'xg_against', 'xg_diff']].describe())
result
W 18
L 3
D 1
Name: count, dtype: int64
goals_for goals_against xg_for xg_against xg_diff
count 22.000000 22.000000 22.000000 22.000000 22.000000
mean 3.227273 0.818182 2.543636 0.981818 1.562273
std 1.900672 1.220319 1.172618 0.596215 1.454756
min 0.000000 0.000000 0.890000 0.140000 -1.820000
25% 2.000000 0.000000 1.735000 0.560000 0.540000
50% 3.000000 0.000000 2.450000 0.920000 1.670000
75% 4.000000 1.000000 2.890000 1.355000 2.412500
max 8.000000 4.000000 5.190000 2.710000 4.750000
Based on the data above, Chelsea scored an average of 3.23 goals per match, but were only expected to score 2.54 goals per match. That's a difference of about 0.68 goals per match, more than their predicted goals. In a 22-match season, that adds up to about 15 more goals than expected.
import matplotlib.pyplot as plt
match_level_df['xg_diff'].hist(bins=15)
plt.xlabel('xG Differential (Chelsea xG - Opponent xG)')
plt.ylabel('Number of Matches')
plt.title('Distribution of Chelsea xG Differential, 2023/24')
plt.show()
Most values are positive which shows that Chelsea outperformed opponents in underlying chances most matches.
match_level_df.boxplot(column='xg_diff', by='result')
plt.title('xG Differential by Match Result')
plt.suptitle('') # removes the auto-generated subtitle pandas adds
plt.xlabel('Result')
plt.ylabel('xG Differential')
plt.show()
Wins are clustered at a higher xG differential and losses are clustered at a lower/negative value.
plt.scatter(match_level_df['xg_for'], match_level_df['goals_for'])
plt.plot([0, match_level_df['xg_for'].max()], [0, match_level_df['xg_for'].max()], 'r--', label='Perfect xG conversion')
plt.xlabel('xG For')
plt.ylabel('Actual Goals For')
plt.title('Chelsea: Actual Goals vs. xG per Match')
plt.legend()
plt.show()
The points above the red line indicate the matches that Chelsea scored more than their xG (overperformance).
# Any matches with 0 xG? Missing data? Extreme outliers?
print(match_level_df[match_level_df['xg_for'] == 0])
print(match_level_df.sort_values('xg_diff').head(3)) # worst xG diff matches
print(match_level_df.sort_values('xg_diff', ascending=False).head(3)) # best xG diff matches
Empty DataFrame
Columns: [match_id, date, opponent, venue, result, goals_for, goals_against, xg_for, xg_against, xg_diff]
Index: []
match_id date opponent venue result goals_for \
10 3913105 2023-12-10 Arsenal WFC Away L 1
16 3913158 2024-03-24 West Ham United LFC Away W 2
7 3913138 2024-02-16 Manchester City WFC Home L 0
goals_against xg_for xg_against xg_diff
10 4 0.89 2.71 -1.82
16 0 1.01 1.10 -0.09
7 1 1.47 1.45 0.02
match_id date opponent venue result goals_for \
13 3913081 2023-11-04 Aston Villa W Away W 6
12 3913077 2023-10-22 Brighton & Hove Albion WFC Home W 4
3 3913180 2024-05-05 Bristol City WFC Home W 8
goals_against xg_for xg_against xg_diff
13 0 5.19 0.43 4.75
12 2 4.94 1.39 3.55
3 0 4.56 1.41 3.15
One callout is that they lost to Man City when they had a positive xG diff value.
print(match_level_df.sort_values('goals_for', ascending=False).head(5)[['opponent', 'goals_for', 'xg_for', 'xg_diff']])
opponent goals_for xg_for xg_diff 3 Bristol City WFC 8 4.56 3.15 15 Manchester United W 6 3.30 2.94 13 Aston Villa W 6 5.19 4.75 11 Leicester City WFC 5 2.49 1.24 19 Liverpool WFC 5 2.91 2.15
#Session 4
import pandas as pd
import numpy as np
match_level_df = pd.read_csv("chelsea_match_level_2023_24.csv")
match_level_df.head()
| match_id | date | opponent | venue | result | goals_for | goals_against | xg_for | xg_against | xg_diff | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3913165 | 2024-04-17 | Aston Villa W | Home | W | 3 | 0 | 2.83 | 0.36 | 2.48 |
| 1 | 3913134 | 2024-02-04 | Everton LFC | Home | W | 3 | 0 | 3.08 | 0.56 | 2.52 |
| 2 | 3913069 | 2023-10-14 | West Ham United LFC | Home | W | 2 | 0 | 2.41 | 0.86 | 1.56 |
| 3 | 3913180 | 2024-05-05 | Bristol City WFC | Home | W | 8 | 0 | 4.56 | 1.41 | 3.15 |
| 4 | 3913173 | 2024-05-01 | Liverpool WFC | Away | L | 3 | 4 | 1.84 | 1.68 | 0.16 |
result_map = {'W': 3, 'D': 1, 'L': 0}
match_level_df['result_points'] = match_level_df['result'].map(result_map)
match_level_df[['opponent', 'result', 'result_points', 'xg_diff']]
| opponent | result | result_points | xg_diff | |
|---|---|---|---|---|
| 0 | Aston Villa W | W | 3 | 2.48 |
| 1 | Everton LFC | W | 3 | 2.52 |
| 2 | West Ham United LFC | W | 3 | 1.56 |
| 3 | Bristol City WFC | W | 3 | 3.15 |
| 4 | Liverpool WFC | L | 0 | 0.16 |
| 5 | Tottenham Hotspur Women | W | 3 | 0.87 |
| 6 | Arsenal WFC | W | 3 | 1.86 |
| 7 | Manchester City WFC | L | 0 | 0.02 |
| 8 | Manchester United W | W | 3 | 0.72 |
| 9 | Bristol City WFC | W | 3 | 1.78 |
| 10 | Arsenal WFC | L | 0 | -1.82 |
| 11 | Leicester City WFC | W | 3 | 1.24 |
| 12 | Brighton & Hove Albion WFC | W | 3 | 3.55 |
| 13 | Aston Villa W | W | 3 | 4.75 |
| 14 | Manchester City WFC | D | 1 | 1.50 |
| 15 | Manchester United W | W | 3 | 2.94 |
| 16 | West Ham United LFC | W | 3 | -0.09 |
| 17 | Leicester City WFC | W | 3 | 2.21 |
| 18 | Brighton & Hove Albion WFC | W | 3 | 2.16 |
| 19 | Liverpool WFC | W | 3 | 2.15 |
| 20 | Everton LFC | W | 3 | 0.18 |
| 21 | Tottenham Hotspur Women | W | 3 | 0.48 |
Mapping the results to a numerical value to use for correlation. Callout - they lost to Liverpool but had an xG diff of 0.16, an example of where Chelsea should've done better than the result.
Chelsea's sole draw of the season came agianst Manchester City, despite generating 1.50 more expected goals than their opponent - one of the clearest single-match gaps between underlying performance and result in the dataset.
correlation = match_level_df['xg_diff'].corr(match_level_df['result_points'])
print(f"Correlation between xG differential and result: {correlation:.3f}")
Correlation between xG differential and result: 0.568
A positive value (0.4-0.7) confirms xG diff and better results move together.
match_level_df['goal_diff'] = match_level_df['goals_for'] - match_level_df['goals_against']
goal_diff_corr = match_level_df['goal_diff'].corr(match_level_df['result_points'])
print(f"Correlation between goal differential and result: {goal_diff_corr:.3f}")
Correlation between goal differential and result: 0.728
This is a sanity check. This tells whether xG diff predicts outcome better or worse than just knowing the actual goal difference. Note how much lower xG diff is than this.
The correlation coefficient measures the linear relationship between the magnitude of goal_diff and result_points. Since result_points only takes 3 values (0,1,3) while goal_diff ranges widely (a 1-0 win and an 8-0 win are both just 3 points), that variation adds noise to a linear correlation even though the outcome itself is deterministic.
without_outlier = match_level_df[match_level_df['opponent'] != 'Bristol City WFC']
corr_no_outlier = without_outlier['xg_diff'].corr(without_outlier['result_points'])
print(f"Correlation without Bristol City match: {corr_no_outlier:.3f}")
print(f"Correlation with Bristol City match: {correlation:.3f}")
Correlation without Bristol City match: 0.561 Correlation with Bristol City match: 0.568
These two values have a very small difference, so the outlier isn't distorting much.
from scipy import stats
wins = match_level_df[match_level_df['result'] == 'W']['xg_diff']
draws = match_level_df[match_level_df['result'] == 'D']['xg_diff']
losses = match_level_df[match_level_df['result'] == 'L']['xg_diff']
print(f"n wins={len(wins)}, n draws={len(draws)}, n losses={len(losses)}")
# Kruskal-Wallis is safer than ANOVA with small/uneven group sizes
h_stat, p_value = stats.kruskal(wins, draws, losses)
print(f"Kruskal-Wallis H-statistic: {h_stat:.3f}, p-value: {p_value:.4f}")
n wins=18, n draws=1, n losses=3 Kruskal-Wallis H-statistic: 6.638, p-value: 0.0362
The p-value is less than 0.05 which is evidence that xG actually differs across the three outcome groups. The draws group has only 1 match.
Wins vs. losses is a meaningful comparison. Draws can't be statistically characterized. Just a limitation of a single 22-match season.
h_stat_2group, p_value_2group = stats.kruskal(wins, losses)
print(f"Wins vs Losses only — H-statistic: {h_stat_2group:.3f}, p-value: {p_value_2group:.4f}")
Wins vs Losses only — H-statistic: 6.313, p-value: 0.0120
The xG diff between these two groups is statistically significant since p = 0.0120 < 0.05. This means that Chelsea's underlying performance (xG diff) reilably distinguished their wins from their losses this season. The losses data group is still small - 'significant in this season's sample'
findings = {
'xg_diff_result_correlation': correlation,
'goal_diff_result_correlation': goal_diff_corr,
'kruskal_h_stat_wins_losses': h_stat_2group,
'kruskal_p_value_wins_losses': p_value_2group,
'n_wins': len(wins),
'n_draws': len(draws),
'n_losses': len(losses),
'draw_note': 'Only draw was vs Man City, xG diff +1.50 — underperformance relative to underlying numbers',
'method_note': 'Used wins-vs-losses Kruskal-Wallis rather than 3-group, since n=1 draw is not a statistically meaningful group'
}
print(findings)
import json
with open('session4_findings.json', 'w') as f:
json.dump(findings, f, indent=2)
{'xg_diff_result_correlation': np.float64(0.5675149273777641), 'goal_diff_result_correlation': np.float64(0.7283377035825139), 'kruskal_h_stat_wins_losses': np.float64(6.313131313131322), 'kruskal_p_value_wins_losses': np.float64(0.011984698922786682), 'n_wins': 18, 'n_draws': 1, 'n_losses': 3, 'draw_note': 'Only draw was vs Man City, xG diff +1.50 — underperformance relative to underlying numbers', 'method_note': 'Used wins-vs-losses Kruskal-Wallis rather than 3-group, since n=1 draw is not a statistically meaningful group'}
# Session 5
import pandas as pd
import numpy as np
match_level_df = pd.read_csv("chelsea_match_level_2023_24.csv")
match_level_df.head()
| match_id | date | opponent | venue | result | goals_for | goals_against | xg_for | xg_against | xg_diff | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3913165 | 2024-04-17 | Aston Villa W | Home | W | 3 | 0 | 2.83 | 0.36 | 2.48 |
| 1 | 3913134 | 2024-02-04 | Everton LFC | Home | W | 3 | 0 | 3.08 | 0.56 | 2.52 |
| 2 | 3913069 | 2023-10-14 | West Ham United LFC | Home | W | 2 | 0 | 2.41 | 0.86 | 1.56 |
| 3 | 3913180 | 2024-05-05 | Bristol City WFC | Home | W | 8 | 0 | 4.56 | 1.41 | 3.15 |
| 4 | 3913173 | 2024-05-01 | Liverpool WFC | Away | L | 3 | 4 | 1.84 | 1.68 | 0.16 |
match_level_df['attack_residual'] = match_level_df['goals_for'] - match_level_df['xg_for']
match_level_df['defense_residual'] = match_level_df['goals_against'] - match_level_df['xg_against']
# A combined "overall luck/performance" measure
match_level_df['net_residual'] = match_level_df['attack_residual'] - match_level_df['defense_residual']
match_level_df[['opponent', 'result', 'goals_for', 'xg_for', 'attack_residual',
'goals_against', 'xg_against', 'defense_residual', 'net_residual']].round(2)
| opponent | result | goals_for | xg_for | attack_residual | goals_against | xg_against | defense_residual | net_residual | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Aston Villa W | W | 3 | 2.83 | 0.17 | 0 | 0.36 | -0.36 | 0.53 |
| 1 | Everton LFC | W | 3 | 3.08 | -0.08 | 0 | 0.56 | -0.56 | 0.48 |
| 2 | West Ham United LFC | W | 2 | 2.41 | -0.41 | 0 | 0.86 | -0.86 | 0.45 |
| 3 | Bristol City WFC | W | 8 | 4.56 | 3.44 | 0 | 1.41 | -1.41 | 4.85 |
| 4 | Liverpool WFC | L | 3 | 1.84 | 1.16 | 4 | 1.68 | 2.32 | -1.16 |
| 5 | Tottenham Hotspur Women | W | 1 | 1.43 | -0.43 | 0 | 0.56 | -0.56 | 0.13 |
| 6 | Arsenal WFC | W | 3 | 2.49 | 0.51 | 1 | 0.62 | 0.38 | 0.13 |
| 7 | Manchester City WFC | L | 0 | 1.47 | -1.47 | 1 | 1.45 | -0.45 | -1.02 |
| 8 | Manchester United W | W | 3 | 1.70 | 1.30 | 1 | 0.98 | 0.02 | 1.28 |
| 9 | Bristol City WFC | W | 3 | 2.81 | 0.19 | 0 | 1.03 | -1.03 | 1.22 |
| 10 | Arsenal WFC | L | 1 | 0.89 | 0.11 | 4 | 2.71 | 1.29 | -1.18 |
| 11 | Leicester City WFC | W | 5 | 2.49 | 2.51 | 2 | 1.25 | 0.75 | 1.76 |
| 12 | Brighton & Hove Albion WFC | W | 4 | 4.94 | -0.94 | 2 | 1.39 | 0.61 | -1.55 |
| 13 | Aston Villa W | W | 6 | 5.19 | 0.81 | 0 | 0.43 | -0.43 | 1.24 |
| 14 | Manchester City WFC | D | 1 | 2.17 | -1.17 | 1 | 0.67 | 0.33 | -1.50 |
| 15 | Manchester United W | W | 6 | 3.30 | 2.70 | 0 | 0.37 | -0.37 | 3.07 |
| 16 | West Ham United LFC | W | 2 | 1.01 | 0.99 | 0 | 1.10 | -1.10 | 2.09 |
| 17 | Leicester City WFC | W | 4 | 2.70 | 1.30 | 0 | 0.49 | -0.49 | 1.79 |
| 18 | Brighton & Hove Albion WFC | W | 3 | 2.30 | 0.70 | 0 | 0.14 | -0.14 | 0.84 |
| 19 | Liverpool WFC | W | 5 | 2.91 | 2.09 | 1 | 0.76 | 0.24 | 1.85 |
| 20 | Everton LFC | W | 3 | 1.16 | 1.84 | 0 | 0.98 | -0.98 | 2.82 |
| 21 | Tottenham Hotspur Women | W | 2 | 2.28 | -0.28 | 1 | 1.80 | -0.80 | 0.52 |
Positive attack_residual = scored more chances than deserved. Positive defense_residual = conceded more goals than expected.
print("TOP 5 OVERPERFORMANCES (net_residual):")
print(match_level_df.sort_values('net_residual', ascending=False).head(5)[
['opponent', 'result', 'goals_for', 'xg_for', 'goals_against', 'xg_against', 'net_residual']
].round(2))
print("\nTOP 5 UNDERPERFORMANCES (net_residual):")
print(match_level_df.sort_values('net_residual').head(5)[
['opponent', 'result', 'goals_for', 'xg_for', 'goals_against', 'xg_against', 'net_residual']
].round(2))
TOP 5 OVERPERFORMANCES (net_residual):
opponent result goals_for xg_for goals_against xg_against \
3 Bristol City WFC W 8 4.56 0 1.41
15 Manchester United W W 6 3.30 0 0.37
20 Everton LFC W 3 1.16 0 0.98
16 West Ham United LFC W 2 1.01 0 1.10
19 Liverpool WFC W 5 2.91 1 0.76
net_residual
3 4.85
15 3.07
20 2.82
16 2.09
19 1.85
TOP 5 UNDERPERFORMANCES (net_residual):
opponent result goals_for xg_for goals_against \
12 Brighton & Hove Albion WFC W 4 4.94 2
14 Manchester City WFC D 1 2.17 1
10 Arsenal WFC L 1 0.89 4
4 Liverpool WFC L 3 1.84 4
7 Manchester City WFC L 0 1.47 1
xg_against net_residual
12 1.39 -1.55
14 0.67 -1.50
10 2.71 -1.18
4 1.68 -1.16
7 1.45 -1.02
All 5 top overperformances are wins, but there is one win result in the top 5 underperformances that are also wins. When Chelsea outscored their xG, they tended to win, but when they underperformed xG, results suffered.
Chelsea's win over Brighton masked a defensive underperformance - they conceded nearly 1.5x their expected goals against, even in a game they controlled offensively.
total_attack_residual = match_level_df['attack_residual'].sum()
total_defense_residual = match_level_df['defense_residual'].sum()
print(f"Season total — goals scored above xG: {total_attack_residual:.2f}")
print(f"Season total — goals conceded above xG against: {total_defense_residual:.2f}")
Season total — goals scored above xG: 15.04 Season total — goals conceded above xG against: -3.60
Chelsea overperformed offensively and defensively. They scored more than their chances deserved (+15.04) and conceded less than their opponents' chances deserved (-3.60). Overall, that's about an 18.6-goal swing in Chelsea's favor across the season, beyond what raw chance quality alone would predict.
match_level_df['net_residual_zscore'] = (
(match_level_df['net_residual'] - match_level_df['net_residual'].mean())
/ match_level_df['net_residual'].std()
)
outliers = match_level_df[match_level_df['net_residual_zscore'].abs() > 1.5]
print(f"Matches with |z-score| > 1.5: {len(outliers)}")
print(outliers[['opponent', 'result', 'net_residual', 'net_residual_zscore']].round(2))
Matches with |z-score| > 1.5: 1
opponent result net_residual net_residual_zscore
3 Bristol City WFC W 4.85 2.5
One true statistical outlier (z=2.5) and a handful of notable-but-not-extreme matches that are meaningfully large but don't cross a strict threshold.
outliers_loose = match_level_df[match_level_df['net_residual_zscore'].abs() > 1.0]
print(f"Matches with |z-score| > 1.0: {len(outliers_loose)}")
print(outliers_loose[['opponent', 'result', 'net_residual', 'net_residual_zscore']].round(2))
Matches with |z-score| > 1.0: 8
opponent result net_residual net_residual_zscore
3 Bristol City WFC W 4.85 2.50
4 Liverpool WFC L -1.16 -1.25
7 Manchester City WFC L -1.02 -1.16
10 Arsenal WFC L -1.18 -1.26
12 Brighton & Hove Albion WFC W -1.55 -1.49
14 Manchester City WFC D -1.50 -1.46
15 Manchester United W W 3.07 1.39
20 Everton LFC W 2.82 1.23
import matplotlib.pyplot as plt
match_level_df_sorted = match_level_df.sort_values('date')
plt.figure(figsize=(10, 5))
plt.bar(range(len(match_level_df_sorted)), match_level_df_sorted['net_residual'],
color=['green' if x > 0 else 'red' for x in match_level_df_sorted['net_residual']])
plt.axhline(0, color='black', linewidth=0.8)
plt.xlabel('Match (chronological order)')
plt.ylabel('Net Residual (overperformance / underperformance)')
plt.title('Chelsea Women 2023/24: Over/Underperformance vs. xG, By Match')
plt.tight_layout()
plt.show()
Overperformance clusters started early in the season and continued throughout.
match_level_df.to_csv("chelsea_match_level_with_residuals.csv", index=False)
session5_summary = {
'total_attack_residual': round(total_attack_residual, 2),
'total_defense_residual': round(total_defense_residual, 2),
'n_outlier_matches': len(outliers),
'outlier_opponents': outliers['opponent'].tolist()
}
print(session5_summary)
import json
with open('session5_findings.json', 'w') as f:
json.dump(session5_summary, f, indent=2)
{'total_attack_residual': np.float64(15.04), 'total_defense_residual': np.float64(-3.6), 'n_outlier_matches': 1, 'outlier_opponents': ['Bristol City WFC']}
#Session 6
import pandas as pd
import numpy as np
match_level_df = pd.read_csv("chelsea_match_level_with_residuals.csv")
match_level_df.head()
| match_id | date | opponent | venue | result | goals_for | goals_against | xg_for | xg_against | xg_diff | attack_residual | defense_residual | net_residual | net_residual_zscore | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3913165 | 2024-04-17 | Aston Villa W | Home | W | 3 | 0 | 2.83 | 0.36 | 2.48 | 0.17 | -0.36 | 0.53 | -0.197822 |
| 1 | 3913134 | 2024-02-04 | Everton LFC | Home | W | 3 | 0 | 3.08 | 0.56 | 2.52 | -0.08 | -0.56 | 0.48 | -0.228997 |
| 2 | 3913069 | 2023-10-14 | West Ham United LFC | Home | W | 2 | 0 | 2.41 | 0.86 | 1.56 | -0.41 | -0.86 | 0.45 | -0.247702 |
| 3 | 3913180 | 2024-05-05 | Bristol City WFC | Home | W | 8 | 0 | 4.56 | 1.41 | 3.15 | 3.44 | -1.41 | 4.85 | 2.495727 |
| 4 | 3913173 | 2024-05-01 | Liverpool WFC | Away | L | 3 | 4 | 1.84 | 1.68 | 0.16 | 1.16 | 2.32 | -1.16 | -1.251548 |
import statsmodels.api as sm
import statsmodels.formula.api as smf
from sklearn.metrics import confusion_matrix, classification_report
result_codes = {'L': 0, 'D': 1, 'W': 2}
model_data = match_level_df[['result', 'xg_diff']].copy()
model_data['result_code'] = model_data['result'].map(result_codes)
mn_model = smf.mnlogit('result_code ~ xg_diff', data=model_data).fit()
print(mn_model.summary())
Optimization terminated successfully.
Current function value: 0.325568
Iterations 10
MNLogit Regression Results
==============================================================================
Dep. Variable: result_code No. Observations: 22
Model: MNLogit Df Residuals: 18
Method: MLE Df Model: 2
Date: Thu, 27 Aug 2026 Pseudo R-squ.: 0.4352
Time: 20:39:54 Log-Likelihood: -7.1625
converged: True LL-Null: -12.680
Covariance Type: nonrobust LLR p-value: 0.004014
=================================================================================
result_code=1 coef std err z P>|z| [0.025 0.975]
---------------------------------------------------------------------------------
Intercept -2.5508 1.967 -1.297 0.195 -6.407 1.305
xg_diff 4.2711 4.057 1.053 0.292 -3.680 12.222
---------------------------------------------------------------------------------
result_code=2 coef std err z P>|z| [0.025 0.975]
---------------------------------------------------------------------------------
Intercept -0.2128 1.131 -0.188 0.851 -2.430 2.005
xg_diff 4.5955 3.969 1.158 0.247 -3.183 12.374
=================================================================================
Attempted a 3-class multinomial model, but with only 1 draw in the season, individual coefficients were statistically unstable, so we pivoted to a binary Win/Not-Win model as the primary, more defensible result.
match_level_df['is_win'] = (match_level_df['result'] == 'W').astype(int)
logit_model = smf.logit('is_win ~ xg_diff', data=match_level_df).fit()
print(logit_model.summary())
Optimization terminated successfully.
Current function value: 0.306144
Iterations 8
Logit Regression Results
==============================================================================
Dep. Variable: is_win No. Observations: 22
Model: Logit Df Residuals: 20
Method: MLE Df Model: 1
Date: Thu, 27 Aug 2026 Pseudo R-squ.: 0.3543
Time: 21:00:20 Log-Likelihood: -6.7352
converged: True LL-Null: -10.431
Covariance Type: nonrobust LLR p-value: 0.006552
==============================================================================
coef std err z P>|z| [0.025 0.975]
------------------------------------------------------------------------------
Intercept 0.1110 0.838 0.132 0.895 -1.532 1.754
xg_diff 1.5134 0.811 1.867 0.062 -0.076 3.102
==============================================================================
The model is significant since the LLR p-value = 0.0066. This means that including xg_diff meaningfully improves the model over just guessing based on the win rate alone. And the confidence interval on xg_diff is tighter than the sprawling multinomial ones.
The individual's coefficient's p-value is 0.062, which is higher than 0.05. Which means that xG diff is a strong, positive predictor of winning, but with only 22 matches, we can't say it's "statistically significant".
odds_ratio = np.exp(logit_model.params['xg_diff'])
print(f"Odds ratio for xg_diff: {odds_ratio:.3f}")
Odds ratio for xg_diff: 4.542
This means that every 1-unit increase in xG differential roughly quadruples the odds of winning.
match_level_df['predicted_win_prob'] = logit_model.predict(match_level_df)
match_level_df['predicted_win'] = (match_level_df['predicted_win_prob'] > 0.5).astype(int)
print(match_level_df[['opponent', 'result', 'xg_diff', 'predicted_win_prob', 'predicted_win']].round(2))
opponent result xg_diff predicted_win_prob \
0 Aston Villa W W 2.48 0.98
1 Everton LFC W 2.52 0.98
2 West Ham United LFC W 1.56 0.92
3 Bristol City WFC W 3.15 0.99
4 Liverpool WFC L 0.16 0.59
5 Tottenham Hotspur Women W 0.87 0.81
6 Arsenal WFC W 1.86 0.95
7 Manchester City WFC L 0.02 0.54
8 Manchester United W W 0.72 0.77
9 Bristol City WFC W 1.78 0.94
10 Arsenal WFC L -1.82 0.07
11 Leicester City WFC W 1.24 0.88
12 Brighton & Hove Albion WFC W 3.55 1.00
13 Aston Villa W W 4.75 1.00
14 Manchester City WFC D 1.50 0.92
15 Manchester United W W 2.94 0.99
16 West Ham United LFC W -0.09 0.49
17 Leicester City WFC W 2.21 0.97
18 Brighton & Hove Albion WFC W 2.16 0.97
19 Liverpool WFC W 2.15 0.97
20 Everton LFC W 0.18 0.59
21 Tottenham Hotspur Women W 0.48 0.70
predicted_win
0 1
1 1
2 1
3 1
4 1
5 1
6 1
7 1
8 1
9 1
10 0
11 1
12 1
13 1
14 1
15 1
16 0
17 1
18 1
19 1
20 1
21 1
The Man City draw had a predicted win probability of 92%. So, the model thought Chelsea should have won this match with near-certainty, but it ended in a draw instead. (Underperformance highlight backed by a formal model)
Liverpool loss predicted 59% chance to win, but Chelsea lost. The Man City loss was 54% prediction win for Chelsea, but they lost. (Barely favored to win, lost anyway). The West Ham game had a 49% win prediction, but Chelsea actually one.
cm = confusion_matrix(match_level_df['is_win'], match_level_df['predicted_win'])
print("Confusion Matrix:")
print(cm)
print()
print(classification_report(match_level_df['is_win'], match_level_df['predicted_win']))
Confusion Matrix:
[[ 1 3]
[ 1 17]]
precision recall f1-score support
0 0.50 0.25 0.33 4
1 0.85 0.94 0.89 18
accuracy 0.82 22
macro avg 0.68 0.60 0.61 22
weighted avg 0.79 0.82 0.79 22
This model is good at spotting wins (94% recall for 18 match wins), but is bad at spotting non-wins (25% recall for 4 non-win matches)
draw_row = match_level_df[match_level_df['result'] == 'D']
print(draw_row[['opponent', 'xg_diff', 'predicted_win_prob']])
opponent xg_diff predicted_win_prob 14 Manchester City WFC 1.5 0.915372
Noting the draw separately since the binary model excludes it from win analysis. The 92% predicted win probability means that the model thought Chelsea should have won this game.
match_level_df.to_csv("chelsea_match_level_with_predictions.csv", index=False)
session6_summary = {
'logit_coefficient_xg_diff': round(logit_model.params['xg_diff'], 3),
'logit_pvalue_xg_diff': round(logit_model.pvalues['xg_diff'], 4),
'odds_ratio': round(odds_ratio, 3),
'pseudo_r_squared': round(logit_model.prsquared, 3),
'model_note': 'Multinomial (3-class W/D/L) model was unstable due to n=1 draw; pivoted to binary Win/Not-Win logistic regression as the primary model'
}
print(session6_summary)
import json
with open('session6_findings.json', 'w') as f:
json.dump(session6_summary, f, indent=2)
{'logit_coefficient_xg_diff': np.float64(1.513), 'logit_pvalue_xg_diff': np.float64(0.062), 'odds_ratio': np.float64(4.542), 'pseudo_r_squared': np.float64(0.354), 'model_note': 'Multinomial (3-class W/D/L) model was unstable due to n=1 draw; pivoted to binary Win/Not-Win logistic regression as the primary model'}
#Session 7
import pandas as pd
import matplotlib.pyplot as plt
match_level_df = pd.read_csv("chelsea_match_level_with_predictions.csv")
match_level_df['date'] = pd.to_datetime(match_level_df['date'])
match_level_df = match_level_df.sort_values('date').reset_index(drop=True)
match_level_df.head()
| match_id | date | opponent | venue | result | goals_for | goals_against | xg_for | xg_against | xg_diff | attack_residual | defense_residual | net_residual | net_residual_zscore | is_win | predicted_win_prob | predicted_win | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3912597 | 2023-10-01 | Tottenham Hotspur Women | Home | W | 2 | 1 | 2.28 | 1.80 | 0.48 | -0.28 | -0.80 | 0.52 | -0.204057 | 1 | 0.697922 | 1 |
| 1 | 3912600 | 2023-10-08 | Manchester City WFC | Away | D | 1 | 1 | 2.17 | 0.67 | 1.50 | -1.17 | 0.33 | -1.50 | -1.463540 | 0 | 0.915372 | 1 |
| 2 | 3913069 | 2023-10-14 | West Ham United LFC | Home | W | 2 | 0 | 2.41 | 0.86 | 1.56 | -0.41 | -0.86 | 0.45 | -0.247702 | 1 | 0.922146 | 1 |
| 3 | 3913077 | 2023-10-22 | Brighton & Hove Albion WFC | Home | W | 4 | 2 | 4.94 | 1.39 | 3.55 | -0.94 | 0.61 | -1.55 | -1.494715 | 1 | 0.995862 | 1 |
| 4 | 3913081 | 2023-11-04 | Aston Villa W | Away | W | 6 | 0 | 5.19 | 0.43 | 4.75 | 0.81 | -0.43 | 1.24 | 0.244868 | 1 | 0.999325 | 1 |
fig, ax = plt.subplots(figsize=(9, 7))
colors = match_level_df['result'].map({'W': '#1f77b4', 'D': '#ff7f0e', 'L': '#d62728'})
ax.scatter(match_level_df['xg_for'], match_level_df['goals_for'], c=colors, s=80, edgecolors='black', linewidths=0.5, zorder=3)
max_val = max(match_level_df['xg_for'].max(), match_level_df['goals_for'].max()) + 1
ax.plot([0, max_val], [0, max_val], 'k--', alpha=0.5, label='Expected (Goals = xG)', zorder=1)
# Label the standout matches
standouts = match_level_df[match_level_df['opponent'].isin(['Bristol City WFC', 'Manchester City WFC'])
& (match_level_df['net_residual'].abs() > 1.4)]
for _, row in standouts.iterrows():
ax.annotate(f"{row['opponent'].replace(' WFC','').replace(' FCW','')}\n({row['result']})",
(row['xg_for'], row['goals_for']),
textcoords="offset points", xytext=(8, 5), fontsize=8)
ax.set_xlabel('Expected Goals (xG For)')
ax.set_ylabel('Actual Goals Scored')
ax.set_title("Chelsea Women 2023/24: Actual Goals vs. xG, by Match Result")
from matplotlib.lines import Line2D
legend_elements = [Line2D([0], [0], marker='o', color='w', label='Win', markerfacecolor='#1f77b4', markersize=8),
Line2D([0], [0], marker='o', color='w', label='Draw', markerfacecolor='#ff7f0e', markersize=8),
Line2D([0], [0], marker='o', color='w', label='Loss', markerfacecolor='#d62728', markersize=8),
Line2D([0], [0], color='k', linestyle='--', alpha=0.5, label='Expected (Goals = xG)')]
ax.legend(handles=legend_elements, loc='upper left')
plt.tight_layout()
plt.savefig('chart1_goals_vs_xg.png', dpi=150)
plt.show()
This is a clean scatter with color-coded W/D/L, a diagonal reference line, and 1-2 key matches labeled by name. Opening report visual.
fig, ax = plt.subplots(figsize=(11, 5))
colors2 = ['#2ca02c' if x > 0 else '#d62728' for x in match_level_df['net_residual']]
bars = ax.bar(range(len(match_level_df)), match_level_df['net_residual'], color=colors2, edgecolor='black', linewidth=0.3)
ax.axhline(0, color='black', linewidth=1)
ax.set_xticks(range(len(match_level_df)))
ax.set_xticklabels(match_level_df['opponent'].str.replace(' WFC','').str.replace(' FCW','').str.replace(' Women',''),
rotation=75, ha='right', fontsize=8)
ax.set_ylabel('Net Residual (Goal Performance vs. xG)')
ax.set_title('Chelsea Women 2023/24: Match-by-Match Performance vs. Expected Goals')
# Annotate the one true statistical outlier
outlier_idx = match_level_df[match_level_df['opponent'] == 'Bristol City WFC']['net_residual'].idxmax()
ax.annotate('Statistical outlier\n(z = 2.5)',
xy=(outlier_idx, match_level_df.loc[outlier_idx, 'net_residual']),
xytext=(outlier_idx, match_level_df.loc[outlier_idx, 'net_residual'] + 0.5),
ha='center', fontsize=8, fontweight='bold')
plt.tight_layout()
plt.savefig('chart2_season_timeline.png', dpi=150)
plt.show()
Chronological order of matches. The y-axis is a combined attack+defence performance vs. what xG predicted. Green bars = matches where Chelsea outperformanced the underlying numbers (scored more/conceded less than expected). Red bars = matches where Chelsea underperformed the the underlying numbers.
fig, ax = plt.subplots(figsize=(9, 6))
colors3 = match_level_df['is_win'].map({1: '#1f77b4', 0: '#d62728'})
ax.scatter(match_level_df['xg_diff'], match_level_df['predicted_win_prob'], c=colors3, s=70, edgecolors='black', linewidths=0.5)
ax.axhline(0.5, color='gray', linestyle=':', alpha=0.7, label='50% decision threshold')
# Label the Man City draw specifically
draw_row = match_level_df[match_level_df['result'] == 'D'].iloc[0]
ax.annotate('Man City (Draw)\nPredicted 92% win',
(draw_row['xg_diff'], draw_row['predicted_win_prob']),
textcoords="offset points", xytext=(-10, -20), fontsize=9, fontweight='bold',
arrowprops=dict(arrowstyle='->', lw=1))
ax.set_xlabel('xG Differential')
ax.set_ylabel('Model-Predicted Win Probability')
ax.set_title('Predicted Win Probability vs. xG Differential')
legend_elements = [Line2D([0], [0], marker='o', color='w', label='Actual Win', markerfacecolor='#1f77b4', markersize=8),
Line2D([0], [0], marker='o', color='w', label='Actual Non-Win', markerfacecolor='#d62728', markersize=8)]
ax.legend(handles=legend_elements, loc='lower right')
plt.tight_layout()
plt.savefig('chart3_model_fit.png', dpi=150)
plt.show()
X-axis: Chelsea's xG minus opponent's xG. Y-axis: the model's predicted win probability for that match. Blue points - matches Chelsea actually won. Red points - matches Chelsea actually did not win. The dotted grey line at 0.5 is the model's decision threshold: above it is 'predicted win', below it is 'predicted non-win'.
chart1_goals_vs_xg.png is the headline visual for the write-up — it plots actual goals against xG per match, color-coded by result. Most points sit above the diagonal reference line, visually confirming Chelsea's season-long attacking overperformance (+15.04 goals above xG). The labeled outliers — Bristol City (an 8-goal outburst on just 4.56 xG) and the Man City draw — show the two extremes of this story: one match where Chelsea drastically outscored their chances, and one where strong underlying numbers didn't convert into a win at all.
chart3_model_fit.png shows the logistic regression's predicted win probability against xG differential, with actual outcomes color-coded. Higher xG differential clearly pushes predicted win probability upward, consistent with the model's significant fit (LLR p=0.007). The most important callout is the Man City draw: despite generating a strong +1.50 xG differential, the model predicted a 92% chance of a Chelsea win — making this the single largest gap between underlying performance and actual result in the season.