v3: Model-vs-Market scoreboard — frozen forecasts, fair scoring, live page
- prediction_snapshots: model + de-vigged market frozen ≤15min pre-kickoff (or at first sight of a live match, flagged 'late' and excluded from totals). - src/lib/model/scoring.ts: ONE shared RPS/Brier/log-loss implementation for the backtest and the live scoreboard. - /api/scoreboard + /scoreboard page: per-match model-vs-bookmaker bars, RPS for both after FT, 'closer' badges, running head-to-head with honest rules printed (small-sample caveat included). Nav: 'vs Market'. - Methodology page now shows the three-way split + ensemble bake-off variants. - Verified end-to-end with a simulated match: snapshot froze model 80.7% and DraftKings 67.0% (de-vig correct), both scored at FT, late-flag honored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
// Proper scoring rules — ONE implementation shared by the offline backtest and
|
||||
// the live Model-vs-Market scoreboard, so the numbers are always comparable.
|
||||
|
||||
import type { MatchProbs } from '../types';
|
||||
|
||||
export type Outcome3 = 'home' | 'draw' | 'away';
|
||||
|
||||
export const outcomeOfScore = (hs: number, as: number): Outcome3 =>
|
||||
hs > as ? 'home' : hs < as ? 'away' : 'draw';
|
||||
|
||||
/** Multiclass Brier (sum of squared errors over the 3 outcomes; 0 best, 2 worst). */
|
||||
export function brierScore(p: MatchProbs, o: Outcome3): number {
|
||||
return (
|
||||
(p.home - (o === 'home' ? 1 : 0)) ** 2 +
|
||||
(p.draw - (o === 'draw' ? 1 : 0)) ** 2 +
|
||||
(p.away - (o === 'away' ? 1 : 0)) ** 2
|
||||
);
|
||||
}
|
||||
|
||||
/** Ranked Probability Score for the ordered outcomes home > draw > away (0 best). */
|
||||
export function rpsScore(p: MatchProbs, o: Outcome3): number {
|
||||
const y1 = o === 'home' ? 1 : 0;
|
||||
const y2 = o === 'away' ? 0 : 1; // cumulative: home+draw
|
||||
return ((p.home - y1) ** 2 + (p.home + p.draw - y2) ** 2) / 2;
|
||||
}
|
||||
|
||||
export function logLoss(p: MatchProbs, o: Outcome3): number {
|
||||
return -Math.log(Math.max(1e-12, p[o]));
|
||||
}
|
||||
@@ -165,16 +165,56 @@ export interface TeamProfile {
|
||||
qualifyOdds: number | null;
|
||||
}
|
||||
|
||||
// ---- Model-vs-Market scoreboard ----
|
||||
|
||||
export interface MarketSnapshot {
|
||||
provider: string;
|
||||
probs: MatchProbs; // de-vigged
|
||||
homeMl: number;
|
||||
drawMl: number;
|
||||
awayMl: number;
|
||||
}
|
||||
|
||||
export interface ScoreboardRow {
|
||||
num: number;
|
||||
home: string;
|
||||
away: string;
|
||||
group: string | null;
|
||||
kickoff: string;
|
||||
status: MatchStatus;
|
||||
homeScore: number | null;
|
||||
awayScore: number | null;
|
||||
takenAt: number;
|
||||
/** Snapshot was taken after kickoff — excluded from the fair totals. */
|
||||
late: boolean;
|
||||
model: MatchProbs;
|
||||
market: MarketSnapshot | null;
|
||||
/** Scores, present once the match finished (regulation result). */
|
||||
scored: { outcome: 'home' | 'draw' | 'away'; modelRps: number; marketRps: number | null; modelBrier: number; marketBrier: number | null } | null;
|
||||
}
|
||||
|
||||
export interface ScoreboardData {
|
||||
rows: ScoreboardRow[];
|
||||
/** Aggregates over finished, fair (pre-kickoff, with-market) matches. */
|
||||
totals: { n: number; modelRps: number; marketRps: number; modelBrier: number; marketBrier: number; modelCloser: number; marketCloser: number } | null;
|
||||
}
|
||||
|
||||
// ---- model backtest (Methodology page) ----
|
||||
|
||||
export interface BacktestScores { brier: number; logloss: number; rps: number; accuracy: number }
|
||||
export interface BacktestReport {
|
||||
generatedAt: string;
|
||||
trainEnd: string;
|
||||
/** Test window start (validation ends here). */
|
||||
testFrom?: string;
|
||||
testTo: string;
|
||||
tested: number;
|
||||
params: { goalsPerElo: number; avgGoals: number; rho: number; homeAdvElo: number };
|
||||
/** Hyper-params tuned on the validation window (never on test). */
|
||||
validation?: { from: string; to: string; tuned: { xi: number; shrinkK: number; ensembleW: number } };
|
||||
model: BacktestScores;
|
||||
/** Bake-off: every candidate scored on the same untouched test window. */
|
||||
variants?: ({ name: string } & BacktestScores)[];
|
||||
baselines: { uniform: BacktestScores; baseRate: BacktestScores; eloOnly: BacktestScores };
|
||||
reliability: { predicted: number; observed: number; count: number }[];
|
||||
ece: number;
|
||||
|
||||
Reference in New Issue
Block a user