SportsDataTutorials

Build an Elo Rating System from Scratch

Part 7 of 7 in Basketball Analytics with the NBA API · course bundle (code + data)

BasketballIntermediatePython~11 min read

What you'll build

An Elo rating system built in ~20 lines over a real 2023-24 NBA season, with a rating-progression chart and the final-standings ranking it discovers on its own.

An Elo rating system built in ~20 lines over a real 2023-24 NBA season, with a rating-progression chart and the final-standings ranking it discovers on its own.
Data: Bundled sample (real 2023-24 NBA game results), retrieved June 2026

Here's something that should feel a little like magic: hand a computer nothing but a list of who-beat-whom, never tell it which teams are good, and it will rank them correctly anyway. The trick is Elo — the rating system invented for chess and now running quietly under FiveThirtyEight's sports models, the international football rankings, and your video-game matchmaking. It's about twenty lines of Python, and once you've built one you'll never look at a "power ranking" the same way.

This builds on Your First Sports Data Visualization and pairs well with Monte Carlo season simulation. The data is the bundled nba_home_results.csv (real 2023-24 NBA results), so it runs offline. The back-test at the end adds two more bundled files, NFL games from 1999 to 2025 and the 2025-26 Premier League, to choose K from the data instead of by convention.

  1. The whole idea in one sentence

    Every team starts at the same rating — 1500 by convention. Before a game, the ratings predict a win probability. After the game, the winner takes points from the loser: a lot if the result was a surprise, a little if it was expected. That's it. The system is a closed economy — points only move between the two teams — so the league average never drifts from 1500.

    The prediction uses the logistic curve, the same S-shape behind most win-probability models. The gap in ratings (plus a bonus for the home side) becomes a probability:

    python
    def expected(r_home, r_away, home_adv=100):
        # probability the home team wins, from the rating gap
        return 1 / (1 + 10 ** ((r_away - (r_home + home_adv)) / 400))

    The 400 is a scale constant from chess: a 400-point edge means you're expected to win about 91% of the time. The home_adv of 100 Elo points is roughly the documented NBA home-court edge, added to the home team's strength for the prediction only.

  2. Load the games — in date order

    This is the one rule you cannot break: Elo must process games chronologically. A team's rating in March depends on every result before it, so shuffling the games scrambles the ratings.

    python
    import pandas as pd
    
    games = pd.read_csv("nba_home_results.csv")
    games["date"] = pd.to_datetime(games["date"])
    games = games.sort_values("date").reset_index(drop=True)   # non-negotiable
    
    START, K = 1500.0, 20.0
    rating = {t: START for t in
              pd.unique(games[["home_team", "away_team"]].values.ravel())}

    K is the step size — how many points one game can move a rating. Bigger K reacts faster but is noisier; 20 is a sensible middle for a season.

  3. The update: move toward the surprise

    For each game, compute the expectation, see what happened, and shift both ratings by K × (actual − expected). The winner's gain is exactly the loser's loss.

    python
    for _, g in games.iterrows():
        h, a = g["home_team"], g["away_team"]
        exp_h = expected(rating[h], rating[a])
        home_won = 1.0 if g["home_pts"] > g["away_pts"] else 0.0
        rating[h] += K * (home_won - exp_h)
        rating[a] -= K * (home_won - exp_h)

    Watch the first three games go by one at a time:

    Rating the first three games
    First three games, rated one at a time:
    
    Denver Nuggets (1500) vs Los Angeles Lakers (1500): P(home win)=0.64, home won  ->  Denver Nuggets now 1507, Los Angeles Lakers now 1493
    Golden State Warriors (1500) vs Phoenix Suns (1500): P(home win)=0.64, home lost  ->  Golden State Warriors now 1487, Phoenix Suns now 1513
    Los Angeles Clippers (1500) vs Portland Trail Blazers (1500): P(home win)=0.64, home won  ->  Los Angeles Clippers now 1507, Portland Trail Blazers now 1493
    
    Notice: an expected home win nudges ratings only a little;
    an upset (low P, away wins) moves them a lot. That's the K-step at work.

    Look at the size of the moves. An expected home win shifts the ratings by about 7 points; the upset in game two — the home team was a 64% favorite and lost — moves them nearly twice as far. That asymmetry is the entire intelligence of the system: surprising results carry more information, so they count for more.

  4. Run the season and read the standings it invented

    After all 1,231 games, sort the ratings. We never told the model a single thing about team quality — only who beat whom — yet:

    Season-end Elo, top and bottom
    Final 2023-24 Elo ratings (top 6):
      Boston Celtics           1687
      Oklahoma City Thunder    1641
      Denver Nuggets           1638
      Minnesota Timberwolves   1612
      Dallas Mavericks         1608
      Phoenix Suns             1607
    
    Bottom 3:
      Portland Trail Blazers   1321
      Detroit Pistons          1286
      Washington Wizards       1272
    
    Sum stays ~1500 x 30 = closed economy: points only move between teams.

    That's the real 2023-24 NBA, recovered from results alone: the Celtics (the season's best record) on top, the Wizards and Pistons at the bottom. The ratings discovered the standings without ever seeing a win-loss column.

  5. Did it actually predict anything?

    A ranking is only useful if it forecasts. Check the simplest version: before each game, did the higher-rated team (home edge included) go on to win?

    A sanity check on the ratings
    In-sample: the higher pre-game Elo (incl. home edge) picked the winner
    in 765 of 1231 games = 62.1%.
    That beats a coin flip because real teams differ - but it's in-sample,
    so treat it as a sanity check, not a forecast accuracy.

    About 62% — comfortably better than a coin flip, which is what you'd hope for from a system this simple. The honest caveat is that this is in-sample: the ratings were built from these same games, so this is a sanity check, not a true out-of-sample accuracy. A real forecast test scores games the tuning never saw; the back-test below does that in three sports.

  6. Picture the season

    Plot a few teams' ratings game by game and the season becomes a story you can see — the contenders climbing away from 1500, the cellar team sliding down.

    python
    import matplotlib.pyplot as plt
    
    history = {t: [START] for t in rating}     # record before the loop, append inside it
    # ...append rating[h], rating[a] to history each game...
    
    fig, ax = plt.subplots(figsize=(9, 5.5))
    for t in ["Boston Celtics", "Oklahoma City Thunder", "Washington Wizards"]:
        ax.plot(history[t], label=t)
    ax.axhline(1500, ls=":"); ax.legend()
    fig.savefig("elo_progression.png", dpi=144, bbox_inches="tight")
    Line chart of Elo ratings across the 2023-24 season for three NBA teams: two contenders climbing well above the 1500 start line and one team sliding steadily below it
    Data: Bundled sample (real 2023-24 NBA game results), retrieved June 2026

    And the full final ranking, every team relative to the 1500 average:

    Horizontal bar chart of all 30 NBA teams ranked by season-end Elo rating, bars extending right from 1500 for above-average teams and left for below-average ones
    Data: Bundled sample (real 2023-24 NBA game results), retrieved June 2026

