Skip to content

bbasaranemir/xforge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

115 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

xForge V3 — Provider-Agnostic Football Analytics Pipeline

V2 Pipeline Python 3.11 dbt PostgreSQL 15 Dart XGBoost License: MIT

End-to-end football analytics pipeline engineered for production. Provider-agnostic ingestion via a Dart microservice (StatsBomb · Opta · Wyscout adapter pattern), a four-layer dbt medallion architecture with a universal 105×68 m coordinate system, calibrated XGBoost models for xG/xP, value-iteration xT, cosine-similarity recruitment model, K-Means set-piece clustering, and a fully automated BI visualisation layer — all validated through GitHub Actions CI on 147 World Cup 2022 matches (524,457 events).


Analytics Visualisations

Five portfolio-grade charts are generated automatically on every CI run and uploaded as a 90-day artifact. The images below are sourced from the latest successful pipeline run.

xT Surface Heatmap Shot Map with xG Bubbles
xT Surface Heatmap Shot Map xG
Team xG vs Goals Scored Set-Piece Delivery Clusters
Team xG Set-Piece Clusters
Player xP Ranking (Top 20)
Player xP Ranking

Charts are generated by scripts/visualise.py using mplsoccer and matplotlib on live pipeline data. The latest PNG artifacts are available under Actions → xForge V2 Pipeline → analytics-visualisations-{run_id}.


Architecture

StatsBomb Open Data / Opta F24 / Wyscout v3
         │
         ▼
 Dart Ingestion Service          HTTP :8090 · Adapter Pattern
 ├── StatsBombAdapter            120×80 → UnifiedEvent
 ├── OptaAdapter                 100×100 → UnifiedEvent
 └── WyscoutAdapter              100×100 → UnifiedEvent
         │
         ▼
  PostgreSQL 15 · fact_events    LIST-partitioned by competition_id
         │
         ▼
 dbt Medallion Pipeline
 ├── Bronze                      Type-cast pass-through, provider tests
 ├── Silver                      105×68 m normalisation, spatial range tests
 │   └── silver_pass_links       Recipient extraction for pass network
 ├── Gold                        Player / team aggregations with ML values
 └── Marts                       BI-ready tables — player metrics, leaderboards
     ├── mart_pressing_metrics   PPDA per team per match
     └── mart_pass_network       Player-to-player pass pairs
         │
         ├── ML Models (run before Gold/Marts — write back to fact_events)
         │   ├── xG Model        XGBoost · calibrated goal probability per shot
         │   ├── xT Model        Value iteration · 16×12 grid · 192 cells
         │   ├── xP Model        XGBoost · pass completion probability (AUC 0.897)
         │   └── Player Sim.     cosine NearestNeighbors · top-10 similar players
         │
         ├── Materialized Views  mv_team_xg · mv_shot_locations (zero-downtime refresh)
         ├── BI Visualisations   5 PNG charts · mplsoccer · 90-day CI artifacts
         ├── PDF Match Report    5-page mplsoccer report per match
         └── SportsCode XML      Top-25 xT events · Hudl-compatible
