Modeling NBA Playmaking Latent Ability
If you’re a subscriber, you’ve probably noticed we’re on a bit of a latent skill modeling kick. First it was shooting ability, then hustling. In this post, we’re going to take a look at modeling players’ underlying playmaking ability
The Model
We start with a hierarchical model pooled by position (full model at the end), where we learn a latent playmaking variable for each player that predicts their downstream playmaking stats including:
Potential Assists
Secondary Assists
Assist Points Created
Turnover Rate
Unlike hustle stats which were modeled per minute (for good reason), these are modeled per possession.
The full stan model is at the bottom of this post.
Results
Here’s our top playmakers
But this doesn’t tell the whole story, maybe some of these players are making plays by taking a lot of risk. Comparing it to their turnover rate tells an entirely different story:
This highlights how efficient playmakers like Chris Paul, Tyrese Haliburton, and Tre Jones are. While James Harden, Cade Cunningham, and Trae Young are on the other end of the spectrum.
Chris Paul is an obvious stand-out in the above plot. In the next article, I’m going to highlight how he has (or hasn’t) changed in playmaking ability over time. Spoiler: he hasn’t.
Full Stan Model
data {
int<lower=0> N; // Number of players
vector[N] possessions; // Exposure: Possessions
vector[N] passes_made; // Exposure: Passes
// Counts (Poisson)
array[N] int potential_ast;
array[N] int secondary_ast;
array[N] int turnovers;
// Continuous (Normal)
vector[N] ast_pts_created;
// Groups
int<lower=0> P;
array[N] int<lower=1, upper=P> position;
}
parameters {
// Latent Skill (Vision)
vector[N] skill_raw;
vector[P] mu_skill;
real<lower=0> sigma_skill;
// Discrimination & Intercepts (Per 100 Possessions)
real beta0_pot; real<lower=0> beta1_pot;
real beta0_sec; real<lower=0> beta1_sec;
// Turnovers (Per 100 Passes)
real beta0_tov; real<lower=0> beta1_tov;
// Points Created (Continuous)
real alpha_pts; real<lower=0> beta_pts; real<lower=0> sigma_pts;
}
transformed parameters {
vector[N] skill;
for (i in 1:N) {
skill[i] = mu_skill[position[i]] + skill_raw[i] * sigma_skill;
}
}
model {
// Hierarchical Skill Priors
skill_raw ~ std_normal();
mu_skill ~ normal(0, 1);
sigma_skill ~ normal(0, 1);
// Priors for Counts (Log scale)
beta0_pot ~ normal(2.5, 1); beta1_pot ~ normal(0.5, 0.5);
beta0_sec ~ normal(0.5, 1); beta1_sec ~ normal(0.5, 0.5);
// Priors for Turnovers
beta0_tov ~ normal(-2.5, 1); beta1_tov ~ normal(0.5, 0.5);
// Priors for Continuous
alpha_pts ~ normal(30, 10); beta_pts ~ normal(5, 5); sigma_pts ~ normal(5, 5);
// Likelihoods
// 1. Potential Assists
potential_ast ~ poisson_log(log(possessions) + beta0_pot + beta1_pot * skill);
// 2. Secondary Assists
secondary_ast ~ poisson_log(log(possessions) + beta0_sec + beta1_sec * skill);
// 3. Turnovers (Negative relationship with Skill)
turnovers ~ poisson_log(log(passes_made) + beta0_tov - beta1_tov * skill);
// 4. Assist Points Created
ast_pts_created ./ (possessions / 100.0 + 1e-9) ~ normal(alpha_pts + beta_pts * skill, sigma_pts);
}
generated quantities {
vector[N] skill_out = skill;
}


