Files
personal-website/src/lib/derby/model.ts
2026-09-20 02:23:12 +00:00

185 lines
6.3 KiB
TypeScript
Raw 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.

import type { Horse, Result } from './data';
export type Weights = {
form: number;
trial: number;
record: number;
draw: number;
market: number;
risk: number;
/** halveringstijd van de vormweging, in koersen */
halfLife: number;
/** seconden die de (langzamere) merrieproef wordt bestraft t.o.v. heat 1 */
heatGap: number;
/** softmax-temperatuur: >1 vlakker, <1 scherper */
temperature: number;
/** Stern-demping voor plaatskansen: lagere posities gebruiken p^lambda */
lambda: number;
};
export const defaultWeights: Weights = {
form: 1,
trial: 1,
record: 0.6,
draw: 0.4,
market: 0.6,
risk: 1,
halfLife: 2,
heatGap: 0.8,
temperature: 1,
lambda: 0.8
};
export const presets: Record<string, Partial<Weights>> = {
Standaard: {},
Proef: { form: 0.5, trial: 1.6, record: 0.3, draw: 0.4, market: 0 },
Vorm: { form: 1.6, trial: 0.5, record: 0.5, draw: 0.3, market: 0 },
Markt: { form: 0.3, trial: 0.3, record: 0.3, draw: 0.2, market: 1.6 }
};
export const featureKeys = ['form', 'trial', 'record', 'draw', 'market'] as const;
export type FeatureKey = (typeof featureKeys)[number];
/** logit-schaal: z-scores × gewichten worden hiermee vermenigvuldigd */
const SCALE = 0.55;
const POSITION_POINTS: Record<number, number> = { 1: 1, 2: 0.75, 3: 0.6, 4: 0.45, 5: 0.35, 6: 0.25 };
const positionPoints = (r: Result) => (r === 'D' ? 0 : (POSITION_POINTS[r] ?? 0.1));
/** binnenste startnummers zijn goud, 9 en 10 staan in de tweede rij */
const DRAW_SCORE = [0, 0.6, 0.7, 0.6, 0.45, 0.4, 0.25, 0.1, 0, -0.2, -0.3];
export type Features = {
form: number;
trial: number;
record: number;
draw: number;
market: number;
pFail: number;
recordEstimated: boolean;
};
export function features(h: Horse, all: Horse[], w: Weights): Features {
const decay = h.form.map((_, i) => Math.pow(0.5, i / w.halfLife));
const form =
h.form.reduce((sum: number, r, i) => sum + decay[i] * positionPoints(r), 0) /
decay.reduce((a, b) => a + b, 0);
const heatWinner = Math.min(...all.filter((o) => o.trial.heat === h.trial.heat).map((o) => o.trial.time));
const trial = -(h.trial.time - heatWinner) - (h.trial.heat === 2 ? w.heatGap : 0);
const known = all.filter((o) => o.record !== null).map((o) => o.record as number);
const recordEstimated = h.record === null;
const record = -(h.record ?? known.reduce((a, b) => a + b, 0) / known.length);
const disq = h.form.filter((r) => r === 'D').length;
const poor = h.form.filter((r) => r !== 'D' && r >= 7).length;
const pFail = Math.min(0.5, 0.03 + 0.1 * disq + 0.03 * poor);
return { form, trial, record, draw: DRAW_SCORE[h.n], market: -Math.log(h.trial.odds), pFail, recordEstimated };
}
function zscores(values: number[]): number[] {
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const sd = Math.sqrt(values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length);
return values.map((v) => (sd === 0 ? 0 : (v - mean) / sd));
}
export type Prediction = {
horse: Horse;
features: Features;
/** bijdrage per feature aan de logit (gewicht × z × schaal) */
contributions: Record<FeatureKey, number>;
riskPenalty: number;
logit: number;
p: number;
};
export function predict(runners: Horse[], w: Weights): Prediction[] {
if (runners.length === 0) return [];
const feats = runners.map((h) => features(h, runners, w));
const z = Object.fromEntries(featureKeys.map((k) => [k, zscores(feats.map((f) => f[k]))])) as Record<FeatureKey, number[]>;
const rows = runners.map((horse, i) => {
const contributions = Object.fromEntries(
featureKeys.map((k) => [k, w[k] * z[k][i] * SCALE])
) as Record<FeatureKey, number>;
const riskPenalty = w.risk * Math.log(1 - feats[i].pFail);
const logit = Object.values(contributions).reduce((a, b) => a + b, 0) + riskPenalty;
return { horse, features: feats[i], contributions, riskPenalty, logit, p: 0 };
});
const max = Math.max(...rows.map((r) => r.logit));
const exp = rows.map((r) => Math.exp((r.logit - max) / w.temperature));
const total = exp.reduce((a, b) => a + b, 0);
rows.forEach((r, i) => (r.p = exp[i] / total));
return rows.sort((a, b) => b.p - a.p);
}
export type Placings = {
/** kans op 1e, 2e, 3e per paard (index = positie in `preds`) */
top: { win: number; place2: number; place3: number }[];
exacta: { a: number; b: number; p: number }[];
koppel: { a: number; b: number; p: number }[];
trio: { a: number; b: number; c: number; p: number }[];
};
/**
* Exacte Plackett–Luce-uitwerking over alle geordende drietallen, met
* Stern-demping: voor de 2e en 3e plaats wordt p^lambda gebruikt, wat de
* neiging van pure PL corrigeert om de favoriet te vaak tweede te laten worden.
*/
export function placings(preds: Prediction[], lambda: number): Placings {
const n = preds.length;
const p1 = preds.map((r) => r.p);
const p2 = p1.map((p) => Math.pow(p, lambda));
const s2 = p2.reduce((a, b) => a + b, 0);
const top = preds.map(() => ({ win: 0, place2: 0, place3: 0 }));
const exacta: Placings['exacta'] = [];
const trio: Placings['trio'] = [];
for (let a = 0; a < n; a++) {
top[a].win = p1[a];
for (let b = 0; b < n; b++) {
if (b === a) continue;
const pab = p1[a] * (p2[b] / (s2 - p2[a]));
exacta.push({ a, b, p: pab });
top[b].place2 += pab;
for (let c = 0; c < n; c++) {
if (c === a || c === b) continue;
const pabc = pab * (p2[c] / (s2 - p2[a] - p2[b]));
trio.push({ a, b, c, p: pabc });
top[c].place3 += pabc;
}
}
}
const koppelMap = new Map<string, { a: number; b: number; p: number }>();
for (const e of exacta) {
const [a, b] = e.a < e.b ? [e.a, e.b] : [e.b, e.a];
const key = `${a}-${b}`;
const cur = koppelMap.get(key) ?? { a, b, p: 0 };
cur.p += e.p;
koppelMap.set(key, cur);
}
const desc = <T extends { p: number }>(xs: T[]) => xs.sort((x, y) => y.p - x.p);
return { top, exacta: desc(exacta), koppel: desc([...koppelMap.values()]), trio: desc(trio) };
}
/** verwachte waarde per euro en Kelly-fractie voor een winweddenschap */
export function betValue(p: number, odds: number) {
const edge = p * odds - 1;
const kelly = odds > 1 ? Math.max(0, edge / (odds - 1)) : 0;
return { edge, kelly };
}
export const fairOdds = (p: number) => (p > 0 ? 1 / p : Infinity);
export function formatTime(seconds: number) {
const m = Math.floor(seconds / 60);
const s = seconds - m * 60;
return `${m}'${s.toFixed(1).padStart(4, '0').replace('.', '"')}`;
}