The brief
You have joined the analytics desk of a sports media company. The editor wants two things:
- Team strength estimates - attack and defence ratings with honest uncertainty, not a league table with extra steps.
- 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 HEREcells. - 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
- How large is home advantage - in goals and in results?
- 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.
- One sentence: is a Poisson likelihood a defensible starting point?
print("goals per match home %.3f (variance %.3f) away %.3f (variance %.3f)"
% (epl25.FTHG.mean(), epl25.FTHG.var(), epl25.FTAG.mean(), epl25.FTAG.var()))
print("results:", epl25.FTR.value_counts(normalize=True).round(3).to_dict())
print("correlation between home and away goals: %.3f" % np.corrcoef(epl25.FTHG, epl25.FTAG)[0, 1])
goals = np.arange(0, 8)
fig, axes = plt.subplots(1, 2, figsize=(11, 3.8), sharey=True)
for ax, col, label in zip(axes, ["FTHG", "FTAG"], ["home goals", "away goals"]):
observed = epl25[col].value_counts(normalize=True).reindex(goals, fill_value=0)
ax.bar(goals, observed, alpha=0.6, label="observed")
ax.plot(goals, stats.poisson.pmf(goals, epl25[col].mean()), "o-", color="C1", label="Poisson, same mean")
ax.set(xlabel=label, title=f"mean {epl25[col].mean():.2f}, variance {epl25[col].var():.2f}")
ax.legend()
axes[0].set(ylabel="share of matches");
goals per match home 1.513 (variance 1.633) away 1.421 (variance 1.416)
results: {'H': 0.408, 'A': 0.347, 'D': 0.245}
correlation between home and away goals: -0.142
assert h.check("task0", home_goals_mean=epl25.FTHG.mean(), away_goals_mean=epl25.FTAG.mean())
- PASS
home_goals_mean= 1.513 is consistent with the reference solution. - PASS
away_goals_mean= 1.421 is consistent with the reference solution.
Home teams scored 1.51 goals per match against 1.42 for away teams, and won 41% of matches against 35% - a real but, in this particular season, modest home advantage.
The marginal goal distributions sit close to a Poisson with the same mean, and variances are close to the means (1.63 vs 1.51 at home, almost identical away). Note that this is the marginal distribution over all fixtures: a mixture of Poissons with different rates (Liverpool at home to Southampton, and the reverse) must be somewhat overdispersed, so a variance slightly above the mean is what a Poisson model with team effects predicts. The same goes for the negative correlation between home and away goals (-0.14): strong teams score a lot and concede little, so it need not mean that goals within a match depend on each other. Poisson is a defensible starting point; we will check it properly in Task 3.
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
- Fit exactly this model to 2024/25 with default sampler settings, using
coords/dimsfor teams. - 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.
- 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.
Solution
def team_codes(names, teams):
return pd.Categorical(names, categories=teams).codes
teams25 = np.sort(epl25.HomeTeam.unique())
home25, away25 = team_codes(epl25.HomeTeam, teams25), team_codes(epl25.AwayTeam, teams25)
with pm.Model(coords={"team": teams25}) as naive_model:
intercept = pm.Normal("intercept", 0, 3)
home = pm.Normal("home", 0, 3)
attack = pm.Normal("attack", 0, 3, dims="team")
defence = pm.Normal("defence", 0, 3, dims="team")
pm.Poisson("home_goals", pm.math.exp(intercept + home + attack[home25] - defence[away25]), observed=epl25.FTHG.values)
pm.Poisson("away_goals", pm.math.exp(intercept + attack[away25] - defence[home25]), observed=epl25.FTAG.values)
naive_idata = pm.sample(random_seed=RANDOM_SEED)
print("divergences:", int(naive_idata.sample_stats["diverging"].sum()))
az.summary(naive_idata, var_names=["intercept", "home", "attack"], coords={"team": ["Liverpool", "Southampton"]}, round_to=2)
divergences: 0
| mean | sd | eti89_lb | eti89_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| intercept | 0.24 | 0.85 | -1.29 | 1.53 | 59.56 | 52.48 | 1.05 | 0.11 | 0.07 |
| home | 0.06 | 0.06 | -0.03 | 0.16 | 1992.18 | 2384.58 | 1.00 | 0.00 | 0.00 |
| attack[Liverpool] | 0.50 | 0.58 | -0.56 | 1.36 | 58.10 | 58.88 | 1.08 | 0.08 | 0.07 |
| attack[Southampton] | -0.66 | 0.60 | -1.74 | 0.22 | 61.28 | 69.93 | 1.08 | 0.08 | 0.07 |
No divergences - but r_hat of 1.05-1.08 and a bulk ESS of about 60 for the intercept and
the team effects: four chains, 4000 draws, and the information of sixty. Yet home, in the
same model, has an ESS of 2000 and r_hat of 1.00. That contrast is the clue.
Look at the posterior sd of the attack ratings: about 0.6 on the log scale, i.e. we supposedly cannot tell whether Liverpool score 0.5x or 2x what an average team does, after 38 matches. Now look at a difference between two teams:
post = naive_idata.posterior
difference = post["attack"].sel(team="Liverpool") - post["attack"].sel(team="Southampton")
level = post["attack"].mean("team") - post["defence"].mean("team")
print("sd of attack[Liverpool] : %.2f" % post["attack"].sel(team="Liverpool").std())
print("sd of attack[Southampton] : %.2f" % post["attack"].sel(team="Southampton").std())
print("sd of attack[Liverpool] - attack[Southampton] : %.2f" % difference.std())
print("sd of intercept : %.2f" % post["intercept"].std())
print("sd of intercept + mean(attack) - mean(defence): %.2f" % (post["intercept"] + level).std())
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].scatter(post["attack"].sel(team="Liverpool"), post["attack"].sel(team="Southampton"), s=4, alpha=0.3)
axes[0].set(xlabel="attack[Liverpool]", ylabel="attack[Southampton]", title="two team ratings")
axes[1].scatter(level, post["intercept"], s=4, alpha=0.3)
axes[1].set(xlabel="mean(attack) - mean(defence)", ylabel="intercept", title="intercept vs average team level");
sd of attack[Liverpool] : 0.58 sd of attack[Southampton] : 0.60 sd of attack[Liverpool] - attack[Southampton] : 0.22 sd of intercept : 0.85 sd of intercept + mean(attack) - mean(defence): 0.04

