underdog-lab / scripts /recalibration_evaluation.py
Moftah
Calibrate forecasts and add market blend gate
a68e63f
Raw
History Blame Contribute Delete
8.58 kB
"""Post-hoc temperature-scaling recalibration for the shipped Dixon-Coles Elo
model.
The walk-forward folds from ``backtest_walk_forward.py`` (refit per test
year at ``HALF_LIFE_DAYS=180.0``, the current shipped half-life -- see
``models/upgrade_evaluation.json``) are reused to collect out-of-fold
forecasts for every match 2018-2026. A single temperature ``T`` (see
``forecasting/calibration.py``) is fit on the pooled SELECTION_YEARS
(2018-2025) out-of-fold forecasts and evaluated against the ``T=1``
(no-op) baseline using the same three-way selection/confirmation discipline
as ``upgrade_evaluation.py``:
- SELECTION_YEARS (2018-2025): the fitted T must beat T=1 on mean log loss
here.
- CONFIRMATION_YEAR (2026): held out from fitting. The fitted T must ALSO
beat T=1 on this fold, both overall and on the neutral-venue subset.
Writes models/recalibration_evaluation.json. Does not modify
src/underdog_lab/world_cup/forecasting.py or models/backtest_report.json --
adopting the fitted temperature is a separate, human-reviewed step.
Usage:
python scripts/recalibration_evaluation.py
"""
from __future__ import annotations
import json
import random
from datetime import date
from underdog_lab.config import MODEL_DIR
from underdog_lab.forecasting.calibration import apply_temperature, fit_temperature
from backtest_common import (
fit_dixon_coles,
load_matches_with_self_elo,
observed_outcome,
score_candidate,
)
REPORT_PATH = MODEL_DIR / "recalibration_evaluation.json"
HALF_LIFE_DAYS = 180.0
SELECTION_YEARS = list(range(2018, 2026))
CONFIRMATION_YEAR = 2026
ALL_YEARS = SELECTION_YEARS + [CONFIRMATION_YEAR]
EMPTY_TOTALS = {"log_loss": 0.0, "brier": 0.0, "rps": 0.0}
BOOTSTRAP_ITERATIONS = 2000
def fold_matches(all_matches: list[dict], test_year: int):
train_cutoff = date(test_year - 1, 12, 31)
train_matches = [m for m in all_matches if m["date"] <= train_cutoff]
test_matches = [m for m in all_matches if m["date"].year == test_year]
return train_cutoff, train_matches, test_matches
def collect_out_of_fold(all_matches: list[dict], years: list[int]) -> dict[int, list[tuple]]:
"""For each year, fit a model on data strictly before it and forecast
every match played during it. Returns {year: [(forecast, outcome, is_neutral), ...]}."""
per_year: dict[int, list[tuple]] = {}
for year in years:
train_cutoff, train_matches, test_matches = fold_matches(all_matches, year)
if not test_matches:
continue
model = fit_dixon_coles(train_matches, train_cutoff, HALF_LIFE_DAYS)
rows = []
for match in test_matches:
forecast = model.forecast(match["home_elo"], match["away_elo"], neutral_venue=match["neutral"])
outcome = observed_outcome(match["home_goals"], match["away_goals"])
rows.append((forecast, outcome, match["neutral"]))
per_year[year] = rows
return per_year
def _aggregate(rows: list[tuple], temperature: float) -> tuple[dict, dict, int, int]:
"""rows: list of (forecast, outcome, is_neutral). Returns (mean_all, mean_neutral, n_all, n_neutral)."""
totals_all = dict(EMPTY_TOTALS)
totals_neutral = dict(EMPTY_TOTALS)
n_all = 0
n_neutral = 0
for forecast, outcome, is_neutral in rows:
scores = score_candidate(apply_temperature(forecast, temperature), outcome)
for key, value in scores.items():
totals_all[key] += value
n_all += 1
if is_neutral:
for key, value in scores.items():
totals_neutral[key] += value
n_neutral += 1
mean_all = {k: v / n_all for k, v in totals_all.items()} if n_all else {}
mean_neutral = {k: v / n_neutral for k, v in totals_neutral.items()} if n_neutral else {}
return mean_all, mean_neutral, n_all, n_neutral
def paired_log_loss_interval(
rows: list[tuple],
temperature: float,
*,
seed: int = 2026,
) -> list[float]:
"""Bootstrap fitted-minus-baseline log-loss differences."""
differences = [
score_candidate(apply_temperature(forecast, temperature), outcome)[
"log_loss"
]
- score_candidate(forecast, outcome)["log_loss"]
for forecast, outcome, _ in rows
]
rng = random.Random(seed)
samples = [
sum(rng.choice(differences) for _ in differences) / len(differences)
for _ in range(BOOTSTRAP_ITERATIONS)
]
samples.sort()
return [
samples[int(0.025 * BOOTSTRAP_ITERATIONS)],
samples[int(0.975 * BOOTSTRAP_ITERATIONS)],
]
def main() -> None:
all_matches = load_matches_with_self_elo(date(2026, 6, 12))
per_year = collect_out_of_fold(all_matches, ALL_YEARS)
selection_rows = [row for year in SELECTION_YEARS for row in per_year.get(year, [])]
confirmation_rows = per_year.get(CONFIRMATION_YEAR, [])
confirmation_neutral_rows = [
row for row in confirmation_rows if row[2]
]
temperature = fit_temperature([(forecast, outcome) for forecast, outcome, _ in selection_rows])
selection_baseline, _, n_selection, _ = _aggregate(selection_rows, 1.0)
selection_fitted, _, _, _ = _aggregate(selection_rows, temperature)
confirm_baseline_all, confirm_baseline_neutral, n_confirm, n_confirm_neutral = _aggregate(
confirmation_rows, 1.0
)
confirm_fitted_all, confirm_fitted_neutral, _, _ = _aggregate(confirmation_rows, temperature)
beats_on_selection = selection_fitted["log_loss"] < selection_baseline["log_loss"]
beats_on_confirmation = confirm_fitted_all["log_loss"] < confirm_baseline_all["log_loss"]
beats_on_confirmation_neutral = (
confirm_fitted_neutral["log_loss"] < confirm_baseline_neutral["log_loss"]
)
intervals = {
"selection": paired_log_loss_interval(selection_rows, temperature),
"confirmation": paired_log_loss_interval(
confirmation_rows,
temperature,
),
"confirmation_neutral": paired_log_loss_interval(
confirmation_neutral_rows,
temperature,
),
}
intervals_below_zero = all(interval[1] < 0.0 for interval in intervals.values())
adopted = (
temperature != 1.0
and beats_on_selection
and beats_on_confirmation
and beats_on_confirmation_neutral
and intervals_below_zero
)
report = {
"half_life_days": HALF_LIFE_DAYS,
"selection_years": SELECTION_YEARS,
"confirmation_year": CONFIRMATION_YEAR,
"n_selection": n_selection,
"n_confirmation": n_confirm,
"n_confirmation_neutral": n_confirm_neutral,
"fitted_temperature": temperature,
"selection": {
"baseline_t1": selection_baseline,
"fitted_t": selection_fitted,
},
"confirmation": {
"baseline_t1": confirm_baseline_all,
"fitted_t": confirm_fitted_all,
},
"confirmation_neutral": {
"baseline_t1": confirm_baseline_neutral,
"fitted_t": confirm_fitted_neutral,
},
"paired_log_loss_difference_bootstrap_95": intervals,
"gate": {
"beats_baseline_on_selection": beats_on_selection,
"beats_baseline_on_confirmation": beats_on_confirmation,
"beats_baseline_on_confirmation_neutral": beats_on_confirmation_neutral,
"all_paired_bootstrap_intervals_below_zero": intervals_below_zero,
},
"adopted": adopted,
"criterion": (
"The fitted temperature must differ from 1.0 and produce a lower "
"mean log loss than the T=1 (no-op) baseline on the pooled "
"2018-2025 selection folds AND on the held-out 2026 confirmation "
"fold, both overall and on its neutral-venue subset. Paired 95% "
"bootstrap intervals for all three reported slices must remain "
"below zero. The neutral subset overlaps the overall 2026 fold "
"and is not an independent confirmation sample."
),
}
MODEL_DIR.mkdir(parents=True, exist_ok=True)
REPORT_PATH.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"Wrote {REPORT_PATH}")
print("fitted temperature:", temperature, "(adopted:", adopted, ")")
print(json.dumps(report["selection"], indent=2))
print(json.dumps(report["confirmation"], indent=2))
print(json.dumps(report["confirmation_neutral"], indent=2))
print(json.dumps(report["gate"], indent=2))
if __name__ == "__main__":
main()