45c0a978fc
- scripts/buildBacktest.ts: honest walk-forward validation — params fit on pre-2018 internationals, tested out-of-sample on 7,988 matches (2018-2026) predicting each game from prior data only. Proper scoring (Brier/log-loss/RPS/ accuracy) vs uniform / base-rate / Elo-only baselines + a calibration analysis (reliability bins + ECE). Results: 60% accuracy, RPS 0.171 (beats all baselines), ECE 0.01 (excellent calibration). - Methodology page (/methodology): plain-language model walkthrough, the backtest scorecard, a custom-SVG reliability diagram, and honest limits. Transparency is the differentiator — no market odds, no overclaiming. - ReliabilityDiagram component; 'Model' nav entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
200 lines
9.0 KiB
TypeScript
200 lines
9.0 KiB
TypeScript
// Walk-forward backtest of the Elo + Dixon-Coles model on historical
|
|
// internationals, with an honest train/test split (params fit on pre-2018,
|
|
// tested out-of-sample on 2018→now). Outputs proper scoring metrics (Brier,
|
|
// log-loss, RPS, accuracy), baseline comparisons, and a calibration/reliability
|
|
// analysis → public/data/backtest.json, shown on the Methodology page.
|
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import { canonicalTeam } from '../src/lib/teams';
|
|
import { HOME_ADV_ELO, importanceWeight, eloDelta } from '../src/lib/model/elo';
|
|
import { lambdasFromElo, scoreMatrix, outcomeProbs, poissonPmf, type ModelParams } from '../src/lib/model/poisson';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const CSV = join(ROOT, 'data', 'raw', 'international-results.csv');
|
|
const OUT_DIR = join(ROOT, 'public', 'data');
|
|
|
|
const START_ELO = 1500;
|
|
const PARAM_FROM = '2006-01-01';
|
|
const TRAIN_END = '2018-01-01'; // params fit on [PARAM_FROM, TRAIN_END)
|
|
const TEST_TO = '2026-06-01'; // exclude the 2026 World Cup itself
|
|
|
|
interface Row { date: string; home: string; away: string; hs: number; as: number; tournament: string; neutral: boolean }
|
|
type Probs = { h: number; d: number; a: number };
|
|
type Outcome = 'h' | 'd' | 'a';
|
|
|
|
function parse(): Row[] {
|
|
const lines = readFileSync(CSV, 'utf8').split('\n');
|
|
const rows: Row[] = [];
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const c = lines[i]?.split(',');
|
|
if (!c || c.length < 9) continue;
|
|
const hs = Number(c[3]); const as = Number(c[4]);
|
|
if (!Number.isFinite(hs) || !Number.isFinite(as)) continue;
|
|
rows.push({ date: c[0]!, home: c[1]!, away: c[2]!, hs, as, tournament: c[5]!, neutral: c[8]!.trim().toUpperCase() === 'TRUE' });
|
|
}
|
|
rows.sort((a, b) => a.date.localeCompare(b.date));
|
|
return rows;
|
|
}
|
|
|
|
function dcTau(h: number, a: number, lh: number, la: number, rho: number): number {
|
|
if (h === 0 && a === 0) return 1 - lh * la * rho;
|
|
if (h === 0 && a === 1) return 1 + lh * rho;
|
|
if (h === 1 && a === 0) return 1 + la * rho;
|
|
if (h === 1 && a === 1) return 1 - rho;
|
|
return 1;
|
|
}
|
|
|
|
const outcomeOf = (hs: number, as: number): Outcome => (hs > as ? 'h' : hs < as ? 'a' : 'd');
|
|
|
|
// ---- scoring ----
|
|
function brier(p: Probs, o: Outcome): number {
|
|
return (p.h - (o === 'h' ? 1 : 0)) ** 2 + (p.d - (o === 'd' ? 1 : 0)) ** 2 + (p.a - (o === 'a' ? 1 : 0)) ** 2;
|
|
}
|
|
function logloss(p: Probs, o: Outcome): number {
|
|
return -Math.log(Math.max(1e-12, p[o]));
|
|
}
|
|
function rps(p: Probs, o: Outcome): number {
|
|
// ordered H, D, A
|
|
const y = { h: o === 'h' ? 1 : 0, d: o === 'd' ? 1 : 0, a: o === 'a' ? 1 : 0 };
|
|
const c1p = p.h, c1y = y.h;
|
|
const c2p = p.h + p.d, c2y = y.h + y.d;
|
|
return ((c1p - c1y) ** 2 + (c2p - c2y) ** 2) / 2;
|
|
}
|
|
const argmax = (p: Probs): Outcome => (p.h >= p.d && p.h >= p.a ? 'h' : p.d >= p.a ? 'd' : 'a');
|
|
|
|
function scoreSet(preds: { p: Probs; o: Outcome }[]) {
|
|
let b = 0, l = 0, r = 0, correct = 0;
|
|
for (const { p, o } of preds) { b += brier(p, o); l += logloss(p, o); r += rps(p, o); if (argmax(p) === o) correct++; }
|
|
const n = preds.length;
|
|
return { brier: b / n, logloss: l / n, rps: r / n, accuracy: correct / n };
|
|
}
|
|
|
|
function main(): void {
|
|
const rows = parse();
|
|
const elo = new Map<string, number>();
|
|
const get = (t: string) => elo.get(t) ?? START_ELO;
|
|
|
|
// ---- fit params on the training window ----
|
|
let sumTotal = 0, nCal = 0, sxy = 0, sxx = 0;
|
|
const calib: { d: number; h: number; a: number }[] = [];
|
|
// we need a first pass to fit params using pre-match Elo; do it inline while walking
|
|
// (Elo is updated every match; calibration accumulates only within the train window)
|
|
|
|
const testPreds: { p: Probs; o: Outcome }[] = [];
|
|
const baseUniform: { p: Probs; o: Outcome }[] = [];
|
|
const baseRate: { p: Probs; o: Outcome }[] = [];
|
|
const baseElo: { p: Probs; o: Outcome }[] = [];
|
|
const reliabilityRaw: { p: number; y: number }[] = [];
|
|
let wcCorrect = 0, wcN = 0;
|
|
|
|
// outcome base rates over the test window (computed in a quick pre-scan)
|
|
let th = 0, td = 0, ta = 0, tn = 0;
|
|
for (const r of rows) {
|
|
if (r.date < TRAIN_END || r.date >= TEST_TO) continue;
|
|
const o = outcomeOf(r.hs, r.as); if (o === 'h') th++; else if (o === 'd') td++; else ta++; tn++;
|
|
}
|
|
const rate: Probs = { h: th / tn, d: td / tn, a: ta / tn };
|
|
|
|
// params get finalized at TRAIN_END; predictions before that use a rolling fit.
|
|
let params: ModelParams = { goalsPerElo: 0.0057, avgGoals: 2.7, rho: -0.05, homeAdvElo: HOME_ADV_ELO };
|
|
let paramsFixed = false;
|
|
|
|
const finalizeParams = (): ModelParams => {
|
|
const avgGoals = sumTotal / Math.max(1, nCal);
|
|
const goalsPerElo = sxy / Math.max(1e-9, sxx);
|
|
let bestRho = -0.05, bestLL = -Infinity;
|
|
for (let rho = -0.2; rho <= 0.1 + 1e-9; rho += 0.02) {
|
|
let ll = 0;
|
|
for (const m of calib) {
|
|
const sup = goalsPerElo * m.d;
|
|
const lh = Math.min(8, Math.max(0.15, avgGoals / 2 + sup / 2));
|
|
const la = Math.min(8, Math.max(0.15, avgGoals / 2 - sup / 2));
|
|
ll += Math.log(Math.max(1e-12, poissonPmf(lh, m.h) * poissonPmf(la, m.a) * dcTau(m.h, m.a, lh, la, rho)));
|
|
}
|
|
if (ll > bestLL) { bestLL = ll; bestRho = rho; }
|
|
}
|
|
return { goalsPerElo, avgGoals, rho: Math.round(bestRho * 100) / 100, homeAdvElo: HOME_ADV_ELO };
|
|
};
|
|
|
|
const predict = (eh: number, ea: number, homeAdv: number): Probs => {
|
|
const { lambdaHome, lambdaAway } = lambdasFromElo(eh, ea, params, homeAdv);
|
|
const p = outcomeProbs(scoreMatrix(lambdaHome, lambdaAway, params.rho));
|
|
return { h: p.home, d: p.draw, a: p.away };
|
|
};
|
|
|
|
for (const r of rows) {
|
|
if (!paramsFixed && r.date >= TRAIN_END) { params = finalizeParams(); paramsFixed = true; }
|
|
|
|
const home = canonicalTeam(r.home), away = canonicalTeam(r.away);
|
|
const eh = get(home), ea = get(away);
|
|
const homeAdv = r.neutral ? 0 : HOME_ADV_ELO;
|
|
const o = outcomeOf(r.hs, r.as);
|
|
|
|
// accumulate calibration only in the param-fitting window
|
|
if (r.date >= PARAM_FROM && r.date < TRAIN_END) {
|
|
const effDiff = eh - ea + homeAdv;
|
|
sumTotal += r.hs + r.as; nCal++; sxy += effDiff * (r.hs - r.as); sxx += effDiff * effDiff;
|
|
calib.push({ d: effDiff, h: r.hs, a: r.as });
|
|
}
|
|
|
|
// score out-of-sample
|
|
if (r.date >= TRAIN_END && r.date < TEST_TO) {
|
|
const p = predict(eh, ea, homeAdv);
|
|
testPreds.push({ p, o });
|
|
baseUniform.push({ p: { h: 1 / 3, d: 1 / 3, a: 1 / 3 }, o });
|
|
baseRate.push({ p: rate, o });
|
|
// elo-only: expected score → W/A split, fixed draw rate
|
|
const e = 1 / (1 + 10 ** ((ea - eh - homeAdv) / 400));
|
|
baseElo.push({ p: { h: e * (1 - rate.d), d: rate.d, a: (1 - e) * (1 - rate.d) }, o });
|
|
reliabilityRaw.push({ p: p.h, y: o === 'h' ? 1 : 0 }, { p: p.d, y: o === 'd' ? 1 : 0 }, { p: p.a, y: o === 'a' ? 1 : 0 });
|
|
if (r.tournament.includes('FIFA World Cup') && !r.tournament.includes('qualification')) {
|
|
wcN++; if (argmax(p) === o) wcCorrect++;
|
|
}
|
|
}
|
|
|
|
// update Elo (always)
|
|
const k = importanceWeight(r.tournament);
|
|
const d = eloDelta(r.hs, r.as, eh, ea, k, homeAdv);
|
|
elo.set(home, eh + d); elo.set(away, ea - d);
|
|
}
|
|
|
|
// reliability bins (10) + ECE
|
|
const bins = Array.from({ length: 10 }, () => ({ sp: 0, sy: 0, n: 0 }));
|
|
for (const { p, y } of reliabilityRaw) {
|
|
const idx = Math.min(9, Math.floor(p * 10));
|
|
bins[idx]!.sp += p; bins[idx]!.sy += y; bins[idx]!.n++;
|
|
}
|
|
const reliability = bins.map((b) => ({ predicted: b.n ? b.sp / b.n : 0, observed: b.n ? b.sy / b.n : 0, count: b.n }));
|
|
const totalN = reliabilityRaw.length;
|
|
const ece = bins.reduce((s, b) => s + (b.n ? (b.n / totalN) * Math.abs(b.sp / b.n - b.sy / b.n) : 0), 0);
|
|
|
|
const out = {
|
|
generatedAt: new Date().toISOString(),
|
|
paramFrom: PARAM_FROM,
|
|
trainEnd: TRAIN_END,
|
|
testTo: TEST_TO,
|
|
tested: testPreds.length,
|
|
params,
|
|
model: scoreSet(testPreds),
|
|
baselines: {
|
|
uniform: scoreSet(baseUniform),
|
|
baseRate: scoreSet(baseRate),
|
|
eloOnly: scoreSet(baseElo),
|
|
},
|
|
reliability,
|
|
ece: Math.round(ece * 1000) / 1000,
|
|
worldCup: { tested: wcN, accuracy: wcN ? wcCorrect / wcN : 0 },
|
|
};
|
|
|
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
writeFileSync(join(OUT_DIR, 'backtest.json'), JSON.stringify(out));
|
|
console.log(`backtest → public/data/backtest.json (${out.tested} test matches, ${TRAIN_END}→${TEST_TO})`);
|
|
console.log(` model: brier ${out.model.brier.toFixed(3)} logloss ${out.model.logloss.toFixed(3)} rps ${out.model.rps.toFixed(3)} acc ${(out.model.accuracy * 100).toFixed(1)}%`);
|
|
console.log(` uniform: brier ${out.baselines.uniform.brier.toFixed(3)} logloss ${out.baselines.uniform.logloss.toFixed(3)} rps ${out.baselines.uniform.rps.toFixed(3)}`);
|
|
console.log(` baseRate:brier ${out.baselines.baseRate.brier.toFixed(3)} rps ${out.baselines.baseRate.rps.toFixed(3)} | eloOnly rps ${out.baselines.eloOnly.rps.toFixed(3)}`);
|
|
console.log(` ECE ${out.ece} | WC accuracy ${(out.worldCup.accuracy * 100).toFixed(1)}% (${out.worldCup.tested})`);
|
|
}
|
|
|
|
main();
|