Does the second half kickoff have an impact on who wins an NFL game?

We’ll be looking at the probability the team that receives the kickoff after halftime wins an NFL game. We’ll be accounting for who is winning at the half.

The data is the last 10 NFL season results, collected from the nflfastR package and the load_pbp() function.

pbp <- load_pbp(2013:2024)

Cleaning and getting the results

The next two code chunks will simplify the data from load_pbp() to show who was winning at halftime, who receives the kickoff to start the second half of the game, and who won the game (all home/away/tie, if relevant) for each of the 2,991 games.

half_time_df <- 
  pbp |> 
  filter(qtr == 3) |> 
  # Getting the first play of the second half for each game
  slice(.by = game_id, 1) |> 
  # Selecting the relevant columns
  select(game_id, season, week, home_team, away_team, play_type, posteam, defteam, home_score, away_score, total_home_score, total_away_score) |> 
  
  mutate(
    # Determining if the home team is on off or def to start the 2nd half
    start_half_side = if_else(home_team == posteam, "offense", "defense"),
    # Determining if the home team is winning, losing, or tied at halftime
    start_half_status = case_when(
      total_home_score >  total_away_score ~ "winning",
      total_home_score <  total_away_score ~ "losing",
      total_home_score == total_away_score ~ "tie"
    ),
    # Determining if the home team won
    game_result = case_when(
      home_score >  away_score ~ "won",
      home_score <  away_score ~ "lost",
      home_score == away_score ~ "tie"
    ),
    half_score_diff = total_home_score - total_away_score,
    end_score_diff = home_score - away_score
  )

# Displaying the relevant columns
half_time_df |> 
  dplyr::select(
    season, week,
    home_team,
    away_team,
    start_half_side,
    start_half_status,
    half_score_diff,
    game_result,
    end_score_diff
  )
## # A tibble: 3,159 × 9
##    season  week home_team away_team start_half_side start_half_status
##     <int> <int> <chr>     <chr>     <chr>           <chr>            
##  1   2013     1 LA        ARI       defense         tie              
##  2   2013     1 NO        ATL       offense         winning          
##  3   2013     1 DEN       BAL       offense         losing           
##  4   2013     1 CHI       CIN       defense         losing           
##  5   2013     1 SF        GB        offense         tie              
##  6   2013     1 LAC       HOU       offense         winning          
##  7   2013     1 JAX       KC        defense         losing           
##  8   2013     1 CLE       MIA       defense         winning          
##  9   2013     1 DET       MIN       defense         losing           
## 10   2013     1 BUF       NE        offense         losing           
## # ℹ 3,149 more rows
## # ℹ 3 more variables: half_score_diff <dbl>, game_result <chr>,
## #   end_score_diff <int>

Next, we calculate the proportion of games won by the home team for each 6 category combinations:

win_prop_all <- 
  half_time_df |> 
  drop_na() |> 
  # Counting how many games for each start half winner, start half team, and winner combo
  count(
    start_half_side, start_half_status, game_result,
    name = "counts"
  ) |> 
  # Calculating the conditional prop of winner for start_half_winner and start_half_team
  mutate(
    .by = c(start_half_side, start_half_status),
    games = sum(counts),
    prop = counts/sum(counts)
  ) |> 
  filter(game_result == "won") |> 
  arrange(start_half_status) |> 
  select(start_half_status, start_half_side, games, prop)

# Temporarily renaming the columns to display in a graphic table
win_prop_all |> 
  mutate(prop = paste0(round(prop, 3)*100,"%")) |> 
  rename(
    `Win Percentage` = prop,
    `Halftime Standing` = start_half_status,
    `Start Half` = start_half_side,
    Games = games
  ) |> 
  gt::gt()
Halftime Standing Start Half Games Win Percentage
losing defense 650 24.8%
losing offense 671 27.4%
tie defense 114 52.6%
tie offense 113 55.8%
winning defense 850 76.6%
winning offense 758 83.5%

From the table above, when the home team is losing or the game is tied, there is about a 3% higher chance of winning the game when the home team starts the second half on offense. When the home team is already winning, the win percentage increases by almost 7% compared to if they start the half on defense!

