Assignment Description

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.


Step 1. Load Data

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()

Step 2. Initial Field Goal Analysis

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))
NFL Field Goal Percentage by Distance (2018–2020)
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%

Step 3. Expected Field Goal Model

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)

Step 4. Kick Location and Angle Analysis

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")

Step 5. Kicker Performance Above Expectation

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)
  )

Step 6. Top Kickers by Total Field Goals Above Expected

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)
  )

Step 7. Best Kicker Candidates and Long-Range Performance

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")
Long-Range Field Goal Performance (50+ Yards, 2018–2020)
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

Step 8. Season-to-Season Kicker Consistency

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

Step 9. Final Kicker Comparison

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.

Final Comparison of Top NFL Kickers (2018–2020)
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

Step 10. Conclusion

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.