robomotic's picture
Add Bayesian model tab v2: qualification data, FIFA prior, tournament winner simulation
32a3a9c
Raw
History Blame Contribute Delete
17.1 kB
"""Dixon-Coles Bayesian Poisson model for World Cup match prediction.
Data sources used for fitting:
1. Historical WC matches 1930-2022 (jfjelstul/worldcup dataset)
2. Recent international results 2022-present (martj42/international_results)
Priors:
- FIFA World Rankings (June 2025) provide informative prior means for
each team's total strength (α+δ), split evenly between attack and defense.
- L2 regularisation (λ=1.0) around those prior means acts as a Gaussian
prior: αᵢ ~ N(prior_atk_i, 1), δᵢ ~ N(prior_dfs_i, 1).
Uncertainty is the Laplace approximation (inverse Hessian diagonal at MAP).
"""
import io
import math
import time
import unicodedata
from pathlib import Path
from threading import Lock
from typing import Any
import numpy as np
import pandas as pd
import requests
from scipy.optimize import minimize
from scipy.stats import poisson
from data.loader import get_matches
from data.live import get_all_named_matches, normalize_team_name
from bayesian.rankings import get_prior_means
_DECAY_XI = 0.002 # per-week temporal decay
_MAX_GOALS = 8 # score matrix truncation
_RIDGE_LAMBDA = 1.0 # L2 regularisation strength
_DC_RHO = -0.1 # Dixon-Coles low-score correction
# Recent data settings
_RECENT_URL = (
"https://raw.githubusercontent.com/martj42/international_results"
"/master/results.csv"
)
_RECENT_CACHE = Path(__file__).parent.parent / "data" / "2026" / "recent_results.csv"
_RECENT_FROM = "2022-01-01" # only use post-2022 WC cycle data
_RECENT_CACHE_LOCK = Lock()
_RECENT_CACHE_TTL = 86400 # refresh local cache once per day
_FRIENDLY_WEIGHT = 0.4 # down-weight friendlies vs competitive matches
_model_cache: dict | None = None
# ── name normalisation ────────────────────────────────────────────────────────
def _norm(name: str) -> str:
nfkd = unicodedata.normalize("NFKD", name)
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower().strip()
# Maps openfootball / martj42 / common names → jfjelstul historical names
_HIST_NAME_MAP: dict[str, str] = {
"usa": "United States",
"united states of america": "United States",
"bosnia & herzegovina": "Bosnia and Herzegovina",
"bosnia-herzegovina": "Bosnia and Herzegovina",
"bosnia and herzegovina": "Bosnia and Herzegovina",
"ir iran": "Iran",
"korea republic": "South Korea",
"republic of korea": "South Korea",
"dpr korea": "North Korea",
"trinidad & tobago": "Trinidad and Tobago",
"trinidad and tobago": "Trinidad and Tobago",
"côte d'ivoire": "Ivory Coast",
"cote d'ivoire": "Ivory Coast",
"ivory coast": "Ivory Coast",
"china pr": "China",
"chinese taipei": "Taiwan",
"north macedonia": "Macedonia",
"republic of ireland": "Ireland",
}
def _to_hist_name(name: str) -> str:
"""Map any external team name to the name used in this model's team index."""
direct = normalize_team_name(name)
normed = _norm(direct)
return _HIST_NAME_MAP.get(normed, direct)
# ── recent international data ─────────────────────────────────────────────────
def _is_cache_fresh() -> bool:
if not _RECENT_CACHE.exists():
return False
age = time.time() - _RECENT_CACHE.stat().st_mtime
return age < _RECENT_CACHE_TTL
def fetch_recent_international_data() -> pd.DataFrame:
"""Download martj42 international results CSV and return post-2022 rows.
Caches locally to avoid re-downloading on every model fit.
Returns an empty DataFrame on any failure so the model degrades gracefully.
"""
with _RECENT_CACHE_LOCK:
if not _is_cache_fresh():
try:
resp = requests.get(_RECENT_URL, timeout=30)
resp.raise_for_status()
_RECENT_CACHE.parent.mkdir(parents=True, exist_ok=True)
_RECENT_CACHE.write_bytes(resp.content)
except Exception:
if not _RECENT_CACHE.exists():
return pd.DataFrame()
try:
df = pd.read_csv(_RECENT_CACHE, low_memory=False)
except Exception:
return pd.DataFrame()
required = {"date", "home_team", "away_team", "home_score", "away_score", "tournament"}
if not required.issubset(df.columns):
return pd.DataFrame()
df["date"] = pd.to_datetime(df["date"], errors="coerce")
df = df.dropna(subset=["date", "home_score", "away_score"])
df = df[df["date"] >= _RECENT_FROM].copy()
df["home_score"] = df["home_score"].astype(int)
df["away_score"] = df["away_score"].astype(int)
# Normalise team names to the same space as the historical WC data
df["home_team"] = df["home_team"].apply(_to_hist_name)
df["away_team"] = df["away_team"].apply(_to_hist_name)
return df
def _recent_match_weight(tournament: str) -> float:
"""Weight competitive matches higher than friendlies."""
t = str(tournament).lower()
if "friendly" in t:
return _FRIENDLY_WEIGHT
return 1.0
# ── historical WC data ────────────────────────────────────────────────────────
def _get_wc2026_team_names() -> set[str]:
"""Return the set of normalised team names playing in the 2026 WC."""
try:
matches = get_all_named_matches()
names: set[str] = set()
for m in matches:
names.add(_to_hist_name(m["team1"]))
names.add(_to_hist_name(m["team2"]))
return names
except Exception:
return set()
def load_training_data() -> dict:
"""Load and merge historical WC + recent international data for model fitting.
Recent data is filtered to matches where at least one team is a 2026 WC
participant, keeping the parameter space tractable (~100-150 teams vs 271).
"""
# --- Historical WC matches (1930-2022) ---
hist = get_matches()
hist = hist[hist["tournament_name"].str.contains("Men", na=False)].copy()
hist = hist.dropna(subset=["home_team_score", "away_team_score"])
hist["home_team_score"] = hist["home_team_score"].astype(int)
hist["away_team_score"] = hist["away_team_score"].astype(int)
hist["match_date"] = pd.to_datetime(hist["match_date"], errors="coerce")
hist = hist.dropna(subset=["match_date"])
rows_hist = pd.DataFrame({
"date": hist["match_date"],
"home_team": hist["home_team_name"],
"away_team": hist["away_team_name"],
"home_score": hist["home_team_score"],
"away_score": hist["away_team_score"],
"base_weight": 1.0,
})
# --- Recent international matches (2022+) ---
# Filter to matches involving at least one 2026 WC team so the parameter
# space stays tractable (prevents optimiser from managing 270+ teams).
wc_teams = _get_wc2026_team_names()
recent = fetch_recent_international_data()
if not recent.empty and wc_teams:
in_wc = recent["home_team"].isin(wc_teams) | recent["away_team"].isin(wc_teams)
recent = recent[in_wc]
if not recent.empty:
rows_recent = pd.DataFrame({
"date": recent["date"],
"home_team": recent["home_team"],
"away_team": recent["away_team"],
"home_score": recent["home_score"],
"away_score": recent["away_score"],
"base_weight": recent["tournament"].apply(_recent_match_weight),
})
all_rows = pd.concat([rows_hist, rows_recent], ignore_index=True)
n_recent = len(rows_recent)
else:
all_rows = rows_hist
n_recent = 0
# --- Temporal decay (applied to full combined dataset) ---
ref_date = all_rows["date"].max()
all_rows["weeks_ago"] = (ref_date - all_rows["date"]).dt.days / 7.0
all_rows["weight"] = all_rows["base_weight"] * np.exp(-_DECAY_XI * all_rows["weeks_ago"])
# --- Team index (union of all teams seen) ---
all_teams = sorted(
set(all_rows["home_team"].tolist()) | set(all_rows["away_team"].tolist())
)
team_idx: dict[str, int] = {t: i for i, t in enumerate(all_teams)}
# Drop rows where either team name failed to map (rare edge case)
mask = all_rows["home_team"].isin(team_idx) & all_rows["away_team"].isin(team_idx)
all_rows = all_rows[mask]
home_t = np.array([team_idx[t] for t in all_rows["home_team"]])
away_t = np.array([team_idx[t] for t in all_rows["away_team"]])
home_g = all_rows["home_score"].to_numpy()
away_g = all_rows["away_score"].to_numpy()
weights = all_rows["weight"].to_numpy()
return {
"home_t": home_t,
"away_t": away_t,
"home_g": home_g,
"away_g": away_g,
"weights": weights,
"team_idx": team_idx,
"all_teams": all_teams,
"n_teams": len(all_teams),
"n_matches_wc": len(rows_hist),
"n_matches_recent": n_recent,
}
# ── model fitting ─────────────────────────────────────────────────────────────
def fit_model(data: dict, prior_means: dict[str, float]) -> Any:
"""Fit Dixon-Coles Poisson model via MAP with ranking-informed Gaussian priors.
prior_means: dict mapping team name → prior mean for total strength (α+δ).
Each is split evenly: atk_prior[i] = dfs_prior[i] = prior_means[team] / 2.
"""
n = data["n_teams"]
all_teams = data["all_teams"]
home_t = data["home_t"]
away_t = data["away_t"]
home_g = data["home_g"]
away_g = data["away_g"]
weights = data["weights"]
# Build prior mean arrays (split total strength 50/50 between atk and dfs)
strength_prior = np.array([prior_means.get(t, 0.0) for t in all_teams])
atk_prior = strength_prior / 2.0
dfs_prior = strength_prior / 2.0
def nll(params: np.ndarray) -> float:
mu = params[0]
gamma = params[1]
atk = params[2:2 + n]
dfs = params[2 + n:]
lh = np.exp(np.clip(mu + atk[home_t] - dfs[away_t] + gamma, -5, 5))
la = np.exp(np.clip(mu + atk[away_t] - dfs[home_t], -5, 5))
ll = weights * (
home_g * np.log(np.maximum(lh, 1e-9)) - lh
+ away_g * np.log(np.maximum(la, 1e-9)) - la
)
# Ridge around FIFA-ranking-informed prior means (not around zero)
ridge = _RIDGE_LAMBDA * 0.5 * (
np.sum((atk - atk_prior) ** 2) + np.sum((dfs - dfs_prior) ** 2)
)
return -ll.sum() + ridge
# Initialise at prior means rather than zero
x0 = np.zeros(2 + 2 * n)
x0[0] = math.log(1.5)
x0[1] = 0.3
x0[2:2 + n] = atk_prior
x0[2 + n:] = dfs_prior
return minimize(
nll,
x0,
method="L-BFGS-B",
options={"maxiter": 5000, "ftol": 1e-10, "gtol": 1e-7, "maxfun": 100000},
)
def extract_parameters(result: Any, n_teams: int) -> dict:
"""Unpack optimiser result into named arrays with Laplace uncertainties."""
params = result.x
try:
hess_inv_diag = np.diag(np.array(result.hess_inv.todense()))
except Exception:
hess_inv_diag = np.ones(len(params)) * 0.01
std = np.sqrt(np.abs(hess_inv_diag))
return {
"mu": float(params[0]),
"gamma": float(params[1]),
"attack": params[2:2 + n_teams],
"defense": params[2 + n_teams:],
"attack_std": std[2:2 + n_teams],
"defense_std": std[2 + n_teams:],
"converged": bool(result.success),
"n_iter": int(result.nit),
}
# ── match prediction ──────────────────────────────────────────────────────────
def _dc_correction(hg: int, ag: int, lh: float, la: float, rho: float) -> float:
if hg == 0 and ag == 0:
return 1.0 - lh * la * rho
if hg == 1 and ag == 0:
return 1.0 + la * rho
if hg == 0 and ag == 1:
return 1.0 + lh * rho
if hg == 1 and ag == 1:
return 1.0 - rho
return 1.0
def predict_match_probs(
team_a: str,
team_b: str,
team_idx: dict[str, int],
params: dict,
) -> dict:
"""Predict win/draw/loss probabilities for team_a (home) vs team_b (away)."""
a_known = team_a in team_idx
b_known = team_b in team_idx
atk_a = params["attack"][team_idx[team_a]] if a_known else 0.0
dfs_a = params["defense"][team_idx[team_a]] if a_known else 0.0
atk_b = params["attack"][team_idx[team_b]] if b_known else 0.0
dfs_b = params["defense"][team_idx[team_b]] if b_known else 0.0
mu = params["mu"]
gamma = params["gamma"]
lh = math.exp(mu + atk_a - dfs_b + gamma)
la = math.exp(mu + atk_b - dfs_a)
G = _MAX_GOALS + 1
pmf_h = np.array([poisson.pmf(g, lh) for g in range(G)])
pmf_a = np.array([poisson.pmf(g, la) for g in range(G)])
mat = np.outer(pmf_h, pmf_a)
for hg in range(2):
for ag in range(2):
mat[hg, ag] *= max(_dc_correction(hg, ag, lh, la, _DC_RHO), 0.0)
mat = mat / mat.sum()
home_prob = float(np.tril(mat, -1).sum())
draw_prob = float(np.trace(mat))
away_prob = float(np.triu(mat, 1).sum())
total = home_prob + draw_prob + away_prob
home_prob /= total
draw_prob /= total
away_prob /= total
if home_prob >= draw_prob and home_prob >= away_prob:
predicted = "HOME"
elif away_prob >= draw_prob and away_prob >= home_prob:
predicted = "AWAY"
else:
predicted = "DRAW"
return {
"home_prob": home_prob,
"draw_prob": draw_prob,
"away_prob": away_prob,
"predicted": predicted,
"lambda_home": lh,
"lambda_away": la,
"team_a_known": a_known,
"team_b_known": b_known,
}
def predict_all_2026_matches(params: dict, team_idx: dict[str, int]) -> list[dict]:
matches = get_all_named_matches()
results = []
for m in matches:
hist_a = _to_hist_name(m["team1"])
hist_b = _to_hist_name(m["team2"])
probs = predict_match_probs(hist_a, hist_b, team_idx, params)
results.append({
"team1": m["team1"],
"team2": m["team2"],
"hist_team1": hist_a,
"hist_team2": hist_b,
"date": m.get("date", ""),
"group": m.get("group", m.get("round", "")),
"score": m.get("score"),
**probs,
})
return results
def compute_bayesian_calibration(params: dict, team_idx: dict[str, int]) -> dict:
from benchmark.runner import _load_results
raw = _load_results()
if not raw:
return {"brier": None, "log_loss": None, "accuracy": None, "n_matches": 0}
seen: set[str] = set()
played: list[dict] = []
for r in raw:
key = f"{r['team1']}||{r['team2']}||{r['date']}"
if key not in seen and r.get("actual_result") not in (None, "", "UNKNOWN"):
seen.add(key)
played.append(r)
if not played:
return {"brier": None, "log_loss": None, "accuracy": None, "n_matches": 0}
brier_sum = 0.0
log_loss_sum = 0.0
correct = 0
for r in played:
hist_a = _to_hist_name(r["team1"])
hist_b = _to_hist_name(r["team2"])
probs = predict_match_probs(hist_a, hist_b, team_idx, params)
actual = r["actual_result"]
p_actual = probs.get(f"{actual.lower()}_prob", 0.0)
brier_sum += (p_actual - 1.0) ** 2
log_loss_sum += -math.log(max(p_actual, 1e-6))
if probs["predicted"] == actual:
correct += 1
n = len(played)
return {
"brier": brier_sum / n,
"log_loss": log_loss_sum / n,
"accuracy": correct / n,
"n_matches": n,
}
# ── top-level entry point ─────────────────────────────────────────────────────
def fit_and_predict() -> dict:
"""Fit the model and predict all 2026 matches. Result is cached after first call."""
global _model_cache
if _model_cache is not None:
return _model_cache
data = load_training_data()
prior_means = get_prior_means(data["all_teams"])
result = fit_model(data, prior_means)
params = extract_parameters(result, data["n_teams"])
predictions = predict_all_2026_matches(params, data["team_idx"])
calibration = compute_bayesian_calibration(params, data["team_idx"])
_model_cache = {
"params": params,
"team_idx": data["team_idx"],
"all_teams": data["all_teams"],
"n_matches_wc": data["n_matches_wc"],
"n_matches_recent": data["n_matches_recent"],
"predictions": predictions,
"calibration": calibration,
"converged": params["converged"],
}
return _model_cache