msds-comms-plotter 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msds_comms_plotter/__init__.py +10 -0
- msds_comms_plotter/altair_charts.py +224 -0
- msds_comms_plotter/chartkit.py +248 -0
- msds_comms_plotter/worldcup.py +313 -0
- msds_comms_plotter-0.1.0.dist-info/METADATA +685 -0
- msds_comms_plotter-0.1.0.dist-info/RECORD +9 -0
- msds_comms_plotter-0.1.0.dist-info/WHEEL +5 -0
- msds_comms_plotter-0.1.0.dist-info/licenses/LICENSE +339 -0
- msds_comms_plotter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Fetch and build FIFA World Cup 2022 player statistics from StatsBomb open data.
|
|
2
|
+
|
|
3
|
+
Source: https://github.com/statsbomb/open-data (free, event-level data).
|
|
4
|
+
FIFA World Cup 2022 -> competition_id=43, season_id=106 (64 matches).
|
|
5
|
+
|
|
6
|
+
This module uses **pandas** only (plus the standard library and ``requests``).
|
|
7
|
+
Raw JSON is cached under ``data/raw/`` and the processed per-player-per-match
|
|
8
|
+
table is written to ``data/processed/`` as Parquet (never CSV).
|
|
9
|
+
|
|
10
|
+
Run as a script::
|
|
11
|
+
|
|
12
|
+
python -m msds_comms_plotter.worldcup
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
BASE = "https://raw.githubusercontent.com/statsbomb/open-data/master/data"
|
|
25
|
+
COMPETITION_ID = 43
|
|
26
|
+
SEASON_ID = 106
|
|
27
|
+
|
|
28
|
+
# Resolve project directories relative to this file (src/msds_comms_plotter/).
|
|
29
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
30
|
+
RAW_DIR = PROJECT_ROOT / "data" / "raw" / "statsbomb"
|
|
31
|
+
PROCESSED_DIR = PROJECT_ROOT / "data" / "processed"
|
|
32
|
+
|
|
33
|
+
SHOTS_ON_TARGET = {"Goal", "Saved", "Saved to Post"}
|
|
34
|
+
YELLOW = {"Yellow Card", "Second Yellow"}
|
|
35
|
+
RED = {"Red Card", "Second Yellow"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# Fetching (with a simple on-disk cache so we never re-download)
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
def _get_json(url: str, cache_path: Path, session: requests.Session):
|
|
42
|
+
"""Return parsed JSON for ``url``, caching the raw bytes at ``cache_path``."""
|
|
43
|
+
if cache_path.exists():
|
|
44
|
+
return json.loads(cache_path.read_text())
|
|
45
|
+
for attempt in range(4):
|
|
46
|
+
try:
|
|
47
|
+
resp = session.get(url, timeout=60)
|
|
48
|
+
resp.raise_for_status()
|
|
49
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
cache_path.write_bytes(resp.content)
|
|
51
|
+
return resp.json()
|
|
52
|
+
except requests.RequestException:
|
|
53
|
+
if attempt == 3:
|
|
54
|
+
raise
|
|
55
|
+
time.sleep(2 ** attempt)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_matches(session: requests.Session) -> list:
|
|
59
|
+
"""Return the list of match metadata dicts for the 2022 World Cup."""
|
|
60
|
+
url = f"{BASE}/matches/{COMPETITION_ID}/{SEASON_ID}.json"
|
|
61
|
+
return _get_json(url, RAW_DIR / "matches.json", session)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_events(match_id: int, session: requests.Session) -> list:
|
|
65
|
+
url = f"{BASE}/events/{match_id}.json"
|
|
66
|
+
return _get_json(url, RAW_DIR / "events" / f"{match_id}.json", session)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_lineups(match_id: int, session: requests.Session) -> list:
|
|
70
|
+
url = f"{BASE}/lineups/{match_id}.json"
|
|
71
|
+
return _get_json(url, RAW_DIR / "lineups" / f"{match_id}.json", session)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------- #
|
|
75
|
+
# Parsing helpers
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
def _name(d, key):
|
|
78
|
+
"""Safely pull a nested ``{key: {'name': ...}}`` value."""
|
|
79
|
+
v = d.get(key)
|
|
80
|
+
return v.get("name") if isinstance(v, dict) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _blank_record():
|
|
84
|
+
return {
|
|
85
|
+
"minutes_played": 0,
|
|
86
|
+
"starter": False,
|
|
87
|
+
"position": None,
|
|
88
|
+
"goals": 0,
|
|
89
|
+
"penalty_goals": 0,
|
|
90
|
+
"own_goals": 0,
|
|
91
|
+
"assists": 0,
|
|
92
|
+
"key_passes": 0,
|
|
93
|
+
"shots": 0,
|
|
94
|
+
"shots_on_target": 0,
|
|
95
|
+
"xg": 0.0,
|
|
96
|
+
"passes": 0,
|
|
97
|
+
"passes_completed": 0,
|
|
98
|
+
"crosses": 0,
|
|
99
|
+
"dribbles": 0,
|
|
100
|
+
"dribbles_completed": 0,
|
|
101
|
+
"tackles": 0,
|
|
102
|
+
"interceptions": 0,
|
|
103
|
+
"blocks": 0,
|
|
104
|
+
"ball_recoveries": 0,
|
|
105
|
+
"clearances": 0,
|
|
106
|
+
"fouls_committed": 0,
|
|
107
|
+
"fouls_won": 0,
|
|
108
|
+
"yellow_cards": 0,
|
|
109
|
+
"red_cards": 0,
|
|
110
|
+
"free_kicks_taken": 0,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _match_end_minute(events: list) -> int:
|
|
115
|
+
"""Approximate the final whistle minute from the last recorded event.
|
|
116
|
+
|
|
117
|
+
Penalty shootouts (period 5) are excluded so they don't inflate minutes.
|
|
118
|
+
"""
|
|
119
|
+
return max((e.get("minute", 0) for e in events if e.get("period") != 5),
|
|
120
|
+
default=90)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_match_stats(match: dict, events: list, lineups: list) -> pd.DataFrame:
|
|
124
|
+
"""Build a per-player stats table for a single match."""
|
|
125
|
+
match_id = match["match_id"]
|
|
126
|
+
end_minute = _match_end_minute(events)
|
|
127
|
+
|
|
128
|
+
# Seed every player from the official lineups so bench players who never
|
|
129
|
+
# touched the ball still appear (with zeroed stats).
|
|
130
|
+
records: dict[int, dict] = {}
|
|
131
|
+
meta: dict[int, dict] = {}
|
|
132
|
+
for team in lineups:
|
|
133
|
+
team_name = team.get("team_name")
|
|
134
|
+
for pl in team.get("lineup", []):
|
|
135
|
+
pid = pl["player_id"]
|
|
136
|
+
records[pid] = _blank_record()
|
|
137
|
+
meta[pid] = {"player": pl.get("player_name"), "team": team_name}
|
|
138
|
+
|
|
139
|
+
def rec(ev):
|
|
140
|
+
pl = ev.get("player")
|
|
141
|
+
if not pl:
|
|
142
|
+
return None
|
|
143
|
+
pid = pl["id"]
|
|
144
|
+
if pid not in records: # safety net for anyone missing from lineups
|
|
145
|
+
records[pid] = _blank_record()
|
|
146
|
+
meta[pid] = {"player": pl.get("name"), "team": _name(ev, "team")}
|
|
147
|
+
return records[pid]
|
|
148
|
+
|
|
149
|
+
# Minutes: starters play from 0; subs/red cards adjust the window.
|
|
150
|
+
on_min: dict[int, int] = {}
|
|
151
|
+
off_min: dict[int, int] = {}
|
|
152
|
+
|
|
153
|
+
for ev in events:
|
|
154
|
+
# Skip penalty-shootout events (period 5): shootout goals are not
|
|
155
|
+
# counted as match goals in official statistics.
|
|
156
|
+
if ev.get("period") == 5:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
etype = _name(ev, "type")
|
|
160
|
+
|
|
161
|
+
if etype == "Starting XI":
|
|
162
|
+
for pl in ev.get("tactics", {}).get("lineup", []):
|
|
163
|
+
pid = pl["player"]["id"]
|
|
164
|
+
if pid in records:
|
|
165
|
+
records[pid]["starter"] = True
|
|
166
|
+
records[pid]["position"] = _name(pl, "position")
|
|
167
|
+
on_min[pid] = 0
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
r = rec(ev)
|
|
171
|
+
if r is None:
|
|
172
|
+
continue
|
|
173
|
+
minute = ev.get("minute", 0)
|
|
174
|
+
|
|
175
|
+
if etype == "Substitution":
|
|
176
|
+
off_min[ev["player"]["id"]] = minute
|
|
177
|
+
repl = ev.get("substitution", {}).get("replacement")
|
|
178
|
+
if repl and repl["id"] in records:
|
|
179
|
+
on_min[repl["id"]] = minute
|
|
180
|
+
if records[repl["id"]]["position"] is None:
|
|
181
|
+
records[repl["id"]]["position"] = _name(ev, "position")
|
|
182
|
+
|
|
183
|
+
elif etype == "Shot":
|
|
184
|
+
shot = ev.get("shot", {})
|
|
185
|
+
r["shots"] += 1
|
|
186
|
+
r["xg"] += shot.get("statsbomb_xg", 0.0) or 0.0
|
|
187
|
+
outcome = _name(shot, "outcome")
|
|
188
|
+
is_pen = _name(shot, "type") == "Penalty"
|
|
189
|
+
if _name(shot, "type") == "Free Kick":
|
|
190
|
+
r["free_kicks_taken"] += 1
|
|
191
|
+
if outcome in SHOTS_ON_TARGET:
|
|
192
|
+
r["shots_on_target"] += 1
|
|
193
|
+
if outcome == "Goal":
|
|
194
|
+
r["goals"] += 1
|
|
195
|
+
if is_pen:
|
|
196
|
+
r["penalty_goals"] += 1
|
|
197
|
+
|
|
198
|
+
elif etype == "Pass":
|
|
199
|
+
p = ev.get("pass", {})
|
|
200
|
+
r["passes"] += 1
|
|
201
|
+
if p.get("outcome") is None: # StatsBomb omits outcome on completions
|
|
202
|
+
r["passes_completed"] += 1
|
|
203
|
+
if p.get("goal_assist"):
|
|
204
|
+
r["assists"] += 1
|
|
205
|
+
if p.get("shot_assist"):
|
|
206
|
+
r["key_passes"] += 1
|
|
207
|
+
if p.get("cross"):
|
|
208
|
+
r["crosses"] += 1
|
|
209
|
+
if _name(p, "type") == "Free Kick":
|
|
210
|
+
r["free_kicks_taken"] += 1
|
|
211
|
+
|
|
212
|
+
elif etype == "Dribble":
|
|
213
|
+
r["dribbles"] += 1
|
|
214
|
+
if _name(ev.get("dribble", {}), "outcome") == "Complete":
|
|
215
|
+
r["dribbles_completed"] += 1
|
|
216
|
+
|
|
217
|
+
elif etype == "Duel":
|
|
218
|
+
if _name(ev.get("duel", {}), "type") == "Tackle":
|
|
219
|
+
r["tackles"] += 1
|
|
220
|
+
|
|
221
|
+
elif etype == "Interception":
|
|
222
|
+
r["interceptions"] += 1
|
|
223
|
+
elif etype == "Block":
|
|
224
|
+
r["blocks"] += 1
|
|
225
|
+
elif etype == "Ball Recovery":
|
|
226
|
+
r["ball_recoveries"] += 1
|
|
227
|
+
elif etype == "Clearance":
|
|
228
|
+
r["clearances"] += 1
|
|
229
|
+
|
|
230
|
+
elif etype == "Foul Committed":
|
|
231
|
+
r["fouls_committed"] += 1
|
|
232
|
+
card = _name(ev.get("foul_committed", {}), "card")
|
|
233
|
+
if card in YELLOW:
|
|
234
|
+
r["yellow_cards"] += 1
|
|
235
|
+
if card in RED:
|
|
236
|
+
r["red_cards"] += 1
|
|
237
|
+
off_min[ev["player"]["id"]] = minute
|
|
238
|
+
|
|
239
|
+
elif etype == "Foul Won":
|
|
240
|
+
r["fouls_won"] += 1
|
|
241
|
+
|
|
242
|
+
elif etype == "Bad Behaviour":
|
|
243
|
+
card = _name(ev.get("bad_behaviour", {}), "card")
|
|
244
|
+
if card in YELLOW:
|
|
245
|
+
r["yellow_cards"] += 1
|
|
246
|
+
if card in RED:
|
|
247
|
+
r["red_cards"] += 1
|
|
248
|
+
off_min[ev["player"]["id"]] = minute
|
|
249
|
+
|
|
250
|
+
elif etype == "Own Goal Against":
|
|
251
|
+
r["own_goals"] += 1
|
|
252
|
+
|
|
253
|
+
# Finalise minutes played.
|
|
254
|
+
for pid, r in records.items():
|
|
255
|
+
if pid in on_min:
|
|
256
|
+
start = on_min[pid]
|
|
257
|
+
end = off_min.get(pid, end_minute)
|
|
258
|
+
r["minutes_played"] = max(0, end - start)
|
|
259
|
+
|
|
260
|
+
df = pd.DataFrame.from_dict(records, orient="index")
|
|
261
|
+
df.insert(0, "player_id", df.index)
|
|
262
|
+
df.insert(1, "player", [meta[p]["player"] for p in df.index])
|
|
263
|
+
df.insert(2, "team", [meta[p]["team"] for p in df.index])
|
|
264
|
+
df.reset_index(drop=True, inplace=True)
|
|
265
|
+
|
|
266
|
+
# Attach match context.
|
|
267
|
+
df["match_id"] = match_id
|
|
268
|
+
df["match_date"] = match.get("match_date")
|
|
269
|
+
df["stage"] = _name(match, "competition_stage")
|
|
270
|
+
df["home_team"] = (match.get("home_team") or {}).get("home_team_name")
|
|
271
|
+
df["away_team"] = (match.get("away_team") or {}).get("away_team_name")
|
|
272
|
+
df["home_score"] = match.get("home_score")
|
|
273
|
+
df["away_score"] = match.get("away_score")
|
|
274
|
+
return df
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def build_all(limit: int | None = None) -> pd.DataFrame:
|
|
278
|
+
"""Download every match and return the combined per-player-per-match table."""
|
|
279
|
+
session = requests.Session()
|
|
280
|
+
matches = get_matches(session)
|
|
281
|
+
matches.sort(key=lambda m: m.get("match_date", ""))
|
|
282
|
+
if limit:
|
|
283
|
+
matches = matches[:limit]
|
|
284
|
+
|
|
285
|
+
frames = []
|
|
286
|
+
for i, match in enumerate(matches, 1):
|
|
287
|
+
mid = match["match_id"]
|
|
288
|
+
print(f"[{i}/{len(matches)}] match {mid}: "
|
|
289
|
+
f"{(match.get('home_team') or {}).get('home_team_name')} vs "
|
|
290
|
+
f"{(match.get('away_team') or {}).get('away_team_name')}")
|
|
291
|
+
events = get_events(mid, session)
|
|
292
|
+
lineups = get_lineups(mid, session)
|
|
293
|
+
frames.append(build_match_stats(match, events, lineups))
|
|
294
|
+
|
|
295
|
+
df = pd.concat(frames, ignore_index=True)
|
|
296
|
+
df["pass_completion_pct"] = (
|
|
297
|
+
(df["passes_completed"] / df["passes"]).where(df["passes"] > 0) * 100
|
|
298
|
+
).round(1)
|
|
299
|
+
return df
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main():
|
|
303
|
+
df = build_all()
|
|
304
|
+
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
out = PROCESSED_DIR / "wc2022_player_match_stats.parquet"
|
|
306
|
+
df.to_parquet(out, index=False)
|
|
307
|
+
print(f"\nWrote {len(df):,} player-match rows to {out}")
|
|
308
|
+
print(f"Players: {df['player_id'].nunique()} Matches: {df['match_id'].nunique()}")
|
|
309
|
+
return df
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
main()
|