Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

football-model — English league match-outcome model (Elo + ordered logit)

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.

Pipeline (reproduce)

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 -q

Data

Source: 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).

Model (baseline — deliberately untuned)

  • One Elo ladder per division, updated match-by-match in date order. Fixed home-advantage boost HOME_ADV = 65 Elo points; constant K = 20; a draw scores S = 0.5 for the update; between-season regression to the mean 0.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_away to 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.

Known baseline limitation (documented, not yet fixed)

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).

Results — held-out 2025/26

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.

Reading the baseline

  • 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.

Step 2 — cross-division ratings + form (negative result)

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:

  1. Pulled E2 (League One) — 17,639 matches (total now 48,053) — so sides promoted into the Championship have a real prior.
  2. 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).
  3. 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.

Held-out 2025/26 — the gap to the reference did NOT close

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).

Why (verified, not a bug)

  • 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.

Verdict

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.

Files

  • 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.pyFootballElo ladder + 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.

About

English domestic-league betting-efficiency study (Premier League / Championship / League One): walk-forward Elo vs the closing line. Concluded negative result. Research, not betting advice.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages