NBA Basketball Prediction
A prediction you cannot audit is a guess with a percentage on it.
58.84%
best hit rate
632
matches trained on
9
weighted team metrics
12.5-15k
generations analysed
Why it exists
A Projeto Integrador: the closing work of the technical course in informatics at IFSC Gaspar, written with three classmates and defended in 2021. The subject came from a market that was growing quickly in Brazil at the time and had no shortage of confident claims — sports betting. Everybody had a system. Nobody published one.
Genetic algorithms were the gap. Neural networks and regression had been pointed at basketball repeatedly; a weight vector evolved against real match data had not. And the question that actually interested us was never whether it could win money. It was what it would say about the game: which of a team's first-quarter numbers carry the first quarter, and by how much.
The problem
Predicting a whole NBA game is a crowded problem with a great deal of prior work. Predicting only the first quarter is a narrower one and a harsher test — fewer possessions, less time for the better team to assert itself, more noise per minute played.
The input is deliberately thin. For each team, the averages of its own past first quarters: points, spread, rebounds, shooting percentages, turnovers, wins. Scraped from Basketball Reference with Selenium and BeautifulSoup into a SQLite schema of teams, matches and per-team participations. No injuries, no rosters, no odds, no rest days. If nine weights over those numbers can beat chance, that is a statement about the numbers themselves.
Architecture
A genetic algorithm, and around it only what feeds one. An individual is nine weights drawn from [-100, 100], one per metric. Its fitness is the share of matches in the training window whose first quarter it calls correctly. Generations are elitist — the best individuals persist untouched, the rest are recombined and mutated — which is why a fitness log only ever goes up, and why it is a staircase rather than a curve.
The command line drives four verbs: scrape, train, predict, validate. The last one is the one that matters. It puts the algorithm and two controls through the same fitness function, so a good score has something to be good against.
Decisions worth explaining
- 01
There is no held-out test set, and this page is not going to pretend otherwise. Fitness is measured on the same 632 matches the weights are trained on, so 58.84% is an in-sample number. The only out-of-sample evidence we produced was cruder and more public: predictions for upcoming games posted to a Twitter account as the games were played.
- 02
Controls were built before conclusions. A fully random weight vector and a constant vector of ones run through the identical fitness function, because 58% on its own is a number with nothing to be measured against.
- 03
Parameters were calibrated one at a time against a fixed baseline, which treats them as independent when they plainly are not. The paper says so rather than presenting the sweep as settled — and the one sweep that came back flat was reported as inconclusive instead of dressed up as a finding.
- 04
Every fitness axis starts at 50%. A predictor below chance is not a bad predictor, it is a useful one read backwards, so 50% is the floor that carries any meaning at all.
- 05
The requirement asked for a 70% hit rate. It was never met, and it stayed in the document as a failed requirement rather than being quietly rewritten down to what we actually reached.
Best fitness per generation
Two training runs committed to the repository, replayed from their own logs. Both climb quickly and then stop: the longer run's last improvement lands at generation 405, and the thousand generations after it produce nothing at all. Neither run comes close to the 70% the requirements asked for.
- Run A — 1,410 generations, 56.41% to 59.06%
- Run B — 515 generations, 59.06% to 60.47%
Best fitness: the percentage of first quarters called correctly.
Source: src/data/json/gen/teste1.json and teste2.json, sampled every 5 generations. The algorithm is elitist, so the best fitness never falls and the line is a staircase.
Hit rate by method
Where the algorithm lands against the related work the paper measures itself against, and against a random predictor. At 58.84% it clears chance and the football baseline, and trails every basketball method above it. The bars are not like for like and should not be read as if they were: only this project predicts a first quarter — the three above it predict whole games, two of them in a different league.
Linear regression
NBB, full game · Michelon et al., 2017
75%
Neural network
NBB, full game · Michelon et al., 2017
74%
Data mining, machine learning
NBA playoffs, full game · Fukano et al., 2018
73.19%
This project — genetic algorithm
NBA, first quarter · 2021
58.84%
Gradient boosting
Football, full match · Fernandes, 2019
52.78%
Random predictor
Control, 50% by construction
50%
Source: the paper, section 4.3, figures 18 and 19.
The best individual's weight vector
The nine multipliers the winning chromosome settled on, applied to a team's own first-quarter averages. Magnitude is how much the algorithm decided a metric mattered; a negative sign means it found the metric running higher in teams that go on to lose the quarter. Two of them run against intuition — the points a team averages carries a negative weight — which the paper flags rather than explains away.
won
+98.4
points
-59.5
spread
+78.5
offensive_rebounds
+81.3
defensive_rebounds
+4.9
field_goals_percentage
-54.9
three_point_field_goals_percentage
+98.0
turnover
-36.2
turnover (as printed)
+72.5
Source: the paper, section 5.1. It prints turnover as the label for both of the last two genes; reproduced here as published, since the database schema has only one such column.
Calibration sweep
Five parameters, each moved on its own against a fixed baseline, five cycles of a hundred generations per value — between 12,500 and 15,000 distinct generations in all. The last row is the one worth reading: the share of randomly generated individuals made no measurable difference anywhere between 0 and 40, which is not a result. It is a sweep that did not resolve.
| Parameter | Values tested | Chosen |
|---|---|---|
| mutation_chance | 1 · 5 · 15 · 30 · 60 · 95 | 15 |
| mutation_magnitude | [0, 0] · [-1, 1] · [-10, 10] · [-100, 100] · [-1000, 1000] | [-1, 1] |
| population_size | 15 · 50 · 100 · 200 · 500 | 500 |
| persistent_individuals | 0 · 5 · 10 · 25 · 40 | 5 |
| random_individuals | 0 · 5 · 10 · 25 · 40 | 0 / 25inconclusive |
Source: the paper, section 4.2, figures 12 to 16.
Stack
Status
Finished and frozen. Academic work, defended in 2021 at IFSC Gaspar by Bernardo Pires Mesko, Grégori Sabel, João Vitor Waldrich and Rafael Guilherme Onesko, advised by Thiago Lipinski-Paes and Raphael Silvano Ferreira Silva. The code and the paper are public; neither is maintained.
The honest summary is that it worked and it was not enough. 58.84% clears chance by a comfortable margin and falls well short of the 70% that was asked for. Every number on this page comes from the paper or from a training log committed to the repository — and the figures that flatter the project are not here without the ones that do not.