Match Outcome Model
Summary
A multinomial logistic regression that predicts a probability for each
of away win, draw, home win for every CPL regular-season match.
The model is trained once from a frozen training split, exported as
JSON coefficients, and applied to every historical match plus every
new match as it lands.
The intent of v1 is a clean, reproducible training pipeline: a config file, fixed train/val/test split, two model families compared honestly, full metric dump, and a backtest report against the Elo-renormalised xPts baseline. Adding features (shot volume, set-piece volume, head-to-head, the Shot Quality Index) becomes a small change to the feature set followed by a retrain.
v1 does not beat the xPts baseline on Brier or log loss. It is shipped anyway: the pipeline itself is the deliverable, the ugly numbers are reported alongside the headline ones, and v1.1 has a clear path to improvement once shot features land.
Data
- Source:
matches,team_elo_history,team_form_seriesfrom the local CPL database. One row per regular-season match, home perspective. - Time range: 2019 through 2026 (in-season). 719 regular-season matches with both Elo entries and 5-match rolling form on both sides. Zero matches dropped at v1 because rolling-form gaps for early-season matches are filled with neutral priors (PPG 1.0, GD 0.0, rest 7 days) rather than dropped.
- Excluded: playoff matches.
- Splits:
- Train: 2019, 2020, 2021, 2022, 2023 (482 matches)
- Validation (model + hyperparameter selection): 2024 (117 matches)
- Test (held out, reported once): 2025 (117 matches)
- 2026 in-season: not used in training, only inference.
Choices
v1 feature set
Four features, deliberately small:
| Feature | Definition |
|---|---|
elo_diff |
home_elo_before - away_elo_before (raw, no +100) |
ppg_diff_5 |
rolling-5 PPG difference (home - away) |
gd_diff_5 |
rolling-5 goal difference (home - away) |
rest_diff |
home_rest_days - away_rest_days, clipped to [-10, 10] |
The intercept absorbs the league-wide home advantage, which is a deliberate divergence from the xPts baseline (which hard-codes +100 into the Elo expected-score). Letting the logit learn the home advantage from data is cleaner once the model has more features layered on.
The full candidate list (shot volume, box-shot share, set-piece
volume, head-to-head, Shot Quality Index) is deferred to v1.1 for
two reasons: (1) raw match_stats xG is identically zero across every
season, so the most informative shot feature is unavailable; (2) on
720 matches, every additional feature buys overfitting risk that the
small validation set struggles to detect. v1 is the calibration
baseline that v1.1 has to clear.
Multinomial vs ordinal logit
Both are fit. Hyperparameters tuned on the validation set. Winner chosen by validation Brier. Multinomial wins on this dataset (val Brier 0.6371 vs ordinal 0.6393), which matches general findings in football modelling: proportional-odds rarely holds because draws are not "halfway between" wins and losses, they're a structurally different outcome.
Standardization
Z-scored on the training set only. Means and standard deviations are frozen into the artifact and applied identically at inference time, so the inference path can never see leakage from validation or test data.
Artifact format: JSON coefficients
Coefficients, intercepts, feature means, and feature stds are written to a plain JSON file. Inference is a hand-rolled pure-Python softmax with zero scikit-learn at runtime. This keeps the production container free of the ML dependency tree and removes any binary-format compatibility risk across redeploys. sklearn is only installed for training.
Frozen training, no automatic retrain
The nightly sync runs inference against the frozen artifact but does not retrain the model. Retraining is an explicit, version-bumped operation followed by a commit, so historical predictions don't silently shift underneath the user.
Per-match storage
Predictions are stored in a team_match_predictions table, two rows
per match (one per team), keyed by (team_id, match_id, model_version). The xPts baseline's team_match_expectations table
is left in place so the two metrics can be compared side-by-side and
the simpler view does not regress when this model ships.
Validation
Headline numbers (model_v1, multinomial logit, C=0.03)
| Split | n | Brier | Log loss | Accuracy |
|---|---|---|---|---|
| Train (2019-2023) | 482 | 0.6324 | 1.0495 | 0.452 |
| Validation (2024) | 117 | 0.6371 | 1.0557 | 0.496 |
| Test (2025) | 117 | 0.6030 | 1.0082 | 0.513 |
| xPts baseline (2025) | 117 | 0.6011 | 0.9991 | 0.479 |
Read the test row honestly: v1 is 0.0019 worse on Brier and 0.0091 worse on log loss than the xPts baseline on the 2025 hold-out. It is 0.034 better on accuracy. Net: with the v1 feature set, the multinomial logit edges out the baseline on the "most-likely outcome" metric but is fractionally worse-calibrated.
v1 is the calibration baseline, not the headline result.
Calibration (test set, home-win column, 10 bins)
| Bin | n | Predicted | Empirical |
|---|---|---|---|
| 0.20-0.30 | 16 | 0.274 | 0.313 |
| 0.30-0.40 | 32 | 0.362 | 0.250 |
| 0.40-0.50 | 37 | 0.450 | 0.351 |
| 0.50-0.60 | 27 | 0.544 | 0.815 |
| 0.60-0.70 | 3 | 0.609 | 0.333 |
| 0.70-0.80 | 1 | 0.751 | 1.000 |
| 0.80-0.90 | 1 | 0.880 | 1.000 |
Three things stand out: (1) the model is under-confident in the 0.50-0.60 bin where it actually wins way more than it predicts, (2) the high-confidence tails are too small to read meaningfully on 117 matches, (3) the middle bins (0.30-0.50) are over-predicting home wins. Both issues are consistent with a feature set that under-uses home advantage and form, which is the v1.1 fix.
Inference parity
A regression test loads the JSON artifact, runs the pure-Python softmax, and asserts probability vectors sum to 1 within 1e-9. Any drift between sklearn's fit-time probabilities and the JSON-coefficient inference would crash the test suite.
v1.1 investigation: shot features, calibration, walk-forward
After v1 shipped, three follow-up experiments tried to close the small gap to the xPts baseline. None worked. The combined finding is worth more than any of the individual numbers: on this dataset, an Elo rating that already encodes home advantage and an empirical draw rate is at the noise floor of what 720 matches can support, and v1's extra features sit inside that noise floor. Elo with a fitted draw rate is genuinely a hard baseline for an 8-team league.
Shot feature ablation
Added rolling-5 means of shots, box-shot share
(shots-at-goal-inside-box / shots), and corners to the v1 feature
set. Test season is 2025. Multinomial picked by validation Brier on
2024.
| Experiment | Val Brier | Test Brier | Test Log loss | Test Acc |
|---|---|---|---|---|
| xPts baseline | — | 0.6011 | 0.9991 | 0.479 |
| v1 (4 features) | 0.6371 | 0.6030 | 1.0082 | 0.5128 |
| + shots_diff_5 | 0.6400 | 0.6063 | 1.0139 | 0.5043 |
| + box_share_diff_5 | 0.6387 | 0.6075 | 1.0166 | 0.4957 |
| + corners_diff_5 | 0.6437 | 0.6172 | 1.0298 | 0.4615 |
| + shots + box | 0.6405 | 0.6048 | 1.0126 | 0.4872 |
| + shots + box + corners | 0.6445 | 0.6114 | 1.0223 | 0.4615 |
Every shot feature combination is worse than v1 on every metric.
Corners is the worst single addition. Plausible reason: rolling
team-level shot totals are highly collinear with what Elo already
encodes (good teams shoot more), so they add parameters faster than
they add signal. The lift from "shot quality" requires shot location
or shot xG, neither of which is available in the SDP feed today
(expected-goals is identically zero across all seasons).
Calibration ablation
The v1 model loses to baseline by 0.0019 Brier on test. Three calibration methods, each fit on the 2024 validation set only, then applied to the 2025 test set:
| Method | Test Brier | Test Log loss | Verdict |
|---|---|---|---|
| Raw v1 (no calibration) | 0.6030 | 1.0082 | reference |
| Isotonic (per class) | 0.6387 | 1.812 | overfits hard, creates 0/1 plateaus that crush log loss |
| Platt (per-class sigmoid) | 0.6395 | 1.0590 | breaks the joint structure of multinomial output |
| Temperature scaling (1 param) | 0.6043 | 1.0100 | T fits to ≈1; the model is already well-calibrated |
Temperature scaling is the cleanest read: with one parameter and mathematically guaranteed not to hurt accuracy, it found T ≈ 1, which is the optimiser telling us there is no calibration headroom on this validation set. Isotonic and Platt both burn validation degrees of freedom and overfit immediately. The full calibrator infrastructure stays available for future phases, but v1 ships uncalibrated.
Walk-forward backtest
To escape the noise floor of a single 117-match test set, train a
fresh v1 multinomial on every season strictly before season Y, then
score on Y, for Y in 2021..2025. Hyperparameter C chosen in-fold by
holding out the most recent training season. Compare per-season and
pooled against the xPts baseline on identical matches.
| Year | n | v1 Brier | Baseline | Δ Brier |
|---|---|---|---|---|
| 2021 | 117 | 0.6519 | 0.6465 | +0.0054 |
| 2022 | 117 | 0.6353 | 0.6363 | -0.0010 |
| 2023 | 117 | 0.6504 | 0.6611 | -0.0106 |
| 2024 | 117 | 0.6375 | 0.6314 | +0.0062 |
| 2025 | 117 | 0.6030 | 0.6011 | +0.0019 |
Pooled (n=583):
| Brier | Log loss | Accuracy | |
|---|---|---|---|
| v1 (walk-forward) | 0.6356 | 1.0547 | 0.4803 |
| xPts baseline | 0.6352 | 1.0516 | 0.4580 |
| Δ | +0.0003 | +0.0031 | +0.0223 |
Read this carefully:
- On Brier and log loss, v1 and the xPts baseline are statistically indistinguishable across 583 walk-forward matches. The sign of the per-season difference flips (v1 wins 2023 by a meaningful margin, loses 2021 and 2024 by smaller ones, ties 2022 and 2025).
- On accuracy, v1 wins by +2.2 percentage points. That is 13 more correct picks out of 583. Modest, but consistent across the seasons where it doesn't lose Brier outright.
- Translation: v1 makes slightly different calls than the Elo renormalisation, those calls happen to land on the most-likely outcome a bit more often, and the calibration is the same to within noise.
Why Elo is the hard baseline here
Three structural reasons specific to CPL:
- The xPts baseline isn't "just Elo". It's Elo plus a hand-coded +100 home advantage plus an empirically-fitted league-wide draw rate. Three signals fused with strong priors before any model touches the numbers.
- CPL is small. Eight teams, ~117 matches per season, every pair meets ~3-4 times. Elo converges fast on that. By matchday 5 the ratings already separate the league. Rolling-5 form features have little to add that the Elo update doesn't already capture.
- The features that would actually beat Elo are unavailable. Shot quality (real xG by location), lineup quality (who is actually starting today), and live in-match state are the levers that move football models in the public literature. The Shot Quality Index targets the first of those. The rest are dataset extensions beyond this model's current scope.
The takeaway is a positive one: the v1 pipeline is correct, the backtest is rigorous, and the conclusion is "the right next move is the Shot Quality Index, not more feature hunting on this data."
Failure modes
- v1 does not beat the baseline on calibration metrics. Documented loudly above. Shipping the pipeline anyway.
- xG unavailable. The SDP
match_statsblob has anexpected-goalsfield but it is identically zero across all seasons. This is the single biggest gap between v1 and "a real match outcome model". The Shot Quality Index is the planned fix. - Tiny validation and test sets. 117 matches each. A single bin in the calibration plot can swing meaningfully from one match. Don't read too much into individual bins.
- 2020 Island Games in train. 35 matches in a bubble with zero travel, no rest variance, and atypical home advantage. Currently treated as a normal training season. A future revision could exclude it or carry a season-fixed-effect.
- Neutral-prior fill for early-season form. First-of-season matches get PPG=1.0, GD=0.0, rest=7. The first three or four matchdays of every season are therefore predicted using slightly washed-out features, and the model's behavior on those matches is effectively "Elo only". Acceptable today, but worth tracking.
- Frozen home advantage. Baked into the model intercept at training time. If CPL home advantage shifts (rule changes, crowd patterns, expansion teams with weak home stadia), the model won't notice until the next retrain.
- No accounting for in-season strength swings. Rolling form is the only "what is this team doing right now" signal. Injuries, manager changes, and lineup quality are not features.
What would make it better
Reordered after the v1.1 investigation. The bottom four ideas are unchanged from v1; the top three reflect what the ablations actually showed.
- Add the Shot Quality Index as a feature. The v1.1 shot ablation made it concrete: team-level shot totals do not help on this dataset, because Elo already encodes "good teams shoot more". Real shot quality (location-based xG) is the feature that Elo cannot see, and SQI is the closest proxy currently available.
- Lineup quality features. Who is actually starting (and who is injured / suspended) is a signal Elo cannot capture and the v1 feature set does not even attempt. Requires lineup data ingestion.
- Stop looking for v1.x improvements on the existing feature surface. The v1.1 walk-forward shows v1 and the xPts baseline are within sampling noise on Brier and log loss across 583 matches. There is no calibration headroom. Any further feature-engineering experiments on team-level totals are likely to replicate the v1.1 result.
- Per-team home advantage. Today the intercept averages over the league. A team-level fixed effect is one extra parameter per club and might help calibration in the higher-confidence bins. Worth trying once an SQI feature exists, since the larger feature set will have more headroom for the team intercepts to interact with.
- Set-piece volume from
match_eventscorner / free kick counts. Worth trying alongside SQI, but on the v1.1 evidence it is unlikely to help on its own. - Head-to-head history with a small window (last 4 meetings) and a long-run prior shrink. Same caveat: deferred to a retest alongside SQI.
- Bigger leagues / more matches. Not actionable from inside this project, but worth stating: most of v1's pain is sample size, not modelling choices. With 380 matches per season and 20 teams, the gap between an Elo baseline and a multi-feature model would be meaningfully larger.
Live diagnostics
The numbers above are frozen test-set results. The section rendered
below this document is live: after every daily sync, the same metrics
(Brier, log loss, accuracy, and the 10-bin home-win calibration table
as a reliability diagram) are recomputed over the current season's
scored predictions, with the Elo baseline evaluated over the same
matches. Predictions are stored pre-match from point-in-time features,
so the season-to-date numbers are honest forecasts, not hindsight.
Early in a season the bins are thin — the section flags samples under
50 matches. Served by /api/model-diagnostics.