underdog-lab / scripts /upgrade_evaluation.py
Moftah
Tune recency and add ensemble evaluation
b05ddd5
Raw
History Blame Contribute Delete
12.3 kB
"""Workstream 1 & 2 experiments: recency half-life and a second-model ensemble.
Two changes to the walk-forward-fitted Dixon-Coles Elo model are evaluated
here, both gated by the same no-lookahead, selection/confirmation discipline
used in ``backtest_walk_forward.py``'s ship gate:
1. Recency half-life (Workstream 1): ``HALF_LIFE_DAYS=1095`` (3 years) in
the shipped fit was never tuned. This sweeps a range of half-lives.
2. Ensemble with a second, independently-computed rating (Workstream 2):
``forecasting/self_elo.py`` computes a strength rating from scratch
(fixed K-factor, goal-difference multiplier, neutral start) using only
match results -- independent of the eloratings.net ``home_elo``/
``away_elo`` columns the shipped model uses. A second Dixon-Coles model
is fit on this rating and blended with the eloratings-Elo model via a
logarithmic opinion pool (``forecasting/ensemble.py``).
Selection vs confirmation:
- SELECTION_YEARS (2018-2025): candidates are ranked by mean log loss here.
- CONFIRMATION_YEAR (2026): held out from selection entirely. A candidate
is only declared a winner if it ALSO beats the baseline on this fold,
both overall and on the neutral-venue subset -- the same three-way
criterion as the official ship gate, applied here for selection instead
of being skipped.
Writes models/upgrade_evaluation.json. Does not modify
src/underdog_lab/world_cup/forecasting.py, models/elo_fit_report.json, or
models/backtest_report.json -- promoting a winning configuration into the
shipped MODEL is a separate, human-reviewed step.
Usage:
python scripts/upgrade_evaluation.py
"""
from __future__ import annotations
import json
from datetime import date
from underdog_lab.config import MODEL_DIR
from underdog_lab.forecasting.ensemble import blend_forecasts
from backtest_common import (
fit_dixon_coles,
load_matches_with_self_elo,
observed_outcome,
score_candidate,
)
REPORT_PATH = MODEL_DIR / "upgrade_evaluation.json"
SELECTION_YEARS = list(range(2018, 2026))
CONFIRMATION_YEAR = 2026
ALL_YEARS = SELECTION_YEARS + [CONFIRMATION_YEAR]
CURRENT_HALF_LIFE = 1095.0
HALF_LIFE_SWEEP = [180.0, 365.0, 547.0, 730.0, 1095.0, 1460.0, 2190.0]
ENSEMBLE_WEIGHTS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
EMPTY_TOTALS = {"log_loss": 0.0, "brier": 0.0, "rps": 0.0}
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 _aggregate(rows: list[tuple[dict, bool]]) -> tuple[dict, dict, int, int]:
"""rows: list of (score_dict, 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 scores, is_neutral in rows:
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 eval_eloratings_model(all_matches: list[dict], years: list[int], half_life: float) -> dict:
"""Fit the eloratings-Elo Dixon-Coles model per fold and score it."""
rows: list[tuple[dict, bool]] = []
per_year: dict[int, dict] = {}
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)
year_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"])
scores = score_candidate(forecast, outcome)
year_rows.append((scores, match["neutral"]))
rows.extend(year_rows)
mean_all, mean_neutral, n_all, n_neutral = _aggregate(year_rows)
per_year[year] = {"mean_scores": mean_all, "neutral_mean_scores": mean_neutral, "n": n_all, "n_neutral": n_neutral}
mean_all, mean_neutral, n_all, n_neutral = _aggregate(rows)
return {"per_year": per_year, "mean_scores": mean_all, "neutral_mean_scores": mean_neutral, "n": n_all, "n_neutral": n_neutral}
def fit_fold_models(all_matches: list[dict], years: list[int], half_life: float) -> dict[int, dict]:
"""Fit BOTH the eloratings-Elo and self-Elo models per fold."""
folds = {}
for year in years:
train_cutoff, train_matches, test_matches = fold_matches(all_matches, year)
if not test_matches:
continue
elo_model = fit_dixon_coles(train_matches, train_cutoff, half_life)
self_model = fit_dixon_coles(train_matches, train_cutoff, half_life, elo_keys=("self_home_elo", "self_away_elo"))
folds[year] = {"test_matches": test_matches, "elo_model": elo_model, "self_model": self_model}
return folds
def eval_ensemble(folds: dict[int, dict], years: list[int], weight: float) -> dict:
rows: list[tuple[dict, bool]] = []
per_year: dict[int, dict] = {}
for year in years:
if year not in folds:
continue
fold = folds[year]
year_rows = []
for match in fold["test_matches"]:
elo_forecast = fold["elo_model"].forecast(match["home_elo"], match["away_elo"], neutral_venue=match["neutral"])
self_forecast = fold["self_model"].forecast(match["self_home_elo"], match["self_away_elo"], neutral_venue=match["neutral"])
forecast = blend_forecasts(elo_forecast, self_forecast, weight) if weight < 1.0 else elo_forecast
outcome = observed_outcome(match["home_goals"], match["away_goals"])
scores = score_candidate(forecast, outcome)
year_rows.append((scores, match["neutral"]))
rows.extend(year_rows)
mean_all, mean_neutral, n_all, n_neutral = _aggregate(year_rows)
per_year[year] = {"mean_scores": mean_all, "neutral_mean_scores": mean_neutral, "n": n_all, "n_neutral": n_neutral}
mean_all, mean_neutral, n_all, n_neutral = _aggregate(rows)
return {"per_year": per_year, "mean_scores": mean_all, "neutral_mean_scores": mean_neutral, "n": n_all, "n_neutral": n_neutral}
def main() -> None:
all_matches = load_matches_with_self_elo(date(2026, 6, 12))
# --- Workstream 1: half-life sweep, selected on 2018-2025 only ---
half_life_results = {}
for half_life in HALF_LIFE_SWEEP:
half_life_results[half_life] = eval_eloratings_model(all_matches, ALL_YEARS, half_life)
def selection_mean(res: dict, metric: str = "log_loss") -> float:
total = 0.0
n = 0
for year in SELECTION_YEARS:
if year not in res["per_year"]:
continue
total += res["per_year"][year]["mean_scores"][metric] * res["per_year"][year]["n"]
n += res["per_year"][year]["n"]
return total / n
half_life_selection = {hl: selection_mean(res) for hl, res in half_life_results.items()}
best_half_life = min(half_life_selection, key=half_life_selection.get)
confirm_current = half_life_results[CURRENT_HALF_LIFE]["per_year"][CONFIRMATION_YEAR]
confirm_best = half_life_results[best_half_life]["per_year"][CONFIRMATION_YEAR]
half_life_beats_on_selection = half_life_selection[best_half_life] < half_life_selection[CURRENT_HALF_LIFE]
half_life_beats_on_confirmation = (
confirm_best["mean_scores"]["log_loss"] < confirm_current["mean_scores"]["log_loss"]
)
half_life_beats_on_confirmation_neutral = (
confirm_best["neutral_mean_scores"]["log_loss"] < confirm_current["neutral_mean_scores"]["log_loss"]
)
half_life_winner = (
best_half_life
if (
best_half_life != CURRENT_HALF_LIFE
and half_life_beats_on_selection
and half_life_beats_on_confirmation
and half_life_beats_on_confirmation_neutral
)
else CURRENT_HALF_LIFE
)
# --- Workstream 2: ensemble with self-Elo model, using half_life_winner ---
folds = fit_fold_models(all_matches, ALL_YEARS, half_life_winner)
ensemble_results = {weight: eval_ensemble(folds, ALL_YEARS, weight) for weight in ENSEMBLE_WEIGHTS}
def ensemble_selection_mean(res: dict, metric: str = "log_loss") -> float:
total = 0.0
n = 0
for year in SELECTION_YEARS:
if year not in res["per_year"]:
continue
total += res["per_year"][year]["mean_scores"][metric] * res["per_year"][year]["n"]
n += res["per_year"][year]["n"]
return total / n
ensemble_selection = {w: ensemble_selection_mean(res) for w, res in ensemble_results.items()}
best_weight = min(ensemble_selection, key=ensemble_selection.get)
confirm_ensemble_best = ensemble_results[best_weight]["per_year"][CONFIRMATION_YEAR]
confirm_ensemble_baseline = ensemble_results[1.0]["per_year"][CONFIRMATION_YEAR]
ensemble_beats_on_selection = ensemble_selection[best_weight] < ensemble_selection[1.0]
ensemble_beats_on_confirmation = (
confirm_ensemble_best["mean_scores"]["log_loss"] < confirm_ensemble_baseline["mean_scores"]["log_loss"]
)
ensemble_beats_on_confirmation_neutral = (
confirm_ensemble_best["neutral_mean_scores"]["log_loss"]
< confirm_ensemble_baseline["neutral_mean_scores"]["log_loss"]
)
ensemble_winner = (
best_weight < 1.0
and ensemble_beats_on_selection
and ensemble_beats_on_confirmation
and ensemble_beats_on_confirmation_neutral
)
report = {
"selection_years": SELECTION_YEARS,
"confirmation_year": CONFIRMATION_YEAR,
"half_life_sweep": {
"current_half_life": CURRENT_HALF_LIFE,
"candidates": HALF_LIFE_SWEEP,
"selection_mean_log_loss": half_life_selection,
"best_half_life_by_selection": best_half_life,
"confirmation": {
"current": confirm_current,
"best_candidate": confirm_best,
},
"gate": {
"beats_current_on_selection": half_life_beats_on_selection,
"beats_current_on_confirmation": half_life_beats_on_confirmation,
"beats_current_on_confirmation_neutral": half_life_beats_on_confirmation_neutral,
},
"winner": half_life_winner,
"adopted": half_life_winner != CURRENT_HALF_LIFE,
},
"ensemble": {
"half_life_used": half_life_winner,
"weights": ENSEMBLE_WEIGHTS,
"weight_is_share_on_eloratings_model": True,
"selection_mean_log_loss": ensemble_selection,
"best_weight_by_selection": best_weight,
"confirmation": {
"eloratings_only": confirm_ensemble_baseline,
"best_ensemble": confirm_ensemble_best,
},
"gate": {
"beats_eloratings_only_on_selection": ensemble_beats_on_selection,
"beats_eloratings_only_on_confirmation": ensemble_beats_on_confirmation,
"beats_eloratings_only_on_confirmation_neutral": ensemble_beats_on_confirmation_neutral,
},
"adopted": ensemble_winner,
},
}
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("half_life winner:", half_life_winner, "(adopted:", report["half_life_sweep"]["adopted"], ")")
print("ensemble best weight:", best_weight, "(adopted:", ensemble_winner, ")")
print(json.dumps(half_life_selection, indent=2))
print(json.dumps(ensemble_selection, indent=2))
if __name__ == "__main__":
main()