world-cup-oracle / bayesian /simulator.py
robomotic's picture
Add Bayesian model tab v2: qualification data, FIFA prior, tournament winner simulation
32a3a9c
Raw
History Blame Contribute Delete
7.64 kB
"""Monte Carlo tournament simulator for the 2026 FIFA World Cup.
Simulates the full tournament N times:
- Group stage: round-robin, played matches use actual scores
- Qualifier rule: top 2 per group + 8 best 3rd-place teams → 32 teams
- Knockout: single-elimination with 50/50 penalty shootout on draws
Returns championship probability for every team.
"""
from __future__ import annotations
from collections import Counter, defaultdict
import numpy as np
from data.live import get_2026_fixtures
from bayesian.model import _to_hist_name
N_SIMS = 5_000
# ── lambda matrix pre-computation ────────────────────────────────────────────
def build_lambda_matrix(params: dict, team_idx: dict[str, int]) -> tuple[np.ndarray, np.ndarray]:
"""Pre-compute expected-goals matrices for all team pairs.
lh_mat[i, j] = E[home goals] when team-i is home, team-j is away
la_mat[i, j] = E[away goals] when team-i is home, team-j is away
"""
n = len(team_idx)
atk = params["attack"] # shape (n,)
dfs = params["defense"] # shape (n,)
mu = params["mu"]
gamma = params["gamma"]
lh_mat = np.exp(np.clip(mu + gamma + atk[:, None] - dfs[None, :], -5, 5))
la_mat = np.exp(np.clip(mu + atk[None, :] - dfs[:, None], -5, 5))
return lh_mat, la_mat
# ── fixture parsing ───────────────────────────────────────────────────────────
def _parse_score(match: dict) -> tuple[int, int] | None:
score = match.get("score")
if not score:
return None
ft = score.get("ft", [])
return (int(ft[0]), int(ft[1])) if len(ft) >= 2 else None
def build_group_data(team_idx: dict[str, int]) -> dict:
"""Parse 2026 group-stage fixtures into a structured dict.
Returns:
{
"Group A": {
"teams": [hist_name, ...], # 4 teams in hist-name space
"fixtures": [
(home_idx, away_idx, actual_hg_or_None, actual_ag_or_None),
...
]
},
...
}
"""
all_fixtures = get_2026_fixtures()
group_fixtures: dict[str, list] = defaultdict(list)
group_teams: dict[str, set] = defaultdict(set)
for m in all_fixtures:
grp = m.get("group", "")
if not grp:
continue
h_raw = _to_hist_name(m["team1"])
a_raw = _to_hist_name(m["team2"])
group_teams[grp].add(h_raw)
group_teams[grp].add(a_raw)
actual = _parse_score(m)
h_idx = team_idx.get(h_raw)
a_idx = team_idx.get(a_raw)
if h_idx is None or a_idx is None:
continue
group_fixtures[grp].append((
h_idx, a_idx,
actual[0] if actual else None,
actual[1] if actual else None,
))
return {
grp: {
"teams": [team_idx[t] for t in sorted(group_teams[grp]) if t in team_idx],
"fixtures": group_fixtures[grp],
}
for grp in sorted(group_fixtures.keys())
}
# ── group-stage simulation ────────────────────────────────────────────────────
def _group_standings(team_indices: list[int], results: list[tuple]) -> list[tuple]:
"""Return teams sorted by (points, goal_diff, goals_for) descending.
results: list of (home_idx, away_idx, hg, ag)
Returns: list of (team_idx, pts, gd, gf)
"""
pts: dict[int, int] = {t: 0 for t in team_indices}
gd: dict[int, int] = {t: 0 for t in team_indices}
gf: dict[int, int] = {t: 0 for t in team_indices}
for h, a, hg, ag in results:
gf[h] += hg
gf[a] += ag
gd[h] += hg - ag
gd[a] += ag - hg
if hg > ag:
pts[h] += 3
elif ag > hg:
pts[a] += 3
else:
pts[h] += 1
pts[a] += 1
ranked = sorted(team_indices, key=lambda t: (pts[t], gd[t], gf[t]), reverse=True)
return [(t, pts[t], gd[t], gf[t]) for t in ranked]
# ── knockout simulation ───────────────────────────────────────────────────────
def _ko_match(h: int, a: int, lh_mat: np.ndarray, la_mat: np.ndarray, rng: np.random.Generator) -> int:
"""Simulate a knockout match. Draws go to 50/50 penalty shootout."""
hg = rng.poisson(lh_mat[h, a])
ag = rng.poisson(la_mat[h, a])
if hg > ag:
return h
if ag > hg:
return a
return h if rng.random() < 0.5 else a
def _simulate_knockout_bracket(teams: list[int], lh_mat: np.ndarray, la_mat: np.ndarray, rng: np.random.Generator) -> int:
"""Single-elimination bracket starting from `teams` (must be power of 2 in length).
Teams are paired sequentially: (0 vs 1), (2 vs 3), …
"""
round_teams = list(teams)
while len(round_teams) > 1:
next_round = []
for i in range(0, len(round_teams), 2):
winner = _ko_match(round_teams[i], round_teams[i + 1], lh_mat, la_mat, rng)
next_round.append(winner)
round_teams = next_round
return round_teams[0]
# ── full tournament simulation ────────────────────────────────────────────────
def simulate_tournament(
params: dict,
team_idx: dict[str, int],
n_sims: int = N_SIMS,
seed: int = 42,
) -> dict[str, float]:
"""Run n_sims Monte Carlo simulations of the full 2026 WC.
Returns dict of {team_hist_name: championship_probability}.
"""
rng = np.random.default_rng(seed)
lh_mat, la_mat = build_lambda_matrix(params, team_idx)
group_data = build_group_data(team_idx)
if not group_data:
return {}
idx_to_team = {v: k for k, v in team_idx.items()}
winner_counts: Counter = Counter()
for _ in range(n_sims):
all_third: list[tuple] = []
auto_qualifiers: list[int] = []
# ---- group stage ----
for grp_name, grp in group_data.items():
team_indices = grp["teams"]
results = []
for h_idx, a_idx, actual_hg, actual_ag in grp["fixtures"]:
if actual_hg is not None:
results.append((h_idx, a_idx, actual_hg, actual_ag))
else:
hg = int(rng.poisson(lh_mat[h_idx, a_idx]))
ag = int(rng.poisson(la_mat[h_idx, a_idx]))
results.append((h_idx, a_idx, hg, ag))
standings = _group_standings(team_indices, results)
auto_qualifiers.append(standings[0][0]) # 1st place
auto_qualifiers.append(standings[1][0]) # 2nd place
all_third.append(standings[2]) # 3rd place entry
# ---- 8 best 3rd-place teams ----
best_third = sorted(all_third, key=lambda x: (x[1], x[2], x[3]), reverse=True)[:8]
r32_teams = auto_qualifiers + [entry[0] for entry in best_third]
# ---- knockout bracket ----
# Shuffle so bracket pairings vary across simulations (no fixed seeding bias)
rng.shuffle(r32_teams)
champion_idx = _simulate_knockout_bracket(r32_teams, lh_mat, la_mat, rng)
winner_counts[idx_to_team[champion_idx]] += 1
total = sum(winner_counts.values())
return {team: count / total for team, count in winner_counts.most_common()}