In the 2023 NFL season, the Detroit Lions finished 12-5 while the Dallas Cowboys also finished 12-5. Were these teams equally good? Not necessarily—Detroit played the NFC North (one of the NFL's toughest divisions that year), while Dallas played the...
In This Chapter
Chapter 16: Strength of Schedule
Introduction
In the 2023 NFL season, the Detroit Lions finished 12-5 while the Dallas Cowboys also finished 12-5. Were these teams equally good? Not necessarily—Detroit played the NFC North (one of the NFL's toughest divisions that year), while Dallas played the NFC East schedule. When we account for the quality of opponents each team faced, the picture changes dramatically.
Strength of Schedule (SOS) measures how difficult a team's set of opponents is. This seemingly simple concept creates profound challenges:
- How do we measure opponent quality?
- Should we weight games equally or by recency?
- How do we handle circular references (Team A beat Team B, who beat Team C, who beat Team A)?
- How much does SOS actually affect outcomes?
This chapter explores methods for calculating, interpreting, and applying strength of schedule analysis—a critical component in fair team evaluation and accurate prediction models.
Why Strength of Schedule Matters
The Record Illusion
Win-loss records are the most visible measure of team quality, but they can be deeply misleading without context:
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple
from dataclasses import dataclass
def compare_records_with_sos(team_records: Dict, schedules: Dict) -> pd.DataFrame:
"""
Demonstrate how SOS reveals hidden quality differences.
Args:
team_records: Dict of team -> (wins, losses)
schedules: Dict of team -> list of opponent abbreviations
"""
# Assume opponent win percentages are known
opponent_win_pcts = {
'KC': 0.647, 'SF': 0.706, 'BAL': 0.765, 'BUF': 0.647,
'MIA': 0.647, 'DAL': 0.706, 'DET': 0.706, 'PHI': 0.647,
'CLE': 0.647, 'JAX': 0.529, 'LAR': 0.588, 'GB': 0.529,
'PIT': 0.588, 'SEA': 0.529, 'MIN': 0.471, 'TB': 0.529,
'HOU': 0.588, 'IND': 0.529, 'CIN': 0.529, 'ATL': 0.471,
'DEN': 0.471, 'NYJ': 0.412, 'TEN': 0.353, 'CHI': 0.471,
'NO': 0.529, 'LAC': 0.294, 'ARI': 0.235, 'NE': 0.235,
'NYG': 0.353, 'LV': 0.471, 'WAS': 0.235, 'CAR': 0.118
}
results = []
for team, (wins, losses) in team_records.items():
schedule = schedules.get(team, [])
if schedule:
sos = np.mean([opponent_win_pcts.get(opp, 0.5) for opp in schedule])
else:
sos = 0.5
# Simple adjustment: wins above expected vs average schedule
expected_wins = len(schedule) * (1 - sos + 0.5) # Simplified
results.append({
'team': team,
'wins': wins,
'losses': losses,
'win_pct': wins / (wins + losses),
'sos': sos,
'sos_rank': None # Will be filled
})
df = pd.DataFrame(results)
df['sos_rank'] = df['sos'].rank(ascending=False).astype(int)
return df.sort_values('win_pct', ascending=False)
Key Applications
1. Fair Team Comparison
Two 10-6 teams aren't equivalent if one faced all playoff teams and the other faced all bottom-feeders:
def adjust_record_for_sos(wins: int, games: int, sos: float,
league_avg_sos: float = 0.5) -> float:
"""
Adjust win total for strength of schedule.
Returns SOS-adjusted wins using a simple linear model.
Args:
wins: Actual wins
games: Total games played
sos: Team's strength of schedule (opponent avg win %)
league_avg_sos: League average SOS (typically 0.5)
Returns:
Adjusted win total
"""
# Each 1% of SOS above average = roughly 0.16 harder wins
# (Based on historical analysis of point spreads)
difficulty_adjustment = (sos - league_avg_sos) * 16 * games / 100
adjusted_wins = wins + difficulty_adjustment
return round(adjusted_wins, 1)
# Example
actual_wins = 11
games = 17
hard_schedule_sos = 0.55 # Opponents average 55% win rate
easy_schedule_sos = 0.45 # Opponents average 45% win rate
print(f"Team A (11 wins, SOS=.55): {adjust_record_for_sos(11, 17, 0.55):.1f} adjusted wins")
print(f"Team B (11 wins, SOS=.45): {adjust_record_for_sos(11, 17, 0.45):.1f} adjusted wins")
# Team A: ~12.4 adjusted wins
# Team B: ~9.6 adjusted wins
2. Prediction Model Input
SOS is essential for accurate predictions because future opponents matter:
def predict_with_sos(team_rating: float, opponent_rating: float,
home_field: float = 2.5) -> Dict:
"""
Make prediction accounting for opponent strength.
Args:
team_rating: Team's power rating (points above average)
opponent_rating: Opponent's power rating
home_field: Home field advantage in points
Returns:
Prediction details
"""
# Expected margin is simply rating difference plus HFA
expected_margin = team_rating - opponent_rating + home_field
# Convert to win probability (using logistic approximation)
# Each point of margin ≈ 3% win probability
win_prob = 1 / (1 + 10 ** (-expected_margin / 8))
return {
'expected_margin': round(expected_margin, 1),
'win_probability': round(win_prob, 3),
'rating_diff': team_rating - opponent_rating
}
3. Draft Pick Value
NFL draft order uses SOS as a tiebreaker, making it financially significant:
def calculate_draft_implications(team_records: List[Tuple],
sos_values: List[float]) -> pd.DataFrame:
"""
Show how SOS affects draft order for teams with same record.
Teams with same record are ordered by SOS (lower = earlier pick).
"""
df = pd.DataFrame({
'team': [t[0] for t in team_records],
'wins': [t[1] for t in team_records],
'losses': [t[2] for t in team_records],
'sos': sos_values
})
# Sort by wins (ascending), then SOS (ascending for draft)
df = df.sort_values(['wins', 'sos'], ascending=[True, True])
df['draft_position'] = range(1, len(df) + 1)
return df
Methods for Calculating SOS
Method 1: Simple Opponent Win Percentage
The most straightforward approach averages opponents' win-loss records:
def calculate_simple_sos(team: str, games: pd.DataFrame) -> Dict:
"""
Calculate SOS using simple opponent winning percentage.
Args:
team: Team abbreviation
games: DataFrame with game_id, home_team, away_team,
home_score, away_score, season
Returns:
SOS metrics
"""
# Get all opponents
home_games = games[games['home_team'] == team]
away_games = games[games['away_team'] == team]
opponents_home = list(home_games['away_team']) # Away teams when we're home
opponents_away = list(away_games['home_team']) # Home teams when we're away
all_opponents = opponents_home + opponents_away
# Calculate each opponent's record (excluding games vs this team)
opponent_records = {}
for opp in set(all_opponents):
opp_games = games[
((games['home_team'] == opp) | (games['away_team'] == opp)) &
(games['home_team'] != team) &
(games['away_team'] != team)
]
opp_wins = (
((opp_games['home_team'] == opp) &
(opp_games['home_score'] > opp_games['away_score'])).sum() +
((opp_games['away_team'] == opp) &
(opp_games['away_score'] > opp_games['home_score'])).sum()
)
opp_total = len(opp_games)
opponent_records[opp] = opp_wins / opp_total if opp_total > 0 else 0.5
# Weight by number of times faced
from collections import Counter
opponent_counts = Counter(all_opponents)
total_weight = sum(opponent_counts.values())
weighted_sos = sum(
opponent_records.get(opp, 0.5) * count
for opp, count in opponent_counts.items()
) / total_weight
return {
'team': team,
'sos': round(weighted_sos, 4),
'games_played': len(all_opponents),
'unique_opponents': len(set(all_opponents)),
'hardest_opponent': max(opponent_records, key=opponent_records.get),
'easiest_opponent': min(opponent_records, key=opponent_records.get)
}
Advantages: - Simple to understand and calculate - Uses actual results
Disadvantages: - Circular logic (opponent records include games vs each other) - Doesn't account for home/away or margin of victory - All games weighted equally
Method 2: Opponent's Opponents (Second-Order SOS)
This addresses circularity by looking at opponents' opponents:
def calculate_second_order_sos(team: str, games: pd.DataFrame) -> Dict:
"""
Calculate SOS using opponents' opponents.
More robust than simple SOS as it reduces circular dependencies.
"""
# First, get simple SOS for all teams
teams = list(set(games['home_team'].tolist() + games['away_team'].tolist()))
simple_sos = {}
for t in teams:
result = calculate_simple_sos(t, games)
simple_sos[t] = result['sos']
# Now calculate second-order SOS
# This is the average SOS of your opponents
home_games = games[games['home_team'] == team]
away_games = games[games['away_team'] == team]
opponents_home = list(home_games['away_team'])
opponents_away = list(away_games['home_team'])
all_opponents = opponents_home + opponents_away
second_order = np.mean([simple_sos.get(opp, 0.5) for opp in all_opponents])
# Combined SOS (weighted average of first and second order)
first_order = simple_sos.get(team, 0.5)
combined_sos = 0.67 * first_order + 0.33 * second_order
return {
'team': team,
'sos_first_order': round(first_order, 4),
'sos_second_order': round(second_order, 4),
'sos_combined': round(combined_sos, 4)
}
This formula—weighting direct opponents (first-order) more heavily than opponents' opponents (second-order)—is used by the NFL for tiebreakers.
Method 3: Power Rating-Based SOS
Instead of using win-loss records, use team power ratings:
def calculate_rating_based_sos(team: str, games: pd.DataFrame,
power_ratings: Dict[str, float]) -> Dict:
"""
Calculate SOS using power ratings instead of records.
Power ratings better capture true team quality.
Args:
team: Team abbreviation
games: Schedule DataFrame
power_ratings: Dict of team -> rating (points above average)
"""
home_games = games[games['home_team'] == team]
away_games = games[games['away_team'] == team]
opponents_home = list(home_games['away_team'])
opponents_away = list(away_games['home_team'])
all_opponents = opponents_home + opponents_away
opponent_ratings = [power_ratings.get(opp, 0) for opp in all_opponents]
return {
'team': team,
'sos_rating': round(np.mean(opponent_ratings), 2),
'sos_std': round(np.std(opponent_ratings), 2),
'hardest_opponent': max(all_opponents,
key=lambda x: power_ratings.get(x, 0)),
'easiest_opponent': min(all_opponents,
key=lambda x: power_ratings.get(x, 0)),
'games': len(all_opponents)
}
Advantages: - Uses more sophisticated team quality measures - Avoids some circularity issues - Can incorporate margin of victory, efficiency metrics
Disadvantages: - Requires a power rating system - Different rating systems give different results
Method 4: Pythagorean SOS
Use Pythagorean expectation (points scored/allowed) for opponent strength:
def calculate_pythagorean_sos(team: str, games: pd.DataFrame) -> Dict:
"""
Calculate SOS using Pythagorean win expectation.
Pythagorean expectation better reflects underlying team quality
than actual wins, which include randomness.
"""
def pythagorean_expectation(points_for: float, points_against: float,
exponent: float = 2.37) -> float:
"""NFL-specific Pythagorean expectation."""
if points_for + points_against == 0:
return 0.5
return points_for ** exponent / (
points_for ** exponent + points_against ** exponent
)
# Calculate Pythagorean expectation for all teams
teams = list(set(games['home_team'].tolist() + games['away_team'].tolist()))
team_scoring = {}
for t in teams:
home = games[games['home_team'] == t]
away = games[games['away_team'] == t]
points_for = home['home_score'].sum() + away['away_score'].sum()
points_against = home['away_score'].sum() + away['home_score'].sum()
total_games = len(home) + len(away)
if total_games > 0:
team_scoring[t] = {
'ppg': points_for / total_games,
'papg': points_against / total_games,
'pyth': pythagorean_expectation(points_for, points_against)
}
else:
team_scoring[t] = {'ppg': 21, 'papg': 21, 'pyth': 0.5}
# Get opponents
home_games = games[games['home_team'] == team]
away_games = games[games['away_team'] == team]
opponents_home = list(home_games['away_team'])
opponents_away = list(away_games['home_team'])
all_opponents = opponents_home + opponents_away
# Calculate Pythagorean SOS
opp_pyth = [team_scoring.get(opp, {}).get('pyth', 0.5) for opp in all_opponents]
return {
'team': team,
'sos_pythagorean': round(np.mean(opp_pyth), 4),
'opp_avg_ppg': round(np.mean([
team_scoring.get(opp, {}).get('ppg', 21) for opp in all_opponents
]), 1),
'opp_avg_papg': round(np.mean([
team_scoring.get(opp, {}).get('papg', 21) for opp in all_opponents
]), 1)
}
The Circularity Problem
Understanding the Issue
SOS calculations face a fundamental problem: to know how good Team A's opponents are, we need to know how good those opponents are—but their quality depends on their opponents, which might include Team A:
Team A beat Team B (record depends on game vs A)
Team B beat Team C (record depends on game vs B)
Team C beat Team A (record depends on game vs C)
→ Circular dependency
Solutions to Circularity
1. Exclude Head-to-Head Games
Remove games between the team and opponent when calculating opponent records:
def calculate_sos_excluding_head_to_head(team: str,
games: pd.DataFrame) -> float:
"""
Calculate SOS excluding head-to-head games.
This partially addresses circularity by not counting
the team's own performance in opponent records.
"""
# Get opponents
home_games = games[games['home_team'] == team]
away_games = games[games['away_team'] == team]
opponents = set(home_games['away_team'].tolist() +
away_games['home_team'].tolist())
opponent_records = {}
for opp in opponents:
# Games where opponent played, excluding vs this team
opp_games = games[
((games['home_team'] == opp) | (games['away_team'] == opp)) &
~((games['home_team'] == team) | (games['away_team'] == team))
]
wins = (
((opp_games['home_team'] == opp) &
(opp_games['home_score'] > opp_games['away_score'])).sum() +
((opp_games['away_team'] == opp) &
(opp_games['away_score'] > opp_games['home_score'])).sum()
)
opponent_records[opp] = wins / len(opp_games) if len(opp_games) > 0 else 0.5
# Weight opponents by times faced
from collections import Counter
opp_list = (home_games['away_team'].tolist() +
away_games['home_team'].tolist())
opp_counts = Counter(opp_list)
weighted_sos = sum(
opponent_records[opp] * count
for opp, count in opp_counts.items()
) / sum(opp_counts.values())
return weighted_sos
2. Iterative Refinement (Colley Matrix Method)
Use linear algebra to solve the circular dependencies:
import numpy as np
from numpy.linalg import solve
def calculate_colley_ratings(games: pd.DataFrame) -> Dict[str, float]:
"""
Calculate team ratings using Colley Matrix method.
The Colley method uses linear algebra to solve circular
dependencies and produce consistent team ratings.
Used in BCS college football rankings.
"""
teams = sorted(set(games['home_team'].tolist() +
games['away_team'].tolist()))
n_teams = len(teams)
team_idx = {team: i for i, team in enumerate(teams)}
# Initialize Colley matrix (n x n) and b vector (n x 1)
C = np.zeros((n_teams, n_teams))
b = np.ones(n_teams) # Start with 1 for each team
for team in teams:
i = team_idx[team]
# Get all games for this team
team_games = games[
(games['home_team'] == team) | (games['away_team'] == team)
]
wins = 0
losses = 0
for _, game in team_games.iterrows():
if game['home_team'] == team:
opponent = game['away_team']
won = game['home_score'] > game['away_score']
else:
opponent = game['home_team']
won = game['away_score'] > game['home_score']
j = team_idx[opponent]
# Add to off-diagonal (opponent interaction)
C[i, j] -= 1
if won:
wins += 1
else:
losses += 1
# Diagonal element: 2 + total games
C[i, i] = 2 + wins + losses
# b vector: 1 + (wins - losses) / 2
b[i] = 1 + (wins - losses) / 2
# Solve Cr = b
try:
ratings = solve(C, b)
return {team: round(rating, 4) for team, rating in
zip(teams, ratings)}
except np.linalg.LinAlgError:
return {team: 0.5 for team in teams}
def colley_sos(team: str, games: pd.DataFrame,
ratings: Dict[str, float]) -> float:
"""Calculate SOS using Colley ratings."""
home_games = games[games['home_team'] == team]
away_games = games[games['away_team'] == team]
opponents = (home_games['away_team'].tolist() +
away_games['home_team'].tolist())
return np.mean([ratings.get(opp, 0.5) for opp in opponents])
3. Regression-Based Ratings
Use regression to simultaneously estimate all team strengths:
from sklearn.linear_model import Ridge
def calculate_regression_ratings(games: pd.DataFrame) -> Dict[str, float]:
"""
Calculate team ratings using regression.
This method estimates team offensive and defensive strengths
simultaneously, handling circular dependencies through the
regression framework.
"""
teams = sorted(set(games['home_team'].tolist() +
games['away_team'].tolist()))
team_idx = {team: i for i, team in enumerate(teams)}
n_teams = len(teams)
# Create design matrix
# Each game has: home_team offense, away_team offense (negative),
# home_team defense (negative), away_team defense
n_games = len(games)
# We'll predict margin = home_score - away_score
# Using just team strength (offense - defense combined)
X = np.zeros((n_games, n_teams))
y = np.zeros(n_games)
for idx, (_, game) in enumerate(games.iterrows()):
home_idx = team_idx[game['home_team']]
away_idx = team_idx[game['away_team']]
X[idx, home_idx] = 1 # Home team
X[idx, away_idx] = -1 # Away team
y[idx] = game['home_score'] - game['away_score']
# Remove one team (for identifiability) - make league average 0
X = X[:, :-1] # Remove last team
# Fit ridge regression
model = Ridge(alpha=1.0)
model.fit(X, y)
# Get ratings (last team's rating is negative sum of others)
ratings_array = list(model.coef_) + [-sum(model.coef_)]
return {team: round(rating, 2) for team, rating in
zip(teams, ratings_array)}
Future vs Past SOS
The Time Dimension
SOS can be calculated for games already played (retrospective) or games yet to come (prospective):
@dataclass
class SOSAnalysis:
"""Complete strength of schedule analysis."""
team: str
season: int
# Past (retrospective)
past_sos: float
past_games: int
past_rank: int
# Future (prospective)
future_sos: float
future_games: int
future_rank: int
# Combined
full_season_sos: float
remaining_difficulty: str # "harder", "easier", "similar"
def calculate_past_future_sos(team: str, games: pd.DataFrame,
current_week: int,
power_ratings: Dict[str, float]) -> SOSAnalysis:
"""
Calculate both past and future strength of schedule.
Args:
team: Team abbreviation
games: Full season schedule (including unplayed)
current_week: Current week of season
power_ratings: Current team power ratings
"""
# Split into past and future
team_games = games[
(games['home_team'] == team) | (games['away_team'] == team)
]
past_games = team_games[team_games['week'] < current_week]
future_games = team_games[team_games['week'] >= current_week]
def get_opponents(df):
opponents = []
for _, game in df.iterrows():
if game['home_team'] == team:
opponents.append(game['away_team'])
else:
opponents.append(game['home_team'])
return opponents
past_opponents = get_opponents(past_games)
future_opponents = get_opponents(future_games)
past_sos = np.mean([power_ratings.get(o, 0) for o in past_opponents]) if past_opponents else 0
future_sos = np.mean([power_ratings.get(o, 0) for o in future_opponents]) if future_opponents else 0
all_opponents = past_opponents + future_opponents
full_sos = np.mean([power_ratings.get(o, 0) for o in all_opponents]) if all_opponents else 0
# Determine difficulty change
if len(future_opponents) == 0:
difficulty = "season_complete"
elif future_sos > past_sos + 1:
difficulty = "harder"
elif future_sos < past_sos - 1:
difficulty = "easier"
else:
difficulty = "similar"
return SOSAnalysis(
team=team,
season=int(games['season'].iloc[0]) if 'season' in games.columns else 2023,
past_sos=round(past_sos, 2),
past_games=len(past_games),
past_rank=0, # Would calculate across all teams
future_sos=round(future_sos, 2),
future_games=len(future_games),
future_rank=0,
full_season_sos=round(full_sos, 2),
remaining_difficulty=difficulty
)
Why Future SOS Matters
Playoff Races:
def analyze_playoff_race(teams: List[str], current_records: Dict,
future_sos: Dict, remaining_games: Dict) -> pd.DataFrame:
"""
Analyze playoff race considering future schedule difficulty.
Shows how future SOS affects playoff projections.
"""
results = []
for team in teams:
wins, losses = current_records[team]
sos = future_sos[team]
games_left = remaining_games[team]
# Simple projection: win rate adjusted by SOS
# Base win rate from current record
base_win_rate = wins / (wins + losses) if (wins + losses) > 0 else 0.5
# Adjust for future SOS (each point of opponent rating = ~3% win prob)
# SOS of 0 = average opponents, positive = harder
adjusted_win_rate = base_win_rate - (sos * 0.03)
adjusted_win_rate = max(0.1, min(0.9, adjusted_win_rate)) # Bound
projected_future_wins = adjusted_win_rate * games_left
projected_final_wins = wins + projected_future_wins
results.append({
'team': team,
'current_wins': wins,
'current_losses': losses,
'games_remaining': games_left,
'future_sos': sos,
'projected_wins': round(projected_final_wins, 1),
'playoff_likelihood': 'High' if projected_final_wins >= 10 else
'Medium' if projected_final_wins >= 9 else 'Low'
})
return pd.DataFrame(results).sort_values('projected_wins', ascending=False)
Adjusting Metrics for SOS
SOS-Adjusted Win Total
The most common adjustment converts raw wins to "wins against average competition":
def calculate_adjusted_wins(team: str, games: pd.DataFrame,
power_ratings: Dict[str, float]) -> Dict:
"""
Calculate SOS-adjusted win total.
Converts actual wins to what team would have achieved
against average (0-rated) opponents.
"""
team_games = games[
((games['home_team'] == team) | (games['away_team'] == team)) &
(games['home_score'].notna())
]
total_wins = 0
expected_wins = 0
adjusted_wins = 0
for _, game in team_games.iterrows():
is_home = game['home_team'] == team
if is_home:
won = game['home_score'] > game['away_score']
opponent = game['away_team']
hfa = 2.5 # Home field advantage
else:
won = game['away_score'] > game['home_score']
opponent = game['home_team']
hfa = -2.5 # Away field disadvantage
opp_rating = power_ratings.get(opponent, 0)
# Expected win probability vs this opponent
# Using simplified logistic model
rating_diff = -opp_rating + hfa # Negative because opponent is other team
expected_wp = 1 / (1 + 10 ** (-rating_diff / 8))
# Expected win probability vs average opponent
neutral_wp = 1 / (1 + 10 ** (-(hfa) / 8))
total_wins += int(won)
expected_wins += expected_wp
# Adjusted: if beat strong team, worth more than 1 win
# If beat weak team, worth less than 1 win
if won:
# Credit: 1 * (neutral_wp / expected_wp)
# Beat good team = more credit, beat bad team = less credit
adjusted_wins += min(1.5, neutral_wp / expected_wp)
else:
# Still get partial credit based on difficulty
adjusted_wins += 0 # Could give partial credit for close losses
return {
'team': team,
'actual_wins': total_wins,
'expected_wins': round(expected_wins, 1),
'wins_above_expected': round(total_wins - expected_wins, 1),
'adjusted_wins': round(adjusted_wins, 1),
'games': len(team_games)
}
SOS-Adjusted Efficiency
Adjust EPA and other efficiency metrics for opponent quality:
def calculate_sos_adjusted_epa(team: str, pbp: pd.DataFrame,
opponent_defenses: Dict[str, float]) -> Dict:
"""
Calculate SOS-adjusted offensive EPA.
Args:
team: Team abbreviation
pbp: Play-by-play data
opponent_defenses: Dict of team -> defensive EPA allowed (negative = good)
"""
team_plays = pbp[
(pbp['posteam'] == team) &
(pbp['play_type'].isin(['pass', 'run']))
]
raw_epa = team_plays['epa'].mean()
# Calculate opponent-adjusted EPA
# For each game, adjust based on how opponent's defense compares to average
adjusted_epa_sum = 0
play_count = 0
for game_id in team_plays['game_id'].unique():
game_plays = team_plays[team_plays['game_id'] == game_id]
# Determine opponent
opponent = game_plays['defteam'].iloc[0]
# Opponent defensive EPA (negative = good defense)
opp_def_epa = opponent_defenses.get(opponent, 0)
# Adjustment: if opponent allows less EPA than average,
# add that difference back to give team credit
adjustment = -opp_def_epa # Negate: good defense = positive adjustment
game_epa = game_plays['epa'].sum()
adjusted_game_epa = game_epa + (adjustment * len(game_plays))
adjusted_epa_sum += adjusted_game_epa
play_count += len(game_plays)
adjusted_epa_per_play = adjusted_epa_sum / play_count if play_count > 0 else 0
return {
'team': team,
'raw_epa_play': round(raw_epa, 3),
'adjusted_epa_play': round(adjusted_epa_per_play, 3),
'adjustment': round(adjusted_epa_per_play - raw_epa, 3),
'plays': play_count
}
SOS in Prediction Models
Incorporating SOS
Prediction models need to account for opponent strength:
def build_prediction_with_sos(home_team: str, away_team: str,
ratings: Dict[str, float],
home_sos: Dict[str, float],
away_sos: Dict[str, float],
hfa: float = 2.5) -> Dict:
"""
Build game prediction accounting for SOS context.
This model considers that a team's rating might be inflated
or deflated based on their schedule so far.
"""
home_rating = ratings.get(home_team, 0)
away_rating = ratings.get(away_team, 0)
# SOS adjustment: if a team had easy schedule, discount rating
# If hard schedule, boost rating
# Average SOS should be ~0 (or 0.5 in win% terms)
home_sos_adj = home_sos.get(home_team, 0) * 0.3 # Partial adjustment
away_sos_adj = away_sos.get(away_team, 0) * 0.3
# Adjusted ratings
adj_home_rating = home_rating + home_sos_adj
adj_away_rating = away_rating + away_sos_adj
# Prediction
spread = adj_home_rating - adj_away_rating + hfa
# Win probability
win_prob = 1 / (1 + 10 ** (-spread / 8))
return {
'home_team': home_team,
'away_team': away_team,
'raw_spread': round(home_rating - away_rating + hfa, 1),
'sos_adjusted_spread': round(spread, 1),
'home_win_prob': round(win_prob, 3),
'sos_impact': round((home_sos_adj - away_sos_adj), 2)
}
SOS for Remaining Schedule
Project season outcomes considering remaining opponents:
def project_season_with_sos(team: str, current_wins: int, current_losses: int,
remaining_opponents: List[str],
team_rating: float,
opponent_ratings: Dict[str, float],
is_home: List[bool]) -> Dict:
"""
Project final record based on remaining schedule.
Args:
team: Team abbreviation
current_wins/losses: Current record
remaining_opponents: List of remaining opponents
team_rating: Team's power rating
opponent_ratings: All teams' power ratings
is_home: List of booleans for home/away
"""
projected_wins = current_wins
projected_losses = current_losses
game_projections = []
hfa = 2.5
for opp, home in zip(remaining_opponents, is_home):
opp_rating = opponent_ratings.get(opp, 0)
if home:
spread = team_rating - opp_rating + hfa
else:
spread = team_rating - opp_rating - hfa
win_prob = 1 / (1 + 10 ** (-spread / 8))
game_projections.append({
'opponent': opp,
'home': home,
'win_prob': round(win_prob, 3),
'spread': round(spread, 1)
})
projected_wins += win_prob
projected_losses += (1 - win_prob)
return {
'team': team,
'current_record': f"{current_wins}-{current_losses}",
'projected_final_wins': round(projected_wins, 1),
'projected_final_losses': round(projected_losses, 1),
'remaining_games': len(remaining_opponents),
'remaining_sos': round(np.mean([
opponent_ratings.get(o, 0) for o in remaining_opponents
]), 2),
'game_by_game': game_projections
}
Team-Specific SOS Patterns
Division Effects
NFL scheduling creates systematic SOS patterns:
def analyze_division_sos_effects(games: pd.DataFrame) -> pd.DataFrame:
"""
Analyze how division affects strength of schedule.
Teams in strong divisions face tougher schedules due to:
1. Playing division rivals twice each (6 games)
2. Playing same-place finishers from other divisions
"""
# Define divisions
divisions = {
'NFC North': ['GB', 'MIN', 'DET', 'CHI'],
'NFC South': ['NO', 'TB', 'ATL', 'CAR'],
'NFC East': ['PHI', 'DAL', 'NYG', 'WAS'],
'NFC West': ['SF', 'SEA', 'LAR', 'ARI'],
'AFC North': ['BAL', 'CIN', 'CLE', 'PIT'],
'AFC South': ['HOU', 'JAX', 'TEN', 'IND'],
'AFC East': ['MIA', 'BUF', 'NE', 'NYJ'],
'AFC West': ['KC', 'LV', 'LAC', 'DEN']
}
team_to_division = {}
for div, teams in divisions.items():
for team in teams:
team_to_division[team] = div
# Calculate each team's SOS
results = []
all_teams = list(set(games['home_team'].tolist() + games['away_team'].tolist()))
for team in all_teams:
sos_result = calculate_simple_sos(team, games)
division = team_to_division.get(team, 'Unknown')
results.append({
'team': team,
'division': division,
'sos': sos_result['sos']
})
df = pd.DataFrame(results)
# Add division average SOS
division_sos = df.groupby('division')['sos'].mean()
df['division_avg_sos'] = df['division'].map(division_sos)
return df.sort_values('sos', ascending=False)
Year-to-Year SOS Variance
def calculate_sos_volatility(team: str, games_by_year: Dict[int, pd.DataFrame]) -> Dict:
"""
Calculate how much a team's SOS varies year to year.
Some teams consistently face tough or easy schedules;
others vary significantly.
"""
yearly_sos = []
for year, games in games_by_year.items():
result = calculate_simple_sos(team, games)
yearly_sos.append({
'year': year,
'sos': result['sos']
})
if len(yearly_sos) < 2:
return {'team': team, 'volatility': 0, 'trend': 0}
sos_values = [y['sos'] for y in yearly_sos]
years = [y['year'] for y in yearly_sos]
# Volatility: standard deviation
volatility = np.std(sos_values)
# Trend: linear regression slope
if len(years) >= 3:
trend = np.polyfit(years, sos_values, 1)[0]
else:
trend = sos_values[-1] - sos_values[0]
return {
'team': team,
'avg_sos': round(np.mean(sos_values), 4),
'sos_volatility': round(volatility, 4),
'sos_trend': round(trend, 4),
'years_analyzed': len(yearly_sos),
'hardest_year': yearly_sos[np.argmax(sos_values)]['year'],
'easiest_year': yearly_sos[np.argmin(sos_values)]['year']
}
Practical Implementation
Complete SOS Calculator
from dataclasses import dataclass
from typing import Dict, List, Optional
import pandas as pd
import numpy as np
@dataclass
class SOSReport:
"""Comprehensive strength of schedule report."""
team: str
season: int
# Basic SOS
simple_sos: float
simple_sos_rank: int
# Advanced SOS
pythagorean_sos: float
rating_based_sos: float
# Context
division_sos: float
non_division_sos: float
# Breakdown
home_opponent_sos: float
away_opponent_sos: float
# Outlook
remaining_sos: Optional[float]
difficulty_trend: str
class StrengthOfScheduleCalculator:
"""
Comprehensive strength of schedule calculator.
Example usage:
import nfl_data_py as nfl
schedules = nfl.import_schedules([2023])
calculator = StrengthOfScheduleCalculator(schedules, 2023)
# Get team SOS
report = calculator.calculate_team_sos('KC')
# Rank all teams
rankings = calculator.rank_all_teams()
"""
def __init__(self, schedules: pd.DataFrame, season: int):
"""
Initialize calculator with schedule data.
Args:
schedules: Schedule DataFrame
season: Season year
"""
self.schedules = schedules[schedules['season'] == season].copy()
self.season = season
# Filter to completed games
self.completed_games = self.schedules[
self.schedules['home_score'].notna()
].copy()
# All teams
self.teams = list(set(
self.schedules['home_team'].tolist() +
self.schedules['away_team'].tolist()
))
# Pre-calculate team records
self._calculate_all_records()
# Pre-calculate power ratings
self._calculate_power_ratings()
def _calculate_all_records(self):
"""Calculate win-loss records for all teams."""
self.records = {}
for team in self.teams:
home = self.completed_games[self.completed_games['home_team'] == team]
away = self.completed_games[self.completed_games['away_team'] == team]
wins = (
(home['home_score'] > home['away_score']).sum() +
(away['away_score'] > away['home_score']).sum()
)
losses = (
(home['home_score'] < home['away_score']).sum() +
(away['away_score'] < away['home_score']).sum()
)
ties = (
(home['home_score'] == home['away_score']).sum() +
(away['away_score'] == away['home_score']).sum()
)
games = wins + losses + ties
win_pct = (wins + 0.5 * ties) / games if games > 0 else 0.5
self.records[team] = {
'wins': wins,
'losses': losses,
'ties': ties,
'games': games,
'win_pct': win_pct
}
def _calculate_power_ratings(self):
"""Calculate power ratings using simple margin-based method."""
self.power_ratings = {}
for team in self.teams:
home = self.completed_games[self.completed_games['home_team'] == team]
away = self.completed_games[self.completed_games['away_team'] == team]
home_margin = (home['home_score'] - home['away_score']).sum()
away_margin = (away['away_score'] - away['home_score']).sum()
total_games = len(home) + len(away)
avg_margin = (home_margin + away_margin) / total_games if total_games > 0 else 0
self.power_ratings[team] = avg_margin
# Normalize to league average of 0
league_avg = np.mean(list(self.power_ratings.values()))
for team in self.power_ratings:
self.power_ratings[team] -= league_avg
def _get_opponents(self, team: str,
completed_only: bool = True) -> List[str]:
"""Get list of opponents for a team."""
games = self.completed_games if completed_only else self.schedules
home_games = games[games['home_team'] == team]
away_games = games[games['away_team'] == team]
return (home_games['away_team'].tolist() +
away_games['home_team'].tolist())
def calculate_simple_sos(self, team: str) -> float:
"""Calculate simple opponent win percentage SOS."""
opponents = self._get_opponents(team)
if not opponents:
return 0.5
# Exclude head-to-head games
opponent_win_pcts = []
for opp in opponents:
if opp in self.records:
# Get opponent's record excluding games vs this team
opp_home = self.completed_games[
(self.completed_games['home_team'] == opp) &
(self.completed_games['away_team'] != team)
]
opp_away = self.completed_games[
(self.completed_games['away_team'] == opp) &
(self.completed_games['home_team'] != team)
]
wins = (
(opp_home['home_score'] > opp_home['away_score']).sum() +
(opp_away['away_score'] > opp_away['home_score']).sum()
)
games = len(opp_home) + len(opp_away)
win_pct = wins / games if games > 0 else 0.5
opponent_win_pcts.append(win_pct)
return np.mean(opponent_win_pcts) if opponent_win_pcts else 0.5
def calculate_rating_sos(self, team: str) -> float:
"""Calculate SOS using power ratings."""
opponents = self._get_opponents(team)
if not opponents:
return 0
return np.mean([self.power_ratings.get(o, 0) for o in opponents])
def calculate_team_sos(self, team: str,
current_week: Optional[int] = None) -> SOSReport:
"""
Calculate comprehensive SOS for a team.
Args:
team: Team abbreviation
current_week: If provided, calculates remaining SOS
"""
simple_sos = self.calculate_simple_sos(team)
rating_sos = self.calculate_rating_sos(team)
# Division breakdown
divisions = self._get_divisions()
team_division = None
for div, teams in divisions.items():
if team in teams:
team_division = div
break
div_opponents = []
non_div_opponents = []
for opp in self._get_opponents(team):
if team_division and opp in divisions.get(team_division, []):
div_opponents.append(opp)
else:
non_div_opponents.append(opp)
div_sos = np.mean([self.records.get(o, {}).get('win_pct', 0.5)
for o in div_opponents]) if div_opponents else 0.5
non_div_sos = np.mean([self.records.get(o, {}).get('win_pct', 0.5)
for o in non_div_opponents]) if non_div_opponents else 0.5
# Home/away breakdown
home_games = self.completed_games[self.completed_games['home_team'] == team]
away_games = self.completed_games[self.completed_games['away_team'] == team]
home_opp_sos = np.mean([self.records.get(o, {}).get('win_pct', 0.5)
for o in home_games['away_team']]) if len(home_games) > 0 else 0.5
away_opp_sos = np.mean([self.records.get(o, {}).get('win_pct', 0.5)
for o in away_games['home_team']]) if len(away_games) > 0 else 0.5
# Remaining SOS
remaining_sos = None
difficulty_trend = "unknown"
if current_week:
future_games = self.schedules[
((self.schedules['home_team'] == team) |
(self.schedules['away_team'] == team)) &
(self.schedules['week'] >= current_week) &
(self.schedules['home_score'].isna())
]
if len(future_games) > 0:
future_opponents = []
for _, game in future_games.iterrows():
if game['home_team'] == team:
future_opponents.append(game['away_team'])
else:
future_opponents.append(game['home_team'])
remaining_sos = np.mean([
self.records.get(o, {}).get('win_pct', 0.5)
for o in future_opponents
])
if remaining_sos > simple_sos + 0.05:
difficulty_trend = "harder_ahead"
elif remaining_sos < simple_sos - 0.05:
difficulty_trend = "easier_ahead"
else:
difficulty_trend = "similar"
# Ranking
all_sos = {t: self.calculate_simple_sos(t) for t in self.teams}
sorted_teams = sorted(all_sos.keys(), key=lambda x: all_sos[x], reverse=True)
rank = sorted_teams.index(team) + 1
return SOSReport(
team=team,
season=self.season,
simple_sos=round(simple_sos, 4),
simple_sos_rank=rank,
pythagorean_sos=round(simple_sos, 4), # Simplified
rating_based_sos=round(rating_sos, 2),
division_sos=round(div_sos, 4),
non_division_sos=round(non_div_sos, 4),
home_opponent_sos=round(home_opp_sos, 4),
away_opponent_sos=round(away_opp_sos, 4),
remaining_sos=round(remaining_sos, 4) if remaining_sos else None,
difficulty_trend=difficulty_trend
)
def rank_all_teams(self) -> pd.DataFrame:
"""Rank all teams by strength of schedule."""
results = []
for team in self.teams:
simple_sos = self.calculate_simple_sos(team)
rating_sos = self.calculate_rating_sos(team)
record = self.records.get(team, {})
results.append({
'team': team,
'wins': record.get('wins', 0),
'losses': record.get('losses', 0),
'win_pct': round(record.get('win_pct', 0.5), 3),
'sos': round(simple_sos, 4),
'rating_sos': round(rating_sos, 2)
})
df = pd.DataFrame(results)
df = df.sort_values('sos', ascending=False).reset_index(drop=True)
df['sos_rank'] = range(1, len(df) + 1)
return df
def _get_divisions(self) -> Dict[str, List[str]]:
"""Return NFL division structure."""
return {
'NFC North': ['GB', 'MIN', 'DET', 'CHI'],
'NFC South': ['NO', 'TB', 'ATL', 'CAR'],
'NFC East': ['PHI', 'DAL', 'NYG', 'WAS'],
'NFC West': ['SF', 'SEA', 'LAR', 'ARI'],
'AFC North': ['BAL', 'CIN', 'CLE', 'PIT'],
'AFC South': ['HOU', 'JAX', 'TEN', 'IND'],
'AFC East': ['MIA', 'BUF', 'NE', 'NYJ'],
'AFC West': ['KC', 'LV', 'LAC', 'DEN']
}
def print_sos_report(report: SOSReport) -> None:
"""Pretty print an SOS report."""
print(f"\n{'='*50}")
print(f"Strength of Schedule Report: {report.team}")
print(f"Season: {report.season}")
print(f"{'='*50}")
print(f"\n--- Overall SOS ---")
print(f"Simple SOS: {report.simple_sos:.3f} (Rank: {report.simple_sos_rank})")
print(f"Rating-based SOS: {report.rating_based_sos:+.1f}")
print(f"\n--- Breakdown ---")
print(f"Division: {report.division_sos:.3f}")
print(f"Non-Division: {report.non_division_sos:.3f}")
print(f"Home opponents: {report.home_opponent_sos:.3f}")
print(f"Away opponents: {report.away_opponent_sos:.3f}")
if report.remaining_sos:
print(f"\n--- Outlook ---")
print(f"Remaining SOS: {report.remaining_sos:.3f}")
print(f"Trend: {report.difficulty_trend}")
print(f"\n{'='*50}\n")
Common Mistakes and Pitfalls
Mistake 1: Ignoring Opponent Quality When Evaluating Teams
# WRONG: Comparing records without context
def naive_comparison(team_a_wins, team_b_wins):
return "Team A better" if team_a_wins > team_b_wins else "Team B better"
# RIGHT: Account for schedule
def informed_comparison(team_a_wins, team_a_sos, team_b_wins, team_b_sos):
# Adjust wins for SOS difference
sos_diff = team_a_sos - team_b_sos
adjusted_a = team_a_wins + (sos_diff * 10) # 10% adjustment per .01 SOS
if abs(adjusted_a - team_b_wins) < 1:
return "Teams are comparable"
return "Team A better" if adjusted_a > team_b_wins else "Team B better"
Mistake 2: Using Only Past SOS for Predictions
# WRONG: Only look at past opponents
def predict_with_past_sos(team_rating, past_sos):
return team_rating # Ignores future difficulty
# RIGHT: Weight remaining schedule
def predict_with_future_sos(team_rating, past_sos, future_sos,
past_games, future_games):
# Estimate true rating accounting for past SOS
sos_adjusted_rating = team_rating + (past_sos - 0.5) * 3
# Project future wins based on adjusted rating and future opponents
expected_future_wp = 0.5 + (sos_adjusted_rating - future_sos * 5) / 20
expected_wins = future_games * expected_future_wp
return expected_wins
Mistake 3: Treating All SOS Methods as Equivalent
Different methods capture different aspects:
def compare_sos_methods(team: str, games: pd.DataFrame) -> pd.DataFrame:
"""
Show how different SOS methods give different results.
"""
methods = {
'Simple (Win %)': calculate_simple_sos(team, games)['sos'],
'Pythagorean': calculate_pythagorean_sos(team, games)['sos_pythagorean'],
'Second-order': calculate_second_order_sos(team, games)['sos_combined'],
# Add more methods as needed
}
df = pd.DataFrame([
{'method': k, 'sos': v} for k, v in methods.items()
])
# Methods can diverge significantly
df['deviation_from_mean'] = df['sos'] - df['sos'].mean()
return df
Mistake 4: Not Accounting for Division Games
Division games are weighted more heavily in SOS:
def division_weighted_sos(team: str, games: pd.DataFrame,
divisions: Dict) -> float:
"""
Calculate SOS with appropriate division weighting.
Division games happen twice per year, so they have
more impact on both record and SOS.
"""
team_div = None
for div, teams in divisions.items():
if team in teams:
team_div = div
break
opponents = []
team_games = games[
(games['home_team'] == team) | (games['away_team'] == team)
]
div_count = 0
non_div_count = 0
div_sos_sum = 0
non_div_sos_sum = 0
for _, game in team_games.iterrows():
opp = game['away_team'] if game['home_team'] == team else game['home_team']
opp_wp = get_opponent_win_pct(opp, games, team) # Defined elsewhere
if opp in divisions.get(team_div, []):
div_count += 1
div_sos_sum += opp_wp
else:
non_div_count += 1
non_div_sos_sum += opp_wp
# Weight: division games count 1.5x due to familiarity factor
weighted_sos = (
(div_sos_sum * 1.5 + non_div_sos_sum) /
(div_count * 1.5 + non_div_count)
) if (div_count + non_div_count) > 0 else 0.5
return weighted_sos
Summary
Key Takeaways:
- SOS is essential for fair comparison: Raw records are misleading without opponent context
- Multiple methods exist: Simple win %, Pythagorean, rating-based, iterative—each has tradeoffs
- Circularity is solvable: Excluding head-to-head, using second-order SOS, or iterative methods
- Future SOS matters: For predictions and playoff projections, remaining schedule is critical
- Division effects are significant: NFL scheduling creates systematic SOS patterns
When to Use SOS:
- Comparing teams with similar records
- Building prediction models
- Projecting playoff races
- Evaluating draft position tiebreakers
- Adjusting efficiency metrics for opponent quality
Best Practices:
- Use SOS alongside other metrics, not in isolation
- Consider both past and future SOS
- Account for home/away splits in opponent difficulty
- Recognize that different calculation methods serve different purposes
Preview: Chapter 17
Next, we'll explore Team Building and Roster Construction—how successful teams allocate resources across positions, the value of draft picks, free agency strategy, and the economics of building a competitive NFL roster.
References
- Carroll, B., Palmer, P., & Thorn, J. (1988). The Hidden Game of Football
- Football Outsiders. "DVOA and SOS Calculations"
- Winston, W. L. (2012). Mathletics
- Pro Football Reference. "Strength of Schedule"
- FiveThirtyEight. "NFL Elo Ratings Methodology"