add derby page
This commit is contained in:
184
src/lib/derby/model.ts
Normal file
184
src/lib/derby/model.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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('.', '"')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user