Challenges · C02 · ★★★☆☆

Premier League: team strengths, a title race and a fight with the bookmaker

Difficulty
★★★☆☆
Time
3-4 hours
Data
football-data.co.uk: every Premier League match of 2024/25 and 2025/26, with Bet365 odds
Skills
Poisson regression with crossed group effects · diagnosing non-identifiability · pm.ZeroSumNormal · hierarchical shrinkage · posterior predictive checks on quantities the model never saw · simulating a season from the posterior predictive · proper scoring rules against a market benchmark

The brief

You have joined the analytics desk of a sports media company. The editor wants two things:

  1. Team strength estimates - attack and defence ratings with honest uncertainty, not a league table with extra steps.
  2. Probabilistic forecasts - "Arsenal have an X% chance of the title, West Ham a Y% chance of going down" - that the desk can publish mid-season without embarrassing itself.

The head of the desk is a sceptic: "Bookmakers do this for a living. Show me your numbers next to theirs on matches your model has not seen, then we talk."

You will build the model on the complete 2024/25 season, then deploy it on 2025/26 as it stood on 13 February 2026 - 26 rounds played, 12 to go - and forecast the rest. We know how the season ended, so we can mark your homework.

How this notebook works

  • Each task states what to deliver, not how. Write your code in the YOUR CODE HERE cells.
  • Stuck? h.hint("task2") reveals hints one level at a time: nudge → approach → code skeleton. Try to get by on nudges.
  • h.check("task2", home=...) compares your numbers with the reference solution.
  • A full worked solution lives in notebooks/solutions/. Open it only when you are done (or truly stuck).
import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
from scipy import stats

from pymc_challenges import Hints, data

RANDOM_SEED = 2526
rng = np.random.default_rng(RANDOM_SEED)
az.style.use("arviz-variat")

h = Hints("C02")
h.tasks()

C02 - Premier League: team strengths, a title race and a fight with the bookmaker

  • task0 - Look at the data (2 hints, 2 check(s))
  • task1 - The textbook model and what is wrong with it (3 hints, 1 check(s))
  • task2 - An identified, hierarchical model (3 hints, 3 check(s))
  • task3 - Posterior predictive checks on results (3 hints, 1 check(s))
  • task4 - Forecast the run-in (3 hints, 3 check(s))
  • task5 - Scoring against the bookmaker (3 hints, 3 check(s))
  • task6 - Would you bet on it? (3 hints, 1 check(s))

The data

One row per match. The columns you need: HomeTeam, AwayTeam, full-time home and away goals FTHG and FTAG, the full-time result FTR (H, D or A), and Bet365's pre-match decimal odds B365H, B365D, B365A (a winning 1-unit bet at odds 2.5 pays back 2.5). There are many more columns (shots, cards, other bookmakers); ignore them or use them in "Going further".

COLUMNS = ["Date", "HomeTeam", "AwayTeam", "FTHG", "FTAG", "FTR", "B365H", "B365D", "B365A"]


def prepare(df):
    df = df[COLUMNS].copy()
    df["Date"] = pd.to_datetime(df.Date, dayfirst=True)
    return df.sort_values("Date", kind="stable").reset_index(drop=True)


data.describe("epl_2425")
epl25 = prepare(data.load("epl_2425"))
epl25.head()
epl_2425: All 380 match results with goals, shots and bookmaker odds.
  source: football-data.co.uk, English Premier League 2024/25.
  url:    https://www.football-data.co.uk/mmz4281/2425/E0.csv
Date HomeTeam AwayTeam FTHG FTAG FTR B365H B365D B365A
0 2024-08-16 Man United Fulham 1 0 H 1.60 4.20 5.25
1 2024-08-17 Ipswich Liverpool 0 2 A 8.50 5.50 1.33
2 2024-08-17 Arsenal Wolves 2 0 H 1.18 7.50 13.00
3 2024-08-17 Everton Brighton 0 3 A 2.63 3.30 2.63
4 2024-08-17 Newcastle Southampton 1 0 H 1.36 5.25 8.00

A helper for the bookkeeping, so you can spend your time on the modelling. Ties on points are broken by goal difference, then goals scored.

def league_table(df):
    """League table (points, goals for/against, goal difference) from a frame of matches."""
    home = pd.DataFrame({"team": df.HomeTeam, "gf": df.FTHG, "ga": df.FTAG})
    away = pd.DataFrame({"team": df.AwayTeam, "gf": df.FTAG, "ga": df.FTHG})
    long = pd.concat([home, away])
    long["played"] = 1
    long["pts"] = np.select([long.gf > long.ga, long.gf == long.ga], [3, 1], 0)
    table = long.groupby("team")[["played", "pts", "gf", "ga"]].sum()
    table["gd"] = table.gf - table.ga
    table = table.sort_values(["pts", "gd", "gf"], ascending=False)
    table.insert(0, "pos", range(1, len(table) + 1))
    return table


league_table(epl25)
pos played pts gf ga gd
team
Liverpool 1 38 84 86 41 45
Arsenal 2 38 74 69 34 35
Man City 3 38 71 72 44 28
Chelsea 4 38 69 64 43 21
Newcastle 5 38 66 68 47 21
Aston Villa 6 38 66 58 51 7
Nott'm Forest 7 38 65 58 46 12
Brighton 8 38 61 66 59 7
Bournemouth 9 38 56 58 46 12
Brentford 10 38 56 66 57 9
Fulham 11 38 54 54 54 0
Crystal Palace 12 38 53 51 51 0
Everton 13 38 48 42 44 -2
West Ham 14 38 43 46 62 -16
Man United 15 38 42 44 54 -10
Wolves 16 38 42 54 69 -15
Tottenham 17 38 38 64 65 -1
Leicester 18 38 25 33 80 -47
Ipswich 19 38 22 36 82 -46
Southampton 20 38 12 26 86 -60

Task 0 · Look at the data

Use the 2024/25 season.

Deliver

  1. How large is home advantage - in goals and in results?
  2. Are goals plausibly Poisson? Compare the observed distribution of home goals and of away goals with a Poisson of the same mean, and compare means with variances.
  3. One sentence: is a Poisson likelihood a defensible starting point?
Your turn Work this task in the notebook.

Task 1 · The textbook model

The classic model for football scores gives every team an attack and a defence rating. For a match between home team $h$ and away team $a$:

$$ \begin{aligned} \text{home goals} &\sim \text{Poisson}(\lambda_h), & \log \lambda_h &= \mu + \eta + \text{att}_h - \text{def}_a \\ \text{away goals} &\sim \text{Poisson}(\lambda_a), & \log \lambda_a &= \mu + \text{att}_a - \text{def}_h \end{aligned} $$

with $\mu$ an intercept and $\eta$ the home advantage. A colleague suggests "uninformative priors, let the data speak": independent Normal(0, 3) on $\mu$, on $\eta$ and on every $\text{att}$ and $\text{def}$.

Deliver

  1. Fit exactly this model to 2024/25 with default sampler settings, using coords/dims for teams.
  2. Read the diagnostics. Something is wrong. Explain what, in terms of the model - not in terms of the sampler - and produce one plot or table that demonstrates your explanation.
  3. Which quantities does this posterior pin down well in spite of the problem, and which not at all? Give one example of each, with numbers.
Your turn Work this task in the notebook.

Task 2 · An identified, hierarchical model

Deliver

  1. A reformulated model in which every parameter is identified by construction (not by luck of the prior), and in which the spread of attacking and of defensive ability across the league is learned from the data rather than fixed by you.
  2. Priors you can defend, backed by a prior predictive check on a scale a football fan would recognise.
  3. Clean diagnostics, and a comparison with Task 1: what happened to the effective sample size?
  4. A plot of attack and defence ratings with uncertainty. Then compare your attack ratings with the naive estimate "log of goals scored relative to the league average". Which way does the model move teams, and why?
Your turn Work this task in the notebook.

Task 3 · Check the model against things it was not fitted to

The model was fitted to home goals and away goals, separately. Football is about results. A model can match both goal distributions and still get the things people care about wrong.

Deliver posterior predictive checks, each with a verdict, for

  1. the share of draws in a season,
  2. the frequency of the most common scorelines (0-0, 1-1, 1-0, ...),
  3. one further statistic of your choice that the likelihood does not target directly.

Does anything fail badly enough to matter for forecasting results?

Your turn Work this task in the notebook.

Task 4 · Forecast the run-in

Now the live season. It is 13 February 2026: every team has played 26 of 38 matches. played is what you may fit to; remaining holds the 120 fixtures still to come. (It also holds their results and odds. Those are for marking - do not let the model see them.)

data.describe("epl_2526")
epl26 = prepare(data.load("epl_2526"))

CUTOFF = pd.Timestamp("2026-02-13")
played = epl26[epl26.Date < CUTOFF].reset_index(drop=True)
remaining = epl26[epl26.Date >= CUTOFF].reset_index(drop=True)

print(f"{len(played)} matches played, {len(remaining)} remaining")
league_table(played)
epl_2526: All 380 match results with goals, shots and bookmaker odds.
  source: football-data.co.uk, English Premier League 2025/26.
  url:    https://www.football-data.co.uk/mmz4281/2526/E0.csv
