The goal of this project is to accept the NFL Big Data Bowl Datasets for 2018-2020 and determine who is the best kicker. However, the criteria is who performs best under pressure. Using variables created using the score differential at the time of the kick, the amount of game remaining at the time of the kick, and the stage of the season in which the kick takes place.
Import datasets for 2018,2019, and 2020. Combine the three into a new dataset called tracking_df.Finally, import the plays, players, and games datasets.
setwd("C:/Users/nparc/Downloads/School/Fall 26/IS470")
library(data.table)
library(dplyr)
library(ggplot2)
library(scales)
library(plotly)
#import
y1 <- fread("tracking2018.csv")
y2 <- fread("tracking2019.csv")
y3 <- fread("tracking2020.csv")
tracking_df <- rbind(y1, y2, y3)
rm(y1, y2, y3)
plays <- fread("plays.csv")
players <- fread("players.csv")
games <- fread("games.csv")
I began by determining score differential at the time of the kick and assigning it to a variable.
#create dataframe, connect nflId to kicker name
field_goals <- plays |>
filter(
specialTeamsPlayType == "Field Goal",
specialTeamsResult %in% c(
"Kick Attempt Good",
"Kick Attempt No Good"
)
) |>
left_join(
players |> select(nflId, displayName),
by = c("kickerId" = "nflId")
)
#convert to binary for ease later
field_goals <- field_goals |>
mutate(
made = ifelse(
specialTeamsResult == "Kick Attempt Good",
1,
0
)
)
names(field_goals)
## [1] "gameId" "playId" "playDescription"
## [4] "quarter" "down" "yardsToGo"
## [7] "possessionTeam" "specialTeamsPlayType" "specialTeamsResult"
## [10] "kickerId" "returnerId" "kickBlockerId"
## [13] "yardlineSide" "yardlineNumber" "gameClock"
## [16] "penaltyCodes" "penaltyJerseyNumbers" "penaltyYards"
## [19] "preSnapHomeScore" "preSnapVisitorScore" "passResult"
## [22] "kickLength" "kickReturnYardage" "playResult"
## [25] "absoluteYardlineNumber" "displayName" "made"
#collect teams
field_goals <- field_goals |>
left_join(
games |>
select(gameId, homeTeamAbbr, visitorTeamAbbr),
by = "gameId"
)
#check score differential
field_goals <- field_goals |>
mutate(
score_diff = case_when(
possessionTeam == homeTeamAbbr ~
preSnapHomeScore - preSnapVisitorScore,
possessionTeam == visitorTeamAbbr ~
preSnapVisitorScore - preSnapHomeScore,
TRUE ~ NA_real_
)
)
Let’s take a look at where field goal attempts crossed the goal line
#convert time on clock to numeric
field_goals <- field_goals |>
mutate(
time_remaining = as.numeric(sub(":.*", "", gameClock)) * 60 +
as.numeric(sub(".*:", "", gameClock))
)
#calculate time remaining
field_goals <- field_goals |>
mutate(
total_time_remaining = case_when(
quarter <= 4 ~ time_remaining + (4 - quarter) * 15 * 60,
quarter > 4 ~ time_remaining,
TRUE ~ NA_real_
)
)
#table(is.na(field_goals$score_diff))
#table(is.na(field_goals$time_remaining))
field_goals <- field_goals |>
mutate(
score_pressure = 1 / (abs(score_diff) + 1)
)
field_goals <- field_goals |>
mutate(
time_pressure = 1 / (total_time_remaining / 60 + 1)
)
field_goals <- field_goals |>
mutate(
game_pressure = score_pressure * time_pressure
)
field_goals |>
select(
displayName,
quarter,
gameClock,
score_diff,
score_pressure,
time_pressure,
game_pressure
) |>
arrange(desc(game_pressure))
## displayName quarter gameClock score_diff score_pressure
## <char> <int> <char> <num> <num>
## 1: Mason Crosby 4 00:03:00 0 1.00000000
## 2: Daniel Carlson 5 00:04:00 0 1.00000000
## 3: Wil Lutz 4 00:26:00 0 1.00000000
## 4: Ka'imi Fairbairn 4 00:06:00 0 1.00000000
## 5: Ka'imi Fairbairn 5 00:03:00 0 1.00000000
## ---
## 2600: Jason Myers 2 00:02:00 -31 0.03125000
## 2601: Aldrick Rosas 2 00:04:00 31 0.03125000
## 2602: Cody Parkey 2 00:03:00 32 0.03030303
## 2603: Joey Slye 2 00:36:00 -35 0.02777778
## 2604: Jason Sanders 2 10:43:00 -28 0.03448276
## time_pressure game_pressure
## <num> <num>
## 1: 1.00000000 1.0000000000
## 2: 1.00000000 1.0000000000
## 3: 1.00000000 1.0000000000
## 4: 1.00000000 1.0000000000
## 5: 1.00000000 1.0000000000
## ---
## 2600: 0.03225806 0.0010080645
## 2601: 0.03225806 0.0010080645
## 2602: 0.03225806 0.0009775171
## 2603: 0.03225806 0.0008960573
## 2604: 0.02439024 0.0008410429
fg_model <- glm(
made ~ kickLength,
data = field_goals,
family = binomial
)
field_goals <- field_goals |>
mutate(
expected_make = predict(
fg_model,
newdata = field_goals,
type = "response"
)
)
field_goals |>
select(
displayName,
kickLength,
made,
expected_make
)
## displayName kickLength made expected_make
## <char> <int> <num> <num>
## 1: Matt Bryant 21 1 0.9846960
## 2: Jake Elliott 26 1 0.9730351
## 3: Matt Bryant 52 1 0.6407354
## 4: Justin Tucker 41 1 0.8642314
## 5: Stephen Hauschka 52 0 0.6407354
## ---
## 2600: Jason Myers 36 1 0.9190286
## 2601: Jason Myers 30 1 0.9578404
## 2602: Tristan Vizcaino 36 1 0.9190286
## 2603: Tristan Vizcaino 47 1 0.7607671
## 2604: Tristan Vizcaino 33 1 0.9413772
field_goals <- field_goals |>
mutate(
performance = made - expected_make
)
field_goals <- field_goals |>
mutate(
pressure_performance = performance * game_pressure
)
summary(field_goals$game_pressure)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000841 0.003289 0.006944 0.043081 0.019325 1.000000
field_goals |>
select(
displayName,
quarter,
gameClock,
score_diff,
kickLength,
made,
expected_make,
game_pressure,
pressure_performance
) |>
arrange(desc(game_pressure))
## displayName quarter gameClock score_diff kickLength made
## <char> <int> <char> <num> <int> <num>
## 1: Mason Crosby 4 00:03:00 0 52 0
## 2: Daniel Carlson 5 00:04:00 0 35 0
## 3: Wil Lutz 4 00:26:00 0 44 1
## 4: Ka'imi Fairbairn 4 00:06:00 0 59 0
## 5: Ka'imi Fairbairn 5 00:03:00 0 37 1
## ---
## 2600: Jason Myers 2 00:02:00 -31 55 1
## 2601: Aldrick Rosas 2 00:04:00 31 23 1
## 2602: Cody Parkey 2 00:03:00 32 50 1
## 2603: Joey Slye 2 00:36:00 -35 23 1
## 2604: Jason Sanders 2 10:43:00 -28 54 1
## expected_make game_pressure pressure_performance
## <num> <num> <num>
## 1: 0.6407354 1.0000000000 -6.407354e-01
## 2: 0.9272293 1.0000000000 -9.272293e-01
## 3: 0.8181538 1.0000000000 1.818462e-01
## 4: 0.4424788 1.0000000000 -4.424788e-01
## 5: 0.9099934 1.0000000000 9.000658e-02
## ---
## 2600: 0.5576322 0.0010080645 4.459353e-04
## 2601: 0.9807892 0.0010080645 1.936572e-05
## 2602: 0.6920861 0.0009775171 3.009911e-04
## 2603: 0.9807892 0.0008960573 1.721397e-05
## 2604: 0.5859443 0.0008410429 3.482386e-04
table(field_goals$quarter)
##
## 1 2 3 4 5
## 444 950 473 710 27
field_goals <- field_goals |>
mutate(
total_time_remaining = case_when(
quarter <= 4 ~ time_remaining + (4 - quarter) * 15 * 60,
quarter > 4 ~ time_remaining,
TRUE ~ NA_real_
)
)
range(field_goals$total_time_remaining, na.rm = TRUE)
## [1] 0 3480
field_goals <- field_goals |>
mutate(
score_pressure = 1 - pmin(abs(score_diff) / 14, 1),
time_pressure = 1 - pmin(total_time_remaining / (60 * 60), 1),
game_pressure = score_pressure * time_pressure,
pressure_performance = performance * game_pressure
)
summary(field_goals$game_pressure)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.00000 0.09167 0.21667 0.28611 0.41786 1.00000
field_goals |>
select(
displayName,
quarter,
gameClock,
score_diff,
kickLength,
made,
game_pressure
) |>
arrange(desc(game_pressure))
## displayName quarter gameClock score_diff kickLength made
## <char> <int> <char> <num> <int> <num>
## 1: Mason Crosby 4 00:03:00 0 52 0
## 2: Daniel Carlson 5 00:04:00 0 35 0
## 3: Wil Lutz 4 00:26:00 0 44 1
## 4: Ka'imi Fairbairn 4 00:06:00 0 59 0
## 5: Ka'imi Fairbairn 5 00:03:00 0 37 1
## ---
## 2600: Dustin Hopkins 3 04:04:00 -17 26 1
## 2601: Jason Sanders 2 01:48:00 -18 32 1
## 2602: Greg Zuerlein 2 00:04:00 -14 57 1
## 2603: Ryan Succop 2 08:49:00 14 38 1
## 2604: Rodrigo Blankenship 2 02:41:00 17 24 1
## game_pressure
## <num>
## 1: 1
## 2: 1
## 3: 1
## 4: 1
## 5: 1
## ---
## 2600: 0
## 2601: 0
## 2602: 0
## 2603: 0
## 2604: 0
names(games)
## [1] "gameId" "season" "week" "gameDate"
## [5] "gameTimeEastern" "homeTeamAbbr" "visitorTeamAbbr"
names(plays)
## [1] "gameId" "playId" "playDescription"
## [4] "quarter" "down" "yardsToGo"
## [7] "possessionTeam" "specialTeamsPlayType" "specialTeamsResult"
## [10] "kickerId" "returnerId" "kickBlockerId"
## [13] "yardlineSide" "yardlineNumber" "gameClock"
## [16] "penaltyCodes" "penaltyJerseyNumbers" "penaltyYards"
## [19] "preSnapHomeScore" "preSnapVisitorScore" "passResult"
## [22] "kickLength" "kickReturnYardage" "playResult"
## [25] "absoluteYardlineNumber"
game_results <- plays |>
group_by(gameId) |>
slice_max(playId, n = 1, with_ties = FALSE) |>
ungroup() |>
select(
gameId,
home_score = preSnapHomeScore,
visitor_score = preSnapVisitorScore
)
game_results <- game_results |>
left_join(
games |>
select(
gameId,
season,
week,
homeTeamAbbr,
visitorTeamAbbr
),
by = "gameId"
)
home_games <- game_results |>
transmute(
season,
week,
gameId,
team = homeTeamAbbr,
opponent = visitorTeamAbbr,
team_score = home_score,
opponent_score = visitor_score
)
away_games <- game_results |>
transmute(
season,
week,
gameId,
team = visitorTeamAbbr,
opponent = homeTeamAbbr,
team_score = visitor_score,
opponent_score = home_score
)
team_games <- bind_rows(home_games, away_games) |>
arrange(season, team, week)
team_games <- team_games |>
mutate(
result = case_when(
team_score > opponent_score ~ "W",
team_score < opponent_score ~ "L",
TRUE ~ "T"
)
)
team_games <- team_games |>
group_by(season, team) |>
mutate(
wins_before = lag(cumsum(result == "W"), default = 0),
losses_before = lag(cumsum(result == "L"), default = 0),
ties_before = lag(cumsum(result == "T"), default = 0)
) |>
ungroup()
team_games |>
select(
season,
week,
team,
opponent,
wins_before,
losses_before,
ties_before
)
## # A tibble: 1,528 × 7
## season week team opponent wins_before losses_before ties_before
## <int> <int> <chr> <chr> <int> <int> <int>
## 1 2018 1 ARI WAS 0 0 0
## 2 2018 2 ARI LA 0 1 0
## 3 2018 3 ARI CHI 0 2 0
## 4 2018 4 ARI SEA 0 3 0
## 5 2018 5 ARI SF 0 3 1
## 6 2018 6 ARI MIN 1 3 1
## 7 2018 7 ARI DEN 1 4 1
## 8 2018 8 ARI SF 1 5 1
## 9 2018 10 ARI KC 2 5 1
## 10 2018 11 ARI OAK 2 6 1
## # ℹ 1,518 more rows
team_games <- team_games |>
mutate(
games_played = wins_before + losses_before + ties_before,
win_pct = ifelse(
games_played == 0,
0,
(wins_before + 0.5 * ties_before) / games_played
)
)
afc_teams <- c(
"BAL", "BUF", "CIN", "CLE", "DEN", "HOU", "IND",
"JAX", "KC", "LV", "LAC", "MIA", "NE", "NYJ",
"PIT", "TEN"
)
team_games <- team_games |>
mutate(
conference = ifelse(team %in% afc_teams, "AFC", "NFC")
)
team_games <- team_games |>
group_by(season, week, conference) |>
mutate(
conference_rank = min_rank(desc(win_pct))
) |>
ungroup()
team_games <- team_games |>
mutate(
playoff_spots = case_when(
season <= 2019 ~ 6,
season == 2020 ~ 7
)
)
team_games <- team_games |>
mutate(
playoff_distance = conference_rank - playoff_spots,
playoff_race = case_when(
playoff_distance <= -2 ~ 0.25,
playoff_distance == -1 ~ 0.50,
playoff_distance == 0 ~ 1.00,
playoff_distance == 1 ~ 1.00,
playoff_distance == 2 ~ 0.75,
playoff_distance == 3 ~ 0.50,
playoff_distance >= 4 ~ 0.25
)
)
team_games <- team_games |>
mutate(
playoff_timing = case_when(
week <= 8 ~ 0.25,
week <= 12 ~ 0.50,
week <= 15 ~ 0.75,
week >= 16 ~ 1.00
)
)
team_games <- team_games |>
mutate(
playoff_importance = playoff_race * playoff_timing
)
field_goals <- field_goals |>
left_join(
team_games |>
select(
gameId,
week,
team,
wins_before,
losses_before,
ties_before,
win_pct,
conference_rank,
playoff_race,
playoff_timing,
playoff_importance
),
by = c(
"gameId" = "gameId",
"possessionTeam" = "team"
)
)
field_goals |>
select(
gameId,
displayName,
possessionTeam,
wins_before,
losses_before,
win_pct,
conference_rank,
playoff_importance
)
## gameId displayName possessionTeam wins_before losses_before
## <int> <char> <char> <int> <int>
## 1: 2018090600 Matt Bryant ATL 0 0
## 2: 2018090600 Jake Elliott PHI 0 0
## 3: 2018090600 Matt Bryant ATL 0 0
## 4: 2018090900 Justin Tucker BAL 0 0
## 5: 2018090900 Stephen Hauschka BUF 0 0
## ---
## 2600: 2021010315 Jason Myers SEA 11 3
## 2601: 2021010315 Jason Myers SEA 11 3
## 2602: 2021010315 Tristan Vizcaino SF 6 8
## 2603: 2021010315 Tristan Vizcaino SF 6 8
## 2604: 2021010315 Tristan Vizcaino SF 6 8
## win_pct conference_rank playoff_importance
## <num> <int> <num>
## 1: 0.0000000 1 0.0625
## 2: 0.0000000 1 0.0625
## 3: 0.0000000 1 0.0625
## 4: 0.0000000 1 0.0625
## 5: 0.0000000 1 0.0625
## ---
## 2600: 0.7666667 2 0.2500
## 2601: 0.7666667 2 0.2500
## 2602: 0.4333333 8 1.0000
## 2603: 0.4333333 8 1.0000
## 2604: 0.4333333 8 1.0000
field_goals <- field_goals |>
mutate(
total_pressure = game_pressure * playoff_importance
)
summary(field_goals$total_pressure)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000000 0.009821 0.029167 0.060031 0.068750 1.000000
field_goals |>
select(
displayName,
week,
possessionTeam,
wins_before,
losses_before,
conference_rank,
game_pressure,
playoff_importance,
total_pressure
) |>
arrange(desc(total_pressure))
## displayName week possessionTeam wins_before losses_before
## <char> <int> <char> <int> <int>
## 1: Sam Sloman 17 TEN 8 5
## 2: Matthew McCrane 17 PIT 8 5
## 3: Matthew McCrane 17 PIT 8 5
## 4: Jason Sanders 16 MIA 9 5
## 5: Dustin Hopkins 16 WAS 6 7
## ---
## 2600: Dustin Hopkins 16 WAS 5 8
## 2601: Jason Sanders 17 MIA 10 5
## 2602: Greg Zuerlein 17 DAL 3 11
## 2603: Ryan Succop 17 TB 10 5
## 2604: Rodrigo Blankenship 17 IND 9 5
## conference_rank game_pressure playoff_importance total_pressure
## <int> <num> <num> <num>
## 1: 8 1.0000000 1.00 1.0000000
## 2: 6 0.9666667 1.00 0.9666667
## 3: 6 0.8666667 1.00 0.8666667
## 4: 7 0.8571429 1.00 0.8571429
## 5: 7 0.8047619 1.00 0.8047619
## ---
## 2600: 10 0.0000000 0.50 0.0000000
## 2601: 5 0.0000000 0.25 0.0000000
## 2602: 15 0.0000000 0.25 0.0000000
## 2603: 4 0.0000000 0.25 0.0000000
## 2604: 7 0.0000000 1.00 0.0000000
names(team_games)
## [1] "season" "week" "gameId"
## [4] "team" "opponent" "team_score"
## [7] "opponent_score" "result" "wins_before"
## [10] "losses_before" "ties_before" "games_played"
## [13] "win_pct" "conference" "conference_rank"
## [16] "playoff_spots" "playoff_distance" "playoff_race"
## [19] "playoff_timing" "playoff_importance"
names(field_goals)
## [1] "gameId" "playId" "playDescription"
## [4] "quarter" "down" "yardsToGo"
## [7] "possessionTeam" "specialTeamsPlayType" "specialTeamsResult"
## [10] "kickerId" "returnerId" "kickBlockerId"
## [13] "yardlineSide" "yardlineNumber" "gameClock"
## [16] "penaltyCodes" "penaltyJerseyNumbers" "penaltyYards"
## [19] "preSnapHomeScore" "preSnapVisitorScore" "passResult"
## [22] "kickLength" "kickReturnYardage" "playResult"
## [25] "absoluteYardlineNumber" "displayName" "made"
## [28] "homeTeamAbbr" "visitorTeamAbbr" "score_diff"
## [31] "time_remaining" "total_time_remaining" "score_pressure"
## [34] "time_pressure" "game_pressure" "expected_make"
## [37] "performance" "pressure_performance" "week"
## [40] "wins_before" "losses_before" "ties_before"
## [43] "win_pct" "conference_rank" "playoff_race"
## [46] "playoff_timing" "playoff_importance" "total_pressure"
summary(field_goals$playoff_importance)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.0625 0.0625 0.1250 0.2027 0.2500 1.0000
summary(field_goals$total_pressure)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000000 0.009821 0.029167 0.060031 0.068750 1.000000
field_goals |>
select(
displayName,
week,
possessionTeam,
wins_before,
losses_before,
conference_rank,
playoff_importance,
game_pressure,
total_pressure
) |>
arrange(desc(total_pressure))
## displayName week possessionTeam wins_before losses_before
## <char> <int> <char> <int> <int>
## 1: Sam Sloman 17 TEN 8 5
## 2: Matthew McCrane 17 PIT 8 5
## 3: Matthew McCrane 17 PIT 8 5
## 4: Jason Sanders 16 MIA 9 5
## 5: Dustin Hopkins 16 WAS 6 7
## ---
## 2600: Dustin Hopkins 16 WAS 5 8
## 2601: Jason Sanders 17 MIA 10 5
## 2602: Greg Zuerlein 17 DAL 3 11
## 2603: Ryan Succop 17 TB 10 5
## 2604: Rodrigo Blankenship 17 IND 9 5
## conference_rank playoff_importance game_pressure total_pressure
## <int> <num> <num> <num>
## 1: 8 1.00 1.0000000 1.0000000
## 2: 6 1.00 0.9666667 0.9666667
## 3: 6 1.00 0.8666667 0.8666667
## 4: 7 1.00 0.8571429 0.8571429
## 5: 7 1.00 0.8047619 0.8047619
## ---
## 2600: 10 0.50 0.0000000 0.0000000
## 2601: 5 0.25 0.0000000 0.0000000
## 2602: 15 0.25 0.0000000 0.0000000
## 2603: 4 0.25 0.0000000 0.0000000
## 2604: 7 1.00 0.0000000 0.0000000
field_goals <- field_goals |>
mutate(
total_pressure = game_pressure * playoff_importance
)
field_goals <- field_goals |>
mutate(
pressure_performance = performance * total_pressure
)
kicker_pressure <- field_goals |>
group_by(displayName) |>
summarise(
attempts = n(),
made = sum(made),
fg_percentage = mean(made),
expected_percentage = mean(expected_make),
above_expected = mean(performance),
pressure_performance = mean(pressure_performance),
high_pressure_attempts = sum(total_pressure >= 0.50),
.groups = "drop"
) |>
filter(attempts >= 10) |>
arrange(desc(pressure_performance))
kicker_pressure
## # A tibble: 48 × 8
## displayName attempts made fg_percentage expected_percentage above_expected
## <chr> <int> <dbl> <dbl> <dbl> <dbl>
## 1 Matthew McCr… 11 8 8 0.830 -0.103
## 2 Justin Tucker 92 87 87 0.848 0.0976
## 3 Matt Bryant 34 28 28 0.808 0.0155
## 4 Dustin Hopki… 85 73 73 0.844 0.0151
## 5 Nick Folk 41 38 38 0.874 0.0532
## 6 Jason Sanders 83 71 71 0.840 0.0157
## 7 Harrison But… 84 77 77 0.877 0.0397
## 8 Jake Elliott 66 54 54 0.851 -0.0325
## 9 Josh Lambo 57 54 54 0.851 0.0961
## 10 Chandler Cat… 19 15 15 0.873 -0.0833
## # ℹ 38 more rows
## # ℹ 2 more variables: pressure_performance <dbl>, high_pressure_attempts <int>
kicker_pressure |>
slice_head(n = 10)
## # A tibble: 10 × 8
## displayName attempts made fg_percentage expected_percentage above_expected
## <chr> <int> <dbl> <dbl> <dbl> <dbl>
## 1 Matthew McCr… 11 8 8 0.830 -0.103
## 2 Justin Tucker 92 87 87 0.848 0.0976
## 3 Matt Bryant 34 28 28 0.808 0.0155
## 4 Dustin Hopki… 85 73 73 0.844 0.0151
## 5 Nick Folk 41 38 38 0.874 0.0532
## 6 Jason Sanders 83 71 71 0.840 0.0157
## 7 Harrison But… 84 77 77 0.877 0.0397
## 8 Jake Elliott 66 54 54 0.851 -0.0325
## 9 Josh Lambo 57 54 54 0.851 0.0961
## 10 Chandler Cat… 19 15 15 0.873 -0.0833
## # ℹ 2 more variables: pressure_performance <dbl>, high_pressure_attempts <int>
kicker_pressure |>
select(
displayName,
attempts,
made,
fg_percentage,
expected_percentage,
above_expected,
pressure_performance,
high_pressure_attempts
) |>
arrange(desc(pressure_performance)) |>
slice_head(n = 15)
## # A tibble: 15 × 8
## displayName attempts made fg_percentage expected_percentage above_expected
## <chr> <int> <dbl> <dbl> <dbl> <dbl>
## 1 Matthew McCr… 11 8 8 0.830 -0.103
## 2 Justin Tucker 92 87 87 0.848 0.0976
## 3 Matt Bryant 34 28 28 0.808 0.0155
## 4 Dustin Hopki… 85 73 73 0.844 0.0151
## 5 Nick Folk 41 38 38 0.874 0.0532
## 6 Jason Sanders 83 71 71 0.840 0.0157
## 7 Harrison But… 84 77 77 0.877 0.0397
## 8 Jake Elliott 66 54 54 0.851 -0.0325
## 9 Josh Lambo 57 54 54 0.851 0.0961
## 10 Chandler Cat… 19 15 15 0.873 -0.0833
## 11 Austin Seibe… 34 27 27 0.854 -0.0598
## 12 Mike Nugent 19 17 17 0.901 -0.00650
## 13 Mason Crosby 71 63 63 0.846 0.0411
## 14 Wil Lutz 85 77 77 0.864 0.0417
## 15 Graham Gano 45 42 42 0.833 0.100
## # ℹ 2 more variables: pressure_performance <dbl>, high_pressure_attempts <int>
field_goals |>
summarise(
total_kicks = n(),
high_pressure_kicks = sum(total_pressure >= 0.50, na.rm = TRUE),
average_pressure = mean(total_pressure, na.rm = TRUE),
max_pressure = max(total_pressure, na.rm = TRUE)
)
## total_kicks high_pressure_kicks average_pressure max_pressure
## 1 2604 24 0.06003073 1
field_goals |>
select(
displayName,
week,
possessionTeam,
kickLength,
made,
expected_make,
game_pressure,
playoff_importance,
total_pressure
) |>
arrange(desc(total_pressure)) |>
slice_head(n = 20)
## displayName week possessionTeam kickLength made expected_make
## <char> <int> <char> <int> <num> <num>
## 1: Sam Sloman 17 TEN 37 1 0.9099934
## 2: Matthew McCrane 17 PIT 35 1 0.9272293
## 3: Matthew McCrane 17 PIT 47 1 0.7607671
## 4: Jason Sanders 16 MIA 44 1 0.8181538
## 5: Dustin Hopkins 16 WAS 46 1 0.7811800
## 6: Jake Elliott 17 PHI 50 1 0.6920861
## 7: Greg Zuerlein 16 LA 52 1 0.6407354
## 8: Jason Sanders 16 MIA 22 1 0.9828516
## 9: Matt Bryant 17 ATL 37 1 0.9099934
## 10: Daniel Carlson 16 LV 22 1 0.9828516
## 11: Tristan Vizcaino 17 SF 33 1 0.9413772
## 12: Dustin Hopkins 16 WAS 40 1 0.8772406
## 13: Younghoe Koo 16 ATL 39 0 0.8891632
## 14: Cairo Santos 15 CHI 42 1 0.8500792
## 15: Matt Gay 16 TB 41 1 0.8642314
## 16: Dustin Hopkins 15 WAS 36 1 0.9190286
## 17: Daniel Carlson 16 LV 20 1 0.9863448
## 18: Ryan Succop 16 TEN 33 1 0.9413772
## 19: Daniel Carlson 15 LV 23 1 0.9807892
## 20: Justin Tucker 16 BAL 56 1 0.5289405
## displayName week possessionTeam kickLength made expected_make
## <char> <int> <char> <int> <num> <num>
## game_pressure playoff_importance total_pressure
## <num> <num> <num>
## 1: 1.0000000 1.0000 1.0000000
## 2: 0.9666667 1.0000 0.9666667
## 3: 0.8666667 1.0000 0.8666667
## 4: 0.8571429 1.0000 0.8571429
## 5: 0.8047619 1.0000 0.8047619
## 6: 0.7666667 1.0000 0.7666667
## 7: 0.7595238 1.0000 0.7595238
## 8: 0.7333333 1.0000 0.7333333
## 9: 0.9285714 0.7500 0.6964286
## 10: 0.9285714 0.7500 0.6964286
## 11: 0.6500000 1.0000 0.6500000
## 12: 0.6190476 1.0000 0.6190476
## 13: 0.7857143 0.7500 0.5892857
## 14: 0.7726190 0.7500 0.5794643
## 15: 0.5761905 1.0000 0.5761905
## 16: 1.0000000 0.5625 0.5625000
## 17: 0.7166667 0.7500 0.5375000
## 18: 0.5357143 1.0000 0.5357143
## 19: 0.9500000 0.5625 0.5343750
## 20: 0.5238095 1.0000 0.5238095
## game_pressure playoff_importance total_pressure
## <num> <num> <num>
kicker_pressure |>
select(
displayName,
attempts,
made,
fg_percentage,
expected_percentage,
above_expected,
pressure_performance,
high_pressure_attempts
) |>
arrange(desc(pressure_performance)) |>
slice_head(n = 15)
## # A tibble: 15 × 8
## displayName attempts made fg_percentage expected_percentage above_expected
## <chr> <int> <dbl> <dbl> <dbl> <dbl>
## 1 Matthew McCr… 11 8 8 0.830 -0.103
## 2 Justin Tucker 92 87 87 0.848 0.0976
## 3 Matt Bryant 34 28 28 0.808 0.0155
## 4 Dustin Hopki… 85 73 73 0.844 0.0151
## 5 Nick Folk 41 38 38 0.874 0.0532
## 6 Jason Sanders 83 71 71 0.840 0.0157
## 7 Harrison But… 84 77 77 0.877 0.0397
## 8 Jake Elliott 66 54 54 0.851 -0.0325
## 9 Josh Lambo 57 54 54 0.851 0.0961
## 10 Chandler Cat… 19 15 15 0.873 -0.0833
## 11 Austin Seibe… 34 27 27 0.854 -0.0598
## 12 Mike Nugent 19 17 17 0.901 -0.00650
## 13 Mason Crosby 71 63 63 0.846 0.0411
## 14 Wil Lutz 85 77 77 0.864 0.0417
## 15 Graham Gano 45 42 42 0.833 0.100
## # ℹ 2 more variables: pressure_performance <dbl>, high_pressure_attempts <int>
kicker_pressure_final <- field_goals |>
group_by(displayName) |>
summarise(
attempts = n(),
made = sum(made),
fg_percentage = mean(made),
expected_percentage = mean(expected_make),
above_expected = mean(performance),
pressure_weighted_performance =
sum(performance * total_pressure, na.rm = TRUE) /
sum(total_pressure, na.rm = TRUE),
high_pressure_attempts =
sum(total_pressure >= 0.50, na.rm = TRUE),
total_pressure = sum(total_pressure, na.rm = TRUE),
.groups = "drop"
) |>
filter(attempts >= 10) |>
arrange(desc(pressure_weighted_performance))
kicker_pressure_final |>
select(
displayName,
attempts,
made,
fg_percentage,
expected_percentage,
above_expected,
pressure_weighted_performance,
high_pressure_attempts
) |>
slice_head(n = 15)
## # A tibble: 15 × 8
## displayName attempts made fg_percentage expected_percentage above_expected
## <chr> <int> <dbl> <dbl> <dbl> <dbl>
## 1 Matt Bryant 34 28 28 0.808 0.0155
## 2 Matthew McCr… 11 8 8 0.830 -0.103
## 3 Dustin Hopki… 85 73 73 0.844 0.0151
## 4 Nick Folk 41 38 38 0.874 0.0532
## 5 Justin Tucker 92 87 87 0.848 0.0976
## 6 Harrison But… 84 77 77 0.877 0.0397
## 7 Mike Nugent 19 17 17 0.901 -0.00650
## 8 Josh Lambo 57 54 54 0.851 0.0961
## 9 Jason Sanders 83 71 71 0.840 0.0157
## 10 Mason Crosby 71 63 63 0.846 0.0411
## 11 Graham Gano 45 42 42 0.833 0.100
## 12 Wil Lutz 85 77 77 0.864 0.0417
## 13 Austin Seibe… 34 27 27 0.854 -0.0598
## 14 Sergio Casti… 12 8 8 0.837 -0.170
## 15 Tyler Bass 28 22 22 0.817 -0.0314
## # ℹ 2 more variables: pressure_weighted_performance <dbl>,
## # high_pressure_attempts <int>
kicker_pressure_final |>
arrange(desc(high_pressure_attempts)) |>
select(
displayName,
attempts,
high_pressure_attempts,
pressure_weighted_performance
)
## # A tibble: 48 × 4
## displayName attempts high_pressure_attempts pressure_weighted_performance
## <chr> <int> <int> <dbl>
## 1 Dustin Hopkins 85 3 0.123
## 2 Daniel Carlson 77 3 -0.0915
## 3 Matthew McCrane 11 2 0.138
## 4 Jason Sanders 83 2 0.0841
## 5 Cairo Santos 50 2 0.0210
## 6 Matt Bryant 34 1 0.143
## 7 Justin Tucker 92 1 0.109
## 8 Harrison Butker 84 1 0.0913
## 9 Jake Elliott 66 1 0.0453
## 10 Younghoe Koo 57 1 -0.0330
## # ℹ 38 more rows
field_goals <- field_goals |>
select(-any_of("season")) |>
left_join(
games |> select(gameId, season),
by = "gameId"
)
kicker_season <- field_goals |>
group_by(season, displayName) |>
summarise(
attempts = n(),
made = sum(made),
accuracy = mean(made) * 100,
above_expected = mean(performance) * 100,
pressure_weighted_performance = ifelse(
sum(total_pressure, na.rm = TRUE) > 0,
sum(performance * total_pressure, na.rm = TRUE) /
sum(total_pressure, na.rm = TRUE) * 100,
NA_real_
),
.groups = "drop"
)
library(plotly)
library(dplyr)
plot_data <- kicker_season |>
filter(attempts >= 10) |>
mutate(
season = factor(season),
hover_text = paste0(
"Kicker: ", displayName,
"<br>Season: ", season,
"<br>Attempts: ", attempts,
"<br>Made: ", made,
"<br>Accuracy: ", round(accuracy, 1), "%",
"<br>Above expected: ", round(above_expected, 2),
" percentage points",
"<br>Pressure performance: ",
round(pressure_weighted_performance, 2)
)
)
plot_ly(
data = plot_data,
x = ~pressure_weighted_performance,
y = ~reorder(displayName, pressure_weighted_performance),
color = ~season,
text = ~hover_text,
hoverinfo = "text",
type = "bar",
orientation = "h"
) |>
layout(
barmode = "group",
title = "NFL Kicker Pressure Performance by Season",
xaxis = list(title = "Pressure-Weighted Performance"),
yaxis = list(title = "Kicker", automargin = TRUE),
legend = list(title = list(text = "Season")),
height = 900
)
## Warning: Specifying width/height in layout() is now deprecated.
## Please specify in ggplotly() or plot_ly()
library(ggplot2)
library(dplyr)
top15_season <- kicker_season |>
filter(attempts >= 10) |>
group_by(season) |>
slice_max(
order_by = pressure_weighted_performance,
n = 15,
with_ties = FALSE
) |>
ungroup()
ggplot(
top15_season,
aes(
x = pressure_weighted_performance,
y = reorder(displayName, pressure_weighted_performance),
fill = pressure_weighted_performance
)
) +
geom_col() +
facet_wrap(~ season, scales = "free_y") +
scale_fill_gradient2(
low = "darkred",
mid = "white",
high = "darkgreen",
midpoint = 0,
limits = c(-0.20, 0.20),
oob = scales::squish,
name = "Pressure\nPerformance"
) +
labs(
title = "Top 15 NFL Kickers Under Pressure by Season",
x = "Pressure-Weighted Performance",
y = "Kicker"
) +
theme_minimal() +
theme(
plot.title = element_text(hjust = 0.5, face = "bold"),
axis.text.y = element_text(size = 7),
legend.position = "bottom"
)
Here are some takeaway conclusions from my project.