# Draft Combine Measures & Defense, Part One - Basic Relationships

When we talk about the defensive potential of incoming NBA players, we’re usually referring to a set of physical attributes – height, length, physical strength, footspeed, etc. – that can may be improved upon at the margins, but essentially exist as raw athletic foundation upon which a defensive stalwart can be built.

But to what extent, and in what combination, do these building blocks actually predict future defensive performance? This post is the first in a series that aims at answering that question.

## Introduction

In future posts, I’ll be using a wide range of physical measures to generate predictive models of how raw athletic traits predict future defensive performance. In this first post, I start by discussing the draft combine data that I’ll be using, the basic shape of this data, and how a few frequently-cited combine measures match up with basic defensive statistics. (See the end of the post for the code I used to gather and process the data.)

To measure athletic traits, I use the stats.nba.com API to gather all combine data made available for incoming NBA players, dating back to when the data first become available in 2000. Figure 1, below, shows what the resulting combine data looks like.

A few observations jump out from the above scatterplots and correlations:

• Height, wingspan (the measure from finger tip to finger tip when a player’s arms are spread out), and standing reach (how high off the ground a player can reach while keeping his feet on the ground) are the three most tightly correlated measures, though there’s still substantial variation in going from height to wingspan.

• Measures of speed and explosiveness – lane agility, vertical jump, 3/4-court sprint – tend to run in the opposite direction of bigness; the larger a player, the lower he jumps, and the longer it takes him to complete speed/agility tests. These relationships tend to be relatively weak, though, as do the relationships between speed & explosiveness measures.

• Bench press isn’t related to much of anything except weight. This could make imply particular usefulness (it tells us stuff that other measures don’t) or particularn not-usefulness (it’s just a random measure that doesn’t correspond to much of anything on a basketball court).

In addition to these observations, there are a few other considerations to keep in mind as you peruse these data. The first has to do with the nature of missingness in the data. Of the full set of 1272 combine records, only 148 contain data for each of the measures shown in Figure 1, and the average record is missing 3 of the 13 available measures. Some of this is due to systematic patterns of missingness due to measures passing into and out of existence in different draft years – hand measures only became a thing in 2010; bodyfat measures started in 2003, were abandoned in 2005, and came back in 2006; bench press measures weren’t taken in 2014 or 2016-2018; etc. This sort of missigness is relatively benign for inferential purposes, as it applies to all players drafted in a given year. As such, it can be approximated as MCAR (missing completely at random) data, and it can be dealt with in ways that don’t introduce bias into our estimates of relationships among the data.

More vexing are missing measures that are MNAR (missing not at random). An example of this might arise from players with unexpectedly poor jumping ability who decide to skip out on the vertical jump measures. This missingness – in which we’re disproportionately likely to miss out on low “true” values – can result in biased estimates of the distribution of vertical jump measures and of relationships between vertical jump and other measures of interest. Dealing with such bias can be difficult, but we’ll cross that bridge in future posts.

Finally, it’s worth pausing to consider why the data show the correlations they do in Figure 1. One source of correlation is physical mechanism; e.g. guys who are really heavy, all else equal, are going to have a harder time accelerating that weight into the air, so we’d expect to see negative correlations between weight and vertical jump measures. A second, more subtle source of correlation comes from the presence of colliders in the data. For instance, even if there were no underlying correlation between wingspan and footspeed in the general population, it’s very likely the case that elite basketball players with exceptionally long arms could get away with being a bit slower afoot. Thus, we’d expect to see a positve correlation between wingspan and 3/4-court sprint time, even if long-limbed people on average were no faster or slower than short-limbed people. These two sources of correlation (or, in combination, the absence of correlation) are helpful to keep in mind when interpreting what relationships in the data actually mean for player performance.

## Height, Wingspan, and Standing Reach