flowchart TD
    SB["StatsBomb Open Data\n147 matches · 524,457 events"]
    OA["Opta F24 JSON\nvia OptaAdapter"]
    WY["Wyscout v3 JSON\nvia WyscoutAdapter"]

    subgraph DART["Dart Ingestion Service — :8090"]
        SA["StatsBombAdapter\n120×80"]
        OPT["OptaAdapter\n100×100"]
        WSC["WyscoutAdapter\n100×100"]
        PW["PostgresWriter\nON CONFLICT DO NOTHING"]
    end

    subgraph PG["PostgreSQL 15"]
        FE["fact_events\nLIST-partitioned · competition_id\nxt_value · xp_value · xg_value"]
        DIM["dim_matches · dim_players\ndim_teams · dim_competitions"]
        AUX["xt_surface 192 cells\nset_piece_clusters\nmodel_registry\nmv_team_xg\nplayer_similarity_scores"]
    end

    subgraph DBT["dbt Medallion"]
        BR["Bronze\ntype-cast pass-through"]
        SL["Silver\n105x68 m normalisation\nspatial range tests\nsilver_pass_links"]
        GD["Gold\nplayer + team aggregations"]
        MT["Marts\nmart_pressing_metrics · mart_pass_network\nmart_player_metrics · mart_team_summary"]
    end

    subgraph ML["ML Pipeline"]
        XG["xG Model\nXGBoost · calibrated"]
        XT["xT Model\nValue iteration · 16x12"]
        XP["xP Model\nXGBoost · AUC 0.897"]
        KM["K-Means\nSet-piece clusters k=6"]
        PS["Player Similarity\ncosine NearestNeighbors · top-10"]
    end

    subgraph SERVE["Serving Layer"]
        VIZ["BI Visualisations\n5 PNG charts · CI artifacts"]
        PDF["PDF Match Report\n5-page mplsoccer"]
        XML["SportsCode XML\n25 top-xT events · Hudl"]
        SUP["Apache Superset\n7 charts · dashboard"]
    end

    SB --> SA
    OA --> OPT
    WY --> WSC
    SA & OPT & WSC --> PW --> FE & DIM
    DIM & FE --> BR --> SL
    SL --> XG & XT & XP & KM & PS
    XG & XT & XP & KM --> FE
    PS --> AUX
    FE --> GD --> MT
    MT & AUX --> VIZ & PDF & XML & SUP
Loading

API Reference

GET /health

Returns {"status":"ok"} — used by Docker/K8s liveness probes. Always public.

POST /ingest

Ingests events for a single match.

Request:

{"provider": "statsbomb", "match_id": 3869685}

Optional for Opta / Wyscout (file):

{"provider": "wyscout", "match_id": 1234567, "file_path": "/data/wyscout_match.json"}

Optional for Wyscout (API with Bearer token):

{"provider": "wyscout", "match_id": 1234567, "url": "https://...", "api_key": "TOKEN"}

Response (200):

{"provider": "statsbomb", "match_id": 3869685, "written": 3401}

Error responses: 400 (invalid input), 401 (bad token), 502 (upstream fetch failed)

Authentication: Bearer token via Authorization: Bearer <API_TOKEN> header. Bypassed when API_TOKEN env var is not set (local dev / CI only).

Rate limiting: 60 requests per minute per IP (sliding window). HTTP 429 with Retry-After header when exceeded.

Security: HTTPS-only upstream URLs enforced in all adapters (SSRF protection). Bearer token compared in constant time (timing-attack resistance). Request body capped at 10 MB (DoS protection).


GET /api/v1/matches/{id}/xg

Returns xG values for all shot events in a match. Backed by a partial index on fact_events(match_id) WHERE xg_value IS NOT NULL.

Query params: limit (default 200, max 2000) · offset (default 0)

Response (200):

{
  "match_id": 3869685,
  "limit": 200,
  "offset": 0,
  "events": [
    {"event_id": "a1b2c3d4-...", "xg_value": 0.142},
    {"event_id": "e5f6g7h8-...", "xg_value": 0.031}
  ]
}

Error responses: 400 (invalid match_id or params), 401, 429 (rate limit)


GET /api/v1/players/{id}/similar

Returns up to 10 most similar players by cosine similarity across 12 aggregated features (goals/90, xG/90, progressive passes, pressures, etc.). Requires player_similarity.py to have been run first to populate player_similarity_scores.

Query params: position (optional) — filters results to a specific position group (e.g. CM, Left Center Back). Letters and spaces only, max 60 chars. Blocks <script>, ../, and control characters.

Response (200):

{
  "player_id": 5503,
  "similar": [
    {"player_id": 6012, "player_name": "Luka Modrić", "position": "CM", "similarity_score": 0.947, "rank": 1},
    {"player_id": 7234, "player_name": "Toni Kroos",  "position": "CM", "similarity_score": 0.931, "rank": 2}
  ]
}

Error responses: 400 (invalid player_id or position format), 401, 429


GET /api/v1/matches/{id}/summary

Decision-ready match intelligence: was the scoreline fair, which team pressed harder, and who created the most threat?

Response (200):

