This appendix is for analysts and modellers. It documents the goal model, Monte Carlo configuration, calibration, walk-forward backtests, sensitivity audit, data schema, reproduction steps, and limitations. If you just want the plain-English version, read the Methodology.

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)

  1. Most points
  2. Best goal difference
  3. Most goals scored
  4. Head-to-head points among tied teams
  5. Head-to-head goal difference
  6. Head-to-head goals scored
  7. Fair-play points (approximated as zero in simulation)
  8. 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_base picks up get_team_elo_adjustment() from scripts/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 to data/live/matchday_intelligence_log.jsonl for full audit.

Calibration & performance metrics

Holdout (4,839 unseen modern matches)

MetricModelElo-onlyNaive (33/33/33)
Log-loss0.8690.9081.055
Brier0.5110.5350.667
Top-1 accuracy60.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.

WCNLog-lossBrierAccLift vs Elo
Loading walk_forward.json…

Feature ablation

ConfigurationLog-lossBrierAccuracy
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.

ParameterDefaultTested 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-life180 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 slope0.30[0.20, 0.45]

Top-team ranges from latest sensitivity run:

TeamMeanMinMaxRange
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.

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_available is hard-coded false) — 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 /injuries doesn't expose player ratings, so v2 cross-references the injured name against data/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 as ambiguous_classification warnings on the dashboard for operator review. data/live/team_adjustments.json stacks 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_cache is keyed on the 2026-06-11 pre-tournament cut and reused for both group and knockout sims. Completed WC26 results don't fold back into def_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 into effective_elo. The simulator then re-publishes p_home_win/p_draw/p_away_win for 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.py coalesces 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.

← Back to the dashboard