Drafting by numbers
Every August, right before the new English Premier League (EPL) season begins, fantasy football enthusiasts do the same thing: draft a squad based on vibes, last season’s highlight reels, and whatever default ranking the fantasy football mobile app states. I wanted to see how far I could get by actually modeling it — scraping every recorded on-pitch action from three seasons of top flight football across several European competitions, building a statistical model to predict each player’s match performance and match appearances, and using it to draft a real team in a real league.
This post is the technical walkthrough: what the pipeline does end to end,
where it broke, and how I found out. The code is
public at epl-fantasy-draft-stats-tool
if you want to run it yourself.
The problem, concretely
Sleeper is an app that lets you and your friends run your own competition built entirely around real English Premier League (EPL) players. Before the season starts, each manager drafts a squad — and because each real player can only belong to one fantasy team, the draft order and who you pick matter. From there, a fixed scoring formula converts each player’s actual on-pitch performance into points every match: goals, assists, shots on target, key passes, tackles, clean sheets, and more each have a set point value, all driven entirely by what the players do in real EPL matches.
So the modeling question is well-defined: for every draftable EPL player, predict their expected match performance — their output across every stat Sleeper’s scoring system rewards — and predict how many matches they’ll actually appear in, since an injury-prone or rotation-risk player scores zero on games he doesn’t play. Convert both into points using Sleeper’s real formula, and rank every player by the result. That ranked list is what you draft from on the day.
Getting the data
There’s no clean API for this. I used
soccerdata’s WhoScored scraper
to pull full event streams — every pass, shot, tackle, card, substitution —
for every match across 8 leagues (Premier League plus 7 feeder leagues players
transfer in from) and 3 seasons. That’s roughly 8,800 matches and, once
flattened, millions of individual event rows.
Two engineering details mattered more than expected:
event_idis only unique within a single match, per team — not globally. Two different teams in the same match commonly reuse the sameevent_idfor unrelated events. Any join has to key on the composite(game_id, team_id, event_id), notevent_idalone. Missing this silently corrupts joins rather than erroring.- The field that looks like it tells you who got the assist can’t be trusted. Each goal event includes a reference to a player who appears to be the assist provider, but that reference is sometimes just wrong. The only reliable way to find the real assist is to look at the actual pass immediately before the goal and check whether that specific pass was tagged as a key pass.
Both of these are the kind of bug that doesn’t throw an exception — it just quietly gives you a wrong number that looks plausible. They only surfaced when I spot-checked specific players’ known stat lines against reality — comparing what my pipeline generated for a player against their actual stats in the Sleeper app — which turned out to be the single most useful debugging technique in this whole project.
Shot quality: building an xG model from scratch
Not every shot is equally likely to become a goal — a tap-in from six yards and a speculative strike from 30 shouldn’t count the same. That’s the idea behind “expected goals” (xG): a probability, from 0 to 1, that a given shot results in a goal, based on the conditions it was taken under. (“Expected assists,” xA, is the same idea applied to the pass that created the chance.) Before predicting anything about a player, I built my own xG model: a logistic regression trained on 222,000 shots pooled across all 8 leagues, using pre-shot geometry (distance, angle), body part, situation (open play, corner, penalty, direct free kick), and whether the chance was tagged a “big chance.” I scored it with AUC — a standard 0-to-1 measure of how well a model tells goals and misses apart, where 0.5 is a coin flip and 1.0 is perfect — and got 0.805, respectable for a from-scratch model built on event-stream data rather than a commercial provider’s tracking data.
The output — xg/xa per shot — feeds into the model as an input
feature, not a scoring category (Sleeper scores real goals and assists, not
expected ones). xg_exp, a player’s rolling expected-goals rate, carries
more weight in the goals model than any other input — genuinely used, not
just present. Yet accuracy barely moved (R², which measures how much of the
real variation in goals the model can explain, from 0 to 1, went
0.077 → 0.079). Reason: goal-scoring is mostly finishing luck no pre-shot
feature can predict, and xG overlaps with shot-volume history the model
already had. A feature can matter and still not move the needle.
Features and leakage discipline
Every player-match gets ~150 engineered features. That number isn’t for show: predicting 16 different stats means capturing many different kinds of signal — recent form, opponent tendencies, team style, injury recency, fixture congestion, set-piece role, and more. Each feature is one hypothesis about what drives a specific stat, kept only if it actually holds up in testing. The one rule governing all of them: a feature can only see matches before the one it’s predicting. Break that rule and you get “data leakage” — the model accidentally sees information from the future, which makes it look far more accurate in testing than it will ever be on a real, unplayed match.
That sounds obvious, but it’s easy to get subtly wrong. Every rolling feature here is built to shift one match backward before any average is calculated, so a player’s “form” going into a match never includes that match itself.
Late in the project I found a leak one level removed from the main model. The main model is properly tested on a season it never saw during training. But one of its inputs, xG, comes from a separate, earlier model — and that earlier model had been trained on every season, including the one used to test the main model. So the test season’s information was leaking in indirectly, through that one feature, even though the main model itself was set up correctly. The fix was to build a second, throwaway version of the xG model, trained only on the older seasons, used only for testing. The numbers barely moved once fixed — but that’s not the point. The point is that I now have a repeatable check for this specific kind of indirect leak, rather than assuming the setup was correct because the results looked plausible.
Training: 16 models, not one
Rather than one model predicting fantasy points directly, the pipeline trains 16 separate models — one per stat: 13 for outfielders (goals, assists, tackles, and so on), 3 for goalkeepers (saves, high claims, smothers). Each one predicts a player’s expected count for that single stat in a typical match. Those predictions get converted into Sleeper points using the real scoring formula, then multiplied by a separately-estimated number of expected appearances.
All 16 are ridge regression models — linear regression with a built-in penalty that stops it over-trusting any single feature, which keeps predictions stable on noisy, real-world data. Two reasons I chose this over one big model or a fancier algorithm, both tested rather than assumed:
- Interpretability. A per-stat linear model lets me see exactly which features drive, say, tackles won vs. goals, and sanity-check that against real football knowledge.
- It beat the alternatives. I tested two tree-based methods (gradient boosting and random forest) against the same ridge model, same features, same test data. Both lost on accuracy. Two different algorithms losing to the same simple model is a stronger result than either loss alone — I stopped looking for a better algorithm after that.
To know whether any of this actually works, every model is tested on a season it never trained on — never a random shuffle, since that would let it peek at the future. The check that matters most: take the model’s pre-season ranking of every player and compare it to a ranking built the same way but using each player’s real, already-known results from that season. If the two lists agree at the top, the model is doing its job — the whole point of ranking players is knowing who to pick first.
This check caught a real mistake. For most of the project I’d only been comparing against a much simpler ranking — season total points alone — instead of the composite ranking I actually use to make picks. I’d chosen that composite deliberately, on intuition: no single number tells you everything worth knowing about a player, so it weighs a few different angles on him at once — consistency, quality per match, and season total — rather than reducing him to one stat. Once I checked the model against that real composite instead, accuracy among the top 20 players jumped from 45% to 65%. The model hadn’t gotten better — I’d simply been checking its work against the wrong answer the whole time.
Where the Model Falls Short
To check whether the trained models were actually worth their complexity, I compared all 16 of them against simple, non-modeled baselines — a league-wide average, a player’s own career average, his form over his last 5 matches, and just his last match — on the same test data. I checked two things: how close each prediction lands to the real number, and whether it puts players in the right order relative to each other, since that’s what actually matters for drafting. Result: the other 11 of the 16 stats comfortably beat every baseline on both counts — that’s the majority case, and it’s why the model is worth using at all. But 5 stats didn’t: for goalkeeper smothers, the trained model loses to a plain career average on both counts — it’s less accurate and gets more players out of order. For high claims, it only gets more players out of order. Three more stats are less accurate than the baseline but still order players correctly. That’s documented plainly in the project’s README under “Known Limitations” — not something I’d rather people didn’t notice.
One more limitation, documented rather than hidden, and worth actually explaining rather than waving at: ridge regression doesn’t just fit a pattern to the data — it fits a pattern, then deliberately shrinks it back toward “just predict the average,” by an amount controlled by a single knob. Turn that knob down and the model trusts whatever pattern it found in full; turn it up and the model barely trusts the pattern at all, and its prediction collapses toward each player’s plain historical average. You don’t set that knob by hand — you try a wide range of settings and keep whichever one predicts best on matches the model didn’t train on. For goals, assists, and penalties, the setting that won that test was the most extreme “don’t trust the pattern” setting available. That’s not me under-tuning it — it’s the data itself showing that, for these three stats, leaning on recent form, opponent, or anything else actually made predictions worse than just using a player’s long-run average. The likely reason: whether a player scores in one specific match comes down heavily to finishing luck that no feature can see coming, so the safest bet really is close to his average — and this is how the model discovered that on its own, rather than me assuming it going in. And player positions come from the raw match data, which occasionally disagrees with the position Sleeper itself lists a player under — a mismatch that affects more than just that player’s own rank, since positions are also used to compare him against his peers.
Using it for a real draft
I drafted from pick #3 in a 10-team snake league using the model’s rankings as the base layer, then overrode it where I had information the model structurally can’t see: a new manager, a transfer rumor, a role change. Here’s every player I picked, in draft order, alongside where the model’s composite ranking put them and where they ranked on each of the three things that composite blends — quality per 90 minutes played, quality per match appearance, and total points for the season:
| Pick | Player | Composite Rank | Per-90 Rank | Per-Match Rank | Season-Total Rank |
|---|---|---|---|---|---|
| 1 | Bruno Fernandes | 1 | 3 | 1 | 1 |
| 3 | Marcus Tavernier | 11 | 25 | 17 | 21 |
| 2 | Morgan Gibbs-White | 12 | 40 | 7 | 6 |
| 11 | Yankuba Minteh | 13 | 18 | 36 | 26 |
| 4 | Dango Ouattara | 16 | 16 | 52 | 35 |
| 5 | Pascal Groß | 21 | 49 | 16 | 14 |
| 8 | Bazoumana Touré | 25 | 28 | 41 | 58 |
| 7 | Luka Vuskovic | 33 | 81 | 9 | 27 |
| 10 | Alex Iwobi | 38 | 69 | 41 | 38 |
| 9 | David Raya | 49 | 123 | 32 | 20 |
| 6 | Pedro Porro | 52 | 122 | 40 | 25 |
| 17 | Abdul Fatawu | 53 | 67 | 48 | 126 |
| 15 | Mamadou Sangaré | 89 | 149 | 80 | 61 |
| 13 | Tarik Muharemović | 95 | 171 | 63 | 52 |
| 14 | Omar Alderete | 123 | 189 | 97 | 57 |
| 16 | Marco Palestra | 257 | 280 | 226 | 216 |
| 12 | Christos Tzolis | — | — | — | — |
A couple of picks need context the table can’t show. Minteh, at pick 11, was already ruled out for 3 months with an injury when I took him — a deliberate call, not an oversight; I was willing to stash one medium-term injury player on the bench for the upside once he’s back. Porro, at pick 6, was a pedigree bet: an attacking full-back with a strong Sleeper points finish to the 25/26 season under Tottenham’s new manager, taken well ahead of his composite rank of 52.
Not every pick was model-led. Palestra and Tzolis were pure vibe picks — taken well below where the model (or, for Tzolis, no data at all) would have had them, on reasoning the model has no way to represent. Sangaré and Muharemović sat somewhere in between: the model liked them enough to be in the conversation, but the actual call leaned as much on gut feel as on their ranking.
Leave those two vibe picks out, and a pattern shows up in the rest: in a 10-team league, the player taken at your Nth personal pick should, on average, have a composite rank of roughly 10×N if every manager in the league were drafting purely by the numbers. Compare each of my other 15 picks against that benchmark and every single one beat it — Bruno at pick 1 (composite rank 1, benchmark ~10), Fatawu at pick 17 (composite rank 53, benchmark ~170), everything in between. On average, my picks landed 41 composite ranks better than that benchmark. That’s not proof the model is right — it’s that I trusted the model for most of my picks, took its advice, and can point to a real, computable number showing I came out ahead of the benchmark for it. Whatever happens over the season, that’s a squad I feel confident about going in.
What I’d point to if you’re evaluating this as engineering work
- Leakage discipline treated as infrastructure, not vigilance — every rolling feature is built to shift one match backward before it’s calculated, so it can never see the match it’s predicting, and a dedicated code path exists specifically to catch leakage in evaluation, separate from production training.
- Model choice backed by a real held-out comparison, not intuition — two algorithm families tested and rejected with numbers, not assumed inferior.
- Limitations measured and published, including the two cases where the shipped model loses to doing nothing.
- Bugs found by checking predictions against ground truth, not just unit tests — own goals silently scored as regular goals, goals-against attributed regardless of substitution timing, a home/away join silently broken for 16+ clubs from a name-matching mismatch. All caught by picking real players and diffing model output against what actually happened.
Full pipeline, docs, and code: epl-fantasy-draft-stats-tool.
docs/MODEL_STATE.md has the line-by-line verification of every claim above
against the actual code, if you want to check my work.