Inspiration
I've been a competitive cyclist for 7 years — raced criteriums, suffered through alpine climbs, and spent countless weekends chasing wheels in crosswinds. I know how a stage unfolds: the early break, the peloton's calculation, the final 5km where positioning decides everything. But I also know how hard it is to predict a winner. Favorites crash, domestiques get their day, a tailwind changes the math.
Every July I'd build mental models: Pogačar on this climb, Philipsen on that finish, Evenepoel against the clock. This project started as a way to formalize that intuition — to see if the patterns I've internalized over thousands of race kilometers actually hold up in the data.
What it does
Scrapes 2020–2025 Tour de France stage results and rider profiles from ProCyclingStats, enriches with career statistics, trains an XGBRanker learning-to-rank model, and outputs calibrated top-5 win probabilities per stage for the 2026 start list.
How I built it
Data collection (2 scripts, fully automated)
scraper_complete.py— Playwright + Stealth bypasses Cloudflare; parallel workers (3 browsers) fetch all 126 stages across 6 years. Extracts: position, rider, team, time gaps, stage metadata (type, distance, elevation, profile icon). Checkpointed per year — resume on failure.scrape_riders.py— Iterates unique riders (~500), hits rider profile pages for PCS ranking, career wins, PCS points. Cached torider_data.json; merges back into main CSV.
Feature engineering (14 features)
Model
- XGBRanker (
rank:ndcg,ndcg@5), 200 trees, depth 5, LR 0.05, subsample 0.8 - Groups = year-stage combinations (riders per stage)
- StandardScaler on numerical features via ColumnTransformer
- LabelEncoders fitted on historical data; unseen 2026 riders mapped via normalized name matching (diacritic removal, fuzzy fallback)
Prediction
- Builds cartesian product: 21 stages × 110 riders = 2,310 rows
- Applies same encoders, feature transforms, scaler
- Model outputs raw scores → softmax per stage → probabilities
- Top 5 per stage written to
predictions_2026.csv
Challenges I ran into
- ProCyclingStats anti-bot (Cloudflare) → Stealth + persistent profile + delays
- Rider name matching across years (accents, formatting) → normalized fuzzy lookup
- Unseen 2026 riders → default encodings + stats from JSON cache
Accomplishments that I'm proud of
- Clean 19,680-row dataset spanning 6 editions, 120 stage winners
- Model correctly surfaces known specialists: Pogačar 15–55% on 8 mountain stages, Philipsen/Merlier 20–31% on flats, Evenepoel 13–16% on TT/TTT
- Fully reproducible: one command (
python predict_winners.py) retrains and predicts from raw CSVs
What I learned
- Learning-to-rank (XGBRanker) significantly outperforms binary classification for "who wins this stage"
- Rider-type × stage-type match is the single strongest feature (unsurprising to any cyclist)
- 3-year rolling win average captures form better than raw career wins
- Softmax over ranker scores produces reasonably calibrated probabilities without Platt scaling
What's next for Tour-de-France-Predictor-2026
- External signals: Weather (wind, rain, temp), team roster strength, GC standings at stage start
- Calibration: Isotonic regression on holdout years for betting-market-ready probabilities
- Scope: Extend to Vuelta/Giro; add GC podium and points/KOM jersey prediction
- Deployment: Lightweight API serving predictions; simple web UI for stage-by-stage exploration
Log in or sign up for Devpost to join the conversation.