{
  "match_id": 3869685,
  "home_team": "Barcelona",
  "away_team": "Real Madrid",
  "home_score": 4,
  "away_score": 0,
  "xg_home": 2.81,
  "xg_away": 0.43,
  "xg_diff": 2.38,
  "pressing_intensity": {
    "home": {"label": "High",   "ppda": 6.2},
    "away": {"label": "Medium", "ppda": 11.7}
  },
  "key_threats": [
    {"rank": 1, "player_name": "Lionel Messi",  "team": "Barcelona",  "total_xt": 0.412},
    {"rank": 2, "player_name": "Luis Suárez",   "team": "Barcelona",  "total_xt": 0.287},
    {"rank": 3, "player_name": "Andrés Iniesta", "team": "Barcelona", "total_xt": 0.241}
  ]
}
Field Meaning
xg_diff home_xg − away_xg. Positive = home team had the better of it on quality chances.
pressing_intensity.label PPDA-derived: High < 10 · Medium 10–15 · Low > 15
pressing_intensity.ppda Passes allowed per defensive action. Lower = more aggressive press. null when insufficient data.
key_threats Top-3 players by cumulative xT (passes + carries into higher-threat zones).

Error responses: 400 (invalid match_id), 401, 404 (match not found), 429


GET /api/v1/players/{id}/replacement

Returns top-N statistically similar players with per-stat deltas and a plain-English recruitment recommendation. Backed by the pre-computed cosine similarity model (player_similarity.py).

Query params: top_n (default 3, max 10)

Response (200):

{
  "target": {
    "player_id": 5503,
    "player_name": "Luka Modrić",
    "position": "CM",
    "stats": {"avg_xg": 0.042, "pass_completion_pct": 88.3, "avg_xt_per_pass": 0.0041, "total_shots": 47}
  },
  "candidates": [
    {
      "rank": 1,
      "player_id": 7234,
      "player_name": "Toni Kroos",
      "position": "CM",
      "similarity_score": 0.943,
      "stats": {"avg_xg": 0.038, "pass_completion_pct": 91.2, "avg_xt_per_pass": 0.0044, "total_shots": 39},
      "delta": {"avg_xg": -0.004, "pass_completion_pct": 2.9, "avg_xt_per_pass": 0.0003}
    }
  ],
  "recommendation": "Replacing Luka Modrić: best match is Toni Kroos (similarity 0.94). xG/shot 0.038 vs 0.042 (lower), pass completion 91.2% vs 88.3% (higher)."
}

Error responses: 400 (invalid player_id or top_n), 401, 404 (player not found), 429


What V3 Adds Over V1

Capability V1 V2 V3
Data providers StatsBomb only StatsBomb + Opta + Wyscout v3
Ingestion language Python Dart microservice, HTTP API Wyscout adapter (file + URL+Bearer)
Coordinate system StatsBomb 120×80 (raw) Universal 105×68 m — single source of truth wyscout_100x100 branch in coord_normalise macro
Data layer Single staging schema Bronze / Silver / Gold / Marts medallion + silver_pass_links
Coordinate tests None dbt spatial range tests on every Silver run wyscout added to accepted_values
xG model Post-hoc rescaled CalibratedClassifierCV (Platt scaling) xG feeds player similarity features
BI output Superset (local only) 5 static PNGs via CI — shareable artifacts + PPDA mart + pass network mart
CI coverage Lint + unit tests Full end-to-end pipeline on 524,457 events 131 tests (115 Python · 66 Dart)
Data volume Single match 147 WC 2022 matches, bulk incremental loader same
Recruitment None finishing_quality metric in mart_player_metrics Cosine similarity model — top-10 similar players per player
Pressing analytics None None PPDA per team per match with High/Medium/Low label
Pass network None None Player-to-player pass pairs with completion rate
Decision layer None None /summary: xg_diff + PPDA + key threats · /replacement: stat deltas + recommendation string

ML Models

Model Algorithm Input Result
xT Surface Value iteration (15 passes) Silver events · 16×12 grid 192 cells, max xT = 0.298
xG Classifier XGBoost + Platt scaling Silver shots — distance, angle, pressure Calibrated goal probability
xP Classifier XGBoost Silver passes — start/end coords, distance, pressure AUC 0.897 · log-loss 0.293 · 118,187 training passes
Set-piece Clustering K-Means k=6 Corner + shot locations (105×68 m) 12 cluster centroids (6 corner zones, 6 shot zones)
Press Trigger Detector Rule-based sequence Ball recovery + 3 defensive actions / 5 s 165 press triggers detected (WC 2022)
Player Similarity scikit-learn NearestNeighbors (cosine) 12 aggregated features per player — shots, goals, xG, passes, completion rate, location, pressure Top-10 most similar players per player; idempotent write-back to player_similarity_scores

xP engineering note: all three ML models read from the Silver layer and write xp_value / xg_value / xt_value back to fact_events before dbt Gold and Marts materialise — ensuring mart_player_metrics.avg_xp is populated on every run.

xG calibration: CalibratedClassifierCV(XGBClassifier(), cv=5, method='sigmoid') — Platt scaling ensures xG=0.30 represents a genuine ~30% conversion rate, not merely a ranking score. A Brier score and expected-vs-actual goal check gate every training run.

Coordinate normalisation: a single dbt macro normalise_x(col, coord_system) converts any provider's raw coordinates to the 105×68 m universal pitch. Adding a third provider requires one new adapter file and one macro branch — zero changes to downstream models.


Business Use Cases

Pass quality scouting: xP isolates pass difficulty from completion rate. A midfielder completing high-difficulty passes (low xP) in high-threat zones (high xT) appears on no traditional completion-rate leaderboard — but surfaces immediately in mart_player_metrics.

Opponent set-piece analysis: K-Means clustering of 1,384 corners and 4,904 shots across 147 WC matches reveals six repeatable delivery zones per event type. Coaching staff receive cluster centroids and member counts without manual video tagging.

Video integration: the pipeline generates a SportsCode/Hudl-compatible XML file containing the 25 highest-xT events per match. Analysts open the file directly in Hudl Sportscode — no manual timestamp entry.

Striker recruitment: finishing_quality = goals − total_xG separates clinical finishers from shot-volume players. Available in mart_player_metrics; directly queryable in Superset without custom SQL.

Hidden gem scouting: the player similarity model indexes every player on 12 aggregated features (shot profile, pass profile, location, pressure resistance). A scout running SELECT * FROM player_similarity_scores WHERE player_id = :target ORDER BY rank receives the 10 most statistically similar players — surfacing affordable alternatives to a transfer target across any provider's dataset.

Pressing intensity analysis: mart_pressing_metrics computes PPDA per team per match using all three providers' data. A PPDA of 6.2 (High) means the team forced an error or blocked every 6.2 opponent passes — directly comparable across StatsBomb, Opta, and Wyscout feeds without any manual normalisation.

Post-match decision intelligence: GET /api/v1/matches/{id}/summary delivers three actionable signals in a single API call: xg_diff (was the scoreline fair?), pressing_intensity per team (PPDA-derived High/Medium/Low), and key_threats (top-3 xT contributors). Analysts get a structured JSON verdict instead of raw event rows — the pipeline answers the question, not just provides the data.

Transfer target replacement: GET /api/v1/players/{id}/replacement queries pre-computed cosine similarity scores and returns top-N candidates with per-stat deltas (xG, pass completion, xT/pass) and a plain-English recommendation string. A scout can surface affordable alternatives to a £60M target with a single HTTP request.


Data at Scale — WC 2022 (Competition 43)

Metric Value
Matches ingested 147
Total events 524,457
xP training passes 118,187
xP model AUC 0.897
Set-piece cluster centroids 12
Press triggers detected 165
xT grid cells 192 (16×12, 105×68 m)
CI pipeline duration ~11 min (full end-to-end)
BI chart artifacts 5 PNGs · 90-day retention

CI/CD Pipeline

Every push to main runs the full pipeline against a live PostgreSQL instance:

checkout
  → install Python 3.11 deps
  → init schema (01_schema.sql + 02_v2_migration.sql)
  → bulk ingest 147 matches (ingest_season=true)
  → build + start Dart ingestion service
  → ingest match events via Dart HTTP API
  → dbt bronze  (run + test)
  → dbt silver  (run + test — spatial range gate)
  → xG model    (XGBoost · calibrated)
  → xT model    (value iteration)
  → xP model    (XGBoost · writes xp_value to fact_events)
  → player similarity model (cosine NearestNeighbors · writes player_similarity_scores)
  → dbt gold    (run + test)
  → dbt marts   (run + test — avg_xp now populated)
  → refresh materialized views
  → export SportsCode XML
  → tactical models (K-Means · press trigger)
  → generate PDF match report
  → generate analytics visualisations (5 PNG charts)
  → upload artifacts (XML · PDF · 5 PNGs · dbt logs)
  → pipeline summary (GitHub Step Summary)

The Silver spatial range tests (location_x ∈ [0, 105], location_y ∈ [0, 68]) act as a hard gate — if any event falls outside the universal pitch boundary after coordinate normalisation, the pipeline fails before ML models train.


Project Structure

xforge/
├── .github/
│   └── workflows/
│       └── pipeline_v2.yml         # Full end-to-end CI pipeline
├── config/
│   └── superset_config.py          # Superset secret key, DB URI, feature flags
├── dart_ingestion/
│   ├── Dockerfile                  # Multi-stage AOT compile — ~10 MB image
│   ├── pubspec.yaml
│   └── lib/
│       ├── main.dart               # Shelf HTTP server — POST /ingest, GET /health
│       │                           #   GET /api/v1/matches/{id}/xg
│       │                           #   GET /api/v1/matches/{id}/summary  ← xg_diff · PPDA · key threats
│       │                           #   GET /api/v1/players/{id}/similar
│       │                           #   GET /api/v1/players/{id}/replacement  ← stat deltas · recommendation
│       ├── middleware/
│       │   ├── bearer_auth.dart    # Constant-time Bearer token guard
│       │   └── rate_limit.dart     # Sliding-window 60 req/min per IP
│       ├── models/unified_event.dart
│       ├── adapters/
│       │   ├── adapter_interface.dart
│       │   ├── statsbomb_adapter.dart   # 120×80 → UnifiedEvent
│       │   ├── opta_adapter.dart        # 100×100 → UnifiedEvent + SSRF guard
│       │   └── wyscout_adapter.dart     # 100×100 → UnifiedEvent  (Wyscout v3) + SSRF guard
│       └── db/postgres_writer.dart      # Bulk INSERT · queryXgValues · querySimilarPlayers
│                                        # queryMatchSummary · queryReplacementCandidates
├── dbt_project/
│   ├── macros/
│   │   └── coord_normalise.sql     # normalise_x / normalise_y — single normalisation point
│   └── models/
│       ├── bronze/                 # Type-cast pass-through + provider tests
│       ├── silver/                 # 105×68 m normalisation + spatial range tests
│       │                           # silver_pass_links — recipient extraction for pass network
│       ├── gold/                   # Player / team aggregations
│       └── marts/                  # mart_player_metrics · mart_team_summary
│                                   # mart_match_summary · mart_competition_leaderboard
│                                   # mart_pressing_metrics (PPDA) · mart_pass_network
├── docs/
│   └── screenshots/                # CI-generated PNGs committed from latest artifact
├── scripts/
│   ├── init/
│   │   ├── 01_schema.sql           # Tables, partitions, indexes
│   │   └── 02_v2_migration.sql     # V2 columns, materialized views
│   ├── massive_ingestion.py        # Incremental bulk StatsBomb loader
│   ├── xg_model.py                 # XGBoost xG + Platt calibration
│   ├── xt_model.py                 # Value-iteration xT surface
│   ├── predictive_models.py        # XGBoost xP + chunked prediction write-back
│   ├── tactical_models.py          # K-Means clustering + press trigger detection
│   ├── player_similarity.py        # Cosine NearestNeighbors recruitment model
│   ├── visualise.py                # 5 PNG BI charts — mplsoccer + matplotlib
│   ├── report_generator.py         # 5-page PDF per match
│   ├── xml_generator.py            # SportsCode/Hudl XML — top-25 xT events
│   ├── refresh_materialized_views.py
│   └── setup_superset.py           # Autonomous Superset bootstrap — 7 charts + dashboard
├── docker-compose.yml              # Local: Postgres + Superset + pgAdmin
├── requirements.txt
└── LICENSE

Getting Started

Local environment

Prerequisites: Docker >= 24, Docker Compose v2, 4 GB RAM minimum.

git clone https://github.com/bbasaranemir/xforge.git
cd xforge
docker compose up -d postgres
psql -h localhost -U analytics -d football_db -f scripts/init/01_schema.sql
psql -h localhost -U analytics -d football_db -f scripts/init/02_v2_migration.sql

Run the Dart ingestion service:

docker compose up -d dart_ingestion
curl http://localhost:8090/health
# → {"status":"ok"}

curl -X POST http://localhost:8090/ingest \
  -H "Content-Type: application/json" \
  -d '{"provider":"statsbomb","match_id":3869685,"competition_id":43}'
# → {"written":3401}

# After running ML models (player_similarity.py, xt_model.py):
curl http://localhost:8090/api/v1/matches/3869685/summary
# → {"xg_diff":2.38,"pressing_intensity":{"home":{"label":"High","ppda":6.2},...},"key_threats":[...]}

curl "http://localhost:8090/api/v1/players/5503/replacement?top_n=3"
# → {"target":{...},"candidates":[...],"recommendation":"Replacing Luka Modrić: best match is ..."}

Run the dbt medallion:

cd dbt_project
dbt deps
dbt run  --select bronze silver gold marts
dbt test --select silver   # spatial range gate

Launch Superset with pre-built dashboards:

docker compose up -d superset
python scripts/setup_superset.py
# → 7 charts and Matchday Analytics dashboard bootstrapped at http://localhost:8088

CI trigger

Navigate to Actions → xForge V2 Pipeline → Run workflow. Set ingest_season: true to load all 147 WC 2022 matches before running models. The full pipeline completes in approximately 11 minutes; five PNG charts are uploaded as a 90-day artifact under analytics-visualisations-{run_id}.

To update the screenshots in this README after a successful run:

# Download analytics-visualisations-{run_id}.zip from the Actions artifact panel,
# extract to docs/screenshots/, then:
git add docs/screenshots/*.png
git commit -m "docs: update BI visualisation screenshots from CI run {run_id}"
git push

Database Schema

dim_competitions ─┐
dim_seasons       ├──► fact_events   (LIST-partitioned by competition_id)
dim_matches       │         │
dim_players       │         ├── xt_value    (value-iteration xT)
dim_teams ────────┘         ├── xp_value    (XGBoost pass completion probability)
                            └── xg_value    (XGBoost calibrated goal probability)
                                 │
                    ┌────────────┼────────────────────┐
                    ▼            ▼                    ▼
              xt_surface    set_piece_clusters    model_registry
              (192 cells)   (12 centroids)        (AUC, log-loss)

                            mv_team_xg            (REFRESH CONCURRENTLY)
                            mv_shot_locations

                            player_similarity_scores       (cosine top-10 per player)

                            analytics_analytics_marts.*
                            ├── mart_player_metrics        (avg_xg · avg_xp · finishing_quality)
                            ├── mart_team_summary          (xG · shot counts per team per match)
                            ├── mart_match_summary         (per-match aggregates)
                            ├── mart_pressing_metrics      (PPDA · High/Medium/Low per team per match)
                            ├── mart_pass_network          (player-to-player pass pairs)
                            └── mart_competition_leaderboard (xT per match rank)

Partitioning: fact_events uses PostgreSQL LIST partitioning on competition_id. The CI environment adds --shm-size 256m to the Postgres service container to support large cross-partition joins during mart materialisation.

Materialised views: REFRESH MATERIALIZED VIEW CONCURRENTLY is used throughout — BI tools and Superset see no read-lock downtime during refresh cycles.


Data Source

StatsBomb Open Data — used under the StatsBomb Open Data Licence. This project is not affiliated with or endorsed by StatsBomb.


License

MIT

About

Production-grade football analytics platform — xT/xP event modeling, XGBoost (AUC 0.8948), dbt, Airflow at 9.2M event scale

Topics

Resources

License

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors