Give every badminton player an Elo rating, update it match by match, and see who comes out on top — then check whether those ratings actually predict who wins. Same idea GreenCode used for tennis in his "I trained an AI to predict sports" video; badminton is a nice fit because, like tennis, it's one player against another with a clear winner and no draws to muddy the maths.
The fun part of this project ended up being the data, not the maths. It started on a public CSV dump that stopped in 2021 and had broken doubles; it now runs on BWF's own internal API — the JSON feed bwfbadminton.com's frontend talks to, found by watching the network tab on a tournament page. That gets us every elite match from 2007 to this week: Superseries, Grand Prix, the whole HSBC World Tour, World Championships and Olympics. 105,147 matches across all five disciplines, doubles included, with unique player ids so there's none of the usual name-spelling mess.
Men's singles — Shi Yu Qi on top, which matches the official BWF world ranking, with the new wave (Alwi Farhan, Lanier, Kunlavut) right behind.
| # | Player | Elo | Peak | W–L |
|---|---|---|---|---|
| 1 | Shi Yu Qi | 2562 | 2712 | 297–99 |
| 2 | Viktor Axelsen | 2524 | 2781 | 400–136 |
| 3 | Alwi Farhan | 2479 | 2489 | 81–39 |
| 4 | Alex Lanier | 2476 | 2525 | 81–36 |
| 5 | Kunlavut Vitidsarn | 2464 | 2616 | 190–91 |
| 6 | Anders Antonsen | 2440 | 2562 | 246–122 |
| 7 | Sun Chao | 2428 | 2428 | 32–1 |
| 8 | Jonatan Christie | 2426 | 2567 | 286–149 |
| 9 | Victor Lai | 2404 | 2477 | 36–22 |
| 10 | Christo Popov | 2400 | 2582 | 100–74 |
(Sun Chao is the small-sample caveat: 32–1 is a monster run, but all of it at Super 100 level and 33 matches is barely a rating.)
The all-time peak list reads like a hall of fame, in the right order: Momota
2799, Axelsen 2781, Lin Dan 2732, Lee Chong Wei 2714, Shi Yu Qi 2712.
python run.py leaderboard --by peak --active-days 0 to see it.
Women's singles — An Se-young sits at 2890, nearly 300 points clear of the field. Her 317–56 record is the most dominant thing the model has seen: her current rating is higher than any peak anyone else ever reached in this data — including peak Momota.
| # | Player | Elo | Peak | W–L |
|---|---|---|---|---|
| 1 | An Se Young | 2890 | 2891 | 317–56 |
| 2 | Akane Yamaguchi | 2606 | 2720 | 441–137 |
| 3 | Wang Zhi Yi | 2598 | 2679 | 219–80 |
| 4 | Chen Yu Fei | 2586 | 2762 | 353–100 |
| 5 | Han Yue | 2406 | 2520 | 205–116 |
| 6 | Pornpawee Chochuwong | 2306 | 2356 | 231–164 |
| 7 | Putri Kusuma Wardani | 2294 | 2370 | 106–67 |
| 8 | Ratchanok Intanon | 2256 | 2498 | 455–227 |
| 9 | Sim Yu Jin | 2252 | 2282 | 77–62 |
| 10 | Pusarla V. Sindhu | 2246 | 2515 | 368–189 |
(Ratings live on an arbitrary scale — after tuning, the spread widened, so don't compare these numbers to chess Elo; the gaps are what carry meaning.)
Doubles finally works (the old CSV data had team two's partner missing on every row; the API has all four players). Current #1s: Kim Won Ho / Seo Seung Jae in MD, Feng Yan Zhe / Huang Dong Ping in XD — matching the actual world rankings. Pairs are rated as units, keyed by player id so the same duo always maps to the same rating.
Run python run.py for the live tables. --min-games 25 filters to
established players only — with a 20-year archive, a hot newcomer on a 30-match
tear can gatecrash the default view before the sample says much.
Walking through every match in date order, forming a win probability before each result and only then updating the ratings (no peeking), scored against naive baselines. Matches where both sides had 10+ prior games:
| Discipline | n | coin flip | head-to-head | more experienced | Elo |
|---|---|---|---|---|---|
| Men's singles | 18,308 | 50% | 55.0% | 61.5% | 69.9% |
| Women's singles | 14,704 | 50% | 58.0% | 64.0% | 75.2% |
| Men's doubles | 9,328 | 50% | 55.9% | 61.6% | 70.8% |
| Women's doubles | 7,349 | 50% | 58.4% | 63.8% | 76.4% |
| Mixed doubles | 8,406 | 50% | 56.9% | 62.0% | 73.4% |
Elo also wins on log-loss and Brier everywhere (e.g. MS 0.583 vs 0.693 coin
flip; "more experienced" gets decent accuracy but its log-loss is a disaster
because it commits 100% to one side every time). Women's events are the most
predictable — the top of those fields has historically been more separated
from the pack. python run.py backtest --calibration shows the probabilities
are honest too: when the model says 70%, it lands near 70%.
For the curious: on the old 2018–21 CSV data the first version of this engine scored 63.2% (MS) / 72.9% (WS). Getting to 69.9% / 75.2% came in two steps — about +6 points from tripling the history (more data beats everything), and about +1 more from tuning the Elo constants on held-out years instead of trusting my hand-picked defaults.
The obvious next question — GreenCode's whole second video — is whether feeding
a gradient-boosted model more than just the rating gap does better. So I built
leak-free features (Elo gap, recent form, head-to-head, experience, tournament
tier, home, days of rest), pooled all five disciplines, trained on 2007–2023,
tuned on 2024–25, and tested on 2026 (3,186 matches). python ml_compare.py.
First run, XGBoost edged my hand-set Elo on log-loss. So before conceding I
tuned Elo's own knobs the same walk-forward way (python tune_elo.py —
coordinate descent on the validation years, test window touched exactly once).
The rematch, learned models re-fed the tuned Elo:
| Model (test = 2026) | Accuracy | Log-loss | Brier |
|---|---|---|---|
| Elo only (tuned) | 69.4% | 0.583 | 0.197 |
| Logistic | 69.4% | 0.580 | 0.197 |
| XGBoost | 69.4% | 0.576 | 0.197 |
| XGBoost + Elo | 69.6% | 0.576 | 0.197 |
Everything ties on accuracy; XGBoost keeps a 0.007 log-loss sliver from squeezing a bit more calibration out of head-to-head and form. Feature importance says why there's so little left to win: the Elo gap alone is 47%, experience 23%, everything else splits scraps. A tuned rating gap is basically the whole signal. Same lesson as a football experiment I ran — feature and data quality dominate model complexity. And worth knowing that ~70% (not some headline 83%) is what an honest, leak-free model gets here.
What tuning found, because two of the three surprised me:
- Yearly regression to the mean hurts prediction. My "new season, prove
it again" rule (pull everyone 20% toward 1500 each January) felt obviously
right and cost real accuracy — the ratings already track form through the
updates themselves; the reset just throws information away. Now off by
default (
--regressto bring it back). - Home advantage is ~0 in badminton once ratings are known. The crowd at
Istora is loud; the shuttle doesn't care. Off by default (
--home-adv). - A faster, flatter K curve (
320/(games+5)^0.32) beats my first guess. Margin-of-victory and tier-weighted K are implemented (--mov,EloParams.tier_k) but didn't survive validation — both stay off.
Standard Elo, one rating pool per discipline, walked through the matches
chronologically. The key design choice is a K-factor that shrinks with
experience — a player's first dozen matches move their rating fast, then it
settles (FiveThirtyEight's tennis trick): K = 320 / (games + 5) ** 0.32,
with the constants set by tune_elo.py on held-out years rather than by me.
Knobs that exist but default to off, because validation said so (see the
model-comparison section): yearly regression to the mean (--regress), home
advantage (--home-adv), margin-of-victory weighting (--mov), and
tier-weighted K. The current-form ladder comes from the --active-days
filter (inactive players are hidden, not decayed).
python predict_japan.py pulls the published draw for the Japan Open 2026
(which starts the day after I write this) and prints a win probability for every
scheduled match, then writes them to predictions_japan_open_2026.md with a
timestamp — locked in before a ball is hit, so they can be graded afterwards.
It's the honest version of GreenCode's "predict Wimbledon live" finale: real
matchups, real probabilities, falsifiable in a couple of days.
The one thing missing to fully close the loop is a benchmark to beat — for tennis he had IBM and the bookmakers. Badminton has no free historical closing-odds source that maps to these matches (the archives are paywalled or Cloudflare-walled and need fuzzy name-matching), so for now the baselines are the naive ones above rather than a bookmaker line. That's the honest gap.
python fetch_bwf.py # builds/tops-up the local cache (data/bwf/)
fetch_bwf.py pulls two things from the API, both cached and resumable:
a tournament index (one search call per year, 2007→now), then one call per
elite tournament for its complete match list — every game score, winner,
round, and all four player slots. ~610 tournaments, gzipped to ~60 MB, at a
polite 1.3 s between requests (~40 min the first time, seconds after that —
rerun it whenever you want to top up the latest results).
Quirks the loader (badminton/bwf.py) absorbs so the model doesn't have to:
- Finished tournaments group matches under
by_time; recent ones only fillby_court. Read both, dedupe on match id. - Walkovers have a winner but no games — dropped.
- Two different players can share a display name; ids disambiguate them.
- Future tournaments return
results: null(they get retried on the next fetch rather than cached empty).
The original CSV source (juanliong14's BWF dump,
2018–21, singles only usable) still loads with --source csv — it's what the
project was built and first validated on, and it's a handy cross-check.
pip install -r requirements.txt
python fetch_bwf.py # once; then rerun to top up new results
python run.py # current MS + WS ladders
python run.py leaderboard --discipline MD WD XD --top 20
python run.py leaderboard --discipline MS --by peak # all-time peaks
python run.py backtest --calibration
python run.py predict "An Se Young" "Wang Zhi Yi" --discipline WS
pytest -qName lookup in predict is forgiving — partial, any word order. Knobs live on
the command line (--home-adv, --regress, --flat-k, --mov, --source)
or as defaults in EloParams in badminton/elo.py.
fetch_bwf.py BWF API -> local cache (index + per-tournament matches)
fetch_data.py the old CSV download (fallback source)
export_csv.py cache -> plain per-discipline CSVs (the shareable dataset)
run.py CLI: leaderboard / backtest / predict
ml_compare.py the "does XGBoost beat Elo?" experiment
tune_elo.py tunes the Elo knobs on held-out years
predict_japan.py locked-in predictions for a live tournament
badminton/
bwf.py API cache -> tidy match table
data.py legacy CSVs -> the same table
elo.py the rating engine (dynamic K, home adv, regression)
evaluate.py walk-forward backtest, baselines, calibration
features.py leak-free feature matrix for the learned models
learn.py logistic + XGBoost training/comparison
tests/test_elo.py rating maths + parsing + leakage checks
data/bwf/ the API cache (matches/ is gitignored, ~60 MB)
data/csv/ exported CSVs for sharing (+ their own README)
legacy/ the original dead-end scraper + a stray team-event file
- Team events (Thomas/Uber/Sudirman Cup) — the API has them; the ties just need unpacking into individual rubbers.
- Margin-of-victory weighting is stubbed (
--mov) but off; with 105k matches there's finally enough data to test whether straight-games wins carry signal. - Rating-history plots per player — peak Momota vs peak Axelsen vs peak Lin Dan deserves a chart. (Lin Dan's prime is only half-covered: the archive starts 2007 and misses his 2004–06 run.)
- The API's live-scores endpoint could make this predict matches during tournaments, which is where this stops being a backtest and starts being fun.