260 matches played, 120 remaining
pos played pts gf ga gd
team
Arsenal 1 26 57 50 18 32
Man City 2 26 53 54 24 30
Aston Villa 3 26 50 37 27 10
Man United 4 26 45 47 37 10
Chelsea 5 26 44 47 30 17
Liverpool 6 26 42 41 35 6
Brentford 7 26 40 40 35 5
Everton 8 26 37 29 30 -1
Bournemouth 9 26 37 43 45 -2
Newcastle 10 26 36 37 37 0
Sunderland 11 26 36 27 30 -3
Fulham 12 26 34 35 40 -5
Crystal Palace 13 26 32 28 32 -4
Brighton 14 26 31 34 34 0
Leeds 15 26 30 36 45 -9
Tottenham 16 26 29 36 37 -1
Nott'm Forest 17 26 27 25 38 -13
West Ham 18 26 24 32 49 -17
Burnley 19 26 18 28 51 -23
Wolves 20 26 9 16 48 -32

Deliver

  1. Your Task 2 model fitted to played, with clean diagnostics.
  2. Simulations of the 120 remaining fixtures from the posterior predictive (parameter uncertainty and match-day randomness), turned into complete final league tables - one table per posterior draw, with the usual tie-breakers.
  3. For every team: expected final points with a 94% interval, P(title), P(top 4), P(relegation) (bottom three).
  4. The reckoning: compare with league_table(epl26). Were the favourites right? How many of the 20 final points totals fall inside your 94% intervals, and how many should?
Your turn Work this task in the notebook.

Task 5 · Your numbers next to the bookmaker's

The sceptic's test. For each of the 120 held-out matches, the model and the bookmaker each give three probabilities (home win, draw, away win), and then one of the three happened.

Two proper scoring rules, both "lower is better", averaged over matches, where $p_{ik}$ is the probability given to outcome $k$ in match $i$ and $o_{ik}$ is 1 if it happened, else 0:

$$\text{log score} = -\frac{1}{N}\sum_i \log p_{i,\text{observed}} \qquad\qquad \text{Brier} = \frac{1}{N}\sum_i \sum_{k \in \{H,D,A\}} (p_{ik} - o_{ik})^2$$

Deliver

  1. Model probabilities for H/D/A for every held-out match, from the Task 4 posterior.
  2. Bookmaker probabilities from the Bet365 odds. Raw inverse odds do not sum to one; how big is the bookmaker's margin (the "overround"), and what do you do about it?
  3. A reference forecaster that knows nothing about teams.
  4. A table of both scores for all three forecasters - and a statement about whether 120 matches are enough to separate them. Quantify it.
Your turn Work this task in the notebook.

Task 6 · Would you bet on it?

The head of desk reads your table and asks the natural follow-up: "Fine, the market is better on average. But when your model disagrees with it - is there money in that? And what does the market know that your model does not?"

Deliver

  1. A back-test on the held-out matches: stake 1 unit on every outcome where your model sees positive expected value at the Bet365 odds (the real odds, margin included). Number of bets, profit, return on investment - with an uncertainty interval on the ROI.
  2. A diagnosis of how the model's probabilities differ from the market's. Is the model more or less opinionated? If you mix the two forecasts, w * model + (1 - w) * market, which w scores best on the held-out matches, and what does that tell you?
  3. A recommendation in three sentences. What is the model good for, what is it not good for, and what would you add first?
Your turn Work this task in the notebook.

Going further

  • Last season as a prior. Fit both seasons jointly, with a 2025/26 rating centred on a fraction of the 2024/25 rating and a separate prior for promoted teams. Does it score better on the held-out matches? (A quick attempt of ours scored 1.056 against 1.052 for the single-season model at round 26 - no better - and its sampler diagnostics needed work. Does it help at round 8, when the current season says little?)
  • Dependent scores. Task 3 found home and away goals more negatively correlated than the model allows, and the run-in had more draws than either forecaster expected. Add the low-score correction of Dixon & Coles (1997) with pm.Potential or pm.CustomDist, or a match-level random effect shared by both sides with opposite signs. Does the log score improve?
  • Time-varying strength. Chelsea and Burnley broke the model in Task 4. Let ratings follow a pm.GaussianRandomWalk over match rounds. Is there evidence that form is real, or is it noise?
  • Better data. Shots on target (HST, AST) are about three times as plentiful as goals. Model them jointly with goals and see whether ratings sharpen faster early in the season.
h.progress()

C02: 0/20 hints used, 14/14 checks passed.

Reference solution

A complete, executed solution with commentary on every task — including the ways the obvious model fails. Try the hint ladder first.

Open the worked solution →