NBA Latent Shooting Ability
Modeling raw shooting talent
Previously, we’ve developed a comprehensive hierarchical Bayesian model that we applied to FT shooting and 3PT shooting separately.
Here, we develop a more unified model that hierarchically learns each player’s latent shooting ability, by incorporating their FT shooting and 3PT shooting data from the 2025-26 season so far.
The Model
The full model is at the bottom of this post, but at a high level we’re using a hierarchical binomial model pooled by position to learn a latent shooting ability variable for each player. The shooting ability is used downstream to predict both their 3PT and FT shooting.
Here, on the x-axis we’re plotting the learned latent shooting skill, vs the actual/modeled FT and 3PT% of each player. To be honest, I was initially surprised at how well this works for predicting free throw shooting. 3PT shooting is a bit disappointing.
Top shooting talent in the NBA
Here’s our estimate for latent shooting skill for the top 20 players in the NBA, and then broken out by position below.
There’s a lot more we want to do. There are so many more events that our latent shooting ability variable can be used to predict (corner 3s, mid range shots, etc), which can help tighten up our estimates. For example, knowing how well someone shoots from mid range should help tighten up our estimate for how well they shoot from the free throw line. I’m looking forward to incorporating this data. More broadly, I’m becoming interested in these latent variables/vectors that can be used to predict specific events.
Full Stan Model
// Unified Shooting Ability Model
// Estimates a single latent "skill" per player that drives both FT% and 3P%
data {
int<lower=0> N; // number of players
int<lower=1> P; // number of positions (3)
array[N] int<lower=1,upper=P> position;
// FT Data
array[N] int<lower=0> fta;
array[N] int<lower=0> ftm;
// 3PT Data
array[N] int<lower=0> fg3a;
array[N] int<lower=0> fg3m;
}
parameters {
// Latent Skill (hierarchical by position)
// We model this as a Z-score (standard normal) for identifiability
vector[P] mu_skill; // Average skill per position
vector<lower=0>[P] sigma_skill; // Variance in skill per position
vector[N] raw_skill; // Player-specific skill deviation
// Shot Mechanics (Intercepts & Slopes)
real beta0_ft; // Base difficulty FT
real<lower=0> beta1_ft; // How much skill helps FT (discrimination)
real beta0_3p; // Base difficulty 3PT
real<lower=0> beta1_3p; // How much skill helps 3PT (discrimination)
}
transformed parameters {
vector[N] skill;
// Non-centered parameterization for skill
for (n in 1:N) {
skill[n] = mu_skill[position[n]] + sigma_skill[position[n]] * raw_skill[n];
}
}
model {
// 1. Priors
// Skill priors
mu_skill ~ normal(0, 1);
sigma_skill ~ normal(1, 0.5);
raw_skill ~ std_normal();
// Shot parameter priors
beta0_ft ~ normal(1.1, 0.5); // ~75% baseline
beta1_ft ~ normal(1, 0.5); // Positive correlation
beta0_3p ~ normal(-0.5, 0.5); // ~38% baseline
beta1_3p ~ normal(1, 0.5); // Positive correlation
// 2. Likelihoods
ftm ~ binomial_logit(fta, beta0_ft + beta1_ft * skill);
fg3m ~ binomial_logit(fg3a, beta0_3p + beta1_3p * skill);
}
generated quantities {
vector[N] ft_pct;
vector[N] fg3_pct;
for (n in 1:N) {
ft_pct[n] = inv_logit(beta0_ft + beta1_ft * skill[n]);
fg3_pct[n] = inv_logit(beta0_3p + beta1_3p * skill[n]);
}
}