Measures of height and arm length, and their respect importance, come up quite a bit in NBA discussions, so I want to pay specific attention to their relationship upfront. Figure 2 shows the relationship for all playes that have gone through the combine and whose data are available through the stats.nba.com API. Interestingly, the relationship is highly linear, with an additional inch of height corresponding very nearly to an additional inch of wingspan, with an offest of about 5 inches. i.e. the average wingspan for a combine participant is simply going to be his (shoeless) height plus 5".

While the relationship is highly linear, about 30% of the variance in wingspan is unexplained by variance in height. Thus, we can get the 6'10.75" Kelly Olynyk with a 6'9.75" wingspan and the 6'10.5" Hassan Whiteside with a wingspan of 7'7", yielding tremendously different functional body types at approximately the same height.

One thing clear from Figure 2 is that wingspan is not destiny. Doug Wrenn owns the most improbably long set of arms in combine history, and, despite being an otherwise very good athlete, washed out before he played a single NBA game. Conversely, Jimmy Butler’s arms are decidely on the t-rex side of the wingspan spectrum, and he’s universally considered an elite wing defender. That said, he’s not nearly as elite, nor as versatile, as Kawhi Leonard, who has a much longer wingspan at the same height.

The 2018 draft predicted two of the biggest wingspan outliers in draft history, with Mo Bamba at 7’10" possessing the longest wingspan in draft history, and Svi Mykhailiuk, who has very good height for a sweet-shooting wing and also the wingspan of a 6-foot-nothing point.

Finally, Figure 3 below shows the relationship between standing reach and height. Intuitively, the relation is a bit tighter than with wingspan, with a higher R2 and less pronounced outliers. A word of caution, though: the highlighted data points indicate some possible errors in the underlying values. Specifically, Doug Wrenn, who was measured with an improbably long 7’7" wingspan, is also listed with an improbably short 8’4" standing reach. Unless his arms attach at his waist, one of these numbers is almost certainly in error. Likewise, Donovan Mitchell’s listed standing reach seems questionable given his height and wingspan. While most of the standing reach measures seem plausible, these potential errors should be kept in mind during future modeling, as they’ll likely add some noise to any modeled relationships.

## Height and Wingspan vs. Defensive Stats

As mentioned above, I plan in future posts to get into more complicated models that predict defensive performance based on the full gamut of combine stats. Here, though, I wrap up by looking at how well height and winspan respectively correspond with 1) defensive rebounding rate (the percentage of available defensive rebounds that a player grabs), 2) offensive rebound rate (same, but for OReb), 3) block rate (an estimate of the percentage of opponent FGA that a player blocks), and 4) steal rate (an estimate of the percentage of opponent possessions that end with a player’s steal). I gathered these defensive stats by using R’s ballr package to scrape Basketball Reference data.

In all of the following comparisons, I fit height and wingspan to defensive stats using a 3rd order polynomial, first fitting a model for the entire body of combine participants (left panels) and then fitting three models for three broad position groups (right panels).

### Defensive Rebound Rate

Figures 4 and 5 show a few interesting patterns with respect to defensive rebounding, height, and wingspan. Across all players, height and wingspan are both fairly strong predictors of defensive rebounding, with height accounting for a slightly greater portion of rebounding variance. As shown in the right panels, though, both height and wingspan are fairly week predictors of defensive rebounding for both bigs and lead guards. Amongst wings, though, both height and wingspan are again relatively strongly related to defensive rebounding.

### Offensive Rebound Rate

The relationships for offensive rebounding are similar overall to those for defensive rebounding. One interesting pattern, though: looking just at bigs (centers, power forwards, and center/power forward hybrids), wingspan is a much better predictor of offensive rebounding than is height.

### Block Rate

While height tends to be a very slightly better predictor of overall rebounding prowess than wingspan, wingspan is a much better predictor of shot blocking prowess. Figures 8 and 9 show this: wingspan is a substantially better predictor of shot blocking across the entire pool of players, as well as within positional sub-groups. This is especially true for bigs.

### Steal Rate

Finally, both height and wingspan are similarly, modestly negatively associated with steal rates – taller, longer players get fewer steals on average. To a large extent, this negative relationship appears to be a function of positional grouping.

## Conclusion

This post was meant to give an overview of the combine measures available from stats.nba.com, and to look a bit more closely at how height/length measures relate to each other and to a few common defensive statistics. What I found: there’s a suprisingly linear, one-to-one relationship between differences in height and differences in wingspan, that height and wingspan predict rebounding more or less equally well, but that wingspan is a substantially better indicator of shot-blocking prowess. In future posts, I’ll be examining combine athleticism measures (e.g. max vertical jump, lane agility) more closely, and I’ll be combining these measures with size measures to come up with some genuinely predictive models of defensive performance.

## Code Used for Post

My first task was to gather and clean all the combine data available from the stats.nba.com API, going back to 2000 (first year of availability). I gathered all data, regardless of whether a player made it to the NBA or not, but I checked to see if a player participated in more than one combine, and I took only his second (last) set of results if he did.

library(httr)
library(jsonlite)
library(dplyr)
library(stringr)

request_headers = c(
"accept-encoding" = "gzip, deflate, sdch",
"accept-language" = "en-US,en;q=0.8",
"cache-control" = "no-cache",
"connection" = "keep-alive",
"host" = "stats.nba.com",
"pragma" = "no-cache",
"upgrade-insecure-requests" = "1",
"user-agent" = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_2) AppleWebKit/601.3.9 (KHTML, like Gecko) Version/9.0.2 Safari/601.3.9"
)

# specify vector of years to collect data for (from ballr)
years <- 2000:2018

# specify vector of year strings (formatted for stats.nba API call)
years_str <- paste(
years, "-",
str_pad(
as.character(years-2000 + 1), width=2, pad = '0'
),
sep = ''
)

# Write function to get combine stats from nba.com API
getCombineStats <- function(year){

players_combineStats_JSON <- GET(
"http://stats.nba.com/stats/draftcombinestats",
query = list(
SeasonYear = year,
LeagueID = '00'),
add_headers(request_headers)
)

players_combineStats_list <- fromJSON(content(players_combineStats_JSON, as = "text"))

players_combineStats_df <- tbl_df(data.frame(players_combineStats_list$resultSets$rowSet[[1]], stringsAsFactors = FALSE))
names(players_combineStats_df) = tolower(players_combineStats_list$resultSets$headers[[1]])

return(players_combineStats_df)
}

# use map function in run combine stats function for all years, outputting list of data frames
combineStats_allYrList <- years_str %>%
purrr::map(getCombineStats)

#bind_rows
combine_df <- combineStats_allYrList %>% bind_rows()

# find 2nd measures for players who got measured twice
combine_dups <- combine_df[duplicated(combine_df$player_name),] # remove duplicates from original dataframe & add 2nd measures back in combine_noDups_df <- combine_df %>% filter( !(player_name %in% combine_dups$player_name) )

combine_fin_df <- bind_rows(
combine_noDups_df, combine_dups
)

# select relevant columns for joining to adv stats
combine_fin_df <- combine_fin_df %>%
select(
combine_year = season,
player = player_name,

position,
height_wo_shoes,
weight,
wingspan,
standing_reach,
body_fat_pct:bench_press
) %>%
mutate( # add flag to later tell easily if a player had any combine data joined
# remove all special characters/punctuation from player names for better joins with advanced stats data
player = str_replace_all(player, '[\\.\\*\\,]', ''),
combine_flag = 1
) %>%
mutate_at(vars(height_wo_shoes:bench_press), as.numeric) #convert combine measures to numeric variable type

# reassign 0-valued lane agility time to NA
combine_fin_df$modified_lane_agility_time[ combine_fin_df$modified_lane_agility_time == 0
] <- NA

# remove Svi Mykhailiuk's 2017 combine records (re-tested in 2018 under full name)
combine_fin_df <- combine_fin_df %>% filter(player != 'Svi Mykhailiuk')

Next, I gathered defensive advanced stats for all NBA player-seasons during the years for which combine data is available (using the very useful ballr package for this Basketball Reference scraping task), cleaned this data, and then joined the combine data to it.

library(ballr)

# apply ballr function to all desired years to get list of advanced stats data frames
advanceStats_allYrList <- years %>%
purrr::map(NBAPerGameAdvStatistics)

# add year to adv stats data frames, bind them, select relevant vars

add_year <- function(df,years_str){
df$season <- years_str return(df) } advanceStats_allYrList <- purrr::map2( advanceStats_allYrList,years_str, add_year ) advanceStats_df <-advanceStats_allYrList %>% bind_rows() %>% select( player, season, pos, age, team = tm, g,mp,per,orbpercent,drbpercent,trbpercent, stlpercent,blkpercent,dws,dbpm, link ) %>% mutate( player = str_replace_all(player, '[\\.\\*\\,]', ''), playerYr = paste(player,season) ) # use only the 'TOT' season records for players who played for more than one team in a given season advanceStats_combSeas <- advanceStats_df %>% filter(team == 'TOT') advanceStats_fin_df <- advanceStats_df %>% filter(!(playerYr %in% advanceStats_combSeas$playerYr) |
team == 'TOT')

# join combine data to adv stats data
join_df <- advanceStats_fin_df %>%
left_join(combine_fin_df)

# check for erroneous joins -- combine year later than season year, cause by different players with same name
join_df$season_num <- substr(join_df$season, start = 1, stop = 4) %>%
as.numeric()

join_df$temporal_flag <- NA_character_ join_df$temporal_flag[join_df$season_num <= join_df$combine_year] <- 'Bad'
join_df$temporal_flag[join_df$season_num > join_df$combine_year] <- 'Good' join_df_bad <- join_df %>% filter(temporal_flag == 'Bad') join_df$temporal_flag[
join_df$link %in% join_df_bad$link
] <- 'Bad'

combine_advDef_final_df <- join_df %>%
filter(temporal_flag == 'Good')

Data in hand, I created some plots. First, the big scatter matrix (Figure 1):

library(ggplot2)
library(GGally)
library(rlang)
library(ggrepel)
library(viridis)
library(ggpmisc)
library(cowplot)

