NBA Hustle Modeling
Quantifying effort
Here, we’re looking at how hard players are working. This might be our least serious model yet, but I certainly had a lot of fun working on it.
The Model
As always, the Stan full model is at the bottom of the post. For each player, we learn two hierarchical latent variables that describe their interior and exterior hustle. This is very much in the spirit of our previous latent skill model for NBA shooting, but much less rigorous. These latent variables are simultaneously used to predict various hustle stats using a poisson distribution.
These are the hustle stats we included in our first iteration of the model.
Interior Hustle Stats:
Screen Assists
Box Outs
Contested Shots
Exterior Hustle Stats:
Deflections
Charges Drawn
Loose Balls Recovered
Defensive Speed
What’s funny, to me at least, is our poisson estimates use per-minute stats, which is usually a pretty naive way to model NBA performance. But in the case of hustling, I like it. What you want is someone who’s grinding every minute of the game.
Top Hustlers
Using this, we can get the bayesian posterior estimates for the top interior and exterior hustlers:
And we can break each player out on both dimensions. In line with expectations, centers have higher interior hustle latent skills, and guards have higher exterior hustle latent skills. The players in the top right are those who score high in both regards.
It’s pretty obvoius Paul Reed wins this model.
Let me know if there are any players you are particularly interested in, and I’ll highlight their posterior estimates.
Full Stan Model
/*
====================================================================
MODEL 1: PERIMETER HUSTLE (Guards/Wings)
Features: Deflections, Loose Balls, Charges (Poisson) + Speed (Normal)
====================================================================
*/
data {
int<lower=0> N; // Number of players
vector[N] minutes; // Exposure
// Counts (Poisson)
array[N] int deflections;
array[N] int loose_balls;
array[N] int charges;
// Continuous (Normal)
vector[N] speed; // Avg Speed Def
vector[N] distance; // Dist Miles Def
// Groups
int<lower=0> P;
array[N] int<lower=1, upper=P> position;
}
parameters {
// Latent Skill
vector[N] skill_raw;
vector[P] mu_skill;
real<lower=0> sigma_skill;
// Discrimination & Intercepts
real beta0_deflect; real<lower=0> beta1_deflect;
real beta0_loose; real<lower=0> beta1_loose;
real beta0_charge; real<lower=0> beta1_charge;
// Continuous Params (Speed ~ Normal(alpha + beta*skill, sigma))
real alpha_speed; real<lower=0> beta_speed; real<lower=0> sigma_speed;
real alpha_dist; real<lower=0> beta_dist; real<lower=0> sigma_dist;
}
transformed parameters {
vector[N] skill;
for (i in 1:N) {
skill[i] = mu_skill[position[i]] + skill_raw[i] * sigma_skill;
}
}
model {
// Priors
skill_raw ~ std_normal();
mu_skill ~ normal(0, 1);
sigma_skill ~ normal(0, 1);
// Priors for Counts
beta0_deflect ~ normal(-3, 2); beta1_deflect ~ normal(0.5, 0.5);
beta0_loose ~ normal(-4, 2); beta1_loose ~ normal(0.5, 0.5);
beta0_charge ~ normal(-5, 2); beta1_charge ~ normal(0.5, 0.5);
// Priors for Continuous
// Speed is around 3.5-4.5 mph
alpha_speed ~ normal(4, 1); beta_speed ~ normal(0.1, 0.1); sigma_speed ~ normal(0.5, 0.5);
// Likelihoods
deflections ~ poisson_log(log(minutes) + beta0_deflect + beta1_deflect * skill);
loose_balls ~ poisson_log(log(minutes) + beta0_loose + beta1_loose * skill);
charges ~ poisson_log(log(minutes) + beta0_charge + beta1_charge * skill);
// Continuous Likelihood
speed ~ normal(alpha_speed + beta_speed * skill, sigma_speed);
}
generated quantities {
vector[N] skill_out = skill;
}
/*
====================================================================
MODEL 2: INTERIOR HUSTLE (Bigs)
Features: Box Outs, Contests, Screen Assists (Poisson)
====================================================================
*/
data {
int<lower=0> N; // Number of players
vector[N] minutes; // Exposure
// Counts (Poisson)
array[N] int box_outs;
array[N] int contest_2pt;
array[N] int contest_3pt;
array[N] int screen_assists;
// Groups
int<lower=0> P;
array[N] int<lower=1, upper=P> position;
}
parameters {
// Latent Skill
vector[N] skill_raw;
vector[P] mu_skill;
real<lower=0> sigma_skill;
// Discrimination & Intercepts
real beta0_box; real<lower=0> beta1_box;
real beta0_cont2; real<lower=0> beta1_cont2;
real beta0_cont3; real<lower=0> beta1_cont3;
real beta0_screen; real<lower=0> beta1_screen;
}
transformed parameters {
vector[N] skill;
for (i in 1:N) {
skill[i] = mu_skill[position[i]] + skill_raw[i] * sigma_skill;
}
}
model {
// Priors
skill_raw ~ std_normal();
mu_skill ~ normal(0, 1);
sigma_skill ~ normal(0, 1);
// Priors for Counts
beta0_box ~ normal(-3, 2); beta1_box ~ normal(0.5, 0.5);
beta0_cont2 ~ normal(-2, 2); beta1_cont2 ~ normal(0.5, 0.5);
beta0_cont3 ~ normal(-2, 2); beta1_cont3 ~ normal(0.5, 0.5);
beta0_screen ~ normal(-3, 2); beta1_screen ~ normal(0.5, 0.5);
// Likelihoods
box_outs ~ poisson_log(log(minutes) + beta0_box + beta1_box * skill);
contest_2pt ~ poisson_log(log(minutes) + beta0_cont2 + beta1_cont2 * skill);
contest_3pt ~ poisson_log(log(minutes) + beta0_cont3 + beta1_cont3 * skill);
screen_assists ~ poisson_log(log(minutes) + beta0_screen + beta1_screen * skill);
}
generated quantities {
vector[N] skill_out = skill;
}



