A Fantasy Premier League squad picker with a measurement harness attached, so model changes are judged on evidence rather than plausibility.
The project started as two files: a form/PPG scoring proxy and a MILP squad solver. The solver was already sound. The scoring half was weak, and there was no way to tell whether any change to it helped — so the first thing built here was the backtest, and everything after was decided by it.
# tonight's squad, 90 minutes before the deadline
python pick_team.py --confidence 0.85
# ad-hoc squad
python fpl_optimizer.py --model ep --horizon 1
# is a model actually any good?
python fpl_backtest.py --compare --season 2025-26
python fpl_backtest.py --selftest # prove the harness first
# interactive
streamlit run fpl_dashboard.pyRequires pulp pandas numpy requests streamlit altair beautifulsoup4 lxml.
| File | Role |
|---|---|
fpl_data.py |
Live API, season snapshots, per-gameweek history; caches to data/ |
fpl_features.py |
One canonical feature frame, built identically live and historically |
fpl_model.py |
Model registry: proxy, ppg, ep, v2, blend |
fpl_backtest.py |
Walk-forward IC harness, squad simulation, and a self-test |
fpl_optimizer.py |
The MILP: squad, XI, captain, transfers, forced picks |
fpl_lineups.py |
Predicted-lineup adapter, plus the Fantasy Football Scout scraper |
fpl_league.py |
Mini-league: effective ownership, Monte Carlo, P(win the week) |
pick_team.py |
The pre-deadline run — scrape, rebuild, rank captains, diff |
fpl_dashboard.py |
Streamlit front end over the same functions |
Measured over 2023-24 and 2024-25 (37 gameweeks each), scored by mean rank information coefficient — the Spearman correlation between predicted and actual points, recomputed each gameweek.
| Model | 2023-24 | 2024-25 | Squad sim (GW6-38, 2023-24) |
|---|---|---|---|
proxy (the original) |
0.342 | 0.346 | 1820 pts |
v2 (rules decomposition) |
0.453 | 0.449 | 2179 pts |
ep (FPL's own estimate) |
0.650 | 0.619 | 2350 pts |
FPL's published ep_next beats everything built here, by 530 points a
season over the original proxy. Three separate attempts to beat it failed:
a hand-tuned convex blend, a weight sweep, and a fitted ridge stack with ep
as a feature (0.621 against ep alone at 0.643). There is no more signal to
extract by recombining the existing data.
Much of ep's edge is that FPL zeroes it for unavailable players — real
pre-deadline information. Any blend must preserve those zeros; backfilling
them with a history-based score promotes exactly the players about to score
nothing, and cost 0.2 IC when tried.
Exception — pre-season. ep_next compresses to 26 distinct values capped
at 4.0, which flattens the premiums (Haaland and a £4.5m goalkeeper score
identically). For gameweek 1 only, use --model v2 --ep-blend 0. From GW2,
use ep.
Replacing estimated minutes with perfect team news takes v2 from 0.449 to
0.725 — bigger than the gap between any two models here, and past ep.
The break-even against ep sits at only ~65% lineup accuracy; public feeds
run 80-90%.
History-based minutes prediction already reaches ~0.78 IC against actual
minutes. The oracle needs 1.00. That gap is information, not modelling —
which is why fpl_lineups.py exists and why four internal improvements were
tested and rejected:
| Idea | Result |
|---|---|
| Shorter minutes window | +0.059 IC, but an artefact of the pool filter; squad sim split across seasons. Reverted. |
| Fitted ridge stack | 0.621 vs ep's 0.643 out-of-sample |
| Team strength ratings over FDR | 0.349 vs FDR's 0.361, and that was an optimistic upper bound |
| FPL's own news as a lineup source | Already wired into avail; changed 0 squad picks |
Your points relative to the field from one player are (your_multiplier - EO) * points, where EO is effective ownership. Summed over a squad, the EO term is
a constant that does not depend on your picks — so maximising expected
relative points has the identical optimum to maximising expected points.
Ownership cannot matter through the mean.
It matters through variance: a high-EO player moves you with the field, a
differential moves you against it. So fpl_league.py simulates player scores
and rival squads and estimates P(finish top) directly.
Caveat: before a deadline no rival's picks are public, so rivals are synthesised from global ownership and are weaker than real managers. Absolute P(win) reads far too high; only the comparison between options is meaningful. After a deadline the tool switches to real picks automatically.
A backtest that quietly reads the future produces beautiful numbers. Three
checks run under --selftest:
- No target leakage — scrambling every stat from gameweek
tonward must leave the features fortbit-identical. (The assertion insidebuild_featuresonly restates the filter above it and can never fire; this perturbation test is the one that would actually catch leakage.) - Sensitivity — a model that peeks at the answer must score IC ≈ 1.0, proving the harness can see signal at all.
- No false signal — that same model against a shuffled target must score ≈ 0.
- GW1 is unmeasurable.
merged_gwcarries no prior-season data, so the backtest starts at GW2. Everything said about gameweek 1 rests on inspection, not measurement. - The lineup scraper is unvalidated. Its accuracy cannot be checked until
matches are played.
--confidenceis an assumption until then. - Scrapers are brittle. A site redesign breaks the parse; it fails loudly on a zero-row scrape rather than returning garbage.
defensive_contributiononly exists from 2025-26, so that season is the primary validation set and earlier ones are robustness checks.- Squad simulation simplifies: players sell at current price (no 50% sell-on fee) and chips are never played.
Multi-week transfer planning (currently one-week greedy), chip timing (Bench Boost and Triple Captain are live from GW1 and completely ignored), bookmaker-odds integration, and per-position models.