5 min read

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...

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:

  1. SOS is essential for fair comparison: Raw records are misleading without opponent context
  2. Multiple methods exist: Simple win %, Pythagorean, rating-based, iterative—each has tradeoffs
  3. Circularity is solvable: Excluding head-to-head, using second-order SOS, or iterative methods
  4. Future SOS matters: For predictions and playoff projections, remaining schedule is critical
  5. 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

  1. Carroll, B., Palmer, P., & Thorn, J. (1988). The Hidden Game of Football
  2. Football Outsiders. "DVOA and SOS Calculations"
  3. Winston, W. L. (2012). Mathletics
  4. Pro Football Reference. "Strength of Schedule"
  5. FiveThirtyEight. "NFL Elo Ratings Methodology"