Data Visuals

Bar Chart

Let’s start by showing a pairwise bar chart with halftime standing on the x-axis, win percentage on the y-axis, and how the team starts the second half represented by the color of the bar

ggplot(
  data = win_prop_all,
  mapping = aes(
    x = start_half_status,
    fill = start_half_side,
    y = prop
  )
) + 
  
  geom_col(
    position = 'dodge2'
  ) + 
  
  ggfittext::geom_bar_text(
    mapping = aes(label = paste0(round(prop,3)*100, "%")),
    position = 'dodge2',
    fontface = "bold"
  ) +
  
  labs(
    x = "Team's Standing at Halftime",
    y = NULL,
    fill = "Second Half Start",
    title = "NFL Team Win Percentage by Halftime Standing and Kickoff"
  ) + 
  
  scale_y_continuous(
    expand = c(0, 0, 0.05, 0),
    labels = scales::label_percent()
  ) + 
  
  scale_fill_manual(
    values = c("tomato", "steelblue"),
    labels = c("Kicking", "Receiving")
  ) + 
  
  theme(
    legend.position = c(0.2, 0.8)
  )

Regardless if the team is losing, tied, or winning at the start of the second half, teams are more likely to win when receiving the ball to start the 3rd quarter. If the team is losing or winning, there is about a 5% increase in win percentage, while a tie game only has about a 1% increase in win percentage.

Dumbbell Plot

When comparing the results of a binary variable (i.e. Kicking vs Receiving), it’s often better to use a dumbbell plot instead of a bar chart

ggplot(
  data = win_prop_all,
  mapping = aes(
    x = start_half_status,
    y = prop,
    color = start_half_side
  )
) + 
  
  geom_line(
    color = "black",
    linewidth = 1
  ) + 
  
  geom_point(
    size = 3
  ) +
  
  geom_text(
    mapping = aes(
      label = paste0(round(prop, 3)*100, "%"),
      hjust = if_else(str_detect(start_half_side, "defense"), +1.25, -0.25)
    ),
    show.legend = F
  ) +
  
  labs(
    x = "Game Result at Halftime",
    y = NULL,
    title = "How often does a team win when starting the second half<br>
             on <span style='color:steelblue;'>offense</span> 
             or <span style='color:tomato;'>defense?</span>",
    color = "Starting the Second Half:"
  ) +
  
  theme(
    plot.title = ggtext::element_markdown(hjust = 0.5),
    legend.position = 'none'
  ) + 
  
  scale_x_discrete(
    labels = c("Losing", "Tied", "Winning")
  ) +
  
  scale_y_continuous(
    #expand = c(0, 0, 0.05, 0),
    labels = scales::label_percent(),
    limits = c(0, 1)
  ) + 
  
  scale_color_manual(
    values = c("tomato", "steelblue"),
    labels = c("Kicking", "Receiving")
  ) + 
  
  
  coord_flip() 

Both graphs show the same result: In the NFL, it is better to Receive than to Give (Kick).

Logistic regression

Let’s take a model building approach, which allows us to aggregate the effect of the second half kickoff across the three different halftime results.

\(\chi^2\) Test of Conditional Independence

We’ll start by examining the conditional probabilities that a team wins or loses if they elect to kick or receive at the start of the second half, conditional on if they are winning, losing, or tied at half time.

If winning or losing is independent of the home team starting the second half on offense or defense, given the home teams standing at half time, the joint probability should be:

\[p_{ij|k} = p_{i|k} \times p_{j|k}\]

where

  • $p_{ij|k} = $ the joint probability of the game’s final outcome and second half role (offense/defense), conditional on the halftime standing (winning/losing/tie)

  • $p_{i|k} = $ the conditional probability of the game’s final outcome given the halftime standing

  • $p_{j|k} = $ the conditional probability of the second half role given the halftime standing

If we want to test if we can split \(p_{ij|k}\) into the separate conditional probabilities, we can conduct a Pearson test of conditional independence.