Choosing K by back-test, in three sports

K = 20 and a 100-point home edge are conventions. Here the data chooses them. For each sport we run Elo over a training block, score every pre-game probability with log loss (the penalty for being confidently wrong; a coin flip scores 0.6931), and keep the K and home edge with the lowest loss from a grid of 12 K values (4 to 80) and 9 home edges (0 to 200 Elo points). Then that pair is scored on a later held-out block that played no part in choosing it.

  • NBA: the 1,230 regular-season games of 2023-24 (the In-Season Tournament final is dropped, as official standings drop it). The first 615 by date train; the last 615 are held out.
  • NFL: all 7,276 played games from 1999 to 2025 in nfl_games_lines.csv, playoffs included; only the teams, scores, season and the Home/Neutral venue flag are read. Neutral-site games get no home edge, relocated franchises keep one rating (St. Louis to the Los Angeles Rams, San Diego to the Chargers, Oakland to Las Vegas), and each rating moves a third of the way back to 1500 between seasons. 1999 is a burn-in, 2000-2012 trains (3,455 games), 2013-2025 is held out (3,562).
  • Premier League: the 380 matches of 2025-26 in epl_results_2025_26.csv, 190 to train and 190 held out. A draw scores 0.5 for each side, the same way a tie does in the NFL.
python
import numpy as np, pandas as pd

def run_elo(g, k, hfa, carry=1/3):
    """Pre-game P(home win) for every game, in date order."""
    r, season, out = {}, None, np.empty(len(g))
    for i, (s, h, a, sc, neu) in enumerate(zip(g.season, g.home, g.away, g.score, g.neutral)):
        if s != season:                          # new season: a third of the way back to 1500
            r = {t: 1500 + (v - 1500) * (1 - carry) for t, v in r.items()}
            season = s
        rh, ra = r.get(h, 1500.0), r.get(a, 1500.0)
        p = 1 / (1 + 10 ** ((ra - rh - (0 if neu else hfa)) / 400))
        out[i] = p
        r[h], r[a] = rh + k * (sc - p), ra - k * (sc - p)
    return out

def log_loss(p, s):
    p = np.clip(p, 1e-12, 1 - 1e-12)
    return -np.mean(s * np.log(p) + (1 - s) * np.log(1 - p))

# g: one sport's games in date order with season, home, away, neutral and
# score (1 home win, 0.5 draw/tie, 0 loss); train/test: boolean masks over g
s = g.score.values
_, k_best, h_best = min((log_loss(run_elo(g, k, h)[train], s[train]), k, h)
                        for k in [4, 8, 12, 16, 20, 24, 28, 32, 40, 48, 60, 80]
                        for h in range(0, 201, 25))
held_out = log_loss(run_elo(g, k_best, h_best)[test], s[test])

The finished script builds g and the two masks for each sport and prints the whole comparison:

K chosen on a training block, scored on a held-out block
Held-out log loss (lower is better; a coin flip scores 0.6931)

       games         picked     held-out log loss            best K
