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:
@@ -1,7 +1,7 @@
|
||||
import type { Fixture, TeamOdds } from '../types';
|
||||
import { rankGroup, type Result } from '../standings';
|
||||
import { lambdasFromElo, scoreMatrix, outcomeProbs, type OutcomeProbs } from './poisson';
|
||||
import { knockoutAdvanceProb, ratingOf, type RatingsModel } from './predict';
|
||||
import { outcomeProbs, type OutcomeProbs } from './poisson';
|
||||
import { knockoutAdvanceProb, matchMatrix, ratingOf, type RatingsModel } from './predict';
|
||||
import { hostAdvantage } from './hosts';
|
||||
|
||||
export type { TeamOdds };
|
||||
@@ -60,10 +60,7 @@ function buildScoreCdf(
|
||||
home: string, away: string, venue: string, model: RatingsModel,
|
||||
): { h: number; a: number; cum: number }[] {
|
||||
const homeAdv = hostAdvantage(home, away, venue, model.params.homeAdvElo);
|
||||
const { lambdaHome, lambdaAway } = lambdasFromElo(
|
||||
ratingOf(model, home), ratingOf(model, away), model.params, homeAdv,
|
||||
);
|
||||
const m = scoreMatrix(lambdaHome, lambdaAway, model.params.rho);
|
||||
const m = matchMatrix(home, away, model, homeAdv); // ensemble when configured
|
||||
const cdf: { h: number; a: number; cum: number }[] = [];
|
||||
let cum = 0;
|
||||
for (let h = 0; h < m.length; h++) {
|
||||
@@ -147,8 +144,7 @@ export function buildSimulator(fixtures: Fixture[], model: RatingsModel) {
|
||||
let p = advMemo.get(key);
|
||||
if (p === undefined) {
|
||||
const homeAdv = hostAdvantage(home, away, venue, model.params.homeAdvElo);
|
||||
const { lambdaHome, lambdaAway } = lambdasFromElo(ratingOf(model, home), ratingOf(model, away), model.params, homeAdv);
|
||||
const probs: OutcomeProbs = outcomeProbs(scoreMatrix(lambdaHome, lambdaAway, model.params.rho));
|
||||
const probs: OutcomeProbs = outcomeProbs(matchMatrix(home, away, model, homeAdv));
|
||||
p = knockoutAdvanceProb(probs, ratingOf(model, home), ratingOf(model, away));
|
||||
advMemo.set(key, p);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,18 @@ import {
|
||||
type OutcomeProbs,
|
||||
type Scoreline,
|
||||
} from './poisson';
|
||||
import { teamDcLambdas, poolMatrices, type TeamDcParams } from './teamDc';
|
||||
|
||||
/** The ratings.json payload (also the runtime model state after live re-rating). */
|
||||
export interface RatingsModel {
|
||||
asOf: string;
|
||||
params: ModelParams;
|
||||
ratings: Record<string, number>;
|
||||
/** 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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>;
|
||||
defence: Record<string, number>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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<string, number>();
|
||||
const def = new Map<string, number>();
|
||||
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<string, number>();
|
||||
const den = new Map<string, number>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user