v3 Phase B core: Team-DC + backtested ensemble, squad values
- src/lib/model/teamDc.ts: per-team attack/defence Dixon-Coles via weighted MLE (Maher iterations), exponential time-decay, shrinkage for thin histories, learned home multiplier; log-pool of score matrices. 7 vitest cases. - buildBacktest.ts rewritten as a strict three-way-split bake-off: params pre-2018, tune (xi, k, w) on 2018-2022 validation, final scores on untouched 2022-2026 test (4,448 matches). Result: ensemble RPS 0.1721 beats Elo-DC 0.1726 and Team-DC 0.1801; ECE stays 0.01. Tuned: xi=0.5 k=5 w=0.75. - Runtime now IS the backtested model: ratings.json carries teamDc+ensembleW; matchMatrix() pools both members for predictions, Monte Carlo and advance probs; ModelEngine re-rating preserves ensemble params; lambdas reported as matrix expectations (drives in-play). - scripts/buildSquadValues.ts: Transfermarkt FIWC participants page -> all 48 squad market values (France 1.52bn ... Qatar 20m). Display/availability layer only — June-2026 values are NOT in the backtested model (would leak). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,14 @@ import { dirname, join } from 'node:path';
|
||||
import { canonicalTeam, ALL_TEAMS } from '../src/lib/teams';
|
||||
import { HOME_ADV_ELO, importanceWeight, eloDelta, expectedHome } from '../src/lib/model/elo';
|
||||
import { poissonPmf } from '../src/lib/model/poisson';
|
||||
import { fitTeamDc, type DcMatch } from '../src/lib/model/teamDc';
|
||||
|
||||
// Ensemble hyper-params — tuned on the 2018–2022 validation window and verified
|
||||
// out-of-sample on 2022–2026 by scripts/buildBacktest.ts. Keep in sync with it.
|
||||
const TEAMDC_XI = 0.5;
|
||||
const TEAMDC_SHRINK_K = 5;
|
||||
const ENSEMBLE_W = 0.75; // log-pool weight on the Elo-DC member
|
||||
const FIT_WINDOW_YEARS = 15;
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CSV = join(ROOT, 'data', 'raw', 'international-results.csv');
|
||||
@@ -119,6 +127,27 @@ function main(): void {
|
||||
// Ensure every WC team has a rating (default for any never seen).
|
||||
for (const t of ALL_TEAMS) if (!(t in ratings)) ratings[t] = START_ELO;
|
||||
|
||||
// ---- Team-DC ensemble member: fit on the recent window through today ----
|
||||
const nowT = new Date(lastDate).getTime();
|
||||
const windowMs = FIT_WINDOW_YEARS * 365 * 24 * 60 * 60 * 1000;
|
||||
const dcTrain: DcMatch[] = [];
|
||||
for (const r of rows) {
|
||||
const t = new Date(r.date).getTime();
|
||||
if (nowT - t > windowMs) continue;
|
||||
dcTrain.push({
|
||||
daysAgo: (nowT - t) / (24 * 60 * 60 * 1000),
|
||||
home: canonicalTeam(r.home), away: canonicalTeam(r.away),
|
||||
homeGoals: r.hs, awayGoals: r.as, neutral: r.neutral,
|
||||
});
|
||||
}
|
||||
const teamDc = fitTeamDc(dcTrain, TEAMDC_XI, TEAMDC_SHRINK_K);
|
||||
// Trim to relevant nations: WC qualifiers + any team rated (keeps file small).
|
||||
const keep = new Set<string>([...ALL_TEAMS, ...Object.keys(ratings)]);
|
||||
teamDc.attack = Object.fromEntries(Object.entries(teamDc.attack).filter(([t]) => keep.has(t)).map(([t, v]) => [t, Math.round(v * 1e4) / 1e4]));
|
||||
teamDc.defence = Object.fromEntries(Object.entries(teamDc.defence).filter(([t]) => keep.has(t)).map(([t, v]) => [t, Math.round(v * 1e4) / 1e4]));
|
||||
teamDc.mu = Math.round(teamDc.mu * 1e4) / 1e4;
|
||||
teamDc.homeMult = Math.round(teamDc.homeMult * 1e4) / 1e4;
|
||||
|
||||
const out = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
asOf: lastDate,
|
||||
@@ -131,6 +160,8 @@ function main(): void {
|
||||
rho,
|
||||
homeAdvElo: HOME_ADV_ELO,
|
||||
},
|
||||
teamDc,
|
||||
ensembleW: ENSEMBLE_W,
|
||||
ratings,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user