\[\chi^2 = \sum_{k = 1}^3 \chi^2_k\]

where \(\chi^2_k\) is the chi-squared test of independence test statistic for game’s final result and second half role for the \(k^{\textrm{th}}\) group of half time standing. For example, \(\chi^2_{\textrm{tie}}\) would be the test statistic for independence just for the games that are tied at half time. To calculate the overall test statistic, we add up the individual test statistics for the winning at halftime, losing at halftime, and tied at halftime groups.

The table below shows the results of the individual tests of independence depending on halftime results

test_of_cond_indep <- 
  half_time_df |> 
  # Removing the games that actually ended in a tie
  filter(game_result != "tie") |> 
  # Calculating the chi-squared test values for each halftime standing
  summarize(
    .by = start_half_status,
    rstatix::chisq_test(x = start_half_side, y = game_result)
  )

test_of_cond_indep |> 
  # Adding the overall results of the test of conditional independence
  add_row(
    start_half_status = "Overall Results",
    n = sum(test_of_cond_indep$n),
    statistic = sum(test_of_cond_indep$statistic),
    df = sum(test_of_cond_indep$df),
    .before = 1
  ) |> 
  mutate(
    p = pchisq(q = statistic, df = df, lower.tail = F)
  ) |> 
  dplyr::select(
    `Chi-squared Test` = start_half_status, 
    games = n,
    statistic, 
    `p-value` = p,
    df
  ) |> 
  arrange(-statistic) |> 
  gt::gt()
Chi-squared Test games statistic p-value df
Overall Results 3148 13.16689068 0.0042892492 3
winning 1603 12.09249032 0.0005062531 1
losing 1320 1.03651981 0.3086322565 1
tie 225 0.03788055 0.8456831914 1

Looking at the overall results, there is some evidence of an association between how the home team starts the second half and the game result, even after accounting for the game’s standing at halftime (\(\chi^2_3 \approx 13\), \(\textrm{p-value = 0.0044}\)).

When the game is tied or if the home team is losing, there doesn’t appear to be a statistically significant relationship. However, when the home team is winning, there does appear to be a statistically significant advantage in how often the team wins when they start the second half on offense compared to defense.

We can visualize where the advantages or disadvantages occur with a tile plot, with the color of each tile representing the standardized residual:

\[z = \frac{\textrm{Obs} - \textrm{Exp}}{\sqrt{\textrm{Exp}}}\]

The standardized residuals follow a standard Normal distribution, and we typically state that there is an association between two outcomes of the variables when \(|z| \ge 3\).

chi2_sdres <- 
  half_time_df |> 
  # Removing the games that actually ended in a tie
  filter(game_result != "tie") |> 
  # Calculating the chi-squared test values for each halftime standing
  count(
    start_half_side, start_half_status, game_result, name = "n_ijk"
  ) |> 
  # Adding up the total number of game result (i) and start_half_status (k)
  mutate(
    .by = c(game_result, start_half_status),
    n_ik = sum(n_ijk)
  ) |> 
  # Adding up the total number of start_half_side (j) and start_half_status (k)
  mutate(
    .by = c(start_half_side, start_half_status),
    n_jk = sum(n_ijk)
  ) |> 
  # Adding up the total number for start_half_status (k)
  mutate(
    .by = start_half_status,
    n_k = sum(n_ijk)
  ) |> 
  # Calculating the expected counts and standardized residuals
  mutate(
    expected = n_ik * n_jk / n_k,
    stan_res = (n_ijk - expected)/sqrt(expected),
    
    # Changing the facet labels in the tile plot
    start_half_status = case_when(
      start_half_status == "winning" ~ "Winning",
      start_half_status == "losing"  ~ "Losing",
      start_half_status == "tie"     ~ "Tied",
    ) |> fct_rev()
  ) 

