File size: 9,027 Bytes
f7d934a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
from __future__ import annotations

"""Walk-forward backtest: fitted Dixon-Coles Elo model vs baselines.

For each test year Y in TEST_YEARS, the Dixon-Coles Elo model is refit (same
MLE procedure as fit_elo_dixon_coles.py) on only the matches strictly before
Y, then scored on every match played during Y. This mirrors how the model
would actually have been used -- no fold sees data from its own future.

Three candidates are scored on each fold's test matches:

  - uniform: 1/3 / 1/3 / 1/3 regardless of teams (no skill baseline).
  - current: the pre-remediation model previously shipped in
    underdog_lab.world_cup.forecasting.MODEL (independent Poisson,
    elo_scale=0.00165, home_advantage_elo=0, rho=0 -- hand-set, never fit).
  - fitted: this fold's freshly-fit Dixon-Coles Elo model (intercept,
    elo_scale, home_advantage_elo, rho all fit on the training window only).

Metrics: mean log loss, Brier score, and Rank Probability Score (RPS) per
candidate, summed/averaged across all test folds. A basic calibration table
for the fitted model's predicted home-win probability is also produced.

Ship gate: the fitted model must beat both "uniform" and "current" on mean
log loss across all test folds combined and beat "current" on the
neutral-venue subset that most closely matches World Cup inference. Writes
models/backtest_report.json with the full breakdown and the gate verdict.
Does not modify
src/underdog_lab/world_cup/forecasting.py -- that swap is a separate,
human-reviewed step gated on this report's verdict.

Usage:
  python scripts/backtest_walk_forward.py
"""

import json
from datetime import date
from types import SimpleNamespace

from underdog_lab.config import MODEL_DIR
from underdog_lab.forecasting.elo_goals import EloGoalModel
from underdog_lab.forecasting.poisson import forecast_from_lambdas

from backtest_common import (
    calibration_table,
    fit_dixon_coles,
    load_matches_with_self_elo,
    observed_outcome,
    score_candidate,
)

REPORT_PATH = MODEL_DIR / "backtest_report.json"
# Selected by scripts/upgrade_evaluation.py: beats the previous 1095-day
# (3 year) half-life on mean log loss across 2018-2025 selection folds AND
# on the held-out 2026 confirmation fold, both overall and on the
# neutral-venue subset. See models/upgrade_evaluation.json.
HALF_LIFE_DAYS = 180.0

# Test years: the dataset starts 2015-01-03, so 2018 onward leaves at least
# three years of training data for the first fold.
TEST_YEARS = list(range(2018, 2027))

UNIFORM_FORECAST = SimpleNamespace(p_home=1 / 3, p_draw=1 / 3, p_away=1 / 3)

# The pre-remediation model previously shipped in world_cup/forecasting.py:
# independent Poisson (rho=0), hand-set elo_scale, no home advantage.
CURRENT_MODEL = EloGoalModel(
    intercept=0.09531017980432493,
    elo_scale=0.00165,
    home_advantage_elo=0.0,
)


def current_model_forecast(home_elo: float, away_elo: float, neutral: bool):
    lambda_home, lambda_away = CURRENT_MODEL.lambdas(home_elo, away_elo, neutral_venue=neutral)
    return forecast_from_lambdas(lambda_home, lambda_away)


