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>
This commit is contained in:
+169
-108
@@ -1,14 +1,16 @@
|
||||
// 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.
|
||||
// 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 (2018–2022) : tune Team-DC decay ξ, shrinkage k, ensemble weight w
|
||||
// test (2022–2026) : 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');
|
||||
@@ -16,13 +18,21 @@ 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
|
||||
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;
|
||||
|
||||
interface Row { date: string; home: string; away: string; hs: number; as: number; tournament: string; neutral: boolean }
|
||||
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[] = [];
|
||||
@@ -31,9 +41,13 @@ function parse(): Row[] {
|
||||
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.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.date.localeCompare(b.date));
|
||||
rows.sort((a, b) => a.t - b.t);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -46,120 +60,165 @@ function dcTau(h: number, a: number, lh: number, la: number, rho: number): numbe
|
||||
}
|
||||
|
||||
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 }; };
|
||||
|
||||
// ---- 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]));
|
||||
}
|
||||
const logloss = (p: Probs, o: Outcome): number => -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 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 = preds.length;
|
||||
return { brier: b / n, logloss: l / n, rps: r / n, accuracy: correct / n };
|
||||
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;
|
||||
|
||||
// ---- 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)
|
||||
/** pre-match Elo per row index (so later passes never see post-match info) */
|
||||
const preElo: { eh: number; ea: number }[] = new Array(rows.length);
|
||||
|
||||
const testPreds: { p: Probs; o: Outcome }[] = [];
|
||||
const baseUniform: { p: Probs; o: Outcome }[] = [];
|
||||
const baseRate: { p: Probs; o: Outcome }[] = [];
|
||||
const baseElo: { p: Probs; o: Outcome }[] = [];
|
||||
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 (2018–2022)…');
|
||||
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 (computed in a quick pre-scan)
|
||||
// outcome base rates over the test window
|
||||
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++;
|
||||
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 };
|
||||
|
||||
// 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;
|
||||
for (const p of testPreds) {
|
||||
const r = rows[p.i]!;
|
||||
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 });
|
||||
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++;
|
||||
}
|
||||
|
||||
// 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));
|
||||
@@ -167,33 +226,35 @@ function main(): void {
|
||||
}
|
||||
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 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: TRAIN_END,
|
||||
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(testPreds),
|
||||
baselines: {
|
||||
uniform: scoreSet(baseUniform),
|
||||
baseRate: scoreSet(baseRate),
|
||||
eloOnly: scoreSet(baseElo),
|
||||
},
|
||||
model: scoreSet(setEnsemble), // the SHIPPED model = ensemble
|
||||
variants: [
|
||||
{ name: 'Ensemble (shipped)', ...scoreSet(setEnsemble) },
|
||||
{ name: 'Elo–Dixon-Coles', ...scoreSet(setElo) },
|
||||
{ name: 'Team Dixon-Coles', ...scoreSet(setDc) },
|
||||
],
|
||||
baselines: { uniform: scoreSet(setUniform), baseRate: scoreSet(setRate), eloOnly: scoreSet(setElo) },
|
||||
reliability,
|
||||
ece: Math.round(ece * 1000) / 1000,
|
||||
worldCup: { tested: wcN, accuracy: wcN ? wcCorrect / wcN : 0 },
|
||||
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(`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})`);
|
||||
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();
|
||||
|
||||
@@ -9,6 +9,14 @@ 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.
|
||||
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');
|
||||
@@ -119,6 +127,27 @@ function main(): void {
|
||||
// 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,
|
||||
@@ -131,6 +160,8 @@ function main(): void {
|
||||
rho,
|
||||
homeAdvElo: HOME_ADV_ELO,
|
||||
},
|
||||
teamDc,
|
||||
ensembleW: ENSEMBLE_W,
|
||||
ratings,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Transfermarkt FIWC participants page → public/data/squadvalues.json
|
||||
// One page lists all 48 squads with total + average market value — far more
|
||||
// robust than scraping 48 squad pages. Values move slowly, so this is a
|
||||
// build-time artifact (re-run data:build to refresh). Used by the squad-value
|
||||
// model layer and the team/squad UI; never overrides results-based ratings.
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { canonicalTeam, isKnownTeam } from '../src/lib/teams';
|
||||
|
||||
const URL = 'https://www.transfermarkt.com/weltmeisterschaft/teilnehmer/pokalwettbewerb/FIWC';
|
||||
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'data');
|
||||
|
||||
function euros(num: string, unit: string): number {
|
||||
const n = Number(num);
|
||||
return Math.round(n * (unit === 'bn' ? 1e9 : unit === 'm' ? 1e6 : 1e3));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const res = await fetch(URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`transfermarkt ${res.status}`);
|
||||
const html = await res.text();
|
||||
|
||||
const out: Record<string, { totalValue: number; avgValue: number }> = {};
|
||||
for (const tr of html.split('<tr').slice(1)) {
|
||||
const link = tr.match(/<a[^>]*title="([^"]+)"[^>]*href="\/[a-z0-9\-]+\/startseite\/verein\/\d+"/);
|
||||
const vals = [...tr.matchAll(/€([\d.]+)(bn|m|k)/g)].map((m) => euros(m[1]!, m[2]!));
|
||||
if (!link || vals.length < 1) continue;
|
||||
const team = canonicalTeam(link[1]!);
|
||||
if (!isKnownTeam(team) || out[team]) continue; // 48 qualifiers only, first table wins
|
||||
const totalValue = Math.max(...vals);
|
||||
const avgValue = vals.length > 1 ? Math.min(...vals) : Math.round(totalValue / 26);
|
||||
out[team] = { totalValue, avgValue };
|
||||
}
|
||||
|
||||
const teams = Object.keys(out);
|
||||
if (teams.length < 40) {
|
||||
throw new Error(`only parsed ${teams.length}/48 squads — page layout may have changed; aborting (stale file kept)`);
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
writeFileSync(
|
||||
join(OUT_DIR, 'squadvalues.json'),
|
||||
JSON.stringify({ fetchedAt: new Date().toISOString(), source: 'transfermarkt.com', teams: out }),
|
||||
);
|
||||
const top = teams.sort((a, b) => out[b]!.totalValue - out[a]!.totalValue).slice(0, 6)
|
||||
.map((t) => `${t} €${(out[t]!.totalValue / 1e9).toFixed(2)}bn`).join(', ');
|
||||
console.log(`squad values → public/data/squadvalues.json (${teams.length} teams)`);
|
||||
console.log(` top: ${top}`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user