Machine-learning case study · Pro cycling

Reading
the Race

Four years teaching a model to pick who finishes near the front of a Grand Tour stage — two neural architectures, a stack of hard-won lessons, and an honest benchmark of both on a race neither had seen.

2022Project start
2Neural generations
~5%Positive class
0.310Best NDCG@5
The problem

Picking the front of the bunch

The task: for every rider in every stage of a Grand Tour, predict whether they'll finish near the front. It's a deceptively nasty prediction problem.

Only a handful of riders finish in the top places, so the target is brutally imbalanced — roughly one positive for every twenty negatives. The signal is sequential (a rider's season builds toward the race) and multi-modal (results tables, but also the shape of the road: how steep, how long, how the final kilometres climb). Get any of those wrong and the model quietly learns to predict "nobody finishes well" and calls it a day.

The data comes from years of scraped race results, rider histories, and stage-profile & finish-line images, warehoused in MongoDB, with strictly temporal train / validation / test splits so the model is always judged on races it has never seen.

The climb · two generations

How the model evolved

Each era was rebuilt, then re-scored on one common out-of-sample holdout — the Giro d'Italia 2026, held out of training entirely — so the generations compare honestly.

v1
Sequence model 2024 · LSTM encoder–decoder 0.214NDCG@5

A hand-built recurrent net: encode a rider's pre-race season into a context vector, then decode it across the 21 stages of the tour to a finishing probability per stage.

Rider season history → LSTM encoder → context × 21 stages → LSTM decoder → P(top finish)

Under the hood: custom masked precision/recall metrics and a sentinel mask value threaded through the loss, so padded stages never pollute training or the score.

v2
Multi-modal 2025 · + image autoencoders 0.310NDCG@5

The road itself is a feature. Two convolutional autoencoders compress each stage-profile and finish-line image to a compact latent, fed into the sequence model alongside the numbers.

Stage-profile img Finish-line img → Conv autoencoders → 100-d → fused with season history → LSTM enc–dec → P(top finish)

Under the hood: hand-written mask-aware layers (MaskAwareNormalization, MaskedBatchNormalization) so normalization skips padding · a custom masked focal loss with tunable α/γ · a diversity loss to fight majority-class collapse.

Both scored on the same Giro 2026 holdout, strictly out-of-sample, through one shared scorer. Coverage differs: v1 was scored on 183 of 184 riders with its image inputs masked (2026 images are not in the 2024 store), v2 on 123 of 184 (encoded-image coverage), so the two are not on the identical rider set and the gap is indicative rather than exact.

Deeper cuts: version-by-version analysis with architecture flowcharts · the build log, read as a stage profile · how the scoring was done.

Bugs from the early versions

Where the wheels came off

Four real bugs from v1 and v2 — the kind that never throw an error, they just quietly wreck your results. Each was surfaced and fixed during the 2026 refactor, with Claude Code in the loop.

Class imbalance

Boring regularization beat every clever trick

Focal loss, class weights, stratified batching, warmup schedules, a 4-class reformulation — every fancy imbalance fix made the model collapse to all-negatives around epoch 10. Heavy L1 regularization with plain cross-entropy was what actually held it together.

Silent data bug

A masking mismatch poisoned training

Padded timesteps were filled with one sentinel value while the masking layer was told to ignore a different one. The model quietly trained on padding — no error, just a validation loss that blew up from 0.33 to 4.6.

One typo

A single string cost a whole version

Recovering the v1 2024 checkpoint hinged on one misspelled race-type label — 'one_day' — that had silently broken a feature mapping. Everything ran; the numbers were just wrong.

Metrics lying

The scoreboard was broken too

On the multi-class experiment, masked precision reported exactly 1.0 or 0.0 — the metric wasn't handling more than two classes, so I couldn't even trust what I was reading while debugging.

Under the tarp

Still in the race

Still trying to be the first to actually win this thing with AI — so the model I currently play with is developed privately. 🙂 In 2026 it placed 686th of 93,902 at the Tour de France (top 1%) and 2,089th of 44,451 at the Giro (top 5%), my own numbers from the game's standings. Everything else — the two neural architectures, the benchmark, the bugs — is right here on the table.