# Create function to write correlations w/o extra label,
#   and with size scaling. Code taken from:
# https://github.com/ggobi/ggally/issues/139
my_custom_cor <- function(data, mapping, color = I("grey50"), sizeRange = c(1, 5), ...) {

# get the x and y data to use the other code
x <- eval_tidy(mapping$x, data) # changed function from eval() to eval_tidy() y <- eval_tidy(mapping$y, data)

ct <- cor.test(x,y)

r <- unname(ct$estimate) rt <- format(r, digits=2)[1] # since we can't print it to get the strsize, just use the max size range cex <- max(sizeRange) # helper function to calculate a useable size percent_of_range <- function(percent, range) { percent * diff(range) + min(range, na.rm = TRUE) } # plot the cor value ggally_text( label = as.character(rt), mapping = aes(), xP = 0.5, yP = 0.5, size = I(percent_of_range(cex * abs(r), sizeRange)), color = color, ... ) } # start with full combine data, select only measure variables, pass to ggpairs(), using custom correlation display function for lower half combine_fin_df %>% select( Height = height_wo_shoes, Wing-\nspan = wingspan, Reach = standing_reach, Hand\nLength = hand_length, Hand\nWidth = hand_width, Weight = weight, Body\nFat % = body_fat_pct, Vert,\nStand = standing_vertical_leap, Vert,\nMax = max_vertical_leap, Lane\nAgility = lane_agility_time, Lang\nAgility, 2 = modified_lane_agility_time, 3/4 Sprint = three_quarter_sprint, Bench\nReps = bench_press ) %>% ggpairs( upper = list(continuous = wrap("smooth_loess", alpha = 0.01, color = 'blue')), lower = list(continuous = wrap(my_custom_cor, sizeRange = c(1,3))), diag = list(continuous = 'density'), axisLabels = 'none', showStrips = TRUE ) + theme_void() + theme( strip.text = element_text(face = 'bold') ) Then, the closer looks at height, wingspan, and reach (Figures 2 and 3): # specify list of players to call out on graph combine_fin_df_wingspanCallOuts <- combine_fin_df %>% filter(player %in% c( "Donovan Mitchell", "Doug Wrenn", "Hassan Whiteside", "Jason Maxiell", "Kawhi Leonard", "Mohamed Bamba", "Will Solomon", "Jimmy Butler", "JJ Redick", "Kelly Olynyk", "Martynas Andriuskevicius", "Sviatoslav Mykhailiuk", "TJ Ford" )) # fit 1st/2nd/3rd order polynomial linear models # (i used only 1st order fit, since the relationship was very close to linear) lm_ht_wng_O1 <- lm(wingspan ~ poly(height_wo_shoes, 1, raw = TRUE), na.action = "na.exclude", data = combine_fin_df) lm_ht_wng_O2 <- lm(wingspan ~ poly(height_wo_shoes, 2, raw = TRUE), na.action = na.exclude, data = combine_fin_df) lm_ht_wng_O3 <- lm(wingspan ~ poly(height_wo_shoes, 3, raw = TRUE), na.action = na.exclude, data = combine_fin_df) # capture player residuals to see who stands out (used to help populate above list of called out players) combine_fin_df$lm_ht_wng_O1_resid <- NA
combine_fin_df$lm_ht_wng_O1_resid[ !is.na(combine_fin_df$height_wo_shoes) & !is.na(combine_fin_df$wingspan) ] <- lm_ht_wng_O1$residuals

# specify formula to use in graphing fit curves and R2 values
formula_ht_wng <- y ~ poly(x, 1, raw = TRUE)

# use ggplot to plot all points, overlay call-out points, tweak aesthetics
ggplot(
data = combine_fin_df,
aes(x = height_wo_shoes, y = wingspan)
) +
geom_abline(slope = 1, intercept = 0, color = 'black', linetype = 2) +
geom_point(alpha = 0.1, color = 'blue') +
geom_smooth(method = 'lm',
formula = formula_ht_wng, color = 'black') +
ggpmisc::stat_poly_eq( aes( label = paste(stat(eq.label), stat(rr.label), sep = "~~~~~~~~") ),
formula = formula_ht_wng, parse = TRUE,
eq.x.rhs = ' ~italic(Height)',
eq.with.lhs = "italic(Wingspan)~=~",
rr.digits = 3) +
geom_point(
data = combine_fin_df_wingspanCallOuts,
color = 'red'
) +
geom_text_repel( # use ggrepel here to facilitate call-out labeling
data = combine_fin_df_wingspanCallOuts,
aes(x = height_wo_shoes, y = wingspan, label = player),
nudge_y = ifelse(combine_fin_df_wingspanCallOuts$lm_ht_wng_O1_resid > 0, 3, -3), nudge_x = ifelse(combine_fin_df_wingspanCallOuts$lm_ht_wng_O1_resid > 0, -1, 1)
) +
scale_y_continuous(
limits = c(5.5*12, 8.5*12),
breaks = c(5.5*12, 6*12, 6.5*12, 7*12, 7.5*12, 8*12),
labels = c('5\'6"', '6\'0"', '6\'6"', '7\'0"', '7\'6"', '8\'0"')
) +
scale_x_continuous(
limits = c(5.5*12, 7.5*12),
breaks = c(5.5*12, 6*12, 6.5*12, 7*12, 7.5*12),
labels = c('5\'6"', '6\'0"', '6\'6"', '7\'0"', '7\'6"')
) +
labs(
x = "Height (no shoes)",
y = "Wingspan"
) +
theme_minimal()

## Graph for height vs standing reach produced via nearly identical code

And finally, the two-panel plots linking height and winspan, respectively, with rebound, block, and steal rates (Figures 4 through 11):

# create variable for broad positional groupings
combine_advDef_final_df$position_gen <- NA_character_ combine_advDef_final_df$position_gen[
combine_advDef_final_df$position %in% c('PG','PG-SG', 'SG-PG') ] <- 'Lead Guard' combine_advDef_final_df$position_gen[
combine_advDef_final_df$position %in% c('SG','SG-SF', 'SF-SG', 'SF', 'SF-PF') ] <- 'Wing' combine_advDef_final_df$position_gen[
combine_advDef_final_df$position %in% c('PF-SF','PF', 'PF-C', 'C-PF', 'C') ] <- 'Big' combine_advDef_final_df$position_gen <- factor(combine_advDef_final_df\$position_gen,levels = c('Lead Guard','Wing','Big'))

# specify generic formulas for fit curves on graphs
formula_O1 <- y ~ poly(x, 1, raw = TRUE)
formula_O2 <- y ~ poly(x, 2, raw = TRUE)
formula_O3 <- y ~ poly(x, 3, raw = TRUE)

# create graph of 3rd order polynomial curve fit to rebounding vs. height relationship for players split out by position
p_ht_drbRt_posBrk <- combine_advDef_final_df %>%
filter(mp >= 1000 & !is.na(position_gen)) %>%
ggplot(aes(x = height_wo_shoes, y = drbpercent, group = position_gen)) +
geom_point(alpha = 0.1, aes(color = position_gen)) +
geom_smooth(method = 'lm', formula = formula_O3, se = FALSE,
aes(color = position_gen)) +
stat_poly_eq( aes(color = position_gen),
formula = formula_O3, parse = TRUE,
rr.digits = 3) +
scale_color_viridis(discrete=TRUE,
name = '') +
coord_cartesian(ylim = c(0,40)) +
scale_x_continuous(
limits = c(5.5*12, 7.1*12),
breaks = c(5.5*12, 6*12, 6.5*12, 7*12),
labels = c('5\'6"', '6\'0"', '6\'6"', '7\'0"')
) +
labs(
x = 'Height (no shoes)',
y = 'Defensive Rebound Rate',
title = 'By Position'
) +
theme_minimal() +
theme(
legend.position = 'bottom',
plot.title = element_text(hjust = 0.5, face = 'italic', size = rel(1.1))
)

# create graph of 3rd order polynomial curve fit to rebounding vs. height relationship for all players
p_ht_drbRt_allPos <- combine_advDef_final_df %>%
filter(mp >= 1000) %>%
ggplot(aes(x = height_wo_shoes, y = drbpercent)) +
geom_point(alpha = 0.1, color = 'blue') +
geom_smooth(method = 'lm', formula = formula_O3,
se = TRUE, color = 'black') +
stat_poly_eq(formula = formula_O3, parse = TRUE,
rr.digits = 3) +
coord_cartesian(ylim = c(0,40)) +
scale_x_continuous(
limits = c(5.5*12, 7.1*12),
breaks = c(5.5*12, 6*12, 6.5*12, 7*12),
labels = c('5\'6"', '6\'0"', '6\'6"', '7\'0"')
) +
labs(
x = 'Height (no shoes)',
y = 'Defensive Rebound Rate',
title = 'Total'
) +
theme_minimal() +
theme(
legend.position = 'bottom',
plot.title = element_text(hjust = 0.5, face = 'italic', size = rel(1.1))
)

# combine previous two graphs into side-by-side grid
g_ht_drbRt <- plot_grid(
p_ht_drbRt_allPos, p_ht_drbRt_posBrk,
align = 'h', axis = 'bt'
)

# create title for pair of graphs
title_ht_drbRt <- ggdraw() +
draw_label("Defensive Rebound Rate vs. Height",
fontface = 'bold')

# attach title to plots and display
plot_grid(
title_ht_drbRt, g_ht_drbRt, ncol = 1, rel_heights = c(0.1, 1)
)

## the code for the rest of the height/wingspan vs. defensive stats graphs is nearly identical to the above