Skip to content

Repository files navigation

Badminton Elo

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.

The ratings, as of July 2026

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.

Does it actually predict matches?

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.

Does a fancier model beat plain Elo? (mostly no)

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 (--regress to 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.

How the Elo works

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).

Predicting a live tournament

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.

The data pipeline

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 fill by_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.

Running it

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 -q

Name 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.

Layout

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

What I'd do next

  • 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.

About

Player Elo ratings for pro badminton, built on 105k BWF matches (2007–2026) pulled from the tour's own undocumented API. Elo ties XGBoost head-to-head — the rating gap is the whole signal.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages