Jazz Refs Bot
A Twitter bot that posted referee stats before Utah Jazz games.
Today's @utahjazz vs. @Warriors game will be reffed by 1. Curtis Blair: averages 0.78 bad calls per minute in Last 2 Minute Reports, ranking #77 of 79 NBA refs 2. Mark Lindsay: 0.45 bad calls/min - #27/79 3. James Capers: 0.4 bad calls/min - #17/79 #takenote
— Tonight's Jazz Refs (@JazzRefs) May 10, 2021
Back in 2021, when X was Twitter and bots were adorable little cron jobs, I built a bot that scraped the NBA's Last Two Minute Reports and used them to rank the referees.
I then alerted Jazz fans to what they had to look forward to that night.
The job did the following:
- Checked the NBA's officiating site for tonight's Jazz game and its assigned crew.
- Scraped any Last Two Minute Reports published since the last run and appended them to a running dataset.
- Scored every referee in the league on bad calls per minute reviewed, where a bad call is an incorrect call or an incorrect non-call.
- Wrote the post and posted it.
The stats
The Twitter bot just used a simple average (bad calls per minute while they were on the floor) for ranking the refs. This is obviously imperfect for a few reasons:
- Sample sizes vary a lot. One ref had been reviewed for 4 minutes and another for 70. A ref with 4 clean minutes would be ranked first without a prior.
- Blame is shared. Three refs are on the floor for every bad call, so a ref who works with error-prone crews gets blamed for their mistakes.
To improve this, I wrote some (now defunct) blog posts where I built a hierarchical Bayesian Poisson model to properly estimate error rates. Each ref has their own bad-call rate, and a game's expected number of bad calls is the sum of its crew's rates. A shared prior pulls refs with little data toward the league average.
The main takeaway from this model is that the Last Two Minute Reports don't provide nearly enough data to definitively rank NBA refs' skill, as can be seen in how wide the posterior credible intervals on the refs' rankings are in the graphic below:
The Stan model
data {
int<lower=0> N_games;
int<lower=0> N_refs;
int bad_calls[N_games];
vector[N_games] minutes;
matrix[N_games, N_refs] refs; // 1 if the ref worked that game
}
parameters {
vector<lower=0>[N_refs] r; // each ref's bad calls per minute
real<lower=0> theta; // league-wide average rate
}
transformed parameters {
vector<lower=0>[N_games] mu;
for (i in 1:N_games)
mu[i] = minutes[i] * dot_product(r, row(refs, i));
}
model {
for (i in 1:N_games)
target += poisson_lpmf(bad_calls[i] | mu[i]);
target += exponential_lpdf(r | 1 / theta);
target += exponential_lpdf(theta | 10);
}