Python Sports Modelling

Building A Clean NFL Elo Rating Model From Results

Introduction

Every sports model starts with the same fundamental problem: how do we measure team strength?

Before we can predict winners, estimate point margins, calculate win probabilities, or build more sophisticated models, we first need a reliable way of comparing teams on a common scale.

At first glance the answer might seem obvious. Surely league positions or win-loss records tell us which teams are strongest? In reality, they only tell part of the story.

Imagine two NFL teams that both have an 8–4 record. One has earned victories against several strong opponents, while the other has beaten teams near the bottom of the league. Although their records are identical, most people would agree that the first team has probably demonstrated greater underlying strength.

Team strength also changes continuously throughout a season. Injuries, coaching changes, player movement and changing form all influence how strong a team is today compared with a few weeks ago. Any useful rating system therefore needs to update as new information becomes available, while still remembering what happened previously.

Elo was designed to solve exactly this problem: estimating relative team strength from observed results.

What Is Elo?

Elo was developed by physicist Arpad Elo as a rating system for chess before later being adopted across many sports. Although the competitors have changed, the central idea remains the same.

We cannot model something that we cannot measure. Elo provides a numerical measure of team strength. Every team is assigned a rating, and that rating changes after every game as new evidence becomes available.

Those updates depend on only two pieces of information:

  • What the ratings expected to happen before the game.
  • What actually happened.

If a team performs better than expected, its rating increases. If it performs worse than expected, its rating decreases. The size of the update depends not only on whether the team won or lost, but also on how surprising that result was.

"Good rating systems do not remove uncertainty from sport. They measure it consistently."

Why Start With Elo?

Modern sports analytics makes use of increasingly sophisticated statistical and machine learning models. So why begin with something as simple as Elo?

The answer is that good modelling begins with understanding. Before introducing additional variables or more complex algorithms, we should first build a model whose behaviour we completely understand. If the simplest model does not behave sensibly, adding complexity rarely fixes the underlying problem.

Throughout the SharpModels series we follow one engineering principle:

"Build the simplest model that answers the current question before introducing additional complexity."

Elo is an excellent starting point because it is transparent, computationally efficient and straightforward to validate. More importantly, it provides a robust baseline estimate of team strength that can later become one input within more sophisticated predictive models.

This first implementation deliberately excludes home advantage, margin of victory, seasonal carryover and other refinements. By introducing one modelling assumption at a time throughout the series, we can clearly measure the contribution of every improvement instead of changing multiple parts of the model simultaneously.

By the End of This Article

By the time you finish this article you will understand:

  • Why sports models need a rating system.
  • How Elo measures team strength.
  • How expected game outcomes are calculated from team ratings.
  • How ratings are updated after every game.
  • Why games must be processed chronologically.
  • Why this baseline implementation deliberately ignores several real-world factors.
  • How to build a complete baseline NFL Elo model in Python.

No prior knowledge of Elo is assumed. Every modelling decision, equation and section of code will be introduced before it is used, with the emphasis placed on understanding why each step is necessary, not simply how to reproduce it.

The first step is understanding how Elo transforms team ratings into expected game outcomes. Once that idea is clear, the Python implementation becomes surprisingly simple.

How Elo Turns Ratings Into Expectations

We now have a way of measuring team strength. The next question is just as important:

"Given the ratings of two teams, what should we expect to happen when they play each other?"

Notice the wording carefully. We are not asking which team will win. We are asking what the model should expect to happen before the game begins.

Sport is uncertain. Strong teams lose. Underdogs win. A good rating system should never pretend otherwise.

Instead of making a yes-or-no prediction, Elo estimates an expected score. In a two-outcome setting this can be interpreted as an expected win probability. For example, if a team has a 70% expected chance of winning, we would still expect it to lose roughly three games out of every ten.

Building Some Intuition

Before looking at the mathematics, consider three simple examples.

Example 1: Team A and Team B both have an Elo rating of 1500.

Since the model currently believes both teams are equally strong, it should expect each team to win about half the time. The expected score is therefore 0.50 for each team.

Example 2: Team A has an Elo rating of 1600 and Team B has a rating of 1500.

Team A is now rated 100 points higher. The model should make Team A the favourite, but it should not expect Team A to win every game. There is still plenty of uncertainty.