assert h.check("task1", sd_attack_difference=difference.std())
- PASS
sd_attack_difference= 0.2232 is consistent with the reference solution.
The model is not identifiable. The likelihood only ever sees the sums $\mu + \text{att}_i - \text{def}_j$, and those are unchanged if you
- add a constant to every attack rating and subtract it from $\mu$, or
- add the same constant to every attack and every defence rating.
So there are two directions in parameter space along which the data say nothing at all. The posterior is a long, thin ridge (both panels): its length is set purely by the prior
- make the prior wider and the ridge gets longer - while its width is what the data
determined. NUTS has to crawl along it, hence the dismal ESS. It would be a mistake to "fix"
this with more draws or a higher
target_accept: the sampler is reporting a real property of the model.
What is pinned down: differences between teams (sd about 0.22 instead of 0.6), the home advantage, and the overall scoring level $\mu + \overline{\text{att}} - \overline{\text{def}}$ (sd 0.04, against 0.85 for $\mu$ on its own). Those are the identified quantities, and the fix is to parameterise the model so that only they are free.
Task 2 · An identified, hierarchical model
Deliver
- 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.
- Priors you can defend, backed by a prior predictive check on a scale a football fan would recognise.
- Clean diagnostics, and a comparison with Task 1: what happened to the effective sample size?
- 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?
Solution
pm.ZeroSumNormal constrains the ratings to sum to zero across teams, which removes both
flat directions at once: attack ratings are now "relative to the average team", and the
intercept is unambiguously the log scoring rate of an average away team against an average
defence. Giving the ratings a learned scale sigma_att, sigma_def makes the model
hierarchical: teams are shrunk towards the league average by an amount the data choose.
Priors, on the log scale: teams score around 1-2 goals a match, so intercept ~ Normal(0, 0.5)
covers rates from about 0.4 to 2.7. Home advantage multiplies scoring by maybe 1.1-1.4;
Normal(0, 0.25). A rating sd of 0.5 would already mean the best attacks score
$e^{1} \approx 2.7$ times the average, so HalfNormal(0.5) is generous.
The model is wrapped in a function with pm.Data inputs because we will fit it again to
another season and predict fixtures it has not seen. (The noncentred switch is for Task 4.)
def build_model(df, teams, noncentred=False):
coords = {"team": teams, "match": df.index.values}
with pm.Model(coords=coords) as model:
home_idx = pm.Data("home_idx", team_codes(df.HomeTeam, teams), dims="match")
away_idx = pm.Data("away_idx", team_codes(df.AwayTeam, teams), dims="match")
intercept = pm.Normal("intercept", 0, 0.5)
home = pm.Normal("home", 0, 0.25)
sigma_att = pm.HalfNormal("sigma_att", 0.5)
sigma_def = pm.HalfNormal("sigma_def", 0.5)
if noncentred: # same model, different geometry for the sampler - see E02
attack = pm.Deterministic("attack", sigma_att * pm.ZeroSumNormal("attack_z", sigma=1, dims="team"), dims="team")
defence = pm.Deterministic("defence", sigma_def * pm.ZeroSumNormal("defence_z", sigma=1, dims="team"), dims="team")
else:
attack = pm.ZeroSumNormal("attack", sigma=sigma_att, dims="team")
defence = pm.ZeroSumNormal("defence", sigma=sigma_def, dims="team")
mu_home = pm.Deterministic("mu_home", pm.math.exp(intercept + home + attack[home_idx] - defence[away_idx]), dims="match")
mu_away = pm.Deterministic("mu_away", pm.math.exp(intercept + attack[away_idx] - defence[home_idx]), dims="match")
pm.Poisson("home_goals", mu_home, observed=df.FTHG.values, dims="match", shape=mu_home.shape)
pm.Poisson("away_goals", mu_away, observed=df.FTAG.values, dims="match", shape=mu_away.shape)
return model
PARAMS = ["intercept", "home", "sigma_att", "sigma_def"]
model25 = build_model(epl25, teams25)
with model25:
idata25 = pm.sample_prior_predictive(1000, random_seed=RANDOM_SEED)
prior_goals = idata25.prior_predictive["home_goals"].values.ravel()
print("prior predictive home goals - quantiles 50% / 90% / 99%:", np.quantile(prior_goals, [0.5, 0.9, 0.99]))
print("share of simulated teams scoring more than 7 in a match: %.3f" % (prior_goals > 7).mean())
prior predictive home goals - quantiles 50% / 90% / 99%: [1. 3. 9.] share of simulated teams scoring more than 7 in a match: 0.017
A median of one goal, nine in ten simulated performances at three goals or fewer, and just under 2% at more than seven. No side scored more than seven in either season of our data, so the prior is generous in the tail - which is what weakly informative means - but nothing absurd dominates. Fit:
with model25:
idata25.update(pm.sample(random_seed=RANDOM_SEED))
print("divergences:", int(idata25.sample_stats["diverging"].sum()))
az.summary(idata25, var_names=PARAMS, ci_kind="hdi", ci_prob=0.94, round_to=3)
divergences: 0
| mean | sd | hdi94_lb | hdi94_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| intercept | 0.304 | 0.044 | 0.223 | 0.386 | 3254.403 | 3217.859 | 1.003 | 0.001 | 0.001 |
| home | 0.061 | 0.059 | -0.060 | 0.162 | 3417.038 | 3209.177 | 1.001 | 0.001 | 0.001 |
| sigma_att | 0.248 | 0.060 | 0.141 | 0.357 | 2379.264 | 2629.246 | 1.003 | 0.001 | 0.001 |
| sigma_def | 0.219 | 0.052 | 0.129 | 0.315 | 2575.697 | 2571.945 | 1.001 | 0.001 | 0.001 |
def min_ess(idata):
"""Smallest bulk ESS within each (group of) parameter(s)."""
s = az.summary(idata, var_names=["intercept", "home", "attack", "defence"])
return s.ess_bulk.groupby(s.index.str.split("[").str[0]).min()
ess = pd.DataFrame({"naive": min_ess(naive_idata), "zero-sum hierarchical": min_ess(idata25)}).round(0)
ess.index.name = "minimum bulk ESS"
ess
| naive | zero-sum hierarchical | |
|---|---|---|
| minimum bulk ESS | ||
| attack | 58.0 | 4222.0 |
| defence | 30.0 | 4557.0 |
| home | 1992.0 | 3417.0 |
| intercept | 60.0 | 3254.0 |
assert h.check(
"task2",
home=idata25.posterior["home"].mean(),
sigma_att=idata25.posterior["sigma_att"].mean(),
sigma_def=idata25.posterior["sigma_def"].mean(),
)
- PASS
home= 0.06145 is consistent with the reference solution. - PASS
sigma_att= 0.2482 is consistent with the reference solution. - PASS
sigma_def= 0.2192 is consistent with the reference solution.
Same likelihood, same data; ESS is 50 to 150 times higher for the intercept and the
ratings, because the ridge is gone. (home was identified all along and barely changes.)
The home advantage in 2024/25 is small: about +6% goals, with an interval that includes zero. Team abilities have an sd of roughly 0.25 (attack) and 0.22 (defence) on the log scale.
def rating_table(idata, teams):
post = idata.posterior
out = pd.DataFrame(index=teams)
for var in ["attack", "defence"]:
hdi = az.hdi(post[var], prob=0.94)
out[var] = post[var].mean(("chain", "draw")).values
out[f"{var}_lo"], out[f"{var}_hi"] = hdi.sel(ci_bound="lower").values, hdi.sel(ci_bound="upper").values
return out
ratings25 = rating_table(idata25, teams25).sort_values("attack")
goals_for = league_table(epl25).gf.loc[ratings25.index]
ratings25["raw_attack"] = np.log(goals_for / goals_for.mean())
fig, axes = plt.subplots(1, 3, figsize=(14, 5.5))
ypos = np.arange(len(ratings25))
for ax, var in zip(axes, ["attack", "defence"]):
r = ratings25
ax.errorbar(r[var], ypos, xerr=[r[var] - r[f"{var}_lo"], r[f"{var}_hi"] - r[var]], fmt="o", ms=4)
ax.axvline(0, color="k", lw=1)
ax.set(yticks=ypos, yticklabels=r.index if var == "attack" else [], title=f"{var} (94% HDI)", xlabel="log scale")
axes[2].scatter(ratings25.raw_attack, ratings25.attack)
lim = [-0.8, 0.6]
axes[2].plot(lim, lim, color="k", lw=1, label="no shrinkage")
axes[2].set(xlabel="raw: log(goals scored / league average)", ylabel="model attack rating", title="shrinkage")
axes[2].legend();

slope = np.polyfit(ratings25.raw_attack, ratings25.attack, 1)[0]
print(f"slope of model rating on raw rating: {slope:.2f}")
slope of model rating on raw rating: 0.69
Liverpool's attack and Arsenal's defence stand out at the top; the three relegated sides (Southampton, Ipswich, Leicester) are clearly the worst at both ends. But intervals are wide: after a full season, most mid-table teams cannot be separated.
The model's ratings are a compressed version of the raw ones, by about 30% (slope 0.69):
extreme scoring records are partly luck, and the hierarchical prior says so. Southampton's
26 goals in 38 matches were bad, but the model's best guess is that their attack was not
quite that bad. Nobody chose the 30%: it follows from sigma_att and from how much a
season of goals can tell you about one team.
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
- the share of draws in a season,
- the frequency of the most common scorelines (0-0, 1-1, 1-0, ...),
- one further statistic of your choice that the likelihood does not target directly.
Does anything fail badly enough to matter for forecasting results?
with model25:
pm.sample_posterior_predictive(idata25, extend_inferencedata=True, random_seed=RANDOM_SEED)
az.plot_ppc_rootogram(idata25, var_names=["home_goals", "away_goals"]);

The rootograms cover what the model was fitted to: observed counts (black) sit inside or at the edge of the predictive intervals. Now for what it was not. Each posterior predictive draw is one replicated season of 380 matches:
pp = az.extract(idata25, group="posterior_predictive", var_names=["home_goals", "away_goals"])
hg_rep, ag_rep = pp["home_goals"].values, pp["away_goals"].values # (match, sample)
hg_obs, ag_obs = epl25.FTHG.values[:, None], epl25.FTAG.values[:, None]
def correlation(hg, ag):
hg, ag = hg - hg.mean(axis=0), ag - ag.mean(axis=0)
return (hg * ag).mean(axis=0) / (hg.std(axis=0) * ag.std(axis=0))
statistics = {
"share of draws": lambda hg, ag: (hg == ag).mean(axis=0),
"share of 0-0": lambda hg, ag: ((hg == 0) & (ag == 0)).mean(axis=0),
"variance of total goals": lambda hg, ag: (hg + ag).var(axis=0),
"correlation of home and away goals": correlation,
}
fig, axes = plt.subplots(2, 2, figsize=(11, 6.5))
for ax, (name, fn) in zip(axes.ravel(), statistics.items()):
rep, obs = fn(hg_rep, ag_rep), fn(hg_obs, ag_obs)[0]
ax.hist(rep, bins=30, alpha=0.7)
ax.axvline(obs, color="k", label="observed")
ax.set(title=f"{name}\nobserved {obs:.3f}, replicated {rep.mean():.3f}, P(rep >= obs) = {(rep >= obs).mean():.2f}", yticks=[])
ax.title.set_fontsize(10)
axes[0, 0].legend();

scorelines = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0), (0, 2), (2, 1), (1, 2), (2, 2), (3, 0), (3, 1)]
rows = []
for i, j in scorelines:
rep = ((hg_rep == i) & (ag_rep == j)).mean(axis=0)
rows.append({"score": f"{i}-{j}", "observed": ((hg_obs == i) & (ag_obs == j)).mean(),
"replicated": rep.mean(), "lo": np.quantile(rep, 0.03), "hi": np.quantile(rep, 0.97)})
score_table = pd.DataFrame(rows).set_index("score")
fig, ax = plt.subplots(figsize=(10, 3.8))
xs = np.arange(len(score_table))
ax.errorbar(xs, score_table.replicated, yerr=[score_table.replicated - score_table.lo, score_table.hi - score_table.replicated],
fmt="o", color="C0", label="replicated (94% interval)")
ax.scatter(xs, score_table.observed, color="k", marker="x", zorder=3, label="observed")
ax.set(xticks=xs, xticklabels=score_table.index, xlabel="home-away score", ylabel="share of matches")
ax.legend();

assert h.check("task3", draw_share_replicated=statistics["share of draws"](hg_rep, ag_rep).mean())
- PASS
draw_share_replicated= 0.2301 is consistent with the reference solution.
Verdict. The share of draws - the quantity most likely to embarrass a model built from two independent Poissons - is reproduced: the model expects about 23%, the season had 24.5%, well inside the replicated range. Of the eleven most common scorelines, ten fall inside their 94% intervals; only 2-2 was clearly more frequent than the model expects, and 0-0 was rarer than expected, though not alarmingly so.
The one check that comes close to failing is the third: the variance of total goals per match was 2.6, and only about 2% of replicated seasons are that tame. The fourth panel says where it comes from. Team effects alone do make home and away goals negatively correlated in the replications (about -0.06), as we argued in Task 0, but the real season had -0.14, further out than roughly 94% of replications. When one side scored more, the other scored a little less than independence allows. Goals within a match are mildly dependent - the finding that made Dixon & Coles (1997) famous, although their version (too many 0-0 and 1-1) is not quite what this season shows.
Does it matter for forecasting results? The draw share and the scorelines say: not much. Two tail probabilities of 0.02 and 0.06 among several checks on one season are a lead, not a verdict. We proceed, and keep the dependence between the two scores in mind as the place to look if the forecasts disappoint.
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
- Your Task 2 model fitted to
played, with clean diagnostics. - 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.
- For every team: expected final points with a 94% interval, P(title), P(top 4), P(relegation) (bottom three).
- 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?
Solution
"Your Task 2 model, with clean diagnostics" hides a small trap. With 26 matches per team
instead of 38, sigma_att and sigma_def are less well determined and values near zero
become plausible - the funnel geometry of E02. Fit the model both ways and compare:
teams26 = np.sort(epl26.HomeTeam.unique())
fits26 = {}
for label, noncentred in [("centred", False), ("non-centred", True)]:
with build_model(played, teams26, noncentred=noncentred) as model26:
fits26[label] = pm.sample(random_seed=RANDOM_SEED)
rows = {}
for label, idata in fits26.items():
summary = az.summary(idata, var_names=["sigma_att", "sigma_def"])
rows[label] = {"divergences": int(idata.sample_stats["diverging"].sum()), "min ess_bulk": summary.ess_bulk.min(),
"min ess_tail": summary.ess_tail.min(), "max r_hat": summary.r_hat.max()}
pd.DataFrame(rows).T
| divergences | min ess_bulk | min ess_tail | max r_hat | |
|---|---|---|---|---|
| centred | 0.0 | 448.379024 | 316.528415 | 1.008097 |
| non-centred | 0.0 | 811.371386 | 607.096361 | 1.003756 |
Neither fit diverges, but the centred one is visibly less comfortable: for the two sigmas a
minimum bulk ESS of about 450 (tail: 320) and an r_hat of 1.008, against about 810 (610)
and 1.004 for the non-centred version. With the full season of Task 2 it is the other way
round (try it): the more the data say about every team, the better the centred form does.
The centred fit would pass, but title and relegation probabilities lean on the tails of the
posterior, so we take the better one.
idata26 = fits26["non-centred"] # model26 is the non-centred model, the last one built in the loop
az.summary(idata26, var_names=PARAMS, ci_kind="hdi", ci_prob=0.94, round_to=3)
| mean | sd | hdi94_lb | hdi94_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| intercept | 0.183 | 0.056 | 0.078 | 0.286 | 2257.498 | 2399.078 | 1.003 | 0.001 | 0.001 |
| home | 0.209 | 0.074 | 0.068 | 0.341 | 2364.512 | 2363.149 | 1.003 | 0.002 | 0.001 |
| sigma_att | 0.210 | 0.062 | 0.104 | 0.338 | 1295.662 | 1511.186 | 1.004 | 0.002 | 0.001 |
| sigma_def | 0.172 | 0.066 | 0.039 | 0.302 | 811.371 | 607.096 | 1.003 | 0.002 | 0.002 |
One number stands out: home is 0.21, i.e. home sides score about 23% more - more than
three times last season's estimate, with an interval that excludes zero. Whether home
advantage really moves that much from one year to the next, or whether one of the two
seasons is a fluke, is something a single-season model cannot know. We will see in Task 6
what the bookmaker thinks.
Now swap the fixtures in with pm.set_data and sample the posterior predictive for the
unseen matches. We also keep the match rates mu_home, mu_away for Task 5.
with model26:
pm.set_data(
{"home_idx": team_codes(remaining.HomeTeam, teams26), "away_idx": team_codes(remaining.AwayTeam, teams26)},
coords={"match": remaining.index.values},
)
forecast = pm.sample_posterior_predictive(
idata26, var_names=["mu_home", "mu_away", "home_goals", "away_goals"], predictions=True, random_seed=RANDOM_SEED
)
sims = az.extract(forecast, group="predictions")
hg_sim, ag_sim = sims["home_goals"].values, sims["away_goals"].values # (match, sample)
hg_sim.shape
(120, 4000)
Bookkeeping, vectorised over draws: start every simulated season from the real table on 13 February, add the simulated results, and rank with points, then goal difference, then goals scored (scaled so that one number sorts all three; a tiny random term breaks exact ties).
def simulate_tables(current, fixtures, hg, ag, teams, rng):
"""Final points, goal difference and rank per team and draw, arrays of shape (team, sample)."""
n_draws = hg.shape[1]
start = current.loc[teams]
pts = np.repeat(start.pts.values[:, None], n_draws, axis=1).astype(float)
gd = np.repeat(start.gd.values[:, None], n_draws, axis=1).astype(float)
gf = np.repeat(start.gf.values[:, None], n_draws, axis=1).astype(float)
h_idx, a_idx = team_codes(fixtures.HomeTeam, teams), team_codes(fixtures.AwayTeam, teams)
for k in range(len(fixtures)):
pts[h_idx[k]] += np.select([hg[k] > ag[k], hg[k] == ag[k]], [3, 1], 0)
pts[a_idx[k]] += np.select([ag[k] > hg[k], hg[k] == ag[k]], [3, 1], 0)
gd[h_idx[k]] += hg[k] - ag[k]
gd[a_idx[k]] += ag[k] - hg[k]
gf[h_idx[k]] += hg[k]
gf[a_idx[k]] += ag[k]
order_key = pts * 1e6 + (gd + 500) * 1e3 + gf + rng.uniform(0, 0.5, size=pts.shape)
rank = (-order_key).argsort(axis=0).argsort(axis=0) + 1
return pts, gd, rank
pts_sim, gd_sim, rank_sim = simulate_tables(league_table(played), remaining, hg_sim, ag_sim, teams26, rng)
final = league_table(epl26)
outlook = pd.DataFrame({
"pts_13_feb": league_table(played).pts.loc[teams26].values,
"exp_pts": pts_sim.mean(axis=1),
"lo": np.quantile(pts_sim, 0.03, axis=1),
"hi": np.quantile(pts_sim, 0.97, axis=1),
"P_title": (rank_sim == 1).mean(axis=1),
"P_top4": (rank_sim <= 4).mean(axis=1),
"P_relegation": (rank_sim >= 18).mean(axis=1),
}, index=teams26)
outlook["actual_pts"] = final.pts.loc[teams26]
outlook["actual_pos"] = final.pos.loc[teams26]
outlook = outlook.sort_values("actual_pos")
outlook.round(2)
| pts_13_feb | exp_pts | lo | hi | P_title | P_top4 | P_relegation | actual_pts | actual_pos | |
|---|---|---|---|---|---|---|---|---|---|
| Arsenal | 57 | 79.15 | 70.00 | 88.00 | 0.72 | 1.00 | 0.00 | 85 | 1 |
| Man City | 53 | 74.19 | 65.00 | 83.00 | 0.24 | 0.98 | 0.00 | 78 | 2 |
| Man United | 45 | 63.27 | 54.00 | 73.00 | 0.00 | 0.44 | 0.00 | 71 | 3 |
| Aston Villa | 50 | 68.63 | 60.00 | 78.00 | 0.03 | 0.82 | 0.00 | 65 | 4 |
| Liverpool | 42 | 60.09 | 51.00 | 69.00 | 0.00 | 0.20 | 0.00 | 60 | 5 |
| Bournemouth | 37 | 53.49 | 45.00 | 63.00 | 0.00 | 0.02 | 0.00 | 57 | 6 |
| Sunderland | 36 | 51.98 | 43.00 | 61.00 | 0.00 | 0.01 | 0.00 | 54 | 7 |
| Brighton | 31 | 47.57 | 39.00 | 57.00 | 0.00 | 0.00 | 0.02 | 53 | 8 |
| Brentford | 40 | 58.08 | 49.00 | 68.00 | 0.00 | 0.11 | 0.00 | 53 | 9 |
| Chelsea | 44 | 62.61 | 53.00 | 72.00 | 0.00 | 0.41 | 0.00 | 52 | 10 |
| Fulham | 34 | 50.48 | 42.00 | 59.03 | 0.00 | 0.00 | 0.01 | 52 | 11 |
| Newcastle | 36 | 51.95 | 43.00 | 61.00 | 0.00 | 0.01 | 0.00 | 49 | 12 |
| Everton | 37 | 52.27 | 44.00 | 61.00 | 0.00 | 0.01 | 0.00 | 49 | 13 |
| Leeds | 30 | 45.77 | 37.00 | 55.00 | 0.00 | 0.00 | 0.06 | 47 | 14 |
| Crystal Palace | 32 | 47.22 | 39.00 | 57.00 | 0.00 | 0.00 | 0.03 | 45 | 15 |
| Nott'm Forest | 27 | 40.76 | 32.97 | 50.00 | 0.00 | 0.00 | 0.27 | 44 | 16 |
| Tottenham | 29 | 45.40 | 37.00 | 54.00 | 0.00 | 0.00 | 0.04 | 41 | 17 |
| West Ham | 24 | 37.50 | 29.00 | 47.00 | 0.00 | 0.00 | 0.61 | 39 | 18 |
| Burnley | 18 | 30.64 | 23.00 | 40.00 | 0.00 | 0.00 | 0.95 | 22 | 19 |
| Wolves | 9 | 20.55 | 13.00 | 30.00 | 0.00 | 0.00 | 1.00 | 20 | 20 |
fig, ax = plt.subplots(figsize=(9, 6))
ypos = np.arange(len(outlook))[::-1]
ax.errorbar(outlook.exp_pts, ypos, xerr=[outlook.exp_pts - outlook.lo, outlook.hi - outlook.exp_pts],
fmt="o", ms=4, label="forecast on 13 Feb (94% interval)")
ax.scatter(outlook.pts_13_feb, ypos, marker="|", color="0.5", label="points on 13 Feb")
ax.scatter(outlook.actual_pts, ypos, marker="x", color="k", zorder=3, label="actual final points")
ax.set(yticks=ypos, yticklabels=outlook.index, xlabel="points", xlim=(5, 108))
ax.legend(loc="lower right", fontsize=10);

