All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
160 lines
4.8 KiB
TypeScript
160 lines
4.8 KiB
TypeScript
// 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<string, Partial<GWeights>> = {
|
||
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<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));
|
||
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<GFeatureKey, number> = { 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<GFeatureKey, number>;
|
||
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<GFeatureKey, number[]>;
|
||
const rows = runners.map((horse, i) => {
|
||
const contributions = Object.fromEntries(
|
||
gFeatureKeys.map((k) => [k, w[k] * z[k][i] * SCALE])
|
||
) as Record<GFeatureKey, 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);
|
||
}
|