5 min read

The offensive line presents the greatest analytical challenge in football. These five players rarely touch the ball, generate no traditional statistics, and work as a unit where individual contribution is difficult to isolate. Yet their impact is...

Chapter 9: Offensive Line Analytics

Chapter Overview

The offensive line presents the greatest analytical challenge in football. These five players rarely touch the ball, generate no traditional statistics, and work as a unit where individual contribution is difficult to isolate. Yet their impact is profound—elite blocking creates rushing lanes, extends plays for quarterbacks, and enables the entire offensive system. This chapter explores the methods analysts use to evaluate offensive lines despite data limitations, from team-level metrics to individual grading systems and emerging tracking-based approaches.

Learning Objectives

By the end of this chapter, you will be able to:

  1. Understand why offensive line analytics is uniquely challenging
  2. Calculate team-level O-line metrics from play-by-play data
  3. Interpret sack rate, pressure rate, and stuff rate as blocking indicators
  4. Analyze rushing success relative to blocking quality
  5. Understand individual O-line grading methodologies
  6. Evaluate pass protection and run blocking separately
  7. Recognize limitations in current O-line evaluation methods

9.1 The Offensive Line Analytics Challenge

Why O-Line is Different

Unlike other positions, offensive linemen:

  1. Generate no direct statistics: No yards, catches, or touchdowns
  2. Share credit (and blame): Five players work together on every play
  3. Depend on scheme: Zone vs. power creates different evaluations
  4. Face variable opposition: Some defensive fronts are harder than others
  5. Are invisible to casual observers: Non-blocking outcomes draw attention

The Data Problem

Standard play-by-play data contains almost nothing about offensive line play:

import nfl_data_py as nfl
import pandas as pd

pbp = nfl.import_pbp_data([2023])

# What O-line data exists in standard PBP?
oline_columns = [col for col in pbp.columns if 'line' in col.lower() or
                 'block' in col.lower() or 'sack' in col.lower()]
print("O-line related columns:", oline_columns)

# Result: Mostly outcome-based (sack, qb_hit) rather than process-based

What We Can Measure

From play-by-play: - Sacks and QB hits allowed - Rushing yards and success rates - Time to throw (via tracking) - Scrambles and pressured plays

From charting services (PFF, SIS): - Individual player grades - Pressures allowed per player - Run blocking grades by zone - Penalties

From tracking data (Next Gen Stats): - Time in pocket - Yards before contact - Separation at catch point


9.2 Team-Level Blocking Metrics

Sack Rate Analysis

Sack rate is the most accessible blocking metric:

def calculate_sack_rate(pbp: pd.DataFrame) -> pd.DataFrame:
    """Calculate sack rate by team."""
    # Filter to pass attempts
    pass_plays = pbp[pbp['pass_attempt'] == 1]

    team_sacks = (pass_plays
        .groupby('posteam')
        .agg(
            dropbacks=('pass_attempt', 'count'),
            sacks=('sack', 'sum'),
            qb_hits=('qb_hit', 'sum'),
            scrambles=('qb_scramble', 'sum')
        )
    )

    team_sacks['sack_rate'] = team_sacks['sacks'] / team_sacks['dropbacks']
    team_sacks['pressure_rate'] = (
        (team_sacks['sacks'] + team_sacks['qb_hits'] + team_sacks['scrambles']) /
        team_sacks['dropbacks']
    )

    return team_sacks.sort_values('sack_rate')

Interpreting Sack Rate

Sack Rate Interpretation
< 4% Elite protection
4-6% Above average
6-8% Average
8-10% Below average
> 10% Poor protection

Important caveat: Sack rate reflects both O-line and quarterback. Quick releases reduce sacks; slow processers increase them.

Adjusted Sack Rate

To better isolate O-line from QB:

def adjusted_sack_rate(pbp: pd.DataFrame) -> pd.DataFrame:
    """Adjust sack rate for QB time to throw tendencies."""
    pass_plays = pbp[pbp['pass_attempt'] == 1]

    # Group by team
    team_stats = (pass_plays
        .groupby('posteam')
        .agg(
            dropbacks=('pass_attempt', 'count'),
            sacks=('sack', 'sum'),
            avg_target_depth=('air_yards', 'mean'),  # Proxy for time needed
            shotgun_rate=('shotgun', 'mean')
        )
    )

    team_stats['raw_sack_rate'] = team_stats['sacks'] / team_stats['dropbacks']

    # Simple adjustment: deeper targets need more time
    # Adjust for ADOT (higher ADOT = more time needed = more excusable sacks)
    league_adot = team_stats['avg_target_depth'].mean()
    team_stats['adot_factor'] = team_stats['avg_target_depth'] / league_adot

    team_stats['adj_sack_rate'] = team_stats['raw_sack_rate'] / team_stats['adot_factor']

    return team_stats.sort_values('adj_sack_rate')

