d3e8df96ef
Validated on the strict 2018-2022 window and confirmed on the untouched 2022-2026 test set (RPS 0.1703 vs 0.1721 over 4,448 matches): - the Elo member now blends 30% of a 3x-faster Elo walk, so recent results move ratings much harder - ensemble weight shifts from 75/25 toward Elo to 45/55 toward the time-decayed Team-DC member — the recent-form model now leads - Team-DC refits nightly (and at boot) on the 15-year window plus every finished 2026 match, via a new committed-at-build dcTrain.json (server-only, excluded from the PWA precache) - a recent-form goal multiplier was also tested and did NOT validate; it ships disabled, and backtest.json records the whole decision buildBacktest.ts grew the experiment harness: wider xi/k grids, the fast-Elo and form variants, a pre-registered ship bar (>=0.0005 validation RPS), and a 7-day refit-cadence check. Older ratings.json files still work — all new model fields are optional with golden regression coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
202 lines
8.4 KiB
TypeScript
202 lines
8.4 KiB
TypeScript
// international-results.csv → public/data/ratings.json
|
||
// Walks every men's international 1872→now in date order, maintaining a
|
||
// World-Football-Elo rating per team. Then calibrates the goals model
|
||
// (goals-per-Elo slope, mean goals) on the modern era and MLE-fits Dixon-Coles
|
||
// rho. The runtime re-uses these ratings + params to predict and simulate.
|
||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
import { canonicalTeam, ALL_TEAMS } from '../src/lib/teams';
|
||
import { HOME_ADV_ELO, importanceWeight, eloDelta, expectedHome } from '../src/lib/model/elo';
|
||
import { poissonPmf } from '../src/lib/model/poisson';
|
||
import { fitTeamDc, type DcMatch } from '../src/lib/model/teamDc';
|
||
|
||
// Ensemble hyper-params — tuned on the 2018–2022 validation window and verified
|
||
// out-of-sample on 2022–2026 by scripts/buildBacktest.ts. Keep in sync with it.
|
||
// v4 recency config: more ensemble weight on Team-DC, plus a fast Elo walk
|
||
// (K×3) blended into the Elo member at β=0.3, so recent results count harder.
|
||
const TEAMDC_XI = 0.25;
|
||
const TEAMDC_SHRINK_K = 3;
|
||
const ENSEMBLE_W = 0.45; // log-pool weight on the Elo-DC member
|
||
const FAST_M = 3; // fast-Elo K multiplier
|
||
const FAST_BETA = 0.3; // blend weight on the fast walk
|
||
const FIT_WINDOW_YEARS = 15;
|
||
|
||
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 CALIB_FROM = '2010-01-01'; // modern-era window for the goals calibration
|
||
|
||
interface Row {
|
||
date: string; home: string; away: string; hs: number; as: number;
|
||
tournament: string; neutral: boolean;
|
||
}
|
||
|
||
function parseCsv(text: string): Row[] {
|
||
const lines = text.split('\n');
|
||
const rows: Row[] = [];
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
if (!line) continue;
|
||
const c = line.split(','); // this dataset has no embedded commas
|
||
if (c.length < 9) continue;
|
||
const hs = Number(c[3]);
|
||
const as = Number(c[4]);
|
||
if (!Number.isFinite(hs) || !Number.isFinite(as)) continue; // skip NA / future fixtures
|
||
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;
|
||
}
|
||
|
||
function main(): void {
|
||
const rows = parseCsv(readFileSync(CSV, 'utf8'));
|
||
const elo = new Map<string, number>();
|
||
const get = (t: string) => elo.get(t) ?? START_ELO;
|
||
const eloFast = new Map<string, number>();
|
||
const getFast = (t: string) => eloFast.get(t) ?? START_ELO;
|
||
|
||
// Calibration accumulators (modern era only).
|
||
let sumTotal = 0, nCalib = 0;
|
||
let sxy = 0, sxx = 0; // regression of goal-diff on effective Elo diff
|
||
const calib: { d: number; h: number; a: number }[] = [];
|
||
|
||
let lastDate = '';
|
||
for (const r of rows) {
|
||
const home = canonicalTeam(r.home);
|
||
const away = canonicalTeam(r.away);
|
||
const eh = get(home);
|
||
const ea = get(away);
|
||
const homeAdv = r.neutral ? 0 : HOME_ADV_ELO;
|
||
const effDiff = eh - ea + homeAdv;
|
||
|
||
if (r.date >= CALIB_FROM) {
|
||
const gd = r.hs - r.as;
|
||
sumTotal += r.hs + r.as; nCalib++;
|
||
sxy += effDiff * gd; sxx += effDiff * effDiff;
|
||
calib.push({ d: effDiff, h: r.hs, a: r.as });
|
||
}
|
||
|
||
const k = importanceWeight(r.tournament);
|
||
const delta = eloDelta(r.hs, r.as, eh, ea, k, homeAdv);
|
||
elo.set(home, eh + delta);
|
||
elo.set(away, ea - delta);
|
||
const feh = getFast(home), fea = getFast(away);
|
||
const fastDelta = eloDelta(r.hs, r.as, feh, fea, k * FAST_M, homeAdv);
|
||
eloFast.set(home, feh + fastDelta);
|
||
eloFast.set(away, fea - fastDelta);
|
||
lastDate = r.date;
|
||
}
|
||
|
||
const avgGoals = sumTotal / Math.max(1, nCalib);
|
||
const goalsPerElo = sxy / Math.max(1e-9, sxx);
|
||
|
||
// MLE-fit Dixon-Coles rho on the modern era (classic DC likelihood: the four
|
||
// low-score cells, no renormalization). Grid search keeps it simple + robust.
|
||
let bestRho = 0, bestLL = -Infinity;
|
||
for (let rho = -0.2; rho <= 0.1 + 1e-9; rho += 0.01) {
|
||
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));
|
||
const p = poissonPmf(lh, m.h) * poissonPmf(la, m.a) * dcTau(m.h, m.a, lh, la, rho);
|
||
ll += Math.log(Math.max(1e-12, p));
|
||
}
|
||
if (ll > bestLL) { bestLL = ll; bestRho = rho; }
|
||
}
|
||
const rho = Math.round(bestRho * 100) / 100;
|
||
|
||
// Sanity: home win rate the model expects vs reality, on the calib window.
|
||
let expHome = 0, n2 = 0;
|
||
for (const r of rows) {
|
||
if (r.date < CALIB_FROM) continue;
|
||
const homeAdv = r.neutral ? 0 : HOME_ADV_ELO;
|
||
// recompute pre-match would need stored ratings; use final as a rough proxy
|
||
expHome += expectedHome(get(canonicalTeam(r.home)), get(canonicalTeam(r.away)), homeAdv);
|
||
n2++;
|
||
}
|
||
|
||
const ratings: Record<string, number> = {};
|
||
for (const [team, r] of [...elo.entries()].sort((a, b) => b[1] - a[1])) {
|
||
ratings[team] = Math.round(r * 10) / 10;
|
||
}
|
||
// Ensure every WC team has a rating (default for any never seen).
|
||
for (const t of ALL_TEAMS) if (!(t in ratings)) ratings[t] = START_ELO;
|
||
const ratingsFast: Record<string, number> = {};
|
||
for (const t of Object.keys(ratings)) ratingsFast[t] = Math.round(getFast(t) * 10) / 10;
|
||
|
||
// ---- Team-DC ensemble member: fit on the recent window through today ----
|
||
const nowT = new Date(lastDate).getTime();
|
||
const windowMs = FIT_WINDOW_YEARS * 365 * 24 * 60 * 60 * 1000;
|
||
const dcTrain: DcMatch[] = [];
|
||
for (const r of rows) {
|
||
const t = new Date(r.date).getTime();
|
||
if (nowT - t > windowMs) continue;
|
||
dcTrain.push({
|
||
daysAgo: (nowT - t) / (24 * 60 * 60 * 1000),
|
||
home: canonicalTeam(r.home), away: canonicalTeam(r.away),
|
||
homeGoals: r.hs, awayGoals: r.as, neutral: r.neutral,
|
||
});
|
||
}
|
||
const teamDc = fitTeamDc(dcTrain, TEAMDC_XI, TEAMDC_SHRINK_K);
|
||
// Trim to relevant nations: WC qualifiers + any team rated (keeps file small).
|
||
const keep = new Set<string>([...ALL_TEAMS, ...Object.keys(ratings)]);
|
||
teamDc.attack = Object.fromEntries(Object.entries(teamDc.attack).filter(([t]) => keep.has(t)).map(([t, v]) => [t, Math.round(v * 1e4) / 1e4]));
|
||
teamDc.defence = Object.fromEntries(Object.entries(teamDc.defence).filter(([t]) => keep.has(t)).map(([t, v]) => [t, Math.round(v * 1e4) / 1e4]));
|
||
teamDc.mu = Math.round(teamDc.mu * 1e4) / 1e4;
|
||
teamDc.homeMult = Math.round(teamDc.homeMult * 1e4) / 1e4;
|
||
|
||
const out = {
|
||
generatedAt: new Date().toISOString(),
|
||
asOf: lastDate,
|
||
matchesProcessed: rows.length,
|
||
calibrationFrom: CALIB_FROM,
|
||
calibrationMatches: nCalib,
|
||
params: {
|
||
goalsPerElo: Math.round(goalsPerElo * 1e6) / 1e6,
|
||
avgGoals: Math.round(avgGoals * 1000) / 1000,
|
||
rho,
|
||
homeAdvElo: HOME_ADV_ELO,
|
||
},
|
||
teamDc,
|
||
ensembleW: ENSEMBLE_W,
|
||
ratingsFast,
|
||
fastBeta: FAST_BETA,
|
||
fastM: FAST_M,
|
||
teamDcFit: { xi: TEAMDC_XI, shrinkK: TEAMDC_SHRINK_K, windowYears: FIT_WINDOW_YEARS },
|
||
ratings,
|
||
};
|
||
|
||
mkdirSync(OUT_DIR, { recursive: true });
|
||
writeFileSync(join(OUT_DIR, 'ratings.json'), JSON.stringify(out));
|
||
|
||
// Compact training rows for the server's nightly in-tournament Team-DC refit
|
||
// (absolute dates, so daysAgo can be recomputed as the tournament progresses).
|
||
const dcRows = rows
|
||
.filter((r) => nowT - new Date(r.date).getTime() <= windowMs)
|
||
.map((r) => [r.date, canonicalTeam(r.home), canonicalTeam(r.away), r.hs, r.as, r.neutral ? 1 : 0]);
|
||
writeFileSync(join(OUT_DIR, 'dcTrain.json'), JSON.stringify({ asOf: lastDate, rows: dcRows }));
|
||
console.log(`dcTrain → public/data/dcTrain.json (${dcRows.length} rows, ${FIT_WINDOW_YEARS}y window)`);
|
||
|
||
const top = Object.entries(ratings).slice(0, 12).map(([t, r]) => `${t} ${Math.round(r)}`).join(', ');
|
||
console.log(`ratings → public/data/ratings.json (${rows.length} matches, asOf ${lastDate})`);
|
||
console.log(`params: goalsPerElo=${out.params.goalsPerElo} avgGoals=${out.params.avgGoals} rho=${rho} homeAdvElo=${HOME_ADV_ELO}`);
|
||
console.log(`top: ${top}`);
|
||
}
|
||
|
||
main();
|