sport  train/test    K  home    picked  K=20,h=100  base    in hindsight
NBA     615/615     40    50    0.6323  0.6475      0.7065  K=20
NFL    3455/3562    40    50    0.6371  0.6584      0.6876  K=40
EPL     190/190     60    75    0.6888  0.6796      0.6907  K=20

Home side's share of points, training / held-out block:
  NBA 58.4% / 50.2%, NFL 57.1% / 55.5%, EPL 57.9% / 54.7%

Held-out log loss by K (home edge fixed at the picked value):
   K     NBA     NFL     EPL
   4  0.6579  0.6709  0.6837
   8  0.6374  0.6605  0.6762
  12  0.6281  0.6533  0.6718
  16  0.6242  0.6482  0.6697
  20  0.6232  0.6445  0.6690
  24  0.6238  0.6417  0.6693
  28  0.6253  0.6398  0.6703
  32  0.6273  0.6384  0.6719
  40  0.6323  0.6371  0.6760
  48  0.6380  0.6372  0.6808
  60  0.6471  0.6391  0.6888
  80  0.6635  0.6456  0.7030
Line chart of held-out log loss against Elo K on a log scale for the NBA, NFL and Premier League: the NBA and Premier League curves bottom out at K=20, the NFL curve at K=40
Data: bundled NBA 2023-24 results (Basketball-Reference), NFL games 1999-2025 (nflverse) and Premier League 2025-26 results (ESPN), retrieved June-August 2026. Script: 65_build_an_elo_rating_system.py.

Three findings, each read straight off that output:

  • Where the training block looks like the future, tuning pays and the answer is stable. On thirteen NFL seasons the training block picks K = 40 with a 50-point home edge, and the held-out seasons agree: their own lowest loss is also at K = 40 (0.6371; K = 48 is 0.6372). The tuned pair scores 0.6371 against 0.6584 for the K = 20, 100-point convention and 0.6876 for "predict the home side's training-block share every time".
  • Inside one season, the first half picks too big a K. The NBA training half picks K = 40 (held-out 0.6323), but the held-out half does best at K = 20 (0.6232). In the Premier League the gap is wider: the picked K = 60 scores 0.6888, barely ahead of the home-share baseline's 0.6907, while K = 20 would have scored 0.6690. Every team starts this one-season run at 1500, so through the first half the ratings are still spreading out to match the real gaps between teams, and a large K is rewarded for getting there quickly; that is the likely reason the pick does not carry over. The NFL run avoids the problem because its ratings carry from season to season. The practical rule: tune K on blocks that start the way your forecasts will start.
  • The 100-point home edge is too big for all three. The picks are 50 Elo points for the NBA and NFL and 75 for the Premier League. In the logistic formula above, 50 points means an equal home team wins 57.1% of the time and 100 points means 64.0%. The home side's share of points also moved inside the blocks: 58.4% in the NBA training half, 50.2% in the held-out half. That drop is why the NBA's home-share baseline (0.7065) scores worse than a coin flip on the held-out games.

The same three files run through thirteen other methods from this site, including the held-out AUC of this tuned Elo, in replication_grid.py, a single script that prints one table across the three sports. The files themselves are documented on the datasets page.

Troubleshooting

My ratings look random / wrong

Almost always the date sort. If you process games out of order, a team's rating reflects the wrong history. Confirm games.sort_values("date") ran and that your loop reads the sorted frame, not the original.

The ratings drift away from 1500 overall

They shouldn't — each update adds to one team exactly what it subtracts from the other, so the total is conserved. If the league mean moves, you're updating one side with a different value than the other (a sign error). The home team gets +K(actual − exp); the away team gets the negative of the same number.

What value of K should I use?

It's a trade-off. Small K (10) is stable but slow to react to a team that's genuinely improved; large K (40) reacts fast but whipsaws on noise. Choose it by back-test, as in the section above: on NFL seasons carried over from year to year, K = 40 had the lowest held-out log loss; in the second half of one NBA or Premier League season, K = 20 did. Many real systems also shrink K once a team has a long history, or scale each update by margin of victory.

Challenge yourself

Extend the back-test. Add a margin-of-victory multiplier (a blowout should move ratings more than a one-point game) and see whether the held-out log loss falls in each sport. Then tune the NFL's between-season carry-over, fixed at one third here, alongside K. For the one-season leagues, try seeding each team from the first half's final ratings and re-tuning K on the second half alone: does the picked K come down toward 20?

Get the code

Want it all in one file? This is the finished script behind this tutorial - the run that produced the outputs above.

Download the finished script (65_build_an_elo_rating_system.py)

This script imports a small shared helper (and reads any bundled sample data) that live next to it in /downloads/ — grab these into the same folder so it runs as-is: sdt_common.py, sdt_nba.py, nba_home_results.csv, nfl_games_lines.csv, epl_results_2025_26.csv. Or skip the collecting: the Basketball Analytics with the NBA API bundle has this whole course’s scripts and data in one ZIP.

Built from public data and the sources named in this tutorial; the printed outputs and charts come from running the tutorial's own script. How this site is made · about the site

Progress is saved only in this browser.