Rugby — hierarchical Poisson with crossed team random effects¶
From-scratch Metropolis-within-Gibbs; the PyMC Six-Nations example¶
Module: rugby_gibbs.py. The PyMC "Hierarchical model for Rugby prediction" (Baio & Blangiardo 2010), 2014 Six Nations. This is the richest of the three hierarchical-Poisson projects: every team carries two random effects — an attack and a defence — that appear, crossed, across many matches.
| project | random effect(s) | conjugacy | sampler |
|---|---|---|---|
| Pumps | one Gamma rate per pump | conjugate | (almost) pure Gibbs |
| Epil | one Normal intercept per patient | non-conjugate | Metropolis-within-Gibbs |
| Rugby (here) | two crossed Normal effects per team (attack, defence) | non-conjugate | Metropolis-within-Gibbs |
Model¶
Each match (home team $h$, away team $a$) produces two scores, each Poisson: $$\text{home score}\sim\text{Poisson}(\theta_h),\quad \log\theta_h=\mu+\text{home}+\text{att}_h-\text{def}_a,$$ $$\text{away score}\sim\text{Poisson}(\theta_a),\quad \log\theta_a=\mu\ \ \ \ \ \ \ +\text{att}_a-\text{def}_h.$$ A team scores more with a stronger attack $\text{att}$ and against a weaker defence $\text{def}$ (higher $\text{def}_t$ ⇒ fewer points conceded). The home side gets a fixed bonus $\text{home}$. Hierarchical priors tie the teams together: $$\text{att}_t\sim N(0,\sigma_{\text{att}}^2),\quad \text{def}_t\sim N(0,\sigma_{\text{def}}^2),\quad \mu,\text{home}\sim N(0,10^2),\quad \sigma^2_{\bullet}\sim\text{IG}(a_0,b_0).$$
Algorithm (crossed Metropolis-within-Gibbs)¶
We stack the 15 matches into 30 scoring events, each with linear predictor $\eta=\mu+\text{home}\cdot\mathbb 1_{\text{home}}+\text{att}_{\text{scorer}}-\text{def}_{\text{opp}}$. One sweep:
- $(\mu,\text{home})$ — block random-walk Metropolis.
- $\text{att}_t$ — per-team RW-Metropolis, vectorised over teams (step $\propto1/\sqrt{\text{curvature}_t}$, accept/reject all teams at once via
np.bincountover the scorer index). Enters with $+$ sign. - $\text{def}_t$ — same, indexed by the opponent; enters with $-$ sign.
- Centre att and def (subtract their means, absorb into $\mu$) — the sum-to-zero translation move, since att/def levels are confounded with $\mu$ (att adds, def subtracts in every event). Exact Gibbs step; without it the level mixes terribly.
- $\sigma^2_{\text{att}},\sigma^2_{\text{def}}$ — Inverse-Gamma conjugate (sum-to-zero ⇒ $T-1$ effective d.f.).
This is the same machinery as Epil's GLMM (hpois_gibbs.py), generalised to two crossed effect vectors instead of one nested intercept.
import numpy as np, matplotlib.pyplot as plt
from rugby_gibbs import rugby_data, rugby_gibbs, summary
y, scorer, opp, is_home, teams = rugby_data('rugby.csv', 2014); T = len(teams)
out = rugby_gibbs(y, scorer, opp, is_home, T, R=30000, burn=5000, seed=1)
print(f"acceptance (mu,home) {out['accept_fix']:.2f} att {out['accept_att']:.2f} def {out['accept_def']:.2f}")
print(f"mu = {out['mu'].mean():.3f} (baseline score exp(mu) = {np.exp(out['mu'].mean()):.1f})")
print(f"home = {out['home'].mean():.3f} 95% CI [{np.percentile(out['home'],2.5):.2f}, {np.percentile(out['home'],97.5):.2f}] -> home advantage x{np.exp(out['home'].mean()):.2f}")
print(f"sd_att = {out['sd_att'].mean():.3f} sd_def = {out['sd_def'].mean():.3f}\n")
att = out['att'].mean(0); deff = out['deff'].mean(0)
print(f"{'team':<10}{'attack':>9}{'defence':>9} (sorted by attack)")
for t in np.argsort(-att):
print(f"{teams[t]:<10}{att[t]:>9.3f}{deff[t]:>9.3f}")
acceptance (mu,home) 0.48 att 0.63 def 0.63 mu = 2.661 (baseline score exp(mu) = 14.3) home = 0.366 95% CI [0.19, 0.54] -> home advantage x1.44 sd_att = 0.594 sd_def = 0.617 team attack defence (sorted by attack) England 0.416 0.247 Ireland 0.187 0.569 Wales 0.135 0.142 France 0.064 -0.144 Italy -0.243 -0.523 Scotland -0.559 -0.292
fig, ax = plt.subplots(1, 2, figsize=(12, 4.6))
# team-strength map: attack (x) vs defence (y); top-right = strong both ways
ax[0].axhline(0, color='grey', lw=.7); ax[0].axvline(0, color='grey', lw=.7)
ax[0].scatter(att, deff, s=70, color='steelblue', zorder=3, edgecolor='k', lw=.4)
for t in range(T):
ax[0].annotate(teams[t], (att[t], deff[t]), fontsize=9, xytext=(5, 4), textcoords='offset points')
ax[0].set_xlabel('attack (higher = scores more)'); ax[0].set_ylabel('defence (higher = concedes less)')
ax[0].set_title('Team-strength map (2014 Six Nations)')
# home-advantage posterior
ax[1].hist(out['home'], bins=50, density=True, color='darkorange', alpha=.8)
ax[1].axvline(out['home'].mean(), color='k', ls='--', lw=1, label=f"mean {out['home'].mean():.2f}")
ax[1].axvline(0, color='firebrick', ls=':', lw=1, label='no home effect')
ax[1].set_xlabel('home advantage (log scale)'); ax[1].set_yticks([])
ax[1].set_title(f"Home advantage (P>0 = {(out['home']>0).mean():.3f})"); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig('rugby_strength.png', dpi=120, bbox_inches='tight'); plt.show()
Results¶
- Home advantage is real: $\text{home}\approx0.37$, i.e. a host scores about 1.4× as much, with essentially all posterior mass above 0.
- Attack: England top (they put 52 on Italy, 29 on Wales), then Ireland and Wales; Scotland weakest (scored 0, 3, 6 in three games). Defence: Ireland best (highest def — they conceded the fewest points and won the 2014 title on points difference), Italy worst (conceded 46, 30, 52).
- The team-strength map separates the table: Ireland and England sit top-right (strong attack and defence); Italy and Scotland bottom-left. This is exactly the 2014 final standings, recovered from 15 matches by pooling through $\sigma_{\text{att}},\sigma_{\text{def}}$.
Takeaways¶
- Crossed random effects, same engine. Two effect vectors (attack/defence), each entering every match through a different team index, are handled by the same vectorised Metropolis-within-Gibbs as Epil's single nested intercept —
np.bincountover the scorer index for attack, over the opponent index for defence. - Sum-to-zero is essential. att and def levels are confounded with $\mu$; the centring/translation move makes the level mix in one step (cf. the identical fix in
hpois_gibbs.py). - Caveat: rugby points arrive in lumps of 3/5/7 (penalties/tries/conversions), so the Poisson is a deliberate simplification — but it captures the relative team strengths well, which is the point of the example. Cross-checks (PyMC NUTS, R
glmer) follow.