Files
NilsBriggen 88afbfad74 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>
2026-06-12 01:08:20 +02:00

526 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// v5 hyperparameter search: rolling-origin cross-validation.
//
// The v4 protocol tuned on ONE validation window (20182022) — selection could
// overfit that window's quirks. v5 selects by MEAN walk-forward RPS across five
// rolling two-year folds, each with its own pre-fold parameter fit (no fold
// ever sees information from its future):
// F1 201416 · F2 201618 · F3 201820 · F4 202022 · F5 202224
// FINAL TEST: 2024-01-01 → 2026-06-01, untouched by selection, evaluated once.
//
// Search axes (staged greedy, like v4 — full grid is combinatorially silly):
// stage 1: Team-DC ξ (decay), k (shrinkage), w (ensemble weight)
// stage 2: fast-Elo blend (m, β)
// stage 3: recent-form goal multiplier γ
// stage 4: Elo-walk axes — HOME_ADV_ELO and K-scale σ
// Ship bar (pre-registered): champion must beat the current shipped config by
// ≥ MIN_GAIN mean-fold RPS. Output → data/cv-report.json (research artifact,
// not public; the public evidence stays scripts/buildBacktest.ts).
//
// Self-contained on purpose: this is a research harness, and keeping it apart
// from the publication script means it can never destabilize shipped evidence.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { canonicalTeam } from '../src/lib/teams';
import { importanceWeight, eloDelta, expectedHome } from '../src/lib/model/elo';
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';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CSV = join(ROOT, 'data', 'raw', 'international-results.csv');
const OUT = join(ROOT, 'data', 'cv-report.json');
const START_ELO = 1500;
const REFIT_DAYS = 60;
const FIT_WINDOW_YEARS = 15;
const PARAM_YEARS = 12; // params fit on the 12 years before each fold/test
const DAY = 24 * 60 * 60 * 1000;
const MIN_GAIN = 0.0005;
const FOLDS = [
{ from: '2014-01-01', to: '2016-01-01' },
{ from: '2016-01-01', to: '2018-01-01' },
{ from: '2018-01-01', to: '2020-01-01' },
{ from: '2020-01-01', to: '2022-01-01' },
{ from: '2022-01-01', to: '2024-01-01' },
];
const TEST = { from: '2024-01-01', to: '2026-06-01' };
const XI_GRID = [0.25, 0.5, 0.75, 1, 1.5, 2];
const K_GRID = [3, 5, 10, 20];
const M_GRID = [2, 3];
const BETA_GRID = [0.1, 0.2, 0.3, 0.4];
const GAMMA_GRID = [0.05, 0.1, 0.15];
const HOMEADV_GRID = [50, 70, 90];
const KSCALE_GRID = [0.75, 1, 1.25];
const FORM_WINDOW = 10;
/** the currently shipped v4 config — the bar to clear (homeAdv 70, σ 1) */
const SHIPPED = { xi: 0.25, k: 3, w: 0.45, m: 3, beta: 0.3, gamma: 0, homeAdv: 70, kScale: 1 };
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 Outcome = 'h' | 'd' | 'a';
function parse(): Row[] {
const lines = readFileSync(CSV, 'utf8').split('\n');
const rows: Row[] = [];
for (let i = 1; i < lines.length; i++) {
const c = lines[i]?.split(',');
if (!c || c.length < 9) continue;
const hs = Number(c[3]); const as = Number(c[4]);
if (!Number.isFinite(hs) || !Number.isFinite(as)) continue;
rows.push({
date: c[0]!, home: canonicalTeam(c[1]!), away: canonicalTeam(c[2]!), hs, as,
tournament: c[5]!, neutral: c[8]!.trim().toUpperCase() === 'TRUE',
t: new Date(c[0]!).getTime(),
});
}
rows.sort((a, b) => a.t - b.t);
return rows;
}
function dcTau(h: number, a: number, lh: number, la: number, rho: number): number {
if (h === 0 && a === 0) return 1 - lh * la * rho;
if (h === 0 && a === 1) return 1 + lh * rho;
if (h === 1 && a === 0) return 1 + la * rho;
if (h === 1 && a === 1) return 1 - rho;
return 1;
}
const outcomeOf = (hs: number, as: number): Outcome => (hs > as ? 'h' : hs < as ? 'a' : 'd');
const probsOf = (m: number[][]): Probs => { const p = outcomeProbs(m); return { h: p.home, d: p.draw, a: p.away }; };
function rps(p: Probs, o: Outcome): number {
const y1 = o === 'h' ? 1 : 0;
const y2 = o === 'a' ? 0 : 1;
return ((p.h - y1) ** 2 + (p.h + p.d - y2) ** 2) / 2;
}
// ---------------------------------------------------------------------------
// Elo walk variants: one pass per (homeAdv, kScale), storing pre-match slow +
// fast ratings and the pre-match form signal (mean Elo surprise, last 10
// non-friendly). Everything is strictly pre-match by construction.
// ---------------------------------------------------------------------------
interface Walk {
preH: Float64Array; preA: Float64Array; // slow Elo, pre-match
fast: Map<number, { h: Float64Array; a: Float64Array }>; // per m
formH: Float64Array; formA: Float64Array;
}
function runWalk(rows: Row[], homeAdvElo: number, kScale: number): Walk {
const n = rows.length;
const w: Walk = {
preH: new Float64Array(n), preA: new Float64Array(n),
fast: new Map(M_GRID.map((m) => [m, { h: new Float64Array(n), a: new Float64Array(n) }])),
formH: new Float64Array(n), formA: new Float64Array(n),
};
const elo = new Map<string, number>();
const eloFast = new Map<number, Map<string, number>>(M_GRID.map((m) => [m, new Map()]));
const formArr = new Map<string, number[]>();
const get = (map: Map<string, number>, t: string) => map.get(t) ?? START_ELO;
const formMean = (t: string) => {
const a = formArr.get(t);
return a && a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0;
};
for (let i = 0; i < n; i++) {
const r = rows[i]!;
const adv = r.neutral ? 0 : homeAdvElo;
const eh = get(elo, r.home), ea = get(elo, r.away);
w.preH[i] = eh; w.preA[i] = ea;
for (const m of M_GRID) {
const fm = eloFast.get(m)!;
const arr = w.fast.get(m)!;
const feh = get(fm, r.home), fea = get(fm, r.away);
arr.h[i] = feh; arr.a[i] = fea;
const fd = eloDelta(r.hs, r.as, feh, fea, importanceWeight(r.tournament) * kScale * m, adv);
fm.set(r.home, feh + fd); fm.set(r.away, fea - fd);
}
w.formH[i] = formMean(r.home);
w.formA[i] = formMean(r.away);
if (r.tournament !== 'Friendly') {
const eH = expectedHome(eh, ea, adv);
const sH = r.hs > r.as ? 1 : r.hs < r.as ? 0 : 0.5;
const push = (t: string, v: number) => {
const a = formArr.get(t) ?? [];
a.push(v);
if (a.length > FORM_WINDOW) a.shift();
formArr.set(t, a);
};
push(r.home, sH - eH);
push(r.away, (1 - sH) - (1 - eH));
}
const d = eloDelta(r.hs, r.as, eh, ea, importanceWeight(r.tournament) * kScale, adv);
elo.set(r.home, eh + d); elo.set(r.away, ea - d);
}
return w;
}
/** Fit goalsPerElo/avgGoals/rho on the PARAM_YEARS before `fromT` (pre-fold only). */
function fitParams(rows: Row[], walk: Walk, homeAdvElo: number, fromT: number): ModelParams {
const startT = fromT - PARAM_YEARS * 365 * DAY;
let sum = 0, n = 0, sxy = 0, sxx = 0;
const calib: { d: number; h: number; a: number }[] = [];
for (let i = 0; i < rows.length; i++) {
const r = rows[i]!;
if (r.t < startT) continue;
if (r.t >= fromT) break;
const eff = walk.preH[i]! - walk.preA[i]! + (r.neutral ? 0 : homeAdvElo);
sum += r.hs + r.as; n++; sxy += eff * (r.hs - r.as); sxx += eff * eff;
calib.push({ d: eff, h: r.hs, a: r.as });
}
const avgGoals = sum / Math.max(1, n);
const goalsPerElo = sxy / Math.max(1e-9, sxx);
let rho0 = -0.05, bestLL = -Infinity;
for (let r0 = -0.2; r0 <= 0.1 + 1e-9; r0 += 0.02) {
let ll = 0;
for (const m of calib) {
const sup = goalsPerElo * m.d;
const lh = Math.min(8, Math.max(0.15, avgGoals / 2 + sup / 2));
const la = Math.min(8, Math.max(0.15, avgGoals / 2 - sup / 2));
ll += Math.log(Math.max(1e-12, poissonPmf(lh, m.h) * poissonPmf(la, m.a) * dcTau(m.h, m.a, lh, la, r0)));
}
if (ll > bestLL) { bestLL = ll; rho0 = +r0.toFixed(2); }
}
return { goalsPerElo, avgGoals, rho: rho0, homeAdvElo };
}
// ---------------------------------------------------------------------------
// Walk-forward Team-DC lambdas per fold per (ξ, k) — the expensive part, cached.
// ---------------------------------------------------------------------------
type Pred = { i: number; lDcH: number; lDcA: number };
function walkTeamDc(rows: Row[], xi: number, k: number, fromT: number, toT: number): Pred[] {
const preds: Pred[] = [];
let fit: TeamDcParams | null = null;
let fitAt = -Infinity;
const windowMs = FIT_WINDOW_YEARS * 365 * DAY;
for (let i = 0; i < rows.length; i++) {
const r = rows[i]!;
if (r.t < fromT) continue;
if (r.t >= toT) break;
if (r.t - fitAt > REFIT_DAYS * DAY) {
const train: DcMatch[] = [];
for (let j = 0; j < i; j++) {
const m = rows[j]!;
if (r.t - m.t > windowMs) continue;
train.push({
daysAgo: (r.t - m.t) / DAY, home: m.home, away: m.away,
homeGoals: m.hs, awayGoals: m.as, neutral: m.neutral,
});
}
fit = fitTeamDc(train, xi, k);
fitAt = r.t;
}
const { lambdaHome, lambdaAway } = teamDcLambdas(fit!, r.home, r.away, r.neutral);
preds.push({ i, lDcH: lambdaHome, lDcA: lambdaAway });
}
return preds;
}
// ---------------------------------------------------------------------------
// Scoring: mean RPS over one fold for a full config, sweeping w cheaply.
// ---------------------------------------------------------------------------
const W_STEPS: number[] = [];
for (let w = 0; w <= 1.0001; w += 0.05) W_STEPS.push(+w.toFixed(2));
const clampL = (x: number) => Math.min(8, Math.max(0.1, x));
interface Cfg { xi: number; k: number; w: number; m: number; beta: number; gamma: number; homeAdv: number; kScale: number }
function sweepW(
rows: Row[], preds: Pred[], walk: Walk, params: ModelParams,
beta: number, m: number, gamma: number,
): { w: number; rps: number }[] {
const acc = new Float64Array(W_STEPS.length);
const fast = walk.fast.get(m)!;
for (const p of preds) {
const r = rows[p.i]!;
const o = outcomeOf(r.hs, r.as);
const adv = r.neutral ? 0 : params.homeAdvElo;
const eh = (1 - beta) * walk.preH[p.i]! + beta * fast.h[p.i]!;
const ea = (1 - beta) * walk.preA[p.i]! + beta * fast.a[p.i]!;
let { lambdaHome: elh, lambdaAway: ela } = lambdasFromElo(eh, ea, params, adv);
let dlh = p.lDcH, dla = p.lDcA;
if (gamma > 0) {
const fh = Math.exp(gamma * walk.formH[p.i]!), fa = Math.exp(gamma * walk.formA[p.i]!);
elh = clampL(elh * fh); ela = clampL(ela * fa);
dlh = clampL(dlh * fh); dla = clampL(dla * fa);
}
const mE = scoreMatrix(elh, ela, params.rho);
const mD = scoreMatrix(dlh, dla, params.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]! / Math.max(1, preds.length) }));
}
// ---------------------------------------------------------------------------
// Isotonic regression (PAV) for the calibration experiment: monotone map from
// predicted probability to observed frequency, fit per outcome class.
// ---------------------------------------------------------------------------
interface IsoMap { x: number[]; y: number[] }
function fitIsotonic(pairs: { p: number; y: number }[]): IsoMap {
const sorted = [...pairs].sort((a, b) => a.p - b.p);
// pool-adjacent-violators on means, weighted by block size
const blocks: { sum: number; n: number; minP: number; maxP: number }[] = [];
for (const { p, y } of sorted) {
blocks.push({ sum: y, n: 1, minP: p, maxP: p });
while (blocks.length > 1) {
const b = blocks[blocks.length - 1]!;
const a = blocks[blocks.length - 2]!;
if (a.sum / a.n <= b.sum / b.n) break;
blocks.splice(blocks.length - 2, 2, { sum: a.sum + b.sum, n: a.n + b.n, minP: a.minP, maxP: b.maxP });
}
}
const x: number[] = [0];
const y: number[] = [0];
for (const b of blocks) {
x.push((b.minP + b.maxP) / 2);
y.push(b.sum / b.n);
}
x.push(1); y.push(1);
// enforce strictly increasing x for interpolation
for (let i = 1; i < x.length; i++) if (x[i]! <= x[i - 1]!) x[i] = x[i - 1]! + 1e-9;
return { x, y };
}
function applyIso(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]!;
}
function calibrate(p: Probs, maps: { h: IsoMap; d: IsoMap; a: IsoMap }): Probs {
const h = Math.max(1e-6, applyIso(maps.h, p.h));
const d = Math.max(1e-6, applyIso(maps.d, p.d));
const a = Math.max(1e-6, applyIso(maps.a, p.a));
const z = h + d + a;
return { h: h / z, d: d / z, a: a / z };
}
function eceOf(preds: { p: Probs; o: Outcome }[]): number {
const bins = Array.from({ length: 10 }, () => ({ sp: 0, sy: 0, n: 0 }));
for (const { p, o } of preds) {
for (const [v, hit] of [[p.h, o === 'h'], [p.d, o === 'd'], [p.a, o === 'a']] as [number, boolean][]) {
const i = Math.min(9, Math.floor(v * 10));
bins[i]!.sp += v; bins[i]!.sy += hit ? 1 : 0; bins[i]!.n++;
}
}
const total = preds.length * 3;
return bins.reduce((s, b) => s + (b.n ? (b.n / total) * Math.abs(b.sp / b.n - b.sy / b.n) : 0), 0);
}
function main(): void {
const t0 = Date.now();
const rows = parse();
console.log(`${rows.length} rows · ${FOLDS.length} folds · test ${TEST.from}${TEST.to}`);
const foldTs = FOLDS.map((f) => ({ fromT: new Date(f.from).getTime(), toT: new Date(f.to).getTime() }));
// Elo walks + per-fold params, per (homeAdv, kScale) — cheap, precompute all.
const walks = new Map<string, Walk>();
const foldParams = new Map<string, ModelParams[]>(); // key → params per fold
for (const ha of HOMEADV_GRID) {
for (const ks of KSCALE_GRID) {
const key = `${ha}|${ks}`;
const w = runWalk(rows, ha, ks);
walks.set(key, w);
foldParams.set(key, foldTs.map((f) => fitParams(rows, w, ha, f.fromT)));
}
}
console.log(`walks + per-fold params ready (${((Date.now() - t0) / 1000).toFixed(0)}s)`);
const baseKey = `${SHIPPED.homeAdv}|${SHIPPED.kScale}`;
const baseWalk = walks.get(baseKey)!;
const baseParams = foldParams.get(baseKey)!;
/** mean fold RPS for (xi,k) preds under a config, returning per-w means */
const dcCache = new Map<string, Pred[][]>(); // `${xi}|${k}` → preds per fold
const foldPreds = (xi: number, k: number): Pred[][] => {
const key = `${xi}|${k}`;
let v = dcCache.get(key);
if (!v) {
v = foldTs.map((f) => walkTeamDc(rows, xi, k, f.fromT, f.toT));
dcCache.set(key, v);
}
return v;
};
const meanSweep = (xi: number, k: number, beta: number, m: number, gamma: number, walk = baseWalk, params = baseParams): { w: number; rps: number }[] => {
const folds = foldPreds(xi, k);
const acc = new Float64Array(W_STEPS.length);
for (let fi = 0; fi < folds.length; fi++) {
const res = sweepW(rows, folds[fi]!, walk, params[fi]!, beta, m, gamma);
for (let wi = 0; wi < W_STEPS.length; wi++) acc[wi] += res[wi]!.rps;
}
return W_STEPS.map((w, wi) => ({ w, rps: acc[wi]! / folds.length }));
};
// ---- control: shipped config under the CV protocol ----
const shippedMean = meanSweep(SHIPPED.xi, SHIPPED.k, SHIPPED.beta, SHIPPED.m, SHIPPED.gamma)
.find((x) => x.w === SHIPPED.w)!.rps;
console.log(`shipped v4 config mean-fold rps ${shippedMean.toFixed(4)}`);
// ---- stage 1: ξ, k, w ----
let best: Cfg & { rps: number } = { ...SHIPPED, rps: Infinity };
for (const xi of XI_GRID) {
for (const k of K_GRID) {
for (const { w, rps: r } of meanSweep(xi, k, 0, 2, 0)) {
if (r < best.rps) best = { ...SHIPPED, xi, k, w, m: 2, beta: 0, gamma: 0, rps: r };
}
console.log(` s1 ξ=${xi} k=${k} (best ξ=${best.xi} k=${best.k} w=${best.w} rps=${best.rps.toFixed(4)})`);
}
}
// ---- stage 2: fast-Elo m, β ----
for (const m of M_GRID) {
for (const beta of BETA_GRID) {
for (const { w, rps: r } of meanSweep(best.xi, best.k, beta, m, 0)) {
if (r < best.rps) best = { ...best, m, beta, w, rps: r };
}
}
}
console.log(` s2 best: m=${best.m} β=${best.beta} w=${best.w} rps=${best.rps.toFixed(4)}`);
// ---- stage 3: form γ ----
for (const gamma of GAMMA_GRID) {
for (const { w, rps: r } of meanSweep(best.xi, best.k, best.beta, best.m, gamma)) {
if (r < best.rps) best = { ...best, gamma, w, rps: r };
}
}
console.log(` s3 best: γ=${best.gamma} w=${best.w} rps=${best.rps.toFixed(4)}`);
// ---- stage 4: Elo-walk axes (homeAdv, kScale) ----
for (const ha of HOMEADV_GRID) {
for (const ks of KSCALE_GRID) {
if (ha === SHIPPED.homeAdv && ks === SHIPPED.kScale && best.rps < Infinity) continue;
const key = `${ha}|${ks}`;
for (const { w, rps: r } of meanSweep(best.xi, best.k, best.beta, best.m, best.gamma, walks.get(key)!, foldParams.get(key)!)) {
if (r < best.rps) best = { ...best, homeAdv: ha, kScale: ks, w, rps: r };
}
}
}
console.log(` s4 best: homeAdv=${best.homeAdv} σ=${best.kScale} w=${best.w} rps=${best.rps.toFixed(4)}`);
const gain = shippedMean - best.rps;
const ship = gain >= MIN_GAIN;
console.log(`champion mean-fold rps ${best.rps.toFixed(4)} vs shipped ${shippedMean.toFixed(4)} → gain ${gain.toFixed(4)}${ship ? 'SHIP' : 'KEEP shipped'}`);
// ---- final test (202426), evaluated once for champion + shipped ----
const testFromT = new Date(TEST.from).getTime();
const testToT = new Date(TEST.to).getTime();
const testEval = (cfg: Cfg): number => {
const key = `${cfg.homeAdv}|${cfg.kScale}`;
const walk = walks.get(key)!;
const params = fitParams(rows, walk, cfg.homeAdv, testFromT);
const preds = walkTeamDc(rows, cfg.xi, cfg.k, testFromT, testToT);
return sweepW(rows, preds, walk, params, cfg.beta, cfg.m, cfg.gamma).find((x) => x.w === cfg.w)!.rps;
};
const champTest = testEval(best);
const shippedTest = testEval(SHIPPED);
console.log(`TEST 202426: champion rps ${champTest.toFixed(4)} | shipped rps ${shippedTest.toFixed(4)}`);
// ---- isotonic calibration experiment (leave-one-fold-out, shipped config) ----
// Bar (pre-registered): mean LOFO RPS must not worsen by >0.0002 AND mean
// LOFO ECE must improve by ≥10% relative — otherwise the layer stays out.
console.log('isotonic calibration experiment (LOFO)…');
const probsFor = (preds: Pred[]): { p: Probs; o: Outcome }[] => {
const out: { p: Probs; o: Outcome }[] = [];
for (const p of preds) {
const r = rows[p.i]!;
out.push({ p: pooledFor(p), o: outcomeOf(r.hs, r.as) });
}
return out;
};
const pooledFor = (p: Pred): Probs => {
const el = eloLambdasFor(p, SHIPPED);
const mE = scoreMatrix(el.lh, el.la, baseParamsForRow(p).rho);
const mD = scoreMatrix(p.lDcH, p.lDcA, baseParamsForRow(p).rho);
return probsOf(SHIPPED.w === 0 ? mD : SHIPPED.w === 1 ? mE : poolMatrices(mE, mD, SHIPPED.w));
};
// helpers bound to the shipped config + per-fold params
const foldOfRow = (i: number): number => {
const t = rows[i]!.t;
return foldTs.findIndex((f) => t >= f.fromT && t < f.toT);
};
const baseParamsForRow = (p: Pred): ModelParams => baseParams[Math.max(0, foldOfRow(p.i))]!;
const eloLambdasFor = (p: Pred, cfg: Cfg): { lh: number; la: number } => {
const fast = baseWalk.fast.get(cfg.m)!;
const eh = (1 - cfg.beta) * baseWalk.preH[p.i]! + cfg.beta * fast.h[p.i]!;
const ea = (1 - cfg.beta) * baseWalk.preA[p.i]! + cfg.beta * fast.a[p.i]!;
const params = baseParamsForRow(p);
const { lambdaHome, lambdaAway } = lambdasFromElo(eh, ea, params, rows[p.i]!.neutral ? 0 : params.homeAdvElo);
return { lh: lambdaHome, la: lambdaAway };
};
const foldProbSets = foldPreds(SHIPPED.xi, SHIPPED.k).map(probsFor);
let rawRps = 0, calRps = 0, rawEce = 0, calEce = 0;
for (let held = 0; held < foldProbSets.length; held++) {
const train = foldProbSets.flatMap((s, i) => (i === held ? [] : s));
const maps = {
h: fitIsotonic(train.map(({ p, o }) => ({ p: p.h, y: o === 'h' ? 1 : 0 }))),
d: fitIsotonic(train.map(({ p, o }) => ({ p: p.d, y: o === 'd' ? 1 : 0 }))),
a: fitIsotonic(train.map(({ p, o }) => ({ p: p.a, y: o === 'a' ? 1 : 0 }))),
};
const heldSet = foldProbSets[held]!;
const calSet = heldSet.map(({ p, o }) => ({ p: calibrate(p, maps), o }));
rawRps += heldSet.reduce((s, x) => s + rps(x.p, x.o), 0) / heldSet.length;
calRps += calSet.reduce((s, x) => s + rps(x.p, x.o), 0) / calSet.length;
rawEce += eceOf(heldSet);
calEce += eceOf(calSet);
}
const nF = foldProbSets.length;
rawRps /= nF; calRps /= nF; rawEce /= nF; calEce /= nF;
const isoOk = calRps <= rawRps + 0.0002 && calEce <= rawEce * 0.9;
console.log(` LOFO raw: rps ${rawRps.toFixed(4)} ece ${rawEce.toFixed(4)} | calibrated: rps ${calRps.toFixed(4)} ece ${calEce.toFixed(4)}${isoOk ? 'SHIP isotonic layer' : 'KEEP raw (bar not met)'}`);
// Final maps (fit on ALL folds) — committed via data/calibration-maps.json and
// embedded into ratings.json by buildRatings.ts when the experiment ships.
let finalMaps: { home: IsoMap; draw: IsoMap; away: IsoMap } | null = null;
if (isoOk) {
const all = foldProbSets.flat();
finalMaps = {
home: fitIsotonic(all.map(({ p, o }) => ({ p: p.h, y: o === 'h' ? 1 : 0 }))),
draw: fitIsotonic(all.map(({ p, o }) => ({ p: p.d, y: o === 'd' ? 1 : 0 }))),
away: fitIsotonic(all.map(({ p, o }) => ({ p: p.a, y: o === 'a' ? 1 : 0 }))),
};
const round = (m: IsoMap): IsoMap => ({ x: m.x.map((v) => +v.toFixed(5)), y: m.y.map((v) => +v.toFixed(5)) });
finalMaps = { home: round(finalMaps.home), draw: round(finalMaps.draw), away: round(finalMaps.away) };
writeFileSync(join(ROOT, 'data', 'calibration-maps.json'), JSON.stringify({
generatedAt: new Date().toISOString(),
fitOn: FOLDS,
config: SHIPPED,
lofo: { rawRps: +rawRps.toFixed(4), calRps: +calRps.toFixed(4), rawEce: +rawEce.toFixed(4), calEce: +calEce.toFixed(4) },
maps: finalMaps,
}, null, 2));
console.log(' final maps → data/calibration-maps.json');
}
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify({
generatedAt: new Date().toISOString(),
protocol: { folds: FOLDS, test: TEST, paramYears: PARAM_YEARS, minGain: MIN_GAIN, selection: 'mean fold RPS, staged greedy' },
shipped: { ...SHIPPED, meanFoldRps: +shippedMean.toFixed(4), testRps: +shippedTest.toFixed(4) },
champion: { ...best, rps: undefined, meanFoldRps: +best.rps.toFixed(4), testRps: +champTest.toFixed(4) },
shipChange: ship,
isotonic: {
bar: 'LOFO RPS worsens ≤0.0002 AND LOFO ECE improves ≥10%',
lofo: { rawRps: +rawRps.toFixed(4), calRps: +calRps.toFixed(4), rawEce: +rawEce.toFixed(4), calEce: +calEce.toFixed(4) },
ship: isoOk,
},
runtimeSec: Math.round((Date.now() - t0) / 1000),
}, null, 2));
console.log(`report → data/cv-report.json (${Math.round((Date.now() - t0) / 1000)}s total)`);
}
main();