Files
cup26/scripts/buildRatings.ts
T
NilsBriggen 047e9f8767 Elo trajectories: a century of strength on every team page
buildRatings samples each WC team's rating along the full walk (yearly
historically, monthly since 2022) into elohistory.json (~72KB, 48
teams). Team pages plot it as a clean SVG line with year and rating
gridlines — Germany's century, 1908 → today, in one glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:12:36 +02:00

243 lines
10 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 { existsSync, 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.
// 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 }[] = [];
// Per-WC-team Elo trajectories: one sample per year historically, one per
// month for the recent window — small enough to serve, rich enough to plot.
const wcSet = new Set(ALL_TEAMS);
const RECENT_FROM = '2022-01-01';
const history = new Map<string, Map<string, number>>(); // team → periodKey → rating
const sample = (team: string, date: string, rating: number): void => {
if (!wcSet.has(team)) return;
const key = date >= RECENT_FROM ? date.slice(0, 7) : date.slice(0, 4);
let m = history.get(team);
if (!m) { m = new Map(); history.set(team, m); }
m.set(key, Math.round(rating));
};
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);
sample(home, r.date, eh + delta);
sample(away, r.date, 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;
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;
// Isotonic calibration maps — research artifact from scripts/cvSearch.ts
// (committed; only valid while the shipped config matches its fit config).
let calibration: unknown = null;
const calFile = join(ROOT, 'data', 'calibration-maps.json');
if (existsSync(calFile)) {
const cal = JSON.parse(readFileSync(calFile, 'utf8')) as {
config: { xi: number; k: number; w: number; m: number; beta: number };
maps: unknown;
};
const c = cal.config;
if (c.xi === TEAMDC_XI && c.k === TEAMDC_SHRINK_K && c.w === ENSEMBLE_W && c.m === FAST_M && c.beta === FAST_BETA) {
calibration = cal.maps;
} else {
console.warn('calibration-maps.json was fit for a different config — skipping (rerun scripts/cvSearch.ts)');
}
}
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 },
...(calibration ? { calibration } : {}),
ratings,
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'ratings.json'), JSON.stringify(out));
// Per-team Elo trajectories for the team pages.
const eloHistory: Record<string, [string, number][]> = {};
for (const [team, m] of history) {
eloHistory[team] = [...m.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
writeFileSync(join(OUT_DIR, 'elohistory.json'), JSON.stringify({ recentFrom: RECENT_FROM, teams: eloHistory }));
console.log(`elo history → public/data/elohistory.json (${Object.keys(eloHistory).length} teams)`);
// 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();