A predictive model for English league football. A walk-forward Elo rating per division feeds an ordered logistic regression that turns the pre-match rating difference into P(Home / Draw / Away). The model is evaluated on a genuine held-out season — scored against actual results and benchmarked against the bookmaker closing-odds line as an external forecast-accuracy reference.
Same discipline as the sibling mlb-model / tennis-model projects: strict no-lookahead, a held-out season untouched during fitting, honest null results. This is a modeling and forecast-evaluation study only.
cd src
python fetch_data.py # download football-data.co.uk E0+E1+E2 CSVs, all seasons -> data/raw/
python build_dataset.py # normalize + consolidate (schema-drift tolerant) -> data/processed/matches.parquet
python run_elo.py # Step 1: single-division walk-forward Elo, fit prob map on TRAIN -> match_probs.parquet
python evaluate.py # Step 1: held-out (2025-26) baseline vs closing-odds reference
python run_xdiv.py # Step 2: cross-division Elo, tune lambda on validation -> match_probs_xdiv.parquet
python evaluate_xdiv.py # Step 2: held-out base vs +cross-division vs +form; isolate contributions
python -m pytest ../tests -qSource: football-data.co.uk (free, direct CSV, no key). E0 (Premier League), E1 (Championship), E2 (League One), 1993/94 → 2025/26. Step 1 uses E0 + E1 (30,414 matches); Step 2 adds E2 (48,053 matches total) so that sides promoted into the Championship carry a real prior rather than cold-starting.
Schema drift handled (verified against the files, not assumed):
- Pre-2000/01: 7 columns — FT result only, no half-time, no odds.
- 2000/01: bookmaker odds added; 2019/20: closing (
*C*) columns added. Column sets and book names drift across eras.
build_dataset.py renames the stable core fields to a lowercase canonical schema
(date, season, division, home_team, away_team, fthg/ftag/ftr, hthg/htag/htr) and
keeps every raw odds column verbatim (union across seasons; older rows carry NaN
for odds that didn't exist yet — nothing dropped). Division/season come from the
filename (authoritative; the in-file Div column is missing in some recent files).
- One Elo ladder per division, updated match-by-match in date order. Fixed
home-advantage boost
HOME_ADV = 65Elo points; constantK = 20; a draw scoresS = 0.5for the update; between-season regression to the mean0.25(teams churn via promotion/relegation). None of these are tuned — sensible placeholders. - Elo yields only an expected score in [0,1], not three probabilities, so an
ordered logistic regression maps the pre-match Elo difference
d = r_home + HOME_ADV − r_awayto P(Home/Draw/Away). Its parameters are fit on training seasons only (1996–2024, after a 3-season warm-up) and frozen for the held-out season. No lookahead: ratings at each match use only prior results; the diff→prob map never sees the test season. - Held-out test season: 2025/26 (the most recent complete season) — E0 380 matches, E1 552 matches. Never touched during any fitting.
- Closing-odds reference: the sharpest available closing odds per match (Pinnacle-close → Bet365-close → market-average-close), inverted to implied probs and normalized so the three probabilities sum to 1 (the built-in margin removed). Used purely as a strong published forecast to measure the model against — the same model-vs-reference log-loss methodology as the MLB/tennis projects.
Ratings live within a single division, so a team promoted/relegated into this division for the first time starts at 1500 regardless of true strength. In the Championship this is severe: relegated PL sides (with parachute money) and promoted League One sides all enter at the mean. Cross-division rating transfer is the natural next feature — out of scope for the baseline (addressed in Step 2).
| League | n | Model log loss | Reference log loss | Gap (model−ref) | Model Brier | Ref Brier | Model acc | Ref acc | Model ECE | Ref ECE |
|---|---|---|---|---|---|---|---|---|---|---|
| E0 Premier League | 380 | 1.0254 | 1.0150 | +0.0105 | 0.6166 | 0.6098 | 0.487 | 0.489 | 0.0369 | 0.0397 |
| E1 Championship | 552 | 1.0592 | 1.0399 | +0.0193 | 0.6393 | 0.6274 | 0.433 | 0.458 | 0.0432 | 0.0299 |
Gap uncertainty (paired per-match log-loss, model − reference):
| League | Gap | SEM | t | vs 0 |
|---|---|---|---|---|
| E0 | +0.0105 | 0.0106 | +0.99 | not distinguishable from 0 |
| E1 | +0.0193 | 0.0076 | +2.53 | measurably behind the reference |
| E1 − E0 | +0.0088 | 0.0130 | +0.68 | not significant (within noise) |
Reference robustness (log loss on shared rows): E0 — B365-close 1.0131 / Avg-close 1.0118 / Pinnacle-close 0.9875 (n=210); E1 — B365-close 1.0408 / Avg-close 1.0408 / Pinnacle-close 1.0502 (n=271). Pinnacle prices the EPL notably sharper; in the Championship the average close is as good as Pinnacle's (partial) coverage.
- In the EPL the baseline Elo is statistically level with the closing line (gap not distinguishable from 0); in the Championship it is measurably behind it (+0.0193, t=2.53).
- Championship outcomes are inherently noisier — even the sharp closing line scores worse there (reference log loss 1.040 vs 1.015).
- The E1−E0 gap difference is not statistically significant (t=0.68), so the cross-division difference in forecast skill is directional, not conclusive.
- Most likely cause of the E1 gap: the documented single-division cold-start blind spot (relegated/promoted sides entering at 1500). That points to a concrete, testable next feature — cross-division ratings — which Step 2 builds and measures.
Hypothesis: the Step-1 Championship gap is caused by the single-division Elo's cold-start blind spot — promoted/relegated sides enter each season at a flat 1500. Fix: carry ratings across divisions so a moved team keeps its earned strength.
What was built:
- Pulled E2 (League One) — 17,639 matches (total now 48,053) — so sides promoted into the Championship have a real prior.
- Cross-division unified Elo: one rating per team carried across E0/E1/E2. The inter-division gap is not assumed — it emerges from promotion/relegation results and is measured: E0 sits ~200–216 Elo above E1, and E1 ~150–165 above E2, stably across 2015–2025. Season boundaries regress continuing teams toward their own division mean; a promoted/relegated prior shrinks a moved team's carried rating toward the destination-division mean by λ (λ=0 = full carry; λ=1 = the old reset-to-average baseline).
- Recent-form feature: shrunk points-per-game over each team's last 5 league
matches (any division), added to the ordered logit as
home_form − away_form.
Discipline: λ selected on the 2024/25 validation season (logit fit on ≤2023), then frozen; final logits refit on all pre-test seasons (≤2024); 2025/26 held out. λ selection returned λ=0 (full carry) — validation log loss rose monotonically with shrinkage, i.e. carrying the cross-division rating beat resetting it on 2024/25.
| League | Variant | Log loss | ECE | Gap (model−ref) | t |
|---|---|---|---|---|---|
| E0 Prem | baseline (Step 1) | 1.0254 | 0.037 | +0.0105 | +0.99 |
| + cross-division | 1.0440 | 0.053 | +0.0291 | +2.72 | |
| + cross-division + form | 1.0449 | 0.061 | +0.0300 | +2.76 | |
| E1 Champ | baseline (Step 1) | 1.0592 | 0.043 | +0.0193 | +2.53 |
| + cross-division | 1.0608 | 0.046 | +0.0209 | +2.88 | |
| + cross-division + form | 1.0609 | 0.046 | +0.0209 | +2.88 |
The cold-start fix widened the gap (E0 markedly, E1 slightly); form added nothing (≤ +0.0009). Reference log loss unchanged (E0 1.0150, E1 1.0399).
- Clean isolation: cross-division changes predictions only on promoted/relegated matches — established-team log loss is identical (E1 base 1.0565 vs xdiv 1.0560; E0 1.0318 vs 1.0308). So the entire effect lives in the target subgroup, as intended.
- On that exact subgroup it HURTS out-of-sample: E0 promoted/relegated matches (n=108) base 1.0093 → xdiv 1.0773; E1 (n=246) base 1.0627 → xdiv 1.0669. The reference beats both there (E0 0.9993, E1 1.0579). 2025/26's new sides (Burnley/Leeds/Sunderland up to E0; Leicester/Ipswich/Southampton down to E1, etc.) out-performed their carried ratings — pure Elo carry over-penalised the promoted clubs, who strengthen over the summer (transfers, parachute money) in ways last season's rating can't see.
- Not robustly fixable by λ: validation (2024/25) prefers λ=0; the held-out season prefers the opposite (E0 improves monotonically toward λ=1; E1 is flat). The optima disagree across seasons, and even the hindsight-optimal λ only matches the baseline gap for E1 (+0.0193) and stays worse for E0 — it never closes it.
The cold-start blind spot is NOT the main cause of the Championship forecast gap. Carrying cross-division ratings and adding recent form do not close it — they slightly widen it. Backward-looking Elo systematically misprices promoted/relegated sides relative to how they actually perform after a summer of squad changes, and no simple carried-rating scheme recovers that. This is a real negative result for the cross-division approach at this stage.
Implication for next steps: the useful signal for promoted/relegated sides is forward-looking information a season-old rating cannot contain (e.g. summer transfer activity, squad strength changes, lineup/availability). Further refinements of a results-only Elo are unlikely to close the Championship gap; that is where a genuinely new feature would have to come from.
src/config.py— leagues, seasons, paths, Elo defaults, closing-odds reference preference.src/fetch_data.py— download + cache raw CSVs.src/build_dataset.py— normalize/consolidate (schema-drift tolerant).src/elo.py—FootballEloladder +OrderedLogit(Elo diff → H/D/A); pure, tested.src/elo_xdiv.py— cross-division ladder + recent-form feature + multi-feature logit.src/run_elo.py— walk-forward + train-only fit →match_probs.parquet.src/run_xdiv.py— cross-division run, λ tuning, variant probs →match_probs_xdiv.parquet.src/evaluate.py— held-out model-vs-reference metrics and per-division comparison.src/evaluate_xdiv.py— Step-2 base vs +cross-division vs +form, contribution isolation.tests/test_elo.py— zero-sum update, direction, simplex, cross-division carry, no-lookahead.data/processed/heldout_summary.csv— machine-readable result summary.