Goal model
Two XGBoost Poisson regressors predict expected goals per side:
λ_home = f_H(elo_h, elo_a, fifa_h, fifa_a, host_h, alt, climate, travel_h, rest_h, squad_h, …) λ_away = f_A(elo_h, elo_a, fifa_h, fifa_a, host_a, alt, climate, travel_a, rest_a, squad_a, …)
Each regressor is trained on 27,000+ post-1990 international matches (poisson objective, early-stopping on a holdout slice).
Negative Binomial parameterisation
To handle the over-dispersion in real football scorelines (variance > mean), the marginal goal distribution is Negative Binomial:
G ~ NB(λ, φ) (mean = λ, variance = λ + λ²/φ) P(G = k) = Γ(k+φ) / (k! · Γ(φ)) · (φ/(φ+λ))^φ · (λ/(φ+λ))^k
The dispersion parameter φ is tuned on the training set; shipped default: φ = 5.0 (where φ → ∞ reduces to Poisson — lower φ means more upset variance). The sensitivity audit varies φ across [3, 5, 8, 15].
Dixon-Coles τ correction
The independent-marginals product under-predicts low-score draws and over-predicts 1-0 / 0-1. The Dixon-Coles correction multiplies the joint pmf on the four affected cells:
P(h, a) := P(H=h) · P(A=a) · τ(h, a, λ_h, λ_a, ρ) τ(0, 0) = 1 − λ_h λ_a ρ τ(0, 1) = 1 + λ_h ρ τ(1, 0) = 1 + λ_a ρ τ(1, 1) = 1 − ρ τ(h, a) = 1 otherwise
Calibrated on training data, current value: ρ = −0.13. The full joint matrix is then re-normalised. Sensitivity varies ρ across [−0.18, −0.08].
Per-match λ noise (Gamma multiplier)
Beyond NB dispersion, each simulation pass draws a Gamma multiplier for each side to capture "off days" / "purple patches":
α ~ Gamma(k=σ⁻², θ=σ²) E[α] = 1, Var[α] = σ² λ_sim = λ · α
Shipped α (Gamma shape) is 12.0, which corresponds to σ ≈ 0.289 (≈ 29% per-match relative noise via σ = 1/√α). This is what turns identical λ values into the realistic 14–18pp spread of W/D/L probabilities seen in the live match cards.
Bracket & tiebreakers
2026 is the first 48-team World Cup. FIFA published the deterministic Round-of-32 bracket in advance (which group-position fills which slot); the 8-best-third-placer slot assignment uses FIFA's Annex C lookup table — 495 rows covering every possible combination of the 8 qualifying-group identities.
Group tiebreaker cascade (per simulation)
- Most points
- Best goal difference
- Most goals scored
- Head-to-head points among tied teams
- Head-to-head goal difference
- Head-to-head goals scored
- Fair-play points (approximated as zero in simulation)
- FIFA World Ranking (new in 2026 — replaces drawing of lots)
The pre-launch validator asserts annex_c_misses == 0 across the production run.
Monte Carlo configuration
- Seeds: 5 independent RNGs (NumPy
PCG64, seeded deterministically). Cross-seed variance is reported as the simulation range (5th–95th percentile across seeds) for every aggregate probability. This captures Monte-Carlo sampling noise from independent tournament rollouts — not parameter / model uncertainty (those would need bootstrap or posterior resampling, which this build does not perform). - Sims per seed: 5,000 → 25,000 total tournaments. Per-team SE on champion probability ≈
√(p(1-p)/25000)≤ 0.32pp at the 50% level. - Knockout overtime: if tied at 90', play extra time with halved λ (15-min halves); if still tied, penalty shootout sampled with team-specific shootout slope (calibrated on historic finals).
- Travel + venue: per-match effective Elo = base Elo + host boost − travel penalty − altitude penalty − heat penalty + form decay + squad-value prior. All inputs are pre-computed once per tournament; only the goal sample is stochastic.
- Live mode: with
--live, completed matches are locked verbatim (their realised goals enter the goal-difference / head-to-head accounting); remaining matches simulate as normal. - Matchday intelligence:
elo_eff_basepicks upget_team_elo_adjustment()fromscripts/live/apply_matchday_adjustments.py, summing the injury (cap ±25, extreme ±35), weather (±15), lineup (±20), and post-match stats-proxy (±8/match, ±20/group) layers. Aggregate matchday cap is ±35 per team-match; grand total with mid-tournament live-form delta is ±45. Every per-tick decision is appended todata/live/matchday_intelligence_log.jsonlfor full audit.
Calibration & performance metrics
Holdout (4,839 unseen modern matches)
| Metric | Model | Elo-only | Naive (33/33/33) |
|---|---|---|---|
| Log-loss | 0.869 | 0.908 | 1.055 |
| Brier | 0.511 | 0.535 | 0.667 |
| Top-1 accuracy | 60.2% | 55.4% | 48.1% |
Walk-forward backtest
Each test World Cup is run with only training data strictly older than that tournament (no peeking). All hyperparameters tuned once on pre-2010 data.
Scope: walk-forward validates the base goal-model layer only (Elo + form + rest + neutrality + importance). The deployed simulator additionally applies host-boost, squad-value, altitude, climate, travel, and injury bonuses pre-injected as Elo deltas (see scripts/03_simulate.py:806-810). Those layers are validated separately via ablation and sensitivity — they are not in the walk-forward chain.
| WC | N | Log-loss | Brier | Acc | Lift vs Elo |
|---|---|---|---|---|---|
| Loading walk_forward.json… | |||||
Feature ablation
| Configuration | Log-loss | Brier | Accuracy |
|---|---|---|---|
| Loading ablation.json… | |||
Sensitivity audit (27 scenarios)
Every hand-coded assumption was varied across the ranges below. Each scenario is a full 5×5,000 re-run.
| Parameter | Default | Tested range |
|---|---|---|
| Host-country Elo boost | +50 | [+30, +70] |
| Sister-host boost | +15 | [+5, +25] |
| Altitude penalty (Mexico City) | −25 | [−15, −40] |
| Heat penalty (very-hot venues) | −15 | [−5, −25] |
| Travel penalty per 1,000 km | −4 Elo | [−2, −7] |
| Squad-value Elo cap | ±10 | [±5, ±20] |
| Time-decay half-life | 180 days | [120, 365] |
| NB dispersion φ | 5.0 | [3, 5, 8, 15] |
| Dixon-Coles ρ | −0.13 | [−0.18, −0.08] |
| λ-noise σ | 0.289 | [0.183 – 0.354] |
| Penalty-shootout slope | 0.30 | [0.20, 0.45] |
Top-team ranges from latest sensitivity run:
| Team | Mean | Min | Max | Range |
|---|---|---|---|---|
| Loading sensitivity.json… | ||||
Top-4 rank ordering (Spain > Argentina > France > England) is identical across all 27 scenarios; positions 5–6 (Brazil / Colombia) swap under altitude-penalty extremes.
Reproducibility
To rerun the full pipeline from a fresh clone:
git clone <repo-url> wc26-matchday-intelligence cd wc26-matchday-intelligence # 1. Environment python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt # 2. Data preparation + Elo python scripts/01_prepare_data.py # 3. Train goal model (≈ 45 s on M-series laptop) python scripts/02_goal_model.py # 4. Evaluation suite python scripts/04_evaluate.py python scripts/06_ablation.py python scripts/07_walk_forward.py python scripts/05_sensitivity.py # slowest — ≈ 5 min for 27 scenarios # 5. Production simulations python scripts/03_simulate.py --no-travel --out predictions_no_travel.json --seeds 5 --sims 5000 python scripts/03_simulate.py --seeds 5 --sims 5000 # ≈ 30 s python scripts/08_travel_impact.py # 6. Sync artefacts to dashboard cp data/processed/predictions.json data/processed/calibration.json data/processed/travel_impact.json dashboard/ cp models/walk_forward.json models/ablation.json models/sensitivity.json dashboard/ # 7. Pre-launch validation — must pass before publishing python scripts/09_validate.py # 8. Serve locally cd dashboard && python3 -m http.server 8765
Seeds are fixed (seed_0=42, seed_1=43, …) — rerunning the simulator with identical inputs reproduces the same probabilities to within floating-point precision. The walk-forward backtest is the strictest reproducibility test: it trains on pre-WC-year data only and tests on the held-out tournament.
Data schema — predictions.json
{
"generated_at": ISO-8601 UTC,
"n_simulations_total": 25000,
"n_seeds": 5,
"n_simulations_per_seed":5000,
"team_predictions": [{
"team": str,
"group": str (A–L),
"elo": float,
"fifa_pts": float,
"squad_value_eur_m": float,
"p_advance_groups": [0,1],
"p_reach_r16": [0,1],
"p_reach_qf": [0,1],
"p_reach_sf": [0,1],
"p_reach_final": [0,1],
"p_champion": [0,1],
"p_third_place": [0,1],
"p_champion_p05": [0,1], // 5th percentile across seeds
"p_champion_p95": [0,1], // 95th percentile across seeds
"p_finish_1st_group": [0,1],
"p_finish_2nd_group": [0,1],
"p_finish_3rd_group": [0,1],
"p_finish_4th_group": [0,1]
}, ...],
"match_predictions": [{
"m": int (1–72),
"date": YYYY-MM-DD,
"time": HH:MM (local kickoff),
"group": str,
"home": str,
"away": str,
"venue": str,
"venue_country": str,
"altitude_m": int,
"climate": "mild" | "hot" | "very_hot" | "high_altitude_*",
"lam_home": float, // mean expected goals
"lam_away": float,
"p_home_win": [0,1],
"p_draw": [0,1],
"p_away_win": [0,1],
"elo_home": float,
"elo_away": float,
"effective_elo_home": float, // base Elo + boosts − penalties
"effective_elo_away": float,
"home_travel_km": float,
"away_travel_km": float,
"home_rest_days": int,
"away_rest_days": int,
"home_travel_penalty":float, // Elo points
"away_travel_penalty":float,
"locked_score": null | "H-A"
// Note: when locked_score is non-null (completed match), the
// p_home_win / p_draw / p_away fields remain the original PRE-MATCH
// forecast — they are NOT collapsed to a one-hot of the realised
// result. Inside each Monte Carlo rollout the scoreline is forced to
// the locked value (scripts/03_simulate.py:388-394); the displayed
// forecast is preserved for backtest / calibration use.
}, ...],
"concentration": { top1_champion_p, top2_combined, top5_combined },
"annex_c_misses": 0,
"live_mode": bool,
"completed_matches": int
}
Raw data downloads
All artefacts are static JSON. They are the same files the dashboard reads — no server, no auth, no rate limits.
predictions.json— team + match probabilities with 5-seed simulation ranges (p05/p95)predictions_live.json— live-mode rerun with locked completed matchescalibration.json— predicted-vs-actual bins on 4,839 unseen matcheswalk_forward.json— backtest on WC 2010/14/18/22ablation.json— feature ablation log-loss + accuracysensitivity.json— 27-scenario hyper-parameter audittravel_impact.json— champion-prob diff with/without travel penaltylive_state.json— mode, completed count, last-updated timestamplive_delta.json— top movers between static and live predictionsmatchday_intelligence.json— consolidated injury / weather / lineup / stats-proxy adjustments per team-match, with caps and provenance
The dashboard only reads the artefacts above. The repository also keeps four upstream feeds in
data/live/ for traceability (injuries_2026.json, weather_2026.json,
lineups_2026.json, match_stats_2026.json) plus the append-only
matchday_intelligence_log.jsonl — one record per matchday-intel tick, retained for the
tournament duration.
Known limitations
- True per-shot xG is deferred. International xG data is patchy pre-2017; the post-match stats proxy uses shots-on-target / possession / corner deltas and is deliberately not labelled xG (
true_xg_availableis hard-codedfalse) — we don't claim shot-location information the providers don't expose. - Injury importance uses a hand-curated whitelist, not per-player ratings. API-Football
/injuriesdoesn't expose player ratings, so v2 cross-references the injured name againstdata/raw/key_players_2026.json(~60 top-12-squad headliners + 6 entries with alias coverage for API name drift). Whitelisted stars auto-promote to tier_1_star (-30 Elo) or tier_1_keeper (-25 Elo); unknown names default to tier_2_starter (-12 Elo). Surname collisions (Argentina's two Martínez) disambiguate by forename prefix; unresolved cases surface asambiguous_classificationwarnings on the dashboard for operator review.data/live/team_adjustments.jsonstacks on top for any corrections. - Lineup adjustments are conservative. Heuristic v1: -8 for a confirmed GK swap, -3 for ≥3 outfield changes vs the team's last recorded XI. First XI of the tournament is display-only (no baseline). Capped ±20.
- Weather is forecast-based within 16 days, climate-bucket past that. Open-Meteo provides per-venue forecasts; past horizon (40-day tournament > 16-day forecast), the static climate bucket carries the load.
- Pre-tournament Elo baseline is held static. Matchday intelligence adjusts effective Elo per match (via aggregated caps), but the underlying historical rating doesn't retrain mid-tournament.
- Form features freeze at the eve of kickoff.
precompute_form_cacheis keyed on the 2026-06-11 pre-tournament cut and reused for both group and knockout sims. Completed WC26 results don't fold back intodef_form/att_form/pts_form— those features describe pre-tournament friendlies only. Mid-tournament narrative ("Germany looks sharp") doesn't move the form layer; live signal flows through the matchday-intel layer (injuries / weather / lineups / stats proxy) instead. Refactoring to per-stage form recompute is a post-tournament item. - Live calibration metrics on completed matches are in-sample. When a match locks, the soft-Elo deltas from
update_team_state.py(e.g. +12 winner / −12 loser) feed back intoeffective_elo. The simulator then re-publishesp_home_win/p_draw/p_away_winfor that fixture using the post-result-informed lambdas; the displayed forecast is therefore a residual fit, not a pre-kickoff snapshot. Champion / advance probabilities at the team level remain valid because the locked-score branch inside Monte Carlo forces the realised scoreline. Any "live log-loss vs uniform" number computed from completed-match probs would be biased toward zero — that metric is intentionally not surfaced on the dashboard. A pre-kickoff snapshot is on the post-tournament roadmap. - Successor-state Elo carries pre-dissolution history.
scripts/01_prepare_data.pycoalesces Soviet Union → Russia and Yugoslavia / FR Yugoslavia / Serbia and Montenegro → Serbia. Serbia's Elo therefore inherits Stoichkov-era wins, mildly inflating its WC26 baseline. Magnitude is small (a few Elo points) but worth flagging since Serbia is a competing nation in 2026. Stripping the pre-2003 fragments is a post-tournament cleanup. - Fair-play points are approximated as zero. Real implementations need card data per match; collecting it is on the roadmap.
- Refereeing patterns are not modelled.
- News + social signals are out of scope. Locker-room turmoil, federation politics, tournament-day weather drama aren't captured beyond the structured feeds.
These are why no contender exceeds ~25%. The World Cup is structurally an upset machine.
Want the implementation?
All code is in the repo — pipeline scripts 01_prepare_data.py through 09_validate.py.