Defense vs position (DvP) tables rank every NBA team by the points it gives up to guards, forwards and centers. They are quoted from the first week of the season. This notebook asks a simple question of six seasons of data: if you rank defenses after 5, 10 or 30 games, how well does that ranking describe the same defenses over the rest of the season?

The NBA defense vs position study has the plain-English write-up, including the player-level test (about half a point for guards against the five easiest defenses). The CSV is archived on Zenodo with DOI 10.5281/zenodo.23879979.

Load the data

u <- "https://betbetter.world/studies/data/nba-defense-vs-position-2018-2025.csv"
dvp <- read.csv(u, encoding = "UTF-8")
nrow(dvp); table(dvp$season); table(dvp$position); sort(unique(dvp$after_games))
## [1] 6360
## 
## 2018-19 2019-20 2021-22 2022-23 2023-24 2024-25 
##    1080     960    1080    1080    1080    1080
## 
##      all  centers forwards   guards 
##     1590     1590     1590     1590
## [1]  5 10 15 20 25 30 40 50 60
head(dvp, 3)
##    season defense_team position after_games allowed_per_game_so_far rank_so_far
## 1 2018-19          ATL      all           5                  113.80          17
## 2 2018-19          ATL      all          10                  119.50          27
## 3 2018-19          ATL      all          15                  119.07          30
##   games_left allowed_per_game_rest rank_rest
## 1         77                119.73        30
## 2         72                119.35        30
## 3         67                119.43        30

Each row is one defense, in one season, for one position, at one checkpoint. allowed_per_game_so_far is what the defense had given up to that position after after_games games; allowed_per_game_rest is what it gave up from then to the end of the season.

Does the early table predict the rest?

For each position and checkpoint, correlate the so-far number with the rest-of-season number across all defenses and seasons.

pos <- c("guards", "forwards", "centers")
cors <- sapply(pos, function(p) sapply(sort(unique(dvp$after_games)), function(g) {
  d <- dvp[dvp$position == p & dvp$after_games == g, ]
  round(cor(d$allowed_per_game_so_far, d$allowed_per_game_rest), 2)
}))
rownames(cors) <- paste("after", sort(unique(dvp$after_games)), "games")
cors
##                guards forwards centers
## after 5 games    0.33     0.09    0.29
## after 10 games   0.31     0.28    0.22
## after 15 games   0.30     0.32    0.25
## after 20 games   0.42     0.35    0.28
## after 25 games   0.50     0.32    0.25
## after 30 games   0.53     0.33    0.24
## after 40 games   0.53     0.35    0.28
## after 50 games   0.51     0.29    0.25
## after 60 games   0.46     0.33    0.32

A correlation of 1 would mean the early table is the whole story. Near 0 means it tells you almost nothing about the rest of the season.

The gap shrinks

Early tables look dramatic because a few games give wide spreads. Compare how far apart the teams look so far with how far apart the same teams turn out to be.

spread <- function(x) round(sd(x), 2)
gap <- do.call(rbind, lapply(sort(unique(dvp$after_games)), function(g) {
  d <- dvp[dvp$position == "guards" & dvp$after_games == g, ]
  data.frame(after_games = g, sd_so_far = spread(d$allowed_per_game_so_far),
             sd_rest = spread(d$allowed_per_game_rest))
}))
gap
##   after_games sd_so_far sd_rest
## 1           5      7.61    3.39
## 2          10      5.91    3.50
## 3          15      4.99    3.64
## 4          20      4.41    3.66
## 5          25      4.27    3.62
## 6          30      4.03    3.68
## 7          40      3.80    3.87
## 8          50      3.68    4.06
## 9          60      3.63    4.71

After 5 games the guard table looks more than twice as spread out as the same defenses turn out to be over the rest of the season. The two only meet at around 40 games. Late in the season the rest-of-season column widens again, simply because it is measured over fewer games.

Rank against rank

The same idea with ranks: how often does a top-five (easiest) defense for guards after 10 games stay in the top five over the rest of the season?

stay <- sapply(sort(unique(dvp$after_games)), function(g) {
  d <- dvp[dvp$position == "guards" & dvp$after_games == g, ]
  early <- d$rank_so_far >= 26
  round(mean(d$rank_rest[early] >= 26), 2)
})
names(stay) <- sort(unique(dvp$after_games))
stay
##    5   10   15   20   25   30   40   50   60 
## 0.20 0.30 0.23 0.20 0.23 0.23 0.23 0.30 0.40

Ranks run from 1 (gives up the least) to 30 (gives up the most), as on the NBA defense vs position page. By chance alone, a team would land in the bottom five about 17% of the time. An early bottom-five defense stays there only 20 to 30% of the time for most of the season: better than chance, but not by much.

A picture

op <- par(mar = c(4, 4, 1, 1))
g <- sort(unique(dvp$after_games))
matplot(g, cors, type = "b", pch = 16, lty = 1, col = c("steelblue", "darkorange", "firebrick"),
        xlab = "Games played so far", ylab = "Correlation with rest of season", ylim = c(0, 1))
legend("topleft", legend = pos, col = c("steelblue", "darkorange", "firebrick"), lty = 1, pch = 16, bty = "n")

par(op)

What to take from it

An early DvP table is mostly noise that looks like signal. For guards the link to the rest of the season grows to about 0.5 by 25 to 30 games; for forwards and centers it stays near 0.3 all season. It never becomes the whole story, and the player-level effect it points to is small: about half a point for guards against the five easiest defenses. Use it as a nudge, not a reason.

The data is free to reuse under CC BY 4.0, with a link back to the study.