wa-setpieces
Set-piece metrics for football (soccer) matches from Opta / Stats Perform F24 event-feed JSON exports (natively) and StatsBomb open-data exports (via an adapter, see below): penalties, kick-offs, free kicks, corners, throw-ins and goal kicks.
Given a match file, this package tags every set-piece restart, aggregates attempts/success rates by team and player, tracks pass end locations for delivery maps, and links each set piece to the shot or goal it produced (via the provider's own assist-chain data). It also covers, for corners and free kicks specifically: second-phase detection, Expected Threat (xT), pitch zones/thirds/channels, possession retention, and a benchmarked team/player rating — with pitch plots built on mplsoccer.
Full documentation, with a runnable plot gallery: https://waltzinganalytics.readthedocs.io
Install
pip install wa-setpieces
Or install from source:
git clone https://github.com/marclamberts/waltzinganalytics.git
cd waltzinganalytics
pip install -e .
Quickstart
from wa_setpieces import load_events, set_piece_summary
match = load_events("match.json")
summary = set_piece_summary(match.events)
print(summary)
contestantId set_piece_type attempts successful success_rate shots goals
cxb4hqite921i... corner 2 1 0.500 1 0
cxb4hqite921i... free_kick 12 9 0.750 0 0
cxb4hqite921i... goal_kick 8 5 0.625 0 0
cxb4hqite921i... kick_off 1 1 1.000 0 0
cxb4hqite921i... throw_in 20 16 0.800 1 0
...
The whole pipeline in one call
Everything below this section — team/player counts, delivery locations,
second-phase detection, retention, added value, the report, and the
rating — is one function chain most people want run together.
run_workflow runs that whole chain for you and hands back every table at
once, instead of wiring five function calls together yourself:
from wa_setpieces import run_workflow, XTModel
model = XTModel.fit(match.events) # optional -- unlocks added value + player rating
result = run_workflow(match.events, "corner", model=model)
result.summary # attempts, success rate, shots, goals
result.deliveries # start/end coordinates for a delivery map
result.second_phases # cleared / first-phase shot / second-phase shot, per corner
result.retention # still in possession ~8s later?
result.added_value # xT added + resulting shot quality + goals, per delivery
result.report # all of the above, rolled up per team
result.team_rating # 0-100 benchmark score per team
result.player_rating # delivery score / finishing score per player
Reach for the individual functions below directly when you only need one
piece, want different parameters per step, or are combining several
matches. run_workflow computes nothing new — it's a convenience
wrapper, not a shortcut that skips anything.
Second phases, xT, zones, retention, added value and outcomes
from wa_setpieces import (
second_phases, second_phase_summary, # corner/free-kick second-phase shots
retention_detail, retention_rate, # possession retained N seconds later
add_thirds, add_channels, add_zone_grid, # pitch location tagging
XTModel, set_piece_delivery_xt, set_piece_xt_summary, # Expected Threat
set_piece_added_value, set_piece_value_summary, # xT + shot quality + goals, blended
corner_report, free_kick_report, # all of the above, merged into one table per team
delivery_outcomes, outcome_summary, # per-delivery outcome category, for a shot map
)
second_phases(match.events, "corner") # per-corner: cleared / first-phase shot / second-phase shot
second_phase_summary(match.events, "free_kick") # per-team roll-up
retention_rate(match.events, "corner") # per-team: % of corners where the ball is retained ~8s later
tagged = add_thirds(match.events) # defensive_third / middle_third / attacking_third
tagged = add_channels(tagged, n=5) # wide / half-space / central
model = XTModel.fit(match.events) # fit an xT grid (fit on many matches for real use!)
set_piece_xt_summary(match.events, "corner", model) # total/average xT added per team
set_piece_added_value(match.events, "corner", model) # per-delivery: xT added + resulting shot quality + goal
corner_report(match.events, model=model) # attempts, success/retention/second-phase rate, added value -- one table
delivery_outcomes(match.events, "corner") # per-delivery: short_corner / direct_shot / second_phase_shot /
# aerial_duel (50/50) / cleared / first_touch_won / first_touch_lost
All of the above are derived heuristics, not raw Opta fields — see
docs/source/advanced.rst (or the hosted docs) for the exact assumptions
and tunable thresholds behind each one. That page also documents a real bug
this uncovered and fixed: F24's eventId is only unique within one team's
own event stream, not globally — every delivery/shot lookup in this
package is scoped accordingly.
Shot value (experimental)
Five pre-trained gradient-boosted models, bundled with the package, score every shot in a match:
pip install -e ".[ml]" # xgboost + scikit-learn + joblib
from wa_setpieces.ml.shot_value import ShotValueModels, shot_value
models = ShotValueModels.load() # loads once; reuse across matches
shots = shot_value(match.events, models)
# eventId, playerName, is_goal, set_piece_type, on_target_prob, xgot, psxg,
# situational_prob, outcome_class_0..3, shot_value (blended)
Read wa_setpieces/shot_value.py's module docstring before trusting this
for anything real. The five models were trained elsewhere against a
feature schema this package has to reconstruct from Opta F24 qualifiers on
each shot event; some inputs (shot geometry, set-piece origin, assist,
left/right foot, goal-mouth placement) are confidently derived from
already-tested logic elsewhere in this package, but several situational
flags (big chance, one-on-one, fast break, scramble, header/volley) have no
reliable qualifier signal in the two real matches this was checked against
and default to False rather than a guessed-and-possibly-wrong qualifier
ID — that gap is documented, not hidden, but it does mean predictions are
degraded relative to the models' original training data.
Ratings
wa_setpieces.core.rating turns a report into a single 0-100 "how good"
score, benchmarked (z-scored) against whoever else is in the table —
always rate against a full season/competition, not one match; a
two-row sample just tells you which of those two had the better match, not
how good either team actually is.
from wa_setpieces.core.rating import team_rating, player_rating
team_rating(corner_report(season_events, model=model))
# ... success_rate, avg_added_value, retention_rate, plus a *_score column
# per metric and a composite `rating` (50 = this table's own average)
player_rating(season_events, "corner", model, min_deliveries=5, min_shots=3)
# delivery_score (taker quality) and finishing_score (shooter quality),
# merged -- a pure taker or pure finisher is rated on the component they
# have, not penalized for the one they don't
Plots
pip install -e ".[viz]" # matplotlib + mplsoccer
from wa_setpieces.viz.plots import (
plot_delivery_map, # arrow map of deliveries, colored by outcome
plot_zone_heatmap, # where events happen, gridded onto the pitch
plot_xt_grid, # a fitted XTModel's grid, as a heatmap
plot_second_phase, # one corner/free-kick's phase sequence, numbered
plot_team_comparison, # grouped bars: both teams, every set-piece type
plot_xt_added_bars, # diverging bar chart of xT added per delivery
plot_corner_sonar, # polar plot of delivery angle + distance
plot_match_timeline, # every set piece on one shared match-minute axis
plot_dashboard, # one-figure report card combining several of the above
plot_set_piece_radar, # two-team radar over a corner_report/free_kick_report
plot_set_piece_outcomes, # shot map: every delivery, colored by outcome category
plot_rating_benchmark, # team/player rating vs. the sample-average baseline
)
plot_delivery_map(
delivery_locations(match.events, "corner"), title="Corner deliveries",
subtitle="20 June 2026 · Delivery map", footer="Data: Opta", dark=False, # or dark=True (default)
)
plot_dashboard(match.events, team_id, set_piece_type="corner") # the "hero" figure
plot_set_piece_radar(corner_report(match.events, model=model)) # team A vs. team B, one glance
plot_rating_benchmark(team_rating(corner_report(season_events, model=model)))
Every plotting function returns (fig, ax) (plot_dashboard returns just
fig, being multi-panel) for further customization, and takes dark: bool = True -- the whole figure switches between a validated dark (navy) and
light (white) palette with that one argument, see
wa_setpieces.viz.theme.get_palette. Colors are assigned by the job they do —
a validated categorical palette for team identity (team-vs-team charts use
a fixed orange-then-blue pairing in both modes), a status pair for
success/fail, gold for goals, single-hue sequential ramps for magnitude,
and a diverging pair for signed quantities like xT added — not picked for
looks; see wa_setpieces/viz/theme.py. subtitle (a muted line under the
title) and footer (a small credit/source line, bottom-right) are
optional on every plot. See the
gallery
for all sixteen plots (in both modes) with full source code.
Other data providers
Opta F24 is the native format (wa_setpieces.core.loader.load_events,
handled directly, no adapter needed). wa_setpieces.providers converts
other providers' feeds into that same internal frame, so every other
module — filters, metrics, chains, phases, retention, xT, value, rating,
viz — works unchanged regardless of source:
from wa_setpieces import load_statsbomb_events
events = load_statsbomb_events("statsbomb_events_export.json")
set_piece_summary(events) # same functions, same DataFrame shape
Read wa_setpieces/providers/statsbomb.py's module docstring for exactly
what is (and isn't) faithfully mapped — set-piece detection, the
assist-chain shot link, retention, xT and rating are all faithful; one
narrow edge case in second-phase timing is documented as an approximation.
Impect is not supported. It's a closed, proprietary feed with no
public schema to build and verify an adapter against — contributing one
needs a real sample export or an official schema reference to check the
mapping against, the same way the StatsBomb adapter and the Opta constants
in wa_setpieces/core/constants.py were verified against real exports.
Command line
wa-setpieces match.json
wa-setpieces match.json --csv summary.csv
wa-setpieces match.json --xt # also fit + print xT for this match (illustrative on one match)
The command-line interface also exposes the complete workflow:
wa-setpieces summary match.json --output summary.json --format json
wa-setpieces train-xt season/*.json --output league-model.npz
wa-setpieces workflow match.json --type corner --model league-model.npz --output tables/
wa-setpieces report match.json --type corner --model league-model.npz --output report.html
Use --provider statsbomb with any new-style command for a StatsBomb
events export. Outputs support CSV, JSON and Parquet where applicable.
Season analysis and defending
SeasonDataset makes multi-match aggregation safe by requiring a
matchId boundary and running temporal heuristics within each match:
from wa_setpieces import SeasonDataset, defensive_set_piece_summary
season = SeasonDataset.from_sources(paths)
season.summary() # competition totals and per-match rates
season.rolling_summary(window=5) # rolling form
season.report("corner", model) # match-level report rows
defensive_set_piece_summary(season.events) # attempts/shots/goals conceded
validate_events documents and checks the provider-neutral event contract;
event_capabilities reports which optional information an adapter supplies.
first_contact_detail and first_contact_summary add player attribution,
explicitly labelled event_sequence because event data cannot prove physical
contact as tracking data can.
Full xT artifacts now persist all probability grids and training metadata:
model.save("league-model.npz")
loaded = XTModel.load("league-model.npz")
loaded.evaluate(held_out_events) # shot count, goals and Brier score
Routine analysis
Every restart can also be described by how it was executed, not only its final outcome:
from wa_setpieces import restart_routines, routine_summary, all_routine_summaries
restart_routines(events, "corner")
# one row per corner: routine_type, distance, progression, direction, side,
# start/end third, start/target channel, retention, shots and goals
routine_summary(events, "goal_kick")
# usage share and outcome rates for short_build / medium_build / long routines
all_routine_summaries(events)
# one combined tactical inventory covering all six restart types
The type-specific routine families are:
- Corners: short, central six-yard, penalty-area, recycled, deep/edge.
- Free kicks: direct shot, short, box delivery, progressive, recycled, lateral.
- Throw-ins: short, medium, long, with location and progression attached.
- Penalties: scored, saved, post, missed.
- Goal kicks: short build, medium build, long.
- Kick-offs: backward, lateral, short forward, direct long.
For a structured tactical analysis, use one call:
from wa_setpieces import analyze_routines
analysis = analyze_routines(events, "corner", min_taker_attempts=3)
analysis.detail # every routine and its geometry/outcome
analysis.summary # routine-family usage and efficiency
analysis.team_profiles # diversity, predictability and preferred patterns
analysis.taker_profiles # taker preferences and creation results
analysis.target_matrix # routine family -> destination zone -> outcomes
detail also includes approximate distance in metres, delivery angle,
verticality, a tactical destination zone, a hierarchical outcome category and
a stable routine_key combining family, side and destination. Multi-match
frames with matchId are automatically separated before temporal analysis.
What counts as a set piece
| Type | Detected on | Opta qualifierId |
|---|---|---|
| Penalty | shot event (miss/post/saved/goal) | 9 |
| Kick-off | pass event | 279 |
| Free kick | pass event (corners excluded) | 5 |
| Corner | pass event | 6 |
| Throw-in | pass event | 107 |
| Goal kick | pass event | 124 |
These qualifier IDs are the standard Opta/Stats Perform F24 vocabulary and
were cross-checked against a real match export (see tests/data/sample_match.json
and tests/test_filters.py): tagged events line up with their expected pitch
location (corner arc, touchline, centre spot, six-yard line).
Package layout
wa_setpieces.core.loader— parse F24 JSON into a tidypandas.DataFrame;load_events_multistacks a whole season.wa_setpieces.core.constants— Opta typeId / qualifierId reference.wa_setpieces.core.filters— extract/tag each set-piece type.wa_setpieces.core.metrics— team/player counts, success rates, delivery locations.wa_setpieces.core.chains— link set pieces to the shots/goals they produced.wa_setpieces.core.zones— pitch thirds, channels and a configurable zone grid.wa_setpieces.core.phases— second-phase detection for corners/free kicks.wa_setpieces.core.retention— possession retention after any restart.wa_setpieces.core.xt— grid-based Expected Threat (xT), fit from data.wa_setpieces.core.value— set-piece added value: delivery xT + resulting shot quality + goals, blended.wa_setpieces.core.outcomes— per-delivery outcome classification (short corner, direct/second-phase shot, aerial duel, cleared, first/lost touch) for a shot-map scatter.wa_setpieces.ml.shot_value— five bundled pre-trained models (on-target probability, xGOT, post-shot xG, situational quality, outcome class) for a richer per-shot value score (optionalmlextra; experimental, read the module docstring).wa_setpieces.core.report—corner_report/free_kick_report: everything above, merged into one table per team.wa_setpieces.core.rating— benchmarked 0-100 team/player "how good" scores from a report (see Ratings below).wa_setpieces.core.workflow—run_workflow: the whole pipeline above, one function call (see "The whole pipeline in one call").wa_setpieces.providers.statsbomb— convert a StatsBomb open-data export into the same internal frame Opta F24 produces.wa_setpieces.viz.plots— mplsoccer/matplotlib plots: delivery maps, heatmaps, sonar, timeline, dashboard, radar, rating benchmark (optionalvizextra).wa_setpieces.viz.theme— the validated dark/light color palettes every plot draws from.wa_setpieces.convert.corners— batch-convert a directory of Opta F24 exports plus a match-list CSV into a flat corners table for tools that expect that schema (optionalconvertextra).wa_setpieces.cli—wa-setpiecescommand-line tool.
Development
pip install -e ".[dev]"
pytest
Releasing
Publishing to PyPI is automated via GitHub Actions trusted publishing
(.github/workflows/publish.yml) — no API token stored anywhere. One-time
setup (only a PyPI project owner can do this, since it requires logging
into PyPI):
- On PyPI: https://pypi.org/manage/account/publishing/ → add a pending
trusted publisher with project name
wa-setpieces, ownermarclamberts, repositorywaltzinganalytics, workflowpublish.yml, environmentpypi. - From then on, publishing a GitHub Release (or pushing a
v*tag) builds the sdist/wheel and uploads them automatically.
Docs
Docs are built with Sphinx (pydata-sphinx-theme + sphinx-gallery, the
same stack mplsoccer's docs use) and hosted on Read the Docs
(.readthedocs.yaml at the repo root). The gallery under examples_gallery/
is executed at build time, so its plots and DataFrame outputs are always
current. To build locally:
pip install -e ".[docs]"
sphinx-build -b html docs/source docs/_build/html
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file wa_setpieces-0.13.0.tar.gz.
File metadata
- Download URL: wa_setpieces-0.13.0.tar.gz
- Upload date:
- Size: 1.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
84dd2c6e5a274b09bd2980c2e25a043cfdd8245ca7159c2dd9e3f5fd528dfd3c
|
|
| MD5 |
d91e7cb722b50d1b70199b9b8d4c4b7a
|
|
| BLAKE2b-256 |
fadc4147df5a880638b5026c9ae121f69aadb3bdacb90646baa6a15cf88d6edf
|
Provenance
The following attestation bundles were made for wa_setpieces-0.13.0.tar.gz:
Publisher:
publish.yml on marclamberts/waltzinganalytics
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wa_setpieces-0.13.0.tar.gz -
Subject digest:
84dd2c6e5a274b09bd2980c2e25a043cfdd8245ca7159c2dd9e3f5fd528dfd3c - Sigstore transparency entry: 2504555939
- Sigstore integration time:
-
Permalink:
marclamberts/waltzinganalytics@3bdbb5856bab78ac04fbb508cb24dbe6f23bea05 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/marclamberts
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@3bdbb5856bab78ac04fbb508cb24dbe6f23bea05 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file wa_setpieces-0.13.0-py3-none-any.whl.
File metadata
- Download URL: wa_setpieces-0.13.0-py3-none-any.whl
- Upload date:
- Size: 1.4 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cb1bd688e5bcfca4cb973a4543057d65b95ee890501d42446d282fb006ba0bf1
|
|
| MD5 |
f3ca99ac2bba0c795947567dff08d764
|
|
| BLAKE2b-256 |
37b58ce61dee2333d389394f4265fd55254eef7e1f311ff5a03c0055f0b13cb5
|
Provenance
The following attestation bundles were made for wa_setpieces-0.13.0-py3-none-any.whl:
Publisher:
publish.yml on marclamberts/waltzinganalytics
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
wa_setpieces-0.13.0-py3-none-any.whl -
Subject digest:
cb1bd688e5bcfca4cb973a4543057d65b95ee890501d42446d282fb006ba0bf1 - Sigstore transparency entry: 2504556029
- Sigstore integration time:
-
Permalink:
marclamberts/waltzinganalytics@3bdbb5856bab78ac04fbb508cb24dbe6f23bea05 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/marclamberts
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@3bdbb5856bab78ac04fbb508cb24dbe6f23bea05 -
Trigger Event:
workflow_dispatch
-
Statement type: