History of statsbomb
The changes to the source code of this entry in the repository, newest first, each with the differences it made to the entry's files. They are also listed on GitLab.
[entries] New entry: statsbomb b85b568
diff --git a/entries/statsbomb/__init__.py b/entries/statsbomb/__init__.py
new file mode 100644
index 0000000..d2444e1
--- /dev/null
+++ b/entries/statsbomb/__init__.py
@@ -0,0 +1,188 @@
+#! /usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2026 Tiago de Paula Peixoto <tiago@skewed.de>
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+from .. import *
+from datetime import timezone
+
+title = "StatsBomb soccer passing networks"
+description = """Passing networks of soccer (association football) players, built from the match event data that StatsBomb (now part of Hudl) publishes as open data: all the 3,961 matches listed in the data, of 24 men's and women's competitions, one network per competition, with all its seasons:
+
+- `african_cup_of_nations`: Africa Cup of Nations 2023 (52 matches);
+- `bundesliga`: German Bundesliga, the 34 matches of Bayer Leverkusen in each of the seasons 2015/2016 and 2023/2024;
+- `champions_league`: the UEFA Champions League (European Cup) finals of 17 seasons between 1970/1971 and 2018/2019, and one match of 1999/2000;
+- `copa_america`: Copa América 2024 (32 matches);
+- `copa_del_rey`: three finals of the Spanish Copa del Rey (1977/1978, 1982/1983 and 1983/1984);
+- `fa_womens_super_league`: English FA Women's Super League, seasons 2018/2019 to 2020/2021 and 2023/2024 (457 matches);
+- `fifa_u20_world_cup`: the final of the FIFA U20 World Cup 1979;
+- `fifa_world_cup`: FIFA World Cups 2018 and 2022 (all 64 matches of each), and 19 matches of the World Cups 1958 to 1990;
+- `frauen_bundesliga`: German Frauen-Bundesliga, season 2023/2024 (132 matches);
+- `indian_super_league`: Indian Super League, season 2021/2022 (115 matches);
+- `la_liga`: Spanish La Liga, all 380 matches of season 2015/2016, and the matches of Lionel Messi with FC Barcelona of 16 seasons between 2004/2005 and 2020/2021, plus one match of 1973/1974 (867 matches);
+- `liga_f`: Spanish Liga F, season 2023/2024 (240 matches);
+- `liga_profesional`: two matches of the Argentine Liga Profesional (1981 and 1997/1998);
+- `ligue_1`: French Ligue 1, season 2015/2016 (377 matches), and matches of Paris Saint-Germain in 2021/2022 and 2022/2023 (435 matches);
+- `major_league_soccer`: six matches of Inter Miami in Major League Soccer 2023;
+- `north_american_league`: the final of the North American Soccer League 1977;
+- `nwsl`: National Women's Soccer League (United States), 36 matches of season 2018 and 137 of season 2023;
+- `premier_league`: English Premier League, season 2015/2016 (380 matches), and the 38 matches of Arsenal in 2003/2004;
+- `serie_a`: Italian Serie A, season 2015/2016 (380 matches), and one match of 1986/1987;
+- `serie_a_women`: Italian Serie A Women, season 2023/2024 (130 matches);
+- `uefa_euro`: UEFA European Championships 2020 and 2024 (102 matches);
+- `uefa_europa_league`: three matches of Napoli in the UEFA Cup 1988/1989;
+- `uefa_womens_euro`: UEFA Women's European Championships 2022 and 2025 (62 matches);
+- `womens_world_cup`: FIFA Women's World Cups 2019 and 2023 (116 matches).
+
+Each node is a player, and each directed edge goes from a player to a teammate who received at least one of their completed passes in a given match, weighted by the number of such passes; the network of a competition is thus a multigraph, with one edge per pair of players and match, and the passing network of a single team in a single match is the subgraph of the edges with the corresponding `match_id` and `team`. Passes include set pieces (e.g. corners, free kicks, goal kicks and throw-ins). StatsBomb records the recipient of each pass; a pass is completed if it has no outcome (otherwise incomplete, out of play, offside, an injury clearance, or unknown). Of the 3,836,550 passes in the matches, the 2,973,962 completed ones are included; the others are left out. Only players who made or received at least one completed pass appear in the networks. The data also contain event files of 274 matches that are not listed in any of the match lists (and lack their metadata); these are left out.
+
+The data are from the repository at commit 4b73468 (7 September 2026). The files of this entry are available only with an access token, since the StatsBomb Public Data User Agreement forbids distributing the data to any third party.
+
+The networks have the following properties:
+
+- Nodes: `player_id`, the StatsBomb id of the player; `name`; `nickname` (empty if none); `country`, the country of the player; `team`, the team(s) for which the player made or received passes in the competition (separated by semicolons); `position`, the position in which the player most often started their appearances (according to the lineups).
+- Edges: `weight`, the number of completed passes; `match_id`, the StatsBomb id of the match; `time`, the date of the match (Unix time, at midnight UTC); `season`, the season of the competition (e.g. "2015/2016" or "2022"); `match`, the label of the match (home and away teams, and final score); `team`, the team of the two players."""
+tags = ['Social', 'Offline', 'Sports', 'Weighted', 'Multigraph', 'Timestamps', 'Metadata']
+url = 'https://github.com/hudl/open-data'
+citation = [('StatsBomb, "StatsBomb Open Data." GitHub repository (2026)', 'https://github.com/hudl/open-data')]
+icon_hash = None
+upstream_license = ("StatsBomb Public Data User Agreement (research use, attribution; no distribution to third parties)", "https://github.com/hudl/open-data/blob/4b73468fc5b0f1950f9f66fada70ad3a4f9327cb/LICENSE.pdf")
+restricted = True
+
+# The repository (formerly statsbomb/open-data) at a fixed commit, as a zip
+# archive
+commit = '4b73468fc5b0f1950f9f66fada70ad3a4f9327cb'
+upstream_prefix = 'https://github.com/hudl/open-data/archive'
+archive = f'{commit}.zip'
+
+# One network per competition (all its seasons), by StatsBomb competition id
+competitions = {
+ 2: 'premier_league',
+ 7: 'ligue_1',
+ 9: 'bundesliga',
+ 11: 'la_liga',
+ 12: 'serie_a',
+ 16: 'champions_league',
+ 35: 'uefa_europa_league',
+ 37: 'fa_womens_super_league',
+ 43: 'fifa_world_cup',
+ 44: 'major_league_soccer',
+ 49: 'nwsl',
+ 53: 'uefa_womens_euro',
+ 55: 'uefa_euro',
+ 72: 'womens_world_cup',
+ 81: 'liga_profesional',
+ 87: 'copa_del_rey',
+ 116: 'north_american_league',
+ 131: 'serie_a_women',
+ 135: 'frauen_bundesliga',
+ 182: 'liga_f',
+ 223: 'copa_america',
+ 1238: 'indian_super_league',
+ 1267: 'african_cup_of_nations',
+ 1470: 'fifa_u20_world_cup',
+}
+files = [(archive, alt, None) for alt in sorted(competitions.values())]
+
+def fetch_upstream(force=False):
+ return fetch_upstream_files(__name__.split(".")[-1], upstream_prefix, files,
+ force)
+
+@cache_network()
+@coerce_props(eforce={"season": "string"})
+@annotate()
+def parse(alts=None):
+ global files
+ name = __name__.split(".")[-1]
+ base = f"{archive}:open-data-{commit}/data"
+ def load(fname):
+ with open_upstream_file(name, f"{base}/{fname}", "rb") as f:
+ return json.load(f)
+ comps = load("competitions.json")
+ for fname, alt, fmt in files:
+ if alts is not None and alt not in alts:
+ continue
+ cid = [c for c, a in competitions.items() if a == alt][0]
+ matches = []
+ for c in comps:
+ if c["competition_id"] != cid:
+ continue
+ matches.extend(load(f"matches/{cid}/{c['season_id']}.json"))
+ matches.sort(key=lambda m: (m["match_date"], m["kick_off"] or "",
+ m["match_id"]))
+
+ g = Graph(directed=True)
+ vprops = [("player_id", "int"), ("name", "string"),
+ ("nickname", "string"), ("country", "string"),
+ ("team", "string"), ("position", "string")]
+ for p, t in vprops:
+ g.vp[p] = g.new_vp(t)
+ eprops = [("weight", "int"), ("match_id", "int"), ("time", "int64_t"),
+ ("season", "string"), ("match", "string"), ("team", "string")]
+ for p, t in eprops:
+ g.ep[p] = g.new_ep(t)
+
+ vs = {}
+ pinfo = {}
+ vteams = defaultdict(list)
+ positions = defaultdict(lambda: defaultdict(int))
+ def get_v(pid, team):
+ if team not in vteams[pid]:
+ vteams[pid].append(team)
+ v = vs.get(pid)
+ if v is None:
+ v = vs[pid] = int(g.add_vertex())
+ return v
+
+ edges = []
+ for m in matches:
+ mid = m["match_id"]
+ for t in load(f"lineups/{mid}.json"):
+ for p in t["lineup"]:
+ pinfo[p["player_id"]] = p
+ if p["positions"]:
+ positions[p["player_id"]][p["positions"][0]["position"]] += 1
+ counts = defaultdict(int)
+ for ev in load(f"events/{mid}.json"):
+ if ev["type"]["name"] != "Pass":
+ continue
+ ps = ev["pass"]
+ # completed passes have no outcome
+ if "outcome" in ps or "recipient" not in ps:
+ continue
+ counts[(ev["team"]["name"], ev["player"]["id"],
+ ps["recipient"]["id"])] += 1
+ d = datetime.strptime(m["match_date"], "%Y-%m-%d")
+ mtime = int(d.replace(tzinfo=timezone.utc).timestamp())
+ home = m["home_team"]["home_team_name"]
+ away = m["away_team"]["away_team_name"]
+ label = f"{home} - {away}, {m['home_score']} - {m['away_score']}"
+ for (team, s, t), c in sorted(counts.items()):
+ edges.append((get_v(s, team), get_v(t, team), c, mid, mtime,
+ m["season"]["season_name"], label, team))
+ g.add_edge_list(edges, eprops=[g.ep[p] for p, t in eprops])
+
+ for pid, v in vs.items():
+ p = pinfo.get(pid, {})
+ g.vp.player_id[v] = pid
+ g.vp.name[v] = p.get("player_name", "")
+ g.vp.nickname[v] = p.get("player_nickname") or ""
+ g.vp.country[v] = (p.get("country") or {}).get("name", "")
+ g.vp.team[v] = "; ".join(vteams[pid])
+ pos = positions.get(pid)
+ g.vp.position[v] = max(pos, key=pos.get) if pos else ""
+ yield alt, g