From f3b2a69b3122e3c06518bf15e2dd1a52df24ad4c Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Thu, 11 Jun 2026 17:23:51 +0200 Subject: [PATCH] v3 Phase B core: Team-DC + backtested ensemble, squad values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/data/squadvalues.json | 1 + scripts/buildBacktest.ts | 277 +++++++++++++++++++++-------------- scripts/buildRatings.ts | 31 ++++ scripts/buildSquadValues.ts | 58 ++++++++ server/src/model.ts | Bin 4542 -> 4691 bytes src/lib/model/monteCarlo.ts | 12 +- src/lib/model/predict.ts | 60 +++++++- src/lib/model/teamDc.test.ts | 91 ++++++++++++ src/lib/model/teamDc.ts | 141 ++++++++++++++++++ 9 files changed, 549 insertions(+), 122 deletions(-) create mode 100644 public/data/squadvalues.json create mode 100644 scripts/buildSquadValues.ts create mode 100644 src/lib/model/teamDc.test.ts create mode 100644 src/lib/model/teamDc.ts diff --git a/public/data/squadvalues.json b/public/data/squadvalues.json new file mode 100644 index 0000000..304b819 --- /dev/null +++ b/public/data/squadvalues.json @@ -0,0 +1 @@ +{"fetchedAt":"2026-06-11T15:14:56.650Z","source":"transfermarkt.com","teams":{"France":{"totalValue":1520000000,"avgValue":58580000},"England":{"totalValue":1360000000,"avgValue":52430000},"Spain":{"totalValue":1220000000,"avgValue":47030000},"Portugal":{"totalValue":1010000000,"avgValue":38670000},"Germany":{"totalValue":947000000,"avgValue":36420000},"Brazil":{"totalValue":928200000,"avgValue":35700000},"Argentina":{"totalValue":782500000,"avgValue":31300000},"Netherlands":{"totalValue":754200000,"avgValue":29010000},"Norway":{"totalValue":589900000,"avgValue":22690000},"Belgium":{"totalValue":547500000,"avgValue":21060000},"Ivory Coast":{"totalValue":522100000,"avgValue":20080000},"Senegal":{"totalValue":478100000,"avgValue":18390000},"Turkey":{"totalValue":473700000,"avgValue":18220000},"Morocco":{"totalValue":447700000,"avgValue":17220000},"Sweden":{"totalValue":406080000,"avgValue":15620000},"Croatia":{"totalValue":387300000,"avgValue":14900000},"USA":{"totalValue":385650000,"avgValue":14830000},"Ecuador":{"totalValue":368700000,"avgValue":14180000},"Uruguay":{"totalValue":359300000,"avgValue":13820000},"Switzerland":{"totalValue":332500000,"avgValue":12790000},"Colombia":{"totalValue":302350000,"avgValue":11630000},"Japan":{"totalValue":270850000,"avgValue":10420000},"Algeria":{"totalValue":256900000,"avgValue":9880000},"Austria":{"totalValue":245200000,"avgValue":9430000},"Ghana":{"totalValue":234600000,"avgValue":8690000},"Canada":{"totalValue":198650000,"avgValue":7640000},"Mexico":{"totalValue":191850000,"avgValue":7380000},"Czech Republic":{"totalValue":188180000,"avgValue":7240000},"Scotland":{"totalValue":170250000,"avgValue":6550000},"Paraguay":{"totalValue":153650000,"avgValue":5910000},"Bosnia & Herzegovina":{"totalValue":151600000,"avgValue":5830000},"DR Congo":{"totalValue":143900000,"avgValue":5530000},"South Korea":{"totalValue":139050000,"avgValue":5350000},"Egypt":{"totalValue":116480000,"avgValue":4480000},"Uzbekistan":{"totalValue":85330000,"avgValue":3280000},"Australia":{"totalValue":77450000,"avgValue":2980000},"Tunisia":{"totalValue":69950000,"avgValue":2690000},"Haiti":{"totalValue":55900000,"avgValue":2150000},"Cape Verde":{"totalValue":54500000,"avgValue":2100000},"South Africa":{"totalValue":49250000,"avgValue":1890000},"Saudi Arabia":{"totalValue":40680000,"avgValue":1560000},"Panama":{"totalValue":34550000,"avgValue":1330000},"New Zealand":{"totalValue":34350000,"avgValue":1320000},"Iran":{"totalValue":32050000,"avgValue":1230000},"Curaçao":{"totalValue":25780000,"avgValue":991000},"Iraq":{"totalValue":21200000,"avgValue":815000},"Jordan":{"totalValue":20300000,"avgValue":781000},"Qatar":{"totalValue":19930000,"avgValue":766000}}} \ No newline at end of file diff --git a/scripts/buildBacktest.ts b/scripts/buildBacktest.ts index d585db0..9b60944 100644 --- a/scripts/buildBacktest.ts +++ b/scripts/buildBacktest.ts @@ -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(); 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(); diff --git a/scripts/buildRatings.ts b/scripts/buildRatings.ts index cfe8c2d..f4214f5 100644 --- a/scripts/buildRatings.ts +++ b/scripts/buildRatings.ts @@ -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([...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, }; diff --git a/scripts/buildSquadValues.ts b/scripts/buildSquadValues.ts new file mode 100644 index 0000000..55f830c --- /dev/null +++ b/scripts/buildSquadValues.ts @@ -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 { + 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 = {}; + for (const tr of html.split(']*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); }); diff --git a/server/src/model.ts b/server/src/model.ts index 82f589a64f4bf2a7e3821f1b08d100a2fecf5fe8..b4b069bb635dff63297527eb4f030991500437d1 100644 GIT binary patch delta 202 zcmXv|JBk896b#G^JVSv7$~rBW%0}+S26};(DQ6n&p5?tavxtccJkI; + /** Team-level Dixon-Coles strengths (second ensemble member); optional so a + * v2 ratings file still works as pure Elo-DC. */ + teamDc?: TeamDcParams; + /** Log-pool weight on the Elo-DC member (backtest-tuned). */ + ensembleW?: number; } export const DEFAULT_ELO = 1500; @@ -33,6 +39,50 @@ export interface MatchPrediction { scorelines: Scoreline[]; } +/** + * The full scoreline distribution for a match: Elo-DC matrix, log-pooled with + * the Team-DC matrix when the model carries ensemble params (the backtested + * shipped configuration). `homeAdv` is the SIGNED Elo bump for the home slot: + * positive = home side is the host at home, negative = the away side is, 0 = neutral. + */ +export function matchMatrix( + home: string, + away: string, + model: RatingsModel, + homeAdv = 0, +): number[][] { + const { lambdaHome, lambdaAway } = lambdasFromElo( + ratingOf(model, home), ratingOf(model, away), model.params, homeAdv, + ); + const mElo = scoreMatrix(lambdaHome, lambdaAway, model.params.rho); + if (!model.teamDc || model.ensembleW == null || model.ensembleW >= 1) return mElo; + + // Team-DC orientation: homeAdv > 0 → home side at home; < 0 → away side at + // home (compute flipped, then swap); 0 → neutral. + let dcHome: number, dcAway: number; + if (homeAdv < 0) { + const f = teamDcLambdas(model.teamDc, away, home, false); + dcHome = f.lambdaAway; dcAway = f.lambdaHome; + } else { + const f = teamDcLambdas(model.teamDc, home, away, homeAdv === 0); + dcHome = f.lambdaHome; dcAway = f.lambdaAway; + } + const mDc = scoreMatrix(dcHome, dcAway, model.params.rho); + return poolMatrices(mElo, mDc, model.ensembleW); +} + +/** Expected goals per side under a scoreline matrix. */ +function matrixLambdas(m: number[][]): { lambdaHome: number; lambdaAway: number } { + let lh = 0, la = 0; + for (let i = 0; i < m.length; i++) { + for (let j = 0; j < m[i]!.length; j++) { + lh += i * m[i]![j]!; + la += j * m[i]![j]!; + } + } + return { lambdaHome: lh, lambdaAway: la }; +} + /** * Win/draw/loss + likely scores for one match. `homeAdv` is the Elo bump for the * home slot (0 neutral; use hostAdvantage() for host nations at home). @@ -43,15 +93,13 @@ export function predictMatch( model: RatingsModel, homeAdv = 0, ): MatchPrediction { - const eloHome = ratingOf(model, home); - const eloAway = ratingOf(model, away); - const { lambdaHome, lambdaAway } = lambdasFromElo(eloHome, eloAway, model.params, homeAdv); - const m = scoreMatrix(lambdaHome, lambdaAway, model.params.rho); + const m = matchMatrix(home, away, model, homeAdv); + const { lambdaHome, lambdaAway } = matrixLambdas(m); return { home, away, - eloHome, - eloAway, + eloHome: ratingOf(model, home), + eloAway: ratingOf(model, away), lambdaHome, lambdaAway, probs: outcomeProbs(m), diff --git a/src/lib/model/teamDc.test.ts b/src/lib/model/teamDc.test.ts new file mode 100644 index 0000000..7e8b273 --- /dev/null +++ b/src/lib/model/teamDc.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { fitTeamDc, teamDcLambdas, poolMatrices, type DcMatch } from './teamDc'; +import { scoreMatrix } from './poisson'; + +function synth(): DcMatch[] { + // Strong beats Weak repeatedly; Mid splits with both. + const ms: DcMatch[] = []; + for (let i = 0; i < 30; i++) { + ms.push({ daysAgo: i * 10, home: 'Strong', away: 'Weak', homeGoals: 3, awayGoals: 0, neutral: true }); + ms.push({ daysAgo: i * 10, home: 'Mid', away: 'Strong', homeGoals: 0, awayGoals: 2, neutral: true }); + ms.push({ daysAgo: i * 10, home: 'Weak', away: 'Mid', homeGoals: 0, awayGoals: 2, neutral: true }); + } + return ms; +} + +describe('fitTeamDc', () => { + const p = fitTeamDc(synth(), 1, 5); + + it('ranks attack and defence sensibly', () => { + expect(p.attack['Strong']!).toBeGreaterThan(p.attack['Mid']!); + expect(p.attack['Mid']!).toBeGreaterThan(p.attack['Weak']!); + expect(p.defence['Strong']!).toBeLessThan(p.defence['Weak']!); // lower = concedes less + }); + + it('keeps strengths normalized around 1', () => { + const atts = Object.values(p.attack); + const mean = atts.reduce((s, x) => s + x, 0) / atts.length; + expect(mean).toBeCloseTo(1, 6); + expect(p.mu).toBeGreaterThan(0.5); + expect(p.mu).toBeLessThan(3); + }); + + it('predicts higher λ for the stronger side and falls back for unknowns', () => { + const { lambdaHome, lambdaAway } = teamDcLambdas(p, 'Strong', 'Weak', true); + expect(lambdaHome).toBeGreaterThan(lambdaAway); + const fb = teamDcLambdas(p, 'Nowhere FC', 'Strong', true); + expect(fb.lambdaHome).toBeGreaterThan(0); // unseen team gets average strengths + }); + + it('shrinkage pulls thin histories toward average', () => { + const thin: DcMatch[] = [{ daysAgo: 1, home: 'A', away: 'B', homeGoals: 5, awayGoals: 0, neutral: true }]; + const loose = fitTeamDc(thin, 0, 1); + const tight = fitTeamDc(thin, 0, 50); + expect(Math.abs(tight.attack['A']! - 1)).toBeLessThan(Math.abs(loose.attack['A']! - 1)); + }); + + it('home multiplier learned from non-neutral games', () => { + const ms: DcMatch[] = []; + for (let i = 0; i < 60; i++) { + ms.push({ daysAgo: i, home: 'X', away: 'Y', homeGoals: 2, awayGoals: 1, neutral: false }); + ms.push({ daysAgo: i, home: 'Y', away: 'X', homeGoals: 2, awayGoals: 1, neutral: false }); + } + const hp = fitTeamDc(ms, 0, 5); + expect(hp.homeMult).toBeGreaterThan(1.05); // symmetric fixtures, home side scores more + }); +}); + +describe('ensemble predictMatch', () => { + it('pools Elo-DC and Team-DC and stays a valid distribution', async () => { + const { predictMatch } = await import('./predict'); + const p = fitTeamDc(synth(), 1, 5); + const model = { + asOf: '2026-06-10', + params: { goalsPerElo: 0.0057, avgGoals: 2.73, rho: -0.05, homeAdvElo: 70 }, + ratings: { Strong: 2000, Weak: 1700 }, + teamDc: p, + ensembleW: 0.75, + }; + const pred = predictMatch('Strong', 'Weak', model); + expect(pred.probs.home + pred.probs.draw + pred.probs.away).toBeCloseTo(1, 6); + expect(pred.probs.home).toBeGreaterThan(0.5); + expect(pred.lambdaHome).toBeGreaterThan(pred.lambdaAway); + // pure-Elo path still works when ensemble params are absent + const pure = predictMatch('Strong', 'Weak', { ...model, teamDc: undefined as never, ensembleW: undefined as never }); + expect(pure.probs.home + pure.probs.draw + pure.probs.away).toBeCloseTo(1, 6); + }); +}); + +describe('poolMatrices', () => { + it('renormalizes and interpolates between the two models', () => { + const a = scoreMatrix(2.0, 0.8, -0.05); + const b = scoreMatrix(1.0, 1.4, -0.05); + const pooled = poolMatrices(a, b, 0.5); + let sum = 0; + for (const row of pooled) for (const v of row) sum += v; + expect(sum).toBeCloseTo(1, 9); + // w=1 returns A, w=0 returns B (up to renormalization) + const pa = poolMatrices(a, b, 1); + expect(pa[2]![0]!).toBeCloseTo(a[2]![0]!, 9); + }); +}); diff --git a/src/lib/model/teamDc.ts b/src/lib/model/teamDc.ts new file mode 100644 index 0000000..d8b7e96 --- /dev/null +++ b/src/lib/model/teamDc.ts @@ -0,0 +1,141 @@ +// Team-level Dixon-Coles: every nation gets multiplicative attack/defence +// strengths, fit by weighted MLE (Maher-style iterative updates) with +// exponential time-decay and shrinkage toward the mean for thin histories. +// This is the second, Elo-independent member of the ensemble. + +export interface TeamDcParams { + /** Mean goals per team per match (league baseline). */ + mu: number; + /** Home-ground multiplier applied to the home side's λ (1 on neutral). */ + homeMult: number; + attack: Record; + defence: Record; +} + +export interface DcMatch { + /** Days before the fit date (for decay weighting). */ + daysAgo: number; + home: string; + away: string; + homeGoals: number; + awayGoals: number; + neutral: boolean; +} + +/** + * Fit attack/defence strengths. + * @param xi decay per year (weight = exp(-xi * daysAgo/365)); 0 = no decay + * @param shrinkK pseudo-observations pulling strengths toward 1.0 + */ +export function fitTeamDc(matches: DcMatch[], xi: number, shrinkK: number, iterations = 25): TeamDcParams { + const w = matches.map((m) => Math.exp((-xi * m.daysAgo) / 365)); + const teams = new Set(); + for (const m of matches) { teams.add(m.home); teams.add(m.away); } + + // initial values + let mu = 0; + { + let g = 0, n = 0; + for (let i = 0; i < matches.length; i++) { + g += w[i]! * (matches[i]!.homeGoals + matches[i]!.awayGoals); + n += w[i]! * 2; + } + mu = n > 0 ? g / n : 1.3; + } + let homeMult = 1.15; + const att = new Map(); + const def = new Map(); + for (const t of teams) { att.set(t, 1); def.set(t, 1); } + + for (let it = 0; it < iterations; it++) { + // attack updates + const num = new Map(); + const den = new Map(); + for (let i = 0; i < matches.length; i++) { + const m = matches[i]!; + const wi = w[i]!; + const hF = m.neutral ? 1 : homeMult; + num.set(m.home, (num.get(m.home) ?? 0) + wi * m.homeGoals); + den.set(m.home, (den.get(m.home) ?? 0) + wi * mu * def.get(m.away)! * hF); + num.set(m.away, (num.get(m.away) ?? 0) + wi * m.awayGoals); + den.set(m.away, (den.get(m.away) ?? 0) + wi * mu * def.get(m.home)!); + } + for (const t of teams) { + att.set(t, ((num.get(t) ?? 0) + shrinkK * mu) / ((den.get(t) ?? 0) + shrinkK * mu)); + } + + // defence updates + num.clear(); den.clear(); + for (let i = 0; i < matches.length; i++) { + const m = matches[i]!; + const wi = w[i]!; + const hF = m.neutral ? 1 : homeMult; + // home defence faces away attack + num.set(m.home, (num.get(m.home) ?? 0) + wi * m.awayGoals); + den.set(m.home, (den.get(m.home) ?? 0) + wi * mu * att.get(m.away)!); + // away defence faces home attack (with home boost) + num.set(m.away, (num.get(m.away) ?? 0) + wi * m.homeGoals); + den.set(m.away, (den.get(m.away) ?? 0) + wi * mu * att.get(m.home)! * hF); + } + for (const t of teams) { + def.set(t, ((num.get(t) ?? 0) + shrinkK * mu) / ((den.get(t) ?? 0) + shrinkK * mu)); + } + + // normalize so mean(att) = mean(def) = 1 (identifiability) + const aMean = [...att.values()].reduce((s, x) => s + x, 0) / att.size; + const dMean = [...def.values()].reduce((s, x) => s + x, 0) / def.size; + for (const t of teams) { att.set(t, att.get(t)! / aMean); def.set(t, def.get(t)! / dMean); } + mu = mu * aMean * dMean; + + // home multiplier from non-neutral games + let hNum = 0, hDen = 0; + for (let i = 0; i < matches.length; i++) { + const m = matches[i]!; + if (m.neutral) continue; + hNum += w[i]! * m.homeGoals; + hDen += w[i]! * mu * att.get(m.home)! * def.get(m.away)!; + } + if (hDen > 0) homeMult = Math.min(1.6, Math.max(1, hNum / hDen)); + } + + return { + mu, + homeMult, + attack: Object.fromEntries(att), + defence: Object.fromEntries(def), + }; +} + +/** λs for a fixture; unseen teams fall back to average (1.0) strengths. */ +export function teamDcLambdas( + p: TeamDcParams, + home: string, + away: string, + neutral: boolean, +): { lambdaHome: number; lambdaAway: number } { + const ah = p.attack[home] ?? 1; + const dh = p.defence[home] ?? 1; + const aa = p.attack[away] ?? 1; + const da = p.defence[away] ?? 1; + const hF = neutral ? 1 : p.homeMult; + return { + lambdaHome: Math.min(8, Math.max(0.15, p.mu * ah * da * hF)), + lambdaAway: Math.min(8, Math.max(0.15, p.mu * aa * dh)), + }; +} + +/** Cell-wise log-opinion pool of two score matrices: mA^w · mB^(1-w), renormalized. */ +export function poolMatrices(mA: number[][], mB: number[][], w: number): number[][] { + const out: number[][] = []; + let total = 0; + for (let i = 0; i < mA.length; i++) { + out[i] = []; + for (let j = 0; j < mA[i]!.length; j++) { + const v = Math.pow(Math.max(1e-12, mA[i]![j]!), w) * Math.pow(Math.max(1e-12, mB[i]![j]!), 1 - w); + out[i]![j] = v; + total += v; + } + } + for (const row of out) for (let j = 0; j < row.length; j++) row[j]! /= total; + return out; +}