9.3 Rush Blocking Evaluation

Stuff Rate: Run Defense at the Line

Stuff rate measures how often rushers are stopped at or behind the line:

def calculate_stuff_rate(pbp: pd.DataFrame) -> pd.DataFrame:
    """Calculate stuff rate by team (offense perspective)."""
    rushes = pbp[pbp['rush_attempt'] == 1]

    team_rushing = (rushes
        .groupby('posteam')
        .agg(
            carries=('rush_attempt', 'count'),
            stuffed=('yards_gained', lambda x: (x <= 0).sum()),
            negative=('yards_gained', lambda x: (x < 0).sum()),
            explosive=('yards_gained', lambda x: (x >= 10).sum())
        )
    )

    team_rushing['stuff_rate'] = team_rushing['stuffed'] / team_rushing['carries']
    team_rushing['negative_rate'] = team_rushing['negative'] / team_rushing['carries']
    team_rushing['explosive_rate'] = team_rushing['explosive'] / team_rushing['carries']

    return team_rushing.sort_values('stuff_rate')

Interpreting Stuff Rate

Stuff Rate Interpretation
< 15% Excellent run blocking
15-18% Above average
18-22% Average
22-25% Below average
> 25% Poor run blocking

Yards Before Contact

If tracking data is available, yards before contact (YBC) directly measures blocking:

def analyze_yards_before_contact(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze YBC as blocking measure (if available)."""
    rushes = pbp[pbp['rush_attempt'] == 1]

    if 'yards_before_contact' not in rushes.columns:
        print("YBC data not available in standard PBP")
        print("Available through Next Gen Stats or PFF")
        return None

    ybc_stats = (rushes
        .groupby('posteam')
        .agg(
            carries=('rush_attempt', 'count'),
            avg_ybc=('yards_before_contact', 'mean'),
            total_ybc=('yards_before_contact', 'sum'),
            avg_total_yards=('yards_gained', 'mean')
        )
    )

    ybc_stats['ybc_pct'] = ybc_stats['avg_ybc'] / ybc_stats['avg_total_yards']

    return ybc_stats.sort_values('avg_ybc', ascending=False)

9.4 Adjusted Line Yards (ALY)

Football Outsiders' Approach

Adjusted Line Yards (ALY) attempts to isolate O-line contribution from RB contribution:

def calculate_adjusted_line_yards(pbp: pd.DataFrame) -> pd.DataFrame:
    """Calculate ALY-style metric."""
    rushes = pbp[pbp['rush_attempt'] == 1].copy()

    # ALY caps credit for long runs (RB contribution)
    # Full credit for 0-4 yards
    # Reduced credit for 5-10 yards (50%)
    # Minimal credit for 10+ (25%)

    def line_yards(yards):
        if yards < 0:
            return yards * 1.25  # Penalty for stuffs
        elif yards <= 4:
            return yards
        elif yards <= 10:
            return 4 + (yards - 4) * 0.5
        else:
            return 4 + 3 + (yards - 10) * 0.25

    rushes['line_yards'] = rushes['yards_gained'].apply(line_yards)

    team_aly = (rushes
        .groupby('posteam')
        .agg(
            carries=('rush_attempt', 'count'),
            raw_ypc=('yards_gained', 'mean'),
            aly=('line_yards', 'mean'),
            stuff_rate=('yards_gained', lambda x: (x <= 0).mean())
        )
        .sort_values('aly', ascending=False)
    )

    return team_aly

By Direction

Evaluating run blocking by gap:

def aly_by_direction(pbp: pd.DataFrame) -> pd.DataFrame:
    """Calculate ALY by run direction."""
    rushes = pbp[pbp['rush_attempt'] == 1].copy()

    if 'run_location' not in rushes.columns:
        print("Run location not available")
        return None

    def line_yards(yards):
        if yards < 0:
            return yards * 1.25
        elif yards <= 4:
            return yards
        elif yards <= 10:
            return 4 + (yards - 4) * 0.5
        else:
            return 4 + 3 + (yards - 10) * 0.25

    rushes['line_yards'] = rushes['yards_gained'].apply(line_yards)

    direction_aly = (rushes
        .groupby(['posteam', 'run_location'])
        .agg(
            carries=('rush_attempt', 'count'),
            aly=('line_yards', 'mean')
        )
        .reset_index()
    )

    # Pivot for comparison
    pivot = direction_aly.pivot(
        index='posteam',
        columns='run_location',
        values='aly'
    )

    return pivot

9.5 Pass Protection Metrics

Pressure Rate

A broader measure than sacks:

def calculate_pressure_metrics(pbp: pd.DataFrame) -> pd.DataFrame:
    """Calculate pass protection metrics."""
    pass_plays = pbp[pbp['pass_attempt'] == 1].copy()

    # Define "pressure" (approximation without charting data)
    # Sack, QB hit, or scramble indicates pressure
    pass_plays['pressured'] = (
        (pass_plays['sack'] == 1) |
        (pass_plays['qb_hit'] == 1) |
        (pass_plays['qb_scramble'] == 1)
    )

    protection = (pass_plays
        .groupby('posteam')
        .agg(
            dropbacks=('pass_attempt', 'count'),
            sacks=('sack', 'sum'),
            qb_hits=('qb_hit', 'sum'),
            scrambles=('qb_scramble', 'sum'),
            pressured_plays=('pressured', 'sum'),
            clean_epa=('epa', lambda x: x[~pass_plays.loc[x.index, 'pressured']].mean()),
            pressured_epa=('epa', lambda x: x[pass_plays.loc[x.index, 'pressured']].mean())
        )
    )

    protection['sack_rate'] = protection['sacks'] / protection['dropbacks']
    protection['pressure_rate'] = protection['pressured_plays'] / protection['dropbacks']
    protection['epa_drop_under_pressure'] = (
        protection['clean_epa'] - protection['pressured_epa']
    )

    return protection.sort_values('pressure_rate')

Clean Pocket Performance

Comparing team performance with and without pressure:

def clean_vs_pressure_analysis(pbp: pd.DataFrame) -> pd.DataFrame:
    """Compare performance clean vs pressured."""
    pass_plays = pbp[pbp['pass_attempt'] == 1].copy()

    pass_plays['pressured'] = (
        (pass_plays['sack'] == 1) |
        (pass_plays['qb_hit'] == 1) |
        (pass_plays['qb_scramble'] == 1)
    )

    by_pressure = (pass_plays
        .groupby(['posteam', 'pressured'])
        .agg(
            plays=('pass_attempt', 'count'),
            epa=('epa', 'mean'),
            comp_pct=('complete_pass', 'mean'),
            ypa=('yards_gained', 'mean')
        )
        .unstack()
    )

    by_pressure.columns = ['_'.join(map(str, col)) for col in by_pressure.columns]

    return by_pressure

9.6 Individual O-Line Evaluation

The Challenge of Individual Grading

Isolating individual linemen requires: 1. Play-by-play assignment: Knowing which defender each lineman blocked 2. Outcome attribution: Determining who was responsible for the result 3. Scheme understanding: Zone blocks differ from man assignments

This data comes from charting services, not standard play-by-play.

PFF Grading System

Pro Football Focus (PFF) grades each player on every play:

  • Scale: 0-100 (60 = average)
  • Components: Pass blocking, run blocking
  • Methodology: Film review with context consideration
# PFF data structure (conceptual - requires subscription)
def understand_pff_grades():
    """Explain PFF O-line grading."""
    explanation = """
    PFF O-Line Grades:

    90-100: All-Pro caliber
    80-89:  Pro Bowl caliber
    70-79:  Starter quality
    60-69:  Average
    50-59:  Below average starter
    40-49:  Backup quality
    <40:    Replacement level

    Components:
    - Pass Block Grade
    - Run Block Grade
    - Overall Grade

    Per-play metrics:
    - Pressures allowed
    - Sacks allowed
    - QB hits allowed
    - Hurries allowed
    """
    print(explanation)

Pressures Allowed

Individual pressure tracking:

def pressures_allowed_analysis():
    """Explain pressures allowed metric."""
    explanation = """
    Pressures Allowed (requires charting):

    Types of pressures:
    - Sacks: QB taken down
    - Hits: QB contacted while throwing
    - Hurries: QB forced to rush throw

    Evaluation:
    - Pressures per pass block
    - Sacks per pass block
    - Total pressure rate

    Considerations:
    - Chip help (RB/TE assistance)
    - Double teams
    - Play design
    - Opponent quality
    """
    print(explanation)

9.7 Scheme Effects on Evaluation

Zone vs. Gap Schemes

Different schemes create different evaluation challenges:

def analyze_scheme_effects():
    """Explain scheme effects on O-line evaluation."""
    schemes = """
    ZONE BLOCKING:
    - All linemen move in same direction
    - Success depends on timing and coordination
    - Harder to assign individual blame
    - Produces higher YPC typically

    GAP/POWER BLOCKING:
    - Specific man assignments
    - Pulling linemen create angles
    - Easier to identify individual failures
    - Lower YPC but more consistent

    EVALUATION IMPLICATIONS:
    - Zone: Collective grade more meaningful
    - Gap: Individual grades more attributable
    - Mixed: Requires play-by-play scheme tagging
    """
    print(schemes)

Pressure Type Analysis

Different pressure sources suggest different O-line issues:

def analyze_pressure_sources(pbp: pd.DataFrame) -> pd.DataFrame:
    """Analyze where pressure comes from."""
    pass_plays = pbp[pbp['pass_attempt'] == 1]

    # Basic breakdown (would need more data for detailed source)
    pressure_analysis = (pass_plays
        .groupby('posteam')
        .agg(
            dropbacks=('pass_attempt', 'count'),
            blitz_rate=('defenders_in_box', lambda x: (x >= 5).mean()),
            sacks_vs_blitz=('sack', lambda x:
                x[pass_plays.loc[x.index, 'defenders_in_box'] >= 5].sum()),
            sacks_vs_no_blitz=('sack', lambda x:
                x[pass_plays.loc[x.index, 'defenders_in_box'] < 5].sum())
        )
    )

    return pressure_analysis

9.8 Tracking Data Applications

Time to Throw

Modern tracking reveals time in pocket:

def analyze_time_to_throw():
    """Explain time to throw analysis."""
    explanation = """
    TIME TO THROW (Next Gen Stats):

    Measures seconds from snap to release.

    O-Line implications:
    - Low time + high efficiency = quick release style
    - High time + low sack rate = elite protection
    - High time + high sack rate = QB or scheme issue
    - Low time + high sack rate = O-line failures

    Typical ranges:
    - <2.5s: Quick game
    - 2.5-3.0s: Average
    - >3.0s: Extended plays

    Caveat: QB style affects time
    """
    print(explanation)

Expected Pressure

Modeled pressure likelihood:

def expected_pressure_model():
    """Explain expected pressure modeling."""
    explanation = """
    EXPECTED PRESSURE MODELING:

    Factors:
    - Defenders rushing
    - Time since snap
    - Blitz indicator
    - Down and distance
    - QB mobility

    Output:
    - Expected pressure rate
    - Pressures allowed vs expected
    - O-line performance over expected

    Benefits:
    - Controls for scheme/situation
    - Allows cross-team comparison
    - Isolates line from QB/play design
    """
    print(explanation)

9.9 Comprehensive O-Line Evaluation Framework

Team-Level Dashboard

class OLineEvaluator:
    """Comprehensive O-line evaluation framework."""

    def __init__(self, pbp: pd.DataFrame):
        self.pbp = pbp
        self.pass_plays = pbp[pbp['pass_attempt'] == 1].copy()
        self.rushes = pbp[pbp['rush_attempt'] == 1].copy()

    def evaluate_team(self, team: str) -> dict:
        """Generate comprehensive O-line evaluation."""
        team_passes = self.pass_plays[self.pass_plays['posteam'] == team]
        team_rushes = self.rushes[self.rushes['posteam'] == team]

        # Pass protection
        pass_protection = {
            'dropbacks': len(team_passes),
            'sacks': team_passes['sack'].sum(),
            'sack_rate': team_passes['sack'].mean(),
            'qb_hits': team_passes['qb_hit'].sum(),
            'pressure_rate': (
                (team_passes['sack'] == 1) |
                (team_passes['qb_hit'] == 1) |
                (team_passes['qb_scramble'] == 1)
            ).mean()
        }

        # Run blocking
        run_blocking = {
            'carries': len(team_rushes),
            'rush_epa': team_rushes['epa'].mean(),
            'rush_success': (team_rushes['epa'] > 0).mean(),
            'ypc': team_rushes['yards_gained'].mean(),
            'stuff_rate': (team_rushes['yards_gained'] <= 0).mean()
        }

        # ALY calculation
        def line_yards(yards):
            if yards < 0:
                return yards * 1.25
            elif yards <= 4:
                return yards
            elif yards <= 10:
                return 4 + (yards - 4) * 0.5
            else:
                return 4 + 3 + (yards - 10) * 0.25

        team_rushes['line_yards'] = team_rushes['yards_gained'].apply(line_yards)
        run_blocking['aly'] = team_rushes['line_yards'].mean()

        return {
            'team': team,
            'pass_protection': pass_protection,
            'run_blocking': run_blocking
        }

    def rank_teams(self) -> pd.DataFrame:
        """Rank all teams by O-line metrics."""
        all_teams = self.pbp['posteam'].dropna().unique()

        results = []
        for team in all_teams:
            eval_data = self.evaluate_team(team)
            results.append({
                'team': team,
                'sack_rate': eval_data['pass_protection']['sack_rate'],
                'pressure_rate': eval_data['pass_protection']['pressure_rate'],
                'stuff_rate': eval_data['run_blocking']['stuff_rate'],
                'aly': eval_data['run_blocking']['aly'],
                'rush_epa': eval_data['run_blocking']['rush_epa']
            })

        df = pd.DataFrame(results)

        # Create composite rank
        df['sack_rank'] = df['sack_rate'].rank()
        df['stuff_rank'] = df['stuff_rate'].rank()
        df['composite'] = (df['sack_rank'] + df['stuff_rank']) / 2

        return df.sort_values('composite')

    def generate_report(self, team: str) -> str:
        """Generate text report for team O-line."""
        eval_data = self.evaluate_team(team)
        pp = eval_data['pass_protection']
        rb = eval_data['run_blocking']

        rankings = self.rank_teams()
        n_teams = len(rankings)
        team_rank = rankings[rankings['team'] == team]['composite'].iloc[0]

        report = f"""
========================================
OFFENSIVE LINE EVALUATION: {team}
========================================

PASS PROTECTION:
  Dropbacks: {pp['dropbacks']}
  Sacks Allowed: {int(pp['sacks'])}
  Sack Rate: {pp['sack_rate']*100:.1f}%
  QB Hits: {int(pp['qb_hits'])}
  Pressure Rate: {pp['pressure_rate']*100:.1f}%

RUN BLOCKING:
  Carries: {rb['carries']}
  Rush EPA: {rb['rush_epa']:.3f}
  YPC: {rb['ypc']:.1f}
  Stuff Rate: {rb['stuff_rate']*100:.1f}%
  Adjusted Line Yards: {rb['aly']:.2f}

OVERALL RANK: {int(team_rank)} of {n_teams}

ASSESSMENT:
"""
        if pp['sack_rate'] < 0.05:
            report += "  + Elite pass protection\n"
        if rb['stuff_rate'] < 0.18:
            report += "  + Strong run blocking\n"
        if pp['sack_rate'] > 0.08:
            report += "  - Pass protection concerns\n"
        if rb['stuff_rate'] > 0.22:
            report += "  - Run blocking struggles\n"

        return report

9.10 Limitations and Future Directions

Current Limitations

  1. Individual attribution is imprecise without charting data
  2. Scheme effects are underappreciated in standard metrics
  3. Opponent adjustment is difficult for O-line
  4. QB contribution conflates with O-line in pass protection
  5. RB contribution conflates with O-line in run blocking

Emerging Approaches

Player tracking improvements: - Individual blocker assignments via computer vision - Contact point detection - Automated pressure attribution

Machine learning applications: - Expected yards based on blocking - Pressure probability models - Win rate estimation

def future_oline_metrics():
    """Describe emerging O-line metrics."""
    metrics = """
    EMERGING O-LINE METRICS:

    Win Rate (ESPN/NFL):
    - % of pass blocks won
    - Based on tracking data
    - Individual player level

    Expected Rushing Yards:
    - Models yards based on blocking
    - Attributes yards to O-line vs RB
    - Uses tracking data

    Pressure Probability:
    - Expected pressure given situation
    - Performance over expected
    - Individual lineman attribution

    These require data not in standard PBP.
    Available through ESPN, Next Gen Stats, PFF.
    """
    print(metrics)

Chapter Summary

Key Takeaways

  1. O-line analytics is data-limited in standard play-by-play
  2. Team-level metrics (sack rate, stuff rate, ALY) provide useful approximations
  3. Individual grading requires charting data (PFF, SIS)
  4. Scheme affects evaluation significantly
  5. QB and RB conflate with O-line in outcome metrics
  6. Tracking data is improving attribution
  7. Pass and run blocking should be evaluated separately

Common Analytical Mistakes

Mistake Better Approach
Blaming O-line for all sacks Consider QB time and mobility
Crediting O-line for all rushing Separate YBC from YAC
Ignoring scheme Zone vs gap affects metrics
Individual from team stats Need charting for individual

Looking Ahead

Chapter 10 explores defensive analytics—evaluating the 11 players trying to stop everything we've analyzed so far.


Practice Exercises

See the accompanying exercises.md file for hands-on practice problems covering O-line evaluation at both team and individual levels.

Further Reading

See further-reading.md for academic papers, industry resources, and data sources for offensive line analytics.