underdog-lab / scripts /tournament_experiment_common.py
Moftah
Finalize forecast integrity and evaluation gates
761e5d5
Raw
History Blame Contribute Delete
3.62 kB
from __future__ import annotations
import random
from collections.abc import Callable
from datetime import date
from underdog_lab.forecasting.calibration import apply_temperature
from underdog_lab.forecasting.scoring import log_loss
from underdog_lab.forecasting.tournament_editions import assign_edition_metadata
from underdog_lab.world_cup.forecasting import CALIBRATION_TEMPERATURE
from backtest_common import (
fit_dixon_coles,
load_matches_with_self_elo,
observed_outcome,
)
HALF_LIFE_DAYS = 180.0
CONFIRMATION_EDITIONS = frozenset({"AC-2024", "CA-2024", "EC-2024", "WC-2022"})
def collect_edition_rows() -> list[dict]:
all_matches = load_matches_with_self_elo(date(2026, 6, 12))
major = assign_edition_metadata(all_matches)
rows = []
for edition_id in sorted({match["edition_id"] for match in major}):
if edition_id.endswith("-2026"):
continue
edition_matches = [
match for match in major if match["edition_id"] == edition_id
]
cutoff = min(match["date"] for match in edition_matches)
train_cutoff = cutoff.fromordinal(cutoff.toordinal() - 1)
train = [match for match in all_matches if match["date"] <= train_cutoff]
model = fit_dixon_coles(train, train_cutoff, HALF_LIFE_DAYS)
for match in edition_matches:
rows.append(
{
**match,
"model": model,
"outcome": observed_outcome(
match["home_goals"],
match["away_goals"],
),
}
)
return rows
def production_forecast(
row: dict,
*,
host_boost: float = 0.0,
force_neutral: bool = False,
):
home_elo = row["home_elo"] + (host_boost if row["home_is_host"] else 0.0)
away_elo = row["away_elo"] + (host_boost if row["away_is_host"] else 0.0)
return apply_temperature(
row["model"].forecast(
home_elo,
away_elo,
neutral_venue=True if force_neutral else row["neutral"],
),
CALIBRATION_TEMPERATURE,
)
def mean_loss(rows: list[dict], forecast_fn: Callable[[dict], object]) -> float:
return sum(log_loss(forecast_fn(row), row["outcome"]) for row in rows) / len(
rows
)
def edition_cluster_interval(
rows: list[dict],
candidate_fn: Callable[[dict], object],
baseline_fn: Callable[[dict], object],
*,
iterations: int = 4000,
seed: int = 2026,
) -> list[float]:
by_edition: dict[str, list[float]] = {}
for row in rows:
by_edition.setdefault(row["edition_id"], []).append(
log_loss(candidate_fn(row), row["outcome"])
- log_loss(baseline_fn(row), row["outcome"])
)
editions = sorted(by_edition)
rng = random.Random(seed)
samples = []
for _ in range(iterations):
selected = [rng.choice(editions) for _ in editions]
differences = [
difference
for edition in selected
for difference in by_edition[edition]
]
samples.append(sum(differences) / len(differences))
samples.sort()
return [
samples[int(0.025 * iterations)],
samples[int(0.975 * iterations)],
]
def split_selection_confirmation(rows: list[dict]) -> tuple[list[dict], list[dict]]:
selection = [
row for row in rows if row["edition_id"] not in CONFIRMATION_EDITIONS
]
confirmation = [
row for row in rows if row["edition_id"] in CONFIRMATION_EDITIONS
]
return selection, confirmation