# Creating the tile plot
ggplot(
  data = chi2_sdres,
  mapping = aes(
    x = start_half_side,
    y = game_result,
    fill = stan_res
  )
) + 
  geom_tile() +
  facet_wrap(facets = vars(start_half_status)) + 
  labs(
    x = "Starting the second half on offense or defense?",
    y = "If the home team won or lost the game",
    fill = "Standardized\nResiduals"
  ) + 
  theme_minimal() + 
  theme(panel.spacing.x = unit(0, "lines")) +
  scale_x_discrete(labels = c("defense" = "Defense", "offense" = "Offense")) + 
  scale_fill_gradient2(
    low = "tomato",
    high = "steelblue",
    limits = c(-max(abs(chi2_sdres$stan_res)), max(abs(chi2_sdres$stan_res)))
  ) + 
  ggfittext::geom_fit_text(
    mapping = aes(label = round(stan_res, 3)),
    contrast = T
  ) + 
  coord_cartesian(expand = F)

Logistic Regression

One drawback of using a \(\chi^2\) test is that it ignores the size of the score difference at halftime. The difference in how likely a team is to win or lose based on if they start the half on offense or defense will change depending on how large the lead one team has. A team is more likely to win the game if they are only down three points than if they were losing by twenty.

To account for the point difference at half time, half_score_diff in the data, we’ll create a logistic regression model. Before, let’s look at the distribution of half time point differential.

# Histogram for point differential
gg_pd_hist <- 
  ggplot(
    data = half_time_df ,
    mapping = aes(
      x = half_score_diff
    )
  ) + 
  geom_histogram(
    binwidth = 3,
    # Coloring the histogram with the NFL logo colors
    fill = "#013369",
    color = "#D50a0a"
  ) + 
  
  labs(
    x = "Point Differential at Halftime",
    y = "Games"
  ) + 
  theme_minimal() + 
  # Sitting the bar on the x-axis
  scale_y_continuous(expand = c(0, 0, 0.05, 0)) +
  scale_x_continuous(
    limits = c(-45, 45),
    expand = c(0, 0),
    breaks = seq(-40, 40, by = 10)
  )



# Box plot for point difference
gg_pd_box <- 
  ggplot(
    data = half_time_df ,
    mapping = aes(
      x = half_score_diff
    )
  ) + 
  geom_boxplot(
    # Coloring the histogram with the NFL logo colors
    fill = "#013369",
    color = "#D50a0a"
  ) + 
  
  labs(x = NULL) + 
  
  theme_minimal() +
  scale_x_continuous(
    limits = c(-45, 45),
    expand = c(0, 0),
    breaks = seq(-40, 40, by = 10)
  ) + 
  scale_y_continuous(minor_breaks = NULL) +
  theme(
    axis.line = element_blank(),
    axis.ticks = element_blank(),
    axis.text = element_blank()
  )

egg::ggarrange(gg_pd_box, gg_pd_hist, heights = 1:2)

cowplot::plot_grid(
  gg_pd_box, 
  gg_pd_hist, 
  ncol = 1, 
  rel_heights = c(1, 2),
  align = 'v', 
  axis = 'lr'
)

There appear to be outliers in the data around \(\pm 30\) and beyond. How many of these games are there?

half_time_df |> 
  filter(abs(half_score_diff) >= 30) |> 
  mutate(half_result = if_else(half_score_diff < 0, "losing", "winning")) |> 
  count(half_result, game_result)
## # A tibble: 3 × 3
##   half_result game_result     n
##   <chr>       <chr>       <int>
## 1 losing      lost           11
## 2 losing      won             1
## 3 winning     won            18

In the 30 games with a point differential of 30 or more at half time, only 1 game ended with the home team losing by 30 or more at half time and eventually winning the game. If the home team is winning by 30 or more at halftime, they always won (18/18). Which game did they manage to pull off an over 30 point comeback?

half_time_df |> 
  filter(half_score_diff <= -30 & game_result == "won") |> 
  select(game_id, half_score_diff, home_score, away_score) |> 
  mutate(
    game_id = str_replace_all(game_id, "_", " ")
  ) |> 
  gt::gt()
game_id half_score_diff home_score away_score
2022 15 IND MIN -33 39 36

During week 15 of the 2022 season, the Minnesota Vikings managed a 33 point comeback against the Indianapolis Colts to end up winning the game 39 - 36 in overtime.

