Case Study 1: Building an NFL Elo Rating System from Scratch

Overview

In this case study, we build a complete NFL Elo rating system from the ground up, calibrate its parameters against synthetic but realistic game data, and evaluate whether its predictions are accurate enough to identify value bets against the market. The study walks through every step from parameter selection to backtesting, demonstrating the practical workflow a sports bettor would follow when deploying an Elo-based model.

Motivation

The National Football League presents an interesting challenge for rating systems. With only 17 regular-season games per team (expanded from 16 in 2021), each game carries significant weight. Single-game variance is high: even elite teams lose to mediocre opponents roughly 20-25% of the time. Roster turnover between seasons is substantial, with the average NFL team turning over approximately 30% of its roster each offseason. These characteristics demand an Elo system that is responsive enough to track genuine changes in team strength but stable enough to avoid chasing noise.

FiveThirtyEight popularized NFL Elo ratings, using a K-factor of 20 and a home-field advantage of roughly 48 Elo points (corresponding to about 3 points on the field, or a ~57% home win rate). They also incorporated quarterback-adjusted ratings, recognizing that the quarterback position has an outsized impact on team performance in football. Our system will start with the standard framework and systematically evaluate parameter choices.

Problem Statement

Given a history of NFL game results spanning multiple seasons, we want to:

  1. Build an Elo system that accurately predicts game outcomes.
  2. Optimize the K-factor, home advantage, and margin-of-victory parameters.
  3. Evaluate the system's calibration: when it says a team has a 70% chance to win, does that team win approximately 70% of the time?
  4. Determine whether the system can identify profitable betting opportunities against synthetic market lines.

Data Generation

Since we are working with a self-contained case study, we generate synthetic NFL data that mimics realistic patterns. Each team has a "true strength" that evolves over seasons, and game outcomes are generated from these true strengths with realistic variance.

"""
Case Study 1: NFL Elo Rating System
Generates synthetic NFL data, builds and calibrates an Elo system,
and backtests against simulated market lines.
"""

import math
import random
import numpy as np
from dataclasses import dataclass, field
from typing import Dict, List, Tuple, Optional