Example 3: Team A has an Elo rating of 1800 while Team B has a rating of 1400.

The difference in ratings is now much larger, so Team A should be considered a much stronger favourite. Even so, Elo never assigns a 100% expectation. No matter how large the rating difference becomes, unexpected results are always possible.

These examples illustrate the behaviour we want from the model. As the rating difference increases, the stronger team's expected score should increase smoothly, without ever reaching absolute certainty.

We now need a mathematical function that converts rating differences into expected scores.

The Expected Score Formula

E(A) = 1 / (1 + 10^((R_B - R_A) / 400))

At first glance this equation may look more complicated than it really is. You do not need to memorise it. The important thing is understanding what it does.

Each part has a simple meaning:

  • RA is Team A's current Elo rating.
  • RB is Team B's current Elo rating.
  • E(A) is Team A's expected score before the game.

The formula depends only on the difference between the two ratings. Their absolute values are not important. If every team in the league gained exactly 100 rating points, every expected score would remain unchanged because the differences between teams would stay exactly the same.

This is why teams are commonly given an initial Elo rating of 1500. The choice of 1500 is simply a convenient starting point. It is not a special value. Only the differences between team ratings influence the model.

The number 400 controls how quickly expected scores change as rating differences increase. Larger values produce a flatter curve, while smaller values produce a steeper one. The traditional Elo system uses 400, and we keep that standard throughout this series so the underlying methodology remains easy to understand.

From Expected Results to Rating Updates

Calculating the expected score is only half of the Elo algorithm. Once the game has finished, we compare what the ratings expected with what actually happened.

The rating update is calculated using:

R_new = R_old + K × (Actual − Expected)

Here, Actual is simply the game's outcome from the team's perspective:

  • 1 for a win.
  • 0.5 for a tie.
  • 0 for a loss.

Expected is the expected score calculated before the game using the Elo equation.

The difference between these two values tells us how surprising the result was.

For example, suppose a team was expected to win with probability 0.75.

  • If it wins, then Actual − Expected = 1.00 − 0.75 = +0.25.
  • If it loses, then Actual − Expected = 0.00 − 0.75 = −0.75.

Now consider a much weaker team that was expected to win with probability only 0.20.

  • If it wins unexpectedly, then Actual − Expected = 1.00 − 0.20 = +0.80.

Notice how much larger the adjustment becomes. The model has learned far more from an unexpected victory than from a result it already considered likely.

This simple idea is the heart of Elo. Teams gain rating points when they perform better than expected and lose rating points when they perform worse than expected. The more surprising the result, the larger the rating adjustment.

One question still remains. Should every surprise change the ratings by exactly the same amount?

The answer is no. The size of each update is controlled by a parameter called the K-factor.

The K-Factor

The K-factor controls how quickly Elo ratings respond to new results.

A larger K-factor makes the ratings move more aggressively after each game. This allows the model to respond quickly to new information, but it can also make the ratings noisy and overly reactive.

A smaller K-factor makes the ratings more stable. This reduces noise, but it can also make the model slow to recognise genuine changes in team strength.

In this introductory implementation we use:

K = 20

This is not claimed to be optimal. It is a simple educational value that produces stable, easy-to-understand ratings. Later in the modelling process, parameters such as K can be tested and refined. For now, the goal is to build a clear baseline model.

From Mathematics to Python

At this point we understand the mathematics behind Elo. The remaining task is simply to translate those ideas into Python.

Although the equations may initially appear complex, the implementation is remarkably straightforward. The computer performs exactly the same sequence of steps for every historical game.

For each game, the algorithm:

  1. Looks up the current Elo ratings of both teams.
  2. Calculates the expected result from those ratings.
  3. Reads the actual game result.
  4. Updates both team ratings.
  5. Saves the new ratings ready for the next game.
  6. Repeats the process for every remaining game.

There are no shortcuts. Every rating depends on every game that came before it, so the entire history must be processed one game at a time.

Before we write the code, we first need the historical game results that drive those updates.

Historical NFL Results Dataset

One of Elo's greatest strengths is how little information it requires.

Unlike many modern predictive models, Elo does not require player statistics, weather data, advanced metrics, tracking data or hundreds of engineered features. It only needs the information required to identify the two teams, determine the winner and place the game in the correct position within history.

