Introduction
In the previous article we built a baseline NFL Elo model from historical results. The implementation followed the Elo algorithm exactly, processed games in chronological order and saved a game-level dataset containing pre-game and post-game ratings for both teams in every match.
However, a model that produces numbers is not necessarily a model that can be trusted.
Before using those ratings in further analysis or extending the model with additional assumptions, we first need to answer a fundamental question:
"Do the ratings evolve in a way that is consistent with the history of the NFL?"
In Article 1 we deliberately built the simplest possible Elo implementation. That simplicity now becomes an advantage because we know exactly how every rating was calculated. If something looks wrong, there are relatively few places where the problem can exist.
This article is therefore not about building a new model. It is about reshaping, visualising and validating the one we have already created.
Why Validation Comes Before Improvement
Once a model appears to work, it is tempting to improve it immediately. We might want to introduce home advantage, margin of victory, player information or more sophisticated statistical techniques.
Doing so too early often makes development more difficult rather than easier. If several new assumptions are introduced at the same time and the model begins to behave unexpectedly, it becomes much harder to determine which change caused the problem.
Good modelling practice is to validate a baseline implementation before introducing additional assumptions.
"Validate the baseline before introducing new assumptions."
Throughout the SharpModels series we improve the model one step at a time. By changing only a single component in each article, we can measure the effect of that change independently and avoid confusing multiple improvements with one another.
Validation is also not a one-time task. Every time a new modelling assumption is introduced, the updated model should be checked again to ensure it continues to behave as expected.
Why Visualise Elo Ratings?
Elo produces a numerical rating after every game, but long columns of numbers rarely reveal much on their own. Humans are naturally good at recognising patterns, trends and unexpected behaviour when information is presented visually.
A well-designed chart can immediately help answer questions such as:
- Do strong teams remain strong over sustained periods?
- Do weaker teams improve or decline gradually?
- Do ratings evolve smoothly through time?
- Are there any unexpected jumps or discontinuities?
- Does the model produce a believable representation of NFL history?
Charts are therefore much more than presentation tools. They are one of the most effective ways to validate a rating system before building more sophisticated models on top of it.
"Before improving a model, first make sure you understand its current behaviour."
From Game-Level Data to Team-Level History
Article 1 saved a game-level Elo dataset. That means each row represents one NFL game.
This is the correct structure for building the Elo model because Elo updates happen one game at a time. Each row contains the home team, away team, final score, pre-game ratings and post-game ratings.
For visualisation, however, this structure is slightly awkward.
If we want to plot the history of a single team, we do not really want a table where that team sometimes appears in the home columns and sometimes appears in the away columns. We want one consistent set of columns:
team_nameopponent_nameteam_elo_preteam_elo_postopponent_elo_preopponent_elo_post
To create that structure, we convert each game into two rows:
- one row from the home team's perspective,
- one row from the away team's perspective.
This does not change the Elo ratings. It simply reshapes the data into a more useful format for plotting, filtering and analysing team histories.
"A game-level table is ideal for calculating Elo. A team-level table is ideal for visualising Elo."
Why Doubling the Table Matters
Consider a single game between Team A and Team B.
In the game-level dataset, that game appears once:
| home_team | away_team | home_elo_pre | away_elo_pre |
|---|---|---|---|
| Team A | Team B | 1600 | 1500 |
For plotting team histories, we want that same game to appear once for each team:
| team_name | opponent_name | is_home_team | team_elo_pre | opponent_elo_pre |
|---|---|---|---|---|
| Team A | Team B | 1 | 1600 | 1500 |
| Team B | Team A | 0 | 1500 | 1600 |
Now every row has the same meaning: it describes one team's perspective in one game.
This makes the visualisation code much simpler because we can filter by
team_name and plot team_elo_pre through time.
Why Plot Pre-Game Ratings?
Throughout this series we visualise each team's pre-game Elo rating.
These ratings represent the model's estimate of team strength immediately before kickoff, using only information that was available at that point in time. Because they contain no knowledge of future results, they provide the correct perspective for validating the model's behaviour.
Plotting post-game ratings would produce very similar curves, but pre-game ratings are the values that will later be used for prediction and further modelling. They therefore provide the most meaningful view of how the model evolves through time.
Designing the Visualisation
Before looking at the Python code, it is worth understanding why the charts are designed in this particular way.
Effective visualisation is not simply about making a figure look attractive. Its purpose is to communicate information clearly and make important patterns easy to recognise. Every design decision in this article has been made to support that goal.
One Team Per Chart
With thirty-two NFL teams, plotting every rating history on a single set of axes would quickly become cluttered. Important trends would overlap, making individual franchises difficult to interpret.
Instead, each team is given its own panel. This allows long-term changes in rating to be followed clearly while still making it easy to compare different teams across the league.
A Shared Vertical Scale
Every subplot shares the same vertical axis. This is a deliberate choice.
Using a common scale ensures that a change of 100 Elo points has exactly the same visual meaning on every chart. Without a shared axis, identical rating movements could appear dramatically different, making comparisons between teams unreliable.
The 1500 Reference Line
The dashed horizontal line marks the initial Elo rating assigned to every team when the baseline model begins.
Although Elo is driven by rating differences rather than absolute values, this reference line provides useful context. It makes it immediately obvious when a team has spent long periods above or below its starting baseline.
The Complete Python Implementation
The code below loads the game-level Elo output created in Article 1, converts it into a team-level history table, saves that reshaped dataset and then produces two visualisations:
- a league-wide grid showing every team's Elo history,
- a single-team chart for the New England Patriots.
import math
import datetime
import polars as pl
import matplotlib.pyplot as plt
# -----------------------------------------------------------------------------
# Input and output files
# -----------------------------------------------------------------------------
# Article 1 created this game-level Elo file.
GAME_LEVEL_INPUT = "nfl_elo_game_level.pq"
# Article 2 creates this team-level Elo history file.
TEAM_LEVEL_OUTPUT_CSV = "nfl_elo_team_level.csv"
TEAM_LEVEL_OUTPUT_PARQUET = "nfl_elo_team_level.pq"
# Chart outputs.
LEAGUE_CHART_OUTPUT = "nfl_elo_grid_clean.png"
TEAM_CHART_OUTPUT = "elo_new_england_patriots_clean.png"
# -----------------------------------------------------------------------------
# Load game-level Elo output from Article 1
# -----------------------------------------------------------------------------
game_df = pl.read_parquet(GAME_LEVEL_INPUT)
game_df = game_df.sort(["date_timestamp", "match_id"])
# -----------------------------------------------------------------------------
# Convert game-level data into team-level history
# -----------------------------------------------------------------------------
# The game-level table has one row per game.
# For visualisation, we create two rows per game:
# one from the home team's perspective and one from the away team's perspective.
home_view = game_df.select(
[
"match_id",
"date",
"date_timestamp",
"season",
"season_end_year",
"match_type",
"neutral",
pl.col("home_team").alias("team_name"),
pl.col("away_team").alias("opponent_name"),
pl.lit(1).alias("is_home_team"),
pl.col("home_score").alias("team_score"),
pl.col("away_score").alias("opponent_score"),
pl.col("home_actual").alias("actual_result"),
pl.col("home_expected").alias("expected_score"),
pl.col("home_elo_pre").alias("team_elo_pre"),
pl.col("home_elo_post").alias("team_elo_post"),
pl.col("away_elo_pre").alias("opponent_elo_pre"),
pl.col("away_elo_post").alias("opponent_elo_post"),
]
)
away_view = game_df.select(
[
"match_id",
"date",
"date_timestamp",
"season",
"season_end_year",
"match_type",
"neutral",
pl.col("away_team").alias("team_name"),
pl.col("home_team").alias("opponent_name"),
pl.lit(0).alias("is_home_team"),
pl.col("away_score").alias("team_score"),
pl.col("home_score").alias("opponent_score"),
pl.col("away_actual").alias("actual_result"),
pl.col("away_expected").alias("expected_score"),
pl.col("away_elo_pre").alias("team_elo_pre"),
pl.col("away_elo_post").alias("team_elo_post"),
pl.col("home_elo_pre").alias("opponent_elo_pre"),
pl.col("home_elo_post").alias("opponent_elo_post"),
]
)
team_df = (
pl.concat([home_view, away_view])
.sort(["date_timestamp", "match_id", "is_home_team"])
)
# -----------------------------------------------------------------------------
# Save team-level output
# -----------------------------------------------------------------------------
team_df.write_csv(TEAM_LEVEL_OUTPUT_CSV)
team_df.write_parquet(TEAM_LEVEL_OUTPUT_PARQUET)
# -----------------------------------------------------------------------------
# Prepare shared chart values
# -----------------------------------------------------------------------------
teams = sorted(team_df["team_name"].unique().to_list())
elo_min = float(team_df["team_elo_pre"].min())
elo_max = float(team_df["team_elo_pre"].max())
# Add a little vertical padding so lines do not touch the chart edges.
padding = 25
elo_min -= padding
elo_max += padding
# -----------------------------------------------------------------------------
# Chart 1: League-wide Elo history
# -----------------------------------------------------------------------------
cols = 4
rows = math.ceil(len(teams) / cols)
fig, axes = plt.subplots(
rows,
cols,
figsize=(cols * 4.0, rows * 2.8),
sharey=True
)
axes = axes.flatten()
for i, team in enumerate(teams):
ax = axes[i]
df_team = (
team_df
.filter(pl.col("team_name") == team)
.sort("date_timestamp")
)
dates = [
datetime.datetime.utcfromtimestamp(int(ts))
for ts in df_team["date_timestamp"].to_list()
]
ax.plot(dates, df_team["team_elo_pre"].to_list())
ax.axhline(1500, linestyle="--", alpha=0.3)
ax.set_title(team, fontsize=9)
ax.set_ylim(elo_min, elo_max)
ax.tick_params(axis="x", labelrotation=45, labelsize=7)
ax.tick_params(axis="y", labelsize=7)
# Remove unused subplot panels.
for j in range(i + 1, len(axes)):
fig.delaxes(axes[j])
fig.suptitle("NFL Pre-Match Elo History — Clean Model", fontsize=16)
plt.tight_layout(rect=[0, 0, 1, 0.97])
plt.savefig(LEAGUE_CHART_OUTPUT, dpi=160)
plt.show()
# -----------------------------------------------------------------------------
# Chart 2: Single-team Elo history
# -----------------------------------------------------------------------------
team_to_plot = "New England Patriots"
patriots_df = (
team_df
.filter(pl.col("team_name") == team_to_plot)
.sort("date_timestamp")
)
dates = [
datetime.datetime.utcfromtimestamp(int(ts))
for ts in patriots_df["date_timestamp"].to_list()
]
plt.figure(figsize=(12, 5))
plt.plot(dates, patriots_df["team_elo_pre"].to_list())
plt.axhline(1500, linestyle="--", alpha=0.3)
plt.title(f"{team_to_plot} Pre-Match Elo History — Clean Model")
plt.xlabel("Date")
plt.ylabel("Pre-Match Elo Rating")
plt.tight_layout()
plt.savefig(TEAM_CHART_OUTPUT, dpi=160)
plt.show()
# -----------------------------------------------------------------------------
# Quick checks
# -----------------------------------------------------------------------------
print(f"Game-level rows loaded: {game_df.height:,}")
print(f"Team-level rows saved: {team_df.height:,}")
print(f"Teams plotted: {len(teams)}")
print(f"Team-level CSV: {TEAM_LEVEL_OUTPUT_CSV}")
print(f"Team-level Parquet: {TEAM_LEVEL_OUTPUT_PARQUET}")
print(f"League chart: {LEAGUE_CHART_OUTPUT}")
print(f"Team chart: {TEAM_CHART_OUTPUT}")
Understanding the Data Transformation
The most important step in the code is the creation of home_view and
away_view.
The home view takes the home-team columns from the game-level dataset and renames them
into generic team-level columns. For example, home_team becomes
team_name, while away_team becomes opponent_name.
The away view performs the same operation in reverse. The away team becomes
team_name, and the home team becomes opponent_name.
Once those two views have the same column names, they can be stacked with
pl.concat(). The result is a team-level table where every row describes one
team's perspective in one game.
This is why the number of rows doubles. If the game-level dataset contains 8,000 games, the team-level dataset will contain 16,000 team-game rows.
Understanding the Visualisation Code
Once the data has been reshaped, plotting team histories becomes straightforward.
The script identifies every unique team name, creates a grid of subplots and then filters
the team-level table one team at a time. Each team's team_elo_pre values are
plotted through time.
Because all teams now use the same column names, the plotting code does not need to know whether a team was home or away in any particular game. That detail has already been handled during the reshaping step.
"Good data structure makes the analysis code simpler."
League-Wide Elo History
The figure below shows the complete pre-game Elo history for every NFL team in the dataset. Each panel represents a single franchise, while every point on a line represents that team's Elo rating immediately before one of its games.
The dashed horizontal line marks an Elo rating of 1500, the initial rating assigned to every team when the baseline model begins. Ratings above this line indicate teams that the model currently considers stronger than their starting baseline, while ratings below the line indicate teams that have performed below that baseline.
The most recent season in the dataset is incomplete at the time of writing. As a result, the right-hand side of some charts should be interpreted as work in progress rather than a complete season history.
How to Read an Elo Chart
When viewing an Elo chart for the first time, it is natural to focus on the exact rating values. In practice, the overall shape of the curve is usually far more informative than any individual number.
As you examine the charts, ask yourself the following questions.
Do Ratings Change Smoothly?
Team strength usually changes gradually rather than overnight. A well-behaved Elo model should therefore produce curves that rise and fall steadily as new results accumulate.
Large unexplained jumps are often worth investigating. They may indicate an implementation issue, a problem with the underlying data or a modelling assumption that deserves closer examination.
Do Strong Teams Stay Strong?
One of Elo's greatest strengths is that it remembers the past without becoming trapped by it.
A dominant team should not become average after a single defeat, just as a struggling team should not become elite after one unexpected victory. Instead, ratings should evolve steadily as evidence accumulates over many games.
Do Long-Term Trends Make Sense?
Over multiple seasons we expect successful organisations to spend extended periods above their initial rating, while rebuilding teams often remain below it until their performances improve consistently.
The overall trajectory of each franchise should broadly align with the historical evolution of the NFL, even though no rating system will perfectly capture every aspect of team strength.
Are There Any Unexpected Patterns?
Visualisation is one of the fastest ways to identify potential implementation problems.
Examples worth investigating include:
- Large unexplained jumps in ratings.
- Extreme week-to-week volatility.
- Teams remaining permanently close to 1500 despite long periods of success or failure.
- The entire league gradually drifting upwards or downwards over time.
None of these automatically proves that the implementation is incorrect. They are simply signals that deserve further investigation before introducing additional modelling assumptions.
"Good validation asks whether the model behaves sensibly before asking whether it can be improved."
What This Chart Does — And Does Not — Tell Us
The league-wide Elo history provides encouraging evidence that the baseline implementation behaves sensibly. Ratings evolve smoothly, long-term periods of success and decline are visible, and there are no obvious discontinuities that immediately suggest a fundamental implementation error.
However, these charts do not prove that the model is perfect.
They do not tell us whether Elo is the best possible rating system, whether the chosen K-factor is optimal or whether home advantage should be incorporated. Those are separate modelling questions that we will investigate later in the series.
What the charts do provide is confidence that the baseline implementation behaves in a logical and consistent manner. That confidence is important because every enhancement introduced in later articles will build upon this foundation.
Case Study: The New England Patriots
Examining the league-wide view gives us confidence that the baseline Elo model behaves sensibly overall, but it can sometimes be difficult to appreciate the finer details when thirty-two teams are displayed simultaneously.
To explore the model in more detail, it is useful to focus on a single franchise. The New England Patriots provide an excellent case study because their history contains prolonged periods of success, gradual decline and the beginning of a new upward trend. These changing fortunes allow us to see how Elo responds as new evidence accumulates over time.
Interpreting the Patriots Elo History
Rather than concentrating on individual games, focus on the overall shape of the curve. Elo is designed to measure long-term changes in team strength, so the broad trends are usually far more informative than short-term fluctuations.
Early Seasons
During the earlier part of the dataset, the Patriots spend much of their time close to their initial Elo rating, occasionally moving above or below it as results fluctuate. At this stage the model sees little sustained evidence that the team is consistently stronger or weaker than its starting baseline.
A Sustained Rise
As successful seasons accumulate, the rating climbs steadily. Notice that the increase is gradual rather than sudden.
This is an important characteristic of Elo. A single good season does not immediately create one of the highest-rated teams in the league. Instead, confidence increases as consistent results continue to support the model's estimate of team strength.
Maintaining a High Rating
Once the Patriots establish themselves well above their starting baseline, they remain there for an extended period.
This demonstrates another strength of Elo. The model retains information from previous seasons while continuing to respond to new results. Individual defeats cause temporary corrections, but they do not erase years of accumulated evidence.
A Gradual Decline
Later in the chart the long-term trend begins to reverse. As less favourable results accumulate, the Elo rating declines steadily over multiple seasons.
Once again the change is progressive rather than abrupt. Elo does not forget previous performance overnight. Instead, its estimate of team strength adapts gradually as more games are played.
The Most Recent Results
The right-hand side of the chart should be interpreted with some care. At the time of writing, the most recent NFL season included in the dataset is incomplete, so the final portion of the curve represents the latest available historical data rather than a finished season.
What Does This Tell Us About the Model?
This single chart illustrates many of the characteristics we hope to see from a well-behaved Elo implementation.
- Ratings evolve smoothly rather than changing abruptly.
- Sustained success produces sustained high ratings.
- Periods of decline emerge gradually as new evidence accumulates.
- The model adapts continuously without overreacting to individual games.
These observations do not prove that the Elo model is optimal. They do, however, provide further evidence that the baseline implementation behaves in a logical, stable and internally consistent manner.
"A good rating system adapts to new evidence without overreacting to individual results."
What We Have Learned
Building an Elo model is only the beginning. Before extending it with additional assumptions, we first need confidence that the baseline implementation behaves as intended.
In this article we used visualisation as a validation tool rather than simply as a way of presenting results. To do that properly, we first reshaped the game-level Elo output into a team-level history table with two rows per game.
That transformation is an important modelling habit. The best data structure for calculating a model is not always the best data structure for analysing or visualising its output.
The charts show that the baseline Elo implementation produces smooth rating movements, gradual long-term changes in team strength, and no obvious behaviour that immediately suggests a fundamental implementation problem.
Although visualisation alone cannot prove that a model is correct, it provides valuable evidence that the implementation behaves sensibly and is ready for further development.
"Validation does not prove a model is correct. It increases confidence that the model behaves as intended."
Where We Go Next
Every team in our baseline Elo model begins with the same initial rating, and so far we have treated every game as though it were played on neutral ground.
In reality, teams often perform differently when playing at home. That advantage has been observed across many sports and is one of the first improvements commonly added to Elo rating systems.
In the next article, Adding Home Advantage to NFL Elo Ratings , we extend the baseline model by introducing a simple home-field adjustment.
Because we have already validated the baseline implementation, we can now measure exactly how this single modelling change affects the behaviour of the ratings.
Key Takeaways
- Article 1 creates a game-level Elo dataset with one row per game.
- Article 2 converts that output into a team-level history table with two rows per game.
- The team-level table makes it easier to plot each franchise's Elo history.
- Visualisation is one of the most effective ways to validate an Elo model.
- Smooth long-term rating movements generally indicate a stable implementation.
- Validation should occur before introducing additional modelling assumptions.
- The baseline Elo model now provides a reliable foundation for further enhancements.