Introduction
Throughout this series, our Elo model has learned from every completed game. Each result has contributed new evidence about the relative strengths of NFL teams, allowing ratings to evolve as more games are played.
That naturally raises the next modelling question:
"Should that evidence remain equally valuable forever?"
The answer is usually no. As time passes, teams change. Players arrive and depart, coaches introduce new systems, injuries heal, young talent develops and organisations naturally improve or decline. Evidence collected last season is still valuable, but it is rarely as reliable as evidence collected more recently.
In the previous articles we built a baseline Elo model, validated its behaviour, and extended it with home advantage and margin of victory. Those additions improved how the model interpreted individual games, but they did not address what should happen when one season ends and another begins.
Without any adjustment, a team's final rating carries directly into Week 1 of the following season. That assumes the team's underlying strength has remained unchanged throughout the off-season, an assumption that is rarely true in professional sport.
In this article we introduce seasonal carryover, a simple adjustment that reduces confidence in older evidence while preserving the long-term knowledge accumulated by the Elo rating system.
Why Does Older Evidence Become Less Reliable?
Seasonal carryover is sometimes described as a rating reset, but that description is misleading.
The objective is not to erase what the model has learned. Historical results remain valuable because they contain genuine evidence about team strength. The challenge is recognising that the reliability of that evidence naturally declines as teams evolve over time.
A rating system that never reduces confidence in older results will tend to become overly anchored to the past, causing it to react more slowly when teams genuinely change.
Seasonal carryover addresses that problem by reducing confidence at the beginning of each season while preserving the relative ordering of teams established during previous years.
"Good predictive models do not forget the past. They simply become less confident in older evidence."
Designing a Seasonal Carryover Rule
Having established that older evidence should gradually become less influential, the next question is how that reduction in confidence should be incorporated into the Elo model.
One obvious solution would be to reset every team's rating back to the baseline Elo of 1500 at the start of each new season.
Although simple, that approach throws away a considerable amount of valuable information. A team that has consistently performed well over several seasons would begin the new year with exactly the same rating as a team that has struggled throughout the same period. The model would effectively forget everything it had learned.
At the opposite extreme, we could carry every rating forward unchanged. That preserves all historical information, but it also assumes that teams remain fundamentally unchanged from one season to the next. In practice, that makes the model slower to recognise genuine improvements and declines in team strength.
"A good carryover rule should reduce confidence in older evidence without discarding it."
What Should a Good Carryover Rule Do?
Before choosing a mathematical formula, it helps to define the behaviour we want from the model.
A good seasonal carryover rule should:
- Preserve the relative ordering of teams at the end of the previous season.
- Reduce confidence in ratings that are based on older evidence.
- Allow the model to adapt more quickly when teams genuinely change during the off-season.
- Apply the adjustment once, at the beginning of each new season.
- Remain simple, transparent and easy to validate.
These requirements suggest that ratings should move part of the way back towards the league average at the beginning of each season, rather than being completely reset or left unchanged.
This approach preserves the information accumulated during previous seasons while acknowledging that our confidence in older ratings should gradually decrease over time.
The Seasonal Carryover Formula
The simplest way to reduce confidence in older ratings is to move every team's Elo rating part of the way back towards the league average at the beginning of each new season.
Importantly, every team moves by the same proportion rather than the same number of Elo points. Teams that finished far above the league average remain above average, while teams that finished below average remain below average. The only difference is that every rating begins the new season a little closer to the middle.
We calculate the adjusted rating using the following equation:
new_rating = BASE_ELO + carryover × (old_rating − BASE_ELO)
Rather than memorising the equation, it helps to think about what it is doing.
- It measures how far the current rating is from the league average.
- It keeps only a chosen proportion of that difference.
- It places the team back that same distance from the league average.
Every team therefore moves towards the baseline Elo rating, but the relative ordering between teams is preserved. Strong teams still begin the season above average, weaker teams still begin below average, and no information from previous seasons is completely discarded.
"Seasonal carryover reduces confidence in previous ratings without forgetting what the model has already learned."
Try It Yourself: How Seasonal Carryover Resets Elo Ratings
Adjust the team rating and carryover value to see how ratings move back towards the league average at the start of a new season.
= 1500 + 135
= 1635
The team keeps 75% of its distance from 1500, so it remains above average but starts the new season closer to the league baseline.
Choosing a Carryover Value
The carryover constant determines how much of the previous season's rating
is retained.
- 0.90 retains most of the previous season's rating, producing only a small adjustment.
- 0.75 provides a balanced compromise between continuity and early-season adaptation.
- 0.50 produces a much stronger reset, allowing ratings to respond more quickly to genuine changes in team strength.
There is no universally correct carryover value. The most appropriate choice depends on the sport, the historical dataset, the modelling objective and the balance between long-term stability and responsiveness.
For the educational examples throughout this series, we use a carryover value of 0.75. The exact value is not the focus of this article. Its purpose is to demonstrate how seasonal carryover can be incorporated into the Elo framework while keeping the implementation simple and transparent.
In practice, production rating systems typically estimate parameters such as seasonal carryover using historical optimisation and ongoing validation. The values used in live systems will often differ from those used in educational examples and may evolve as additional seasons of data become available.
Implementing Seasonal Carryover in Python
With the modelling framework established, incorporating seasonal carryover requires only a small extension to the Elo implementation developed in the previous articles.
The overall structure of the algorithm remains unchanged. Games are still processed in chronological order, expected probabilities are calculated before each game, and Elo ratings are updated immediately after every result.
The only addition is a short block of logic that detects when a new season begins. At that point, every team's rating is adjusted once using the carryover rule before any games from the new season are processed.
"The objective is not to forget the past. The objective is to become appropriately less confident in older evidence."
The complete implementation is shown below. Compared with the previous article, only a small number of lines have been added.
import polars as pl
import math
# ------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------
INPUT_FILE = "nfl_games_data.csv"
GAME_LEVEL_OUTPUT_CSV = "nfl_elo_game_level_home_mov_season.csv"
GAME_LEVEL_OUTPUT_PARQUET = "nfl_elo_game_level_home_mov_season.pq"
BASE_ELO = 1500.0
K = 20.0
HOME_ADVANTAGE = 65.0
SEASON_CARRYOVER = 0.75
# ------------------------------------------------------------------
# Elo helper functions
# ------------------------------------------------------------------
def expected_score(r_a, r_b):
return 1.0 / (1.0 + 10 ** ((r_b - r_a) / 400.0))
def margin_multiplier(margin):
# Logarithmic scaling of the winning margin
return math.log(margin + 1.0)
def apply_carryover(rating, base, carryover):
# Move the rating part of the way back towards the baseline
return base + carryover * (rating - base)
# ------------------------------------------------------------------
# Load and prepare historical results
# ------------------------------------------------------------------
df = pl.read_csv(INPUT_FILE)
df = df.with_columns(
pl.when(pl.col("neutral").is_null())
.then(False)
.otherwise(pl.col("neutral"))
.cast(pl.Boolean)
.alias("neutral")
)
# ------------------------------------------------------------------
# Process games in chronological order
# ------------------------------------------------------------------
df = df.sort(["date_timestamp", "match_id"])
elo = {}
rows = []
current_season = None
for row in df.iter_rows(named=True):
season = row["season"]
# --------------------------------------------------------------
# Apply seasonal carryover once at the start of each new season
# --------------------------------------------------------------
if current_season is None:
current_season = season
elif season != current_season:
for team_name in list(elo.keys()):
elo[team_name] = apply_carryover(
elo[team_name],
BASE_ELO,
SEASON_CARRYOVER
)
current_season = season
home = row["home_team"]
away = row["away_team"]
hs = row["home_score"]
aas = row["away_score"]
if hs is None or aas is None:
continue
r_home = elo.get(home, BASE_ELO)
r_away = elo.get(away, BASE_ELO)
# --------------------------------------------------------------
# Apply home advantage when calculating expectations
# --------------------------------------------------------------
if row["neutral"]:
r_home_adj = r_home
else:
r_home_adj = r_home + HOME_ADVANTAGE
e_home = expected_score(r_home_adj, r_away)
e_away = 1.0 - e_home
# --------------------------------------------------------------
# Convert the final score into Elo outcomes and margin
# --------------------------------------------------------------
if hs > aas:
a_home, a_away = 1.0, 0.0
margin = hs - aas
elif hs < aas:
a_home, a_away = 0.0, 1.0
margin = aas - hs
else:
a_home, a_away = 0.5, 0.5
margin = 0
# --------------------------------------------------------------
# Calculate the margin-of-victory multiplier
# --------------------------------------------------------------
mov_mult = 1.0 if margin == 0 else margin_multiplier(margin)
# --------------------------------------------------------------
# Scale the standard Elo update
# --------------------------------------------------------------
new_home = r_home + K * mov_mult * (a_home - e_home)
new_away = r_away + K * mov_mult * (a_away - e_away)
elo[home] = new_home
elo[away] = new_away
row.update({
"home_elo_pre": r_home,
"home_elo_post": new_home,
"away_elo_pre": r_away,
"away_elo_post": new_away,
"margin": margin,
"mov_multiplier": mov_mult,
"season": season,
"season_carryover": SEASON_CARRYOVER,
})
rows.append(row)
# ------------------------------------------------------------------
# Create the final game-level Elo dataset
# ------------------------------------------------------------------
df_elo = pl.DataFrame(rows)
# ------------------------------------------------------------------
# Example output
# ------------------------------------------------------------------
print(
df_elo.select([
"match_id",
"season",
"home_team",
"away_team",
"home_elo_pre",
"away_elo_pre",
"home_elo_post",
"away_elo_post",
"margin",
"mov_multiplier"
]).head(5)
)
# ------------------------------------------------------------------
# Save the updated Elo dataset
# ------------------------------------------------------------------
df_elo.write_csv(GAME_LEVEL_OUTPUT_CSV)
df_elo.write_parquet(GAME_LEVEL_OUTPUT_PARQUET)
What Changed?
Although the implementation looks very similar to the previous version, only four conceptual changes have been introduced.
-
We define a seasonal carryover constant.
This determines how much of each team's previous rating difference from the baseline is retained when a new season begins. -
We add a carryover helper function.
The function moves a rating part of the way back towards the baseline Elo value while preserving whether the team was above or below that baseline. -
We detect season boundaries.
Before processing each game, the algorithm checks whether the season has changed. -
We apply carryover once per season.
When a new season is detected, every existing team rating is adjusted before the first game of that season is processed.
Every other part of the algorithm remains unchanged. Home advantage still influences the expected probability, margin of victory still scales the Elo update, and ratings continue to evolve sequentially as games are played.
"Well-designed models evolve through small, carefully justified improvements that build on a stable foundation."
Understanding the Effect of Seasonal Carryover
Seasonal carryover does not fundamentally change the Elo rating system. Games are still processed in chronological order, home advantage still influences the expected probability, and margin of victory still scales the size of each rating update.
The only difference is how the model treats evidence from previous seasons. Rather than assuming that last year's ratings remain equally reliable forever, the model begins each new season with slightly less confidence in those historical estimates.
This allows the Elo ratings to retain the valuable information accumulated over many games while adapting more naturally when teams genuinely improve or decline during the off-season.
"Good predictive models remember the past without becoming anchored to it."
What Has Changed?
At this stage, our Elo model now incorporates four distinct sources of evidence:
- Who won the game.
- Where the game was played.
- How convincing the victory was.
- How recent that evidence is.
Importantly, none of these additions change what an Elo rating represents. The ratings still estimate each team's underlying strength. Each enhancement introduced throughout the series simply improves how the model learns from new evidence over time.
Key Takeaways
- Older evidence remains valuable, but confidence in it should gradually decrease over time.
- Seasonal carryover preserves long-term knowledge while allowing ratings to adapt more quickly after the off-season.
- The carryover adjustment is applied once at the beginning of each new season.
- The carryover value controls the balance between continuity and responsiveness.
- Effective predictive models evolve through small, carefully justified improvements rather than large structural changes.
Next Steps
We now have a robust Elo framework that accounts for game outcomes, home advantage, margin of victory and seasonal transitions. The ratings provide a continuously updated estimate of team strength throughout the historical dataset.
The next step is to convert those ratings into something directly useful for forecasting.
That raises our next modelling question:
"How can we convert the difference between two Elo ratings into a pre-game win probability?"
In the next article, Interpreting NFL Elo Rating Differences , we investigate what different Elo rating gaps actually mean in practice. By comparing historical rating differences with real NFL results, we'll build an intuitive understanding of how Elo separates stronger and weaker teams, laying the foundation for converting those rating differences into win probabilities later in the series.