FPL hassanahmad.dev
GW deadline

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:

  1. 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%.
  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.
  3. Bonus conversion is measured per position rather than assumed flat, as above.
  4. Defensive contribution is rebuilt from its components. history_past reports defensive_contribution as 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

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.