Files
cup26/scripts/buildRatings.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

178 lines
7.0 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.
// 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 20182022 validation window and verified
// out-of-sample on 20222026 by scripts/buildBacktest.ts. Keep in sync with it.
const TEAMDC_XI = 0.5;
const TEAMDC_SHRINK_K = 5;
const ENSEMBLE_W = 0.75; // log-pool weight on the Elo-DC member
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;
// 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);
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;
// ---- 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,
ratings,
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'ratings.json'), JSON.stringify(out));
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();