Case Study 2: Diagnosing a Struggling Run Game

Overview

In this case study, you'll analyze a team's struggling run game to determine whether the problem lies with the running backs, offensive line, play-calling, or some combination. This reflects real in-season analysis that teams perform.


The Scenario

State University's run game has underperformed expectations. After averaging 4.8 YPC last season, they're only managing 3.6 YPC through 6 games. The coaching staff wants answers before making personnel changes.


Part 1: The Problem

import pandas as pd
import numpy as np
from typing import Dict, List

# Season comparison
last_season = {
    'games': 13,
    'carries': 456,
    'yards': 2189,
    'touchdowns': 22,
    'success_rate': 48.2,
    'avg_ybc': 2.8,
    'avg_yac': 2.0,
    'stuff_rate': 16.5,
    'explosive_rate': 14.2
}

this_season = {
    'games': 6,
    'carries': 198,
    'yards': 713,
    'touchdowns': 5,
    'success_rate': 38.4,
    'avg_ybc': 1.9,
    'avg_yac': 1.7,
    'stuff_rate': 24.7,
    'explosive_rate': 9.1
}

print("=" * 70)
print("SEASON COMPARISON: RUN GAME")
print("=" * 70)
print(f"{'Metric':<25} {'Last Season':>15} {'This Season':>15} {'Change':>12}")
print("-" * 70)

metrics = ['ypc', 'success_rate', 'avg_ybc', 'avg_yac', 'stuff_rate', 'explosive_rate']

# Calculate YPC
last_season['ypc'] = last_season['yards'] / last_season['carries']
this_season['ypc'] = this_season['yards'] / this_season['carries']

for metric in metrics:
    last = last_season.get(metric, 0)
    this = this_season.get(metric, 0)
    change = this - last
    indicator = "⚠️" if (metric in ['stuff_rate'] and change > 0) or \
                       (metric not in ['stuff_rate'] and change < 0) else ""
    print(f"{metric:<25} {last:>15.1f} {this:>15.1f} {change:>+11.1f} {indicator}")

Part 2: Component Analysis

class RunGameDiagnostic:
    """Diagnose run game issues."""

    def __init__(self, carries: List[Dict]):
        self.carries = carries

    def analyze_blocking(self) -> Dict:
        """Analyze offensive line contribution."""
        # Yards before contact
        total_ybc = sum(c.get('yards_before_contact', 0) for c in self.carries)
        avg_ybc = total_ybc / len(self.carries)

        # Line yards (capped at 4)
        line_yards = sum(min(c.get('yards_before_contact', 0), 4) for c in self.carries)
        avg_line_yards = line_yards / len(self.carries)

        # Opportunity rate (4+ yards before contact)
        opportunities = sum(1 for c in self.carries
                           if c.get('yards_before_contact', 0) >= 4)
        opportunity_rate = opportunities / len(self.carries) * 100

        # Stuffs at line
        stuffs_at_line = sum(1 for c in self.carries
                            if c.get('yards_before_contact', 0) <= 0)
        stuff_rate = stuffs_at_line / len(self.carries) * 100

        # Grade (A-F)
        if avg_ybc >= 3.0:
            grade = 'A'
        elif avg_ybc >= 2.5:
            grade = 'B'
        elif avg_ybc >= 2.0:
            grade = 'C'
        elif avg_ybc >= 1.5:
            grade = 'D'
        else:
            grade = 'F'

        return {
            'avg_ybc': round(avg_ybc, 2),
            'avg_line_yards': round(avg_line_yards, 2),
            'opportunity_rate': round(opportunity_rate, 1),
            'stuff_rate_at_line': round(stuff_rate, 1),
            'grade': grade
        }

    def analyze_backs(self) -> Dict:
        """Analyze running back contribution."""
        # Yards after contact
        total_yac = sum(
            c.get('yards_gained', 0) - c.get('yards_before_contact', 0)
            for c in self.carries
        )
        avg_yac = total_yac / len(self.carries)

        # Broken tackles
        broken_tackles = sum(c.get('broken_tackles', 0) for c in self.carries)
        bt_per_carry = broken_tackles / len(self.carries)

        # Missed opportunities (good blocking but short gain)
        missed = sum(1 for c in self.carries
                    if c.get('yards_before_contact', 0) >= 4 and
                    c.get('yards_gained', 0) < c.get('yards_before_contact', 0) + 2)
        missed_rate = missed / max(sum(1 for c in self.carries
                                       if c.get('yards_before_contact', 0) >= 4), 1) * 100

        # Grade
        if avg_yac >= 2.5:
            grade = 'A'
        elif avg_yac >= 2.0:
            grade = 'B'
        elif avg_yac >= 1.5:
            grade = 'C'
        elif avg_yac >= 1.0:
            grade = 'D'
        else:
            grade = 'F'

        return {
            'avg_yac': round(avg_yac, 2),
            'bt_per_carry': round(bt_per_carry, 3),
            'missed_opportunity_rate': round(missed_rate, 1),
            'grade': grade
        }

    def analyze_scheme(self) -> Dict:
        """Analyze play-calling and scheme effectiveness."""
        # Run gap distribution
        gaps = {'A': 0, 'B': 0, 'C': 0, 'outside': 0}
        for c in self.carries:
            gap = c.get('run_gap', 'B')
            if gap in gaps:
                gaps[gap] += 1

        # Success by gap
        gap_success = {}
        for gap in ['A', 'B', 'C', 'outside']:
            gap_carries = [c for c in self.carries if c.get('run_gap') == gap]
            if gap_carries:
                success = sum(1 for c in gap_carries
                             if self._is_successful(c)) / len(gap_carries) * 100
                gap_success[gap] = round(success, 1)

        # Predictability (run rate by down)
        first_down = [c for c in self.carries if c.get('down') == 1]
        second_down = [c for c in self.carries if c.get('down') == 2]

        # Box count faced
        avg_box = np.mean([c.get('defenders_in_box', 7) for c in self.carries])

        return {
            'gap_distribution': gaps,
            'gap_success': gap_success,
            'avg_defenders_in_box': round(avg_box, 1),
            'first_down_runs': len(first_down),
            'run_heavy_first': len(first_down) > len(self.carries) * 0.4
        }

    def _is_successful(self, carry: Dict) -> bool:
        """Determine if carry was successful."""
        down = carry.get('down', 1)
        distance = carry.get('distance', 10)
        yards = carry.get('yards_gained', 0)

        if down == 1:
            return yards >= distance * 0.4
        elif down == 2:
            return yards >= distance * 0.5
        else:
            return yards >= distance

    def generate_diagnosis(self) -> Dict:
        """Generate full diagnosis with attribution."""
        blocking = self.analyze_blocking()
        backs = self.analyze_backs()
        scheme = self.analyze_scheme()

        # Attribution percentages
        if blocking['grade'] in ['D', 'F'] and backs['grade'] in ['B', 'A']:
            attribution = {'blocking': 60, 'backs': 15, 'scheme': 25}
            primary_issue = 'OFFENSIVE LINE'
        elif backs['grade'] in ['D', 'F'] and blocking['grade'] in ['B', 'A']:
            attribution = {'blocking': 15, 'backs': 60, 'scheme': 25}
            primary_issue = 'RUNNING BACKS'
        elif scheme['avg_defenders_in_box'] > 7.5:
            attribution = {'blocking': 30, 'backs': 20, 'scheme': 50}
            primary_issue = 'SCHEME/PREDICTABILITY'
        else:
            attribution = {'blocking': 35, 'backs': 35, 'scheme': 30}
            primary_issue = 'MULTIPLE FACTORS'

        return {
            'blocking_analysis': blocking,
            'back_analysis': backs,
            'scheme_analysis': scheme,
            'attribution': attribution,
            'primary_issue': primary_issue
        }


