Files
cup26/scripts/buildBacktest.ts
T
NilsBriggen f3b2a69b31 v3 Phase B core: Team-DC + backtested ensemble, squad values
- src/lib/model/teamDc.ts: per-team attack/defence Dixon-Coles via weighted MLE
  (Maher iterations), exponential time-decay, shrinkage for thin histories,
  learned home multiplier; log-pool of score matrices. 7 vitest cases.
- buildBacktest.ts rewritten as a strict three-way-split bake-off: params
  pre-2018, tune (xi, k, w) on 2018-2022 validation, final scores on untouched
  2022-2026 test (4,448 matches). Result: ensemble RPS 0.1721 beats Elo-DC
  0.1726 and Team-DC 0.1801; ECE stays 0.01. Tuned: xi=0.5 k=5 w=0.75.
- Runtime now IS the backtested model: ratings.json carries teamDc+ensembleW;
  matchMatrix() pools both members for predictions, Monte Carlo and advance
  probs; ModelEngine re-rating preserves ensemble params; lambdas reported as
  matrix expectations (drives in-play).
- scripts/buildSquadValues.ts: Transfermarkt FIWC participants page -> all 48
  squad market values (France 1.52bn ... Qatar 20m). Display/availability layer
  only — June-2026 values are NOT in the backtested model (would leak).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:23:51 +02:00

261 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// v3 bake-off backtest with a strict three-way split — the evidence behind the
// shipped model. NO information leaks forward:
// params (pre-2018) : Elo→goals calibration, Dixon-Coles rho
// validate (20182022) : tune Team-DC decay ξ, shrinkage k, ensemble weight w
// test (20222026) : untouched final numbers for every variant + baselines
// Output → public/data/backtest.json (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';
import { fitTeamDc, teamDcLambdas, poolMatrices, type DcMatch, type TeamDcParams } from '../src/lib/model/teamDc';
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 VAL_FROM = '2018-01-01';
const TEST_FROM = '2022-01-01';
const TEST_TO = '2026-06-01';
const REFIT_DAYS = 60;
const FIT_WINDOW_YEARS = 15;
const XI_GRID = [0.5, 1, 1.5, 2, 2.5];
const K_GRID = [5, 10, 20];
interface Row { date: string; home: string; away: string; hs: number; as: number; tournament: string; neutral: boolean; t: number }
type Probs = { h: number; d: number; a: number };
type Outcome = 'h' | 'd' | 'a';
const DAY = 24 * 60 * 60 * 1000;
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: canonicalTeam(c[1]!), away: canonicalTeam(c[2]!), hs, as,
tournament: c[5]!, neutral: c[8]!.trim().toUpperCase() === 'TRUE',
t: new Date(c[0]!).getTime(),
});
}
rows.sort((a, b) => a.t - b.t);
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');
const probsOf = (m: number[][]): Probs => { const p = outcomeProbs(m); return { h: p.home, d: p.draw, a: p.away }; };
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;
}
const logloss = (p: Probs, o: Outcome): number => -Math.log(Math.max(1e-12, p[o]));
function rps(p: Probs, o: Outcome): number {
const y1 = o === 'h' ? 1 : 0;
const y2 = o === 'a' ? 0 : 1;
return ((p.h - y1) ** 2 + (p.h + p.d - y2) ** 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 = Math.max(1, preds.length);
return {
brier: +(b / n).toFixed(4), logloss: +(l / n).toFixed(4),
rps: +(r / n).toFixed(4), accuracy: +(correct / n).toFixed(4),
};
}
function main(): void {
const rows = parse();
const tValFrom = new Date(VAL_FROM).getTime();
const tTestFrom = new Date(TEST_FROM).getTime();
const tTestTo = new Date(TEST_TO).getTime();
// ---------- pass 1: Elo walk + Elo-DC param fit on pre-2018 ----------
const elo = new Map<string, number>();
const get = (t: string) => elo.get(t) ?? START_ELO;
let sumTotal = 0, nCal = 0, sxy = 0, sxx = 0;
const calib: { d: number; h: number; a: number }[] = [];
/** pre-match Elo per row index (so later passes never see post-match info) */
const preElo: { eh: number; ea: number }[] = new Array(rows.length);
for (let i = 0; i < rows.length; i++) {
const r = rows[i]!;
const eh = get(r.home), ea = get(r.away);
preElo[i] = { eh, ea };
const homeAdv = r.neutral ? 0 : HOME_ADV_ELO;
if (r.date >= PARAM_FROM && r.t < tValFrom) {
const eff = eh - ea + homeAdv;
sumTotal += r.hs + r.as; nCal++; sxy += eff * (r.hs - r.as); sxx += eff * eff;
calib.push({ d: eff, h: r.hs, a: r.as });
}
const d = eloDelta(r.hs, r.as, eh, ea, importanceWeight(r.tournament), homeAdv);
elo.set(r.home, eh + d); elo.set(r.away, ea - d);
}
const avgGoals = sumTotal / Math.max(1, nCal);
const goalsPerElo = sxy / Math.max(1e-9, sxx);
let rho = -0.05, bestLL = -Infinity;
for (let r0 = -0.2; r0 <= 0.1 + 1e-9; r0 += 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, r0)));
}
if (ll > bestLL) { bestLL = ll; rho = +r0.toFixed(2); }
}
const params: ModelParams = { goalsPerElo, avgGoals, rho, homeAdvElo: HOME_ADV_ELO };
const eloMatrix = (i: number): number[][] => {
const r = rows[i]!;
const { lambdaHome, lambdaAway } = lambdasFromElo(preElo[i]!.eh, preElo[i]!.ea, params, r.neutral ? 0 : HOME_ADV_ELO);
return scoreMatrix(lambdaHome, lambdaAway, rho);
};
// ---------- walk-forward Team-DC over a window, collecting matrices ----------
type Pred = { i: number; mDc: number[][] };
const walkTeamDc = (xi: number, k: number, fromT: number, toT: number): Pred[] => {
const preds: Pred[] = [];
let fit: TeamDcParams | null = null;
let fitAt = -Infinity;
const windowMs = FIT_WINDOW_YEARS * 365 * DAY;
for (let i = 0; i < rows.length; i++) {
const r = rows[i]!;
if (r.t < fromT) continue;
if (r.t >= toT) break;
if (r.t - fitAt > REFIT_DAYS * DAY) {
const train: DcMatch[] = [];
for (let j = 0; j < i; j++) {
const m = rows[j]!;
if (r.t - m.t > windowMs) continue;
train.push({
daysAgo: (r.t - m.t) / DAY, home: m.home, away: m.away,
homeGoals: m.hs, awayGoals: m.as, neutral: m.neutral,
});
}
fit = fitTeamDc(train, xi, k);
fitAt = r.t;
}
const { lambdaHome, lambdaAway } = teamDcLambdas(fit!, r.home, r.away, r.neutral);
preds.push({ i, mDc: scoreMatrix(lambdaHome, lambdaAway, rho) });
}
return preds;
};
// ---------- validation: tune ξ, k, then w ----------
console.log('tuning on validation (20182022)…');
let best = { xi: 1, k: 10, w: 0.5, rps: Infinity };
for (const xi of XI_GRID) {
for (const k of K_GRID) {
const preds = walkTeamDc(xi, k, tValFrom, tTestFrom);
// pure Team-DC score (w=0 candidate) and weight sweep against Elo
for (let w = 0; w <= 1.0001; w += 0.05) {
let r = 0;
for (const p of preds) {
const pooled = w === 0 ? p.mDc : w === 1 ? eloMatrix(p.i) : poolMatrices(eloMatrix(p.i), p.mDc, w);
r += rps(probsOf(pooled), outcomeOf(rows[p.i]!.hs, rows[p.i]!.as));
}
r /= preds.length;
if (r < best.rps) best = { xi, k, w: +w.toFixed(2), rps: r };
}
console.log(` ξ=${xi} k=${k} done (best so far: ξ=${best.xi} k=${best.k} w=${best.w} rps=${best.rps.toFixed(4)})`);
}
}
// ---------- test window: final scores for every variant ----------
console.log(`testing on ${TEST_FROM}${TEST_TO} with ξ=${best.xi} k=${best.k} w=${best.w}…`);
const testPreds = walkTeamDc(best.xi, best.k, tTestFrom, tTestTo);
const setEnsemble: { p: Probs; o: Outcome }[] = [];
const setElo: { p: Probs; o: Outcome }[] = [];
const setDc: { p: Probs; o: Outcome }[] = [];
const setUniform: { p: Probs; o: Outcome }[] = [];
const setRate: { p: Probs; o: Outcome }[] = [];
const reliabilityRaw: { p: number; y: number }[] = [];
let wcCorrect = 0, wcN = 0;
// outcome base rates over the test window
let th = 0, td = 0, ta = 0, tn = 0;
for (const p of testPreds) {
const o = outcomeOf(rows[p.i]!.hs, rows[p.i]!.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 };
for (const p of testPreds) {
const r = rows[p.i]!;
const o = outcomeOf(r.hs, r.as);
const mE = eloMatrix(p.i);
const pooled = poolMatrices(mE, p.mDc, best.w);
const pe = probsOf(mE), pd = probsOf(p.mDc), pp = probsOf(pooled);
setElo.push({ p: pe, o });
setDc.push({ p: pd, o });
setEnsemble.push({ p: pp, o });
setUniform.push({ p: { h: 1 / 3, d: 1 / 3, a: 1 / 3 }, o });
setRate.push({ p: rate, o });
reliabilityRaw.push({ p: pp.h, y: o === 'h' ? 1 : 0 }, { p: pp.d, y: o === 'd' ? 1 : 0 }, { p: pp.a, y: o === 'a' ? 1 : 0 });
if (r.tournament.includes('FIFA World Cup') && !r.tournament.includes('qualification')) {
wcN++; if (argmax(pp) === o) wcCorrect++;
}
}
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).toFixed(3);
const out = {
generatedAt: new Date().toISOString(),
paramFrom: PARAM_FROM,
trainEnd: VAL_FROM,
validation: { from: VAL_FROM, to: TEST_FROM, tuned: { xi: best.xi, shrinkK: best.k, ensembleW: best.w } },
testFrom: TEST_FROM,
testTo: TEST_TO,
tested: testPreds.length,
params,
model: scoreSet(setEnsemble), // the SHIPPED model = ensemble
variants: [
{ name: 'Ensemble (shipped)', ...scoreSet(setEnsemble) },
{ name: 'EloDixon-Coles', ...scoreSet(setElo) },
{ name: 'Team Dixon-Coles', ...scoreSet(setDc) },
],
baselines: { uniform: scoreSet(setUniform), baseRate: scoreSet(setRate), eloOnly: scoreSet(setElo) },
reliability,
ece,
worldCup: { tested: wcN, accuracy: wcN ? +(wcCorrect / wcN).toFixed(4) : 0 },
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'backtest.json'), JSON.stringify(out));
console.log(`\nbacktest → public/data/backtest.json (${out.tested} test matches, ${TEST_FROM}${TEST_TO})`);
for (const v of out.variants) console.log(` ${v.name.padEnd(20)} rps ${v.rps} brier ${v.brier} logloss ${v.logloss} acc ${(v.accuracy * 100).toFixed(1)}%`);
console.log(` baselines: uniform rps ${out.baselines.uniform.rps} | baseRate rps ${out.baselines.baseRate.rps}`);
console.log(` ECE ${ece} | WC accuracy ${(out.worldCup.accuracy * 100).toFixed(1)}% (${wcN})`);
}
main();