This analysis uses data from the NFL Big Data Bowl 2022 dataset, covering the 2018–2020 NFL seasons, to determine which NFL kicker performed the best. Rather than relying solely on overall field goal percentage, my analysis considers field goal distance, actual performance compared with expected performance, long-range kicking ability, and consistency across seasons.
My goal in this assignment is to identify which kickers consistently exceeded expectations while accounting for the difficulty and volume of their field goal attempts.Grab sources, libraries, and csv files!
# pull in initial wd, sources, and packages
setwd("~/Library/CloudStorage/OneDrive-LoyolaUniversityMaryland/fall 26/IS470")
source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/refs/heads/main/SharedCode.R")
load_packages(c("data.table", "dplyr", "ggplot2", "stringr", "scales", "hms", "gganimate",
"gifski", "ggforce", "sf", "av", "knitr", "kableExtra", "ggimage", "plotly"))
### IMPORT DATA ###
games <- read.csv("BDB2022/games.csv")
plays <- read.csv("BDB2022/plays.csv")
players <- read.csv("BDB2022/players.csv")
PFFScouting <- read.csv("BDB2022/PFFScoutingData.csv")
# merge csvs to one df
plays_df <- plays %>% left_join(games, by = "gameId") %>% left_join(players, by = c("kickerId" = "nflId")) %>%
left_join(PFFScouting, by = c("gameId", "playId"))
### TRACKING DATA ###
# import tracking data from all three seasons
y1 <- fread("BDB2022/tracking2018.csv")
y2 <- fread("BDB2022/tracking2019.csv")
y3 <- fread("BDB2022/tracking2020.csv")
tracking_df <- rbind(y1, y2, y3)
rm(y1, y2, y3)
# normalize tracking coordinates to the same direction of play
tracking_df1 <- tracking_df %>%
mutate(x = if_else(playDirection == "right", 120 - x, x),
y = if_else(playDirection == "right", 160 / 3 - y, y)) %>%
data.frame()
### FIELD GOAL TRACKING DATA ###
# create a data frame that follows the path of the football
perf <- tracking_df1 %>%
select(gameId, playId, x, y, frameId, team, event, time) %>%
left_join(plays %>%
select(gameId, playId, kickerId, kickLength,
specialTeamsPlayType, specialTeamsResult),
by = c("gameId", "playId")) %>%
left_join(players %>%
select(nflId, displayName),
by = c("kickerId" = "nflId")) %>%
filter(team == "football",
specialTeamsPlayType == "Field Goal",
specialTeamsResult %in%
c("Kick Attempt Good", "Kick Attempt No Good")) %>%
mutate(clean_date = paste(
str_sub(gameId, 5, 6),
str_sub(gameId, 7, 8),
str_sub(gameId, 1, 4),
sep = "-")) %>%
group_by(gameId, playId) %>%
filter(any(x < 0, na.rm = TRUE)) %>%
filter(frameId <= min(frameId[x < 0], na.rm = TRUE)) %>%
filter(frameId >= min(frameId[event == "field_goal_attempt"])) %>%
ungroup() %>%
arrange(gameId, playId, frameId) %>%
mutate(dx = x - lag(x),
dy = y - lag(y),
angle = -atan(dy / dx) * 180 / pi) %>%
data.frame()
This section examines how field goal success varies by kick distance. Because longer attempts are generally more difficult, overall accuracy alone may not provide a fair comparison between kickers.
# create a clean field goal attempt data frame
fg_df <- plays_df %>%
filter(specialTeamsPlayType == "Field Goal",
specialTeamsResult %in% c("Kick Attempt Good", "Kick Attempt No Good"),
!is.na(kickLength)) %>%
mutate(made = if_else(specialTeamsResult == "Kick Attempt Good", 1, 0))
# group field goals by distance
fg_distance <- fg_df %>%
mutate(distance_group = case_when(
kickLength < 30 ~ "<30",
kickLength < 40 ~ "30-39",
kickLength < 50 ~ "40-49",
TRUE ~ "50+"
),
distance_group = factor(distance_group,
levels = c("<30", "30-39", "40-49", "50+"))) %>%
group_by(distance_group) %>%
summarise(attempts = n(), makes = sum(made),
fg_pct = mean(made))
| Field Goal Distance | Attempts | Makes | Field Goal % |
|---|---|---|---|
| <30 | 626 | 615 | 98.2% |
| 30-39 | 771 | 718 | 93.1% |
| 40-49 | 812 | 632 | 77.8% |
| 50+ | 395 | 253 | 64.1% |
To account for differences in field goal difficulty, I used logistic regression to estimate the probability of making a field goal based on kick distance. This provides an expected field goal percentage that can be compared with each kicker’s actual performance.
# logistic regression of field goal percentage
fg_model <- glm(made ~ kickLength, data = fg_df, family = binomial)
# add expected FG probability to each attempt
fg_df <- fg_df %>%
mutate(expected_fg = predict(fg_model, newdata = ., type = "response"))
# calculate performance above expectation for each attempt
fg_df <- fg_df %>%
mutate(fg_above_expected = made - expected_fg)
In addition to kick distance, I explored whether the starting location and angle of a field goal attempt improved the accuracy of the expected field goal model. I compared four logistic regression models using AIC, where lower values indicate better model fit after accounting for complexity. Because the tracking-based analysis included fewer field goal attempts than the full dataset, I used these comparisons to evaluate whether location and angle offered meaningful improvements before retaining the distance-only model for the primary analysis.
# get starting location of each field goal attempt
kick_locations <- perf %>%
arrange(gameId, playId, frameId) %>%
group_by(gameId, playId) %>%
slice_head(n = 1) %>%
ungroup() %>%
mutate(
distance_from_center = abs(y - 26.665),
made = if_else(specialTeamsResult == "Kick Attempt Good", 1, 0)
)
# distance-only model
model_distance <- glm(made ~ kickLength,
data = kick_locations, family = binomial)
# distance and lateral location model
model_location <- glm(made ~ kickLength + distance_from_center,
data = kick_locations, family = binomial)
# calculate kick angle
kick_locations <- kick_locations %>%
mutate(
lateral_offset = abs(y - 26.665),
kick_angle = atan2(lateral_offset, abs(x)) * 180 / pi
)
# distance and angle model
model_angle <- glm(made ~ kickLength + kick_angle,
data = kick_locations, family = binomial)
# distance, location, and angle model
model_location_angle <- glm(
made ~ kickLength + distance_from_center + kick_angle,
data = kick_locations,
family = binomial
)
# compare model fit
model_aic <- AIC(model_distance, model_location, model_angle, model_location_angle)
# examine correlation between lateral location and kick angle
location_angle_cor <- cor(kick_locations$distance_from_center,
kick_locations$kick_angle, use = "complete.obs")
To compare kickers fairly, I calculated each kicker’s actual and expected field goal percentages. I also calculated field goal percentage above expected, which measures how much a kicker exceeded expectations, and total field goals above expected, which accounts for both performance and number of attempts. I excluded kickers with fewer than 20 attempts to reduce the influence of small sample sizes.
# summarize actual vs expected performance by kicker
kicker_expected <- fg_df %>%
group_by(displayName) %>%
summarise(
attempts = n(),
makes = sum(made),
actual_fg_pct = mean(made),
expected_fg_pct = mean(expected_fg),
fg_above_expected = actual_fg_pct - expected_fg_pct,
total_fgoe = sum(made - expected_fg),
avg_distance = mean(kickLength, na.rm = TRUE),
.groups = "drop"
) %>%
arrange(desc(fg_above_expected))
# remove kickers with fewer than 20 attempts
kicker_expected_filtered <- kicker_expected %>%
filter(attempts >= 20) %>%
mutate(
performance = if_else(
actual_fg_pct > expected_fg_pct,
"Above Expected",
"Below Expected"
),
attempt_range = cut(
attempts,
breaks = c(19, 39, 59, Inf),
labels = c(" 20-39", " 40-59", " 60+")
)
)
# visualize actual vs expected field goal percentage
acvsexp <- ggplot(kicker_expected_filtered,
aes(x = expected_fg_pct, y = actual_fg_pct,
color = performance, shape = attempt_range,
text = paste0("Kicker: ", displayName,
"<br>Attempts: ", attempts,
"<br>Actual FG%: ", scales::percent(actual_fg_pct, accuracy = 0.1),
"<br>Expected FG%: ", scales::percent(expected_fg_pct, accuracy = 0.1),
"<br>FG% Above Expected: ", scales::percent(fg_above_expected, accuracy = 0.1)))) +
geom_point(size = 3.5, alpha = 0.8) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed") +
scale_color_manual(values = c("Above Expected" = "dodgerblue",
"Below Expected" = "firebrick1")) +
scale_shape_manual(values = c(" 20-39" = 16, " 40-59" = 17, " 60+" = 15)) +
scale_x_continuous(labels = scales::percent) +
scale_y_continuous(labels = scales::percent) +
labs(title = "Actual vs. Expected Field Goal Percentage",
subtitle = "Kickers above the dashed line outperform expectation",
x = "Expected Field Goal Percentage",
y = "Actual Field Goal Percentage",
color = "Performance", shape = "Attempts") +
guides(color = guide_legend(ncol = 2),
shape = guide_legend(ncol = 2)) +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5),
plot.subtitle = element_text(hjust = 0.5))
plotly::ggplotly(acvsexp, tooltip = "text") %>%
plotly::layout(
legend = list(orientation = "v", x = 1.02, xanchor = "left",
y = 0.5, yanchor = "middle", font = list(size = 10)),
margin = list(l = 80, r = 180, b = 70, t = 90)
)
While field goal percentage above expected measures how much a kicker outperformed expectations on average, total field goals above expected also accounts for the number of attempts. This helps identify kickers who provided the greatest overall value across the three seasons.
# top 10 kickers by percentage above expected
top_kickers <- kicker_expected_filtered %>%
select(displayName, attempts, makes, actual_fg_pct,
expected_fg_pct, fg_above_expected, total_fgoe, avg_distance) %>%
arrange(desc(fg_above_expected)) %>%
head(10)
# top 10 kickers by total field goals above expected
top_kickers_total <- kicker_expected_filtered %>%
select(displayName, attempts, makes, actual_fg_pct,
expected_fg_pct, fg_above_expected, total_fgoe, avg_distance) %>%
arrange(desc(total_fgoe)) %>%
head(10)
# visualize top 10 kickers by total FGOE
ggplot(top_kickers_total,
aes(x = reorder(displayName, total_fgoe), y = total_fgoe)) +
geom_col(fill = "dodgerblue", width = 0.7) +
geom_text(aes(label = round(total_fgoe, 2)),
hjust = -0.2, size = 3.5) +
coord_flip() +
scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
labs(
title = "Top 10 NFL Kickers by Total Field Goals Above Expected",
subtitle = "2018–2020 NFL Seasons",
x = "Kicker",
y = "Total Field Goals Above Expected"
) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5),
plot.subtitle = element_text(hjust = 0.5)
)
To identify the strongest candidates, I combined the top 10 kickers by field goal percentage above expected with the top 10 by total field goals above expected. I then examined their performance on attempts of 50 yards or further to evaluate their long-range kicking ability. Kickers appearing in either top 10 ranking were included in the candidate pool, even if they did not rank in the top 10 for both measures.
# identify top 10 kickers by rate and total value
top_rate <- kicker_expected_filtered %>%
arrange(desc(fg_above_expected)) %>%
slice_head(n = 10) %>%
pull(displayName)
top_value <- kicker_expected_filtered %>%
arrange(desc(total_fgoe)) %>%
slice_head(n = 10) %>%
pull(displayName)
# combine candidates from both rankings
best_kicker_candidates <- kicker_expected_filtered %>%
filter(displayName %in% union(top_rate, top_value)) %>%
select(displayName, attempts, makes, actual_fg_pct,
expected_fg_pct, fg_above_expected, total_fgoe, avg_distance) %>%
arrange(desc(total_fgoe))
# analyze field goals from 50+ yards
long_range <- fg_df %>%
filter(displayName %in% best_kicker_candidates$displayName,
kickLength >= 50) %>%
group_by(displayName) %>%
summarise(
attempts_50_plus = n(),
makes_50_plus = sum(made),
fg_pct_50_plus = mean(made),
longest_attempt = max(kickLength),
longest_make = max(kickLength[made == 1]),
.groups = "drop"
)
# add long-range statistics to candidate comparison
best_kicker_candidates <- best_kicker_candidates %>%
left_join(long_range, by = "displayName")
| Kicker | Attempts | Makes | FG% | Longest Attempt | Longest Make |
|---|---|---|---|---|---|
| Younghoe Koo | 7 | 7 | 100.0% | 54 | 54 |
| Josh Lambo | 11 | 10 | 90.9% | 59 | 59 |
| Jason Myers | 11 | 9 | 81.8% | 61 | 61 |
| Graham Gano | 8 | 6 | 75.0% | 63 | 63 |
| Justin Tucker | 12 | 9 | 75.0% | 65 | 56 |
| Mason Crosby | 12 | 9 | 75.0% | 56 | 54 |
| Nick Folk | 4 | 3 | 75.0% | 51 | 51 |
| Harrison Butker | 10 | 7 | 70.0% | 58 | 58 |
| Wil Lutz | 8 | 5 | 62.5% | 58 | 58 |
| Brandon McManus | 21 | 13 | 61.9% | 64 | 58 |
To evaluate consistency, I calculated each candidate’s field goal performance above expected for each season. I then compared the five kickers with the highest total field goals above expected to determine whether their performance remained strong across multiple seasons. Only seasons with at least 10 attempts were included in the consistency analysis.
# calculate performance by kicker and season
kicker_season <- fg_df %>%
filter(displayName %in% best_kicker_candidates$displayName) %>%
group_by(displayName, season) %>%
summarise(
attempts = n(),
actual_fg_pct = mean(made),
expected_fg_pct = mean(expected_fg),
fg_above_expected = actual_fg_pct - expected_fg_pct,
total_fgoe = sum(made - expected_fg),
.groups = "drop"
)
# summarize consistency across qualifying seasons
kicker_consistency <- kicker_season %>%
filter(attempts >= 10) %>%
group_by(displayName) %>%
summarise(
seasons = n(),
seasons_above_expected = sum(fg_above_expected > 0),
avg_season_fg_above_expected = mean(fg_above_expected),
min_season_fg_above_expected = min(fg_above_expected),
.groups = "drop"
) %>%
arrange(desc(seasons_above_expected),
desc(avg_season_fg_above_expected))
# identify the top five kickers by total FGOE
top_5_names <- best_kicker_candidates %>%
slice_max(total_fgoe, n = 5) %>%
pull(displayName)
# visualize season-to-season consistency
consistency_plot <- kicker_season %>%
filter(displayName %in% top_5_names, attempts >= 10) %>%
ggplot(aes(x = season, y = fg_above_expected,
color = displayName, group = displayName)) +
annotate("rect", xmin = -Inf, xmax = Inf,
ymin = 0, ymax = Inf,
fill = "lightgreen", alpha = 0.2) +
annotate("rect", xmin = -Inf, xmax = Inf,
ymin = -Inf, ymax = 0,
fill = "lightcoral", alpha = 0.2) +
geom_hline(yintercept = 0, linetype = "dashed",
color = "gray50") +
geom_line(linewidth = 1) +
geom_point(size = 3) +
scale_x_continuous(breaks = c(2018, 2019, 2020)) +
scale_y_continuous(labels = scales::percent) +
labs(
title = "Season-to-Season Kicker Consistency",
subtitle = "Top 5 Kickers by Total Field Goals Above Expected (2018–2020)",
x = "Season",
y = "Field Goal Percentage Above Expected",
color = "Kicker"
) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5),
plot.subtitle = element_text(hjust = 0.5)
)
consistency_plot
To determine the best overall kicker, I combined each candidate’s performance above expected, field goal attempt volume, long-range accuracy, and season-to-season consistency. This allowed me to evaluate both overall performance and reliability rather than selecting a kicker based on a single statistic. The table below displays the 10 candidates with the highest total field goals above expected, although all candidates from the combined rankings were considered in the analysis.
| Kicker | FG Attempts | Actual FG% | Expected FG% | FG% Above Expected | Total FGOE | 50+ Attempts | 50+ FG% | Seasons Above Expected |
|---|---|---|---|---|---|---|---|---|
| Justin Tucker | 92 | 94.6% | 84.8% | 9.8% | 8.98 | 12 | 75.0% | 3 |
| Jason Myers | 80 | 91.2% | 84.2% | 7.0% | 5.62 | 11 | 81.8% | 2 |
| Brandon McManus | 82 | 87.8% | 81.1% | 6.7% | 5.50 | 21 | 61.9% | 2 |
| Josh Lambo | 57 | 94.7% | 85.1% | 9.6% | 5.48 | 11 | 90.9% | 2 |
| Graham Gano | 45 | 93.3% | 83.3% | 10.0% | 4.51 | 8 | 75.0% | 2 |
| Wil Lutz | 85 | 90.6% | 86.4% | 4.2% | 3.54 | 8 | 62.5% | 2 |
| Harrison Butker | 84 | 91.7% | 87.7% | 4.0% | 3.34 | 10 | 70.0% | 3 |
| Mason Crosby | 71 | 88.7% | 84.6% | 4.1% | 2.92 | 12 | 75.0% | 2 |
| Younghoe Koo | 57 | 91.2% | 87.4% | 3.9% | 2.20 | 7 | 100.0% | 1 |
| Nick Folk | 41 | 92.7% | 87.4% | 5.3% | 2.18 | 4 | 75.0% | 1 |
Based on my analysis of NFL field goal attempts from 2018–2020, Justin Tucker was the best overall kicker. Across 92 attempts, Tucker made 87 field goals, resulting in an actual field goal percentage of approximately 94.6%, compared with an expected percentage of 84.8%. This means he exceeded expectations by about 9.8 percentage points and made nearly nine more field goals than was expected of him based on kick distance.
Tucker also demonstrated strong long-range ability, converting 9 of his 12 attempts from 50 yards or more (75%). Additionally, he performed above expectation in all three seasons, demonstrating that his success was not limited to one particularly strong year.
Although other kickers performed well in individual categories, Tucker stood out for his combination of accuracy, attempt volume, long-range performance, and consistency. For example, Graham Gano achieved a slightly higher field goal percentage above expected, but Tucker maintained nearly the same level of performance across substantially more attempts. Gano also fell slightly below expected in 2018. This makes Tucker the strongest overall choice rather than simply selecting the kicker with the highest percentage.
One limitation of this analysis is that the expected field goal model primarily accounts for kick distance. Although I explored additional predictors such as kick location and angle, they provided only minor improvements in model fit. Other factors, including weather conditions, stadium characteristics, and game pressure, could also influence field goal difficulty. Additionally, the analysis is limited to the 2018–2020 seasons, so the results do not necessarily reflect career-long performance.
Overall, Justin Tucker provided the strongest combination of performance and reliability, making him my selection for the best NFL kicker during the period analyzed.