Before fitting our logistic regression model, we’ll remove the outlier games with a point differential of 30 or more.

# Creating the data set with only |pd| < 30 and no ties
half_time_df2 <- 
  half_time_df |> 
  filter(game_result != "tie", abs(half_score_diff) < 30) |> 
  # Creating a dummy response variable: 1 = won, 0 = lost
  mutate(result = (game_result == "won")*1)

nfl_logistic <- 
  glm(
    formula = result ~ half_score_diff + start_half_side,
    data = half_time_df2,
    family = binomial
  )

broom::tidy(nfl_logistic) |> 
  mutate(
    across(
      .cols = estimate:statistic,
      .fns = ~ round(., digits = 3)
    ),
    p.value = round(p.value, digits = 5)
  ) |> 
  gt::gt()
term estimate std.error statistic p.value
(Intercept) -0.066 0.062 -1.064 0.28735
half_score_diff 0.157 0.006 26.479 0.00000
start_half_sideoffense 0.364 0.089 4.099 0.00004

From the results of logistic regression, there is a statistically significant result (p-value \(\approx\) 0.00004) that a team is more likely to win when starting the second half on offense compared to defense when accounting for the score difference at half time.

We estimate that the odds of winning are 44% higher when starting the second half on offense. Note: The odds of winning and the probability of winning are not the same!

A visualization of the improvement in the probability of winning can be seen in the line graph below. The shaded region represents a 95% confidence interval for the estimated probability of winning, conditional on the half time score difference and if the home team is on offense or defense to begin the second half.

broom::augment_columns(
  x = nfl_logistic,
  newdata = 
    expand.grid(
      half_score_diff = seq(-30, 30, by = 0.1),
      start_half_side = unique(half_time_df$start_half_side)
    ),
  
) |> 
  mutate(
    eta_lb = .fitted - 1.96*.se.fit,
    eta_ub = .fitted + 1.96*.se.fit,
    # Converting fitted and the ci to probabilities
    across(
      .cols = c(.fitted, eta_lb, eta_ub),
      .fns = ~ exp(.) / (1 + exp(.))
    )
  ) |> 
  # Creating the graph
  ggplot(
    mapping = aes(
      x = half_score_diff,
      y = .fitted,
      color = start_half_side
    )
  ) + 
  geom_line() + 
  geom_ribbon(
    mapping = aes(ymin = eta_lb, ymax = eta_ub, fill = start_half_side),
    color = NA,
    alpha = 0.5,
    show.legend = F
  ) + 
  labs(
    title = "Is there an advantage to starting the second half on
             <br><span style='color:#013369;'>offense</span> compared to 
             starting on <span style='color:#D50a0a;'>defense</span>?",
    x = "Home Score Point Differential",
    y = "Estimated Probability the Home Team Wins"
  ) + 
  theme_minimal() + 
  theme(
    plot.title = ggtext::element_markdown(hjust = 0.5),
    legend.position = "none"
  ) + 
  scale_fill_manual(
    values = c("offense" = "#013369", "defense" = "#D50a0a")
  ) +
  scale_color_manual(
    values = c("offense" = "#013369", "defense" = "#D50a0a")
  ) + 
  scale_y_continuous(
    labels = scales::label_percent()
  ) + 
  scale_x_continuous(
    breaks = seq(-30, 30, by = 10),
    minor_breaks = NULL,
    labels = c("30\nLosing", abs(seq(-20, 20, by = 10)), "30\nWinning")
  ) 

While the difference appears to be small, there is a small but relevant improvement in the chance of winning when starting the second half on offense!

Limitations

The biggest limitation of the inferential methods is the assumption that each row in the data are independent. Since the data are 3159 NFL games for 32 different teams across an eleven season span, each game is not independent of every other game. Games with the same team and season are a related, since it is the same players. The scores for NFL games tend to vary significantly from week to week, even for the same team, but teams with strong offenses or defenses are more likely to have a lead during halftime and more likely to win the game compared to teams with weak offenses and defenses.