Pre-snap motion is when an offensive player moves after the offense has set, before the ball is snapped. Offenses use it to make the defense show whether it is in man or zone coverage, to create mismatches, and to get a receiver moving with a head start. For this assignment I used the 2025 NFL Big Data Bowl data, which covers weeks 1 to 9 of the 2022 season, to measure how much each team uses motion and how well its offense does when it does.
The tracking data gives every player’s x and y position 10 times a
second. Since the x direction runs down the field, I looked at y, which
is side to side across the field, because most motion is a player
running sideways behind the line of scrimmage. I only looked at the time
between the line_set event and the snap, because before the
line is set players are still walking up from the huddle, and that is
not motion.
The tracking tags are very sensitive and pick up tiny movements of a
hundredth of a yard, so I needed a cutoff. For each offensive player I
took the furthest he moved side to side between line set and the snap
(his highest y minus his lowest y). If that is more than the cutoff, I
count him as in motion. To choose the cutoff, I tried several values and
compared each one with PFF’s own motionSinceLineset flag
from the player_play file, then kept the cutoff that agreed with PFF the
most often.
A play counts as a motion play if at least one offensive player was in motion. For each team I calculated:
I rank teams by their EPA on motion plays, because using motion a lot does not help unless the plays work. The best motion teams are the ones that use it often and are also successful when they do.
This chunk loads the packages I need and the hex color for every NFL team, which I use to color the teams in the plots and the table.
source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/refs/heads/main/SharedCode.R")
load_packages(c("data.table", "dplyr", "ggplot2", "ggrepel", "kableExtra", "plotly"))
# hex colors for every NFL team (got these from the nflverse team colors list)
# the Saints gold was too light to read so I used a darker gold for NO
team_colors <- c(
ARI = "#97233F", ATL = "#A71930", BAL = "#241773", BUF = "#00338D",
CAR = "#0085CA", CHI = "#0B162A", CIN = "#FB4F14", CLE = "#FF3C00",
DAL = "#002244", DEN = "#002244", DET = "#0076B6", GB = "#203731",
HOU = "#03202F", IND = "#002C5F", JAX = "#006778", KC = "#E31837",
LA = "#003594", LAC = "#007BC7", LV = "#000000", OAK = "#000000",
MIA = "#008E97", MIN = "#4F2683", NE = "#002244", NO = "#9F8958",
NYG = "#0B2265", NYJ = "#003F2D", PHI = "#004C54", PIT = "#000000",
SEA = "#002244", SF = "#AA0000", TB = "#A71930", TEN = "#4495D2",
WAS = "#5A1414"
)
Here I read in the games, plays, players and player_play files. The plays file has the EPA for each play, and player_play has PFF’s motion flag that I use to check my cutoff. I also take out plays that would not be fair to count, like kneel downs, spikes and plays wiped out by a penalty.
games <- fread("games.csv")
plays <- fread("plays.csv")
players <- fread("players.csv")
player_play <- fread("player_play.csv",
select = c("gameId", "playId", "nflId", "motionSinceLineset"))
# only keep normal plays (no kneels, spikes, or plays erased by a penalty)
clean_plays <- plays %>%
filter(playNullifiedByPenalty != "Y",
!(as.character(qbKneel) %in% c("1", "TRUE")),
!(as.character(qbSpike) %in% c("1", "TRUE"))) %>%
select(gameId, playId, possessionTeam, defensiveTeam, down, yardsToGo,
isDropback, expectedPointsAdded, yardsGained) %>%
data.frame()
cat("Plays in the data:", nrow(plays), " Plays I kept:", nrow(clean_plays), "\n")
## Plays in the data: 16124 Plays I kept: 15936
The tracking files are really big (about 1 GB each), so I read them one week at a time and only keep the columns I need. For each play I find the frame where the line was set and the frame of the snap, then for each offensive player I work out how far he moved side to side in between. I also save the full tracking for one play from week 1 so I can animate it later.
weeks <- 1:9 # all 9 weeks. use fewer (like 1:3) if your computer is slow
player_moves <- NULL
anim_df <- NULL
for (wk in weeks) {
d <- fread(paste0("tracking_week_", wk, ".csv"),
select = c("gameId", "playId", "nflId", "displayName", "frameId",
"frameType", "club", "x", "y", "event"))
moves <- d %>%
# only the frames before and at the snap, and no football
filter(frameType != "AFTER_SNAP", club != "football") %>%
# find the line set frame and the snap frame for each play
group_by(gameId, playId) %>%
mutate(set_frame = suppressWarnings(min(frameId[event == "line_set"], na.rm = TRUE)),
snap_frame = suppressWarnings(min(frameId[frameType == "SNAP"]))) %>%
ungroup() %>%
# keep only the frames between line set and the snap
filter(is.finite(set_frame), is.finite(snap_frame),
frameId >= set_frame, frameId <= snap_frame) %>%
# how far each player moved side to side (biggest y minus smallest y)
group_by(gameId, playId, nflId, displayName, club, set_frame, snap_frame) %>%
summarise(lateral_move = max(y) - min(y), .groups = "drop") %>%
# only keep the offense
inner_join(clean_plays %>% select(gameId, playId, possessionTeam),
by = c("gameId", "playId")) %>%
filter(club == possessionTeam) %>%
data.frame()
# save one play from the first week to animate: the player who moved the
# furthest side to side (under 20 yards so it is a normal looking motion)
if (wk == weeks[1]) {
pick <- moves %>%
filter(lateral_move < 20) %>%
arrange(-lateral_move) %>%
head(1)
anim_df <- d %>%
filter(gameId == pick$gameId, playId == pick$playId,
frameId <= pick$snap_frame + 20) %>%
data.frame()
anim_pick <- pick
}
player_moves <- rbind(player_moves, moves)
rm(d, moves)
invisible(gc())
cat("Week", wk, "done\n")
}
cat("Offensive player-plays measured:", nrow(player_moves), "\n")
## Week 1 done
## Week 2 done
## Week 3 done
## Week 4 done
## Week 5 done
## Week 6 done
## Week 7 done
## Week 8 done
## Week 9 done
## Offensive player-plays measured: 173602
Next I tried cutoffs from half a yard up to 5 yards. For each one I
checked how often my “in motion” call agreed with PFF’s
motionSinceLineset flag for the same player on the same
play.
check <- player_moves %>%
inner_join(player_play, by = c("gameId", "playId", "nflId")) %>%
filter(!is.na(motionSinceLineset)) %>%
mutate(pff_motion = as.logical(motionSinceLineset))
cutoffs <- c(0.5, 1, 1.5, 2, 2.5, 3, 4, 5)
cutoff_table <- data.frame(
cutoff = cutoffs,
# percent of player-plays where my call matches PFF's call
agreement = sapply(cutoffs, function(c) round(100 * mean((check$lateral_move >= c) == check$pff_motion), 1)),
# percent of player-plays my rule calls motion
my_motion = sapply(cutoffs, function(c) round(100 * mean(check$lateral_move >= c), 1))
)
# use the cutoff that matches PFF the most
min_motion <- cutoff_table$cutoff[which.max(cutoff_table$agreement)]
cutoff_table %>%
kable(format = "html",
col.names = c("Cutoff (yards)", "Agrees with PFF (%)", "Players called in motion (%)"),
caption = "How well each cutoff matches PFF's motion flag",
align = c("c", "c", "c")) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE, position = "center") %>%
row_spec(which(cutoff_table$cutoff == min_motion), bold = TRUE, background = "#E8F5E9")
| Cutoff (yards) | Agrees with PFF (%) | Players called in motion (%) |
|---|---|---|
| 0.5 | 97.5 | 8.2 |
| 1.0 | 98.8 | 6.5 |
| 1.5 | 98.9 | 6.1 |
| 2.0 | 98.7 | 5.8 |
| 2.5 | 98.5 | 5.5 |
| 3.0 | 98.3 | 5.3 |
| 4.0 | 98.0 | 4.9 |
| 5.0 | 97.4 | 4.3 |
The best match was a cutoff of 1.5 yards, which agreed with PFF on 98.9 percent of player-plays, so that is the cutoff I use for the rest of the assignment. PFF also counts some motion that is not side to side, like a running back moving toward the line, which is probably why the agreement is not 100 percent.
Now I turn the player numbers into play numbers and then team numbers. A play is a motion play if any offensive player moved more than the cutoff. Then I group by team and summarise each team’s motion rate, how far its motion players moved, and its EPA with and without motion.
# one row per play
motion_plays <- player_moves %>%
mutate(in_motion = lateral_move >= min_motion) %>%
group_by(gameId, playId) %>%
summarise(motion_players = sum(in_motion),
motion_yards = sum(lateral_move[in_motion]),
.groups = "drop") %>%
mutate(motion_play = motion_players > 0) %>%
inner_join(clean_plays, by = c("gameId", "playId")) %>%
data.frame()
# one row per team
teams <- motion_plays %>%
group_by(possessionTeam) %>%
summarise(
plays = n(),
motion_plays = sum(motion_play),
motion_rate = round(100 * mean(motion_play), 1),
avg_motion_yards = round(mean(motion_yards[motion_play]), 1),
epa_motion = round(mean(expectedPointsAdded[motion_play], na.rm = TRUE), 3),
epa_no_motion = round(mean(expectedPointsAdded[!motion_play], na.rm = TRUE), 3),
.groups = "drop"
) %>%
mutate(motion_boost = round(epa_motion - epa_no_motion, 3)) %>%
arrange(-epa_motion) %>% # best EPA on motion plays first
data.frame()
cat("Teams:", nrow(teams), " Plays:", nrow(motion_plays),
" Motion plays:", sum(motion_plays$motion_play), "\n")
print(head(teams, 10))
## Teams: 32 Plays: 15782 Motion plays: 8769
## possessionTeam plays motion_plays motion_rate avg_motion_yards epa_motion
## 1 KC 520 376 72.3 9.5 0.239
## 2 PHI 495 204 41.2 10.2 0.179
## 3 BUF 459 266 58.0 10.3 0.126
## 4 JAX 533 297 55.7 10.2 0.086
## 5 SEA 449 223 49.7 10.7 0.074
## 6 CLE 500 315 63.0 13.7 0.071
## 7 NYG 462 261 56.5 11.4 0.050
## 8 MIA 510 403 79.0 10.0 0.042
## 9 BAL 507 343 67.7 8.4 0.038
## 10 MIN 473 259 54.8 10.8 0.015
## epa_no_motion motion_boost
## 1 0.006 0.233
## 2 0.093 0.086
## 3 0.021 0.105
## 4 0.021 0.065
## 5 0.068 0.006
## 6 0.004 0.067
## 7 -0.059 0.109
## 8 0.103 -0.061
## 9 0.035 0.003
## 10 -0.111 0.126
Across the league, 55.6 percent of plays used motion. Plays with motion averaged -0.029 EPA and plays without motion averaged -0.03 EPA.
This animation shows one play from week 1. The gold dot is the player my rule picked up as being in motion, the other offensive players are in their team color, and the defense is grey. Press play to watch him move across the formation before the snap.
off_team <- anim_pick$possessionTeam
def_team <- clean_plays$defensiveTeam[clean_plays$gameId == anim_pick$gameId &
clean_plays$playId == anim_pick$playId]
# where the ball is at the snap is the line of scrimmage
los <- anim_df$x[anim_df$club == "football" & anim_df$frameId == anim_pick$snap_frame][1]
anim <- anim_df %>%
mutate(
group = case_when(club == "football" ~ "Football",
nflId == anim_pick$nflId ~ "Motion player",
club == off_team ~ paste(off_team, "offense"),
TRUE ~ paste(def_team, "defense")),
group = factor(group, levels = c(paste(off_team, "offense"), "Motion player",
paste(def_team, "defense"), "Football")),
hover = ifelse(club == "football", "Football", displayName),
# an id for each dot so plotly knows which dot is which from frame to frame
dot_id = ifelse(club == "football", "football", as.character(nflId))
)
# colors for each group: offense in team color, motion player gold, defense grey, ball brown
group_colors <- setNames(
c(unname(team_colors[off_team]), "#F2A900", "#9E9E9E", "#8B4513"),
levels(anim$group)
)
# white yard lines every 5 yards and a blue line of scrimmage
field_lines <- lapply(seq(10, 110, by = 5), function(v)
list(type = "line", x0 = v, x1 = v, y0 = 0, y1 = 53.3,
line = list(color = "white", width = 1), layer = "below"))
field_lines[[length(field_lines) + 1]] <-
list(type = "line", x0 = los, x1 = los, y0 = 0, y1 = 53.3,
line = list(color = "#1E88E5", width = 3), layer = "below")
p_anim <- plot_ly(anim, x = ~x, y = ~y, frame = ~frameId, ids = ~dot_id,
color = ~group, colors = group_colors,
text = ~hover, hoverinfo = "text",
type = "scatter", mode = "markers",
marker = list(size = 12, line = list(color = "white", width = 1))) %>%
layout(
title = list(text = paste0(anim_pick$displayName, " (", off_team, ") moves ",
round(anim_pick$lateral_move, 1),
" yards across the field before the snap<br>",
"<sup>Line set at frame ", anim_pick$set_frame,
", snap at frame ", anim_pick$snap_frame,
". Blue line = line of scrimmage</sup>")),
shapes = field_lines,
plot_bgcolor = "#4C8C4A",
xaxis = list(range = c(max(0, los - 25), min(120, los + 25)), title = "",
showgrid = FALSE, zeroline = FALSE, showticklabels = FALSE),
yaxis = list(range = c(0, 53.3), title = "", showgrid = FALSE,
zeroline = FALSE, showticklabels = FALSE),
legend = list(orientation = "h", y = -0.05),
margin = list(t = 80)
) %>%
animation_opts(frame = 100, transition = 0, redraw = FALSE) %>%
animation_slider(currentvalue = list(prefix = "Frame "))
p_anim
This bar chart sorts the teams by their motion rate, so you can see which offenses move players before the snap the most. The dashed line is the league average.
league_rate <- round(100 * mean(motion_plays$motion_play), 1)
g_bar <- ggplot(teams, aes(x = reorder(possessionTeam, motion_rate), y = motion_rate,
fill = possessionTeam)) +
geom_col(width = 0.75) +
geom_text(aes(label = paste0(motion_rate, "%")), hjust = -0.15, size = 3.3, colour = "grey25") +
geom_hline(yintercept = league_rate, linetype = "dashed", colour = "grey40") +
scale_fill_manual(values = team_colors, na.value = "grey50", guide = "none") +
scale_y_continuous(expand = expansion(mult = c(0, 0.12))) +
coord_flip() +
labs(
title = "How Often Each Offense Uses Pre-Snap Motion (2022, Weeks 1-9)",
subtitle = paste0("A play counts as motion if an offensive player moved at least ", min_motion,
" yards side to side after the line was set. Dashed line = league average."),
x = NULL,
y = "Plays with motion (%)"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold"),
plot.subtitle = element_text(colour = "grey35"),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank()
)
g_bar
This is the main plot. Each team’s motion rate is on the x axis and its EPA on motion plays is on the y axis. Teams in the green area did better than average on motion plays and teams in the red area did worse. The teams in the top right are the ones I think use motion best, because they use it a lot and it works for them.
avg_x <- mean(teams$motion_rate)
avg_y <- mean(teams$epa_motion)
zone_alpha <- 0.08 # how see-through the green and red are
g <- ggplot(teams, aes(x = motion_rate, y = epa_motion)) +
# green box for above average teams, red box for below average
annotate("rect", xmin = -Inf, xmax = Inf, ymin = avg_y, ymax = Inf,
fill = "#2E7D32", alpha = zone_alpha) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = -Inf, ymax = avg_y,
fill = "#C62828", alpha = zone_alpha) +
# dashed lines at the averages
geom_hline(yintercept = avg_y, linetype = "dashed", colour = "grey60") +
geom_vline(xintercept = avg_x, linetype = "dashed", colour = "grey60") +
# a dot for each team in its team color. bigger dot = more motion plays
geom_point(aes(size = motion_plays, fill = possessionTeam),
shape = 21, colour = "white", stroke = 0.7, alpha = 0.9) +
scale_fill_manual(values = team_colors, na.value = "grey50", guide = "none") +
# team names next to the dots in their team color
geom_text_repel(aes(label = possessionTeam, colour = possessionTeam),
size = 3.6, fontface = "bold",
box.padding = 0.4, min.segment.length = 0,
segment.colour = "grey60", seed = 1) +
scale_colour_manual(values = team_colors, na.value = "grey20", guide = "none") +
# little labels in the corners so you know what they mean
annotate("text", x = Inf, y = Inf, label = "Uses motion a lot + it works",
hjust = 1.05, vjust = 1.8, size = 3.3, colour = "grey40",
fontface = "italic") +
annotate("text", x = -Inf, y = -Inf, label = "Uses motion less + it works less",
hjust = -0.05, vjust = -1, size = 3.3, colour = "grey40",
fontface = "italic") +
scale_size_continuous(range = c(3, 9), name = "Motion plays") +
guides(size = guide_legend(override.aes = list(fill = "grey50"))) +
labs(
title = "Pre-Snap Motion: How Often vs. How Well (2022, Weeks 1-9)",
subtitle = paste0("Dashed lines show the league average. ",
"Green = better than average EPA on motion plays, red = worse."),
x = "Plays with Motion (%)",
y = "EPA per Play with Motion"
) +
theme_minimal() +
theme(
plot.title = element_text(face = "bold"),
plot.subtitle = element_text(colour = "grey35"),
panel.grid.minor = element_blank()
)
# show the plot
g
# teams that were above average in BOTH motion rate and EPA on motion plays (top right)
best_both <- teams %>%
filter(motion_rate > avg_x, epa_motion > avg_y) %>%
arrange(-epa_motion)
There were 9 teams that used motion more than average and also did better than average on their motion plays: KC, BUF, CLE, NYG, MIA, BAL, DET, TEN, ATL. By my definition these are the best motion offenses in the data.
This table ranks every team by its EPA on motion plays. The motion boost column shows how much better (or worse) the team did on motion plays compared to its plays without motion. A positive boost is green and a negative boost is red.
# get the team color for each team (grey if I don't have the team)
name_colors <- unname(team_colors[teams$possessionTeam])
name_colors[is.na(name_colors)] <- "#333333"
# green for a positive motion boost, red for a negative one
boost_colors <- ifelse(teams$motion_boost > 0, "#2E7D32", "#C62828")
teams %>%
select(possessionTeam, plays, motion_plays, motion_rate, avg_motion_yards,
epa_motion, epa_no_motion, motion_boost) %>%
mutate(
Number = row_number(),
.before = 1
) %>%
kable(
format = "html",
col.names = c(
"Rank",
"Team",
"Plays",
"Motion Plays",
"Motion Rate (%)",
"Avg Motion (yards)",
"EPA with Motion",
"EPA without Motion",
"Motion Boost"
),
caption = "Teams Ranked by EPA per Play with Pre-Snap Motion (2022, Weeks 1-9)",
digits = 3,
align = c("l", "l", "c", "c", "c", "c", "c", "c", "c")
) %>%
kable_styling(
bootstrap_options = c(
"striped",
"hover",
"condensed"
),
full_width = FALSE,
position = "center"
) %>%
# make the team names bold and in their team color
column_spec(2, bold = TRUE, color = name_colors) %>%
# color the motion boost green or red
column_spec(9, bold = TRUE, color = boost_colors)
| Rank | Team | Plays | Motion Plays | Motion Rate (%) | Avg Motion (yards) | EPA with Motion | EPA without Motion | Motion Boost |
|---|---|---|---|---|---|---|---|---|
| 1 | KC | 520 | 376 | 72.3 | 9.5 | 0.239 | 0.006 | 0.233 |
| 2 | PHI | 495 | 204 | 41.2 | 10.2 | 0.179 | 0.093 | 0.086 |
| 3 | BUF | 459 | 266 | 58.0 | 10.3 | 0.126 | 0.021 | 0.105 |
| 4 | JAX | 533 | 297 | 55.7 | 10.2 | 0.086 | 0.021 | 0.065 |
| 5 | SEA | 449 | 223 | 49.7 | 10.7 | 0.074 | 0.068 | 0.006 |
| 6 | CLE | 500 | 315 | 63.0 | 13.7 | 0.071 | 0.004 | 0.067 |
| 7 | NYG | 462 | 261 | 56.5 | 11.4 | 0.050 | -0.059 | 0.109 |
| 8 | MIA | 510 | 403 | 79.0 | 10.0 | 0.042 | 0.103 | -0.061 |
| 9 | BAL | 507 | 343 | 67.7 | 8.4 | 0.038 | 0.035 | 0.003 |
| 10 | MIN | 473 | 259 | 54.8 | 10.8 | 0.015 | -0.111 | 0.126 |
| 11 | LV | 464 | 250 | 53.9 | 11.6 | -0.002 | 0.037 | -0.039 |
| 12 | LAC | 489 | 218 | 44.6 | 9.2 | -0.009 | -0.021 | 0.012 |
| 13 | DET | 471 | 321 | 68.2 | 10.7 | -0.017 | -0.117 | 0.100 |
| 14 | NO | 543 | 200 | 36.8 | 11.1 | -0.021 | -0.027 | 0.006 |
| 15 | TEN | 408 | 258 | 63.2 | 11.4 | -0.025 | -0.174 | 0.149 |
| 16 | ATL | 496 | 360 | 72.6 | 12.2 | -0.030 | 0.158 | -0.188 |
| 17 | CHI | 500 | 326 | 65.2 | 9.3 | -0.054 | -0.047 | -0.007 |
| 18 | NE | 512 | 261 | 51.0 | 10.5 | -0.060 | -0.075 | 0.015 |
| 19 | CIN | 529 | 209 | 39.5 | 10.9 | -0.086 | 0.114 | -0.200 |
| 20 | DAL | 438 | 232 | 53.0 | 10.6 | -0.090 | 0.057 | -0.147 |
| 21 | CAR | 451 | 218 | 48.3 | 10.8 | -0.092 | -0.119 | 0.027 |
| 22 | SF | 445 | 312 | 70.1 | 11.1 | -0.093 | 0.274 | -0.367 |
| 23 | TB | 549 | 303 | 55.2 | 10.8 | -0.097 | -0.039 | -0.058 |
| 24 | LA | 456 | 275 | 60.3 | 10.1 | -0.108 | -0.163 | 0.055 |
| 25 | HOU | 427 | 208 | 48.7 | 13.5 | -0.113 | -0.173 | 0.060 |
| 26 | PIT | 467 | 225 | 48.2 | 10.0 | -0.124 | -0.166 | 0.042 |
| 27 | NYJ | 530 | 378 | 71.3 | 10.5 | -0.145 | -0.005 | -0.140 |
| 28 | ARI | 548 | 229 | 41.8 | 11.2 | -0.146 | 0.027 | -0.173 |
| 29 | GB | 538 | 307 | 57.1 | 10.1 | -0.147 | -0.103 | -0.044 |
| 30 | DEN | 482 | 201 | 41.7 | 8.1 | -0.154 | -0.129 | -0.025 |
| 31 | WAS | 560 | 297 | 53.0 | 10.4 | -0.163 | -0.084 | -0.079 |
| 32 | IND | 571 | 234 | 41.0 | 9.5 | -0.224 | -0.155 | -0.069 |
An offensive coordinator would also want to know when motion helps the most, so I split the plays into runs and passes and compared the EPA with and without motion.
motion_plays %>%
mutate(play_type = ifelse(isDropback %in% c(TRUE, "TRUE", 1), "Pass", "Run")) %>%
group_by(play_type) %>%
summarise(
plays = n(),
motion_rate = round(100 * mean(motion_play), 1),
epa_motion = round(mean(expectedPointsAdded[motion_play], na.rm = TRUE), 3),
epa_no_motion = round(mean(expectedPointsAdded[!motion_play], na.rm = TRUE), 3),
.groups = "drop"
) %>%
mutate(motion_boost = round(epa_motion - epa_no_motion, 3)) %>%
kable(
format = "html",
col.names = c("Play Type", "Plays", "Motion Rate (%)", "EPA with Motion",
"EPA without Motion", "Motion Boost"),
caption = "Motion on Runs vs. Passes",
align = c("l", "c", "c", "c", "c", "c")
) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE, position = "center")
| Play Type | Plays | Motion Rate (%) | EPA with Motion | EPA without Motion | Motion Boost |
|---|---|---|---|---|---|
| Pass | 9618 | 51.0 | -0.008 | -0.014 | 0.006 |
| Run | 6164 | 62.6 | -0.055 | -0.061 | 0.006 |
The best team by my metric was KC, who averaged 0.239 EPA per play on plays with motion and used motion on 72.3 percent of its plays. The team that used motion the most was MIA at 79 percent, and the team that used it the least was NO at 36.8 percent.
An offensive coordinator could use this in a few ways. The motion boost shows whether motion is actually helping their offense or if they are just doing it out of habit, so a team with a negative boost might need to change the type of motion they run. Comparing runs and passes shows where motion pays off the most, which can help decide when to call it. They could also look at an upcoming opponent’s defense and see how well other teams did against it with motion.
My metric has some limits. EPA depends on a lot more than motion, like the play call, the players and the defense, so a good EPA on motion plays does not prove that motion caused it. My rule also only catches side to side motion, so it can miss a running back or tight end who moves toward the line, and nine weeks is not a huge sample for each team.