How it works
Two models run in sequence. A movement model (PyTorch) takes the pre-shot positions of all ten players and samples where they end up when the ball comes off the rim โ rebounding is about movement toward the ball, not just where you start. A ranker (LightGBM) then scores all ten players together and returns the probability that each one comes down with it.
The ranker scores the ten players jointly rather than one at a time, under a grouped softmax โ the model is told from the outset that exactly one player gets the rebound, so the probabilities are a single distribution over the group and already sum to one.
The movement model predicts a distribution over whole scenes, not one position per player. A big man might crash the glass or might leak out, and those are two different futures, not one average future somewhere in between. Each request samples a dozen complete scenes; the demo animates one of them, so re-running the same play gives you a slightly different โ and equally plausible โ result each time.
Engineered features
Twenty-seven features per player, computed by the pipeline rather than the web app, so the served features cannot drift from the trained ones. They fall into a few families:
- Geometry to the rim โ distance and angle from the basket.
- Shot context โ how far the shot was taken from, the player's bearing relative to it, and an estimate of the ball's flight time.
- Traffic โ distance to the nearest opponent and nearest player of any kind, and how many bodies are within six and ten feet.
- Box-outs โ who is screening whom off the glass, in both directions.
- Relative standing โ a player's distance to the rim measured against the best-placed player, his nearest teammate, and the group mean. Rebounding is a competition, so position only means something relative to the other nine.
- Identity โ offense or defense, shooter or not, and listed position.
Rebuilt in 2026
The original 2017 version was a Keras network that predicted a single post-shot point per player, feeding eight hand-built features into a scikit-learn random forest that scored each player independently; the ten scores were then divided by their sum to make them look like a distribution. Both stages have been replaced. Scoring the players jointly removed the need for that fudge, and sampling whole scenes removed an averaging artifact that parked players at the midpoint of futures they never actually took.
The rewrite also moved every feature definition into the modelling repo. The 2017 web app recomputed its own features at request time, which is exactly the kind of duplication that silently rots โ and had, in small ways, by the time it was rebuilt.
How well does it do?
On 5,756 held-out shots, it identifies the actual rebounder 29.7% of the time, with the right player in its top three 66.8% of the time. Ten players on the floor makes 10% the number to beat, so it is about three times better than guessing โ which is about right for a genuinely noisy event. A tip-out, a lucky carom or a bounce off a shoulder is not predictable from where everyone stood a second earlier, and no amount of model is going to recover it.
Telling it who is a guard and who is a centre is worth a surprising amount: without positions the same model scores 26.9%, so that one field is worth 2.8 points of top-1. That is why the court asks you to pick positions rather than just placing dots. One caveat on the number โ it was measured on real lineups, and a sketched five-guard lineup is not something the training data contains, so treat it as the ceiling rather than a promise. The movement model places players within 4.9 feet on average, against a mean travel of 7.5 feet.
Front-end
The court is a D3.js overlay on an SVG half-court. Clicks build a payload of player coordinates, a POST hits the Flask service, and the JSON response drives the color gradient and the transition animation, outlining the single most likely rebounder.