Throughout this series we use a clean historical NFL results dataset containing one row for every completed game.

The dataset is available in two formats:

Both files contain identical information. The Parquet version is faster to load and more efficient for larger datasets, while the CSV version is convenient for inspection in spreadsheet software.

What Information Does Elo Actually Need?

Each game contains only a small number of fields that are required by the algorithm:

  • Home team
  • Away team
  • Final scores
  • Game date
  • Season
  • Neutral-site indicator

Notice what is not included.

  • Player statistics
  • Passing or rushing yards
  • Weather conditions
  • Injury reports
  • Possession statistics
  • Any manually engineered features

Those variables may improve more sophisticated models later in the modelling process, but they are not required for a baseline Elo implementation. The purpose of this article is to understand the core rating system before introducing additional complexity.

Why Chronological Order Matters

Elo is a recursive algorithm. Every rating depends on the ratings that existed before the game was played, and those ratings depend on every game that came before them.

This means games must be processed in chronological order.

Imagine calculating Week 18 before Week 1. Teams would effectively be learning from future results that had not yet happened, producing ratings that could never have existed in reality.

In predictive modelling this is often called information leakage: allowing information from the future to influence calculations made in the past. It is one of the most common causes of overly optimistic model performance.

"A model should never learn from information that would not have been available at the time the prediction was made."

By processing games in chronological order, every pre-game Elo rating is based only on information that genuinely existed before kickoff.

Handling NFL Ties

Although ties are uncommon in the NFL, Elo handles them naturally.

Instead of treating a tie as either a win or a loss, both teams receive an Actual score of 0.5.

A tie sits exactly halfway between winning and losing, so assigning a value of 0.5 preserves the logic of the Elo update equation.

If the stronger team was expected to win comfortably, its rating decreases slightly after a tie because it performed below expectation. Conversely, the weaker team gains rating points because it achieved a better result than the model anticipated.

Preparing for the Python Implementation

At this point there are no new modelling concepts left to learn.

We understand how team strength is represented, how expected results are calculated, how ratings are updated, why games must be processed chronologically, and how ties are handled.

The Python implementation simply translates each of those ideas into code. As you read through the script, each section should correspond directly to a concept we have already discussed.

Let's build the model.

Building the Baseline Elo Model in Python

The code below builds the baseline Elo model and saves a game-level output dataset.

This means each row still represents one NFL game. We add the pre-game and post-game Elo ratings for both the home and away teams, then save the result for later use.

We deliberately stop there in this article. In the next article, we will transform this game-level output into a team-level history table with two rows per game: one from the home team's perspective and one from the away team's perspective. That doubled table is useful for visualisation, but it is not part of the core Elo update algorithm.

Implementation Decisions

Before looking at the code, there are four implementation decisions worth understanding.

1. Polars

We use Polars throughout the SharpModels series because it provides a fast, modern DataFrame library with excellent performance on larger datasets. Nothing about the Elo algorithm depends on Polars specifically, but using the same library throughout the series keeps the examples consistent.

2. A Dictionary Stores Current Ratings

During the calculation we only need one piece of information for each team: its current Elo rating.

A Python dictionary is ideal for this because ratings can be retrieved instantly using the team name as the key. After every game, the old rating is replaced with the newly calculated rating, ready for the team's next appearance.

3. Games Are Processed Sequentially

Many modern data-processing tasks can be vectorised so that thousands of rows are calculated simultaneously. Elo is different.

Every game's ratings depend on the ratings produced by previous games. Because of this dependency, the algorithm must process games one at a time. The sequential loop is therefore not a limitation of Python; it is a requirement of the Elo algorithm itself.

4. We Store Both Pre-Game and Post-Game Ratings

Before every update we record each team's current Elo rating. After the update we record the new rating.

Keeping both values serves two purposes.

  • The pre-game ratings represent the information available before kickoff.
  • The post-game ratings become the starting ratings for future games.

Recording both also makes it much easier to validate the implementation and visualise how ratings evolve over time in the next article.

The Complete Python Implementation

With those design decisions explained, the code should now feel much more familiar. Each section corresponds directly to one of the modelling steps discussed throughout this article.

