v2 Phase 3: rigorous backtest + Methodology page

- scripts/buildBacktest.ts: honest walk-forward validation — params fit on
  pre-2018 internationals, tested out-of-sample on 7,988 matches (2018-2026)
  predicting each game from prior data only. Proper scoring (Brier/log-loss/RPS/
  accuracy) vs uniform / base-rate / Elo-only baselines + a calibration analysis
  (reliability bins + ECE). Results: 60% accuracy, RPS 0.171 (beats all
  baselines), ECE 0.01 (excellent calibration).
- Methodology page (/methodology): plain-language model walkthrough, the backtest
  scorecard, a custom-SVG reliability diagram, and honest limits. Transparency is
  the differentiator — no market odds, no overclaiming.
- ReliabilityDiagram component; 'Model' nav entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:12:40 +02:00
parent 7f4838d032
commit 45c0a978fc
8 changed files with 391 additions and 3 deletions
+1
View File
@@ -16,6 +16,7 @@ public/data/fixtures.json
public/data/ratings.json 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/pwa-192x192.png public/pwa-192x192.png
public/pwa-512x512.png public/pwa-512x512.png
public/favicon.svg public/favicon.svg
+2 -1
View File
@@ -17,9 +17,10 @@
"data:fixtures": "bun scripts/buildFixtures.ts", "data:fixtures": "bun scripts/buildFixtures.ts",
"data:ratings": "bun scripts/buildRatings.ts", "data:ratings": "bun scripts/buildRatings.ts",
"data:preview": "bun scripts/buildPreviewData.ts", "data:preview": "bun scripts/buildPreviewData.ts",
"data:backtest": "bun scripts/buildBacktest.ts",
"data:viz": "bun scripts/buildStatsbombViz.ts", "data:viz": "bun scripts/buildStatsbombViz.ts",
"data:icons": "bun scripts/make-icons.mjs", "data:icons": "bun scripts/make-icons.mjs",
"data:build": "bun run data:icons && bun run data:fixtures && bun run data:ratings && bun run data:preview" "data:build": "bun run data:icons && bun run data:fixtures && bun run data:ratings && bun run data:preview && bun run data:backtest"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.95.0", "@tanstack/react-router": "^1.95.0",
+199
View File
@@ -0,0 +1,199 @@
// Walk-forward backtest of the Elo + Dixon-Coles model on historical
// internationals, with an honest train/test split (params fit on pre-2018,
// tested out-of-sample on 2018→now). Outputs proper scoring metrics (Brier,
// log-loss, RPS, accuracy), baseline comparisons, and a calibration/reliability
// analysis → public/data/backtest.json, shown on the Methodology page.
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 { HOME_ADV_ELO, importanceWeight, eloDelta } from '../src/lib/model/elo';
import { lambdasFromElo, scoreMatrix, outcomeProbs, poissonPmf, type ModelParams } from '../src/lib/model/poisson';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CSV = join(ROOT, 'data', 'raw', 'international-results.csv');
const OUT_DIR = join(ROOT, 'public', 'data');
const START_ELO = 1500;
const PARAM_FROM = '2006-01-01';
const TRAIN_END = '2018-01-01'; // params fit on [PARAM_FROM, TRAIN_END)
const TEST_TO = '2026-06-01'; // exclude the 2026 World Cup itself
interface Row { date: string; home: string; away: string; hs: number; as: number; tournament: string; neutral: boolean }
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: c[1]!, away: c[2]!, hs, as, tournament: c[5]!, neutral: c[8]!.trim().toUpperCase() === 'TRUE' });
}
rows.sort((a, b) => a.date.localeCompare(b.date));
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');
// ---- scoring ----
function brier(p: Probs, o: Outcome): number {
return (p.h - (o === 'h' ? 1 : 0)) ** 2 + (p.d - (o === 'd' ? 1 : 0)) ** 2 + (p.a - (o === 'a' ? 1 : 0)) ** 2;
}
function logloss(p: Probs, o: Outcome): number {
return -Math.log(Math.max(1e-12, p[o]));
}
function rps(p: Probs, o: Outcome): number {
// ordered H, D, A
const y = { h: o === 'h' ? 1 : 0, d: o === 'd' ? 1 : 0, a: o === 'a' ? 1 : 0 };
const c1p = p.h, c1y = y.h;
const c2p = p.h + p.d, c2y = y.h + y.d;
return ((c1p - c1y) ** 2 + (c2p - c2y) ** 2) / 2;
}
const argmax = (p: Probs): Outcome => (p.h >= p.d && p.h >= p.a ? 'h' : p.d >= p.a ? 'd' : 'a');
function scoreSet(preds: { p: Probs; o: Outcome }[]) {
let b = 0, l = 0, r = 0, correct = 0;
for (const { p, o } of preds) { b += brier(p, o); l += logloss(p, o); r += rps(p, o); if (argmax(p) === o) correct++; }
const n = preds.length;
return { brier: b / n, logloss: l / n, rps: r / n, accuracy: correct / n };
}
function main(): void {
const rows = parse();
const elo = new Map<string, number>();
const get = (t: string) => elo.get(t) ?? START_ELO;
// ---- fit params on the training window ----
let sumTotal = 0, nCal = 0, sxy = 0, sxx = 0;
const calib: { d: number; h: number; a: number }[] = [];
// we need a first pass to fit params using pre-match Elo; do it inline while walking
// (Elo is updated every match; calibration accumulates only within the train window)
const testPreds: { p: Probs; o: Outcome }[] = [];
const baseUniform: { p: Probs; o: Outcome }[] = [];
const baseRate: { p: Probs; o: Outcome }[] = [];
const baseElo: { p: Probs; o: Outcome }[] = [];
const reliabilityRaw: { p: number; y: number }[] = [];
let wcCorrect = 0, wcN = 0;
// outcome base rates over the test window (computed in a quick pre-scan)
let th = 0, td = 0, ta = 0, tn = 0;
for (const r of rows) {
if (r.date < TRAIN_END || r.date >= TEST_TO) continue;
const o = outcomeOf(r.hs, r.as); if (o === 'h') th++; else if (o === 'd') td++; else ta++; tn++;
}
const rate: Probs = { h: th / tn, d: td / tn, a: ta / tn };
// params get finalized at TRAIN_END; predictions before that use a rolling fit.
let params: ModelParams = { goalsPerElo: 0.0057, avgGoals: 2.7, rho: -0.05, homeAdvElo: HOME_ADV_ELO };
let paramsFixed = false;
const finalizeParams = (): ModelParams => {
const avgGoals = sumTotal / Math.max(1, nCal);
const goalsPerElo = sxy / Math.max(1e-9, sxx);
let bestRho = -0.05, bestLL = -Infinity;
for (let rho = -0.2; rho <= 0.1 + 1e-9; rho += 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, rho)));
}
if (ll > bestLL) { bestLL = ll; bestRho = rho; }
}
return { goalsPerElo, avgGoals, rho: Math.round(bestRho * 100) / 100, homeAdvElo: HOME_ADV_ELO };
};
const predict = (eh: number, ea: number, homeAdv: number): Probs => {
const { lambdaHome, lambdaAway } = lambdasFromElo(eh, ea, params, homeAdv);
const p = outcomeProbs(scoreMatrix(lambdaHome, lambdaAway, params.rho));
return { h: p.home, d: p.draw, a: p.away };
};
for (const r of rows) {
if (!paramsFixed && r.date >= TRAIN_END) { params = finalizeParams(); paramsFixed = true; }
const home = canonicalTeam(r.home), away = canonicalTeam(r.away);
const eh = get(home), ea = get(away);
const homeAdv = r.neutral ? 0 : HOME_ADV_ELO;
const o = outcomeOf(r.hs, r.as);
// accumulate calibration only in the param-fitting window
if (r.date >= PARAM_FROM && r.date < TRAIN_END) {
const effDiff = eh - ea + homeAdv;
sumTotal += r.hs + r.as; nCal++; sxy += effDiff * (r.hs - r.as); sxx += effDiff * effDiff;
calib.push({ d: effDiff, h: r.hs, a: r.as });
}
// score out-of-sample
if (r.date >= TRAIN_END && r.date < TEST_TO) {
const p = predict(eh, ea, homeAdv);
testPreds.push({ p, o });
baseUniform.push({ p: { h: 1 / 3, d: 1 / 3, a: 1 / 3 }, o });
baseRate.push({ p: rate, o });
// elo-only: expected score → W/A split, fixed draw rate
const e = 1 / (1 + 10 ** ((ea - eh - homeAdv) / 400));
baseElo.push({ p: { h: e * (1 - rate.d), d: rate.d, a: (1 - e) * (1 - rate.d) }, o });
reliabilityRaw.push({ p: p.h, y: o === 'h' ? 1 : 0 }, { p: p.d, y: o === 'd' ? 1 : 0 }, { p: p.a, y: o === 'a' ? 1 : 0 });
if (r.tournament.includes('FIFA World Cup') && !r.tournament.includes('qualification')) {
wcN++; if (argmax(p) === o) wcCorrect++;
}
}
// update Elo (always)
const k = importanceWeight(r.tournament);
const d = eloDelta(r.hs, r.as, eh, ea, k, homeAdv);
elo.set(home, eh + d); elo.set(away, ea - d);
}
// reliability bins (10) + ECE
const bins = Array.from({ length: 10 }, () => ({ sp: 0, sy: 0, n: 0 }));
for (const { p, y } of reliabilityRaw) {
const idx = Math.min(9, Math.floor(p * 10));
bins[idx]!.sp += p; bins[idx]!.sy += y; bins[idx]!.n++;
}
const reliability = bins.map((b) => ({ predicted: b.n ? b.sp / b.n : 0, observed: b.n ? b.sy / b.n : 0, count: b.n }));
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);
const out = {
generatedAt: new Date().toISOString(),
paramFrom: PARAM_FROM,
trainEnd: TRAIN_END,
testTo: TEST_TO,
tested: testPreds.length,
params,
model: scoreSet(testPreds),
baselines: {
uniform: scoreSet(baseUniform),
baseRate: scoreSet(baseRate),
eloOnly: scoreSet(baseElo),
},
reliability,
ece: Math.round(ece * 1000) / 1000,
worldCup: { tested: wcN, accuracy: wcN ? wcCorrect / wcN : 0 },
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'backtest.json'), JSON.stringify(out));
console.log(`backtest → public/data/backtest.json (${out.tested} test matches, ${TRAIN_END}${TEST_TO})`);
console.log(` model: brier ${out.model.brier.toFixed(3)} logloss ${out.model.logloss.toFixed(3)} rps ${out.model.rps.toFixed(3)} acc ${(out.model.accuracy * 100).toFixed(1)}%`);
console.log(` uniform: brier ${out.baselines.uniform.brier.toFixed(3)} logloss ${out.baselines.uniform.logloss.toFixed(3)} rps ${out.baselines.uniform.rps.toFixed(3)}`);
console.log(` baseRate:brier ${out.baselines.baseRate.brier.toFixed(3)} rps ${out.baselines.baseRate.rps.toFixed(3)} | eloOnly rps ${out.baselines.eloOnly.rps.toFixed(3)}`);
console.log(` ECE ${out.ece} | WC accuracy ${(out.worldCup.accuracy * 100).toFixed(1)}% (${out.worldCup.tested})`);
}
main();
+2 -1
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Link, Outlet } from '@tanstack/react-router'; import { Link, Outlet } from '@tanstack/react-router';
import { GitMerge, Moon, Radio, Sparkles, Sun, Table, TrendingUp, Trophy } from 'lucide-react'; import { Gauge, GitMerge, Moon, Radio, Sparkles, Sun, Table, TrendingUp, Trophy } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
@@ -11,6 +11,7 @@ const NAV: { to: string; label: string; exact: boolean; icon: LucideIcon }[] = [
{ to: '/groups', label: 'Groups', exact: false, icon: Table }, { to: '/groups', label: 'Groups', exact: false, icon: Table },
{ to: '/bracket', label: 'Bracket', exact: false, icon: GitMerge }, { to: '/bracket', label: 'Bracket', exact: false, icon: GitMerge },
{ to: '/predict', label: 'Predict', exact: false, icon: TrendingUp }, { to: '/predict', label: 'Predict', exact: false, icon: TrendingUp },
{ to: '/methodology', label: 'Model', exact: false, icon: Gauge },
{ to: '/story', label: 'Story', exact: false, icon: Sparkles }, { to: '/story', label: 'Story', exact: false, icon: Sparkles },
]; ];
+49
View File
@@ -0,0 +1,49 @@
import { scaleLinear } from 'd3-scale';
import type { BacktestReport } from '@/lib/types';
const W = 360;
const H = 360;
const M = 34;
/** Calibration plot: predicted probability (x) vs observed frequency (y). Points
* on the diagonal = perfectly calibrated. Dot size ∝ sample count. */
export function ReliabilityDiagram({ reliability, ece }: { reliability: BacktestReport['reliability']; ece: number }) {
const sx = scaleLinear().domain([0, 1]).range([M, W - 8]);
const sy = scaleLinear().domain([0, 1]).range([H - M, 8]);
const maxCount = Math.max(...reliability.map((b) => b.count), 1);
const pts = reliability.filter((b) => b.count > 0);
return (
<div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full max-w-sm">
{/* axes */}
<line x1={M} y1={H - M} x2={W - 8} y2={H - M} stroke="var(--app-line-strong)" strokeWidth={0.8} />
<line x1={M} y1={H - M} x2={M} y2={8} stroke="var(--app-line-strong)" strokeWidth={0.8} />
{/* perfect-calibration diagonal */}
<line x1={sx(0)} y1={sy(0)} x2={sx(1)} y2={sy(1)} stroke="var(--app-line)" strokeDasharray="4 4" strokeWidth={1} />
{[0.25, 0.5, 0.75, 1].map((t) => (
<g key={t}>
<text x={sx(t)} y={H - M + 14} textAnchor="middle" fontSize="10" fill="var(--app-faint)">{t}</text>
<text x={M - 6} y={sy(t) + 3} textAnchor="end" fontSize="10" fill="var(--app-faint)">{t}</text>
</g>
))}
{/* model points + connecting line */}
<polyline
points={pts.map((b) => `${sx(b.predicted)},${sy(b.observed)}`).join(' ')}
fill="none" stroke="var(--app-accent)" strokeWidth={1.5} opacity={0.5}
/>
{pts.map((b, i) => (
<circle key={i} cx={sx(b.predicted)} cy={sy(b.observed)} r={2 + 5 * Math.sqrt(b.count / maxCount)} fill="var(--app-accent)" fillOpacity={0.85}>
<title>{`predicted ${(b.predicted * 100).toFixed(0)}% → happened ${(b.observed * 100).toFixed(0)}% (${b.count})`}</title>
</circle>
))}
<text x={(W + M) / 2} y={H - 4} textAnchor="middle" fontSize="11" fill="var(--app-muted)">predicted probability</text>
<text x={12} y={H / 2} textAnchor="middle" fontSize="11" fill="var(--app-muted)" transform={`rotate(-90 12 ${H / 2})`}>actually happened</text>
</svg>
<p className="mt-1 text-sm text-muted">
Points hug the diagonal when the model says <span className="font-semibold text-ink">30%</span>, it happens about 30% of the time.
Calibration error (ECE) is just <span className="font-bold text-accent">{ece.toFixed(2)}</span> (lower is better; under 0.05 is excellent).
</p>
</div>
);
}
@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { Activity, Dices, Gauge, TrendingUp } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { ReliabilityDiagram } from '@/components/ReliabilityDiagram';
import type { BacktestReport } from '@/lib/types';
const STEPS = [
{ icon: TrendingUp, title: 'Elo ratings', body: '150 years of international results (49,000 matches) build a strength rating for every nation, updated after each game and weighted by match importance.' },
{ icon: Activity, title: 'Expected goals', body: "Each team's goal expectation comes from the rating gap, calibrated on how Elo differences have actually translated into goals — plus host home advantage." },
{ icon: Gauge, title: 'Dixon-Coles', body: 'A bivariate-Poisson scoreline model (with the Dixon-Coles low-score correction) turns those goal expectations into the full distribution of scorelines and win/draw/loss.' },
{ icon: Dices, title: 'Monte Carlo', body: 'The whole 48-team tournament is simulated 20,000 times — sampling every remaining match — to produce championship and advancement odds that update after each result.' },
];
function RpsBar({ label, rps, best, highlight }: { label: string; rps: number; best: number; highlight?: boolean }) {
// shorter bar = better (lower RPS). Scale relative to the worst (uniform ~0.24).
const width = Math.min(100, (rps / 0.26) * 100);
return (
<div className="flex items-center gap-3 text-sm">
<span className={`w-28 shrink-0 ${highlight ? 'font-bold text-ink' : 'text-muted'}`}>{label}</span>
<div className="h-4 flex-1 overflow-hidden rounded bg-elevated">
<div className={`h-full rounded ${highlight ? 'bg-gradient-to-r from-accent-deep to-accent' : 'bg-line-strong'}`} style={{ width: `${width}%` }} />
</div>
<span className={`tnum w-12 text-right ${highlight ? 'font-bold text-accent' : 'text-faint'}`}>{rps.toFixed(3)}</span>
{rps === best && <span className="text-[10px] font-bold uppercase text-accent">best</span>}
</div>
);
}
function Stat({ value, label }: { value: string; label: string }) {
return (
<div className="text-center">
<div className="font-display text-2xl font-extrabold text-ink">{value}</div>
<div className="text-[11px] uppercase tracking-wide text-faint">{label}</div>
</div>
);
}
export function MethodologyPage() {
const [bt, setBt] = useState<BacktestReport | null>(null);
useEffect(() => {
let alive = true;
fetch('/data/backtest.json').then((r) => r.json()).then((d: BacktestReport) => alive && setBt(d)).catch(() => {});
return () => { alive = false; };
}, []);
const bestRps = bt ? Math.min(bt.model.rps, bt.baselines.uniform.rps, bt.baselines.baseRate.rps, bt.baselines.eloOnly.rps) : 0;
return (
<div className="space-y-6">
<PageHeader title="Methodology" subtitle="How the model works — and, measured honestly, how good it actually is." />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{STEPS.map((s, i) => (
<Card key={s.title}>
<CardBody>
<div className="mb-2 flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-md bg-accent-glow text-accent"><s.icon size={16} /></span>
<span className="text-xs font-bold text-faint">STEP {i + 1}</span>
</div>
<h3 className="font-display font-bold text-ink">{s.title}</h3>
<p className="mt-1 text-sm text-muted">{s.body}</p>
</CardBody>
</Card>
))}
</div>
<Card>
<CardHeader><span className="font-display font-bold text-ink">How good is it? (out-of-sample backtest)</span></CardHeader>
<CardBody className="space-y-5">
{bt ? (
<>
<p className="text-sm text-muted">
Model parameters were fit on internationals before {bt.trainEnd.slice(0, 4)}, then tested <span className="font-semibold text-ink">walk-forward</span> on{' '}
<span className="font-semibold text-ink">{bt.tested.toLocaleString()}</span> matches it had never seen ({bt.trainEnd.slice(0, 4)}{bt.testTo.slice(0, 4)}) predicting each game using only prior data.
</p>
<div className="grid grid-cols-3 gap-3 rounded-lg border border-line bg-surface-2 py-3">
<Stat value={`${(bt.model.accuracy * 100).toFixed(0)}%`} label="outcome accuracy" />
<Stat value={bt.model.rps.toFixed(3)} label="ranked prob. score" />
<Stat value={`${(bt.worldCup.accuracy * 100).toFixed(0)}%`} label={`World Cup acc. (${bt.worldCup.tested})`} />
</div>
<div>
<div className="mb-2 text-xs uppercase tracking-wide text-faint">Ranked Probability Score lower is better, vs baselines</div>
<div className="space-y-1.5">
<RpsBar label="This model" rps={bt.model.rps} best={bestRps} highlight />
<RpsBar label="Elo only" rps={bt.baselines.eloOnly.rps} best={bestRps} />
<RpsBar label="Base rates" rps={bt.baselines.baseRate.rps} best={bestRps} />
<RpsBar label="Coin flip" rps={bt.baselines.uniform.rps} best={bestRps} />
</div>
</div>
</>
) : (
<div className="h-32 animate-pulse rounded bg-elevated" />
)}
</CardBody>
</Card>
<Card>
<CardHeader><span className="font-display font-bold text-ink">Is it calibrated?</span></CardHeader>
<CardBody>
{bt ? <ReliabilityDiagram reliability={bt.reliability} ece={bt.ece} /> : <div className="h-64 animate-pulse rounded bg-elevated" />}
</CardBody>
</Card>
<Card>
<CardHeader><span className="font-display font-bold text-ink">Honest limits</span></CardHeader>
<CardBody>
<ul className="space-y-2 text-sm text-muted">
<li> These are <span className="font-semibold text-ink">model probabilities, not betting advice</span>. We don't use bookmaker odds, so we make no claim to beat the market sharp betting markets remain the most accurate forecaster there is.</li>
<li> International expected-goals and event data are sparse, so per-match tactical breakdowns are richer for some teams than others, and improve as live data accumulates during the tournament.</li>
<li> Football is high-variance: a 60% favourite still loses plenty. Calibration means our 60% really is ~60% not that the favourite always wins.</li>
<li> Everything is transparent and reproducible from public data (results, ESPN, StatsBomb) that openness is the point.</li>
</ul>
</CardBody>
</Card>
</div>
);
}
+16
View File
@@ -160,6 +160,22 @@ export interface TeamProfile {
qualifyOdds: number | null; qualifyOdds: number | null;
} }
// ---- model backtest (Methodology page) ----
export interface BacktestScores { brier: number; logloss: number; rps: number; accuracy: number }
export interface BacktestReport {
generatedAt: string;
trainEnd: string;
testTo: string;
tested: number;
params: { goalsPerElo: number; avgGoals: number; rho: number; homeAdvElo: number };
model: BacktestScores;
baselines: { uniform: BacktestScores; baseRate: BacktestScores; eloOnly: BacktestScores };
reliability: { predicted: number; observed: number; count: number }[];
ece: number;
worldCup: { tested: number; accuracy: number };
}
// ---- data-story viz (StatsBomb open data, a fixed historical match) ---- // ---- data-story viz (StatsBomb open data, a fixed historical match) ----
export interface VizShot { export interface VizShot {
+3 -1
View File
@@ -8,6 +8,7 @@ import { PredictionsPage } from './features/predictions/PredictionsPage';
import { StoryPage } from './features/story/StoryPage'; import { StoryPage } from './features/story/StoryPage';
import { MatchPreviewPage } from './features/match/MatchPreviewPage'; import { MatchPreviewPage } from './features/match/MatchPreviewPage';
import { TeamProfilePage } from './features/team/TeamProfilePage'; import { TeamProfilePage } from './features/team/TeamProfilePage';
import { MethodologyPage } from './features/methodology/MethodologyPage';
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: RootLayout, component: RootLayout,
@@ -21,8 +22,9 @@ const predictRoute = createRoute({ getParentRoute: () => rootRoute, path: '/pred
const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: StoryPage }); const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: StoryPage });
const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage }); const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage });
const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage }); const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage });
const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: MethodologyPage });
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute]); const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' }); export const router = createRouter({ routeTree, defaultPreload: 'intent' });