def generate_nfl_season(
    team_strengths: Dict[str, float],
    n_games_per_team: int = 16,
    home_advantage: float = 3.0,
    score_std: float = 13.5,
    seed: Optional[int] = None,
) -> List[dict]:
    """Generate a synthetic NFL regular season.

    Args:
        team_strengths: Mapping from team name to true strength (points).
        n_games_per_team: Number of games each team plays.
        home_advantage: True home-field advantage in points.
        score_std: Standard deviation of score noise.
        seed: Random seed for reproducibility.

    Returns:
        List of game dictionaries with keys: home_team, away_team,
        home_score, away_score, week.
    """
    if seed is not None:
        random.seed(seed)
        np.random.seed(seed)

    teams = list(team_strengths.keys())
    n_teams = len(teams)
    games = []

    # Create a schedule: each team plays n_games_per_team games
    matchups = []
    for _ in range(n_games_per_team // 2 + 1):
        shuffled = teams.copy()
        random.shuffle(shuffled)
        for i in range(0, n_teams - 1, 2):
            if random.random() < 0.5:
                matchups.append((shuffled[i], shuffled[i + 1]))
            else:
                matchups.append((shuffled[i + 1], shuffled[i]))

    # Trim to exact number needed
    games_needed = n_teams * n_games_per_team // 2
    matchups = matchups[:games_needed]

    for week_idx, (home, away) in enumerate(matchups):
        true_diff = (
            team_strengths[home] - team_strengths[away] + home_advantage
        )
        noise = np.random.normal(0, score_std)
        actual_diff = true_diff + noise

        # Convert to realistic scores
        base_score = 21.0
        home_score = max(0, round(base_score + actual_diff / 2 + np.random.normal(0, 3)))
        away_score = max(0, round(base_score - actual_diff / 2 + np.random.normal(0, 3)))

        games.append({
            "home_team": home,
            "away_team": away,
            "home_score": home_score,
            "away_score": away_score,
            "week": week_idx // (n_teams // 2) + 1,
        })

    return games


def evolve_strengths(
    strengths: Dict[str, float],
    volatility: float = 2.0,
    regression: float = 0.33,
) -> Dict[str, float]:
    """Evolve team strengths between seasons.

    Args:
        strengths: Current team strengths.
        volatility: Std dev of between-season strength changes.
        regression: Fraction to regress toward the mean.

    Returns:
        Updated strength dictionary.
    """
    mean_strength = np.mean(list(strengths.values()))
    new_strengths = {}
    for team, strength in strengths.items():
        regressed = strength * (1 - regression) + mean_strength * regression
        new_strengths[team] = regressed + np.random.normal(0, volatility)
    return new_strengths

Building the Elo System

We use the EloSystem class from the chapter with additional methods for systematic parameter optimization.

@dataclass
class EloSystem:
    """Complete Elo rating system for NFL prediction and backtesting."""

    k_factor: float = 20.0
    home_advantage: float = 48.0
    initial_rating: float = 1500.0
    use_mov: bool = True
    season_regression: float = 0.33
    ratings: Dict[str, float] = field(default_factory=dict)
    predictions: List[dict] = field(default_factory=list)

    def get_rating(self, team: str) -> float:
        """Get current rating, initializing if needed."""
        if team not in self.ratings:
            self.ratings[team] = self.initial_rating
        return self.ratings[team]

    def expected_score(self, rating_a: float, rating_b: float) -> float:
        """Compute expected score for team A vs team B."""
        return 1.0 / (1.0 + math.pow(10, (rating_b - rating_a) / 400.0))

    def mov_multiplier(self, margin: int, elo_diff: float) -> float:
        """Margin-of-victory multiplier with autocorrelation adjustment."""
        return math.log(abs(margin) + 1) * (2.2 / (elo_diff * 0.001 + 2.2))

    def predict(self, home_team: str, away_team: str) -> float:
        """Predict home win probability."""
        home_elo = self.get_rating(home_team) + self.home_advantage
        away_elo = self.get_rating(away_team)
        return self.expected_score(home_elo, away_elo)

    def update(self, home_team: str, away_team: str,
               home_score: int, away_score: int) -> dict:
        """Update ratings after a game and return prediction record."""
        home_rating = self.get_rating(home_team)
        away_rating = self.get_rating(away_team)

        home_prob = self.expected_score(
            home_rating + self.home_advantage, away_rating
        )
        home_win = 1 if home_score > away_score else 0

        # Actual scores
        if home_score > away_score:
            s_home, s_away = 1.0, 0.0
        elif home_score < away_score:
            s_home, s_away = 0.0, 1.0
        else:
            s_home, s_away = 0.5, 0.5

        # K-factor with optional MOV
        k = self.k_factor
        if self.use_mov and home_score != away_score:
            margin = abs(home_score - away_score)
            winner_elo = home_rating if home_score > away_score else away_rating
            loser_elo = away_rating if home_score > away_score else home_rating
            k *= self.mov_multiplier(margin, winner_elo - loser_elo)

        home_delta = k * (s_home - home_prob)
        self.ratings[home_team] = home_rating + home_delta
        self.ratings[away_team] = away_rating - home_delta

        record = {
            "home_team": home_team,
            "away_team": away_team,
            "home_prob": home_prob,
            "home_win": home_win,
            "home_score": home_score,
            "away_score": away_score,
        }
        self.predictions.append(record)
        return record

    def regress_to_mean(self) -> None:
        """Apply between-season regression."""
        if not self.ratings:
            return
        mean_r = sum(self.ratings.values()) / len(self.ratings)
        for team in self.ratings:
            self.ratings[team] = (
                self.ratings[team] * (1 - self.season_regression)
                + mean_r * self.season_regression
            )

Parameter Optimization

The most important decision in building an Elo system is selecting the K-factor. We perform a grid search, running the entire multi-season simulation for each candidate K-factor and measuring log-loss on held-out predictions.

def compute_log_loss(predictions: List[dict]) -> float:
    """Compute binary log-loss across all predictions.

    Args:
        predictions: List of dicts with 'home_prob' and 'home_win'.

    Returns:
        Average log-loss.
    """
    total = 0.0
    for pred in predictions:
        p = max(min(pred["home_prob"], 0.999), 0.001)
        y = pred["home_win"]
        total += -(y * math.log(p) + (1 - y) * math.log(1 - p))
    return total / len(predictions)


def compute_brier_score(predictions: List[dict]) -> float:
    """Compute Brier score across all predictions."""
    total = 0.0
    for pred in predictions:
        total += (pred["home_prob"] - pred["home_win"]) ** 2
    return total / len(predictions)


def calibration_check(predictions: List[dict], n_bins: int = 10) -> List[dict]:
    """Compute calibration statistics by probability bin.

    Args:
        predictions: List of prediction records.
        n_bins: Number of bins for calibration analysis.

    Returns:
        List of dicts with bin statistics.
    """
    sorted_preds = sorted(predictions, key=lambda x: x["home_prob"])
    bin_size = len(sorted_preds) // n_bins
    bins = []

    for i in range(n_bins):
        start = i * bin_size
        end = start + bin_size if i < n_bins - 1 else len(sorted_preds)
        bin_preds = sorted_preds[start:end]

        mean_prob = np.mean([p["home_prob"] for p in bin_preds])
        actual_rate = np.mean([p["home_win"] for p in bin_preds])
        bins.append({
            "bin": i + 1,
            "mean_predicted": round(mean_prob, 4),
            "actual_frequency": round(actual_rate, 4),
            "count": len(bin_preds),
            "calibration_error": round(abs(mean_prob - actual_rate), 4),
        })

    return bins


def optimize_k_factor(
    seasons_data: List[List[dict]],
    k_values: List[float],
    home_advantage: float = 48.0,
    use_mov: bool = True,
) -> Dict[float, float]:
    """Test multiple K-factors and return log-loss for each.

    Args:
        seasons_data: List of seasons, each a list of game dicts.
        k_values: K-factors to test.
        home_advantage: Home advantage in Elo points.
        use_mov: Whether to use margin-of-victory adjustment.

    Returns:
        Dictionary mapping K-factor to log-loss.
    """
    results = {}

    for k in k_values:
        elo = EloSystem(
            k_factor=k,
            home_advantage=home_advantage,
            use_mov=use_mov,
        )

        for season_idx, season_games in enumerate(seasons_data):
            if season_idx > 0:
                elo.regress_to_mean()
            for game in season_games:
                elo.update(
                    game["home_team"],
                    game["away_team"],
                    game["home_score"],
                    game["away_score"],
                )

        # Evaluate only on seasons 2+ (after warmup)
        warmup_games = len(seasons_data[0])
        eval_preds = elo.predictions[warmup_games:]
        results[k] = compute_log_loss(eval_preds)

    return results

Running the Full Pipeline

We now generate data, optimize parameters, and evaluate the final system.

def run_case_study() -> None:
    """Execute the complete NFL Elo case study."""
    print("=" * 60)
    print("CASE STUDY 1: NFL Elo Rating System")
    print("=" * 60)

    # --- Step 1: Generate synthetic NFL data ---
    teams = {
        "Chiefs": 5.0, "Bills": 4.0, "Ravens": 3.5, "49ers": 3.0,
        "Eagles": 2.5, "Cowboys": 1.5, "Lions": 1.0, "Dolphins": 0.5,
        "Bengals": 0.0, "Jaguars": -0.5, "Jets": -1.0, "Packers": -1.5,
        "Steelers": -2.0, "Chargers": -2.5, "Broncos": -3.0, "Bears": -4.0,
    }

    np.random.seed(42)
    random.seed(42)

    n_seasons = 5
    all_seasons = []
    current_strengths = teams.copy()

    for season in range(n_seasons):
        season_games = generate_nfl_season(
            current_strengths,
            n_games_per_team=16,
            home_advantage=3.0,
            score_std=13.5,
            seed=42 + season,
        )
        all_seasons.append(season_games)
        current_strengths = evolve_strengths(current_strengths)

    total_games = sum(len(s) for s in all_seasons)
    print(f"\nGenerated {n_seasons} seasons, {total_games} total games")

    # --- Step 2: K-factor optimization ---
    print("\n--- K-Factor Optimization ---")
    k_values = [5, 10, 15, 20, 25, 30, 35, 40]
    k_results = optimize_k_factor(all_seasons, k_values)

    print(f"\n{'K-Factor':>10} {'Log-Loss':>10}")
    print("-" * 22)
    best_k = min(k_results, key=k_results.get)
    for k, ll in sorted(k_results.items()):
        marker = " <-- best" if k == best_k else ""
        print(f"{k:>10.0f} {ll:>10.4f}{marker}")

    # --- Step 3: Build final system with optimal K ---
    print(f"\n--- Final System (K={best_k}) ---")
    final_elo = EloSystem(
        k_factor=best_k,
        home_advantage=48.0,
        use_mov=True,
        season_regression=0.33,
    )

    for season_idx, season_games in enumerate(all_seasons):
        if season_idx > 0:
            final_elo.regress_to_mean()
        for game in season_games:
            final_elo.update(
                game["home_team"],
                game["away_team"],
                game["home_score"],
                game["away_score"],
            )

    # --- Step 4: Evaluation metrics ---
    warmup = len(all_seasons[0])
    eval_predictions = final_elo.predictions[warmup:]

    log_loss_val = compute_log_loss(eval_predictions)
    brier_val = compute_brier_score(eval_predictions)
    accuracy = np.mean([p["home_prob"] > 0.5 for p in eval_predictions
                        if p["home_win"] == 1]) if eval_predictions else 0

    correct = sum(
        1 for p in eval_predictions
        if (p["home_prob"] > 0.5 and p["home_win"] == 1)
        or (p["home_prob"] < 0.5 and p["home_win"] == 0)
    )
    total_accuracy = correct / len(eval_predictions) if eval_predictions else 0

    print(f"\nEvaluation on {len(eval_predictions)} games (seasons 2-5):")
    print(f"  Log-Loss:  {log_loss_val:.4f}")
    print(f"  Brier:     {brier_val:.4f}")
    print(f"  Accuracy:  {total_accuracy:.3f}")

    # --- Step 5: Calibration analysis ---
    print("\n--- Calibration Analysis ---")
    cal_bins = calibration_check(eval_predictions, n_bins=5)
    print(f"{'Bin':>4} {'Predicted':>10} {'Actual':>10} {'Error':>8} {'Count':>6}")
    print("-" * 42)
    for b in cal_bins:
        print(
            f"{b['bin']:>4} {b['mean_predicted']:>10.4f} "
            f"{b['actual_frequency']:>10.4f} {b['calibration_error']:>8.4f} "
            f"{b['count']:>6}"
        )

    avg_cal_error = np.mean([b["calibration_error"] for b in cal_bins])
    print(f"\nAverage calibration error: {avg_cal_error:.4f}")

    # --- Step 6: Simulated betting analysis ---
    print("\n--- Simulated Betting Analysis ---")
    edge_threshold = 0.05  # Only bet when model edge exceeds 5%
    bankroll = 1000.0
    bet_size = 10.0
    bet_results = []

    for pred in eval_predictions:
        # Simulate market line: true prob + noise
        market_prob = pred["home_prob"] + np.random.normal(0, 0.05)
        market_prob = max(0.15, min(0.85, market_prob))

        model_edge = pred["home_prob"] - market_prob

        if abs(model_edge) > edge_threshold:
            if model_edge > 0:
                # Bet on home team
                won = pred["home_win"] == 1
            else:
                # Bet on away team
                won = pred["home_win"] == 0

            payout = bet_size * 0.91 if won else -bet_size
            bankroll += payout
            bet_results.append({
                "edge": abs(model_edge),
                "won": won,
                "payout": payout,
            })

    n_bets = len(bet_results)
    wins = sum(1 for r in bet_results if r["won"])
    total_profit = sum(r["payout"] for r in bet_results)

    print(f"  Games evaluated:     {len(eval_predictions)}")
    print(f"  Bets placed:         {n_bets}")
    print(f"  Bets won:            {wins} ({100*wins/n_bets:.1f}%)" if n_bets > 0 else "")
    print(f"  Total profit/loss:   ${total_profit:+.2f}")
    print(f"  ROI:                 {100*total_profit/(n_bets*bet_size):+.1f}%" if n_bets > 0 else "")
    print(f"  Final bankroll:      ${bankroll:.2f}")

    # --- Step 7: Final rankings ---
    print("\n--- Final Elo Rankings ---")
    rankings = sorted(final_elo.ratings.items(), key=lambda x: x[1], reverse=True)
    for rank, (team, rating) in enumerate(rankings, 1):
        print(f"  {rank:>2}. {team:<12} {rating:.1f}")


if __name__ == "__main__":
    run_case_study()

Key Findings

After running the full pipeline, several important patterns emerge.

K-Factor Selection. The optimal K-factor for our synthetic NFL data typically falls between 15 and 25. Lower K-factors (5-10) produce stable ratings but fail to track genuine mid-season form changes. Higher K-factors (35-40) react too aggressively to individual game results, degrading log-loss because they overfit to noise. The "sweet spot" balances responsiveness with stability and varies slightly depending on the specific season dynamics.

Calibration Quality. When we examine the calibration bins, we expect to see the actual frequency track the predicted probability reasonably closely. The bins at the extremes (very high or very low predicted probabilities) often show the most calibration error because they contain fewer games and are more susceptible to small-sample noise. For a well-tuned system, the average calibration error across bins should be below 0.03 (3 percentage points).

Margin of Victory. Including the MOV adjustment consistently improves log-loss by 1-3% compared to win/loss-only Elo. The improvement is particularly noticeable for games with large margins, where the standard Elo update fails to incorporate the additional information that blowouts provide.

Betting Simulation. The simulated betting analysis demonstrates a fundamental truth: even a well-calibrated Elo system produces modest edges against market-like lines. With a 5% edge threshold, the system typically identifies 30-40% of games as bets, with a win rate of 52-55% and an ROI of 1-4% depending on the noise added to market lines. This is consistent with the difficulty of sports betting: even good models produce small, volatile edges.

Lessons for Practitioners

  1. Start simple, iterate carefully. The basic Elo framework (K-factor + home advantage) captures 80% of the value. Additions like MOV and season regression provide incremental improvements.

  2. Use out-of-sample evaluation exclusively. In-sample log-loss is meaningless for a sequential system. Always evaluate on games the model had not seen when making its prediction.

  3. Calibration matters more than accuracy. A model that predicts 60% probabilities that actually win 60% of the time is more useful for betting than a model with higher accuracy but poor calibration.

  4. Edge size determines profitability. The difference between a 52% and a 55% win rate at -110 odds is the difference between slow losses and modest profits. Every fraction of a percent matters.

  5. Combine with other systems. As we will see in Case Study 2, Elo alone is rarely sufficient. Its greatest value is as one input in a multi-system ensemble.