2026-08-19 · Machine learning
Building the NBA Rebound Predictor
Predicting who comes down with a rebound, from tracking data alone — no player metadata, no reputation, just where everyone is and how they move.
Overview
Inspired by a story about Dennis Rodman, who Isiah Thomas described in a 2011 interview as studying the rotation of every teammate's shot:
"He knew the rotation of every person that shot on our team — if it spins sideways, where it would bounce, how often it would bounce left or right. He had rebounding down to a science, and I never heard anyone think or talk about rebounding and defense the way he could break it down."
If Rodman could learn it, a model should be able to. This project models rebounds purely from positional data, deliberately ignoring who the players are, to isolate how much of rebounding is just position and movement.
I walk through the model in a talk on YouTube.
Status
The 2017 pipeline was Python 2 and no longer ran; the 2020 port on rewrite-pd was
abandoned half-finished. The extraction pipeline has been rebuilt as the
rebounding package on Python 3, with the correctness problems described below
fixed and a test suite over a committed sample game. The rebounder model has been
rebuilt on top of it: 29.8% top-1 from the inputs the web app can actually supply,
against 26.8% for the model family it replaces.
The rebounder is buildable in one command. python -m rebounding.cli train fits
it and writes FinalModel.pkl — the model, the fitted ShotPriors, the served
feature list in fitted order, and the provenance to identify the file later. It takes
about eighteen seconds. The shipping fit uses train+val and scores 29.7% top-1 on
the untouched test games; every ladder number below is the train-only fit, which is
what keeps the regimes comparable.
--regime picks which of the project's three models to build. served is the web
app's, restricted to what the UI can supply; all is the feature-rich source of
truth, which scores 37.8% and which rebounding.serve refuses to serve because
its features do not exist at prediction time. The 8.0 points between those two is the
budget for the third model.
The movement model now lives here too. python -m rebounding.cli train-movement
writes MovementModel.pkl — a set transformer over the ten players that predicts where
they will be when the ball reaches the rim, and the replacement for the 2017 posnn.h5.
It is a generative model: it samples whole coherent scenes rather than predicting one
position per player, which is what the app should draw and what the old one could not
do. See The movement model below for what that changed and what it did not.
The app calls one function. rebounding/serve.py::predict takes ten placed
positions, which team is attacking and who shot, and returns a probability per player
in the caller's own order. It reproduces the training path's 27 features to 0.0 maximum
absolute difference on real shots, which is the property the web app never had: there
is now exactly one definition of every feature, and it lives here.
The weights are deliberately not committed. .gitignore still excludes *.pkl
and *.h5, and the artifact reaches the web app host by scp rather than through git.
That is a choice this time rather than the accident that lost the 2017 model, and it
is why the bundle carries its own provenance: cli describe --model FinalModel.pkl
answers "which model is on the box?" from the file itself. MovementModel.pkl ships the
same way and answers the same question. The 2017 movement model (posnn.h5) and its
normaliser (msd.pkl) survive only in the companion web app repo,
riders994/ReboundWebApp, and are now
superseded rather than needed — the replacement carries its normalisation inside the
bundle, so there is no second file to keep in step with the weights.
Data
| source | what |
|---|---|
| STATS SportVU | player and ball positions at 25 Hz, 636 games of 2015-16 |
| Basketball-Reference play-by-play | shot and rebound events |
See data/MANIFEST.md. The SportVU feed was withdrawn in 2016
and the NBA moved to Second Spectrum in 2017-18, so this corpus cannot be extended —
about 60k rebounds is the hard ceiling, which constrains how large a model is
sensible.
The play-by-play source moved too. stats.nba.com/stats/* no longer answers: it
accepts the connection and holds it open until timeout, from a logged-in browser
session as readily as from a script. Its siblings are gone as well — data.nba.net
does not resolve and cdn.nba.com's live feed 403s a 2015-16 game. Events now come
from Basketball-Reference via rebounding/data/bref.py,
which emits the same frame the NBA feed did, so pairing and everything after it are
unchanged. pbp.py keeps the parsing and pairing but no longer fetches: its
fetch() reads the disk cache and raises on a miss.
One game — the sample game — still has a cached NBA payload from before the shutoff,
and tests/test_bref.py uses it to hold the two sources to each other. Over its 106
misses, shooter, shooting team, rebounding team, blocker and team-rebound flag agree
100%; clocks agree within one second; two rebounds (1.9%) are credited to
different players, both times to a teammate, which is a scoring difference between
the feeds rather than a parse error. End to end the replacement is marginally the
better source, because it states a shot distance on layups and dunks where the NBA
text omits one:
| shots paired | median error | within 3 ft | distance stated | |
|---|---|---|---|---|
| NBA feed | 78/106 | 1.24 ft | 82% | 94.3% |
| Basketball-Reference | 78/106 | 1.00 ft | 83% | 98.1% |
Pipeline
rebounding/
constants.py court geometry, single source of truth
data/
sportvu.py tracking JSON -> numpy arrays + ball features
pbp.py play-by-play -> shots paired with their rebounds
bref.py Basketball-Reference pages -> the same frame pbp.py returns
court.py folding full court onto the attacking half
pairing.py locating each play-by-play shot in the tracking data
features.py per-shot, per-player features in canonical slot order
derived.py relative and shot-context features, computed from the frame
build.py bulk build across games
models/
baselines.py slot prior and nearest-to-rim, the floors
conditional_logit.py grouped softmax over the ten players
boosted.py the same loss with a tree ensemble for a score function
forest.py per-row random forest, reproducing the 2017 shape
eval/
split.py chronological game-level train/val/test
metrics.py top-1, top-3, MRR, log loss
baseline.py the ladder: every model in every feature regime
cli.py
uv venv && uv pip install -e ".[dev]"
pytest
python -m rebounding.cli inspect --game data/7zips/01.01.2016.CHA.at.TOR.7z
python -m rebounding.cli build --games data/7zips --out data/frame.parquet
build needs play-by-play. It reads a cached NBA payload from --pbp-cache when one
exists for that game and otherwise fetches from Basketball-Reference into
--bref-cache (default data/bref, gitignored). Fetching is rate limited to 20
requests a minute, which their terms ask for and they enforce with a temporary ban,
so the first full run over 636 games takes about 35 minutes. Later runs are offline.
What was wrong with the old pipeline
Each of these is now covered by a regression test.
Court folding destroyed left/right. The whole of the orientation logic was
abs(PlayerX - 47). Folding x alone is a reflection, so the same play run at
either end landed on opposite sides of the folded half court, superimposing
left-wing and right-wing shots. Worse, it was applied per player using that player's
own x, so the attacking basket was never actually determined: a defender who hadn't
crossed half court got mirrored into the front court and recorded 34.75 ft from the
rim when he was really 48.75 ft away. The fix is a 180° rotation for the left half,
with the basket resolved once per shot from the ball at the rim.
Shot-to-rebound pairing was unvalidated and often wrong. findShot searched
backward for "ball started rising", which fires on every dribble bounce, every pass
apex, and every rattle of the ball on the rim — 9,073 times in the sample game
against roughly 176 shots — with no bound on how far back it looked. When two shots
resolved to the same tracking frame, the rimErr fallback substituted an arbitrary
moment with no rim condition at all, and stored empty results that became NaN rows.
Nothing measured whether any of it worked.
It is measurable now, by comparing the tracking-derived release distance against the distance stated in the play-by-play text. On the sample game's 95 unblocked misses:
| paired | median error | within 3 ft | |
|---|---|---|---|
| original logic, ported as-is | 70 | 10.06 ft | 29% |
| current | 78 | 1.31 ft | 84% |
Pairing on the game clock let shots span a dead ball. This one was a fault in the rebuilt pipeline, not the old one, and it is the reason the corpus had a tail of impossible shots. Release candidates were bounded by 0.3–3.0 s of game clock, and the game clock stops: on a whistle, a timeout, a review. A release and a rim contact either side of a stoppage can be thirty seconds apart in real time while the clock shows two, so the bound accepted them and the shot was paired across the dead ball — with the "shooter" standing at the far end of the floor in a previous possession.
Every pairing that put a shooter more than 60 ft from the rim was one of these. Over 90 games there were 13 of them, median wall-clock flight 36 s, which the play-by-play describes as layups and short jumpers from 1 to 26 ft. They were not full-court heaves; there are none in the corpus, because a buzzer miss is never credited a rebound and so never pairs.
The moments already carry a wall-clock Timestamp, so the fix is to apply the same
bound to real time (MAX_WALL_FLIGHT_SECONDS) and to derive FlightTime from it. The
game-clock interval stays recoverable as ReleaseClock - RimClock. For 97.8% of shots
the two agree within 0.1 s; where they disagree, the timestamp is the one telling the
truth about how long the ball was in the air.
| before | after | |
|---|---|---|
| shots in the corpus | 42,255 | 41,679 |
| max shot distance | 95.5 ft | 62.0 ft |
| shooters > 60 ft | 118 | 2 |
| shooters > 40 ft | 139 | 17 |
940 shots are now dropped as release_only_across_a_stopped_clock; 576 of those had
been reaching training, the rest were already being caught downstream by the
substitution check. None were recovered — where the clock lies there is usually no
valid alternative release, so the shot is dropped rather than repaired. Accuracy is
unchanged within noise in every regime, which is the expected result for a fix that
removes 1.4% of the data: it buys correctness, not points.
Flight time saturates, and no polynomial says so. With those points gone,
flight_hat's quadratic no longer had anything dragging its tail up: it peaked at
27.9 ft and fell away, predicting that a 48.7 ft shot — the furthest a user can place a
shooter on the app's canvas — hangs 1.46 s, less than a ten-footer, and going negative
past 92 ft. The measured curve climbs to 2.33 s by 30 ft and then flattens, because past
that range the shot is taken on a flatter, harder trajectory.
Raising the degree does not help, and is recorded here so nobody retries it: degrees 2,
3, 4 and 6 all sit within half a millisecond of each other on validation RMSE, every one
is non-monotone, and the higher ones are wilder in the tail — 5.17 s at 62 ft for the
quartic, 9.46 s for the sextic. Saturating forms (log(1+d), sqrt(d), 1 - exp(-d/k))
are monotone but fit no better and the first two keep climbing where the data flattens.
predicted_flight holds the peak instead, which is monotone, scores marginally better
on validation than the unclamped fit (0.4035 against 0.4037), and lands within 0.05 s of
the measured plateau.
Other fixes. 44% of shots lost their description, because only
HOMEDESCRIPTION was kept. 11% of rebounds — the team rebounds — were silently
discarded, biasing the training set toward clean uncontested boards. Transition
flags were encoded as "clock, or 0", so RimStart >= t matched an entire quarter
whenever a shot went up at 0:00. .shift() ran across quarter boundaries.
massunpack overwrote its own run method with a float and wrapped everything in
except Exception: pass, so a run that lost a third of the season looked identical
to one that lost nothing. The whole game's tracking data was held as Python lists
inside DataFrame cells.
Velocity was never computed at all. The pipeline extracted two isolated frames per shot and nothing in between, so both models were being asked to reason about movement from a pair of still photographs.
This was originally written down as the most likely single reason the movement model underperformed. That guess is now measured and it was too strong. Velocity is worth 1.9 points of top-1 to the rebounder, and a player's release-time heading predicts his rim-time position only about four tenths of a second into the flight — extrapolating along it for the full flight is worse than assuming he never moves. Velocity was missing and is worth having, but its absence does not account for the movement model's behaviour; the L2 objective on a multimodal target does. See Where the gains came from below.
Modelling
Results
python -m rebounding.cli baseline --forest --on test, on a chronological game-level
split (441 train / 95 val / 95 test games), team rebounds excluded, scored per shot as
top-1 of ten players. Feature selection and hyperparameters were chosen on validation;
the table below is the single read on the held-out test split.
| regime | what it knows | slot prior | nearest to rim | conditional logit | random forest | boosted softmax |
|---|---|---|---|---|---|---|
| static | ten positions | 24.0% | 23.2% | 26.4% | 26.8% | 27.6% |
| served | + derived features | 24.0% | 23.2% | 27.7% | 29.2% | 29.8% |
| release | + real velocity | 24.0% | 23.2% | 27.1% | 28.9% | 28.6% |
| release+derived | both | 24.0% | 23.2% | 29.7% | 30.9% | 30.8% |
| rim | rim-time positions | 24.0% | 33.7% | 34.8% | 35.6% | 35.8% |
| all | everything | 24.0% | 33.7% | 35.9% | 37.7% | 37.4% |
static is what the web app can supply — a user places ten dots. served adds the
derived features, which need nothing further from the user. The two rows below them
add a release velocity the app has no way to collect, and are there to price it.
Four things fall out of this.
The servable model went from 26.8% to 29.8% without asking the UI for anything new. Roughly a third of that is the model and two thirds the features; §Where the gains came from below splits it.
The train/serve gap is 6.0 points (35.8% at rim time against 29.8% from what the app can supply). That is the entire budget a movement model has to earn back. It was 8.2 points before this round of work and it shrinks every time the release-time model improves, which is worth stating plainly: effort spent on the rebounder and effort spent on the movement model are drawn from the same account, and the rebounder is the cheaper of the two.
Nothing separates the top three models by much. Boosted softmax, random forest and — with the derived features in hand — the conditional logit land within about 1.5 points of each other, and the boosted model does not win every row. On 5,690 validation shots an unpaired standard error is 0.6 points, so differences under about 1.2 points are not distinguishable at all; the comparisons quoted below use a paired bootstrap over games, which is considerably tighter. The features moved the number. The model choice mostly did not.
The gap between top-1 and top-3 is large and stable — 29.8% against 66.7% in the served regime. The model is ranking sensibly and failing to separate the leaders, which is what one would expect of an event with a genuinely random component. A tool that narrows ten players to three is right two times in three.
The 2017 figure of 86% top-1 does not survive contact. Reproducing that model family and scoring it three ways on the same data:
| metric | value |
|---|---|
| per-shot top-1 | 35.8% |
| per-row binary accuracy | 90.1% |
| per-row accuracy, predicting "nobody" for all ten | 90.0% |
| per-row AUC | 0.829 |
86% is not reachable as top-1 from these features, sits below the 90% that predicting "nobody rebounds" scores by construction, and lands close to the 0.829 per-row AUC. The most likely reading is that it was a per-row metric — AUC or accuracy over the ten-times-longer row table — rather than the per-shot question the app actually asks. The 2017 code is gone, so this can't be confirmed directly; the weights survive only in the web app repo. Treated as a per-row number it is unremarkable, and nothing in the current data supports quoting 86% as top-1.
Where the gains came from
The features, mostly, and the relative ones above all. Every feature in the
original pipeline described a player on his own — his coordinates, his distance to the
rim, his angle. Rebounding is a contest between ten people, so the quantities that
decide it are comparisons: who is inside whom, who has space, how a player ranks
against the other nine rather than how many feet he stands from the basket.
rebounding/data/derived.py adds those, and they are a
pure function of the ten positions, so the web app can compute every one of them from
what a user already places on the court.
The importance ranking makes the point better than the accuracy table does. In the
served regime the model puts 31% of its gain on n_opp_inside — how many opponents
are nearer the basket than this player — and another 11% on dist_minus_mean. Raw
pre_dist, the feature the old model leaned on, drops out of the top ten entirely
once the same information is present in relative form.
The model change is real but second-order. Three ways of getting a tree ensemble to answer "which of these ten", on the same release-time features:
| model | validation top-1 |
|---|---|
| random forest, per-row binary, renormalised afterwards | 27.6% |
LightGBM lambdarank, groups of ten |
28.0% |
| LightGBM, grouped softmax | 28.5% |
which is the ordering
conditional_logit.py predicts: a per-row
binary fit never sees the constraint that exactly one of the ten is the rebounder, and
lambdarank optimises a ranking surrogate rather than the likelihood. Matching the
loss to the metric is worth about a point.
boosted.py is that grouped softmax with a tree
ensemble for a score function.
Velocity is worth less than it looks, and says something. Over a shot's flight players travel 7.67 ft on average. Extrapolating each along his release velocity lands 8.46 ft from the truth — worse than assuming he never moves, at 8.41 ft. Damped by the best-fitting factor of 0.38 it improves to 6.49 ft. Release-time velocity is worth about four tenths of a second of movement and nothing after that, because what a player does next is dominated by an intention his current heading does not reveal. That is the strongest argument in this repo for a generative scene model over a kinematic one, and equally the reason no cheap version of one will do.
The movement model
The third of the three, and the one that had the most room in it. It predicts where the
ten players will be when the ball reaches the rim, from where they were at release —
which is both the app's whole interaction (place ten dots, press run, watch) and the
feeder for the served+movement regime. python -m rebounding.cli train-movement fits
it and writes MovementModel.pkl.
It is a set model, and the set part is not decoration. Ten players with no natural order, whose interaction is the phenomenon — a box-out is a pairwise relation, and a model that sees one player at a time cannot represent one. The encoder is four self-attention layers with no positional encoding, so it is permutation equivariant by construction: hand the players over in any order and the predictions follow them. The canonical slot order is a tensor-layout convenience, not information this model is allowed to lean on, and there is a test that says so.
What broke the 2017 version was the loss, not the architecture. An offensive player fourteen feet out either crashes the glass or leaks out in transition; over the training games offence closes on the rim 45.7% of the time, and inside a single starting band the change in rim distance runs from −12.5 ft at the 5th percentile to +12.6 ft at the 95th. Squared error on a target like that returns the conditional mean, which is neither future. So the same encoder carries three interchangeable heads, and the comparison isolates the objective rather than the network:
| head | what it predicts | error | minADE player/scene | cov50 | cov90 | contacts | crash off/def |
|---|---|---|---|---|---|---|---|
| truth | what happened | — | — | — | — | 0.55 | 44.8% / 69.0% |
| stay put | nothing moves | 7.52 ft | 7.52 / 7.52 | 0% | 0% | 0.41 | 0% / 0% |
| damped velocity | straight lines | 6.25 ft | 6.25 / 6.25 | 0% | 0% | 0.52 | 51.0% / 58.4% |
point |
one displacement, L2 | 5.01 ft | 5.01 / 5.01 | 0% | 0% | 0.77 | 50.8% / 84.4% |
mixture |
6 Gaussians per player, NLL | 4.88 ft | 1.78 / 4.73 | 44.0% | 82.4% | 0.65 | 44.9% / 69.6% |
cvae |
one latent per scene, ELBO | 4.94 ft | 1.88 / 4.83 | 47.0% | 83.1% | 0.56 | 46.3% / 70.2% |
Test split, 5,756 shots, 20 samples each. contacts is pairs of players inside two feet
of each other per scene, and the row to compare it against is truth, not zero — real
basketball has contact. cov50/cov90 should read 50% and 90% for a calibrated model.
The damped-velocity row is a reference and not a candidate: it needs a release velocity
the web app has no way to collect.
Every column but error is a Monte-Carlo estimate over 20 draws, so read the last digit
as noise. Re-scoring the shipped bundle at five seeds moves minADE by 0.01 ft, cov50
and cov90 by 0.3 points, and contacts by 0.007 — small enough not to change any
reading above, and large enough that two of the figures quoted here (cov90 83.1%,
contacts 0.56) sit at the flattering edge of that range rather than in its middle.
error involves no sampling and is exact at 4.938 ft.
Four readings.
The architecture pays for itself before any head does. point is the 2017 shape and
lands 5.01 ft from the truth using no velocity at all, against 6.25 ft for
extrapolating a damped real one. Players travel 7.52 ft over a flight, so staying put
costs 7.52 ft and this is a real reduction rather than a shrunken guess.
And it drifts, exactly where the argument says it will. point sends defenders
rimward on 84.4% of shots against a real 69.0%, and produces 40% more player collisions
than the corpus contains. That is a model averaging two opposite intentions, and it is
the failure a user watches happen rather than one that shows up in a table.
Changing only the objective fixes the drift. mixture is the same encoder with a
six-component Gaussian mixture per player and an exact likelihood; its crash rates land
on the real ones. Its minADE tells the other half: among twenty draws one lands 1.78 ft
from the truth per player but 4.73 ft per scene, because nothing couples one
player's choice of component to another's. Ten individually plausible players; no
coherent arrangement.
The scene latent buys plausibility, not accuracy — which was not the prediction.
cvae was expected to close that per-player/per-scene gap and does not (1.88 / 4.83, a
shade worse than mixture on both). What it does deliver is the thing that is visible
on screen: 0.56 contacts per scene against the corpus's 0.55, the best of any head,
and the best calibration. Diagnosing why gives the reason and the fix — the latent is
only carrying 15.4% of the sample variance, with 12 of its 16 dimensions collapsed to
the prior, because the KL term makes independent per-player noise the cheaper way to
explain the data. The coupling it does produce is real: the ten players' latent-driven
deviations correlate at 0.425. There is simply not enough of it.
Loosening the KL term is the obvious fix for that collapse and it does not work.
Refitting the same head at kl_weight 0.2, on validation:
| variant | error | minADE player/scene | cov50 | contacts | crash off/def |
|---|---|---|---|---|---|
| truth | — | — | — | 0.58 | 45.8% / 69.3% |
cvae, default |
4.90 ft | 1.89 / 4.82 | 46.8% | 0.56 | 46.2% / 70.2% |
cvae, kl_weight=0.2 |
5.80 ft | 2.21 / 5.53 | 47.5% | 0.52 | 44.8% / 64.9% |
Giving the latent more room widened the per-scene gap rather than closing it, cost 0.9 ft of accuracy, and pushed the defensive crash rate below the real one. Recorded so nobody retries it — with the caveat that it stopped at epoch 118 of 120 and had not plateaued, so some of that is undertraining rather than the weight being wrong. If the scene gap is worth another attempt, the thing to change is the decoder rather than the weight: most of the sampled spread is its independent per-player sigma, and a decoder that had to route more of the variance through the shared latent would be a sharper test than annealing the penalty in front of it.
What it is worth to the rebounder: almost nothing, and that is the finding
The movement model was meant to have a second job. The served+movement regime feeds
its predicted rim-time positions back to the rebounder as ten extra columns, and the
gap between what the app can supply and rim-time truth was supposed to be the budget it
would earn back. Fitting all of it — the rebounder trained on the movement model's
predictions, never on the corpus's real pos_*, because training on truth and serving
forecasts is the skew that produced 2017's 86%:
| what the rebounder eats | val | test |
|---|---|---|
release only (SERVED_FEATURES) |
28.9% | 30.0% |
| + rim-time columns from "stay put" | 28.8% | 29.7% |
| + rim-time columns from the movement model | 29.9% | 30.1% |
| + the same, marginalised over 16 sampled scenes | 29.5% | 29.8% |
| + rim-time columns from truth | 37.4% | 38.1% |
Read the test column; the validation column is the movement model's own early-stopping
set and is not clean for it. The release-only row is refitted here rather than copied
from the ladder above, and lands at 30.0% against that table's 29.8% — LightGBM under
n_jobs=-1 is not bit-reproducible, and 0.2 points is well inside the 0.6-point
standard error. All five rows share the settings, which is what the comparison needs. Knowing where the ten players actually end up is worth
8.1 points. The best forecast of where they will end up is worth 0.1. The
movement model captures about one percent of its own budget.
That is not a bug in the forecast. The "stay put" row is the control and shows the ten extra columns carry nothing on their own; the movement model's columns are genuinely better than that and still buy nothing. Nor is it over-smoothing: sampled scenes have realistic spread — that is what the calibration and contact numbers above establish — and marginalising over them scores worse, not better. The reading that survives is that the predictable part of a player's next 1.9 seconds is not the part that decides the rebound. What the model gets right is the part that was already implied by where everyone was standing, which the rebounder could see for itself; what decides the board is the residual, and the residual is what "unpredictable" means.
So the recommendation in docs/webapp-handoff.md §5 stands, and now has a measurement
under it rather than a caution: run the rebounder on release-time features, and let
the movement model drive the animation beside it rather than upstream of it. It is a
product feature. It is not an accuracy device, and this is what it cost to find out.
Score it on none of this by accident. rebounding/eval/movement.py reports mean
displacement error and never optimises against it, because that metric is what produced
the drift. What it tunes on instead: whether the truth falls inside the predicted
distribution, whether sampled scenes are physically possible — collisions and implied
speeds, measured against the same statistics computed on real rim-time frames — and
whether the model reproduces the crash rate rather than averaging over it.
Hardware was never the constraint, and in 2017 it was not the missing piece either. Everything above is three models trained on a 2019 laptop CPU with no GPU, over 26,733 training shots of ten players; the longest run was 60 minutes and most of that was contention. What was actually missing in 2017 was permutation-equivariant layers, which arrived that year and were not yet usable, and a density head — which existed, and was not reached for.
Out of scope on purpose: who the players are
The premise is predicting a rebound from location data alone, so per-player history is excluded by design rather than by oversight. This is worth stating because it is not a null result — it works.
A smoothed per-player historical rebound rate, fitted on the training games and applied
to the later ones, is worth +1.2 points of top-1 (29.7% → 30.9%). Only about 0.3 of
that is the position effect the model already has through role; the remaining ~0.9 is
individual, separating players who share a listed position. It is measuring real
rebounding — the highest fitted rates are Drummond (.338), Whiteside (.320) and DeAndre
Jordan (.308), the lowest Isaiah Thomas (.076), Redick (.072) and Wiggins (.072). It
also would not tie the app to this corpus, since any published rebound rate could be
fed at serving time.
It is still out, because "which of these ten players gets the board, knowing only where they stand" is the question the project exists to answer. Adding a scouting prior answers a different and easier one.
role — the listed-position ordinal — is the deliberate exception. It is an attribute
of the player rather than a location, and it is worth 2.1 points (3rd of 27 by gain),
but it was part of the original 2017 model concept and is kept for continuity with it.
If it is ever dropped it must be removed and retrained, never defaulted: serving a
placeholder 3.0 to a model trained on real positions scores 26.9%, worse than the 27.6%
of an honest model that never had the feature.
What was tried and did not work
Recorded because the next person will otherwise try them again.
| idea | result |
|---|---|
| spatial target encoding: smoothed grid of "who rebounds from here", per shot-distance bucket and team | −0.02 pts. Boosting over pre_x, pre_y, is_offense, shot_dist already recovers it |
| neighbour context: attach the nearest opponent's and nearest teammate's own features to each row | −0.50 pts, not distinguishable from noise |
| hierarchical P(team) × P(player | team) instead of one softmax over ten | −1.30 pts, and that one is significant |
| set model: permutation-equivariant net pooling over own team and opponents, 3 blocks | 30.5% against the boosted model's 30.7% — a tie |
| blending the boosted model with the conditional logit, or with the set model | +0.3 pts at best, inside the noise |
| measured flight time instead of predicted | +0.23 pts, not distinguishable — so the servable version costs nothing |
The set model is the interesting negative. It is the architecture the rebuild plan
proposes for the movement model, applied to the rebounder, and on 27k training shots
it matches gradient boosting rather than beating it. Depth tells the same story from
the other direction: 127 leaves scored a point worse than 63 at every learning rate
tried. The corpus cannot grow — see data/MANIFEST.md — so this is
a standing constraint, not a tuning accident. Capacity is not what is missing.
What is missing is more likely information. Nothing in the current feature set knows the ball: its arc, its entry angle, where it actually caroms. Those need a rebuild of the frame from the tracking corpus rather than a better model, and only the parts of them a user could plausibly specify are servable.
What follows describes the 2017 work and what replaces it.
Two models, chained. The rebounder model predicts who gets the board from where everyone stands when the ball reaches the rim; a random forest was reported at 86% top-1, which the section above shows was almost certainly a per-row metric. The movement model exists because the web app can only ask a user for positions at release, so something has to predict where players will be a second later.
The movement model never worked well. It was a Keras dense net doing deterministic point regression from one snapshot to another, one row per player. Three problems: no velocity input; an L2 objective on a multimodal target, which returns the conditional mean and so drifts every player toward the paint; and no interaction term, so nothing stopped two predicted players occupying the same square foot and box-outs were unrepresentable.
There is also a train/serve skew that no amount of movement-model quality fixes: the rebounder was trained and evaluated on ground-truth rim-time positions but served predicted ones. That skew is now measured rather than argued about — 6.0 points of top-1 — and the honest headline number is accuracy from what the app can supply, 29.8% for the train-only fit the ladder above compares and 29.7% for the weights that actually ship, both on the same held-out test games.
The rebounder half of the rebuild plan is done: the grouped softmax matches the
evaluation metric directly and removes the class-weight hacks the original needed, and
features.to_tensor emits the (n_shots, 10, n_features) form in canonical slot order
— offense then defense, each nearest-to-rim first — which is what lets any of these
models consume a whole shot at once.
What remains is the movement half: a scene-level conditional VAE with a
set-transformer encoder, sampling coherent futures rather than one averaged guess. Two
results above bear on it. Its accuracy budget is 6.0 points and shrinking, so it should
be justified as a UI feature — the predicted movement is what the app draws — rather
than as an accuracy device; that argument is made in
docs/webapp-handoff.md. And the fact that constant-velocity
extrapolation is worse than assuming nobody moves says the problem it has to solve is
real, not a matter of arithmetic on a heading.
Credits
Front end by Hayden (@ht44), in riders994/ReboundWebApp.