Created
January 6, 2021 18:24
-
-
Save tejseth/752a72fbcaf5b701a020963ae1e9a272 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| library(tidyverse) | |
| library(gganimate) | |
| library(mvtnorm) | |
| team_ <- "DET" | |
| qb_ <- "M.Stafford" | |
| min_offense_play_result <- 25 | |
| stafford_play <- df_plays %>% | |
| dplyr::filter(possessionTeam == team_, | |
| stringr::str_detect(playDescription, qb_), | |
| offensePlayResult > min_offense_play_result) %>% | |
| dplyr::arrange(-offensePlayResult) %>% | |
| dplyr::select(gameId, playId, possessionTeam, playDescription, absoluteYardlineNumber, yardsToGo) %>% | |
| dplyr::slice(7) | |
| stafford_game <- df_games %>% | |
| dplyr::filter(gameId == stafford_play$gameId) | |
| stafford_track <- df_tracking %>% | |
| dplyr::filter(gameId == stafford_game$gameId, playId == stafford_play$playId) | |
| stafford_direction <- stafford_track %>% head(1) %>% dplyr::pull(playDirection) | |
| stafford_track <- stafford_track %>% | |
| dplyr::select(x, y, s, dir, event, displayName, jerseyNumber, frameId, team) | |
| stafford_game <- as_factor(stafford_game) | |
| typeof(stafford_game) | |
| stafford_track <- stafford_track %>% | |
| dplyr::mutate( | |
| dir_rad = dir * pi / 180, | |
| v_x = sin(dir_rad) * s, | |
| v_y = cos(dir_rad) * s, | |
| v_theta = atan(v_y / v_x), | |
| v_theta = ifelse(is.nan(v_theta), 0, v_theta), | |
| team_name = case_when( | |
| team == "home" ~ "DET", | |
| team == "away" ~ "GB", | |
| TRUE ~ team, | |
| ) | |
| ) %>% | |
| dplyr::select(frameId, event, team = team_name, jerseyNumber, displayName, x, y, s, v_theta, v_x, v_y) | |
| plot_field <- function(field_color="#ffffff", line_color = "#212529", number_color = "#adb5bd") { | |
| field_height <- 160/3 | |
| field_width <- 120 | |
| field <- ggplot() + | |
| theme_minimal() + | |
| theme( | |
| plot.title = element_text(size = 13, hjust = 0.5), | |
| plot.subtitle = element_text(hjust = 1), | |
| legend.position = "bottom", | |
| legend.title.align = 1, | |
| panel.grid.major = element_blank(), | |
| panel.grid.minor = element_blank(), | |
| axis.title = element_blank(), | |
| axis.ticks = element_blank(), | |
| axis.text = element_blank(), | |
| axis.line = element_blank(), | |
| panel.background = element_rect(fill = field_color, color = "white"), | |
| panel.border = element_blank(), | |
| aspect.ratio = field_height/field_width | |
| ) + | |
| # major lines | |
| annotate( | |
| "segment", | |
| x = c(0, 0, 0,field_width, seq(10, 110, by=5)), | |
| xend = c(field_width,field_width, 0, field_width, seq(10, 110, by=5)), | |
| y = c(0, field_height, 0, 0, rep(0, 21)), | |
| yend = c(0, field_height, field_height, field_height, rep(field_height, 21)), | |
| colour = line_color | |
| ) + | |
| # hashmarks | |
| annotate( | |
| "segment", | |
| x = rep(seq(10, 110, by=1), 4), | |
| xend = rep(seq(10, 110, by=1), 4), | |
| y = c(rep(0, 101), rep(field_height-1, 101), rep(160/6 + 18.5/6, 101), rep(160/6 - 18.5/6, 101)), | |
| yend = c(rep(1, 101), rep(field_height, 101), rep(160/6 + 18.5/6 + 1, 101), rep(160/6 - 18.5/6 - 1, 101)), | |
| colour = line_color | |
| ) + | |
| # yard numbers | |
| annotate( | |
| "text", | |
| x = seq(20, 100, by = 10), | |
| y = rep(12, 9), | |
| label = c(seq(10, 50, by = 10), rev(seq(10, 40, by = 10))), | |
| size = 10, | |
| colour = number_color, | |
| ) + | |
| # yard numbers upside down | |
| annotate( | |
| "text", | |
| x = seq(20, 100, by = 10), | |
| y = rep(field_height-12, 9), | |
| label = c(seq(10, 50, by = 10), rev(seq(10, 40, by = 10))), | |
| angle = 180, | |
| size = 10, | |
| colour = number_color, | |
| ) | |
| return(field) | |
| } | |
| fetch_team_colors <- function(team_colors_=NULL, h_team_, a_team_, diverge_=FALSE) { | |
| colors_url <- "https://raw.githubusercontent.com/asonty/ngs_highlights/master/utils/data/nfl_team_colors.tsv" | |
| if (is.null(team_colors_)) { | |
| team_colors_ <- suppressMessages(readr::read_tsv(colors_url)) | |
| } | |
| h_team_color1 <- team_colors_ %>% filter(teams == h_team_) %>% pull(color1) | |
| h_team_color2 <- team_colors_ %>% filter(teams == h_team_) %>% pull(color2) | |
| a_team_color1 <- team_colors_ %>% filter(teams == a_team_) %>% pull(color1) | |
| a_team_color2 <- team_colors_ %>% filter(teams == a_team_) %>% pull(color2) | |
| if (diverge_ == TRUE) { | |
| h_team_color1_family <- team_colors_ %>% filter(teams == h_team_) %>% select(color1_family) %>% pull() | |
| a_team_color1_family <- team_colors_ %>% filter(teams == a_team_) %>% select(color1_family) %>% pull() | |
| if (h_team_color1_family == a_team_color1_family) { | |
| a_team_color1 <- team_colors_ %>% filter(teams == a_team_) %>% select(color2) %>% pull() | |
| a_team_color2 <- team_colors_ %>% filter(teams == a_team_) %>% select(color1) %>% pull() | |
| } | |
| } | |
| df_colors <- tibble( | |
| home_1 = h_team_color1, home_2 = h_team_color2, away_1 = a_team_color1, away_2 = a_team_color2 | |
| ) | |
| return(df_colors) | |
| } | |
| if (stafford_direction == "left") { | |
| line_of_scrimmage = stafford_play$absoluteYardlineNumber | |
| to_go_line = line_of_scrimmage - stafford_play$yardsToGo | |
| } else { | |
| line_of_scrimmage = 100 - stafford_play$absoluteYardlineNumber | |
| to_go_line = line_of_scrimmage + stafford_play$yardsToGo | |
| } | |
| df_colors <- fetch_team_colors(h_team_ = "DET", a_team_ = "GB", diverge_ = T) | |
| play_frames <- plot_field() + | |
| # line of scrimmage | |
| annotate( | |
| "segment", | |
| x = line_of_scrimmage, xend = line_of_scrimmage, y = 0, yend = 160/3, | |
| colour = "#0d41e1", size = 1.5 | |
| ) + | |
| # 1st down marker | |
| annotate( | |
| "segment", | |
| x = to_go_line, xend = to_go_line, y = 0, yend = 160/3, | |
| colour = "#f9c80e", size = 1.5 | |
| ) + | |
| # away team velocities | |
| geom_segment( | |
| data = stafford_track %>% dplyr::filter(team == "GB"), | |
| mapping = aes(x = x, y = y, xend = x + v_x, yend = y + v_y), | |
| colour = df_colors$away_1, size = 1, arrow = arrow(length = unit(0.01, "npc")) | |
| ) + | |
| # home team velocities | |
| geom_segment( | |
| data = stafford_track %>% dplyr::filter(team == "DET"), | |
| mapping = aes(x = x, y = y, xend = x + v_x, yend = y + v_y), | |
| colour = df_colors$home_2, size = 1, arrow = arrow(length = unit(0.01, "npc")) | |
| ) + | |
| # away team locs and jersey numbers | |
| geom_point( | |
| data = stafford_track %>% dplyr::filter(team == "GB"), | |
| mapping = aes(x = x, y = y), | |
| fill = "#f8f9fa", colour = df_colors$away_2, | |
| shape = 21, alpha = 1, size = 8, stroke = 1.5 | |
| ) + | |
| geom_text( | |
| data = stafford_track %>% dplyr::filter(team == "GB"), | |
| mapping = aes(x = x, y = y, label = jerseyNumber), | |
| colour = df_colors$away_1, size = 4.5 | |
| ) + | |
| # home team locs and jersey numbers | |
| geom_point( | |
| data = stafford_track %>% dplyr::filter(team == "DET"), | |
| mapping = aes(x = x, y = y), | |
| fill = df_colors$home_1, colour = df_colors$home_2, | |
| shape = 21, alpha = 1, size = 8, stroke = 1.5 | |
| ) + | |
| geom_text( | |
| data = stafford_track %>% dplyr::filter(team == "DET"), | |
| mapping = aes(x = x, y = y, label = jerseyNumber), | |
| colour = df_colors$home_2, size = 4.5, | |
| ) + | |
| # ball | |
| geom_point( | |
| data = stafford_track %>% dplyr::filter(team == "football"), | |
| mapping = aes(x = x, y = y), | |
| fill = "#935e38", colour = "#d9d9d9", | |
| shape = 21, alpha = 1, size = 4, stroke = 1 | |
| ) + | |
| # title | |
| labs(title = stafford_play$playDescription) + | |
| # animation stuff | |
| transition_time(frameId) + | |
| ease_aes('linear') + | |
| NULL | |
| play_length <- length(unique(stafford_track$frameId)) | |
| play_anim <- animate( | |
| play_frames, | |
| fps = 10, | |
| nframe = play_length, | |
| width = 800, | |
| height = 400, | |
| end_pause = 0 | |
| ) | |
| play_anim | |
| ############## implementing a field control model ############## | |
| # 1. compute player's distance from ball | |
| compute_distance_from_ball <- function(tracking_data) { | |
| tracking_data <- tracking_data %>% | |
| dplyr::inner_join( | |
| tracking_data %>% | |
| dplyr::filter(team == "football") %>% | |
| dplyr::select(frameId, ball_x = x, ball_y = y), | |
| by = "frameId" | |
| ) %>% | |
| dplyr::mutate( | |
| distance_from_ball = sqrt((x-ball_x)^2 + (y-ball_y)^2) | |
| ) %>% | |
| dplyr::select(-ball_x, -ball_y) | |
| return(tracking_data) | |
| } | |
| stafford_track <- stafford_track %>% compute_distance_from_ball() | |
| # 2. compute each player's speed ratio | |
| # here we're using a max speed of 13 yds/s, | |
| # which about lines up with the max speeds seen in | |
| # the Next Gen Stats Fastest Ballcarrier tables | |
| compute_speed_ratio <- function(tracking_data, s_max = 13.00) { | |
| tracking_data <- tracking_data %>% | |
| dplyr::mutate( | |
| s_ratio = s / s_max | |
| ) | |
| return(tracking_data) | |
| } | |
| stafford_track <- stafford_track %>% compute_speed_ratio() | |
| # 3. compute each player's next location | |
| compute_next_loc <- function(tracking_data, delta_t = 0.50) { | |
| tracking_data <- tracking_data %>% | |
| dplyr::mutate( | |
| x_next = x + v_x * delta_t, | |
| y_next = y + v_y * delta_t | |
| ) | |
| return(tracking_data) | |
| } | |
| stafford_track <- stafford_track %>% compute_next_loc() | |
| # 4. compute each player's radius of influence for a given frame | |
| # here we're using a model that approximates the plot shown in | |
| # the appendix of Wide Open Spaces. this original function was | |
| # found by Will Thomson. the modification that I'll make is that | |
| # I'll add a few parameters to the equation, so we can alter the | |
| # min/max radius of influence a player can have, as well as the | |
| # rate at which that radius changes (based on their proximity | |
| # to the ball) | |
| compute_radius_of_influence <- function(tracking_data, | |
| min_radius = 4.00, | |
| max_radius = 10.00, | |
| max_distance_from_ball = 20.00) { | |
| tracking_data <- tracking_data %>% | |
| dplyr::mutate( | |
| radius_of_influence = min_radius + distance_from_ball^3 * (max_radius-min_radius) / max_distance_from_ball, | |
| radius_of_influence = dplyr::case_when( | |
| radius_of_influence > max_radius ~ max_radius, | |
| TRUE ~ radius_of_influence | |
| ) | |
| ) | |
| return(tracking_data) | |
| } | |
| stafford_track <- stafford_track %>% compute_radius_of_influence() | |
| compute_rotation_matrix <- function(v_theta) { | |
| R <- matrix( | |
| c(cos(v_theta), -sin(v_theta), | |
| sin(v_theta), cos(v_theta)), | |
| nrow = 2, | |
| byrow = TRUE | |
| ) | |
| return(R) | |
| } | |
| compute_scaling_matrix <- function(radius_of_influence, s_ratio) { | |
| S <- matrix( | |
| c(radius_of_influence * (1 + s_ratio), 0, | |
| 0, radius_of_influence * (1 - s_ratio)), | |
| nrow = 2, | |
| byrow = TRUE | |
| ) | |
| return(S) | |
| } | |
| compute_covariance_matrix <- function(v_theta, radius_of_influence, s_ratio) { | |
| R <- compute_rotation_matrix(v_theta) | |
| S <- compute_scaling_matrix(radius_of_influence, s_ratio) | |
| Sigma <- R %*% S %*% S %*% solve(R) | |
| return(Sigma) | |
| } | |
| # note that this is meant operate on just 1 row of the tracking dataset | |
| compute_player_zoi <- function(player_frame_tracking_data, field_grid = NULL) { | |
| if(is.null(field_grid)) { | |
| field_grid <- expand_grid( | |
| x = seq(0, 120, length.out = 120), | |
| y = seq(0, 160/3, length.out = 160/3) | |
| ) | |
| } | |
| frameId_ <- player_frame_tracking_data %>% pull(frameId) | |
| displayName_ <- player_frame_tracking_data %>% pull(displayName) | |
| jerseyNumber_ <- player_frame_tracking_data %>% pull(jerseyNumber) | |
| team_ <- player_frame_tracking_data %>% pull(team) | |
| zoi_center_x_ <- player_frame_tracking_data %>% pull(x_next) | |
| zoi_center_y_ <- player_frame_tracking_data %>% pull(y_next) | |
| v_theta_ <- player_frame_tracking_data %>% pull(v_theta) | |
| radius_of_influence_ <- player_frame_tracking_data %>% pull(radius_of_influence) | |
| s_ratio_ <- player_frame_tracking_data %>% pull(s_ratio) | |
| mu <- c(zoi_center_x_, zoi_center_y_) | |
| Sigma <- compute_covariance_matrix(v_theta_, radius_of_influence_, s_ratio_) | |
| player_zoi <- field_grid %>% | |
| dplyr::mutate( | |
| influence = mvtnorm::dmvnorm(x = field_grid, mean = mu, sigma = Sigma), | |
| influence = influence / max(influence), | |
| frameId = frameId_, | |
| displayName = displayName_, | |
| jerseyNumber = jerseyNumber_, | |
| team = team_ | |
| ) | |
| return(player_zoi) | |
| } | |
| compute_team_frame_control <- function(frame_tracking_data, home_team) { | |
| team_frame_control <- frame_tracking_data %>% | |
| dplyr::filter(team != "football") %>% | |
| dplyr::group_split(displayName) %>% | |
| purrr::map_dfr(., compute_player_zoi) %>% | |
| dplyr::mutate( | |
| influence = dplyr::case_when( | |
| team == home_team ~ -1 * influence, | |
| TRUE ~ influence | |
| ) | |
| ) %>% | |
| dplyr::group_by(frameId, x, y) %>% | |
| dplyr::summarise(control = sum(influence), .groups = "keep") %>% | |
| dplyr::mutate(control = 1 / (1 + exp(control))) | |
| return(team_frame_control) | |
| } | |
| df_control <- stafford_track %>% | |
| dplyr::filter(team != "football") %>% | |
| dplyr::group_split(frameId) %>% | |
| purrr::map_dfr(., compute_team_frame_control, "DET") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment