// Algemeen kansmodel voor de overige koersen: zelfde opzet als het Derby-model // (z-scores × gewichten → softmax), maar zonder proef- en marktsignaal. // Signalen: uitslagen, snelheid (beste recente km-tijd), niveau (prijzengeld // van de laatste starts), startpositie en uitvalrisico. import type { Result } from './data'; import type { Race, Runner } from './races'; export type GWeights = { form: number; speed: number; level: number; draw: number; risk: number; halfLife: number; temperature: number; lambda: number; }; export const gFeatureKeys = ['form', 'speed', 'level', 'draw'] as const; export type GFeatureKey = (typeof gFeatureKeys)[number]; export const gDefaults: GWeights = { form: 1, speed: 1, level: 0.6, draw: 0.4, risk: 1, halfLife: 2, temperature: 1, lambda: 0.8 }; /** rensport: geen tijden, startbox minder belangrijk over 1900 m */ export const defaultsFor = (race: Race): GWeights => race.discipline === 'ren' ? { ...gDefaults, speed: 0, draw: 0.2, level: 0.8 } : { ...gDefaults }; export const gPresets: Record> = { Standaard: {}, Vorm: { form: 1.6, speed: 0.5, level: 0.4 }, Snelheid: { form: 0.5, speed: 1.6, level: 0.4 }, Niveau: { form: 0.6, speed: 0.6, level: 1.5 } }; const SCALE = 0.55; const POSITION_POINTS: Record = { 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)); 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 GFeatures = { form: number; speed: number; level: number; draw: number; pFail: number; /** welke signalen ontbraken en het veldgemiddelde kregen */ estimated: GFeatureKey[]; }; const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length; function rawForm(h: Runner, w: GWeights): number | null { if (h.form.length === 0) return null; const decay = h.form.map((_, i) => Math.pow(0.5, i / w.halfLife)); return ( h.form.reduce((s: number, r, i) => s + decay[i] * positionPoints(r), 0) / decay.reduce((a, b) => a + b, 0) ); } export function gFeatures(runners: Runner[], w: GWeights): GFeatures[] { const raw = runners.map((h) => ({ form: rawForm(h, w), speed: h.times.length ? -Math.min(...h.times) : null, level: h.purses.length ? mean(h.purses.map((p) => Math.log(p))) : null })); const fill = (k: 'form' | 'speed' | 'level') => { const known = raw.map((r) => r[k]).filter((v): v is number => v !== null); return known.length ? mean(known) : 0; }; const avg = { form: fill('form'), speed: fill('speed'), level: fill('level') }; return runners.map((h, i) => { const estimated: GFeatureKey[] = []; const pick = (k: 'form' | 'speed' | 'level') => { const v = raw[i][k]; if (v === null) estimated.push(k); return v ?? avg[k]; }; const disq = h.form.filter((r) => r === 'D').length; const poor = h.form.filter((r) => r !== 'D' && r >= 7).length; // debutanten: geen bewijs dat ze in de draf blijven const base = h.form.length === 0 ? 0.12 : 0.03; const pFail = Math.min(0.5, base + 0.1 * disq + 0.03 * poor); return { form: pick('form'), speed: pick('speed'), level: pick('level'), draw: DRAW_SCORE[h.n] ?? 0, pFail, estimated }; }); } /** * ondergrens voor de spreiding per signaal, anders wordt 0,2 s verschil in een * veld met drie bijna gelijke tijden een enorme z-score */ const SD_FLOOR: Record = { form: 0.08, speed: 1, level: 0.3, draw: 0.15 }; function zscores(values: number[], floor: number): number[] { const m = mean(values); const sd = Math.max(floor, Math.sqrt(mean(values.map((v) => (v - m) ** 2)))); return values.map((v) => (v - m) / sd); } export type GPrediction = { horse: Runner; features: GFeatures; contributions: Record; riskPenalty: number; logit: number; p: number; }; export function gPredict(runners: Runner[], w: GWeights): GPrediction[] { if (runners.length === 0) return []; const feats = gFeatures(runners, w); const z = Object.fromEntries( gFeatureKeys.map((k) => [ k, zscores( feats.map((f) => f[k]), SD_FLOOR[k] ) ]) ) as Record; const rows = runners.map((horse, i) => { const contributions = Object.fromEntries( gFeatureKeys.map((k) => [k, w[k] * z[k][i] * SCALE]) ) as Record; 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); }