Why I built it
I watch a lot of football. Before big matches I’d end up digging through and , trying to piece together what might happen. All of it was historical, and none of it was a model. So I built one.
The player similarity side came from a separate itch. I’d watch someone play and wonder who plays like them in other leagues, and nothing surfaced that well.
This is V2. V1 was basically a script: no real database, no structure. I showed it to someone, they pointed out how messy it was, and they were right. After my internship I rebuilt it from scratch with a proper database, an job queue, , and a clean line between the API and the ML layer. It’s still a deliberately scoped version. There’s far more data to pull and a richer model to build, which is where this write-up ends.
The system
Two jobs on one backend: slow, bursty predictions and fast similarity lookups.
Match predictions take real work, so they go through a queue and a separate process. Player similarity is a lookup over vectors computed ahead of time, so it goes straight from the API to the database. Everything persistent lives in one instance on : relational data, player , and the similarity search itself.
The API
Ask for a prediction, get back a job ID.
The frontend is React with , talking to a backend. When it asks for a match prediction, it doesn’t get a prediction back. It gets a job ID.
The route does three things and stops: it generates a for the job, pushes the job onto a Redis queue, and returns the ID. That takes milliseconds. The client then with that ID until the result is ready, so no request ever sits waiting on the model.
Queue and worker
Why a prediction never runs inside the request.
A prediction isn’t instant. For every match the system fetches the last five matches for each team from , averages them, pulls head-to-head history, and assembles 80 before the model even starts. Done inside the request, the user’s connection would sit open and frozen through all of it.
The bigger problem is scale. Picture : thousands of people want the same prediction at the same moment. If every request ran the whole pipeline, the server would repeat identical, expensive work thousands of times over and fall over. None of that work needs repeating. It’s the same match and the same prediction every time.
So the heavy lifting moves off the web server. Redis holds a list that acts as the queue: the API adds jobs to the left end with , and the worker takes them off the right end with . Push left, pop right, so jobs come out in the order they went in.
The worker is its own process that does nothing but wait for jobs. brpop is a , so while the queue is empty the worker isn’t spinning or burning CPU checking over and over. It’s parked until Redis wakes it the moment a job lands. Then it fetches the data, builds the features, runs the model, stores the result, and goes back to waiting.
Features
Turning a fixture into 80 numbers.
The worker’s first real job is to turn a fixture into something a model can read. For each team it takes the last five matches and averages the stats across them to capture current form, then adds what the two sides have done against each other.
That’s 80 features, and they break down as: 19 form stats per team, 4 from the head-to-head record, and 38 from their most recent meeting. Eighty is not a designed number. It’s everything the API gives that could be turned into a number, which is its own kind of limitation.
The model
18 small models instead of one big one.
The model is , a gradient-boosted algorithm. It builds a sequence of small decision trees, each one focused on correcting the mistakes of the trees before it, and the prediction is all of them added together. It’s fast to train and it’s strong on exactly this kind of data: rows and columns of stats.
It predicts 18 match statistics, such as goals, shots, corners, possession, saves and passes, for both teams. Rather than one model predicting all 18 at once, ’s trains 18 independent models, one per stat. If the goals prediction looks off, I can inspect the goals model on its own instead of untangling one giant model doing everything.
Training data comes out of the same five matches the predictor uses. For each matchup the most recent meeting becomes the target, the 18 stats to predict, and the four before it become the inputs. One matchup, one row: 80 numbers in, 18 out. That’s about 280 rows in total, split randomly 80/20 into training and test.
The bar the model has to clear is deliberately dumb: always predicting the average for that stat. If a trained model can’t beat guessing the mean on held-out matches, it hasn’t learned anything, however good the RMSE looks in isolation. New versions are tracked in and only get promoted if they beat the current model by at least 12% on the tracked validation metrics, so a run that lands slightly ahead by luck doesn’t churn production.
- RMSE on goals: off by about 0.87 goals per prediction
- 0.87
- match winners called correctly, against a 33% blind baseline
- ~50%
An of 0.87 sounds high until you remember that goals are rare and partly random. Professional models struggle there too. I treat it as an honesty check rather than a number I expect to get near zero. The winner rate comes from full Premier League and La Liga matchdays, where a blind guess on a three-way outcome gets about a third right.
The cache
The first request runs the model. The next thousand don’t.
When the worker finishes, it writes the result to Redis with a one hour TTL, a time to live after which the entry deletes itself. The first person to request El Clásico triggers the model. The next thousand people who request the same match never touch it: they get the finished prediction straight out of Redis.
The expiry also keeps memory under control. Old predictions clear themselves out instead of piling up forever, and an hour is fresh enough for a match preview.
Getting the data
Where the hardest problem in the project lived.
Similarity starts with player stats: up to four seasons, 2022 through 2025, across more than 10,000 player profiles from API-Football.
The hardest problem in the whole project was here. Player names are inconsistent across data sources. Arabic names get transliterated differently, accents come and go, and abbreviations vary by league. I matched names with and a tuned , and that threshold is a real trade-off. Too low, and different players with similar names get merged. Too high, and obvious matches get missed. Some edge cases still need resolving by hand.
| one source | another source | why they differ |
|---|---|---|
| Mohamed Salah | Mohammed Salah | transliteration |
| Martin Ødegaard | Martin Odegaard | accents |
| Vinícius Júnior | Vinicius Jr. | abbreviation |
Player to vector
Five steps from raw stats to a playing style.
To answer “who plays like this player,” every player becomes an embedding: a list of numbers that describes their playing style, where players who play alike end up with similar lists. Once every player is a vector, the question becomes maths about which vectors sit closest together.
Per-90
Divide by minutes played, multiply by 90. 15 goals in 2,500 minutes becomes 0.54.
Season decay
Blend up to four seasons, recent ones weighted heavier. Seasons under 450 minutes are dropped.
Position z-score
Each stat becomes distance from the average for that position, so roles compare fairly.
Position weights
Scale by what matters for the role: goals at 2.0 for attackers, tackles at 2.0 for defenders.
PCA
Merge stats that move together. 17 numbers become the 12-dimension vector stored in pgvector.
Per-90 puts everyone on the same footing, so a player on 800 minutes compares fairly with a starter on 2,500. Season decay says recent form matters most:
Position turn each stat into how far above or below average the player is for their role, measured in . Raw numbers mean different things by position: 60 passes per 90 is ordinary for a midfielder and exceptional for a striker.
Position weights then emphasise what matters for that role. Attackers weight goals at 2.0 and tackles at 0.3, and defenders flip it, with tackles and interceptions at 2.0 and goals at 0.3.
Finally, compresses the 17 weighted stats down to 12 dimensions, with a separate fit for each position group. That vector is stored in a player_embeddings table with pgvector.
Why compress at all? Because the stats overlap. Goals, shots and xG all rise and fall together, so in the raw 17 dimensions “is a shooter” gets counted three times and shooting quietly dominates every comparison. Compression is how one trait goes back to counting once.
It’s also worth being clear about what PCA does, because the name suggests picking. It doesn’t pick 12 of the 17 stats. It builds 17 brand new axes, each one a blend of all 17 stats, ranks them by how much they , and then the top 12 are kept and the bottom 5 dropped. Stats that move together collapse onto a shared axis, which is exactly the double counting going away.
The search
Direction, not size.
A similarity search is a single SQL query against pgvector, ordered by . Cosine similarity compares the direction two vectors point in and ignores their length: a similar style means the vectors point the same way.
That matters because of how stats scale. Take a prolific winger and a mid-table winger with the same profile shape: lots of dribbles, high xG, few tackles. Their vectors point the same way, but one reaches much further out.
As a sanity check I ran queries on shortlists and on players I watch every week. The results passed the eye test: stylistically similar players clustered together, not just stars with similar minutes.
What I learned
This was my biggest learning leap. It’s where I first connected , vector databases and backend API design as one system rather than separate concepts: data ingestion, , embedding generation and prediction serving working together, with caching deciding whether response times stayed reasonable.
I’ve built several projects since, but SoccerOracle is where I went from following tutorials to architecting something end to end on my own.
What I’d change
Player-level data in the predictions. Right now the model only sees team form and has no idea who’s actually playing. If a key player is injured or the manager rotates the squad, it can’t know. That’s the most impactful thing to add, and it’s the heart of a V3: lineups and injuries feeding the match model.
Scheduled instead of per-request API calls. Every prediction currently fetches recent matches from the external API. That’s slow, it costs API credits, and at real scale the bill grows fast. I’d scrape FBref or Sofascore on a schedule, daily or after matchdays, and store it in Supabase, so a prediction becomes a database query. The API would only be a fallback for data I don’t have yet.
Richer data and a bigger model. More matches and leagues to choose from, and more stats per team as inputs, which goes hand in hand with a model that can use them.
Stack
| Frontend | React 18, TypeScript, Vite, Tailwind CSS, TanStack Query |
|---|---|
| Backend | FastAPI, Python |
| Queue and cache | Redis |
| Model | LightGBM, scikit-learn, pandas |
| Embeddings | PCA (scikit-learn), pgvector |
| Database | Supabase (PostgreSQL) |
| Model tracking | MLflow |
| CI and retraining | GitHub Actions |
