🧪 Methodology
Data lineage, feature engineering, model design and honest cross-validated performance numbers for the TDF 2026 ML dashboard.
Every number on this dashboard is either parsed from public race records or produced by a model whose cross-validated accuracy is published below.
📡 Data: real, and where it comes from #
All race data is parsed from Wikipedia's Tour de France articles, which are
updated within hours of each stage finishing and cite the official timing
(letour.fr / Tissot) for every table. The fetch pipeline
(scripts/fetch_tdf_data.py) parses the underlying wikitext templates —
{{Cyclingresult}} blocks, stage characteristics tables, classification
tables and the startlist — rather than scraping rendered HTML, which makes it
fast and robust.
| Dataset | Contents |
|---|---|
live/ |
2026 route, stage results, GC after every stage, all four classifications, the full 184-rider startlist with DNS/DNF status |
historical/ |
2020-2025: top-10 of all 126 stages, GC after every stage, final classifications (~2,500 rows) |
predictions/ |
model outputs for every remaining 2026 stage, the final GC and jersey projections — regenerated daily |
Why not the sources this project originally named? cqranking.com exposes no
public JSON API and procyclingstats.com blocks automated clients (HTTP 403).
Wikipedia is reliable, licensed CC BY-SA, and independently cites official
timing.
The pipeline validates before writing: 21 stages per year or it fails, unknown stage types fail loudly, and stage distances are cross-checked against the official race total (that check caught a real typo — the route table listed stage 9 as 155.5 km where letour.fr says 185.5 km).
🤖 The models #
Five models plus one simulation, all trained by
scripts/train_predictions.py on the 2020-2025 results. Features are built
only from information available before the stage being predicted — a
rider's career record in previous Tours and their form in the current Tour up
to that morning. No leakage.
1. Stage winner — GradientBoostingClassifier #
One row per (rider × stage): ~10,600 training rows, 126 actual winners. Features:
- Stage: type (flat / hilly / mountain / ITT), distance, race progress
- Career (previous Tours in the dataset): stage wins, podiums, top-10s split by stage type, best final GC
- Form (this Tour, before the stage): wins, podiums, top-10s by type, current GC position
- Interactions: the rider's wins/podiums/top-10s on this stage type — the single strongest signal (sprinters win flat stages, climbers win mountains)
Raw probabilities are normalised per stage so exactly one winner's worth of probability is distributed across the peloton.
2. Stage podium — same features, target = top-3 #
Feeds the jersey projections (a rider doesn't need to win to score points).
3. Stage ranker — XGBRanker (LambdaMART) #
Binary win/top-3 targets throw away the ordinal structure of a stage result — 2nd and 15th are both just "didn't win". The ranker learns a per-stage ordering of all riders directly, trained on graded finish positions (win › podium › top-10 › everyone else, one ranking group per stage) over exactly the same features and the same tree budget as the classifier, so the cross-validation below is a fair model-class comparison, not a tuning contest.
The two compete on every retrain: the ranker takes over when it orders the held-out years better (top-1 hit rate, then top-3) and its scores rank top-3 finishers at least as well as the podium classifier — currently it does both (top-3 AUC 0.778 vs 0.776; in a paired per-stage comparison it picks the winner on 10 of the 13 stages where the two disagree, one-sided sign test p ≈ 0.05). A tie keeps the classifiers.
When the ranker is in charge it supplies rank, win % and podium % from one score: a raw LambdaMART score is not a probability, so the scores are passed through isotonic maps fitted on the ranker's own out-of-fold CV predictions (score → observed win rate / top-3 rate on held-out years), and the win side is then normalised per stage so each stage sums to one winner — the same normalisation the classifier pipeline used. Because isotonic maps are monotone, the published percentages can never contradict the published rank. The calibration is deliberately coarse — with 126 winners in the data it is a step function, so several riders in a stage often share exactly the same percentage; those ties are honest, and the rank breaks them by the model's raw score.
4. Final GC position — GradientBoostingRegressor #
For riders in the GC top 10 after stage 8: predict their final position in Paris. Features: current gap and position, what remains of the route (mountain stages, ITT km), career GC pedigree and mountain form. Riders who crashed out or faded from the top 10 are censored at rank 12.
Honesty required: its cross-validated error is places versus for simply freezing the standings — with six Tours of history, the freeze baseline is genuinely hard to beat. We show the model's ordering because it encodes who typically fades (breakaway survivors) and who climbs (GC pedigree riders), but treat the podium probabilities below as the more meaningful output.
5. GC podium — RandomForestClassifier #
Same inputs, binary target finishes on the final podium. Class-weight balanced; this is the headline GC prediction on the dashboard.
6. Jersey projections — expected-points simulation #
No black box: projected points = current points + expected podium points
- expected "unseen" points over the remaining stages.
- Podium points combine the selected stage model's win/podium probabilities (currently the calibrated ranker — see model 3) with the real UCI points scales (flat wins pay 50 green-jersey points, hilly 30, mountain/ITT 20; mountain-stage winners typically bag ~18 KOM points at summit finishes), summed over the stages still to race.
- Unseen points are everything a finish-position model cannot see: green points scored mid-stage at the intermediate sprint (20-17-…-1 for the first 15 through) and at the finish for places 4-15, plus KOM points collected by breakaways over climbs. No per-sprint data exists in the source, but the aggregate does: the part of a rider's current points total that their observed podium finishes cannot explain must have come from exactly these sources — for the current green-jersey leader that residual is the large majority of his total, because hoovering intermediate sprints is his strategy. Each rider's residual rate per stage so far is projected over the remaining stages, with a mountain stage counted as half a green opportunity (its intermediate sprint stays contestable from the bunch; its finish points realistically do not).
Assumptions worth knowing: the residual rate assumes riders keep collecting these points as they have so far (a mountain-heavy final week makes this optimistic for pure sprinters); the classifications source publishes only each standing's top 10, so riders outside it carry no residual; and the projection is an expectation, not gospel.
🏷️ Rider "specialist" labels #
The specialist tags (Sprinter, Climber, GC contender, Puncheur, Time trialist, Stage hunter, Domestique) are derived from the data, not typed in: each rider's top-10 finishes from 2020 through this morning are split by stage type and a simple documented rule assigns the label (e.g. mostly flat top-10s → Sprinter; GC top-10 plus repeated mountain top-10s → GC contender). Riders with no top-10 in the dataset are labelled Domestique.
⚠️ Limitations — read before betting #
- Breakaways: many Tour stages are won from long-range breakaways that no rider-profile model predicts. That is why the honest top-1 hit rate is , not 85%.
- Six years of history is a small sample for GC dynamics; the position regressor does not beat the standings-freeze baseline and the page says so.
- Top-10 depth: historical training data covers each stage's top 10, not the full finish order.
- No exogenous factors: crashes, illness, weather, team tactics and abandons (like Torstein Træen's exit while in yellow after stage 6) are unmodelled.
- TTT excluded: stage 1 was a team event; rider-level models skip it.
🔁 Reproduce it #
pip install -r requirements.txt
python scripts/fetch_tdf_data.py --all --output data # real data → parquet
python scripts/train_predictions.py --train --predict # models + predictions
dashdown serve . # this dashboard
Every model, metric and prediction on this site regenerates from those three commands. During the race a GitHub Action runs them each morning at 08:00 UTC and again at 16:00 / 18:00 / 20:00 UTC, so the day's result is on the dashboard the same evening.