Downhill model¶
The downhill season model lives in racinglines/models/inputs/run_times/ (section 7) and is driven by
racinglines model forecast mtb_dh and racinglines model backtest mtb_dh. It answers:
- Before a weekend: what's each rider's chance to make the Final, finish top 10, reach the podium, or win? How many championship points do they get on average?
- Mid-season: how will the championship end up?
How accurate it is: Downhill evaluation. The live 2026
projection: Downhill forecast. Since the one engine (staging, 2026-10-11) this model is the
run-times core of racinglines/models/model_global.py, chosen by the inputs sports/mtb_dh.toml declares
(run_times, practice); its runs are still stored as timed_runs, and every price is unchanged
(One engine). Formula 1 runs the same engine on its laps core:
F1 model.
Overview¶
tidy splits.csv
│ FINISH rows of every round, every season and category
▼
fit_season_model ──> rider pace μ, noise σ, rider × weekend spread τ, incident rates
│
▼
simulate_weekend (format-aware) ──> qualifying ranks, Final ranks, points × N sims
│
▼
simulate_standings ──> points so far + simulated rounds ──> title / top-3 odds
What gets modelled¶
The model works on the log of each rider's finish time. For rider i in run r (one run = one event + category + round, e.g. Les Gets 2026 ME Q2):
| Term | Meaning |
|---|---|
run_effect[r] |
How long that run's track and conditions are. It absorbs track length, weather, and differences between categories. |
μ[i] |
The rider's pace, as a fraction of time. μ = −0.02 means about 2% faster than the median rider. |
u[i, weekend] ~ N(0, τ²) |
Rider × track effect: shared by all of a rider's runs that weekend (track suits them, feeling good, setup). |
ε ~ N(0, σ²) |
Run-to-run noise. |
On top of this, each rider has an incident rate p_inc[i]. An incident is a
DNF or DSQ, or a finished run more than INCIDENT_THRESHOLD (4%) slower than
expected: a crash, puncture or big mistake.
Log times make the terms proportional, so a 2% gap means the same thing on a 2:45 track and a 4:10 track.
Fitting¶
fit_season_model(raw, category, half_life_days, category_weights, prior_n,
incident_prior_n, n_iter)
Which runs are used, and their weights¶
Every FINISH row with status == OK from rounds in RUN_WEIGHTS is used, from
every season and category passed in. Each run's weight is the product of three
factors:
| Factor | Value |
|---|---|
Round type (RUN_WEIGHTS) |
practice 0.5; qual, qual1, qual2, semi, final 1.0 |
Category (CATEGORY_WEIGHTS, --junior-weight) |
ME 1.0, MJ 0.25, anything else 0.5 |
| Recency | 0.5 ** (days_before_latest / half_life_days), default half-life 240 days |
With a 240-day half-life, last season's results count about half to three quarters as
much as this month's, and results from three seasons ago about a fifth. These
settings come from the 43-round tuning sweep (Calibration); until
2026-09-28 they were 120 days, junior weight 0.5 and prior_n 1.5.
Estimation, step by step¶
Estimation alternates between two steps, repeated 30 times:
- Run effects:
run_effect[r] = median over riders in r of (log time − μ[i]), using only clean runs. The median is robust to crashes. - Rider pace:
μ[i] = Σ w·(log time − run_effect) / (Σ w + prior_n)over the rider's clean runs (--prior-n, see Calibration).prior_n = 0.5(1.5 before 2026-09-28) shrinks riders with little data toward the field median, since one lucky run shouldn't make a rider a favourite. After each step, μ is re-centred so its median is 0. - Clean runs: a run counts as clean if its residual
log time − run_effect − μis below 0.04. Incidents are excluded from the pace estimate.
How junior results line up with elite ones: each run has its own run effect, so junior times aren't compared directly with elite times. The link comes from the 177 riders who raced both categories: their μ has to fit both sets of runs, which puts junior and elite on one scale. The same happens across seasons through riders who race several years.
Noise terms¶
These come from clean race runs (qual/qual1/qual2/semi/final) in the
target category only:
- σ² is the pooled variance within each rider's weekend (
residual − mean for that rider and weekend). - τ² is the variance of rider-weekend means, minus the part already explained by σ²
(
var(mean) − mean(σ²/n)), with a small floor.
The backtest fit up to 2026 round 5 gives σ ≈ 0.012 and τ ≈ 0.011 (log-time). A rider's time scatters by about 1.2% run to run, and about 1.1% extra from weekend to weekend.
Incidents¶
- Base rate
p0: the share of started race runs in the target category that were incidents. It's about 24% in 2026 elite, which includes slow-but-finished runs. - Per-rider rate: recency- and category-weighted, then shrunk toward
p0:(Σ w·incident + 8·p0) / (Σ w + 8). - How incidents play out: about 14% of incidents are DNF/DSQ. The rest are finished-but-slow, and their time losses are kept in a list that the simulator samples from.
Unknown riders¶
A rider with no history gets μ at the 75th percentile of the target category's
riders (slower than the median), and the base incident rate p0.
Simulating a weekend¶
simulate_weekend(model, riders, n_sims, attend_prob, rng, fmt) works on
(n_sims × riders) arrays, all at once:
- Attendance: each rider starts with probability
attend_prob(or always, if no probabilities are given). - Weekend effect: draw
uonce per rider per simulation; it's shared by all their runs that weekend. - Each run:
μ + u + N(0, σ²). With probabilityp_inc[i]it's an incident: a DNF (time = ∞) with probabilitydnf_share, otherwise a time loss sampled from the observed losses. - Rank each run and move riders through the stages of the weekend format.
Weekend formats¶
event_format(raw, event_id) works out an event's format from its own results.
This is fair in backtests, because the format and field sizes are published
before the race.
| Kind | Stages | Field sizes taken from | Seasons |
|---|---|---|---|
q1q2 |
Q1 → (top q1_to_final go through) → Q2 for everyone else → (top q2_to_final) → Final |
Finalists who didn't ride Q2 / did ride Q2 | Elite 2025–26 |
semi |
Qualifier → (top to_semi) → Semi-Final → (top to_final) → Final |
Semi-Final and Final entry counts | Elite 2023–24 |
single |
Qualifier → (top to_final) → Final |
Final entry count | Elite 2021–22, juniors, weather-shortened weekends |
Unraced rounds use DEFAULT_FORMAT = q1q2 (20 + 10), the 2026 format.
Protected riders (older formats let top-ranked riders into the Final even if they failed to qualify) aren't modelled explicitly. Using the actual Final size (e.g. 64 instead of 60) accounts for them roughly.
Points and standings¶
- Which round pays qualifying points depends on the format, via
QUAL_POINTS_ROUND:qual1(q1q2),semi(semi),qual(single). - Real points so far come from
actual_event_pointsapplied to the target season's actual results. - Standings:
simulate_standingsadds simulated points for each remaining weekend to the real points so far, then ranks everyone in every simulation, with random tie-breaks. That gives each rider's chance of being champion, top 3 and top 10, plus expected points and a p10–p90 range.
Warning
FINAL_POINTS (250/210/180/…/11 for places 1–30) and QUAL_POINTS
(60/50/40/…/2 for places 1–20) are placeholders, and the same tables are
used for every era. See Roadmap. Official tables, once entered,
go in points_schemes per era (racinglines data points mtb_dh import), and --points db uses them
(CLI).
Forecasting the rest of the season¶
forecast_season(raw, target, n_remaining, ...) simulates n_remaining rounds in
total. Some may already be in the data (a weekend in progress), and the rest are
unknown.
Rounds already in the data (weekend in progress)¶
completed_events(target) is the set of target events whose Final has OK
results. Any target event without them (e.g. Timed Training done, Q1 start list
published) is treated as in progress:
- Field: its real start list (
event_starters: first-qualifier entrants not marked DNS, includingSTARTrows). Attendance is certain. - Training cutoff: the model is fit only on data dated before that weekend, so this weekend's runs aren't counted twice.
- Weekend effect: each rider's
uis conditioned on this weekend's Timed Training (weekend_prior). A rider's residualegives the posteriormean = τ²·e / (τ² + σₚ²),sd = √(τ²σₚ² / (τ² + σₚ²)), whereσₚ = 1.5·σbecause training runs are noisier. Riders without a training run keepN(0, τ²); runs slower thanINCIDENT_THRESHOLDare ignored. - Safety check: if the session's residual interquartile range is above
max_iqr(0.08 in log-time), the session isn't a pace signal (e.g. riders held on track) and is ignored with a warning. This happened at Whistler 2026 (IQR 0.71). - Format: simulated with
DEFAULT_FORMAT(Q1 top 20 + Q2 top 10). - Output: a summary per upcoming event (
forecast_<venue>.csv, andrace_predictions.target = "event:<event_id>"with itsrace_idwhen saved), includingtt_pace_adj_pct.
Rounds not in the data yet¶
n_unknown = n_remaining − len(upcoming) rounds use a field of riders who started
the first qualifier in any of the last attend_window = 3 completed events. Each
rider attends with probability starts / 3, so a rider who missed one of the last
three rounds (e.g. through injury) attends with probability 2/3. They're saved as
target = "remaining_round".
Championship rank movement¶
For the next upcoming weekend, rank_moves(current_points, riders, sim_points)
compares each rider's championship rank before and after the simulated weekend
(ranks are "min" style, so tied riders share the better rank). It gives
current_rank, rank_up_prob, rank_down_prob and exp_rank_after, which are
stored in race_predictions.extra. These use the placeholder points tables.
Calibration¶
racinglines model backtest mtb_dh --db --reliability (2026-09-28, cloud build-out): every
walk-forward round of 2021–2026, 43 rounds and 5,171 rider-rounds, elite men.
The odds are too flat where it matters most. Win and podium are close to calibrated. Top 10 and making the Final are not: riders the model gives 10–20% to make the Final made it 6% of the time, and riders it gives 70–90% made it 92% of the time.
| Make the Final: predicted | 0.06–0.10 | 0.10–0.20 | 0.50–0.70 | 0.70–0.90 |
|---|---|---|---|---|
Observed, prior_n 1.5 (the old default) |
2.5% | 5.6% | 71.8% | 92.1% |
Observed, prior_n 0.5 |
5.0% | 8.6% | 64.7% | 87.1% |
Cause: too much shrinkage. prior_n 1.5 pulls riders' paces toward the field so hard
that the order of the field is under-stated. With 0.5, log loss per rider-round, paired by
round (± 2 SE):
| Market | Change | Seasons better |
|---|---|---|
| Make the Final | −0.026 ± 0.005 | 6 of 6 |
| Top 10 | −0.002 ± 0.005 | 5 of 6 (2021 worse) |
| Podium | −0.001 ± 0.001 | 5 of 6 |
| Win | −0.000 ± 0.001 | even |
Tried and not better: a lower INCIDENT_THRESHOLD (0.03, 0.02: small gains on top 10
and podium, worse for making the Final), heavier-tailed run noise (--eps-df 4 and 6),
and scaling σ and τ down (a little better with prior_n 0.5 or 1.0, overconfident with 0.25).
Not tried yet: per-round-type incident rates.
Tuning sweep over the same 43 rounds: prior_n 0.5 / 1.5 × half-life 60 / 120 / 240 / 480
days × junior weight 0.25 / 0.5 / 1.0 (24 combinations). A longer half-life (240) and less
junior weight (0.25) help too. The best, --prior-n 0.5 --half-life-days 240 --junior-weight
0.25, is better than the old defaults in every market (log loss, paired by round, ± 2 SE):
| Market | Change | Seasons better |
|---|---|---|
| Make the Final | −0.035 ± 0.006 | 6 of 6 |
| Top 10 | −0.006 ± 0.006 | 5 of 6 |
| Podium | −0.003 ± 0.002 | 6 of 6 |
| Win | −0.001 ± 0.001 | 3 of 6 |
The practice-run weight (run_weights.practice in sports/mtb_dh.toml) makes little
difference: 0, 0.25, 0.5 and 1.0 are within 0.003 of each other on every market, and today's
0.5 is as good as any.
Lower incident rates in Finals: no gain. Finals have fewer incidents than qualifying in the data (15% vs 23% of runs), but scaling the rate in Finals by 0.5, 0.71 or 0.85 (qualifying up to keep the average) is slightly worse on every market. Not built in.
Rider × venue effects: no gain. A rider's past deviations at the same venue, shrunk
(divided by events there + k, k = 1, 2, 4), used as the mean of that weekend's u on top of
the tuned settings: win log loss −0.0003 at best, podium, top 10 and making the Final
no better or worse, Spearman lower. Two things mattered for a fair test: deviations are
measured from the rider's own level (else they re-add the pace shrinkage), and residuals
beyond ±4% are dropped (practice times include a few far-off laps). Not built into the model.
Rookie improvement: none detected. Shifting riders with 3 or fewer (or 6 or fewer) elite events faster by 0.3% or 0.6% of run time is worse on every market; shifting them 0.3% slower changes log loss by under 0.001. With the 240-day half-life the pace already follows form.
The defaults since 2026-09-28 (owner's OK): PRIOR_N 0.5, HALF_LIFE_DAYS 240,
CATEGORY_WEIGHTS["MJ"] 0.25 in models/inputs/run_times/model.py. They were picked on the same 43
rounds they're scored on, so the gains are in-sample: check them on 2026's next rounds. The old
behaviour is --prior-n 1.5 --half-life-days 120 --junior-weight 0.5.
Training scope and targets¶
- Target (
select_target): only the target season and category are predicted and scored. Points, start lists, standings and every metric come from target rows. - Training (
_training_rows):train_scope="all"uses every row in the CSV;"season"uses only the target season. Backtests only train on data from strictly before the round being predicted.
The older per-race model¶
The fit / predict subcommands are the first approach, built before any real
data existed. They're kept but aren't part of the season pipeline:
pct_backand a robust z-score per round;- pairwise Elo ratings from finishing order;
- a recency-weighted form average and a venue prior;
- a gradient-boosted regressor on
pct_back; - Plackett–Luce win, podium and top-10 probabilities from Elo.
They haven't been checked against the multi-season data. They don't model the qualifying format or points.