One of the main reasons offenses use pre-snap motion is to get the defense to tip its hand: if a defender follows the motion, it usually signals man coverage. This project looks at the defensive side: which defenses do not react to motion, and are they still successful at stopping the offense?
To measure this, I matched each motion player to the closest defender and drew a 2-yard box around that defender’s starting spot. Movement inside the box is allowed. If the defender leaves the box before the snap, he reacted to the motion. A team’s stay rate is the percent of motion plays where its defender stayed in the box.
I loaded all nine weeks of tracking data, keeping only plays with pre-snap motion and the columns needed for the metric.
source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/refs/heads/main/SharedCode.R")
library(scales)
library(hms)
library(data.table)
library(dplyr)
library(ggplot2)
library(stringr)
library(gifski)
library(gganimate)
library(ggforce)
library(sf)
library(kableExtra)
directory <- paste0(getwd(), "/NFLBDB2025")
pp <- paste0(directory, "/player_play.csv")
player_play <- fread(file = pp)
plays <- fread(file = paste0(directory, "/plays.csv"))
games <- fread(file = paste0(directory, "/games.csv"))
players <- fread(file = paste0(directory, "/players.csv"))
#Find the plays that involved motion
motion_play_ids <- player_play |>
filter(motionSinceLineset %in% TRUE | inMotionAtBallSnap %in% TRUE) |>
group_by(gameId, playId) |>
summarise(motion = TRUE, .groups = 'keep') |>
ungroup() |>
data.frame()
#Load each week of tracking data
#Week 1
week1 <- paste0(directory, "/tracking_week_1.csv")
week1_df <- fread(file = week1) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Week 2
week2 <- paste0(directory, "/tracking_week_2.csv")
week2_df <- fread(file = week2) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Week 3
week3 <- paste0(directory, "/tracking_week_3.csv")
week3_df <- fread(file = week3) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Week 4
week4 <- paste0(directory, "/tracking_week_4.csv")
week4_df <- fread(file = week4) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Week 5
week5 <- paste0(directory, "/tracking_week_5.csv")
week5_df <- fread(file = week5) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Week 6
week6 <- paste0(directory, "/tracking_week_6.csv")
week6_df <- fread(file = week6) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Week 7
week7 <- paste0(directory, "/tracking_week_7.csv")
week7_df <- fread(file = week7) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Week 8
week8 <- paste0(directory, "/tracking_week_8.csv")
week8_df <- fread(file = week8) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Week 9
week9 <- paste0(directory, "/tracking_week_9.csv")
week9_df <- fread(file = week9) |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
left_join(motion_play_ids, by = c("gameId", "playId")) |>
filter(motion %in% TRUE) |>
data.frame()
#Combine all 9 weeks into one dataframe
motion_df <- rbind(week1_df, week2_df, week3_df, week4_df, week5_df,
week6_df, week7_df, week8_df, week9_df)
#Count the games and plays that were loaded
motion_df |>
group_by(gameId, playId) |>
summarise(n = n(), .groups = 'keep') |>
ungroup() |>
summarise(games = length(unique(gameId)),
plays = n())
The metric uses a 2-yard box around the defender, and a player must move at least 2 yards to count as being in motion. Only the frames between line set and snap are kept.
#Size of box around defender
box_size <- 2
#Distance the player must travel to be considered "in motion"
min_motion <- 2
#Identify offensive players in motion after the line set
motion_players <- player_play |>
filter(motionSinceLineset %in% TRUE | inMotionAtBallSnap %in% TRUE) |>
select(gameId, playId, nflId) |>
mutate(motion_player = TRUE) |>
data.frame()
#Keep only the frames between the line set and the ball snap
pre_snap_df <- motion_df |>
select(gameId, playId, nflId, frameId, x, y, dis, event, club) |>
#Remove the football
filter(club != "football") |>
#Offense or defense
left_join(plays |> select(gameId, playId, possessionTeam, defensiveTeam),
by = c("gameId", "playId")) |>
mutate(side = ifelse(club == possessionTeam, "offense", "defense")) |>
#Keep only plays with both a line_set and a ball_snap
group_by(gameId, playId) |>
filter(any(event == "line_set", na.rm = TRUE),
any(event == "ball_snap", na.rm = TRUE)) |>
#Find the line set frame and the snap frame for each play
mutate(set_frame = min(frameId[event == "line_set"], na.rm = TRUE),
snap_frame = min(frameId[event == "ball_snap"], na.rm = TRUE)) |>
#Drop rows before line set and after the snap
filter(set_frame < snap_frame,
frameId >= set_frame,
frameId <= snap_frame) |>
ungroup() |>
arrange(gameId, playId, nflId, frameId) |>
data.frame()
For each play, I kept the motion player who moved the most, along with his starting spot and the direction he moved across the field.
#Motion player
mover_df <- pre_snap_df |>
#Keep only the players in motion
left_join(motion_players, by = c("gameId", "playId", "nflId")) |>
filter(motion_player %in% TRUE) |>
#Starting spot and total distance moved before the snap
group_by(gameId, playId, nflId) |>
summarise(mx = first(x),
my = first(y),
motion_dist = sum(dis[frameId > set_frame]),
#How far he moved across the field (+ or - shows the direction)
motion_dy = last(y) - first(y),
.groups = 'keep') |>
ungroup() |>
#Keep the player who moved the most on each play
arrange(gameId, playId, motion_dist) |>
group_by(gameId, playId) |>
slice_tail(n = 1) |>
ungroup() |>
#Remove plays where the motion was too small to count
filter(motion_dist >= min_motion) |>
mutate(motion_nflId = nflId) |>
select(gameId, playId, motion_nflId, mx, my, motion_dist, motion_dy) |>
data.frame()
The matched defender is the one lined up closest to the motion player at the line set. Defensive linemen are excluded since they do not cover players in motion.
#Match the defender closest to the motion player at the line set
matched_df <- pre_snap_df |>
#Defender starting spots at the line set
filter(side == "defense", frameId == set_frame) |>
mutate(dx0 = x, dy0 = y) |>
select(gameId, playId, nflId, dx0, dy0) |>
#Position
left_join(players |> select(nflId, position), by = "nflId") |>
#Defensive linemen do not cover players in motion
filter(!position %in% c("DE", "DT", "NT")) |>
#Add the motion player starting spot
left_join(mover_df, by = c("gameId", "playId")) |>
filter(!is.na(motion_dist)) |>
#Distance between each defender and the motion player
mutate(start_gap = sqrt((dx0 - mx)^2 + (dy0 - my)^2)) |>
#Keep only the closest defender
arrange(gameId, playId, -start_gap) |>
group_by(gameId, playId) |>
slice_tail(n = 1) |>
ungroup() |>
data.frame()
For every frame before the snap, I checked whether the matched defender was outside his box. I also recorded how far he moved, whether he moved the same direction as the motion player, and the result of the play.
#Did the matched defender leave his box before the snap (react to the motion)
box_df <- pre_snap_df |>
#Keep only the matched defender on each play
left_join(matched_df |> select(gameId, playId, nflId, dx0, dy0,
motion_dist, motion_dy, position),
by = c("gameId", "playId", "nflId")) |>
filter(!is.na(dx0)) |>
#Is the defender outside the box
mutate(outside_box = abs(x - dx0) > box_size | abs(y - dy0) > box_size) |>
#Summary of the play
group_by(gameId, playId) |>
summarise(left_box = any(outside_box),
max_disp = max(sqrt((x - dx0)^2 + (y - dy0)^2)),
def_dist = sum(dis[frameId > set_frame]),
motion_dist = first(motion_dist),
motion_dy = first(motion_dy),
#How far the defender moved across the field
def_dy = last(y) - first(y),
position = first(position),
defensiveTeam = first(defensiveTeam),
.groups = 'keep') |>
ungroup() |>
#Did the defender move the same direction (1 = yes, 0 = no)
mutate(same_direction = ifelse(def_dy * motion_dy > 0, 1, 0)) |>
#Play outcome and coverage
left_join(plays |> select(gameId, playId, expectedPointsAdded, yardsGained,
pff_manZone, pff_passCoverage),
by = c("gameId", "playId")) |>
data.frame()
Before looking at the numbers, these two plays show the metric in action. In both animations, the player in motion and the matched defender are highlighted in black, and the shaded box marks the area around the defender’s starting spot.
Man coverage. In this play, the matched defender follows the motion player across the formation and leaves his box. This is exactly the kind of reaction that tells the quarterback the defense is in man coverage.
#Example 1: MAN defender who clearly followed the motion
left_ex <- box_df |>
filter(left_box, pff_manZone == "Man", same_direction == 1) |>
mutate(follow_dist = abs(def_dy)) |>
arrange(follow_dist) |>
slice_tail(n = 1)
#Matched defender and his starting spot
left_match <- matched_df |>
filter(gameId == left_ex$gameId, playId == left_ex$playId)
#Line set and snap frames for this play
left_frames <- pre_snap_df |>
filter(gameId == left_ex$gameId, playId == left_ex$playId) |>
summarise(set_frame = first(set_frame),
snap_frame = first(snap_frame))
#Week of the play and whether the defense is the home or away team
left_week <- games$week[games$gameId == left_ex$gameId]
left_home <- games$homeTeamAbbr[games$gameId == left_ex$gameId]
left_def_team <- ifelse(left_ex$defensiveTeam == left_home, "home", "away")
#Load that week with the class function
one_week_df <- load_data_for_one_week(
directory = directory,
weekNumber = left_week,
merge = TRUE
)
#One play from line set to snap, with the box around the matched defender
left_one_play <- one_week_df |>
filter(gameId == left_ex$gameId, playId == left_ex$playId) |>
filter(frameId >= left_frames$set_frame,
frameId <= left_frames$snap_frame) |>
mutate(xmin = ifelse(team == left_def_team, left_match$dx0 - box_size, NA),
xmax = ifelse(team == left_def_team, left_match$dx0 + box_size, NA),
ymin = ifelse(team == left_def_team, left_match$dy0 - box_size, NA),
ymax = ifelse(team == left_def_team, left_match$dy0 + box_size, NA),
#Highlight the motion player and the matched defender
inMotionAtBallSnap = nflId %in% c(left_match$motion_nflId, left_match$nflId),
#Title of the animation
playDescription = "Man coverage: Defender Follows Man In Motion, Leaving His Box") |>
data.frame()
#Animate the play with the class function
a <- visualize_single_play(
game_df = left_one_play,
highlight_players_in_motion = TRUE,
showBoundingBox = TRUE,
showVoronoi = FALSE
)
a
#Save the gif
anim_save("man_defender_left_box.gif", a)
Zone coverage. In this play, the motion player moves across the formation, but the matched defender stays inside his box. Since no defender follows the motion, the quarterback can usually rule out man coverage, but he still has to read the specific coverage after the snap.
#Example 2: longest motion where a ZONE defender stayed in his box
stayed_ex <- box_df |>
filter(!left_box, pff_manZone == "Zone") |>
arrange(motion_dist) |>
slice_tail(n = 1)
#Matched defender and his starting spot
stayed_match <- matched_df |>
filter(gameId == stayed_ex$gameId, playId == stayed_ex$playId)
#Line set and snap frames for this play
stayed_frames <- pre_snap_df |>
filter(gameId == stayed_ex$gameId, playId == stayed_ex$playId) |>
summarise(set_frame = first(set_frame),
snap_frame = first(snap_frame))
#Week of the play and whether the defense is the home or away team
stayed_week <- games$week[games$gameId == stayed_ex$gameId]
stayed_home <- games$homeTeamAbbr[games$gameId == stayed_ex$gameId]
stayed_def_team <- ifelse(stayed_ex$defensiveTeam == stayed_home, "home", "away")
one_week_df <- load_data_for_one_week(
directory = directory,
weekNumber = stayed_week,
merge = TRUE
)
#One play from line set to snap, with the box around the matched defender
stayed_one_play <- one_week_df |>
filter(gameId == stayed_ex$gameId, playId == stayed_ex$playId) |>
filter(frameId >= stayed_frames$set_frame,
frameId <= stayed_frames$snap_frame) |>
mutate(xmin = ifelse(team == stayed_def_team, stayed_match$dx0 - box_size, NA),
xmax = ifelse(team == stayed_def_team, stayed_match$dx0 + box_size, NA),
ymin = ifelse(team == stayed_def_team, stayed_match$dy0 - box_size, NA),
ymax = ifelse(team == stayed_def_team, stayed_match$dy0 + box_size, NA),
#Highlight the motion player and the matched defender
inMotionAtBallSnap = nflId %in% c(stayed_match$motion_nflId, stayed_match$nflId),
#Title of the animation
playDescription = "Zone coverage: defender stays in his box during the motion") |>
data.frame()
#Animate the play with the class function
a <- visualize_single_play(
game_df = stayed_one_play,
highlight_players_in_motion = TRUE,
showBoundingBox = TRUE,
showVoronoi = FALSE
)
a
#Save the gif
anim_save("zone_defender_stayed.gif", a)
Two plays are only two examples. If the box metric is measuring what it should, defenders in man coverage should leave their box more often than defenders in zone coverage across the entire dataset. The table below breaks down defender movement and offensive EPA for each coverage type, including the standard deviation to show how much plays vary.
#If a defender stays in the box is that zone coverage
coverage_df <- box_df |>
#Coverage is only labeled on pass plays
filter(!is.na(pff_manZone)) |>
#Averages and standard deviations for man vs zone
group_by(pff_manZone) |>
summarise(plays = n(),
pct_left_box = mean(left_box),
avg_disp = mean(max_disp),
sd_disp = sd(max_disp),
avg_epa = mean(expectedPointsAdded, na.rm = TRUE),
sd_epa = sd(expectedPointsAdded, na.rm = TRUE),
.groups = 'keep') |>
ungroup() |>
data.frame()
#Display the coverage table
kable(coverage_df, digits = 3,
col.names = c("Coverage", "Plays", "Left Box Rate",
"Avg Movement (yds)", "SD Movement (yds)",
"Avg EPA", "SD EPA"),
align = "lcccccc",
caption = "Defender Response to Motion by Coverage Type") |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE,
position = "center") |>
row_spec(0, bold = TRUE, background = "#1F3A5F", color = "white") |>
column_spec(1, bold = TRUE)
| Coverage | Plays | Left Box Rate | Avg Movement (yds) | SD Movement (yds) | Avg EPA | SD EPA |
|---|---|---|---|---|---|---|
| Man | 1670 | 0.380 | 2.939 | 3.813 | 0.063 | 1.667 |
| Other | 336 | 0.307 | 2.306 | 2.930 | 0.065 | 1.507 |
| Zone | 4465 | 0.338 | 2.193 | 2.638 | -0.012 | 1.302 |
How to read this table: Each row is a coverage type. Left Box Rate is the share of motion plays where the defender left his box (0.600 = 60%). Avg Movement is how far the defender usually moved, and a high SD Movement means that distance varied a lot from play to play. Avg EPA shows how the offense did, where lower is better for the defense; because SD EPA is large, small differences in Avg EPA should be read with caution.
The key comparison is Man vs Zone: if the metric works, the Man row should have a clearly higher Left Box Rate and Avg Movement. The Other row has fewer plays, so its numbers are less reliable.
The chart below shows the most important number from the table: how often the matched defender left his box in each coverage type.
#How often the defender left his box for each coverage type
coverage_plot <- box_df |>
filter(!is.na(pff_manZone)) |>
group_by(pff_manZone) |>
summarise(Plays = n(),
LeftRate = mean(left_box),
.groups = 'keep') |>
ungroup() |>
data.frame()
g <- ggplot(coverage_plot,
aes(x = pff_manZone, y = LeftRate, fill = pff_manZone)) +
geom_col() +
geom_text(aes(label = paste0(percent(LeftRate, accuracy = 0.1),
" (", Plays, " plays)")),
vjust = -0.5) +
scale_y_continuous(labels = percent, limits = c(0, 1.1)) +
scale_fill_manual(values = c("Man" = "red",
"Zone" = "#1F3A5F",
"Other" = "grey")) +
labs(title = "How Often the Defender Left His Box by Coverage Type",
x = "Coverage",
y = "% of Motion Plays Defender Left His Box") +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"),
legend.position = "none")
g
Takeaway: Defenders in man coverage left their box on 38.0% of motion plays, compared to 33.8% for defenders in zone coverage, and moved farther on average (2.94 vs 2.19 yards). The gap is modest: most man defenders still stayed in their box. Leaving the box is a sign of man coverage rather than a guarantee, so the metric captures part of how defenses tip their hand, but not all of it.
The next question is whether staying put matters. This chart compares the average EPA offenses gained when the matched defender stayed in his box versus when he left it, across every team.
#EPA allowed when the defender stayed vs left his box (all teams)
stay_plot <- box_df |>
mutate(Status = ifelse(left_box, "Left Box", "Stayed in Box")) |>
group_by(Status) |>
summarise(Plays = n(),
EPA = mean(expectedPointsAdded, na.rm = TRUE),
.groups = 'keep') |>
ungroup() |>
data.frame()
g <- ggplot(stay_plot, aes(x = Status, y = EPA, fill = Status)) +
geom_col() +
geom_hline(yintercept = 0) +
geom_text(aes(label = paste0(round(EPA, 3), " (", Plays, " plays)")),
vjust = ifelse(stay_plot$EPA >= 0, -0.5, 1.5)) +
scale_fill_manual(values = c("Stayed in Box" = "#1F3A5F",
"Left Box" = "red")) +
labs(title = "Offensive EPA When the Defender Stayed vs Left His Box",
x = "",
y = "Average Offensive EPA (lower = better defense)") +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"),
legend.position = "none")
g
Takeaway: When the matched defender stayed in his box, offenses averaged 0.005 EPA per play. When he left it, offenses averaged 0.023 EPA per play. The difference is small, about 0.018 EPA per play, compared to how much individual plays vary. On its own, it is not strong evidence, but it points the same direction as the team-level results in Step 10.
The league-wide average does not show which teams are best at this. The table below summarizes every defense, sorted by stay rate. The standard deviation of movement shows how consistent each defense is: a low number means its defender reacts about the same way every time.
#How often each defense stays in the box and EPA allowed
team_df <- box_df |>
#Averages and standard deviations for each defense
group_by(defensiveTeam) |>
summarise(motion_plays = n(),
stay_rate = mean(!left_box),
avg_disp = mean(max_disp),
sd_disp = sd(max_disp),
epa_allowed = mean(expectedPointsAdded, na.rm = TRUE),
sd_epa = sd(expectedPointsAdded, na.rm = TRUE),
avg_yards = mean(yardsGained, na.rm = TRUE),
.groups = 'keep') |>
ungroup() |>
#Teams that stay in the box the most at the top
arrange(-stay_rate) |>
data.frame()
#Display the team table
kable(team_df, digits = 3,
col.names = c("Defense", "Motion Plays", "Stay Rate",
"Avg Movement (yds)", "SD Movement (yds)",
"Avg EPA Allowed", "SD EPA", "Avg Yards Allowed"),
align = "lccccccc",
caption = "Team Defensive Response to Motion") |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE,
position = "center") |>
row_spec(0, bold = TRUE, background = "#1F3A5F", color = "white") |>
column_spec(1, bold = TRUE)
| Defense | Motion Plays | Stay Rate | Avg Movement (yds) | SD Movement (yds) | Avg EPA Allowed | SD EPA | Avg Yards Allowed |
|---|---|---|---|---|---|---|---|
| ATL | 200 | 0.855 | 1.256 | 1.754 | 0.095 | 1.329 | 5.675 |
| DEN | 166 | 0.837 | 1.325 | 1.536 | -0.256 | 1.373 | 4.566 |
| SEA | 201 | 0.791 | 1.421 | 1.585 | -0.002 | 1.422 | 5.955 |
| TB | 229 | 0.764 | 1.735 | 2.348 | -0.062 | 1.363 | 4.996 |
| JAX | 168 | 0.750 | 1.945 | 2.677 | -0.146 | 1.648 | 5.101 |
| GB | 199 | 0.749 | 1.805 | 2.163 | -0.015 | 1.162 | 5.261 |
| MIN | 186 | 0.747 | 1.808 | 2.351 | -0.098 | 1.438 | 5.452 |
| PHI | 184 | 0.734 | 1.737 | 2.289 | -0.064 | 1.460 | 5.467 |
| NE | 241 | 0.726 | 1.962 | 2.370 | -0.063 | 1.462 | 5.469 |
| BAL | 236 | 0.720 | 2.107 | 2.589 | -0.043 | 1.566 | 6.267 |
| CAR | 249 | 0.715 | 1.779 | 2.035 | -0.046 | 1.407 | 5.289 |
| LA | 179 | 0.709 | 1.777 | 1.965 | -0.151 | 1.597 | 5.391 |
| NYG | 203 | 0.704 | 1.917 | 2.636 | 0.026 | 1.281 | 6.025 |
| PIT | 226 | 0.704 | 2.257 | 3.375 | 0.065 | 1.617 | 6.053 |
| ARI | 189 | 0.677 | 2.376 | 3.294 | 0.246 | 1.428 | 6.688 |
| BUF | 187 | 0.668 | 2.511 | 3.118 | 0.150 | 1.340 | 6.439 |
| MIA | 243 | 0.667 | 2.740 | 3.813 | 0.024 | 1.515 | 6.103 |
| DAL | 172 | 0.651 | 2.462 | 2.900 | 0.066 | 1.277 | 5.826 |
| CHI | 209 | 0.632 | 2.587 | 3.187 | 0.090 | 1.431 | 6.416 |
| KC | 188 | 0.628 | 2.249 | 2.355 | 0.120 | 1.238 | 5.809 |
| TEN | 185 | 0.627 | 2.655 | 3.415 | 0.077 | 1.454 | 6.200 |
| DET | 183 | 0.601 | 2.877 | 3.715 | 0.159 | 1.410 | 6.710 |
| LAC | 197 | 0.599 | 3.173 | 3.985 | 0.056 | 1.457 | 5.391 |
| SF | 160 | 0.588 | 2.911 | 3.381 | -0.071 | 1.443 | 4.737 |
| NO | 233 | 0.571 | 2.641 | 3.274 | -0.071 | 1.430 | 5.631 |
| CIN | 215 | 0.553 | 3.327 | 4.306 | -0.042 | 1.275 | 5.712 |
| CLE | 174 | 0.552 | 3.020 | 3.456 | 0.095 | 1.462 | 6.213 |
| NYJ | 247 | 0.543 | 3.090 | 3.494 | 0.054 | 1.260 | 5.336 |
| WAS | 250 | 0.484 | 3.317 | 3.423 | -0.028 | 1.429 | 6.188 |
| IND | 217 | 0.479 | 2.969 | 2.572 | 0.014 | 1.456 | 5.931 |
| HOU | 195 | 0.462 | 3.417 | 3.330 | 0.090 | 1.248 | 6.215 |
| LV | 163 | 0.442 | 3.145 | 2.900 | 0.098 | 1.528 | 4.945 |
How to read this table: Each row is one defense, sorted by Stay Rate, the share of motion plays where its defender stayed in his box (0.750 = 75%). Motion Plays shows the sample size. Avg Movement is how far the defender usually moved, and a low SD Movement means he reacted the same way most of the time. Avg EPA Allowed and Avg Yards Allowed show how the offense did; for EPA, lower is better, and negative means the defense won on average.
The defenses this project is looking for are near the top of the table with a low or negative Avg EPA Allowed.
The chart below breaks the Step 8 comparison down by team. Each row is one defense, and the two dots show the EPA it allowed when its defender stayed in his box versus when he left it. Defenses are ordered from best (top) to worst (bottom) by overall EPA allowed.
#Dumbbell chart: EPA allowed when the defender stayed vs left the box
dumbbell_df <- box_df |>
#Average EPA for each team, split by stayed vs left the box
group_by(defensiveTeam, left_box) |>
summarise(plays = n(),
epa = mean(expectedPointsAdded, na.rm = TRUE),
.groups = 'keep') |>
ungroup() |>
mutate(status = ifelse(left_box, "Left box", "Stayed in box")) |>
#Add each team's overall EPA allowed to order the chart
left_join(team_df |> select(defensiveTeam, epa_allowed),
by = "defensiveTeam") |>
data.frame()
#Best defenses (lowest EPA allowed) at the top
g <- ggplot(dumbbell_df,
aes(x = epa,
y = reorder(defensiveTeam, -epa_allowed))) +
geom_line(aes(group = defensiveTeam), color = "grey60") +
geom_point(aes(color = status), size = 3) +
scale_color_manual(values = c("Stayed in box" = "#1F3A5F",
"Left box" = "tomato")) +
labs(title = "EPA (Expected Points Added) Allowed on Motion Plays: Defender Stayed vs Left His Box",
x = "Average Offensive EPA (lower = better defense)",
y = NULL,
color = NULL) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
g
Takeaway: Defenses where the navy dot sits to the left of the red dot allowed less when their defender stayed put. This shows which teams benefit the most from not reacting to motion.
This final chart brings everything together. Each dot is a defense: the farther right, the more often its defender stayed in his box, and the higher up, the less EPA it allowed. The defenses I was looking for are toward the top right. Larger dots mean the defense faced more motion plays, so its results are more reliable.
#Team scatter plot: best defenses are in the top right
g <- ggplot(team_df, aes(x = stay_rate,
y = epa_allowed,
label = defensiveTeam)) +
geom_point(aes(size = motion_plays), color = "#1F3A5F", alpha = 0.9) +
geom_text(vjust = -1.3, size = 3) +
scale_x_continuous(labels = percent) +
scale_y_reverse() +
labs(title = "Which Defenses Stay Put Against Motion and Still Stop the Offense?",
x = "% of Motion Plays Defender Stayed in His Box (Didn't React to Motion)",
y = "EPA Allowed per Motion Play (higher on chart = better defense)",
size = "Motion plays") +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold"))
g
#Correlation between staying in the box and EPA allowed
load_packages(c("Hmisc"))
cor_results <- rcorr(team_df$stay_rate, team_df$epa_allowed)
#Put the correlation and p-value in a table
cor_df <- data.frame(
Comparison = "Team Stay Rate vs EPA Allowed",
Correlation = round(cor_results$r[1, 2], 3),
P_Value = ifelse(cor_results$P[1, 2] < 0.001, "<0.001",
round(cor_results$P[1, 2], 3)),
Teams = cor_results$n[1, 2]
)
#Display the correlation table
kable(cor_df,
col.names = c("Comparison", "Correlation (r)", "P-Value", "Teams"),
align = "lccc",
caption = "Correlation Between Staying in the Box and EPA Allowed") |>
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE,
position = "center") |>
row_spec(0, bold = TRUE, background = "#1F3A5F", color = "white") |>
column_spec(1, bold = TRUE)
| Comparison | Correlation (r) | P-Value | Teams |
|---|---|---|---|
| Team Stay Rate vs EPA Allowed | -0.368 | 0.038 | 32 |
How to read this table: The Correlation (r) measures how closely a team’s stay rate and the EPA it allowed move together, on a scale from -1 to 1. A negative value means that as stay rate goes up, EPA allowed goes down, so defenses that stay put more tend to allow less. The closer the value is to -1 or 1, the stronger the relationship; values around 0.3 to 0.5 (positive or negative) are usually considered moderate. The P-Value shows how likely a relationship this strong would appear by chance if there were no real connection; a value below 0.05 is considered statistically significant. Teams is the number of defenses included, one data point per team.
Takeaway: The correlation between a team’s stay rate and the EPA it allowed is -0.368 (p = 0.038), a moderate, statistically significant relationship. The defenses that stayed in their box more often than average and allowed less EPA than average were: DEN, LA, JAX, MIN, PHI, NE, TB, CAR, BAL, GB, SEA. DEN ranks first: it had the No. 2 stay rate in the league and allowed the lowest EPA of any defense (-0.256).
Can defenses who do not react to motion still be successful? Yes. Across all 32 teams, defenses that stayed in their box more often tended to allow less EPA on motion plays (r = -0.368, p = 0.038), a moderate, statistically significant relationship. DEN was the best example, with the No. 2 stay rate and the lowest EPA allowed of any defense.
The supporting evidence is weaker but points the same way. Man defenders left their box more often than zone defenders (38.0% vs 33.8%), so reacting to motion is a hint of man coverage, not a guarantee. At the play level, offenses gained slightly less when the defender stayed put (0.005 vs 0.023 EPA). Staying put is not enough on its own, though: ATL had the highest stay rate but still allowed positive EPA.
Why this matters for offensive coordinators: stay rate shows how much motion will reveal against a given defense. Against low stay-rate defenses like WAS, IND, HOU, LV, motion is a reliable way to read the coverage before the snap. Against high stay-rate defenses like ATL, DEN, SEA, motion reveals little, so it is better used to create mismatches, blocking angles, or free releases.