from pathlib import Path

import polars as pl


# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Every team starts with the same Elo rating.
BASE_ELO = 1500.0

# K controls how quickly ratings move after each game.
# Higher K = ratings react faster, but become noisier.
K = 20.0

# Input and output files.
INPUT_FILE = "nfl_games_data.csv"
GAME_LEVEL_OUTPUT_CSV = "nfl_elo_game_level.csv"
GAME_LEVEL_OUTPUT_PARQUET = "nfl_elo_game_level.pq"


# -----------------------------------------------------------------------------
# Elo expected score
# -----------------------------------------------------------------------------
def expected_score(rating_a, rating_b):
    """
    Return team A's expected score against team B.

    If both teams have the same rating, the expected score is 0.50.
    If team A is rated higher, the expected score is above 0.50.
    If team A is rated lower, the expected score is below 0.50.
    """
    return 1.0 / (1.0 + 10 ** ((rating_b - rating_a) / 400.0))


# -----------------------------------------------------------------------------
# Load historical NFL results
# -----------------------------------------------------------------------------
# Required columns:
# match_id, date_timestamp, home_team, away_team, home_score, away_score
df = pl.read_csv(INPUT_FILE)

# Process games in date order. This is essential because each team's rating
# before a game must only use information from games that had already happened.
df = df.sort(["date_timestamp", "match_id"])


# -----------------------------------------------------------------------------
# Build game-level Elo ratings
# -----------------------------------------------------------------------------
# This dictionary stores the latest rating for each team as we move through time.
elo = {}

# This list stores each game row after we add the Elo fields.
rows = []

for row in df.iter_rows(named=True):
    home_team = row["home_team"]
    away_team = row["away_team"]

    home_score = row["home_score"]
    away_score = row["away_score"]

    # Skip games without a final score.
    if home_score is None or away_score is None:
        continue

    # Get each team's rating before the game.
    # If we have not seen the team before, start it at BASE_ELO.
    home_elo_pre = elo.get(home_team, BASE_ELO)
    away_elo_pre = elo.get(away_team, BASE_ELO)

    # Calculate the home team's expected result before the game.
    home_expected = expected_score(home_elo_pre, away_elo_pre)

    # Convert the actual scoreline into an Elo result.
    # Win = 1.0, loss = 0.0, tie = 0.5.
    if home_score > away_score:
        home_actual = 1.0
        away_actual = 0.0
    elif home_score < away_score:
        home_actual = 0.0
        away_actual = 1.0
    else:
        home_actual = 0.5
        away_actual = 0.5

    # Update both teams.
    # The away update is the opposite side of the same result.
    home_elo_post = home_elo_pre + K * (home_actual - home_expected)
    away_elo_post = away_elo_pre - K * (home_actual - home_expected)

    # Store the new ratings for each team's next game.
    elo[home_team] = home_elo_post
    elo[away_team] = away_elo_post

    # Add Elo columns to this game row.
    row.update(
        {
            "home_elo_pre": home_elo_pre,
            "home_elo_post": home_elo_post,
            "away_elo_pre": away_elo_pre,
            "away_elo_post": away_elo_post,
            "home_expected": home_expected,
            "away_expected": 1.0 - home_expected,
            "home_actual": home_actual,
            "away_actual": away_actual,
        }
    )

    rows.append(row)


# -----------------------------------------------------------------------------
# Create game-level output DataFrame
# -----------------------------------------------------------------------------
# This output keeps one row per game.
df_elo = pl.DataFrame(rows)


# -----------------------------------------------------------------------------
# Save game-level output
# -----------------------------------------------------------------------------
df_elo.write_csv(GAME_LEVEL_OUTPUT_CSV)
df_elo.write_parquet(GAME_LEVEL_OUTPUT_PARQUET)


# -----------------------------------------------------------------------------
# Quick checks
# -----------------------------------------------------------------------------
print(f"Loaded games: {df.height:,}")
print(f"Game-level rows saved: {df_elo.height:,}")
print(f"Game-level CSV: {GAME_LEVEL_OUTPUT_CSV}")
print(f"Game-level Parquet: {GAME_LEVEL_OUTPUT_PARQUET}")

Understanding the Implementation

