# Identifying garbage time on NBA play-by-play

One of my favorite NBA websites is Cleaning the Glass, created by Ben Falk, a former front office executive for the Portland Trail-Blazers. One of the many reasons why I like it to much is the fact that they filter out garbage time from their stats. It is not unusual for a game to be out hand for a team, leading both coaches to take their starters out of the game. What happens from this point on is not representative to the rest of the game, since both teams aren’t trying as hard and some players who usually don’t get much playing may try to pad their stats. Therefore, it makes sense to filter it out. Here’s how Cleaning the Glass defines garbage time:

The exact definition CTG uses is: the game has to be in the 4th quarter, the score differential has to be >= 25 for minutes 12-9, >= 20 for minutes 9-6, and >= 10 for the remainder of the quarter. Additionally, there have to be two or fewer starters on the floor combined between the two teams. Importantly, the game can never go back to being non-garbage time, or this clock resets. For example, if it’s a 30 point game to start the 4th quarter, but one team comes back and pulls the game within 8, that comeback is not counted as garbage time. If the leading team regains control and expands the lead back out, garbage time would start when the score went back above 10. This might not capture all of what we’d call garbage time, but it seeems important to err on the side of caution and not mistakenly filter out any game time that we would not consider garbage time.

There are, then, 3 variables we must consider to identify garbage time: score differential, time left on the 4th quarter and combined number of starters on the floor. First, let’s load our edited play-by-play data:

library(tidyverse)

col_types = c(timeQuarter = "c",
start_poss = "c")) %>%
mutate(across(starts_with("description"), ~ coalesce(., "")))

Note: the URL with the data changes daily. To get the most recent one, go here and right click on the latest “data.csv” file to copy the url.

In this data, we have the lineups for the home and away teams at every point in the game, which helps with the variable “combined number of starters on the floor”. First, we need to find the players who were on the floor at the start of the 1st quarter, which will be the starters:

library(tidyverse)

starters_teams <- final_poss_pack %>%
filter(numberEventMessageType == 12 & numberPeriod == 1) %>%   # message type 12 corresponds to start of quarter
distinct(idGame, starters_home = lineupHome, starters_away = lineupAway)

Now we can add it to the play-by-play and find the difference between the lineup at each point and the starters. The number of players who appear in both will be the total number of starters on the floor for each team:

ctg_garbagetime <- final_poss_pack %>%
left_join(starters_teams) %>%
mutate(across(c(starts_with("lineup"), starts_with("starters")), ~ str_split(., ", "), .names = "{.col}_list")) %>%
mutate(total_starters_home = map_int(map2(lineupHome_list, starters_home_list, intersect), length),
total_starters_away = map_int(map2(lineupAway_list, starters_away_list, intersect), length),
total_starters = total_starters_home + total_starters_away) %>%
select(-c(contains("list"), starts_with("starters")))

ctg_garbagetime %>%
distinct(idGame, lineupHome, lineupAway, total_starters_home, total_starters_away, total_starters)
## # A tibble: 32,382 x 6
##    idGame lineupHome lineupAway total_starters_~ total_starters_~ total_starters
##     <dbl> <chr>      <chr>                 <int>            <int>          <int>
##  1 2.21e7 Brook Lop~ Blake Gri~                5                5             10
##  2 2.21e7 Giannis A~ Blake Gri~                4                5              9
##  3 2.21e7 Giannis A~ Blake Gri~                4                4              8
##  4 2.21e7 George Hi~ Blake Gri~                3                4              7
##  5 2.21e7 George Hi~ James Har~                3                3              6
##  6 2.21e7 George Hi~ Kevin Dur~                3                2              5
##  7 2.21e7 George Hi~ Jevon Car~                3                1              4
##  8 2.21e7 George Hi~ Jevon Car~                2                1              3
##  9 2.21e7 Brook Lop~ Jevon Car~                2                1              3
## 10 2.21e7 Brook Lop~ Jevon Car~                1                1              2
## # ... with 32,372 more rows

We already have in our play-by-play the score differential before every event and the time left on the game, so we are already able to identify the criteria that matches the definition of garbage time:

ctg_garbagetime <- ctg_garbagetime %>%
mutate(garbage_time = case_when(
# score differential >= 25 for minutes 12-9:
secsPassedGame >= 2160 & secsPassedGame < 2340 & abs(marginBeforeHome) >= 25 & total_starters <= 2 & numberPeriod == 4 ~ 1,
# score differential >= 20 for minutes 9-6:
secsPassedGame >= 2340 & secsPassedGame < 2520 & abs(marginBeforeHome) >= 20 & total_starters <= 2 & numberPeriod == 4  ~ 1,
# score differential >= 10 for minutes 6 and under:
secsPassedGame >= 2520 & abs(marginBeforeHome) >= 10 & total_starters <= 2 & numberPeriod == 4 ~ 1,
TRUE ~ 0))

However, there is an important point in the definition that we must pay attention to:

Importantly, the game can never go back to being non-garbage time, or this clock resets. For example, if it’s a 30 point game to start the 4th quarter, but one team comes back and pulls the game within 8, that comeback is not counted as garbage time. If the leading team regains control and expands the lead back out, garbage time would start when the score went back above 10.

To account for this, we need to identify the last non-garbage time event in the game before it went to garbage time for good. If we find any before that, the situation above has occurred:

ctg_garbagetime <- ctg_garbagetime %>%
group_by(idGame) %>%
mutate(max_nongarbage = max(numberEvent[which(garbage_time == 0)])) %>%
ungroup() %>%
mutate(garbage_time = ifelse(garbage_time == 1 & numberEvent < max_nongarbage, 0, garbage_time)) %>%
select(-max_nongarbage)

There it is! Now we have identified every event that can be defined as garbage time according to Cleaning the Glass. On the website, there is a note on the game page stating when the stats started to be filtered out:

In order to know if our work matches the one on CTG, let’s find the start of garbage time on our table and compare it to this information. The code below does that, by identifying what led to the start of garbage time (a sub that brought the total number of starters to 2 or less, the time left going below 6 when the lead was between 20 and 10, a made field goal that took the score differential from 24 to 26 with 10 minutes left are some possibilities) and extracting the exact time it happened.

start_garbagetime <- ctg_garbagetime %>%
filter(garbage_time == 1 | lead(garbage_time) == 1) %>%
group_by(idGame, garbage_time) %>%
filter(row_number() == 1) %>%
ungroup() %>%
mutate(total_pts = shotPtsHome + shotPtsAway,
margin_after = abs(ptsHome - ptsAway),
would_start = case_when(numberEventMessageType %in% c(1, 3) & secsPassedGame >= 2160 & margin_after >= 25 & total_pts > 0 ~ 1,
numberEventMessageType %in% c(1, 3) & secsPassedGame >= 2340 & secsPassedGame < 2520 & margin_after >= 20  & total_pts > 0 ~ 1,
numberEventMessageType %in% c(1, 3) & secsPassedGame >= 2520 & margin_after >= 10 & total_pts > 0 ~ 1,
TRUE ~ 0)) %>%
mutate(type_start = case_when(garbage_time == 1 & numberEventMessageType == 8 ~ "sub",
garbage_time == 1 & timeQuarter == "12:00" ~ "start quarter",
would_start == 1 & garbage_time == 0 ~ "shots")) %>%
group_by(idGame) %>%
mutate(total_types = sum(!is.na(type_start)),
has_sub = sum(type_start == "sub")) %>%
ungroup() %>%
mutate(type_start = ifelse(is.na(type_start) & garbage_time == 1 & total_types == 0, "time", type_start),
type_start = ifelse(total_types == 2 & has_sub == 1 & type_start != "sub", NA, type_start)) %>%
filter(!is.na(type_start)) %>%
select(idGame, start_garbage = timeQuarter, type_start)

start_garbagetime
## # A tibble: 229 x 3
##      idGame start_garbage type_start
##       <dbl> <chr>         <chr>
##  1 22100001 03:02         sub
##  2 22100008 06:02         sub
##  3 22100009 03:30         sub
##  4 22100011 03:14         sub
##  5 22100014 03:32         sub
##  6 22100015 09:09         sub
##  7 22100017 01:10         sub
##  8 22100018 03:21         sub
##  9 22100020 02:54         sub
## 10 22100022 02:26         sub
## # ... with 219 more rows

Finally, let’s look at all the games that ended with a margin of 10 or more points and see if it had garbage time and when it started.

library(nbastatR)

team_logs <- game_logs(seasons = 2022, result_types = "team")
## Acquiring NBA basic team game logs for the 2021-22 Regular Season
games_over10 <- team_logs %>%
filter(abs(plusminusTeam) >= 10) %>%
distinct(idGame, dateGame, slugTeam, slugOpponent) %>%
distinct(idGame, dateGame, .keep_all = TRUE) %>%
left_join(start_garbagetime)

And here is the game we looked above:

library(nbastatR)

games_over10 %>%
filter(dateGame == "2022-01-13",
slugTeam == "DEN",
slugOpponent == "POR")
## # A tibble: 1 x 6
##   dateGame     idGame slugTeam slugOpponent start_garbage type_start
##   <date>        <dbl> <chr>    <chr>        <chr>         <chr>
## 1 2022-01-13 22100631 DEN      POR          09:54         sub

It matches perfectly with the start on the website. In the next post, we are going to look at team and player stats with and without filtering out garbage time. Thanks for reading!