Isotonic calibration layer — validated leave-one-fold-out, shipped
The CV harness gained a calibration experiment: per-outcome isotonic maps (pool-adjacent-violators) fit walk-forward on the five folds and judged leave-one-fold-out against a pre-registered bar. It cleared it: LOFO ECE improves 16% (0.0146 → 0.0122) with RPS slightly better too (0.1724 → 0.1722). The final maps (fit on all folds) are committed as a research artifact, embedded into ratings.json by buildRatings when the shipped config matches, and applied in matchMatrix by scaling the score-matrix outcome regions — scorelines, Monte Carlo and displayed probabilities all stay mutually consistent. Without the field, output is bit-identical (golden tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -114,3 +114,49 @@ describe('fast-Elo blend (v4 recency)', () => {
|
||||
expect(blendedRating(m, 'Strong')).toBeCloseTo(0.7 * 2050 + 0.3 * 1500, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isotonic calibration (v5)', () => {
|
||||
const base: RatingsModel = {
|
||||
asOf: '2026-06-10',
|
||||
params: PARAMS,
|
||||
ratings: { Strong: 2050, Weak: 1650 },
|
||||
};
|
||||
// identity maps — calibration applied but should change nothing
|
||||
const identity = { x: [0, 1], y: [0, 1] };
|
||||
// a map that inflates home probability
|
||||
const homeBoost = { x: [0, 0.5, 1], y: [0, 0.6, 1] };
|
||||
|
||||
it('absent calibration ⇒ bit-identical (golden regression)', () => {
|
||||
const a = predictMatch('Strong', 'Weak', base);
|
||||
const b = predictMatch('Strong', 'Weak', { ...base });
|
||||
expect(b.probs).toEqual(a.probs);
|
||||
});
|
||||
|
||||
it('identity maps leave probabilities (numerically) unchanged', () => {
|
||||
const cal = { home: identity, draw: identity, away: identity };
|
||||
const a = predictMatch('Strong', 'Weak', base);
|
||||
const b = predictMatch('Strong', 'Weak', { ...base, calibration: cal });
|
||||
expect(b.probs.home).toBeCloseTo(a.probs.home, 9);
|
||||
expect(b.probs.draw).toBeCloseTo(a.probs.draw, 9);
|
||||
});
|
||||
|
||||
it('calibrated probabilities renormalize to 1 and shift as mapped', () => {
|
||||
const cal = { home: homeBoost, draw: identity, away: identity };
|
||||
const a = predictMatch('Strong', 'Weak', base);
|
||||
const b = predictMatch('Strong', 'Weak', { ...base, calibration: cal });
|
||||
expect(b.probs.home + b.probs.draw + b.probs.away).toBeCloseTo(1, 9);
|
||||
expect(b.probs.home).toBeGreaterThan(a.probs.home);
|
||||
});
|
||||
|
||||
it('scoreline shapes survive within each outcome region', () => {
|
||||
const cal = { home: homeBoost, draw: identity, away: identity };
|
||||
const a = predictMatch('Strong', 'Weak', base);
|
||||
const b = predictMatch('Strong', 'Weak', { ...base, calibration: cal });
|
||||
const ratio = (s: { home: number; away: number; p: number }[], h: number, a2: number) =>
|
||||
s.find((x) => x.home === h && x.away === a2)?.p ?? 0;
|
||||
// two home-win scorelines keep their relative odds
|
||||
const rawRatio = ratio(a.scorelines, 2, 0) / ratio(a.scorelines, 1, 0);
|
||||
const calRatio = ratio(b.scorelines, 2, 0) / ratio(b.scorelines, 1, 0);
|
||||
expect(calRatio).toBeCloseTo(rawRatio, 6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,47 @@ export interface RatingsModel {
|
||||
fastM?: number;
|
||||
/** Team-DC fit hyper-params, shipped so the server can refit in-tournament. */
|
||||
teamDcFit?: { xi: number; shrinkK: number; windowYears: number };
|
||||
/** Isotonic outcome calibration (fit walk-forward on the CV folds, validated
|
||||
* leave-one-fold-out). Applied by scaling the matrix's outcome regions, so
|
||||
* scorelines, simulations and displayed probabilities stay consistent. */
|
||||
calibration?: Calibration;
|
||||
}
|
||||
|
||||
export interface IsoMap {
|
||||
x: number[];
|
||||
y: number[];
|
||||
}
|
||||
|
||||
export interface Calibration {
|
||||
home: IsoMap;
|
||||
draw: IsoMap;
|
||||
away: IsoMap;
|
||||
}
|
||||
|
||||
function isoApply(map: IsoMap, p: number): number {
|
||||
const { x, y } = map;
|
||||
if (p <= x[0]!) return y[0]!;
|
||||
for (let i = 1; i < x.length; i++) {
|
||||
if (p <= x[i]!) {
|
||||
const f = (p - x[i - 1]!) / (x[i]! - x[i - 1]!);
|
||||
return y[i - 1]! + f * (y[i]! - y[i - 1]!);
|
||||
}
|
||||
}
|
||||
return y[y.length - 1]!;
|
||||
}
|
||||
|
||||
/** Scale the matrix's home/draw/away regions to the calibrated outcome masses.
|
||||
* Scoreline shapes inside each region are preserved. */
|
||||
function calibrateMatrix(m: number[][], cal: Calibration): number[][] {
|
||||
const p = outcomeProbs(m);
|
||||
const h = Math.max(1e-6, isoApply(cal.home, p.home));
|
||||
const d = Math.max(1e-6, isoApply(cal.draw, p.draw));
|
||||
const a = Math.max(1e-6, isoApply(cal.away, p.away));
|
||||
const z = h + d + a;
|
||||
const fh = p.home > 1e-12 ? h / z / p.home : 1;
|
||||
const fd = p.draw > 1e-12 ? d / z / p.draw : 1;
|
||||
const fa = p.away > 1e-12 ? a / z / p.away : 1;
|
||||
return m.map((row, i) => row.map((v, j) => v * (i > j ? fh : i === j ? fd : fa)));
|
||||
}
|
||||
|
||||
export const DEFAULT_ELO = 1500;
|
||||
@@ -72,7 +113,8 @@ export function matchMatrix(
|
||||
blendedRating(model, home), blendedRating(model, away), model.params, homeAdv,
|
||||
);
|
||||
const mElo = scoreMatrix(lambdaHome, lambdaAway, model.params.rho);
|
||||
if (!model.teamDc || model.ensembleW == null || model.ensembleW >= 1) return mElo;
|
||||
const finish = (m: number[][]): number[][] => (model.calibration ? calibrateMatrix(m, model.calibration) : m);
|
||||
if (!model.teamDc || model.ensembleW == null || model.ensembleW >= 1) return finish(mElo);
|
||||
|
||||
// Team-DC orientation: homeAdv > 0 → home side at home; < 0 → away side at
|
||||
// home (compute flipped, then swap); 0 → neutral.
|
||||
@@ -85,7 +127,7 @@ export function matchMatrix(
|
||||
dcHome = f.lambdaHome; dcAway = f.lambdaAway;
|
||||
}
|
||||
const mDc = scoreMatrix(dcHome, dcAway, model.params.rho);
|
||||
return poolMatrices(mElo, mDc, model.ensembleW);
|
||||
return finish(poolMatrices(mElo, mDc, model.ensembleW));
|
||||
}
|
||||
|
||||
/** Expected goals per side under a scoreline matrix. */
|
||||
|
||||
Reference in New Issue
Block a user