BackGitHub

SoccerOracle

Predicts football match stats, and finds players who play alike across leagues.

Oct 2025 – Jan 2026 · Solo project · V2 rebuild

match stats predicted
18
features per prediction
80
player profiles
10,000+
player style vectors
12D
SoccerOracle project artwork

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.

FrontendreactFastAPIroutesRedisqueue + cacheWorkerfeatures + modelAPI-Footballrecent matchesSupabasepostgres + pgvectorrequestlpushbrpopresultlast 5 matchescosine searchmatch predictionplayer similarity
The accent path is a prediction request. The dashed path is a similarity search.
Match predictions

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.

ClientFastAPIuuid job idredis listWorkerfetch, build, predictRedis cache1 hour ttlpostjob id, in mslpushbrpopstorepoll with job id until the result is ready
The route only enqueues and returns. The worker does the slow part while the client polls.

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.

team a, last 5team b, last 5head to headAssembleaverage + jointeam a form · 19team b form · 19h2h record · 4latest h2h match · 3880 features in one row
Recent form for both teams plus head-to-head history, assembled into a single row of 80 features.

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.

80 features18 lightgbm models, one per statper teamgoalsshotscornerspossessionsavespassescardsgoals prevented
The same 80 features feed 18 separate models. The goals model can be inspected on its own.

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.

five matches for one matchup12345the four beforemost recent80 featuresaveraged form + h2hOne training row80 in, 18 outthe answer is what actually happened next~280 matchupsrows in totalRandom split80 train / 20 testLightGBM ×18one model per statRMSE on the test setagainst guessing the average
The answer to each row is what actually happened in the next match, which is the only honest label available.

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.

same match, requested over and overrequest 1Worker runs the modelfeatures, 18 modelsrequests 2 to 1,000Redis cache hitno model runstore result, 1h ttl
A burst of identical requests: one model run, everything after it served from memory.
Player similarity

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 sourceanother sourcewhy they differ
Mohamed SalahMohammed Salahtransliteration
Martin ØdegaardMartin Odegaardaccents
Vinícius JúniorVinicius Jr.abbreviation
too low: different players mergetuned thresholdtoo high: real matches missed
Examples of each kind of mismatch, and the trade-off the threshold has to balance.

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.

  1. Per-90

    Divide by minutes played, multiply by 90. 15 goals in 2,500 minutes becomes 0.54.

  2. Season decay

    Blend up to four seasons, recent ones weighted heavier. Seasons under 450 minutes are dropped.

  3. Position z-score

    Each stat becomes distance from the average for that position, so roles compare fairly.

  4. Position weights

    Scale by what matters for the role: goals at 2.0 for attackers, tackles at 2.0 for defenders.

  5. PCA

    Merge stats that move together. 17 numbers become the 12-dimension vector stored in pgvector.

Each row is one player’s vector after that step. PCA compresses 17 stats into the final 12 dimensions.

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:

2025
100%
2024
60%
2023
30%
2022
15%
Any season under 450 minutes is dropped, since its per-90 numbers would be noise, and its weight moves to the other seasons.

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.

−3σ−2σ−1σavg+1σ+2σ+3σ68% of players within 1σ95% within 2σmidfielder, 60 per 90z = 0.0striker, 60 per 90z = +2.5
60 per 90 · midfielder about averagestriker top 0.6%
Illustrative: drag the passes per 90 and watch the same raw number land in a different place for each position, because each player is measured against their own position.

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.

how much each new axis separates players1234567891011121314151617keep the top 12 → the stored vectordrop the last 5
Illustrative spread. The axes are new blends of the stats, not the stats themselves, which is why dropping the last five loses very little.
Wrapping up

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

FrontendReact 18, TypeScript, Vite, Tailwind CSS, TanStack Query
BackendFastAPI, Python
Queue and cacheRedis
ModelLightGBM, scikit-learn, pandas
EmbeddingsPCA (scikit-learn), pgvector
DatabaseSupabase (PostgreSQL)
Model trackingMLflow
CI and retrainingGitHub Actions