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:
@@ -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
@@ -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 (2018–2022) : tune Team-DC decay ξ, shrinkage k, ensemble weight w
|
// validate (2018–2022) : 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 (2022–2026) : untouched final numbers for every variant + baselines
|
// test (2022–2026) : 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 (2018–2022)…');
|
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: 'Elo–Dixon-Coles', ...scoreSet(setElo) },
|
{ name: 'Elo–Dixon-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
@@ -13,9 +13,13 @@ import { fitTeamDc, type DcMatch } from '../src/lib/model/teamDc';
|
|||||||
|
|
||||||
// Ensemble hyper-params — tuned on the 2018–2022 validation window and verified
|
// 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.
|
// out-of-sample on 2022–2026 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}`);
|
||||||
|
|||||||
@@ -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.
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user