Notice how closely the code follows the algorithm described earlier.

  1. The historical games are loaded and sorted into chronological order.
  2. Each team's current rating is retrieved from the dictionary.
  3. The expected result is calculated from those ratings.
  4. The actual game result is converted into 1, 0.5 or 0.
  5. Both ratings are updated using the Elo equation.
  6. The new ratings are stored ready for the next game.
  7. The updated game record is added to the output dataset.

Although the implementation contains only a few dozen lines of Python, it produces a complete historical Elo rating for every NFL team across every game in the dataset.

More importantly, every rating is calculated using only information that would have been available before that game was played. This preserves the integrity of the model and allows the ratings to be used safely in later predictive modelling.

Understanding the Output

Running the Python script produces a game-level Elo dataset. Each row still represents one NFL game, but it now includes the Elo ratings that existed before and after that game.

The most important columns created by the script are:

Column Description
home_elo_pre The home team's Elo rating immediately before kickoff.
away_elo_pre The away team's Elo rating immediately before kickoff.
home_elo_post The home team's Elo rating immediately after the game.
away_elo_post The away team's Elo rating immediately after the game.
home_expected The home team's expected score before the game.
away_expected The away team's expected score before the game.
home_actual The home team's actual Elo result: 1 for win, 0.5 for tie, 0 for loss.
away_actual The away team's actual Elo result: 1 for win, 0.5 for tie, 0 for loss.

Together, these values describe what the model believed before each game and how that belief changed after the result was known.

Why Are Pre-Game Ratings Important?

The pre-game ratings are usually the most valuable output of the model.

They represent the model's estimate of each team's strength using only the information that existed before kickoff. Because they contain no knowledge of the game's outcome, they can safely be used as inputs for prediction and further modelling.

Imagine wanting to know what the model believed before the Kansas City Chiefs played the Buffalo Bills in a particular week. The pre-game ratings allow you to reconstruct that exact point in history, showing precisely what the model knew before the first snap.

Throughout the remainder of this series, almost every extension to the Elo model will build upon these pre-game ratings.

Why Are Post-Game Ratings Important?

The post-game ratings become the starting point for each team's next game. They allow information to flow naturally through an entire season, with every result contributing to the team's future rating.

Without the post-game ratings, the model would have no memory. Every game would begin from the same starting value, preventing the ratings from adapting as teams improve or decline over time.

This is why Elo is often described as a dynamic rating system rather than a static ranking. Every game provides new evidence, and every rating reflects everything the model has learned up to that point.

Why Store Every Historical Rating?

Saving the ratings for every historical game provides much more than a final league table.

It allows us to reconstruct the complete rating history of every team, examine periods of rapid improvement or decline, compare teams across different seasons, and investigate how the model responds to important events throughout a year.

More importantly, storing the full rating history allows us to validate the model itself. We can check whether ratings evolve smoothly, identify unexpected jumps, and verify that the implementation behaves exactly as intended.

The Next Stage: Visualisation

Experienced modellers rarely extend a model immediately after writing it. The first step is to verify that it behaves sensibly.

One of the simplest and most effective validation techniques is visualisation. Charts often reveal implementation errors, unexpected behaviour or unusual rating movements that are difficult to detect by looking at numbers alone.

The game-level output created in this article is the foundation for that next step. However, a one-row-per-game dataset is not ideal for plotting team histories. Each game contains two teams, but each row represents only the game itself.

In the next article, we will reshape this output into a team-level table with two rows per game: one from the home team's perspective and one from the away team's perspective. That structure makes it much easier to plot each team's Elo history through time.

In the next article, Visualising NFL Elo Ratings Through Time , we use that team-level view to plot the rating history of every NFL team and confirm that the baseline implementation behaves as expected before introducing additional modelling assumptions.

"A model that cannot be validated should not be extended."

Key Takeaways

  • Elo provides a simple numerical estimate of team strength.
  • Ratings change after every game by comparing expected results with actual results.
  • Unexpected results produce larger rating movements than expected results.
  • Games must be processed chronologically to avoid information leakage.
  • The baseline model deliberately excludes home advantage, margin of victory and seasonal carryover.
  • Article 1 saves a game-level Elo dataset with one row per game.
  • The team-level history table is created in Article 2 for visualisation.

Continue the Series