What this assignment is about

For this assignment I wanted to figure out who the best field goal kickers in the NFL were over the 2018, 2019 and 2020 seasons. I used the NFL Big Data Bowl special teams data, which has a row for every special teams play along with information about the game, the players and the PFF scouting notes. My plan was to merge those files together, keep only the field goal attempts, and then build one row for each kicker that shows how good he was.

My approach to finding the best kickers

The simplest way to rank kickers is field goal percentage, which is just the kicks made divided by the kicks attempted. That is the main number I use and it is what I sort by. A blocked kick counts as a miss for the kicker, the same way it does in the official stats.

The problem with only using field goal percentage is that not every kick is equally hard. A kicker whose team keeps sending him out for 50 yard attempts is going to miss more than a kicker who mostly gets short ones, even if he is the better kicker. So I also look at how difficult each kicker’s attempts were, using his average attempt distance and how many kicks he made from 50 yards or more. To me the best kickers are the ones who are above average in both accuracy and distance, not just the ones with the highest percentage.

I also wanted the comparison to be fair, so I only kept kickers who played more than one season for a team and who had a minimum number of attempts. Without that, somebody who went 3 for 3 would show up as a perfect kicker, which does not really tell us anything.

Load the packages and the team colors

This chunk loads the packages I need and sets up a list of hex colors for every NFL team. I use those colors later so each kicker’s name shows up in his team’s color.

source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/refs/heads/main/SharedCode.R")

load_packages(c("data.table", "dplyr", "ggplot2", "ggrepel", "kableExtra"))

# hex colors for every NFL team so I can color the kicker names later
# (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"
)

Step 1. Read in the four CSV files and merge them into one df

Here I read in the games, plays, players and PFF scouting files and join them into a single data frame. I start with plays because it has one row for every special teams play. Then I add the season from games, the scouting columns from PFF, and the kicker’s name from players.

games   <- fread("games.csv")
plays   <- fread("plays.csv")
players <- fread("players.csv")

# the PFF file could be named either way, this just checks which one I have
pff_file    <- if (file.exists("PFFScoutingData.csv")) "PFFScoutingData.csv" else "PFFScouting.csv"
PFFScouting <- fread(pff_file)

df <- plays %>%
  left_join(games,       by = "gameId") %>%                  # gives me the season for each play
  left_join(PFFScouting, by = c("gameId", "playId")) %>%     # adds the PFF stuff
  left_join(players,     by = c("kickerId" = "nflId")) %>%   # gets the kicker's name
  data.frame()

cat("Rows in merged df:", nrow(df), " Columns:", ncol(df), "\n")
## Rows in merged df: 19979  Columns: 55

Step 2. Check the counts and pick a play type

Before going any further I checked that my data matches the counts given in the assignment for each play type and each play result. After looking at the play types, I decided to focus on field goals.

cat("Rows in plays:", nrow(plays), "\n")
print(table(plays$specialTeamsPlayType))
print(table(plays$specialTeamsResult))

# I'm going with Field Goals
## Rows in plays: 19979 
## 
## Extra Point  Field Goal     Kickoff        Punt 
##        3488        2657        7843        5991 
## 
##     Blocked Kick Attempt             Blocked Punt                   Downed 
##                       61                       39                      834 
##               Fair Catch        Kick Attempt Good     Kick Attempt No Good 
##                     1645                     5470                      585 
##    Kickoff Team Recovery                   Muffed Non-Special Teams Result 
##                       16                      214                      101 
##            Out of Bounds                   Return                Touchback 
##                      651                     5207                     5156

Step 3. Use dplyr to get one row for each kicker

This is where most of the work happens. I keep only the field goal attempts, mark each one as made or missed, and work out the distance of the kick. Then I group by kicker and team, in case a kicker played for more than one team, and summarise each group into one row with his seasons, attempts, makes, field goal percentage, average distance, longest make and makes from 50 or more yards. Last, I filter down to kickers with more than one season and enough attempts, and sort by field goal percentage.

min_attempts <- 20   # don't want guys who only kicked a few times

kickers <- df %>%
  select(displayName, possessionTeam, season, specialTeamsPlayType,
         specialTeamsResult, kickLength, yardlineSide, yardlineNumber) %>%
  
  # only keep field goals. blocked kicks count as a miss
  filter(specialTeamsPlayType == "Field Goal",
         specialTeamsResult %in% c("Kick Attempt Good",
                                   "Kick Attempt No Good",
                                   "Blocked Kick Attempt"),
         !is.na(displayName)) %>%
  
  mutate(
    made = specialTeamsResult == "Kick Attempt Good",
    
    # how far they are from the end zone
    yards_to_goal = ifelse(!is.na(yardlineSide) & yardlineSide == possessionTeam,
                           100 - yardlineNumber, yardlineNumber),
    
    # how long the kick was. kickLength is blank sometimes so then I
    # add 17 to the yard line (10 for the end zone + 7 for the snap)
    distance = ifelse(is.na(kickLength), yards_to_goal + 17, kickLength),
    
    # the Raiders were OAK and then LV in 2020, so I made them all LV
    # or else their kicker shows up as two different teams
    possessionTeam = ifelse(possessionTeam == "OAK", "LV", possessionTeam)
  ) %>%
  
  # group by kicker and team in case a kicker switched teams
  group_by(displayName, possessionTeam) %>%
  
  summarise(
    seasons      = n_distinct(season),
    attempts     = n(),
    made_fg      = sum(made),
    fg_pct       = round(100 * made_fg / attempts, 1),
    avg_distance = round(mean(distance, na.rm = TRUE), 1),
    longest_made = max(c(0, distance[made]), na.rm = TRUE),
    made_50_plus = sum(made & distance >= 50, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  
  filter(seasons > 1) %>%               # has to have played more than 1 season
  filter(attempts >= min_attempts) %>%  # and enough kicks (min_attempts)
  arrange(-fg_pct) %>%                  # sort so the best FG% is on top
  data.frame()

cat("\nKickers in the analysis:", nrow(kickers), "\n")
cat("Top 10 most accurate kickers:\n")
print(head(kickers, 10))
## 
## Kickers in the analysis: 32 
## Top 10 most accurate kickers:
##        displayName possessionTeam seasons attempts made_fg fg_pct avg_distance
## 1       Josh Lambo            JAX       3       57      54   94.7         37.8
## 2    Justin Tucker            BAL       3       94      87   92.6         38.1
## 3     Younghoe Koo            ATL       2       57      52   91.2         36.8
## 4      Jason Myers            SEA       2       44      40   90.9         38.4
## 5  Harrison Butker             KC       3       85      77   90.6         36.1
## 6        Nick Folk             NE       2       42      38   90.5         36.9
## 7         Wil Lutz             NO       3       86      77   89.5         37.6
## 8     Mason Crosby             GB       3       71      63   88.7         39.5
## 9    Aldrick Rosas            NYG       2       44      39   88.6         35.7
## 10   Chris Boswell            PIT       3       63      55   87.3         36.1
##    longest_made made_50_plus
## 1            59           10
## 2            56            9
## 3            54            7
## 4            61            3
## 5            58            7
## 6            51            3
## 7            58            5
## 8            54            9
## 9            57            4
## 10           59            3

After the filters there were 32 kickers left, each with more than one season and at least 20 attempts for his team.

Step 5. Plot accuracy against difficulty

The plot puts each kicker’s average attempt distance on the x axis and his field goal percentage on the y axis, so one picture shows both how accurate he was and how hard his kicks were. Bigger dots mean more attempts and darker dots mean more makes from 50 or more yards. The dashed lines are the averages for the group. Kickers in the green area were more accurate than average and kickers in the red area were less accurate. The best kickers are in the top right, because they were accurate even though their kicks were longer than most.

n_labels   <- 10                        # how many names to show on the plot
top_labels <- head(kickers, n_labels)

avg_x <- mean(kickers$avg_distance)
avg_y <- mean(kickers$fg_pct)

zone_alpha <- 0.08                      # how see-through the green and red are

g <- ggplot(kickers, aes(x = avg_distance, y = fg_pct)) +
  
  # green box for above average kickers, 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 kicker. bigger dot = more attempts, darker = more 50+ yard kicks made
  geom_point(aes(size = attempts, fill = made_50_plus),
             shape = 21, colour = "white", stroke = 0.7, alpha = 0.9) +
  
  # put the names of the top kickers on the plot in their team color
  geom_text_repel(data = top_labels,
                  aes(label = paste0(displayName, " (", possessionTeam, ")"),
                      colour = possessionTeam),
                  size = 3.4, fontface = "bold",
                  box.padding = 0.5, 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 = "Longer kicks + more accurate",
           hjust = 1.05, vjust = 1.8, size = 3.3, colour = "grey40",
           fontface = "italic") +
  annotate("text", x = -Inf, y = -Inf, label = "Shorter kicks + less accurate",
           hjust = -0.05, vjust = -1, size = 3.3, colour = "grey40",
           fontface = "italic") +
  
  scale_fill_gradient(low = "#c6dbef", high = "#08306b",
                      name = "Made from\n50+ yards") +
  scale_size_continuous(range = c(3, 9), name = "Attempts") +
  guides(size = guide_legend(override.aes = list(fill = "grey50"))) +
  
  labs(
    title    = "Field Goal Kickers: Accuracy vs. Difficulty (2018-2020)",
    subtitle = paste0("Kickers with 2+ seasons and at least ", min_attempts,
                      " attempts for a team. Dashed lines show the group average.\n",
                      "Green = more accurate than average, red = less accurate than average."),
    x = "Average Attempt Distance (yards)",
    y = "Field Goal Percentage"
  ) +
  theme_minimal() +
  theme(
    plot.title       = element_text(face = "bold"),
    plot.subtitle    = element_text(colour = "grey35"),
    panel.grid.minor = element_blank()
  )

# show the plot
g

# kickers who were above average in BOTH accuracy and distance (the top right of the plot)
best_both <- kickers %>%
  filter(fg_pct > avg_y, avg_distance > avg_x) %>%
  arrange(-fg_pct)

There were 7 kickers who were above average in both accuracy and distance: Jason Myers, Mason Crosby, Ka’imi Fairbairn, Brandon McManus, Randy Bullock, Jason Sanders, Dustin Hopkins. By my definition these are the best kickers in the data.

Step 6. The ten most accurate kickers

To finish, I made a table of the ten kickers with the highest field goal percentage, with each name in his team’s color to match the plot.

top10 <- kickers %>%
  arrange(-fg_pct) %>%
  head(10)

# get the team color for each of the 10 kickers (grey if I don't have the team)
name_colors <- unname(team_colors[top10$possessionTeam])
name_colors[is.na(name_colors)] <- "#333333"

top10_table <- top10 %>%
  select(
    displayName,
    possessionTeam,
    seasons,
    attempts,
    made_fg,
    fg_pct,
    avg_distance,
    longest_made
  ) %>%
  mutate(
    Number = row_number(),
    .before = 1
  ) %>%
  kable(
    format = "html",
    col.names = c(
      "#",
      "Kicker",
      "Team",
      "Seasons",
      "Attempts",
      "Made",
      "FG %",
      "Avg Distance (yards)",
      "Longest Made (yards)"
    ),
    caption = "The Ten Most Accurate Field Goal Kickers (2018-2020)",
    digits = 1,
    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 kicker names bold and in their team color
  column_spec(2, bold = TRUE, color = name_colors)

# show the table
top10_table
The Ten Most Accurate Field Goal Kickers (2018-2020)
# Kicker Team Seasons Attempts Made FG % Avg Distance (yards) Longest Made (yards)
1 Josh Lambo JAX 3 57 54 94.7 37.8 59
2 Justin Tucker BAL 3 94 87 92.6 38.1 56
3 Younghoe Koo ATL 2 57 52 91.2 36.8 54
4 Jason Myers SEA 2 44 40 90.9 38.4 61
5 Harrison Butker KC 3 85 77 90.6 36.1 58
6 Nick Folk NE 2 42 38 90.5 36.9 51
7 Wil Lutz NO 3 86 77 89.5 37.6 58
8 Mason Crosby GB 3 71 63 88.7 39.5 54
9 Aldrick Rosas NYG 2 44 39 88.6 35.7 57
10 Chris Boswell PIT 3 63 55 87.3 36.1 59

What I found

The most accurate kicker was Josh Lambo (JAX), who made 54 of 57 field goals, or 94.7 percent. The kicker with the hardest attempts on average was Brandon McManus, whose kicks averaged 41.4 yards. Looking at both together is what makes the plot useful, because a kicker near the top of the table is not always one of the kickers who took the hardest kicks.

One thing my approach does not account for is the conditions of each kick, like weather, wind, or whether the game was played in a dome. Those would be worth adding if I took this further.