Recency-weighted v4 model: fast-Elo blend, Team-DC majority, nightly refit

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>
This commit is contained in:
2026-06-11 23:34:18 +02:00
parent a3f529746f
commit d3e8df96ef
10 changed files with 379 additions and 39 deletions
+1
View File
@@ -19,6 +19,7 @@ public/data/ratings.json
public/data/h2h.json public/data/h2h.json
public/data/scorers.json public/data/scorers.json
public/data/backtest.json public/data/backtest.json
public/data/dcTrain.json
public/pwa-192x192.png public/pwa-192x192.png
public/pwa-512x512.png public/pwa-512x512.png
public/favicon.svg public/favicon.svg
+183 -34
View File
@@ -1,14 +1,21 @@
// v3 bake-off backtest with a strict three-way split — the evidence behind the // v4 bake-off backtest with a strict three-way split — the evidence behind the
// shipped model. NO information leaks forward: // shipped model. NO information leaks forward:
// params (pre-2018) : Elo→goals calibration, Dixon-Coles rho // params (pre-2018) : Elo→goals calibration, Dixon-Coles rho
// validate (20182022) : tune Team-DC decay ξ, shrinkage k, ensemble weight w // validate (20182022) : tune Team-DC decay ξ, shrinkage k, ensemble weight w,
// plus the v4 recency candidates — a fast-Elo blend
// (second walk at K×m, blended in with weight β) and a
// recent-form goal multiplier exp(γ·form), where form is
// each side's mean Elo surprise (actual expected) over
// its last 10 non-friendly matches
// test (20222026) : untouched final numbers for every variant + baselines // test (20222026) : untouched final numbers for every variant + baselines
// Decision rule: a recency candidate ships only if it beats the previous shipped
// config by ≥ MIN_VAL_GAIN RPS on validation. Test is evaluated once, for honesty.
// Output → public/data/backtest.json (Methodology page). // Output → public/data/backtest.json (Methodology page).
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { canonicalTeam } from '../src/lib/teams'; import { canonicalTeam } from '../src/lib/teams';
import { HOME_ADV_ELO, importanceWeight, eloDelta } from '../src/lib/model/elo'; import { HOME_ADV_ELO, importanceWeight, eloDelta, expectedHome } from '../src/lib/model/elo';
import { lambdasFromElo, scoreMatrix, outcomeProbs, poissonPmf, type ModelParams } from '../src/lib/model/poisson'; 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'; import { fitTeamDc, teamDcLambdas, poolMatrices, type DcMatch, type TeamDcParams } from '../src/lib/model/teamDc';
@@ -24,8 +31,15 @@ const TEST_TO = '2026-06-01';
const REFIT_DAYS = 60; const REFIT_DAYS = 60;
const FIT_WINDOW_YEARS = 15; const FIT_WINDOW_YEARS = 15;
const XI_GRID = [0.5, 1, 1.5, 2, 2.5]; const XI_GRID = [0.25, 0.5, 0.75, 1, 1.5, 2, 2.5];
const K_GRID = [5, 10, 20]; const K_GRID = [3, 5, 10, 20];
const M_GRID = [2, 3]; // fast-Elo K multiplier
const BETA_GRID = [0, 0.1, 0.2, 0.3]; // fast-Elo blend weight (0 = control)
const GAMMA_GRID = [0, 0.05, 0.1, 0.15]; // recent-form goal multiplier (0 = control)
const FORM_WINDOW = 10; // last N non-friendly matches feeding the form signal
const MIN_VAL_GAIN = 0.0005; // candidate must beat the previous config by this much
/** the previously shipped v3 config — the bar every candidate has to clear */
const PREV = { xi: 0.5, k: 5, w: 0.75, m: 2, beta: 0, gamma: 0 };
interface Row { date: string; home: string; away: string; hs: number; as: number; tournament: string; neutral: boolean; t: number } 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 Probs = { h: number; d: number; a: number };
@@ -89,13 +103,25 @@ function main(): void {
const tTestFrom = new Date(TEST_FROM).getTime(); const tTestFrom = new Date(TEST_FROM).getTime();
const tTestTo = new Date(TEST_TO).getTime(); const tTestTo = new Date(TEST_TO).getTime();
// ---------- pass 1: Elo walk + Elo-DC param fit on pre-2018 ---------- // ---------- pass 1: Elo walks + form signal + Elo-DC param fit on pre-2018 ----------
const elo = new Map<string, number>(); const elo = new Map<string, number>();
const get = (t: string) => elo.get(t) ?? START_ELO; const get = (t: string) => elo.get(t) ?? START_ELO;
let sumTotal = 0, nCal = 0, sxy = 0, sxx = 0; let sumTotal = 0, nCal = 0, sxy = 0, sxx = 0;
const calib: { d: number; h: number; a: number }[] = []; const calib: { d: number; h: number; a: number }[] = [];
/** pre-match Elo per row index (so later passes never see post-match info) */ /** 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 preElo: { eh: number; ea: number }[] = new Array(rows.length);
/** pre-match fast Elo (same walk at K×m) per multiplier, for the β blend */
const eloFast = new Map<number, Map<string, number>>(M_GRID.map((m) => [m, new Map()]));
const preEloFast = new Map<number, { eh: number; ea: number }[]>(M_GRID.map((m) => [m, new Array(rows.length)]));
/** pre-match recent form per side: mean Elo surprise (s E) over the last
* FORM_WINDOW non-friendly matches — strictly pre-match, like preElo */
const formArr = new Map<string, number[]>();
const formH = new Float64Array(rows.length);
const formA = new Float64Array(rows.length);
const formMean = (t: string): number => {
const a = formArr.get(t);
return a && a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0;
};
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i]!; const r = rows[i]!;
@@ -107,6 +133,27 @@ function main(): void {
sumTotal += r.hs + r.as; nCal++; sxy += eff * (r.hs - r.as); sxx += eff * eff; 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 }); calib.push({ d: eff, h: r.hs, a: r.as });
} }
for (const m of M_GRID) {
const fm = eloFast.get(m)!;
const feh = fm.get(r.home) ?? START_ELO, fea = fm.get(r.away) ?? START_ELO;
preEloFast.get(m)![i] = { eh: feh, ea: fea };
const fd = eloDelta(r.hs, r.as, feh, fea, importanceWeight(r.tournament) * m, homeAdv);
fm.set(r.home, feh + fd); fm.set(r.away, fea - fd);
}
formH[i] = formMean(r.home);
formA[i] = formMean(r.away);
if (r.tournament !== 'Friendly') {
const eHome = expectedHome(eh, ea, homeAdv);
const sHome = r.hs > r.as ? 1 : r.hs < r.as ? 0 : 0.5;
const push = (t: string, v: number): void => {
const a = formArr.get(t) ?? [];
a.push(v);
if (a.length > FORM_WINDOW) a.shift();
formArr.set(t, a);
};
push(r.home, sHome - eHome);
push(r.away, (1 - sHome) - (1 - eHome));
}
const d = eloDelta(r.hs, r.as, eh, ea, importanceWeight(r.tournament), homeAdv); 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); elo.set(r.home, eh + d); elo.set(r.away, ea - d);
} }
@@ -126,15 +173,26 @@ function main(): void {
} }
const params: ModelParams = { goalsPerElo, avgGoals, rho, homeAdvElo: HOME_ADV_ELO }; const params: ModelParams = { goalsPerElo, avgGoals, rho, homeAdvElo: HOME_ADV_ELO };
const eloMatrix = (i: number): number[][] => { // ---------- prediction helpers (all read strictly pre-match state) ----------
const r = rows[i]!; /** Elo-member lambdas, with optional fast-Elo blend R = (1−β)·slow + β·fast. */
const { lambdaHome, lambdaAway } = lambdasFromElo(preElo[i]!.eh, preElo[i]!.ea, params, r.neutral ? 0 : HOME_ADV_ELO); const eloLambdas = (i: number, beta: number, m: number): { lh: number; la: number } => {
return scoreMatrix(lambdaHome, lambdaAway, rho); const slow = preElo[i]!;
const fast = beta > 0 ? preEloFast.get(m)![i]! : slow;
const eh = (1 - beta) * slow.eh + beta * fast.eh;
const ea = (1 - beta) * slow.ea + beta * fast.ea;
const { lambdaHome, lambdaAway } = lambdasFromElo(eh, ea, params, rows[i]!.neutral ? 0 : HOME_ADV_ELO);
return { lh: lambdaHome, la: lambdaAway };
}; };
const clampL = (x: number): number => Math.min(8, Math.max(0.1, x));
/** Recent-form goal multiplier exp(γ·form), applied to both ensemble members. */
const withForm = (lh: number, la: number, i: number, gamma: number): { lh: number; la: number } =>
gamma === 0
? { lh, la }
: { lh: clampL(lh * Math.exp(gamma * formH[i]!)), la: clampL(la * Math.exp(gamma * formA[i]!)) };
// ---------- walk-forward Team-DC over a window, collecting matrices ---------- // ---------- walk-forward Team-DC over a window, collecting lambdas ----------
type Pred = { i: number; mDc: number[][] }; type Pred = { i: number; lDcH: number; lDcA: number };
const walkTeamDc = (xi: number, k: number, fromT: number, toT: number): Pred[] => { const walkTeamDc = (xi: number, k: number, fromT: number, toT: number, refitDays = REFIT_DAYS): Pred[] => {
const preds: Pred[] = []; const preds: Pred[] = [];
let fit: TeamDcParams | null = null; let fit: TeamDcParams | null = null;
let fitAt = -Infinity; let fitAt = -Infinity;
@@ -143,7 +201,7 @@ function main(): void {
const r = rows[i]!; const r = rows[i]!;
if (r.t < fromT) continue; if (r.t < fromT) continue;
if (r.t >= toT) break; if (r.t >= toT) break;
if (r.t - fitAt > REFIT_DAYS * DAY) { if (r.t - fitAt > refitDays * DAY) {
const train: DcMatch[] = []; const train: DcMatch[] = [];
for (let j = 0; j < i; j++) { for (let j = 0; j < i; j++) {
const m = rows[j]!; const m = rows[j]!;
@@ -157,36 +215,96 @@ function main(): void {
fitAt = r.t; fitAt = r.t;
} }
const { lambdaHome, lambdaAway } = teamDcLambdas(fit!, r.home, r.away, r.neutral); const { lambdaHome, lambdaAway } = teamDcLambdas(fit!, r.home, r.away, r.neutral);
preds.push({ i, mDc: scoreMatrix(lambdaHome, lambdaAway, rho) }); preds.push({ i, lDcH: lambdaHome, lDcA: lambdaAway });
} }
return preds; return preds;
}; };
// ---------- validation: tune ξ, k, then w ---------- /** Sweep the ensemble weight w for a fixed (β, m, γ) over cached walk preds. */
console.log('tuning on validation (20182022)…'); const W_STEPS: number[] = [];
let best = { xi: 1, k: 10, w: 0.5, rps: Infinity }; for (let w = 0; w <= 1.0001; w += 0.05) W_STEPS.push(+w.toFixed(2));
const sweepW = (preds: Pred[], beta: number, m: number, gamma: number): { w: number; rps: number }[] => {
const acc = new Float64Array(W_STEPS.length);
for (const p of preds) {
const o = outcomeOf(rows[p.i]!.hs, rows[p.i]!.as);
const el = eloLambdas(p.i, beta, m);
const ef = withForm(el.lh, el.la, p.i, gamma);
const df = withForm(p.lDcH, p.lDcA, p.i, gamma);
const mE = scoreMatrix(ef.lh, ef.la, rho);
const mD = scoreMatrix(df.lh, df.la, rho);
for (let wi = 0; wi < W_STEPS.length; wi++) {
const w = W_STEPS[wi]!;
const pooled = w === 0 ? mD : w === 1 ? mE : poolMatrices(mE, mD, w);
acc[wi] += rps(probsOf(pooled), o);
}
}
return W_STEPS.map((w, wi) => ({ w, rps: acc[wi]! / preds.length }));
};
// ---------- validation stage 1: tune ξ, k, w (β=0, γ=0) ----------
console.log('validation stage 1 — ξ/k/w grid (β=0, γ=0)…');
const valWalks = new Map<string, Pred[]>();
let best = { xi: PREV.xi, k: PREV.k, w: PREV.w, m: PREV.m, beta: 0, gamma: 0, rps: Infinity };
let prevValRps = Infinity; // validation RPS of the previously shipped config
for (const xi of XI_GRID) { for (const xi of XI_GRID) {
for (const k of K_GRID) { for (const k of K_GRID) {
const preds = walkTeamDc(xi, k, tValFrom, tTestFrom); const preds = walkTeamDc(xi, k, tValFrom, tTestFrom);
// pure Team-DC score (w=0 candidate) and weight sweep against Elo valWalks.set(`${xi}|${k}`, preds);
for (let w = 0; w <= 1.0001; w += 0.05) { for (const { w, rps: r } of sweepW(preds, 0, PREV.m, 0)) {
let r = 0; if (xi === PREV.xi && k === PREV.k && w === PREV.w) prevValRps = r;
for (const p of preds) { if (r < best.rps) best = { xi, k, w, m: PREV.m, beta: 0, gamma: 0, rps: r };
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)})`); 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 ---------- // ---------- validation stage 2: fast-Elo blend (m, β) at the stage-1 ξ/k ----------
console.log(`testing on ${TEST_FROM}${TEST_TO} with ξ=${best.xi} k=${best.k} w=${best.w}`); console.log('validation stage 2 — fast-Elo blend (m, β)…');
const testPreds = walkTeamDc(best.xi, best.k, tTestFrom, tTestTo); const stagePreds = valWalks.get(`${best.xi}|${best.k}`)!;
for (const m of M_GRID) {
for (const beta of BETA_GRID) {
if (beta === 0) continue; // control already covered by stage 1
for (const { w, rps: r } of sweepW(stagePreds, beta, m, 0)) {
if (r < best.rps) best = { ...best, m, beta, w, rps: r };
}
}
console.log(` m=${m} swept (best: β=${best.beta} m=${best.m} w=${best.w} rps=${best.rps.toFixed(4)})`);
}
// ---------- validation stage 3: recent-form multiplier γ at the winners ----------
console.log('validation stage 3 — form multiplier γ…');
for (const gamma of GAMMA_GRID) {
if (gamma === 0) continue; // control covered above
for (const { w, rps: r } of sweepW(stagePreds, best.beta, best.m, gamma)) {
if (r < best.rps) best = { ...best, gamma, w, rps: r };
}
}
console.log(` champion: ξ=${best.xi} k=${best.k} w=${best.w} β=${best.beta} m=${best.m} γ=${best.gamma} rps=${best.rps.toFixed(4)}`);
// ---------- decision rule: ship only a validated improvement ----------
const gain = prevValRps - best.rps;
const shipChange = gain >= MIN_VAL_GAIN;
const shipped = shipChange ? best : { ...PREV, rps: prevValRps };
console.log(`previous config validation rps=${prevValRps.toFixed(4)} | champion gain=${gain.toFixed(4)}${shipChange ? 'SHIP candidate' : 'KEEP previous (bar not met)'}`);
// ---------- in-tournament refit cadence check (validation only) ----------
console.log('validating 7-day Team-DC refit cadence…');
const preds7 = walkTeamDc(shipped.xi, shipped.k, tValFrom, tTestFrom, 7);
const refit7Rps = sweepW(preds7, shipped.beta, shipped.m, shipped.gamma).find((x) => x.w === shipped.w)!.rps;
const refit7Ok = refit7Rps <= shipped.rps + MIN_VAL_GAIN;
console.log(` refit=7d rps=${refit7Rps.toFixed(4)} vs ${REFIT_DAYS}d rps=${shipped.rps.toFixed(4)} → nightly refit ${refit7Ok ? 'ENABLED' : 'disabled'}`);
// ---------- test window: shipped + candidate/previous + members + baselines ----------
console.log(`testing on ${TEST_FROM}${TEST_TO} with shipped ξ=${shipped.xi} k=${shipped.k} w=${shipped.w} β=${shipped.beta} m=${shipped.m} γ=${shipped.gamma}`);
const testPreds = walkTeamDc(shipped.xi, shipped.k, tTestFrom, tTestTo);
const testPredsAlt = shipped.xi === PREV.xi && shipped.k === PREV.k
? testPreds
: walkTeamDc(shipChange ? PREV.xi : best.xi, shipChange ? PREV.k : best.k, tTestFrom, tTestTo);
const altCfg = shipChange ? { ...PREV } : { xi: best.xi, k: best.k, w: best.w, m: best.m, beta: best.beta, gamma: best.gamma };
const altByIdx = new Map(testPredsAlt.map((p) => [p.i, p]));
const setEnsemble: { p: Probs; o: Outcome }[] = []; const setEnsemble: { p: Probs; o: Outcome }[] = [];
const setAlt: { p: Probs; o: Outcome }[] = [];
const setElo: { p: Probs; o: Outcome }[] = []; const setElo: { p: Probs; o: Outcome }[] = [];
const setDc: { p: Probs; o: Outcome }[] = []; const setDc: { p: Probs; o: Outcome }[] = [];
const setUniform: { p: Probs; o: Outcome }[] = []; const setUniform: { p: Probs; o: Outcome }[] = [];
@@ -202,12 +320,25 @@ function main(): void {
} }
const rate: Probs = { h: th / tn, d: td / tn, a: ta / tn }; const rate: Probs = { h: th / tn, d: td / tn, a: ta / tn };
/** pooled outcome probs for one test row under a full config */
const pooledProbs = (p: Pred, cfg: { w: number; m: number; beta: number; gamma: number }): Probs => {
const el = eloLambdas(p.i, cfg.beta, cfg.m);
const ef = withForm(el.lh, el.la, p.i, cfg.gamma);
const df = withForm(p.lDcH, p.lDcA, p.i, cfg.gamma);
const mE = scoreMatrix(ef.lh, ef.la, rho);
const mD = scoreMatrix(df.lh, df.la, rho);
return probsOf(cfg.w === 0 ? mD : cfg.w === 1 ? mE : poolMatrices(mE, mD, cfg.w));
};
for (const p of testPreds) { for (const p of testPreds) {
const r = rows[p.i]!; const r = rows[p.i]!;
const o = outcomeOf(r.hs, r.as); const o = outcomeOf(r.hs, r.as);
const mE = eloMatrix(p.i); const el = eloLambdas(p.i, 0, PREV.m);
const pooled = poolMatrices(mE, p.mDc, best.w); const pe = probsOf(scoreMatrix(el.lh, el.la, rho));
const pe = probsOf(mE), pd = probsOf(p.mDc), pp = probsOf(pooled); const pd = probsOf(scoreMatrix(p.lDcH, p.lDcA, rho));
const pp = pooledProbs(p, shipped);
const alt = altByIdx.get(p.i);
if (alt) setAlt.push({ p: pooledProbs(alt, altCfg), o });
setElo.push({ p: pe, o }); setElo.push({ p: pe, o });
setDc.push({ p: pd, o }); setDc.push({ p: pd, o });
setEnsemble.push({ p: pp, o }); setEnsemble.push({ p: pp, o });
@@ -228,11 +359,20 @@ function main(): void {
const totalN = reliabilityRaw.length; 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).toFixed(3); 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 altName = shipChange
? 'Previous ensemble (v3)'
: 'Recency candidate (failed validation bar)';
const out = { const out = {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
paramFrom: PARAM_FROM, paramFrom: PARAM_FROM,
trainEnd: VAL_FROM, trainEnd: VAL_FROM,
validation: { from: VAL_FROM, to: TEST_FROM, tuned: { xi: best.xi, shrinkK: best.k, ensembleW: best.w } }, validation: {
from: VAL_FROM, to: TEST_FROM,
tuned: {
xi: shipped.xi, shrinkK: shipped.k, ensembleW: shipped.w,
fastM: shipped.m, fastBeta: shipped.beta, formGamma: shipped.gamma,
},
},
testFrom: TEST_FROM, testFrom: TEST_FROM,
testTo: TEST_TO, testTo: TEST_TO,
tested: testPreds.length, tested: testPreds.length,
@@ -240,6 +380,7 @@ function main(): void {
model: scoreSet(setEnsemble), // the SHIPPED model = ensemble model: scoreSet(setEnsemble), // the SHIPPED model = ensemble
variants: [ variants: [
{ name: 'Ensemble (shipped)', ...scoreSet(setEnsemble) }, { name: 'Ensemble (shipped)', ...scoreSet(setEnsemble) },
{ name: altName, ...scoreSet(setAlt) },
{ name: 'EloDixon-Coles', ...scoreSet(setElo) }, { name: 'EloDixon-Coles', ...scoreSet(setElo) },
{ name: 'Team Dixon-Coles', ...scoreSet(setDc) }, { name: 'Team Dixon-Coles', ...scoreSet(setDc) },
], ],
@@ -247,6 +388,14 @@ function main(): void {
reliability, reliability,
ece, ece,
worldCup: { tested: wcN, accuracy: wcN ? +(wcCorrect / wcN).toFixed(4) : 0 }, worldCup: { tested: wcN, accuracy: wcN ? +(wcCorrect / wcN).toFixed(4) : 0 },
// the v4 recency decision, recorded for honesty + reproducibility
recency: {
minValGain: MIN_VAL_GAIN,
previous: { ...PREV, valRps: +prevValRps.toFixed(4) },
champion: { xi: best.xi, k: best.k, w: best.w, m: best.m, beta: best.beta, gamma: best.gamma, valRps: +best.rps.toFixed(4) },
shippedChange: shipChange,
nightlyRefit: { refitDays: 7, valRps: +refit7Rps.toFixed(4), baselineValRps: +shipped.rps.toFixed(4), enabled: refit7Ok },
},
}; };
mkdirSync(OUT_DIR, { recursive: true }); mkdirSync(OUT_DIR, { recursive: true });
+27 -3
View File
@@ -13,9 +13,13 @@ import { fitTeamDc, type DcMatch } from '../src/lib/model/teamDc';
// Ensemble hyper-params — tuned on the 20182022 validation window and verified // 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. // out-of-sample on 20222026 by scripts/buildBacktest.ts. Keep in sync with it.
const TEAMDC_XI = 0.5; // v4 recency config: more ensemble weight on Team-DC, plus a fast Elo walk
const TEAMDC_SHRINK_K = 5; // (K×3) blended into the Elo member at β=0.3, so recent results count harder.
const ENSEMBLE_W = 0.75; // log-pool weight on the Elo-DC member 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 FIT_WINDOW_YEARS = 15;
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -62,6 +66,8 @@ function main(): void {
const rows = parseCsv(readFileSync(CSV, 'utf8')); const rows = parseCsv(readFileSync(CSV, 'utf8'));
const elo = new Map<string, number>(); const elo = new Map<string, number>();
const get = (t: string) => elo.get(t) ?? START_ELO; 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). // Calibration accumulators (modern era only).
let sumTotal = 0, nCalib = 0; let sumTotal = 0, nCalib = 0;
@@ -88,6 +94,10 @@ function main(): void {
const delta = eloDelta(r.hs, r.as, eh, ea, k, homeAdv); const delta = eloDelta(r.hs, r.as, eh, ea, k, homeAdv);
elo.set(home, eh + delta); elo.set(home, eh + delta);
elo.set(away, ea - 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; lastDate = r.date;
} }
@@ -126,6 +136,8 @@ function main(): void {
} }
// Ensure every WC team has a rating (default for any never seen). // 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; 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 ---- // ---- Team-DC ensemble member: fit on the recent window through today ----
const nowT = new Date(lastDate).getTime(); const nowT = new Date(lastDate).getTime();
@@ -162,12 +174,24 @@ function main(): void {
}, },
teamDc, teamDc,
ensembleW: ENSEMBLE_W, ensembleW: ENSEMBLE_W,
ratingsFast,
fastBeta: FAST_BETA,
fastM: FAST_M,
teamDcFit: { xi: TEAMDC_XI, shrinkK: TEAMDC_SHRINK_K, windowYears: FIT_WINDOW_YEARS },
ratings, ratings,
}; };
mkdirSync(OUT_DIR, { recursive: true }); mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'ratings.json'), JSON.stringify(out)); 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(', '); 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(`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(`params: goalsPerElo=${out.params.goalsPerElo} avgGoals=${out.params.avgGoals} rho=${rho} homeAdvElo=${HOME_ADV_ELO}`);
+7
View File
@@ -35,6 +35,7 @@ export function buildServer() {
db(); // open + migrate SQLite before anything reads it db(); // open + migrate SQLite before anything reads it
const state = new TournamentState(STATIC_DIR); const state = new TournamentState(STATIC_DIR);
const model = new ModelEngine(STATIC_DIR); const model = new ModelEngine(STATIC_DIR);
model.refitTeamDc(state.allFixtures()); // current Team-DC from day one (covers restarts mid-tournament)
model.recompute(state.allFixtures()); model.recompute(state.allFixtures());
const clients = new Set<WebSocket>(); const clients = new Set<WebSocket>();
@@ -151,6 +152,12 @@ export function buildServer() {
}); });
app.addHook('onClose', async () => stopIngest()); app.addHook('onClose', async () => stopIngest());
// ---- nightly Team-DC refit (backtest-validated): learn from this tournament ----
const refitTimer = setInterval(() => {
if (model.refitTeamDc(state.allFixtures())) broadcast();
}, 24 * 60 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(refitTimer));
// ---- static SPA (prod) with history fallback ---- // ---- static SPA (prod) with history fallback ----
if (existsSync(STATIC_DIR)) { if (existsSync(STATIC_DIR)) {
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false }); void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { refitTrainingSet, type DcRow } from './refit';
import type { Fixture } from '../../src/lib/types';
const slot = (team: string | null) => ({ team, placeholder: null, label: team ?? '?' });
const fx = (over: Partial<Fixture>): Fixture => ({
num: 1, stage: 'group', group: 'A', round: 'Matchday 1', groupRound: 1,
kickoff: '2026-06-14T18:00:00Z', venue: 'Boston (Foxborough)',
home: slot('Spain'), away: slot('Japan'),
status: 'finished', homeScore: 2, awayScore: 0, minute: null,
...over,
} as Fixture);
const base = {
asOf: '2026-06-10',
rows: [
['2026-06-01', 'Spain', 'France', 1, 1, 1],
['2010-01-01', 'Spain', 'France', 3, 0, 0], // outside a 15y window from 2026
] as DcRow[],
};
describe('refitTrainingSet', () => {
it('appends finished fixtures and advances asOf to the newest match', () => {
const { train, asOf } = refitTrainingSet(base, [fx({})], 70);
expect(asOf).toBe('2026-06-14');
const wc = train.find((m) => m.daysAgo === 0);
expect(wc).toMatchObject({ home: 'Spain', away: 'Japan', homeGoals: 2, awayGoals: 0, neutral: true });
});
it('recomputes daysAgo relative to the new asOf', () => {
const { train } = refitTrainingSet(base, [fx({})], 70);
const old = train.find((m) => m.home === 'Spain' && m.away === 'France');
expect(old?.daysAgo).toBe(13); // 2026-06-01 → 2026-06-14
});
it('drops rows that fall outside the window', () => {
const { train } = refitTrainingSet(base, [fx({})], 70);
expect(train.some((m) => m.homeGoals === 3)).toBe(false);
});
it('treats a host playing at home as non-neutral', () => {
const { train } = refitTrainingSet(base, [fx({ home: slot('USA'), venue: 'Los Angeles (Inglewood)' })], 70);
const wc = train.find((m) => m.daysAgo === 0);
expect(wc).toMatchObject({ home: 'USA', neutral: false });
});
it('flips orientation when the away side is the host at home', () => {
const { train } = refitTrainingSet(base, [fx({ away: slot('Mexico'), venue: 'Mexico City' })], 70);
const wc = train.find((m) => m.daysAgo === 0);
expect(wc).toMatchObject({ home: 'Mexico', away: 'Spain', homeGoals: 0, awayGoals: 2, neutral: false });
});
it('skips fixtures without teams or scores', () => {
const { train } = refitTrainingSet(base, [fx({ homeScore: null })], 70);
expect(train.every((m) => m.daysAgo > 0)).toBe(true);
});
});
+51
View File
@@ -0,0 +1,51 @@
// Pure helpers for the nightly in-tournament Team-DC refit (no fs/db imports,
// so vitest can load them). The training base comes from dcTrain.json — compact
// [date, home, away, hs, as, neutral01] rows with absolute dates — plus every
// finished World Cup fixture so far.
import { hostAdvantage } from '../../src/lib/model/hosts';
import type { DcMatch } from '../../src/lib/model/teamDc';
import type { Fixture } from '../../src/lib/types';
export type DcRow = [string, string, string, number, number, number];
const DAY = 24 * 60 * 60 * 1000;
/** Build the refit training set, with daysAgo relative to the newest match in
* the combined data. Host-nation fixtures are oriented so the host is the
* DC home side (the CSV convention for non-neutral rows). */
export function refitTrainingSet(
base: { asOf: string; rows: DcRow[] },
finished: Fixture[],
homeAdvElo: number,
windowYears = 15,
): { train: DcMatch[]; asOf: string } {
let asOf = base.asOf;
const extra: { date: string; home: string; away: string; hs: number; as: number; neutral: boolean }[] = [];
for (const f of finished) {
if (!f.home.team || !f.away.team || f.homeScore == null || f.awayScore == null) continue;
const date = f.kickoff.slice(0, 10);
if (date > asOf) asOf = date;
const adv = hostAdvantage(f.home.team, f.away.team, f.venue, homeAdvElo);
if (adv < 0) {
extra.push({ date, home: f.away.team, away: f.home.team, hs: f.awayScore, as: f.homeScore, neutral: false });
} else {
extra.push({ date, home: f.home.team, away: f.away.team, hs: f.homeScore, as: f.awayScore, neutral: adv === 0 });
}
}
const nowT = new Date(asOf).getTime();
const windowMs = windowYears * 365 * DAY;
const train: DcMatch[] = [];
for (const [date, home, away, hs, as, neutral] of base.rows) {
const t = new Date(date).getTime();
if (nowT - t > windowMs) continue;
train.push({ daysAgo: (nowT - t) / DAY, home, away, homeGoals: hs, awayGoals: as, neutral: neutral === 1 });
}
for (const m of extra) {
train.push({
daysAgo: (nowT - new Date(m.date).getTime()) / DAY,
home: m.home, away: m.away, homeGoals: m.hs, awayGoals: m.as, neutral: m.neutral,
});
}
return { train, asOf };
}
+33 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { expectedHome, eloDelta, goalDiffMultiplier } from './elo'; import { expectedHome, eloDelta, goalDiffMultiplier } from './elo';
import { poissonPmf, scoreMatrix, outcomeProbs, lambdasFromElo, type ModelParams } from './poisson'; import { poissonPmf, scoreMatrix, outcomeProbs, lambdasFromElo, type ModelParams } from './poisson';
import { predictMatch, knockoutAdvanceProb, type RatingsModel } from './predict'; import { blendedRating, predictMatch, knockoutAdvanceProb, type RatingsModel } from './predict';
const PARAMS: ModelParams = { goalsPerElo: 0.0057, avgGoals: 2.73, rho: -0.05, homeAdvElo: 70 }; const PARAMS: ModelParams = { goalsPerElo: 0.0057, avgGoals: 2.73, rho: -0.05, homeAdvElo: 70 };
@@ -82,3 +82,35 @@ describe('predictMatch', () => {
expect(adv).toBeLessThan(1); expect(adv).toBeLessThan(1);
}); });
}); });
describe('fast-Elo blend (v4 recency)', () => {
const base: RatingsModel = {
asOf: '2026-06-10',
params: PARAMS,
ratings: { Strong: 2050, Weak: 1650 },
};
it('absent fast fields ⇒ bit-identical to the slow model (golden regression)', () => {
const withNoise: RatingsModel = { ...base, ratingsFast: { Strong: 2300, Weak: 1400 }, fastBeta: 0 };
const a = predictMatch('Strong', 'Weak', base);
const b = predictMatch('Strong', 'Weak', withNoise);
expect(b.probs).toEqual(a.probs);
expect(b.lambdaHome).toBe(a.lambdaHome);
});
it('blends toward the fast rating with β', () => {
expect(blendedRating({ ...base, ratingsFast: { Strong: 2150, Weak: 1650 }, fastBeta: 0.3 }, 'Strong'))
.toBeCloseTo(0.7 * 2050 + 0.3 * 2150, 9);
});
it('a hot fast rating raises the win probability', () => {
const hot: RatingsModel = { ...base, ratingsFast: { Strong: 2250, Weak: 1650 }, fastBeta: 0.3 };
expect(predictMatch('Strong', 'Weak', hot).probs.home)
.toBeGreaterThan(predictMatch('Strong', 'Weak', base).probs.home);
});
it('falls back to DEFAULT_ELO for unknown teams in the fast walk', () => {
const m: RatingsModel = { ...base, ratingsFast: {}, fastBeta: 0.3 };
expect(blendedRating(m, 'Strong')).toBeCloseTo(0.7 * 2050 + 0.3 * 1500, 9);
});
});
+18 -1
View File
@@ -19,6 +19,14 @@ export interface RatingsModel {
teamDc?: TeamDcParams; teamDc?: TeamDcParams;
/** Log-pool weight on the Elo-DC member (backtest-tuned). */ /** Log-pool weight on the Elo-DC member (backtest-tuned). */
ensembleW?: number; ensembleW?: number;
/** Fast Elo member — the same walk at K×fastM, so recent results move it
* harder. Blended into the Elo member as (1−β)·slow + β·fast. All optional:
* an older ratings file behaves exactly as before. */
ratingsFast?: Record<string, number>;
fastBeta?: number;
fastM?: number;
/** Team-DC fit hyper-params, shipped so the server can refit in-tournament. */
teamDcFit?: { xi: number; shrinkK: number; windowYears: number };
} }
export const DEFAULT_ELO = 1500; export const DEFAULT_ELO = 1500;
@@ -27,6 +35,15 @@ export function ratingOf(model: RatingsModel, team: string): number {
return model.ratings[team] ?? DEFAULT_ELO; return model.ratings[team] ?? DEFAULT_ELO;
} }
/** The rating that drives probabilities: slow Elo blended with the fast walk
* when the model ships one (backtest-validated recency component). */
export function blendedRating(model: RatingsModel, team: string): number {
const slow = model.ratings[team] ?? DEFAULT_ELO;
const beta = model.fastBeta ?? 0;
if (beta <= 0 || !model.ratingsFast) return slow;
return (1 - beta) * slow + beta * (model.ratingsFast[team] ?? DEFAULT_ELO);
}
export interface MatchPrediction { export interface MatchPrediction {
home: string; home: string;
away: string; away: string;
@@ -52,7 +69,7 @@ export function matchMatrix(
homeAdv = 0, homeAdv = 0,
): number[][] { ): number[][] {
const { lambdaHome, lambdaAway } = lambdasFromElo( const { lambdaHome, lambdaAway } = lambdasFromElo(
ratingOf(model, home), ratingOf(model, away), model.params, homeAdv, blendedRating(model, home), blendedRating(model, away), model.params, homeAdv,
); );
const mElo = scoreMatrix(lambdaHome, lambdaAway, model.params.rho); const mElo = scoreMatrix(lambdaHome, lambdaAway, model.params.rho);
if (!model.teamDc || model.ensembleW == null || model.ensembleW >= 1) return mElo; if (!model.teamDc || model.ensembleW == null || model.ensembleW >= 1) return mElo;
+2
View File
@@ -47,6 +47,8 @@ export default defineConfig({
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2,json}'], globPatterns: ['**/*.{js,css,html,svg,png,woff2,json}'],
// server-only refit training data (~1MB) — keep it off every client
globIgnores: ['**/dcTrain.json'],
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
skipWaiting: true, skipWaiting: true,
clientsClaim: true, clientsClaim: true,