inside = ((outlook.actual_pts >= outlook.lo) & (outlook.actual_pts <= outlook.hi))
print(f"final points inside the 94% interval: {inside.sum()} of {len(outlook)}; outside: {list(outlook.index[~inside])}")
print("actual top 4: ", list(final.index[:4]))
print("model's top 4: ", list(outlook.P_top4.sort_values(ascending=False).index[:4]))
print("actually relegated:", list(final.index[-3:]))
print("model's bottom 3: ", list(outlook.P_relegation.sort_values(ascending=False).index[:3]))
# How much of the forecast uncertainty is football, and how much is not knowing the team strengths?
# Re-simulate with every scoring rate fixed at its posterior mean: only match-day randomness is left.
mu_home_fixed = np.repeat(sims["mu_home"].values.mean(axis=1, keepdims=True), hg_sim.shape[1], axis=1)
mu_away_fixed = np.repeat(sims["mu_away"].values.mean(axis=1, keepdims=True), hg_sim.shape[1], axis=1)
pts_fixed, _, _ = simulate_tables(league_table(played), remaining, rng.poisson(mu_home_fixed), rng.poisson(mu_away_fixed), teams26, rng)
print(f"sd of final points, averaged over teams: {pts_sim.std(axis=1).mean():.2f} with full uncertainty, "
f"{pts_fixed.std(axis=1).mean():.2f} with team strengths fixed at their posterior means")
final points inside the 94% interval: 18 of 20; outside: ['Chelsea', 'Burnley'] actual top 4: ['Arsenal', 'Man City', 'Man United', 'Aston Villa'] model's top 4: ['Arsenal', 'Man City', 'Aston Villa', 'Man United'] actually relegated: ['West Ham', 'Burnley', 'Wolves'] model's bottom 3: ['Wolves', 'Burnley', 'West Ham'] sd of final points, averaged over teams: 4.78 with full uncertainty, 4.42 with team strengths fixed at their posterior means
assert h.check(
"task4",
p_title_arsenal=outlook.P_title["Arsenal"],
p_relegation_west_ham=outlook.P_relegation["West Ham"],
expected_points_arsenal=outlook.exp_pts["Arsenal"],
)
- PASS
p_title_arsenal= 0.7235 is consistent with the reference solution. - PASS
p_relegation_west_ham= 0.6088 is consistent with the reference solution. - PASS
expected_points_arsenal= 79.15 is consistent with the reference solution.
On 13 February the model made Arsenal clear but not overwhelming title favourites (about 70%, Manchester City most of the rest), and named Wolves, Burnley and West Ham as the most likely relegated sides, West Ham at roughly 60%. Arsenal won the league and exactly those three went down. The model's four most likely top-four finishers were the actual top four, but do not read much into that: the last place was close to a coin toss between Manchester United (44%) and Chelsea (41%), and it happened to fall the model's way.
More important than the hits: calibration. 18 of 20 final totals fall inside their 94% intervals, where we would expect about 19. The two misses are instructive. Chelsea took 8 points from their last 12 matches after 44 from 26, and Burnley took 4. At the other end, Manchester United's 26 points from 12 matches only just stayed inside their interval. The model assumes a team's strength is constant over the season; form, injuries, managerial changes and fixture congestion are all outside it.
Note how wide the intervals are: roughly ±9 points with 12 matches to go. Most of that is irreducible match-day randomness, not parameter uncertainty: fixing every team's strength at its posterior mean only reduces the sd of final points from 4.8 to 4.4. Better ratings would not make these intervals much narrower. A desk that publishes "Arsenal will finish on 79 points" rather than "between 70 and 88" is selling false precision.
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
- Model probabilities for H/D/A for every held-out match, from the Task 4 posterior.
- 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?
- A reference forecaster that knows nothing about teams.
- A table of both scores for all three forecasters - and a statement about whether 120 matches are enough to separate them. Quantify it.
Solution
The outcome probabilities could be read off the simulated scores of Task 4, but with 4000 draws they would carry Monte Carlo noise of up to about 0.008 each. We have the posterior draws of the scoring rates for each fixture, so we can do without that noise and compute the probabilities exactly: for each draw the score matrix is an outer product of two Poisson pmfs; average over draws (that is the posterior predictive), then sum the lower triangle (home win), diagonal (draw) and upper triangle (away win).
def outcome_probabilities(mu_home, mu_away, max_goals=12):
"""P(home win), P(draw), P(away win) per match from rate draws of shape (match, sample)."""
g = np.arange(max_goals + 1)
pmf_home = stats.poisson.pmf(g[None, :, None], mu_home[:, None, :]) # (match, goals, sample)
pmf_away = stats.poisson.pmf(g[None, :, None], mu_away[:, None, :])
score = np.einsum("mis,mjs->mij", pmf_home, pmf_away) / mu_home.shape[1] # posterior predictive score matrix
probs = np.stack([np.tril(score, -1).sum((1, 2)), np.trace(score, axis1=1, axis2=2), np.triu(score, 1).sum((1, 2))], axis=1)
return probs / probs.sum(axis=1, keepdims=True)
p_model = outcome_probabilities(sims["mu_home"].values, sims["mu_away"].values)
inverse_odds = 1 / remaining[["B365H", "B365D", "B365A"]].values
overround = inverse_odds.sum(axis=1) - 1
p_bookmaker = inverse_odds / inverse_odds.sum(axis=1, keepdims=True)
base_rates = played.FTR.value_counts(normalize=True).loc[["H", "D", "A"]].values
p_base = np.tile(base_rates, (len(remaining), 1))
print(f"mean overround: {overround.mean():.3f}")
print("base rates H/D/A from the first 26 rounds:", base_rates.round(3))
mean overround: 0.055 base rates H/D/A from the first 26 rounds: [0.431 0.265 0.304]
outcome = remaining.FTR.map({"H": 0, "D": 1, "A": 2}).values
onehot = np.eye(3)[outcome]
def log_scores(p):
return -np.log(p[np.arange(len(outcome)), outcome])
def brier_scores(p):
return ((p - onehot) ** 2).sum(axis=1)
forecasters = {"model": p_model, "bookmaker": p_bookmaker, "base rates": p_base}
scores = pd.DataFrame({
"log score": {k: log_scores(p).mean() for k, p in forecasters.items()},
"Brier": {k: brier_scores(p).mean() for k, p in forecasters.items()},
})
scores.round(4)
| log score | Brier | |
|---|---|---|
| model | 1.0523 | 0.6322 |
| bookmaker | 1.0352 | 0.6221 |
| base rates | 1.0853 | 0.6573 |
def paired_difference(a, b):
d = log_scores(forecasters[a]) - log_scores(forecasters[b])
return pd.Series({"mean difference in log score": d.mean(), "standard error": d.std(ddof=1) / np.sqrt(len(d))})
pd.DataFrame({
"model - bookmaker": paired_difference("model", "bookmaker"),
"base rates - model": paired_difference("base rates", "model"),
}).T.round(4)
| mean difference in log score | standard error | |
|---|---|---|
| model - bookmaker | 0.0171 | 0.0189 |
| base rates - model | 0.0329 | 0.0196 |
assert h.check(
"task5",
overround=overround.mean(),
log_score_bookmaker=log_scores(p_bookmaker).mean(),
log_score_model=log_scores(p_model).mean(),
)
- PASS
overround= 0.05547 is consistent with the reference solution. - PASS
log_score_bookmaker= 1.035 is consistent with the reference solution. - PASS
log_score_model= 1.052 is consistent with the reference solution.
Bet365's odds imply probabilities that sum to about 1.055: a 5.5% margin. Dividing by the sum (proportional normalisation) is the simplest way to remove it.
The bookmaker wins, on both scores. Our model sits between the bookmaker and the know-nothing baseline, a little closer to the bookmaker. But look at the standard errors of the paired differences: each gap is about one to two standard errors. With 120 matches we cannot formally separate any pair of these forecasters - football results are so noisy that a useful edge in log score is a few hundredths, and measuring that takes many hundreds of matches. The honest summary for the head of desk: "as expected, no evidence that we beat the market; mild evidence that we beat nothing at all; ask me again in three seasons".
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
- 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.
- 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, whichwscores best on the held-out matches, and what does that tell you? - A recommendation in three sentences. What is the model good for, what is it not good for, and what would you add first?
odds = remaining[["B365H", "B365D", "B365A"]].values
expected_value = p_model * odds - 1 # per unit staked, if the model were right
bets = expected_value > 0
profit = np.where(onehot == 1, odds - 1, -1.0) * bets # (match, outcome)
boot = []
for _ in range(5000):
i = rng.integers(0, len(remaining), len(remaining)) # resample matches
boot.append(profit[i].sum() / bets[i].sum())
print(f"bets placed: {bets.sum()} of {bets.size} possible (H/D/A: {bets.sum(axis=0)})")
print(f"profit: {profit.sum():+.1f} units, ROI {profit.sum() / bets.sum():+.1%}")
roi_lo, roi_hi = np.quantile(boot, [0.03, 0.97])
print(f"94% bootstrap interval for ROI: [{roi_lo:+.0%}, {roi_hi:+.0%}]")
print(f"for reference, ROI of betting every outcome of every match: {np.where(onehot == 1, odds - 1, -1.0).mean():+.1%}")
bets placed: 150 of 360 possible (H/D/A: [58 37 55]) profit: -4.7 units, ROI -3.2% 94% bootstrap interval for ROI: [-29%, +24%] for reference, ROI of betting every outcome of every match: -1.4%
weights = np.linspace(0, 1, 41)
blend_scores = [log_scores(w * p_model + (1 - w) * p_bookmaker).mean() for w in weights]
best_w = weights[int(np.argmin(blend_scores))]
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].scatter(p_bookmaker[:, 0], p_model[:, 0], s=14, label="home win")
axes[0].scatter(p_bookmaker[:, 2], p_model[:, 2], s=14, label="away win")
axes[0].scatter(p_bookmaker[:, 1], p_model[:, 1], s=14, label="draw")
axes[0].plot([0, 0.9], [0, 0.9], color="k", lw=1)
axes[0].set(xlabel="bookmaker probability", ylabel="model probability", title="120 held-out matches")
axes[0].legend()
axes[1].plot(weights, blend_scores)
axes[1].axvline(best_w, color="k", ls="--", lw=1)
axes[1].set(xlabel="weight on the model", ylabel="held-out log score", title=f"best mixture: w = {best_w:.2f}")
print("sd of P(home win) across matches: model %.3f bookmaker %.3f" % (p_model[:, 0].std(), p_bookmaker[:, 0].std()))
print("mean probability of H/D/A: model", p_model.mean(axis=0).round(3), " bookmaker", p_bookmaker.mean(axis=0).round(3),
" what happened", onehot.mean(axis=0).round(3))
print("mean |model - bookmaker| for H/D/A:", np.abs(p_model - p_bookmaker).mean(axis=0).round(3));
sd of P(home win) across matches: model 0.100 bookmaker 0.169 mean probability of H/D/A: model [0.44 0.244 0.316] bookmaker [0.439 0.24 0.321] what happened [0.417 0.292 0.292] mean |model - bookmaker| for H/D/A: [0.078 0.023 0.069]

