Learning ObjectivesBy the end of this chapter, you will be able to:
- Master tidyverse (R) and pandas (Python) for football data manipulation
- Filter, select, and transform play-by-play and roster data effectively
- Handle missing data and outliers in football datasets
- Create derived variables and features for advanced analytics
- Aggregate and summarize data at player, team, and game levels
- Reshape data between wide and long formats
- Join and merge multiple football datasets
- Clean and standardize team names, player names, and temporal data
Introduction
Data wrangling—also called data munging or data preparation—is the process of transforming raw data into a clean, structured format suitable for analysis. In football analytics, this step is crucial because raw play-by-play data, roster information, and schedule data rarely arrive in the exact format you need for analysis. Understanding how to efficiently reshape, filter, combine, and clean data is what separates beginners from practitioners who can tackle real-world analytical challenges.
Think of data wrangling as the foundation of a building. No matter how sophisticated your statistical models or elegant your visualizations, if your data foundation is unstable—filled with errors, missing values, or improperly structured—everything built on top will be compromised. A quarterback evaluation model that includes penalty plays, a team efficiency analysis that doesn't account for garbage time, or a player comparison that fails to handle missing data will produce misleading results that lead to poor decisions.
The importance of data wrangling in football analytics cannot be overstated for several reasons:
Play-by-play data complexity: The nflfastR dataset includes over 370 variables per play. Most analyses require only 10-20 of these variables, but identifying which variables to use, understanding their meanings, and selecting them efficiently is essential. Many variables are highly correlated (yards_gained and epa_yards), some are situation-specific (air_yards only for passes), and others contain nuanced information that requires domain knowledge to interpret correctly.
Missing values are pervasive: EPA is missing on penalties and two-point conversions. Air yards and completion probability don't exist for run plays. Player IDs are missing when players aren't involved in a play. Understanding why data is missing and how to handle it appropriately is crucial. Blindly deleting rows with any missing values would eliminate most of your dataset, while improperly filling missing values can introduce bias and lead to incorrect conclusions.
Outliers require domain knowledge: A play with -15 EPA might be a catastrophic turnover in great field position, or it might be a garbage-time play that shouldn't influence your model of competitive performance. A 99-yard touchdown pass is a legitimate outlier that tells you something important about a player's ability, while a Hail Mary completion that gained 60 yards but still resulted in negative EPA requires context to interpret correctly. You can't mechanically apply statistical outlier detection rules without understanding football context.
Multiple datasets must be combined: Answering interesting questions requires joining play-by-play data with rosters (to get player demographics), with schedules (to get game context), with Next Gen Stats (to get tracking data), and potentially with external data sources (weather, Vegas lines, injury reports). Each join introduces opportunities for errors if not handled carefully—mismatched team abbreviations, missing keys, and duplicate records can all corrupt your analysis.
Inconsistent formats require standardization: Team names appear as "KC" in some datasets and "KAN" in others. Player names might be "P.Mahomes", "Patrick Mahomes", or "MAHOMES, PATRICK". Dates might be strings ("2023-09-07") or date objects. Standardizing these inconsistencies is tedious but essential for correct analysis.
Studies suggest that data scientists spend 60-80% of their time on data wrangling, with only 20-40% on modeling and visualization. This chapter equips you with the essential techniques to efficiently prepare football data for analysis. Mastering these skills will dramatically improve your productivity, reduce errors, and enable you to tackle more sophisticated analytical questions.
The 80/20 Rule of Data Analysis
In football analytics, 80% of your time will be spent wrangling data, and 20% on modeling and visualization. While this might seem backwards—shouldn't analysis be the main activity?—it reflects reality. Clean, well-structured data makes analysis straightforward. Messy, poorly understood data makes even simple analyses error-prone and time-consuming. The good news: time invested in learning data wrangling pays dividends. Techniques you'll learn in this chapter—filtering, grouping, joining, handling missing data—apply to virtually every analysis you'll ever conduct. Master these fundamentals, and more complex analyses become manageable.Tools of the Trade
We'll use two primary ecosystems for data wrangling, each with distinct strengths and philosophies:
R (tidyverse): The tidyverse is a collection of R packages designed for data science with a consistent, human-readable syntax. Key packages include:
dplyr: Data manipulation (filter, select, mutate, summarize) using verb-based functions that express intent clearlytidyr: Data reshaping (pivot_longer, pivot_wider) for converting between wide and long formatsstringr: String manipulation with consistent function names and argumentslubridate: Date/time handling that simplifies common temporal operations
The tidyverse philosophy emphasizes readability and consistency. Functions have verb names (filter, select, arrange) that describe what they do. The pipe operator (%>% or |>) chains operations together in a readable left-to-right sequence. This makes tidyverse code relatively easy to read and understand even months after writing it.
Python (pandas): pandas is the foundational data manipulation library in Python, providing:
- DataFrames for tabular data with powerful indexing and querying capabilities
- Rich methods for filtering, grouping, and transforming with both method chaining and direct operations
- Integration with NumPy for numerical operations, enabling efficient vectorized computations
- Flexibility in syntax—multiple ways to accomplish the same task, allowing you to choose the most appropriate for your situation
pandas draws inspiration from R but adds Python's general-purpose programming capabilities. This makes it ideal for analyses that combine data manipulation with other tasks (web scraping, machine learning, automation). While pandas syntax can be less consistent than tidyverse (multiple ways to filter, select, or group), it's extremely powerful and widely used in industry.
Which Should You Choose? Both ecosystems are excellent. R/tidyverse excels at interactive data analysis and visualization, particularly for statistical work. Python/pandas excels at data pipelines, integration with machine learning libraries, and production systems. Many professional analysts use both, choosing based on the specific task. This textbook provides examples in both languages, allowing you to learn either or both.
Learning Both Languages: A Strategic Advantage
While you can accomplish nearly any football analytics task in either R or Python, learning both provides significant advantages: 1. Career Flexibility: Some organizations prefer R (common in research and statistics-heavy teams), others prefer Python (common in engineering-heavy teams). Knowing both makes you valuable to more organizations. 2. Tool Selection: Some tasks are easier in R (statistical modeling, publication-quality visualizations), others easier in Python (machine learning, data pipelines, web applications). Choose the right tool for each task. 3. Cross-Pollination: Concepts learned in one language deepen understanding of the other. Understanding tidyverse's dplyr helps you appreciate pandas, and vice versa. 4. Reading Others' Code: Much football analytics work is shared publicly in either R or Python. Being bilingual lets you learn from all sources, not just those in your preferred language. Start with whichever language you're more comfortable with, but consider learning the other as your skills develop. The conceptual understanding—what operations you need to perform—matters more than the specific syntax.Loading Required Libraries
Before we can wrangle data, we need to load the libraries that provide data manipulation functionality. This is a necessary first step in every R or Python session where you'll be working with data.
#| label: setup-r
#| message: false
#| warning: false
# Load core tidyverse packages
# The tidyverse is actually a meta-package that loads multiple packages:
# - dplyr (data manipulation)
# - ggplot2 (visualization)
# - tidyr (data reshaping)
# - readr (data import)
# - purrr (functional programming)
# - tibble (modern data frames)
# - stringr (string manipulation)
# - forcats (factor handling)
library(tidyverse) # Data manipulation and visualization
# Load NFL data packages
# nflfastR provides play-by-play data with advanced metrics pre-calculated
# This includes EPA, WPA, success rate, and 370+ other variables
library(nflfastR) # NFL play-by-play data
# Load additional packages for specific tasks
# lubridate simplifies date/time operations (parsing, arithmetic, extraction)
library(lubridate) # Date/time handling
# gt creates publication-quality tables with extensive formatting options
library(gt) # Professional table formatting
# nflplotR adds NFL team logos, colors, and styling to ggplot2 visualizations
library(nflplotR) # NFL-specific plotting tools
# Set default ggplot2 theme for consistent appearance
# theme_minimal() provides clean, uncluttered plots suitable for presentations
theme_set(theme_minimal())
# Display confirmation
cat("✓ All R packages loaded successfully\n")
cat("✓ Ready to wrangle football data!\n")
#| label: setup-py
#| message: false
#| warning: false
# Import core data manipulation packages
# pandas is the primary library for working with tabular data (DataFrames)
# By convention, we import pandas as 'pd' - this is universal in Python data science
import pandas as pd
# numpy provides numerical computing capabilities (arrays, math functions, etc.)
# By convention, we import numpy as 'np'
import numpy as np
# Import NFL data package
# nfl_data_py is the Python equivalent of nflfastR
# By convention, we import it as 'nfl'
import nfl_data_py as nfl
# Import visualization packages
# matplotlib.pyplot is the core plotting library (like base R graphics)
import matplotlib.pyplot as plt
# seaborn provides statistical visualization built on matplotlib
# It creates more attractive plots with less code
import seaborn as sns
# Import datetime for temporal operations
from datetime import datetime, timedelta
# Configure pandas display options for better readability
# These settings control how DataFrames are displayed in notebooks/console
pd.set_option('display.max_columns', 20) # Show up to 20 columns before truncating
pd.set_option('display.width', 120) # Wider display width
pd.set_option('display.precision', 3) # Show 3 decimal places for floats
# Display confirmation
print("✓ All Python packages loaded successfully")
print("✓ Ready to wrangle football data!")
import pandas as pd- Universal convention, allows you to usepd.DataFrame()instead ofpandas.DataFrame()import numpy as np- Universal convention, allows you to usenp.array()instead ofnumpy.array()import matplotlib.pyplot as plt- Standard for plotting functions
pd.read_csv() in someone else's code, you immediately know it's from pandas.
R's library() vs Python's import:
The syntax differs, but the concept is identical:
- R:
library(dplyr)loads the dplyr package - Python:
import pandas as pdloads the pandas module
library(). Python requires you to prefix functions with the module name (or alias), like pd.DataFrame().
Configuration Options:
The pandas display options at the end of the Python setup control how DataFrames are displayed. These are personal preferences—adjust them based on your screen size and how you prefer to view data. Common options include:
display.max_columns: How many columns to show before truncatingdisplay.max_rows: How many rows to show before truncatingdisplay.width: Total character width for displaydisplay.precision: Decimal places for floating point numbers
Loading Sample Data
Throughout this chapter, we'll work with 2023 NFL play-by-play data as our primary dataset. This provides a rich, real-world dataset for demonstrating data wrangling techniques. We'll also load roster and schedule data to demonstrate joining multiple datasets.
Understanding what data you're working with—its structure, size, and contents—is the first step in any data wrangling task. Let's load data and examine what we have.
#| label: load-sample-data-r
#| message: false
#| warning: false
#| cache: true
# Load 2023 play-by-play data
# load_pbp() is the primary nflfastR function for accessing play-by-play data
# It accepts a season year or vector of years, e.g., load_pbp(2020:2023)
# The function automatically caches data locally, so subsequent loads are fast
pbp <- load_pbp(2023)
# Load roster data for the 2023 season
# Rosters include player names, positions, height, weight, college, draft info
# This data is essential for player-level analyses and demographics
rosters <- load_rosters(2023)
# Load schedule data
# Schedules include game dates, locations, outcomes, and other game metadata
# This is crucial for aggregating play-level data to game level
schedules <- load_schedules(2023)
# Display summary information about loaded datasets
cat("=" %R% 50, "\n")
cat("LOADED DATA SUMMARY\n")
cat("=" %R% 50, "\n\n")
cat("Play-by-Play Data:\n")
cat(sprintf(" - %s plays (rows)\n", format(nrow(pbp), big.mark = ",")))
cat(sprintf(" - %s variables (columns)\n", ncol(pbp)))
cat(sprintf(" - Memory size: %.1f MB\n",
object.size(pbp) / 1024^2))
cat(sprintf(" - Weeks: %d through %d\n",
min(pbp$week, na.rm = TRUE), max(pbp$week, na.rm = TRUE)))
cat("\nRoster Data:\n")
cat(sprintf(" - %s player-season records (rows)\n",
format(nrow(rosters), big.mark = ",")))
cat(sprintf(" - %s variables (columns)\n", ncol(rosters)))
cat("\nSchedule Data:\n")
cat(sprintf(" - %s games (rows)\n", nrow(schedules)))
cat(sprintf(" - %s variables (columns)\n", ncol(schedules)))
# Display sample of key variables from play-by-play data
cat("\n" %R% "=" %R% 50, "\n")
cat("SAMPLE PLAY-BY-PLAY DATA (First 3 Plays)\n")
cat("=" %R% 50, "\n")
pbp %>%
select(game_id, posteam, defteam, down, ydstogo, yardline_100,
desc, yards_gained, epa) %>%
head(3) %>%
print(width = 120)
#| label: load-sample-data-py
#| message: false
#| warning: false
#| cache: true
# Load 2023 play-by-play data
# import_pbp_data() is the primary nfl_data_py function for play-by-play data
# It accepts a list of season years, e.g., [2020, 2021, 2022, 2023]
# Like nflfastR, it caches data locally for fast subsequent loads
pbp = nfl.import_pbp_data([2023])
# Load roster data for 2023 season
# Rosters include player demographics, positions, and background information
rosters = nfl.import_rosters([2023])
# Load schedule data
# Schedules provide game-level information for joining and aggregation
schedules = nfl.import_schedules([2023])
# Display summary information about loaded datasets
print("=" * 70)
print("LOADED DATA SUMMARY")
print("=" * 70)
print("\nPlay-by-Play Data:")
print(f" - {len(pbp):,} plays (rows)")
print(f" - {len(pbp.columns)} variables (columns)")
print(f" - Memory size: {pbp.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
print(f" - Weeks: {pbp['week'].min()} through {pbp['week'].max()}")
print("\nRoster Data:")
print(f" - {len(rosters):,} player-season records (rows)")
print(f" - {len(rosters.columns)} variables (columns)")
print("\nSchedule Data:")
print(f" - {len(schedules)} games (rows)")
print(f" - {len(schedules.columns)} variables (columns)")
# Display sample of key variables from play-by-play data
print("\n" + "=" * 70)
print("SAMPLE PLAY-BY-PLAY DATA (First 3 Plays)")
print("=" * 70)
sample_cols = ['game_id', 'posteam', 'defteam', 'down', 'ydstogo',
'yardline_100', 'desc', 'yards_gained', 'epa']
print(pbp[sample_cols].head(3).to_string(index=False))
- 43,000+ plays in the play-by-play dataset
- 2,700+ player-season records in the roster dataset
- 272 games in the schedule dataset (32 teams × 17 regular season games ÷ 2)
- Identifiers: game_id, play_id, old_game_id
- Situation: down, ydstogo, yardline_100, qtr, game_seconds_remaining
- Teams: posteam (offensive team), defteam (defensive team), home_team, away_team
- Outcomes: yards_gained, touchdown, interception, fumble, penalty
- Players: passer_player_id, rusher_player_id, receiver_player_id, etc.
- Advanced Metrics: epa, wpa, cpoe, success, expected_points
- Tracking Data: air_yards, yards_after_catch, time_to_throw
- Loading multiple seasons simultaneously
- Creating many derived variables
- Working on memory-constrained systems
- Selecting only needed columns immediately after loading
- Filtering to specific teams or time periods
- Loading one season at a time
game_id: Unique identifier combining season, week, and teamsposteam: Team with possession (offense)defteam: Team without possession (defense)down: Current down (1, 2, 3, or 4)ydstogo: Yards needed for first downyardline_100: Yards to opponent's end zone (100 = own goal line, 0 = opponent's goal line)desc: Human-readable play descriptionyards_gained: Net yards gained on the playepa: Expected Points Added
Understanding Data Dimensions
The dimensions of your dataset—number of rows and columns—provide crucial context for all subsequent operations: Rows (Observations): Each row in play-by-play data represents one play. With ~43,000 plays across 272 games, this averages to ~158 plays per game, which matches typical NFL totals (about 130-140 offensive plays plus special teams and penalties). Columns (Variables): The 370+ variables might seem overwhelming, but you'll typically use only 10-20 for any specific analysis. Think of the comprehensive variable set as insurance—whatever question you want to answer, the data probably includes the variables you need. Working with Dimensions:- When filtering rows, track how many remain to ensure your filters work as intended
- When selecting columns, choose only what you need for clarity and performance
- When creating new variables, keep track of what you're adding and why
- When joining datasets, understand how dimensions change (rows might increase if it's a one-to-many join)
Selecting and Filtering Data: Focusing Your Analysis
The first step in most analyses is narrowing your dataset to the relevant subset. With 370+ columns and 43,000+ rows, you need to select only the columns you need and filter to only the rows relevant to your question. This section covers the essential operations for focusing your analysis.
Selecting and filtering serve different purposes:
- Selecting columns reduces clutter, improves performance, and helps you focus on relevant variables
- Filtering rows removes irrelevant observations, allowing you to analyze specific subsets (e.g., only pass plays, only specific teams, only competitive situations)
Mastering these operations is foundational—you'll use them in virtually every analysis you ever conduct.
Selecting Columns: Working with What You Need
Play-by-play data includes 370+ variables, but most analyses use only 10-20. Selecting just the columns you need offers several advantages:
Clarity: With fewer columns, your data is easier to inspect and understand. When you print a dataset with 10 columns, you can see all of them. With 370 columns, most are hidden, making it hard to verify your data looks correct.
Performance: Many operations (printing, filtering, grouping) are faster with fewer columns because there's less data to process. While the performance difference is negligible for single-season data, it becomes significant when working with multiple seasons or very large datasets.
Reduced Errors: With fewer columns, you're less likely to accidentally use the wrong variable. If you have both passer_player_name and receiver_player_name in your dataset, you might accidentally use the wrong one. If you've selected only the variables you need, such mistakes become impossible.
Documentation: Your column selection serves as documentation of what variables your analysis uses. Someone reading your code (including future you) can look at the select() call and immediately see what data you're working with.
Let's look at various approaches to selecting columns:
#| label: select-columns-r
# =====================================
# Basic Column Selection
# =====================================
# Select specific columns by name
# This is the most explicit and self-documenting approach
# Use this when you know exactly which columns you need
pbp_basic <- pbp %>%
select(
# Identifiers
game_id, play_id, game_date,
# Teams
posteam, defteam,
# Situation
down, ydstogo, yardline_100,
# Outcome
play_type, yards_gained,
# Advanced metrics
epa, wpa, success
)
cat("Basic selection:", ncol(pbp_basic), "columns\n")
# =====================================
# Pattern-Based Selection
# =====================================
# Select columns matching patterns
# This is useful when you want all columns related to a specific concept
# The tidyverse provides helper functions for pattern matching:
# - starts_with(): columns that start with a prefix
# - ends_with(): columns that end with a suffix
# - contains(): columns that contain a string
# - matches(): columns matching a regular expression
pbp_pass <- pbp %>%
select(
game_id, play_id, # Always include identifiers
starts_with("pass"), # All passing-related columns
ends_with("_epa"), # All EPA variants
contains("air_yards") # All air yards columns
)
cat("Pass-focused selection:", ncol(pbp_pass), "columns\n")
# =====================================
# Selection by Range
# =====================================
# Select consecutive columns
# Use the colon operator to select all columns between two endpoints
# This is less common because column order might not be meaningful
pbp_range <- pbp %>%
select(game_id:play_id, down:ydstogo, epa:wpa)
cat("Range selection:", ncol(pbp_range), "columns\n")
# =====================================
# Negative Selection (Dropping Columns)
# =====================================
# Sometimes it's easier to specify what to remove rather than what to keep
# Use the minus sign or - operator to drop columns
pbp_no_ids <- pbp %>%
select(
-old_game_id, # Drop individual columns
-ends_with("_player_id"), # Drop pattern-matched columns
-starts_with("td_") # Drop all touchdown probability columns
)
cat("After dropping columns:", ncol(pbp_no_ids), "columns\n")
# =====================================
# Selection with Renaming
# =====================================
# Select and rename simultaneously for clearer variable names
pbp_renamed <- pbp %>%
select(
game = game_id,
offense = posteam,
defense = defteam,
down = down,
distance = ydstogo,
field_position = yardline_100,
play_description = desc,
yards = yards_gained,
expected_points_added = epa
)
cat("Selection with renaming:", ncol(pbp_renamed), "columns\n")
# Display column names from each selection approach
cat("\n========================================\n")
cat("COLUMN NAMES FROM BASIC SELECTION:\n")
cat("========================================\n")
cat(paste(names(pbp_basic), collapse = ", "), "\n")
cat("\n========================================\n")
cat("SAMPLE OF PASS-FOCUSED COLUMNS:\n")
cat("========================================\n")
cat(paste(head(names(pbp_pass), 15), collapse = ", "), "\n")
cat("... and", ncol(pbp_pass) - 15, "more columns\n")
#| label: select-columns-py
# =====================================
# Basic Column Selection
# =====================================
# Select specific columns by name using list indexing
# This is the most explicit approach in pandas
# Pass a list of column names inside square brackets
pbp_basic = pbp[[
# Identifiers
'game_id', 'play_id', 'game_date',
# Teams
'posteam', 'defteam',
# Situation
'down', 'ydstogo', 'yardline_100',
# Outcome
'play_type', 'yards_gained',
# Advanced metrics
'epa', 'wpa', 'success'
]]
print(f"Basic selection: {len(pbp_basic.columns)} columns")
# =====================================
# Pattern-Based Selection
# =====================================
# Select columns matching patterns using list comprehension
# Python doesn't have built-in pattern matching like R's dplyr,
# but list comprehensions provide flexible pattern matching
# Columns starting with 'pass'
pass_cols = [col for col in pbp.columns if col.startswith('pass')]
# Columns ending with '_epa'
epa_cols = [col for col in pbp.columns if col.endswith('_epa')]
# Columns containing 'air_yards'
air_cols = [col for col in pbp.columns if 'air_yards' in col]
# Combine all selected columns with identifiers
pbp_pass = pbp[['game_id', 'play_id'] + pass_cols + epa_cols + air_cols]
print(f"Pass-focused selection: {len(pbp_pass.columns)} columns")
# =====================================
# Selection by Position (less common)
# =====================================
# Select columns by position using iloc
# Rarely used because position-based selection is brittle
# (column order might change, breaking your code)
pbp_range = pbp.iloc[:, 0:10] # First 10 columns
print(f"Position-based selection: {len(pbp_range.columns)} columns")
# =====================================
# Negative Selection (Dropping Columns)
# =====================================
# Drop columns using the drop() method
# axis=1 indicates we're dropping columns (not rows)
# Use this when it's easier to specify what to remove
# Drop specific columns
pbp_no_ids = pbp.drop(columns=['old_game_id'])
# Drop columns matching patterns
player_id_cols = [col for col in pbp.columns if col.endswith('_player_id')]
td_cols = [col for col in pbp.columns if col.startswith('td_')]
pbp_no_ids = pbp.drop(columns=player_id_cols + td_cols)
print(f"After dropping columns: {len(pbp_no_ids.columns)} columns")
# =====================================
# Selection with Renaming
# =====================================
# Select and rename using rename() method
# First select columns, then rename them
pbp_renamed = pbp[[
'game_id', 'posteam', 'defteam', 'down', 'ydstogo',
'yardline_100', 'desc', 'yards_gained', 'epa'
]].rename(columns={
'game_id': 'game',
'posteam': 'offense',
'defteam': 'defense',
'ydstogo': 'distance',
'yardline_100': 'field_position',
'desc': 'play_description',
'yards_gained': 'yards',
'epa': 'expected_points_added'
})
print(f"Selection with renaming: {len(pbp_renamed.columns)} columns")
# Display column names from each selection approach
print("\n" + "=" * 70)
print("COLUMN NAMES FROM BASIC SELECTION:")
print("=" * 70)
print(", ".join(pbp_basic.columns))
print("\n" + "=" * 70)
print("SAMPLE OF PASS-FOCUSED COLUMNS:")
print("=" * 70)
print(", ".join(list(pbp_pass.columns)[:15]))
print(f"... and {len(pbp_pass.columns) - 15} more columns")
select(game_id, posteam, epa)
pbp[['game_id', 'posteam', 'epa']]
- It's self-documenting (no need to remember what a pattern matches)
- It's explicit about dependencies (you can see exactly what variables you use)
- It's robust to column additions (if new columns are added to the dataset, your code still works)
select(pass_attempt, pass_touchdown, pass_length, pass_location, ...)
select(starts_with("pass"))
- Exploratory analysis where you want to see all variables related to a topic
- When the dataset has systematic naming conventions (like nflfastR does)
- When you're writing general-purpose functions that should work with any related variables
- Production code where you need specific variables (be explicit about dependencies)
- When patterns might match unintended columns
- When you need to explain your analysis to non-technical audiences
- You want most columns except a few specific ones
- You want to exclude a category of columns (e.g., all player IDs if you're doing team-level analysis)
yardline_100, you might rename it to yards_to_goal for clarity.
However, renaming can also make it harder to look up variable definitions in the nflfastR documentation. Balance readability with reference-ability based on your audience.
Column Selection Best Practices
1. Select early: Narrow your dataset to needed columns as soon as possible. This improves performance and reduces clutter throughout your analysis. 2. Be explicit in production code: For analyses that will be shared or revisited, explicitly list columns rather than using patterns. This makes dependencies clear and prevents breaking changes if the dataset structure changes. 3. Use patterns for exploration: When you're exploring data and don't know exactly what you need, pattern matching helps you quickly grab related variables. 4. Document your selections: Add comments explaining why you selected specific columns, especially if the choice isn't obvious. 5. Verify your selection: After selecting columns, print the column names or dimensions to verify you got what you expected. This catches typos and logic errors. 6. Consider creating a standard selection: If you repeatedly use the same set of columns, create a vector/list of column names that you can reuse:# R
standard_cols <- c("game_id", "posteam", "down", "ydstogo", "epa")
pbp_subset <- pbp %>% select(all_of(standard_cols))
# Python
standard_cols = ['game_id', 'posteam', 'down', 'ydstogo', 'epa']
pbp_subset = pbp[standard_cols]
Filtering Rows: Subsetting to Relevant Observations
While selecting columns narrows the variables you're working with, filtering rows narrows the observations. Filtering is how you focus your analysis on specific situations, teams, players, or time periods.
Effective filtering requires understanding:
- What conditions matter for your analysis question
- How to express those conditions in code
- What happens to missing values when filtering
- How to verify your filter worked as intended
Let's explore filtering with increasingly complex examples:
#| label: filter-basic-r
# =====================================
# Single-Condition Filtering
# =====================================
# Filter to only pass plays
# The == operator tests for equality
# This creates a logical vector (TRUE/FALSE for each row)
# filter() keeps rows where the condition is TRUE
pass_plays <- pbp %>%
filter(play_type == "pass")
cat("Total plays:", format(nrow(pbp), big.mark = ","), "\n")
cat("Pass plays:", format(nrow(pass_plays), big.mark = ","),
sprintf("(%.1f%%)\n", 100 * nrow(pass_plays) / nrow(pbp)))
# =====================================
# Multiple-Condition Filtering (AND logic)
# =====================================
# Filter to passes on 3rd down
# Multiple conditions in filter() are combined with AND
# A row must satisfy ALL conditions to be kept
third_down_passes <- pbp %>%
filter(
play_type == "pass", # Must be a pass AND
down == 3 # Must be 3rd down
)
cat("\n3rd down passes:", format(nrow(third_down_passes), big.mark = ","), "\n")
# =====================================
# Complex Filtering with Multiple Operators
# =====================================
# Filter to critical situations: 3rd/4th down, medium/long distance,
# 4th quarter, close game
# This demonstrates combining multiple logical operators:
# - %in% tests if value is in a set
# - >= tests greater than or equal
# - <= tests less than or equal
critical_plays <- pbp %>%
filter(
down %in% c(3, 4), # 3rd or 4th down
ydstogo >= 5, # 5+ yards to go
qtr == 4, # 4th quarter
score_differential >= -8, # Within 8 points (trailing)
score_differential <= 8 # Within 8 points (leading)
)
cat("\nCritical situation plays:", format(nrow(critical_plays), big.mark = ","), "\n")
# =====================================
# Filtering with OR Logic
# =====================================
# Filter to plays that are either touchdowns OR explosive gains (20+ yards)
# Use the | operator for OR logic
# A row is kept if it satisfies ANY of the conditions
explosive_plays <- pbp %>%
filter(
touchdown == 1 | # Touchdown, OR
yards_gained >= 20, # 20+ yard gain
!is.na(epa) # Must have valid EPA
)
cat("\nExplosive plays:", format(nrow(explosive_plays), big.mark = ","), "\n")
# =====================================
# Negative Filtering (Exclusion)
# =====================================
# Filter to non-penalty, regular offensive plays with EPA
# Use ! for negation (NOT)
# !is.na(x) means "is not missing"
# ! (condition) means "not condition"
clean_plays <- pbp %>%
filter(
!is.na(epa), # EPA must not be missing
play_type %in% c("pass", "run"), # Must be pass or run
penalty == 0 | is.na(penalty) # No penalty (or penalty is NA)
)
cat("\nClean offensive plays:", format(nrow(clean_plays), big.mark = ","), "\n")
# =====================================
# Filtering by Field Position Zones
# =====================================
# Filter to red zone plays (inside opponent's 20-yard line)
# Field position in nflfastR is yards to opponent's goal
# yardline_100 = 20 means 20 yards from opponent's goal (own 80-yard line)
# yardline_100 = 10 means 10 yards from opponent's goal (opponent's 10)
red_zone <- pbp %>%
filter(
yardline_100 <= 20, # 20 or fewer yards to goal
yardline_100 > 0, # Not in end zone
play_type %in% c("pass", "run"),
!is.na(epa)
)
cat("\nRed zone plays:", format(nrow(red_zone), big.mark = ","), "\n")
# =====================================
# Filtering by Team
# =====================================
# Filter to Kansas City Chiefs offensive plays
# posteam is the team with possession (offensive team)
chiefs_offense <- pbp %>%
filter(
posteam == "KC",
play_type %in% c("pass", "run"),
!is.na(epa)
)
cat("\nKC offensive plays:", format(nrow(chiefs_offense), big.mark = ","), "\n")
# =====================================
# Demonstrate Filter Logic with Counts
# =====================================
cat("\n========================================\n")
cat("FILTERING IMPACT SUMMARY\n")
cat("========================================\n")
filter_summary <- tibble(
Filter = c(
"All plays",
"Pass plays only",
"3rd down passes",
"Critical situations",
"Explosive plays",
"Clean offensive plays",
"Red zone plays",
"KC offensive plays"
),
Rows = c(
nrow(pbp),
nrow(pass_plays),
nrow(third_down_passes),
nrow(critical_plays),
nrow(explosive_plays),
nrow(clean_plays),
nrow(red_zone),
nrow(chiefs_offense)
)
) %>%
mutate(
`Percent of Total` = Rows / nrow(pbp) * 100,
`Rows Kept` = format(Rows, big.mark = ","),
`% Kept` = sprintf("%.1f%%", `Percent of Total`)
) %>%
select(Filter, `Rows Kept`, `% Kept`)
print(filter_summary)
#| label: filter-basic-py
# =====================================
# Single-Condition Filtering
# =====================================
# Filter to only pass plays
# pandas provides multiple filtering syntaxes:
# 1. Boolean indexing: df[df['column'] == value]
# 2. query() method: df.query("column == 'value'")
# We'll demonstrate both
# Boolean indexing approach
pass_plays = pbp[pbp['play_type'] == 'pass']
# query() approach (often more readable)
# pass_plays = pbp.query("play_type == 'pass'")
print(f"Total plays: {len(pbp):,}")
print(f"Pass plays: {len(pass_plays):,} ({100*len(pass_plays)/len(pbp):.1f}%)")
# =====================================
# Multiple-Condition Filtering (AND logic)
# =====================================
# Filter to passes on 3rd down
# Multiple conditions with & (AND) operator
# Note: Must wrap each condition in parentheses
third_down_passes = pbp[
(pbp['play_type'] == 'pass') & # Must be a pass AND
(pbp['down'] == 3) # Must be 3rd down
]
# Alternative using query() method (often more readable)
# third_down_passes = pbp.query("play_type == 'pass' and down == 3")
print(f"\n3rd down passes: {len(third_down_passes):,}")
# =====================================
# Complex Filtering with Multiple Operators
# =====================================
# Filter to critical situations
# Demonstrates multiple comparison operators
critical_plays = pbp[
(pbp['down'].isin([3, 4])) & # 3rd or 4th down
(pbp['ydstogo'] >= 5) & # 5+ yards to go
(pbp['qtr'] == 4) & # 4th quarter
(pbp['score_differential'] >= -8) & # Within 8 points (trailing)
(pbp['score_differential'] <= 8) # Within 8 points (leading)
]
# Alternative using query() method
# critical_plays = pbp.query(
# "down in [3, 4] and ydstogo >= 5 and qtr == 4 and "
# "score_differential >= -8 and score_differential <= 8"
# )
print(f"\nCritical situation plays: {len(critical_plays):,}")
# =====================================
# Filtering with OR Logic
# =====================================
# Filter to touchdowns OR explosive gains
# Use the | operator for OR logic
# Note: Must wrap each condition in parentheses
explosive_plays = pbp[
((pbp['touchdown'] == 1) | # Touchdown, OR
(pbp['yards_gained'] >= 20)) & # 20+ yard gain
(pbp['epa'].notna()) # Must have valid EPA
]
# Alternative using query() method
# explosive_plays = pbp.query(
# "(touchdown == 1 or yards_gained >= 20) and epa.notna()"
# )
print(f"\nExplosive plays: {len(explosive_plays):,}")
# =====================================
# Negative Filtering (Exclusion)
# =====================================
# Filter to clean offensive plays
# Use ~ for negation (NOT) in boolean indexing
# .notna() checks for non-missing values (opposite of .isna())
clean_plays = pbp[
(pbp['epa'].notna()) & # EPA must not be missing
(pbp['play_type'].isin(['pass', 'run'])) # Must be pass or run
]
# Alternative using query() method
# clean_plays = pbp.query(
# "epa.notna() and play_type in ['pass', 'run']"
# )
print(f"\nClean offensive plays: {len(clean_plays):,}")
# =====================================
# Filtering by Field Position Zones
# =====================================
# Filter to red zone plays
red_zone = pbp[
(pbp['yardline_100'] <= 20) & # 20 or fewer yards to goal
(pbp['yardline_100'] > 0) & # Not in end zone
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
print(f"\nRed zone plays: {len(red_zone):,}")
# =====================================
# Filtering by Team
# =====================================
# Filter to Kansas City Chiefs offensive plays
chiefs_offense = pbp[
(pbp['posteam'] == 'KC') &
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['epa'].notna())
]
print(f"\nKC offensive plays: {len(chiefs_offense):,}")
# =====================================
# Demonstrate Filter Logic with Summary
# =====================================
print("\n" + "=" * 70)
print("FILTERING IMPACT SUMMARY")
print("=" * 70)
filter_summary = pd.DataFrame({
'Filter': [
'All plays',
'Pass plays only',
'3rd down passes',
'Critical situations',
'Explosive plays',
'Clean offensive plays',
'Red zone plays',
'KC offensive plays'
],
'Rows': [
len(pbp),
len(pass_plays),
len(third_down_passes),
len(critical_plays),
len(explosive_plays),
len(clean_plays),
len(red_zone),
len(chiefs_offense)
]
})
filter_summary['Percent of Total'] = (
filter_summary['Rows'] / len(pbp) * 100
)
filter_summary['Rows Kept'] = filter_summary['Rows'].apply(
lambda x: f"{x:,}"
)
filter_summary['% Kept'] = filter_summary['Percent of Total'].apply(
lambda x: f"{x:.1f}%"
)
print(filter_summary[['Filter', 'Rows Kept', '% Kept']].to_string(index=False))
pbp %>% filter(down == 3)
c(FALSE, TRUE, FALSE, TRUE, ...) (one value per row)
2. Keep rows where vector is TRUE
AND vs. OR Logic:
The most common filtering mistake is confusing AND and OR logic:
AND Logic - Row must satisfy ALL conditions:
# R: Comma means AND
filter(down == 3, ydstogo >= 5)
# Python: & operator means AND
pbp[(pbp['down'] == 3) & (pbp['ydstogo'] >= 5)]
# R: | operator means OR
filter(down == 3 | down == 4)
# Python: | operator means OR
pbp[(pbp['down'] == 3) | (pbp['down'] == 4)]
# WRONG: This creates a syntax error
filter(down == 3, down == 4) # A play can't be both!
# CORRECT:
filter(down == 3 | down == 4) # OR logic
# WRONG: Operator precedence causes unexpected behavior
pbp[pbp['down'] == 3 & pbp['ydstogo'] >= 5] # Precedence issue!
# CORRECT: Parentheses ensure proper order
pbp[(pbp['down'] == 3) & (pbp['ydstogo'] >= 5)]
x == NAis always NA (not TRUE or FALSE), so the row is dropped- You must explicitly check for missingness using
is.na()(R) or.isna()(Python)
# This DOESN'T work as you might expect:
filter(epa == NA) # Returns zero rows!
# This is correct:
filter(is.na(epa)) # Returns rows where epa is missing
# To keep non-missing:
filter(!is.na(epa)) # Returns rows where epa is NOT missing
%in% (R) or .isin() (Python):
# R: Efficient way to test multiple values
filter(down %in% c(3, 4))
# Python: .isin() method
pbp[pbp['down'].isin([3, 4])]
# This is equivalent to but cleaner than:
# R: filter(down == 3 | down == 4)
# Python: pbp[(pbp['down'] == 3) | (pbp['down'] == 4)]
# Option 1: Two conditions with AND
filter(yardline_100 >= 20, yardline_100 <= 50)
# Option 2: between() helper function (R only)
filter(between(yardline_100, 20, 50))
# Verify filter
filtered_data <- pbp %>% filter(down == 3)
# Check that all rows have down == 3
all(filtered_data$down == 3) # Should be TRUE
# Check range of other variables makes sense
summary(filtered_data$ydstogo)
query() method that often produces more readable code:
# Boolean indexing (traditional approach)
result = pbp[
(pbp['down'] == 3) &
(pbp['ydstogo'] >= 5) &
(pbp['play_type'] == 'pass')
]
# query() method (often more readable)
result = pbp.query("down == 3 and ydstogo >= 5 and play_type == 'pass'")
query() method:
- Uses SQL-like syntax that many find more readable
- Avoids repetitive
pbp[...]and parentheses - Can reference variables without quotes for columns
- Supports Python expressions and variables
- Column names with spaces or special characters are problematic
- Can't use all Python functions
- Error messages can be cryptic
Common Filtering Pitfalls
1. Forgetting Missing Values: Many conditions involving NA values return FALSE, not TRUE or FALSE. Always explicitly handle missing values:# WRONG: This doesn't keep NA values
filter(epa > 0) # Drops NA values silently
# CORRECT: Explicit about NAs
filter(epa > 0 | is.na(epa)) # Keeps positive EPA and NAs
# OR
filter(!is.na(epa), epa > 0) # Explicitly requires non-missing
# WRONG: Precedence issues
pbp[pbp['down'] == 3 & pbp['ydstogo'] >= 5]
# CORRECT: Explicit parentheses
pbp[(pbp['down'] == 3) & (pbp['ydstogo'] >= 5)]
== can fail:
# WRONG: May not work due to floating point precision
filter(epa == 0.5)
# BETTER: Use tolerance
filter(abs(epa - 0.5) < 0.0001)
# OR: Use near() function (R)
filter(near(epa, 0.5))
# After filtering, verify results
filtered_data <- pbp %>% filter(down == 3, ydstogo >= 5)
# Check: Do all rows meet criteria?
# Check: Does row count make sense?
# Check: Do value ranges make sense?
# Kansas City's offensive plays
filter(posteam == "KC")
# Kansas City's defensive plays (plays against KC)
filter(defteam == "KC")
# These are different datasets!
Practical Example: Red Zone Efficiency Analysis
Let's combine selection and filtering to answer a real analytical question: How do pass and run efficiency differ in the red zone?
The red zone—inside the opponent's 20-yard line—is strategically important because the compressed field changes play dynamics. Defenses don't have to defend as much space, offensive playbooks are more limited, and scoring opportunities are valuable. Understanding red zone efficiency helps teams optimize play-calling in these high-value situations.
This example demonstrates how to:
- Filter to a specific field position zone
- Calculate efficiency metrics by group
- Present results professionally
- Interpret findings in football context
#| label: red-zone-analysis-r
# =====================================
# Define and Filter Red Zone Plays
# =====================================
# Red zone is defined as inside opponent's 20-yard line
# In nflfastR, yardline_100 represents yards to opponent's goal:
# - yardline_100 = 20 means you're AT your opponent's 20-yard line
# - yardline_100 = 10 means you're at your opponent's 10-yard line
# - yardline_100 = 1 means you're at the 1-yard line
# We exclude yardline_100 = 0 (plays in end zone, usually PATs)
red_zone <- pbp %>%
filter(
# Only pass and run plays (exclude special teams)
play_type %in% c("pass", "run"),
# Inside the 20-yard line but not in end zone
yardline_100 <= 20,
yardline_100 > 0,
# Only plays with valid EPA
!is.na(epa)
)
cat("Red Zone Analysis\n")
cat("=" %R% 50, "\n")
cat("Total plays in red zone:", format(nrow(red_zone), big.mark = ","), "\n")
cat("Percentage of all offensive plays:",
sprintf("%.1f%%\n", 100 * nrow(red_zone) / sum(pbp$play_type %in% c("pass", "run") & !is.na(pbp$epa))))
# =====================================
# Calculate Efficiency Metrics by Play Type
# =====================================
# For each play type (pass/run), calculate:
# - Total plays (volume)
# - Touchdowns (scoring outcomes)
# - Touchdown rate (efficiency)
# - Average EPA (comprehensive efficiency measure)
# - Success rate (consistency)
red_zone_summary <- red_zone %>%
group_by(play_type) %>%
summarise(
# Count total plays
plays = n(),
# Count touchdowns
# touchdown variable is 1 for TD, 0 otherwise
# sum() counts the 1s
touchdowns = sum(touchdown == 1, na.rm = TRUE),
# Calculate TD rate
# This is the primary outcome we care about in red zone
td_rate = touchdowns / plays,
# Calculate average EPA
# EPA captures full value including field position changes
avg_epa = mean(epa, na.rm = TRUE),
# Calculate success rate
# Success = positive EPA
success_rate = mean(epa > 0, na.rm = TRUE),
# Calculate median EPA for robustness to outliers
median_epa = median(epa, na.rm = TRUE),
.groups = "drop"
) %>%
# Calculate percentage of total red zone plays
mutate(
pct_of_rz_plays = plays / sum(plays) * 100
)
# =====================================
# Display Results in Professional Table
# =====================================
red_zone_summary %>%
# Create publication-quality table
gt() %>%
# Add title and context
tab_header(
title = "Red Zone Efficiency by Play Type",
subtitle = "2023 NFL Regular Season | Plays inside opponent's 20-yard line"
) %>%
# Readable column labels
cols_label(
play_type = "Play Type",
plays = "Plays",
pct_of_rz_plays = "% of RZ Plays",
touchdowns = "Touchdowns",
td_rate = "TD Rate",
avg_epa = "Avg EPA",
median_epa = "Median EPA",
success_rate = "Success Rate"
) %>%
# Format numbers appropriately
fmt_number(
columns = c(plays, touchdowns),
decimals = 0,
use_seps = TRUE
) %>%
fmt_percent(
columns = c(td_rate, success_rate, pct_of_rz_plays),
decimals = 1
) %>%
fmt_number(
columns = c(avg_epa, median_epa),
decimals = 3
) %>%
# Add source note
tab_source_note(
source_note = "Data: nflfastR | Red zone defined as inside opponent's 20-yard line"
) %>%
# Highlight best values
# Highest values are best for avg_epa, td_rate, success_rate
data_color(
columns = c(avg_epa, td_rate, success_rate),
palette = "Greens"
)
# =====================================
# Narrative Summary of Findings
# =====================================
cat("\n\n" %R% "=" %R% 50, "\n")
cat("KEY FINDINGS\n")
cat("=" %R% 50, "\n\n")
pass_data <- red_zone_summary %>% filter(play_type == "pass")
run_data <- red_zone_summary %>% filter(play_type == "run")
cat(sprintf("VOLUME:\n"))
cat(sprintf("- Passes: %s plays (%.1f%% of red zone plays)\n",
format(pass_data$plays, big.mark = ","),
pass_data$pct_of_rz_plays))
cat(sprintf("- Runs: %s plays (%.1f%% of red zone plays)\n",
format(run_data$plays, big.mark = ","),
run_data$pct_of_rz_plays))
cat(sprintf("\nTOUCHDOWN EFFICIENCY:\n"))
cat(sprintf("- Pass TD rate: %.1f%%\n", pass_data$td_rate * 100))
cat(sprintf("- Run TD rate: %.1f%%\n", run_data$td_rate * 100))
cat(sprintf("- Advantage: %s by %.1f percentage points\n",
ifelse(pass_data$td_rate > run_data$td_rate, "Pass", "Run"),
abs(pass_data$td_rate - run_data$td_rate) * 100))
cat(sprintf("\nEPA EFFICIENCY:\n"))
cat(sprintf("- Pass avg EPA: %.3f\n", pass_data$avg_epa))
cat(sprintf("- Run avg EPA: %.3f\n", run_data$avg_epa))
cat(sprintf("- Advantage: %s by %.3f EPA\n",
ifelse(pass_data$avg_epa > run_data$avg_epa, "Pass", "Run"),
abs(pass_data$avg_epa - run_data$avg_epa)))
cat(sprintf("\nCONSISTENCY (Success Rate):\n"))
cat(sprintf("- Pass success rate: %.1f%%\n", pass_data$success_rate * 100))
cat(sprintf("- Run success rate: %.1f%%\n", run_data$success_rate * 100))
#| label: red-zone-analysis-py
# =====================================
# Define and Filter Red Zone Plays
# =====================================
# Red zone is inside opponent's 20-yard line
red_zone = pbp[
(pbp['play_type'].isin(['pass', 'run'])) &
(pbp['yardline_100'] <= 20) &
(pbp['yardline_100'] > 0) &
(pbp['epa'].notna())
]
print("Red Zone Analysis")
print("=" * 70)
print(f"Total plays in red zone: {len(red_zone):,}")
# Calculate percentage of all offensive plays
all_offensive = pbp[
pbp['play_type'].isin(['pass', 'run']) & pbp['epa'].notna()
]
print(f"Percentage of all offensive plays: {100*len(red_zone)/len(all_offensive):.1f}%")
# =====================================
# Calculate Efficiency Metrics by Play Type
# =====================================
# Group by play type and calculate multiple metrics
red_zone_summary = red_zone.groupby('play_type').agg(
# Count plays
plays=('play_id', 'count'),
# Count touchdowns (touchdown column is 1 for TD, 0 otherwise)
touchdowns=('touchdown', lambda x: (x == 1).sum()),
# Calculate average EPA
avg_epa=('epa', 'mean'),
# Calculate median EPA
median_epa=('epa', 'median'),
# Calculate success rate (EPA > 0)
success_rate=('epa', lambda x: (x > 0).mean())
).reset_index()
# Calculate TD rate
red_zone_summary['td_rate'] = (
red_zone_summary['touchdowns'] / red_zone_summary['plays']
)
# Calculate percentage of red zone plays
red_zone_summary['pct_of_rz_plays'] = (
red_zone_summary['plays'] / red_zone_summary['plays'].sum() * 100
)
# =====================================
# Display Results
# =====================================
print("\n" + "=" * 70)
print("RED ZONE EFFICIENCY BY PLAY TYPE")
print("2023 NFL Regular Season | Plays inside opponent's 20-yard line")
print("=" * 70)
print()
# Format for display
display_summary = red_zone_summary.copy()
display_summary['Plays'] = display_summary['plays'].apply(lambda x: f"{x:,}")
display_summary['% of RZ'] = display_summary['pct_of_rz_plays'].apply(lambda x: f"{x:.1f}%")
display_summary['TDs'] = display_summary['touchdowns'].apply(lambda x: f"{x:,}")
display_summary['TD Rate'] = display_summary['td_rate'].apply(lambda x: f"{x:.1%}")
display_summary['Avg EPA'] = display_summary['avg_epa'].apply(lambda x: f"{x:.3f}")
display_summary['Median EPA'] = display_summary['median_epa'].apply(lambda x: f"{x:.3f}")
display_summary['Success Rate'] = display_summary['success_rate'].apply(lambda x: f"{x:.1%}")
display_cols = ['play_type', 'Plays', '% of RZ', 'TDs', 'TD Rate',
'Avg EPA', 'Median EPA', 'Success Rate']
print(display_summary[display_cols].to_string(index=False))
# =====================================
# Narrative Summary of Findings
# =====================================
print("\n" + "=" * 70)
print("KEY FINDINGS")
print("=" * 70)
pass_data = red_zone_summary[red_zone_summary['play_type'] == 'pass'].iloc[0]
run_data = red_zone_summary[red_zone_summary['play_type'] == 'run'].iloc[0]
print("\nVOLUME:")
print(f"- Passes: {pass_data['plays']:,} plays ({pass_data['pct_of_rz_plays']:.1f}% of red zone plays)")
print(f"- Runs: {run_data['plays']:,} plays ({run_data['pct_of_rz_plays']:.1f}% of red zone plays)")
print("\nTOUCHDOWN EFFICIENCY:")
print(f"- Pass TD rate: {pass_data['td_rate']:.1%}")
print(f"- Run TD rate: {run_data['td_rate']:.1%}")
advantage = "Pass" if pass_data['td_rate'] > run_data['td_rate'] else "Run"
diff = abs(pass_data['td_rate'] - run_data['td_rate']) * 100
print(f"- Advantage: {advantage} by {diff:.1f} percentage points")
print("\nEPA EFFICIENCY:")
print(f"- Pass avg EPA: {pass_data['avg_epa']:.3f}")
print(f"- Run avg EPA: {run_data['avg_epa']:.3f}")
advantage = "Pass" if pass_data['avg_epa'] > run_data['avg_epa'] else "Run"
diff = abs(pass_data['avg_epa'] - run_data['avg_epa'])
print(f"- Advantage: {advantage} by {diff:.3f} EPA")
print("\nCONSISTENCY (Success Rate):")
print(f"- Pass success rate: {pass_data['success_rate']:.1%}")
print(f"- Run success rate: {run_data['success_rate']:.1%}")
- Down and distance: Obvious passing/running situations aren't comparable
- Specific field position: The 1-yard line differs from the 19-yard line
- Game situation: Score, time, and game script affect play-calling
- Personnel: Different personnel groupings have different efficiency
- Defensive alignment: Play efficiency depends on defensive structure
Building on This Analysis
This red zone analysis demonstrates fundamental data wrangling techniques—filtering, grouping, and summarizing. To deepen insights, you could extend this analysis to: 1. Add more granularity: Break red zone into smaller zones (inside 10, inside 5, goal line) 2. Control for situation: Separate by down and distance 3. Add temporal dimension: Does red zone efficiency change by quarter or game situation? 4. Team-level analysis: Which teams are most/least efficient in the red zone? 5. Player-level analysis: Which QBs and RBs are most efficient in the red zone? 6. Defensive perspective: Which defenses are best at preventing red zone TDs? Each extension requires additional filtering, grouping, or joining, building on the foundations established here.Creating New Variables: Transforming Data for Analysis
Creating derived variables—also called feature engineering—is one of the most important data wrangling skills. Raw data rarely provides all the variables you need for analysis. Instead, you create new variables by transforming existing ones, combining multiple variables, or applying domain knowledge to categorize plays or situations.
Creating derived variables allows you to:
- Categorize continuous variables: Convert yards-to-go into "short/medium/long" distance categories
- Create binary indicators: Flag specific situations like "3rd_down_conversion" or "explosive_play"
- Combine multiple variables: Create complex metrics like "expected_outcome_over_expectation"
- Apply domain knowledge: Use football understanding to create situational variables like "garbage_time" or "obvious_passing_down"
In R, we primarily use mutate() from dplyr. In Python, we use direct assignment, assign(), or apply(). Both approaches allow you to create multiple new variables efficiently.
Basic Variable Creation: Simple Transformations
Let's start with straightforward transformations—creating variables from simple operations on existing variables:
#| label: mutate-basic-r
# =====================================
# Create Simple Derived Variables
# =====================================
# mutate() creates new variables while keeping all existing ones
# Each line inside mutate() creates one new variable
# Variables can reference previously created variables in the same mutate()
pbp_enhanced <- pbp %>%
filter(play_type %in% c("pass", "run"), !is.na(epa)) %>%
mutate(
# ===== Simple Transformations =====
# Binary indicator for successful plays (EPA > 0)
# Logical comparisons (epa > 0) create TRUE/FALSE values
# You can use these directly or convert to 0/1
is_success = epa > 0,
# Alternative: convert to numeric 0/1
success_numeric = as.numeric(epa > 0),
# Absolute value of EPA (magnitude regardless of direction)
epa_magnitude = abs(epa),
# EPA relative to league average (EPA over expectation)
# mean(epa, na.rm = TRUE) calculates league average
epa_over_avg = epa - mean(epa, na.rm = TRUE),
# ===== Categorizing Continuous Variables =====
# Down as descriptive text
# case_when() is like a series of if-else statements
# Each line is: condition ~ result
# The first TRUE condition determines the result
down_label = case_when(
down == 1 ~ "First Down",
down == 2 ~ "Second Down",
down == 3 ~ "Third Down",
down == 4 ~ "Fourth Down",
TRUE ~ "Unknown" # Default/catch-all case
),
# Distance category (short/medium/long yardage)
# Short: 1-3 yards (power situations)
# Medium: 4-7 yards (standard situations)
# Long: 8+ yards (obvious passing situations)
distance_cat = case_when(
ydstogo <= 3 ~ "Short (1-3)",
ydstogo <= 7 ~ "Medium (4-7)",
ydstogo > 7 ~ "Long (8+)",
TRUE ~ "Unknown"
),
# Field position zones
# Field position dramatically affects strategy and EPA
field_zone = case_when(
yardline_100 > 80 ~ "Own Territory (1-20)",
yardline_100 > 50 ~ "Own Side (21-50)",
yardline_100 > 20 ~ "Opponent Side (31-80)",
yardline_100 > 0 ~ "Red Zone (1-20)",
TRUE ~ "Unknown"
),
# ===== Creating Binary Flags =====
# Flag explosive plays (20+ yards gained)
# These are high-value plays that dramatically shift field position
is_explosive = yards_gained >= 20,
# Flag chunk plays (10+ yards)
# These advance the offense significantly
is_chunk = yards_gained >= 10,
# Flag negative plays (lost yardage)
# These hurt offense and often create difficult down-distance situations
is_negative = yards_gained < 0,
# Flag scoring plays (touchdowns)
is_touchdown = touchdown == 1,
# ===== Time-Based Variables =====
# Convert game_seconds_remaining to minutes for readability
game_minutes_remaining = game_seconds_remaining / 60,
# Flag two-minute drill situations (less than 2 minutes in half)
two_minute_drill = game_seconds_remaining <= 120,
# ===== Multi-Variable Combinations =====
# Yards per attempt (could be passing or rushing)
# This normalizes yards_gained to per-play basis
ypa = yards_gained, # In this context, it's the same as yards_gained
# Yards needed percentage (what % of needed yards were gained)
# A 5-yard gain on 3rd-and-10 is 50% of needed
# A 5-yard gain on 3rd-and-3 is 167% of needed (conversion)
yards_needed_pct = if_else(
ydstogo > 0,
yards_gained / ydstogo,
NA_real_ # Avoid division by zero
)
)
# =====================================
# Display Sample of New Variables
# =====================================
cat("=" %R% 50, "\n")
cat("SAMPLE OF NEWLY CREATED VARIABLES\n")
cat("=" %R% 50, "\n\n")
# Show a few plays with original and derived variables
pbp_enhanced %>%
select(
# Original variables
down, ydstogo, yardline_100, yards_gained, epa,
# Derived variables
down_label, distance_cat, field_zone,
is_success, is_explosive, is_chunk
) %>%
head(5) %>%
print(width = 120)
# =====================================
# Summary Statistics for Derived Variables
# =====================================
cat("\n" %R% "=" %R% 50, "\n")
cat("SUMMARY OF DERIVED VARIABLE DISTRIBUTIONS\n")
cat("=" %R% 50, "\n\n")
# For categorical variables, show counts
cat("Distance Categories:\n")
pbp_enhanced %>%
count(distance_cat) %>%
mutate(pct = n / sum(n) * 100) %>%
arrange(desc(n)) %>%
print()
cat("\nField Position Zones:\n")
pbp_enhanced %>%
count(field_zone) %>%
mutate(pct = n / sum(n) * 100) %>%
arrange(desc(n)) %>%
print()
# For binary variables, show TRUE percentages
cat("\nBinary Indicators:\n")
binary_summary <- tibble(
Variable = c("Success Rate", "Explosive Play Rate",
"Chunk Play Rate", "Negative Play Rate",
"Touchdown Rate", "Two-Minute Drill"),
Percentage = c(
mean(pbp_enhanced$is_success) * 100,
mean(pbp_enhanced$is_explosive) * 100,
mean(pbp_enhanced$is_chunk) * 100,
mean(pbp_enhanced$is_negative) * 100,
mean(pbp_enhanced$is_touchdown, na.rm = TRUE) * 100,
mean(pbp_enhanced$two_minute_drill) * 100
)
) %>%
mutate(Percentage = sprintf("%.1f%%", Percentage))
print(binary_summary)
#| label: mutate-basic-py
# =====================================
# Create Simple Derived Variables
# =====================================
# Filter to offensive plays with valid EPA
pbp_enhanced = pbp[
pbp['play_type'].isin(['pass', 'run']) & pbp['epa'].notna()
].copy() # .copy() prevents SettingWithCopyWarning
# ===== Simple Transformations =====
# Binary indicator for successful plays
pbp_enhanced['is_success'] = pbp_enhanced['epa'] > 0
# Alternative: convert to numeric 0/1
pbp_enhanced['success_numeric'] = (pbp_enhanced['epa'] > 0).astype(int)
# Absolute value of EPA
pbp_enhanced['epa_magnitude'] = pbp_enhanced['epa'].abs()
# EPA relative to league average
pbp_enhanced['epa_over_avg'] = (
pbp_enhanced['epa'] - pbp_enhanced['epa'].mean()
)
# ===== Categorizing Continuous Variables =====
# Down as descriptive text
# np.select() applies conditions in order, returning corresponding choices
down_conditions = [
pbp_enhanced['down'] == 1,
pbp_enhanced['down'] == 2,
pbp_enhanced['down'] == 3,
pbp_enhanced['down'] == 4
]
down_labels = ['First Down', 'Second Down', 'Third Down', 'Fourth Down']
pbp_enhanced['down_label'] = np.select(down_conditions, down_labels, default='Unknown')
# Distance category
# pd.cut() bins continuous variables into categories
pbp_enhanced['distance_cat'] = pd.cut(
pbp_enhanced['ydstogo'],
bins=[0, 3, 7, 100], # Bin edges
labels=['Short (1-3)', 'Medium (4-7)', 'Long (8+)'],
include_lowest=True
)
# Field position zones
field_conditions = [
pbp_enhanced['yardline_100'] > 80,
pbp_enhanced['yardline_100'] > 50,
pbp_enhanced['yardline_100'] > 20,
pbp_enhanced['yardline_100'] > 0
]
field_labels = [
'Own Territory (1-20)',
'Own Side (21-50)',
'Opponent Side (31-80)',
'Red Zone (1-20)'
]
pbp_enhanced['field_zone'] = np.select(field_conditions, field_labels, default='Unknown')
# ===== Creating Binary Flags =====
# Flag explosive plays (20+ yards)
pbp_enhanced['is_explosive'] = pbp_enhanced['yards_gained'] >= 20
# Flag chunk plays (10+ yards)
pbp_enhanced['is_chunk'] = pbp_enhanced['yards_gained'] >= 10
# Flag negative plays
pbp_enhanced['is_negative'] = pbp_enhanced['yards_gained'] < 0
# Flag touchdowns
pbp_enhanced['is_touchdown'] = pbp_enhanced['touchdown'] == 1
# ===== Time-Based Variables =====
# Convert seconds to minutes
pbp_enhanced['game_minutes_remaining'] = (
pbp_enhanced['game_seconds_remaining'] / 60
)
# Two-minute drill flag
pbp_enhanced['two_minute_drill'] = (
pbp_enhanced['game_seconds_remaining'] <= 120
)
# ===== Multi-Variable Combinations =====
# Yards needed percentage
pbp_enhanced['yards_needed_pct'] = np.where(
pbp_enhanced['ydstogo'] > 0,
pbp_enhanced['yards_gained'] / pbp_enhanced['ydstogo'],
np.nan
)
# =====================================
# Display Sample of New Variables
# =====================================
print("=" * 70)
print("SAMPLE OF NEWLY CREATED VARIABLES")
print("=" * 70)
print()
sample_cols = [
# Original
'down', 'ydstogo', 'yardline_100', 'yards_gained', 'epa',
# Derived
'down_label', 'distance_cat', 'field_zone',
'is_success', 'is_explosive', 'is_chunk'
]
print(pbp_enhanced[sample_cols].head(5).to_string(index=False))
# =====================================
# Summary Statistics for Derived Variables
# =====================================
print("\n" + "=" * 70)
print("SUMMARY OF DERIVED VARIABLE DISTRIBUTIONS")
print("=" * 70)
# Distance categories
print("\nDistance Categories:")
distance_summary = (pbp_enhanced
.groupby('distance_cat')
.size()
.reset_index(name='count')
)
distance_summary['pct'] = (
distance_summary['count'] / distance_summary['count'].sum() * 100
)
print(distance_summary.to_string(index=False))
# Field position zones
print("\nField Position Zones:")
field_summary = (pbp_enhanced
.groupby('field_zone')
.size()
.reset_index(name='count')
)
field_summary['pct'] = (
field_summary['count'] / field_summary['count'].sum() * 100
)
print(field_summary.to_string(index=False))
# Binary indicators
print("\nBinary Indicators:")
binary_summary = pd.DataFrame({
'Variable': [
'Success Rate',
'Explosive Play Rate',
'Chunk Play Rate',
'Negative Play Rate',
'Touchdown Rate',
'Two-Minute Drill'
],
'Percentage': [
f"{pbp_enhanced['is_success'].mean():.1%}",
f"{pbp_enhanced['is_explosive'].mean():.1%}",
f"{pbp_enhanced['is_chunk'].mean():.1%}",
f"{pbp_enhanced['is_negative'].mean():.1%}",
f"{pbp_enhanced['is_touchdown'].mean():.1%}",
f"{pbp_enhanced['two_minute_drill'].mean():.1%}"
]
})
print(binary_summary.to_string(index=False))
pbp %>%
mutate(
new_var1 = expression1,
new_var2 = expression2
)
mutate() call.
Python's Assignment:
pbp_enhanced['new_var1'] = expression1
pbp_enhanced['new_var2'] = expression2
case_when(
condition1 ~ result1,
condition2 ~ result2,
TRUE ~ default_result
)
- Evaluates conditions in order
- Returns result for first TRUE condition
TRUE ~ valueprovides default for unmatched cases
np.select(
[condition1, condition2],
[result1, result2],
default=default_result
)
- Takes lists of conditions and choices
- Returns corresponding choice for first TRUE condition
default=parameter provides default value
pd.cut() is particularly useful for creating categories from continuous variables:
pd.cut(
variable,
bins=[0, 3, 7, 100], # Bin boundaries
labels=['Short', 'Medium', 'Long'] # Category labels
)
- Calculating rates (mean of binary variable = proportion of TRUE)
- Filtering (filter to is_explosive == TRUE)
- Conditional calculations (calculate EPA for explosive plays)
# These are equivalent for calculating explosive play rate:
mean(pbp$is_explosive) # Mean of TRUE/FALSE
sum(pbp$is_explosive) / nrow(pbp) # Count TRUE / total rows
# R: Use if_else() or case_when()
yards_pct = if_else(ydstogo > 0, yards_gained / ydstogo, NA_real_)
# Python: Use np.where()
yards_pct = np.where(ydstogo > 0, yards_gained / ydstogo, np.nan)
- Prefix flags with
is_:is_explosive,is_success - Use descriptive names:
distance_catnotdc,field_zonenotfz - Be consistent: if you use
_catfor categories, always use it - Avoid abbreviations unless they're standard (epa, wpa are standard in football)
# Check that categories have reasonable distributions
table(pbp_enhanced$distance_cat)
# Check that binary variables have reasonable rates
mean(pbp_enhanced$is_explosive)
# Check for unexpected NA values
sum(is.na(pbp_enhanced$yards_needed_pct))
Why Create Derived Variables Instead of Filtering?
You might wonder: why create a binaryis_explosive variable instead of just filtering to explosive plays when needed?
Creating derived variables has several advantages:
1. Reusability: Once created, you can use the variable multiple times without rewriting the logic
2. Clarity: filter(is_explosive) is clearer than filter(yards_gained >= 20)
3. Consistency: If you define "explosive" once, all subsequent uses are consistent
4. Calculation: You can easily calculate rates: mean(is_explosive) gives explosive play rate
5. Grouping: You can group by the variable: group_by(is_explosive)
Best Practice: Create derived variables when:
- You'll use the logic multiple times
- The definition is conceptually important (like "red zone" or "explosive play")
- You want to calculate rates or distributions
- You might change the definition later (change in one place, not many)
- It's a one-time subset for a specific analysis
- The condition is simple and self-explanatory
- You won't reuse the logic
Advanced Variable Creation: Complex Football-Specific Metrics
Now let's create more sophisticated variables that apply football domain knowledge and combine multiple pieces of information:
#| label: mutate-advanced-r
# =====================================
# Create Advanced Football-Specific Variables
# =====================================
pbp_metrics <- pbp %>%
filter(play_type %in% c("pass", "run"), !is.na(epa)) %>%
mutate(
# ===== Situational Variables =====
# Obvious passing down: 2nd/3rd/4th with 8+ yards to go
# Defenses often expect pass in these situations
obvious_pass_down = (down >= 2) & (ydstogo >= 8),
# Obvious running down: 3rd/4th with 2 or fewer yards
# Defenses often load the box in these situations
obvious_run_down = (down >= 3) & (ydstogo <= 2),
# Standard down: 1st or 2nd with reasonable distance
# Most plays fall in this category
standard_down = (down <= 2) & (ydstogo >= 3) & (ydstogo <= 7),
# Third down conversion situation
# These are critical plays for sustaining drives
third_down_attempt = (down == 3),
# Fourth down attempt (excluding punts and field goals)
# These are high-leverage decisions
fourth_down_attempt = (down == 4) & (play_type %in% c("pass", "run")),
# ===== Game Situation Variables =====
# Score margin categories
# Game script dramatically affects play-calling and efficiency
score_situation = case_when(
score_differential >= 17 ~ "Blowing Out (17+)",
score_differential >= 9 ~ "Leading Comfortably (9-16)",
score_differential >= 4 ~ "Leading (4-8)",
score_differential >= -3 ~ "Close Game (-3 to 3)",
score_differential >= -8 ~ "Trailing (-8 to -4)",
score_differential >= -16 ~ "Trailing Significantly (-16 to -9)",
score_differential < -16 ~ "Getting Blown Out (<-16)",
TRUE ~ "Unknown"
),
# Garbage time: large score margin in 4th quarter
# Plays in garbage time often have distorted stats
# Defense plays prevent, offense plays aggressively
garbage_time = (qtr == 4) & (abs(score_differential) >= 17),
# Competitive plays: exclude garbage time and blowouts
competitive = !garbage_time & (abs(score_differential) <= 16),
# Two-minute drill (end of half): unique strategic situation
# Teams play with urgency, often passing to stop clock
two_min_drill_end_half = (
(qtr == 2 & game_seconds_remaining <= 120) | # End of 1st half
(qtr == 4 & game_seconds_remaining <= 120) # End of game
),
# ===== EPA-Based Variables =====
# EPA quality categories for easy filtering and analysis
epa_quality = case_when(
epa >= 2.0 ~ "Excellent (2.0+)",
epa >= 0.5 ~ "Good (0.5-2.0)",
epa >= -0.5 ~ "Average (-0.5-0.5)",
epa >= -2.0 ~ "Poor (-2.0--0.5)",
epa < -2.0 ~ "Disaster (<-2.0)",
TRUE ~ "Unknown"
),
# EPA relative to situation expectation
# Some situations (short yardage) have higher average EPA than others
epa_vs_situation = epa - mean(epa[
down == down[row_number()] &
distance_cat == distance_cat[row_number()]
], na.rm = TRUE),
# ===== Pass-Specific Variables (only for passes) =====
# For pass plays, create additional metrics
# air_yards: how far ball traveled in air
# yards_after_catch: yards gained after receiver caught ball
# Completion indicator (for passes)
is_completion = if_else(
play_type == "pass",
complete_pass == 1,
NA
),
# YAC share: what percentage of passing yards came after catch?
# High YAC share might indicate receiver skill or poor tackling
# Low YAC share might indicate QB ability to throw to spot
yac_share = if_else(
play_type == "pass" & complete_pass == 1 & air_yards > 0,
yards_after_catch / yards_gained,
NA_real_
),
# Deep pass indicator (air yards >= 20)
# Deep passes are high-risk, high-reward
deep_pass = if_else(
play_type == "pass",
!is.na(air_yards) & air_yards >= 20,
NA
),
# Screen/short pass (air yards <= 5)
# These are often designed to get ball to playmakers in space
short_pass = if_else(
play_type == "pass",
!is.na(air_yards) & air_yards <= 5,
NA
),
# ===== Success Indicators =====
# Successful play: EPA > 0
success = epa > 0,
# Big success: EPA > 1.0 (very positive outcome)
big_success = epa >= 1.0,
# Disaster: EPA < -2.0 (very negative outcome)
disaster = epa <= -2.0,
# First down achieved (accounts for penalty first downs too)
first_down_gained = first_down == 1
)
# =====================================
# Display Samples of Advanced Variables
# =====================================
cat("=" %R% 50, "\n")
cat("ADVANCED METRICS SAMPLE\n")
cat("=" %R% 50, "\n\n")
# Show obvious passing downs
cat("Sample Obvious Passing Downs:\n")
pbp_metrics %>%
filter(obvious_pass_down) %>%
select(down, ydstogo, play_type, yards_gained, epa, desc) %>%
head(3) %>%
print(width = 120)
cat("\nSample Two-Minute Drill Plays:\n")
pbp_metrics %>%
filter(two_min_drill_end_half) %>%
select(qtr, game_seconds_remaining, down, ydstogo, play_type,
yards_gained, epa) %>%
head(3) %>%
print(width = 120)
# ===== Summary Statistics =====
cat("\n" %R% "=" %R% 50, "\n")
cat("SITUATIONAL PLAY DISTRIBUTIONS\n")
cat("=" %R% 50, "\n\n")
situation_summary <- pbp_metrics %>%
summarise(
`Obvious Pass Downs` = sprintf("%.1f%%", mean(obvious_pass_down) * 100),
`Obvious Run Downs` = sprintf("%.1f%%", mean(obvious_run_down) * 100),
`Standard Downs` = sprintf("%.1f%%", mean(standard_down) * 100),
`Garbage Time` = sprintf("%.1f%%", mean(garbage_time) * 100),
`Competitive` = sprintf("%.1f%%", mean(competitive) * 100),
`Two-Min Drill` = sprintf("%.1f%%", mean(two_min_drill_end_half) * 100)
) %>%
pivot_longer(everything(), names_to = "Situation", values_to = "Percentage")
print(situation_summary)
cat("\n")
cat("EPA QUALITY DISTRIBUTION\n")
pbp_metrics %>%
count(epa_quality) %>%
mutate(pct = n / sum(n) * 100) %>%
arrange(desc(n)) %>%
print()
#| label: mutate-advanced-py
# =====================================
# Create Advanced Football-Specific Variables
# =====================================
pbp_metrics = pbp[
pbp['play_type'].isin(['pass', 'run']) & pbp['epa'].notna()
].copy()
# ===== Situational Variables =====
# Obvious passing down
pbp_metrics['obvious_pass_down'] = (
(pbp_metrics['down'] >= 2) & (pbp_metrics['ydstogo'] >= 8)
)
# Obvious running down
pbp_metrics['obvious_run_down'] = (
(pbp_metrics['down'] >= 3) & (pbp_metrics['ydstogo'] <= 2)
)
# Standard down
pbp_metrics['standard_down'] = (
(pbp_metrics['down'] <= 2) &
(pbp_metrics['ydstogo'] >= 3) &
(pbp_metrics['ydstogo'] <= 7)
)
# Third and fourth down attempts
pbp_metrics['third_down_attempt'] = pbp_metrics['down'] == 3
pbp_metrics['fourth_down_attempt'] = (
(pbp_metrics['down'] == 4) &
pbp_metrics['play_type'].isin(['pass', 'run'])
)
# ===== Game Situation Variables =====
# Score situation categories
score_conditions = [
pbp_metrics['score_differential'] >= 17,
pbp_metrics['score_differential'] >= 9,
pbp_metrics['score_differential'] >= 4,
pbp_metrics['score_differential'] >= -3,
pbp_metrics['score_differential'] >= -8,
pbp_metrics['score_differential'] >= -16,
pbp_metrics['score_differential'] < -16
]
score_labels = [
'Blowing Out (17+)',
'Leading Comfortably (9-16)',
'Leading (4-8)',
'Close Game (-3 to 3)',
'Trailing (-8 to -4)',
'Trailing Significantly (-16 to -9)',
'Getting Blown Out (<-16)'
]
pbp_metrics['score_situation'] = np.select(
score_conditions, score_labels, default='Unknown'
)
# Garbage time
pbp_metrics['garbage_time'] = (
(pbp_metrics['qtr'] == 4) &
(pbp_metrics['score_differential'].abs() >= 17)
)
# Competitive plays
pbp_metrics['competitive'] = (
~pbp_metrics['garbage_time'] &
(pbp_metrics['score_differential'].abs() <= 16)
)
# Two-minute drill
pbp_metrics['two_min_drill_end_half'] = (
((pbp_metrics['qtr'] == 2) & (pbp_metrics['game_seconds_remaining'] <= 120)) |
((pbp_metrics['qtr'] == 4) & (pbp_metrics['game_seconds_remaining'] <= 120))
)
# ===== EPA-Based Variables =====
# EPA quality categories
epa_conditions = [
pbp_metrics['epa'] >= 2.0,
pbp_metrics['epa'] >= 0.5,
pbp_metrics['epa'] >= -0.5,
pbp_metrics['epa'] >= -2.0,
pbp_metrics['epa'] < -2.0
]
epa_labels = [
'Excellent (2.0+)',
'Good (0.5-2.0)',
'Average (-0.5-0.5)',
'Poor (-2.0--0.5)',
'Disaster (<-2.0)'
]
pbp_metrics['epa_quality'] = np.select(
epa_conditions, epa_labels, default='Unknown'
)
# ===== Pass-Specific Variables =====
# Completion indicator
pbp_metrics['is_completion'] = np.where(
pbp_metrics['play_type'] == 'pass',
pbp_metrics['complete_pass'] == 1,
np.nan
)
# YAC share
pbp_metrics['yac_share'] = np.where(
(pbp_metrics['play_type'] == 'pass') &
(pbp_metrics['complete_pass'] == 1) &
(pbp_metrics['air_yards'] > 0),
pbp_metrics['yards_after_catch'] / pbp_metrics['yards_gained'],
np.nan
)
# Deep pass indicator
pbp_metrics['deep_pass'] = np.where(
pbp_metrics['play_type'] == 'pass',
(pbp_metrics['air_yards'].notna()) & (pbp_metrics['air_yards'] >= 20),
np.nan
)
# Short pass indicator
pbp_metrics['short_pass'] = np.where(
pbp_metrics['play_type'] == 'pass',
(pbp_metrics['air_yards'].notna()) & (pbp_metrics['air_yards'] <= 5),
np.nan
)
# ===== Success Indicators =====
pbp_metrics['success'] = pbp_metrics['epa'] > 0
pbp_metrics['big_success'] = pbp_metrics['epa'] >= 1.0
pbp_metrics['disaster'] = pbp_metrics['epa'] <= -2.0
pbp_metrics['first_down_gained'] = pbp_metrics['first_down'] == 1
# =====================================
# Display Samples of Advanced Variables
# =====================================
print("=" * 70)
print("ADVANCED METRICS SAMPLE")
print("=" * 70)
# Show obvious passing downs
print("\nSample Obvious Passing Downs:")
obvious_pass_sample = pbp_metrics[pbp_metrics['obvious_pass_down']][[
'down', 'ydstogo', 'play_type', 'yards_gained', 'epa', 'desc'
]].head(3)
print(obvious_pass_sample.to_string(index=False))
print("\nSample Two-Minute Drill Plays:")
two_min_sample = pbp_metrics[pbp_metrics['two_min_drill_end_half']][[
'qtr', 'game_seconds_remaining', 'down', 'ydstogo',
'play_type', 'yards_gained', 'epa'
]].head(3)
print(two_min_sample.to_string(index=False))
# ===== Summary Statistics =====
print("\n" + "=" * 70)
print("SITUATIONAL PLAY DISTRIBUTIONS")
print("=" * 70)
situation_summary = pd.DataFrame({
'Situation': [
'Obvious Pass Downs',
'Obvious Run Downs',
'Standard Downs',
'Garbage Time',
'Competitive',
'Two-Min Drill'
],
'Percentage': [
f"{pbp_metrics['obvious_pass_down'].mean():.1%}",
f"{pbp_metrics['obvious_run_down'].mean():.1%}",
f"{pbp_metrics['standard_down'].mean():.1%}",
f"{pbp_metrics['garbage_time'].mean():.1%}",
f"{pbp_metrics['competitive'].mean():.1%}",
f"{pbp_metrics['two_min_drill_end_half'].mean():.1%}"
]
})
print(situation_summary.to_string(index=False))
print("\nEPA QUALITY DISTRIBUTION:")
epa_quality_dist = (pbp_metrics
.groupby('epa_quality')
.size()
.reset_index(name='count')
)
epa_quality_dist['pct'] = (
epa_quality_dist['count'] / epa_quality_dist['count'].sum() * 100
)
print(epa_quality_dist.to_string(index=False))
- Obvious Passing Down: 2nd/3rd/4th down with 8+ yards needed. Example: 2nd-and-12, 3rd-and-15. In these situations, running is unlikely to gain enough for a first down, so defenses expect pass. This affects:
- Defensive alignment (lighter boxes, more DBs)
- Pass rush strategy (defensive line pins ears back)
- Offensive play-calling (must pass, but defense knows it)
- Obvious Running Down: 3rd/4th down with 2 or fewer yards. Example: 3rd-and-1, 4th-and-2. Defenses load the box (extra defenders near line of scrimmage) expecting run. This affects:
- Run difficulty (more defenders to block)
- Pass opportunity (if defense is wrong, passing could be wide open)
- Play-calling strategy (power run vs. play-action pass?)
- Garbage Time: Large leads in 4th quarter (17+ points) create unusual dynamics. Trailing teams pass aggressively (inflating QB stats but reducing efficiency). Leading teams run to drain clock (reducing rushing efficiency). Garbage time stats are misleading for player evaluation—a QB might throw for 150 yards and 2 TDs in garbage time against a prevent defense, but this doesn't reflect normal ability.
- Competitive Plays: Excluding garbage time and extreme blowouts gives better measure of player/team ability in situations that actually determine wins. Most analyses should focus on competitive plays unless specifically examining blowouts.
- Two-Minute Drill: End-of-half situations create unique strategic constraints. Teams pass more frequently to stop clock, defenses play prevent, incomplete passes stop clock. This affects efficiency metrics and player stats.
- Communication: "Disaster plays" is more interpretable than "EPA < -2.0"
- Analysis: Easy to filter to high-quality or low-quality plays
- Visualization: Quality categories work well in bar charts and tables
- Excellent (2.0+): Big plays, touchdowns, explosive gains
- Good (0.5-2.0): First down conversions, solid gains
- Average (-0.5-0.5): Neutral outcomes, small gains or losses
- Poor (-2.0--0.5): Failed conversions, lost downs
- Disaster (<-2.0): Turnovers, sacks, catastrophic plays
yac_share, deep_pass, and short_pass only make sense for pass plays, so we use conditional logic:
# R: if_else() returns NA for non-pass plays
yac_share = if_else(
play_type == "pass" & complete_pass == 1,
yards_after_catch / yards_gained,
NA_real_
)
# Python: np.where() returns np.nan for non-pass plays
yac_share = np.where(
(play_type == 'pass') & (complete_pass == 1),
yards_after_catch / yards_gained,
np.nan
)
- Defined only when meaningful (pass plays)
- Missing (NA) otherwise, not 0 (which would be misleading)
- Interpretable (high YAC share means receiver gained many yards after catch)
- YAC = 5 yards
- Total gain = 20 yards
- YAC Share = 5/20 = 25%
- Receiver's broken tackles or elusiveness
- Good blocking downfield
- Short passes designed to get ball to playmaker in space
- Poor defensive tackling
- QB's accuracy throwing to specific spots
- Tight coverage preventing yards after catch
- Longer passes where receiver caught at depth
# Check obvious passing downs make sense
pbp_metrics %>%
filter(obvious_pass_down) %>%
count(down, ydstogo) %>%
arrange(desc(n))
# All plays should have down >= 2 and ydstogo >= 8
# If you see down = 1 or ydstogo < 8, your logic has an error
# Check garbage time identification
pbp_metrics %>%
filter(garbage_time) %>%
select(qtr, score_differential, game_seconds_remaining) %>%
summary()
# All plays should be qtr = 4 and |score_differential| >= 17
Garbage Time: A Critical Filter for Accurate Analysis
Garbage time plays—those occurring in non-competitive game situations—can severely distort player and team evaluations if not properly handled. Understanding garbage time is crucial for accurate football analytics. Why Garbage Time Matters: 1. Inflated Offensive Stats: Quarterbacks completing passes against prevent defenses (which concede underneath yards to protect against deep touchdowns) can accumulate impressive-looking stats that don't reflect normal efficiency. A QB might gain 200 yards in garbage time but these yards come in non-competitive situations. 2. Deflated Defensive Stats: Defenses playing prevent (protecting the lead by conceding short gains while preventing big plays) allow yards they wouldn't normally allow. This makes defenses look worse than they are. 3. Strategic Differences: Teams trailing badly pass on nearly 100% of plays, while teams leading run on 80%+ of plays. This creates selection bias if not accounted for. 4. Misleading Efficiency: EPA in garbage time reflects prevent strategy, not normal ability. A pass that gains 12 yards against prevent defense might have negative EPA (lost expected points), even though it gained yards. Common Garbage Time Definitions: Different analysts use different thresholds. Common approaches: 1. Score-Based: 4th quarter plays with 17+ point margin 2. Win Probability-Based: Plays with >95% or <5% win probability 3. Time-and-Score: 4th quarter with 17+ point margin AND >5 minutes remaining 4. Strict Definition: 4th quarter, 21+ point margin (less restrictive) When to Exclude Garbage Time:- Player evaluation (QB ratings, RB efficiency, etc.)
- Team efficiency comparisons
- Predictive modeling (garbage time stats don't predict future performance)
- Play-calling analysis (garbage time play-calling is unrepresentative)
- Total season statistics (for record-keeping)
- Win probability models (garbage time affects win probability)
- Comeback analysis (studying teams that overcome large deficits)
- Specific research on garbage time phenomena
# Player evaluation excluding garbage time
qb_stats <- pbp_metrics %>%
filter(
play_type == "pass",
!garbage_time, # Exclude garbage time
!is.na(epa)
) %>%
group_by(passer_player_name) %>%
summarise(
attempts = n(),
epa_per_play = mean(epa)
)
[Due to length constraints, I'll continue with the remaining sections in the next part. The chapter continues with sections on Grouping and Aggregating, Handling Missing Data, Dealing with Outliers, Reshaping Data, String Manipulation, Date/Time Handling, Joining Datasets, Summary, Exercises, and Further Reading.]
Would you like me to continue with the remaining sections?