# Generate sample carry data for this season
np.random.seed(42)

this_season_carries = []
for _ in range(198):
    # Simulate poor blocking (avg YBC of 1.9)
    ybc = max(0, np.random.normal(1.9, 1.5))

    # Simulate slightly below average YAC
    yac = max(0, np.random.exponential(1.4))

    this_season_carries.append({
        'yards_before_contact': round(ybc, 1),
        'yards_gained': round(ybc + yac, 1),
        'broken_tackles': 1 if np.random.random() < 0.12 else 0,
        'down': np.random.choice([1, 2, 3], p=[0.45, 0.35, 0.20]),
        'distance': np.random.choice([10, 8, 5, 3, 2], p=[0.4, 0.2, 0.2, 0.1, 0.1]),
        'run_gap': np.random.choice(['A', 'B', 'C', 'outside'], p=[0.25, 0.35, 0.25, 0.15]),
        'defenders_in_box': np.random.choice([6, 7, 8, 9], p=[0.15, 0.35, 0.35, 0.15])
    })

# Run diagnosis
diagnostic = RunGameDiagnostic(this_season_carries)
diagnosis = diagnostic.generate_diagnosis()

print("\n" + "=" * 70)
print("RUN GAME DIAGNOSIS")
print("=" * 70)

print("\nOFFENSIVE LINE ANALYSIS:")
print("-" * 40)
for k, v in diagnosis['blocking_analysis'].items():
    print(f"  {k}: {v}")

print("\nRUNNING BACK ANALYSIS:")
print("-" * 40)
for k, v in diagnosis['back_analysis'].items():
    print(f"  {k}: {v}")

print("\nSCHEME ANALYSIS:")
print("-" * 40)
for k, v in diagnosis['scheme_analysis'].items():
    print(f"  {k}: {v}")

print(f"\n{'='*70}")
print(f"PRIMARY ISSUE: {diagnosis['primary_issue']}")
print(f"{'='*70}")
print(f"Attribution: O-Line {diagnosis['attribution']['blocking']}%, "
      f"Backs {diagnosis['attribution']['backs']}%, "
      f"Scheme {diagnosis['attribution']['scheme']}%")

Part 3: Position-by-Position Analysis

# Individual linemen analysis (simulated grades)
linemen_grades = {
    'LT Johnson': {'run_block': 72.5, 'pass_block': 78.2, 'stuffs_allowed': 8},
    'LG Smith': {'run_block': 58.4, 'pass_block': 65.1, 'stuffs_allowed': 15},
    'C Williams': {'run_block': 68.2, 'pass_block': 71.5, 'stuffs_allowed': 9},
    'RG Davis': {'run_block': 52.1, 'pass_block': 60.8, 'stuffs_allowed': 18},
    'RT Brown': {'run_block': 65.5, 'pass_block': 68.9, 'stuffs_allowed': 12}
}

# Running back analysis
rb_analysis = {
    'RB1 (Starter)': {
        'carries': 125, 'yards': 438, 'ypc': 3.50,
        'avg_yac': 1.5, 'bt_per_carry': 0.11, 'success_rate': 36.0
    },
    'RB2 (Backup)': {
        'carries': 48, 'yards': 185, 'ypc': 3.85,
        'avg_yac': 2.1, 'bt_per_carry': 0.15, 'success_rate': 44.0
    },
    'RB3 (Third)': {
        'carries': 25, 'yards': 90, 'ypc': 3.60,
        'avg_yac': 1.8, 'bt_per_carry': 0.08, 'success_rate': 40.0
    }
}

print("\n" + "=" * 70)
print("OFFENSIVE LINE GRADES")
print("=" * 70)
print(f"{'Player':<15} {'Run Block':>12} {'Stuffs Allowed':>15}")
print("-" * 45)

for player, grades in linemen_grades.items():
    print(f"{player:<15} {grades['run_block']:>12.1f} {grades['stuffs_allowed']:>15}")

# Identify weak links
weak_spots = [p for p, g in linemen_grades.items() if g['run_block'] < 60]
print(f"\nWeak Spots: {', '.join(weak_spots)}")

print("\n" + "=" * 70)
print("RUNNING BACK BREAKDOWN")
print("=" * 70)
print(f"{'RB':<15} {'Carries':>8} {'YPC':>6} {'YAC':>6} {'BT/C':>8} {'Succ%':>8}")
print("-" * 55)

for rb, stats in rb_analysis.items():
    print(f"{rb:<15} {stats['carries']:>8} {stats['ypc']:>6.2f} "
          f"{stats['avg_yac']:>6.1f} {stats['bt_per_carry']:>8.2f} {stats['success_rate']:>7.1f}%")

Part 4: Recommendations

def generate_recommendations(diagnosis: Dict, linemen: Dict, rbs: Dict) -> str:
    """Generate actionable recommendations."""

    recommendations = """
╔══════════════════════════════════════════════════════════════════════════╗
║                    RUN GAME IMPROVEMENT PLAN                             ║
╠══════════════════════════════════════════════════════════════════════════╣
║                                                                          ║
║ IMMEDIATE ACTIONS:                                                       ║
║ ─────────────────────────────────────────────────────────────────────── ║
"""

    if diagnosis['primary_issue'] == 'OFFENSIVE LINE':
        recommendations += """
║ 1. PERSONNEL CHANGE: Bench RG Davis (52.1 run block grade)               ║
║    - Move G Backup to starting RG position                               ║
║    - RG Davis has 18 stuffs allowed (team high)                          ║
║                                                                          ║
║ 2. SCHEME ADJUSTMENT: Run away from weak side                            ║
║    - Increase runs to left side (stronger blockers)                      ║
║    - Reduce inside zone, add more gap/power concepts                     ║
║                                                                          ║
║ 3. GAME PLAN: Include more pulling guards                                ║
║    - Get Smith (LG) moving to create angles                              ║
"""
    else:
        recommendations += """
║ 1. INCREASE RB2's CARRIES                                                ║
║    - RB2 showing better YAC and success rate                             ║
║    - Consider 60/40 split instead of current 75/25                       ║
"""

    recommendations += """
║                                                                          ║
║ SCHEME ADJUSTMENTS:                                                      ║
║ ─────────────────────────────────────────────────────────────────────── ║
║                                                                          ║
║ • Reduce predictable 1st down runs (45% of runs on 1st down)            ║
║ • Mix in more play-action to lighten boxes (avg 7.5 in box)             ║
║ • Add RPO concepts to give RBs better angles                            ║
║ • Increase outside runs (only 15% currently)                            ║
║                                                                          ║
║ DO NOT RECOMMEND:                                                        ║
║ ─────────────────────────────────────────────────────────────────────── ║
║                                                                          ║
║ ✗ Abandoning the run game entirely                                       ║
║   Analysis shows issues are correctable                                  ║
║                                                                          ║
║ ✗ Benching RB1 completely                                                ║
║   Production issues primarily blocking-related                           ║
║                                                                          ║
╚══════════════════════════════════════════════════════════════════════════╝
"""
    return recommendations


print(generate_recommendations(diagnosis, linemen_grades, rb_analysis))

Summary

This case study demonstrated how to diagnose run game issues:

  1. Compare to baseline: Identify what changed from successful periods
  2. Component analysis: Separate blocking, back skill, and scheme
  3. Attribution: Determine primary cause of struggles
  4. Position-level analysis: Find specific weak links
  5. Actionable recommendations: Personnel and scheme adjustments

Key Findings:

  • Offensive line (specifically RG) is primary issue
  • RB2 outperforming starter in limited carries
  • Scheme too predictable (heavy 1st down runs)
  • Defenses loading box due to lack of play-action