assert h.check("task6", best_blend_weight=best_w)
- PASS
best_blend_weight= 0.1 is consistent with the reference solution.
The back-test. The model "finds value" in 150 bets - which should already make you suspicious: a market this liquid does not misprice 40% of all outcomes. The realised ROI is a few percent negative, about what the bookmaker's margin predicts for someone betting without an edge, and the bootstrap interval runs from about -30% to +25%. For scale: blindly backing every outcome of every match lost only 1.4% on these matches, less than the margin - that is how noisy 120 matches are. They can neither prove nor disprove an edge; the prior expectation (no edge) stands.
The diagnosis. On average the two forecasters agree almost exactly: 44% home wins, 24% draws, 32% away wins. So the market, too, believes in this season's larger home advantage (and both expected fewer draws than the 29% that happened). The difference is in the spread, and the scatter is the most useful picture in this notebook. The two forecasters agree on the ordering of matches, but the cloud is flatter than the diagonal: where the market says 75%, the model says 60%; where the market says 15%, the model says 20-25%. The sd of the home-win probability across matches is 0.10 for the model and 0.17 for the market. Our model is less opinionated than the market, not more. That is the hierarchical prior at work: after 26 matches it shrinks Arsenal and Wolves towards the average, because from goals alone it cannot rule out luck. The market can, because it knows things our model does not: previous seasons, squad value, injuries, expected goals, line-ups. The best mixture puts a weight of only about 0.1 on the model - on these matches it adds next to nothing to the market's forecast.
Recommendation. Publish the model for what it is good at: transparent team ratings with honest uncertainty, and season simulations (title, top four, relegation) that the odds for single matches do not give you and that proved well calibrated. Do not use it to second-guess match odds, and do not bet with it. The diagnosis says our ratings are too timid, not too bold, so the first thing to add is information that sharpens them faster than goals can: shots or expected goals, and what we knew before the season started.
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.Potentialorpm.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.GaussianRandomWalkover 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.