def run_fold(test_year: int, all_matches: list[dict]) -> dict:
    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]
    if not test_matches:
        return {}

    fitted_model = fit_dixon_coles(train_matches, train_cutoff, HALF_LIFE_DAYS)

    totals = {
        scope: {
            name: {"log_loss": 0.0, "brier": 0.0, "rps": 0.0}
            for name in ("uniform", "current", "fitted")
        }
        for scope in ("all", "neutral")
    }
    counts = {"all": 0, "neutral": 0}
    calibration_rows = []
    for match in test_matches:
        outcome = observed_outcome(match["home_goals"], match["away_goals"])

        current_forecast = current_model_forecast(match["home_elo"], match["away_elo"], match["neutral"])
        fitted_forecast = fitted_model.forecast(match["home_elo"], match["away_elo"], neutral_venue=match["neutral"])
        forecasts = {
            "uniform": UNIFORM_FORECAST,
            "current": current_forecast,
            "fitted": fitted_forecast,
        }
        scopes = ["all"] + (["neutral"] if match["neutral"] else [])
        for scope in scopes:
            counts[scope] += 1
            for candidate, forecast in forecasts.items():
                for metric, value in score_candidate(forecast, outcome).items():
                    totals[scope][candidate][metric] += value
        calibration_rows.append((fitted_forecast.p_home, outcome == "home"))

    means = {
        scope: {
            candidate: {
                metric: total / counts[scope]
                for metric, total in metric_totals.items()
            }
            for candidate, metric_totals in scope_totals.items()
        }
        for scope, scope_totals in totals.items()
        if counts[scope]
    }
    return {
        "test_year": test_year,
        "train_matches": len(train_matches),
        "test_matches": len(test_matches),
        "neutral_test_matches": counts["neutral"],
        "fitted_params": {
            "intercept": fitted_model.intercept,
            "elo_scale": fitted_model.elo_scale,
            "home_advantage_elo": fitted_model.home_advantage_elo,
            "rho": fitted_model.rho,
        },
        "mean_scores": means["all"],
        "neutral_mean_scores": means.get("neutral", {}),
        "calibration_rows": calibration_rows,
    }


def main() -> None:
    all_matches = load_matches_with_self_elo(date(2026, 6, 12))

    folds = []
    for test_year in TEST_YEARS:
        fold = run_fold(test_year, all_matches)
        if fold:
            folds.append(fold)

    all_calibration_rows: list[tuple[float, bool]] = []
    for fold in folds:
        all_calibration_rows.extend(fold.pop("calibration_rows"))

    total_test_matches = sum(fold["test_matches"] for fold in folds)
    neutral_test_matches = sum(fold["neutral_test_matches"] for fold in folds)
    overall = {candidate: {"log_loss": 0.0, "brier": 0.0, "rps": 0.0} for candidate in ("uniform", "current", "fitted")}
    neutral_overall = {
        candidate: {"log_loss": 0.0, "brier": 0.0, "rps": 0.0}
        for candidate in ("uniform", "current", "fitted")
    }
    for fold in folds:
        for candidate, metric_means in fold["mean_scores"].items():
            for metric, mean_value in metric_means.items():
                overall[candidate][metric] += mean_value * fold["test_matches"]
        for candidate, metric_means in fold["neutral_mean_scores"].items():
            for metric, mean_value in metric_means.items():
                neutral_overall[candidate][metric] += (
                    mean_value * fold["neutral_test_matches"]
                )
    for candidate, metric_totals in overall.items():
        for metric in metric_totals:
            overall[candidate][metric] /= total_test_matches
    for candidate, metric_totals in neutral_overall.items():
        for metric in metric_totals:
            neutral_overall[candidate][metric] /= neutral_test_matches

    fitted_beats_uniform = overall["fitted"]["log_loss"] < overall["uniform"]["log_loss"]
    fitted_beats_current = overall["fitted"]["log_loss"] < overall["current"]["log_loss"]
    fitted_beats_neutral_current = (
        neutral_overall["fitted"]["log_loss"]
        < neutral_overall["current"]["log_loss"]
    )
    ship = (
        fitted_beats_uniform
        and fitted_beats_current
        and fitted_beats_neutral_current
    )

    report = {
        "test_years": TEST_YEARS,
        "half_life_days": HALF_LIFE_DAYS,
        "total_test_matches": total_test_matches,
        "neutral_test_matches": neutral_test_matches,
        "folds": folds,
        "overall_mean_scores": overall,
        "neutral_mean_scores": neutral_overall,
        "calibration_home_win": calibration_table(all_calibration_rows),
        "ship_gate": {
            "fitted_beats_uniform_log_loss": fitted_beats_uniform,
            "fitted_beats_current_log_loss": fitted_beats_current,
            "fitted_beats_current_neutral_log_loss": (
                fitted_beats_neutral_current
            ),
            "ship": ship,
            "criterion": (
                "The fitted Dixon-Coles Elo model must have a lower mean "
                "log loss than both the uniform baseline and the model "
                "previously shipped in world_cup/forecasting.py, both overall "
                "and on neutral-venue matches, across walk-forward test folds "
                "(2018-2026, no fold trained on its own test data)."
            ),
        },
    }

    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(json.dumps(report["overall_mean_scores"], indent=2))
    print(json.dumps(report["ship_gate"], indent=2))


if __name__ == "__main__":
    main()