METHOD
The whole model, written out. Numbers in green are your current dial settings, read live from this browser — if you have moved a dial, this page describes your model, not mine.
The projection
P_start
base is last season's starts ÷ 38, clamped to [0.05, 0.95]. flag is
chance_of_playing_next_round ÷ 100, and where the API reports null it is resolved from the status flag
(available 1.0, doubtful 0.75, injured/suspended/unavailable 0.0).
manual_override is a human input, not a model output. It defaults to 1.0 and it is the most valuable control on the site. This is the term where the paid tools beat this one: they buy press-conference and training-ground reporting and turn it into a starting probability. I can't, so rather than pretend the model knows, the dial is handed to you — click any player on MY XI and set it. Everything downstream updates. If a projection here looks wrong, this is almost always the term that is wrong.
Appearance, attack, defence, bonus
appearance = 2 × P(60+ minutes) + 1 × P(under 60), with P(60+) taken as last season's minutes ÷ (starts × 90).
attack is xG90 × the goal value for the position (GK 10, DEF 6, MID 5, FWD 4) plus xA90 × 3, scaled by the
attack dial and the fixture ramp. xG90 and xA90 come from history_past.
defence is P(clean sheet) × clean-sheet points (GK 4, DEF 4, MID 1, FWD 0), plus the defensive-contribution term, plus saves ÷ 3 for keepers.
bonus is BPS per 90 converted to realised bonus points. The work order specified a flat 0.02; measuring the conversion on last season instead gives . Forwards convert at roughly double everyone else's rate because they bank BPS in goal-shaped bursts, which lands them in the top three far more often per BPS accumulated. The dial scales that measured conversion rather than replacing it.
The fixture ramp
Derived from the difficulty of the opponent, taken from the fixtures endpoint, then adjusted for venue.
Rather than expose ten separate ramp values, the fixture swing dial scales the whole ramp around 1.0: at 0 fixtures are ignored entirely, at 1 you get the published ramp, at 2 its swing is doubled. Goals conceded use the mirror of the clean-sheet multiplier.
Where this departs from the work order
Four changes, all of them forced by the calibration check, all of them reversible with a dial:
- Deductions were missing entirely. The specified formula has no term for goals conceded (−1 per 2 for keepers and defenders) or cards. Projecting last season without them put defenders +20% and keepers +19% over their actual totals — almost exactly the size of the missing deduction. Adding the term took mean absolute error from 22.6 points to 13.6 and took the positional bias on both to under 2%.
- The 0.90 attack haircut is not supported. At 0.90 every position projected light. The default is 1.00. The dial is still there at 0.90 if you disagree.
- Bonus conversion is measured per position rather than assumed flat, as above.
- Defensive contribution is rebuilt from its components.
history_pastreportsdefensive_contributionas 0 even for seasons where the stat existed, so the rate is reconstructed from tackles + recoveries + clearances/blocks/interceptions and passed through a logistic to get the probability of clearing the threshold (10 actions for defenders, 12 for everyone further forward) in a full match.
What the model does not know
- Nothing about this season. Every counting stat in the API is zero until GW1 is played, so at GW1 this is
entirely a last-season model. It reads a
player_priorsobject rather than the live data, so around GW6 the source can be swapped behind one function without touching the projection. - Promoted sides. See the evidence section below — this is the limitation that matters most for trust.
- Defender bonus. BPS was rebalanced for 2026/27 — attacking full-backs gain, clearance-heavy centre-backs lose — so last season's BPS is a degraded prior for defenders specifically. Their bonus dial is separate and set to by default.
- Clean sheets are personnel-based. A club's clean-sheet prior is the minutes-weighted rate of the players currently in its squad, not the club's own last-season number, because squads turn over and the club's number is attached to players who have since left.
- Lineups, press conferences, rotation, chips, price changes, and anything in-play. All out of scope.
Evidence behind each projection
Every row in projections.json carries a prior_confidence value, and every surface in the
site shows it. A projected 4.2 built from a flat fallback must not render identically to a projected 4.2 built from 38
games — that is a presentation bug, not a maths bug, and it is the one that costs trust.
Which clubs have no Premier League priors at all:
Their players have an empty history_past, so they carry a positional median and a flat 0.50 start prior.
The optimiser does not pick them on evidence — it picks them because they are cheap and its guess is indistinguishable
from real knowledge. Set the override yourself, or treat those projections as placeholders.
The flat prior outranks real evidence, and that is a problem
This surfaced from a case where the answer was already known. The squad started a Coventry defender away at Arsenal while an Arsenal striker sat on the bench at home to Coventry — the model appearing to project a Coventry clean sheet and no Arsenal return in the same match. It does flag that: the free-lineup section moves the striker into the XI.
But chasing it down found something worse. Two Coventry defenders in the squad carry byte-identical projections — same start probability, same expected points, because both are the same flat guess wearing two names. And that guess, at 0.50, outranks a Hull defender whose 0.13 start probability is real evidence drawn from a genuine, thin Premier League record. Ignorance is beating knowledge, and it is beating it in the direction that makes cheap unknown defenders look like bargains.
Rather than quietly pick a more flattering number, the assumption is now the unknown-player start prior dial. Drag it down and every NO PRIOR row falls together. At 0.00 the model refuses to guess at all and those players vanish from contention; at 0.50 it treats an unknown as a coin flip. There is no correct value here — that is the honest finding, and putting a slider on it is the only truthful way to render it.
Calibration
The model run over last season and compared with what actually happened. This is in-sample by construction, so it is not an accuracy claim — it only asks whether the multipliers put points on the right scale. A ratio of 1.00 means the projection is neither generous nor stingy on average.
The accuracy record
Before every deadline a prediction is written to predictions/GW{n}.json and committed, so its timestamp
is verifiable in git history rather than asserted here. After the gameweek is scored, the actual total goes in the
table below. Including the bad weeks. A tool that sells its model can't publish this; there is no revenue here
to protect, which is the only reason it can be a real position rather than a gimmick.
Built in a day against the public FPL API. Static: a scheduled job fetches, commits a JSON snapshot, and Netlify serves it. No backend, no database, no API key, no account of yours touched.