About AFL Pickems
How It Works
AFL Pickems predicts the winner and margin for every AFL match using machine learning trained on data back to 2010. It's a side project, not a production system, but I've tried to build it with the same discipline I'd want in a real data product: proper testing, proper validation, and an honest account of what still needs work.
Data Governance & Rigor
The model is only as good as the data behind it. Three external sources feed it: match results and other tipsters' picks from the Squiggle API, player-level stats from Footywire, and weather from the Bureau of Meteorology. All three go through the same checks on the way in:
- Incoming records are validated against a schema before they touch the database. If one record in a batch is bad, the whole batch fails rather than writing partial data.
- Every page we scrape gets saved to disk exactly as it arrived, so it can be re-parsed later or checked against what a source actually said, without having to trust memory or re-fetch it.
- Footywire gets scraped rather than queried through a clean API, so it's treated as untrusted: redirects only follow the same host, responses are capped in size, and there's a hard limit on redirect hops.
- Schema changes go through Alembic migrations. Nobody edits the database by hand.
- The backend is built test-first. The suite covers validation, feature engineering, model training, and every API endpoint, and it has to pass before anything merges.
It's not glamorous work, but skipping it is exactly how a model ends up confidently wrong.
A Bug We Found and Fixed
For most of the 2026 season, a bug meant that whenever a new model version got promoted mid-round, it would quietly re-predict matches that had already been played, using the new model instead of whichever one was actually live at the time. A “prediction” for a finished match could really be hindsight, and there was no record of which was which.
The fix is simple: once a match result is known, its prediction is locked, and nothing can touch it after that, not even a retrain. If a match somehow got a result with no locked prediction on record, it gets a single retrospective entry, clearly flagged, rather than being folded in as if it had been called live.
This also affects how accuracy is reported here. Seasons through 2026 use results from this project's walk-forward backtests rather than the live prediction table, because those backtests are generated by training only on past seasons and never go near the bug above. From 2027 the live table is used directly, since it's now safe. I'd rather show that history accurately than quietly drop it.
Modeling Approach
The model predicts margin (home score minus away score) using regression. The winner comes from the sign of that number, confidence from a sigmoid of its size. Instead of relying on one model, several types get trained and compared, including Ridge, ElasticNet, Random Forest, XGBoost, and LightGBM. A model only makes it into the final ensemble if it clears an accuracy bar and isn't too similar to what's already in there. Just picking the three best performers by tip rate actually produced a worse ensemble than this slower, diversity-aware approach.
Accuracy is checked with walk-forward backtesting: train on everything up to a season, then predict that season, one season at a time. There are no random train/test splits, since those can leak future information into the past.
Not everything that gets tried makes the cut. I once built a two-model setup with a specialist for close games, deliberately trained without each team's overall strength rating as an input, and tested it the same way as everything else. It didn't beat the simpler single-model version, so it got reverted. Holding every idea to the same bar is what makes that an easy call instead of a loss.
Features
Around 100 features get engineered per match, across categories like:
- Team form (rolling averages, win streaks, scoring trends)
- Opponent-adjusted team & player ratings (an ELO-style system applied to 11 individual stats, not just win/loss)
- Team strengths and weaknesses (offensive, defensive, contested ball, pressure)
- Player form and availability (individual stats, injury impact scoring)
- Fatigue and scheduling (travel, rest days, fixture congestion)
- Venue factors (ground record, interstate travel)
- Weather conditions (rain, wind, temperature effects)
- Coaching, team cohesion, and squad composition
- Contextual factors (derbies, rest days, ladder position, finals pressure)
Data Sources
- Squiggle API — match results, scores, venues
- Footywire — player-level statistics
- Bureau of Meteorology — weather conditions
What's Next
This is a project I keep coming back to, not a finished one. Current gaps include thin backtest coverage for older seasons, not much monitoring on the ingestion pipeline, and some database debt from before prediction locking existed, like duplicate rows that haven't been cleaned up yet.