Scotland lip cancer — the BYM spatial Poisson model¶
From-scratch Metropolis-within-Gibbs with an intrinsic CAR prior¶
Module: scotland_bym.py. The classic disease-mapping problem (Clayton & Kaldor 1987; Besag, York & Mollié 1991; WinBUGS/GeoBUGS "Lip"). 56 Scottish districts: we have observed lip-cancer cases $O_i$, age-standardised expected cases $E_i$ (the offset), and a covariate $x_i$ = % employed in agriculture/fishing/forestry (AFF — a proxy for outdoor sun exposure). The new ingredient over the hierarchical-Poisson projects is a spatially structured random effect: neighbouring districts borrow strength from each other, not just from a global mean.
Model — Besag-York-Mollié (BYM)¶
$$O_i\sim\text{Poisson}(E_i\,e^{\eta_i}),\qquad \eta_i=\alpha+\beta x_i+\theta_i+\phi_i.$$ Two area-level random effects, the heart of BYM:
- $\theta_i\sim N(0,\sigma_\theta^2)$ — unstructured heterogeneity (exchangeable, as in the Epil GLMM): area-specific noise with no geography.
- $\phi\sim\text{ICAR}(\sigma_\phi^2)$ — structured spatial effect via the intrinsic conditional autoregressive prior: $$\phi_i\mid\phi_{-i}\sim N\!\Big(\tfrac1{n_i}\sum_{j\sim i}\phi_j,\ \tfrac{\sigma_\phi^2}{n_i}\Big),$$ i.e. each district is pulled toward the average of its neighbours. Equivalently $\log p(\phi)=-\tfrac1{2\sigma_\phi^2}\sum_{i\sim j}(\phi_i-\phi_j)^2$ — a penalty on differences across the map's adjacency graph. It is improper (invariant to a global shift), so $\phi$ is re-centred to sum-to-zero with the level absorbed into $\alpha$.
The fitted relative risk of district $i$ is $e^{\eta_i}$ — a smoothed version of the raw SMR $O_i/E_i$.
Algorithm (Metropolis-within-Gibbs)¶
- $(\alpha,\beta)$ — block RW-Metropolis.
- $\theta_i$ — vectorised RW-Metropolis (the $\theta_i$ are conditionally independent), curvature-scaled step.
- $\phi_i$ — sequential RW-Metropolis (neighbours are coupled, so each update must use the current neighbour values), curvature-scaled.
- $\sigma_\theta^2$ Inverse-Gamma ($n/2$ d.f.); $\sigma_\phi^2$ Inverse-Gamma ($(n{-}1)/2$ d.f., since ICAR has rank $n-1$). Re-centre $\theta,\phi$ into $\alpha$ each sweep (location moves).
Where this sits¶
- vs Epil/Pumps/Rugby (hierarchical Poisson): those pool areas toward a global distribution; BYM adds a local spatial term so nearby areas are similar — pooling with geography.
- vs fish ZIP (mixture): different latent structure again — continuous spatial field, not a discrete class.
import numpy as np, matplotlib.pyplot as plt
from scotland_bym import scotland_data, bym_gibbs
O, E, x, adj, names = scotland_data('scotland_lip.csv'); n = len(O)
out = bym_gibbs(O, E, x, adj, R=20000, burn=5000, seed=1)
al, be = out['alpha'], out['beta']
print(f"acceptance (alpha,beta) {out['accept_ab']:.2f} theta {out['accept_theta']:.2f} phi {out['accept_phi']:.2f}")
print(f"alpha = {al.mean():.3f}")
print(f"beta = {be.mean():.3f} 95% CI [{np.percentile(be,2.5):.2f}, {np.percentile(be,97.5):.2f}]"
f" -> RR per +10% AFF = {np.exp(be.mean()):.2f} P(beta>0) = {(be>0).mean():.3f}")
print(f"sd_theta (unstructured) = {out['sd_theta'].mean():.3f}")
print(f"sd_phi (spatial) = {out['sd_phi'].mean():.3f}")
frac = (out['sd_phi']**2 / (out['sd_phi']**2 + out['sd_theta']**2)).mean()
print(f"spatial fraction of area variance = {frac:.2%} -> the heterogeneity is almost entirely SPATIAL")
acceptance (alpha,beta) 0.39 theta 0.61 phi 0.61 alpha = -0.151 beta = 0.302 95% CI [0.04, 0.55] -> RR per +10% AFF = 1.35 P(beta>0) = 0.988 sd_theta (unstructured) = 0.032 sd_phi (spatial) = 0.728 spatial fraction of area variance = 99.58% -> the heterogeneity is almost entirely SPATIAL
smr = O / E
rr = np.exp(al.mean() + be.mean()*x + out['theta'].mean(0) + out['phi'].mean(0))
print('raw SMR range [%.2f, %.2f] smoothed RR range [%.2f, %.2f]'%(smr.min(),smr.max(),rr.min(),rr.max()))
top = np.argsort(-rr)[:6]
print('\nhighest-risk districts:')
for i in top: print(' %-18s O=%2d E=%5.1f SMR=%.2f -> RR=%.2f'%(names[i],int(O[i]),E[i],smr[i],rr[i]))
fig, ax = plt.subplots(1, 2, figsize=(12, 4.6))
# shrinkage: raw SMR -> smoothed RR, point size ∝ expected count (reliability)
ax[0].scatter(smr, rr, s=8+6*E, color='steelblue', alpha=.8, edgecolor='k', lw=.3, zorder=3)
lim=[0, max(smr.max(),rr.max())*1.05]; ax[0].plot(lim,lim,'k:',lw=1,label='SMR = RR')
ax[0].axhline(1,color='grey',lw=.7); ax[0].axvline(1,color='grey',lw=.7)
ax[0].set_xlabel('raw SMR $O_i/E_i$'); ax[0].set_ylabel('smoothed RR $e^{\\eta_i}$')
ax[0].set_title('Spatial smoothing (point size ∝ expected $E_i$)'); ax[0].legend(fontsize=8)
# beta posterior (AFF effect)
ax[1].hist(be, bins=50, density=True, color='seagreen', alpha=.8)
ax[1].axvline(be.mean(),color='k',ls='--',lw=1,label=f'mean {be.mean():.2f}')
ax[1].axvline(0,color='firebrick',ls=':',lw=1,label='no effect')
ax[1].set_xlabel('β (AFF effect, per +10%)'); ax[1].set_yticks([])
ax[1].set_title(f'AFF covariate (P(β>0) = {(be>0).mean():.3f})'); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig('scotland_bym.png', dpi=120, bbox_inches='tight'); plt.show()
raw SMR range [0.00, 6.52] smoothed RR range [0.36, 4.51] highest-risk districts: Skye-Lochalsh O= 9 E= 1.4 SMR=6.52 -> RR=4.51 Banff-Buchan O=39 E= 8.7 SMR=4.50 -> RR=4.34 Okney O= 8 E= 2.4 SMR=3.33 -> RR=3.67 Caithness O=11 E= 3.0 SMR=3.62 -> RR=3.24 Sutherland O= 5 E= 1.8 SMR=2.79 -> RR=3.24 Ross-Cromarty O=15 E= 4.3 SMR=3.52 -> RR=3.15
Mapping the risk surface¶
The scatter above shows the smoothing abstractly; on the actual map of the 56 (pre-1996) Scottish districts it becomes geographic. The boundaries come from the SpatialEpi R package, exported to scotland_geo.csv by scotland_R.ipynb; the district order matches our data exactly, so each polygon takes our model's per-area value directly. Left = the raw SMR (noisy, with extreme values in tiny districts); right = the BYM-smoothed RR (a coherent risk surface — the speckle is replaced by a clear north-vs-central-belt gradient).
import pandas as pd, matplotlib as mpl
from matplotlib.collections import PolyCollection
# district boundaries exported from SpatialEpi by scotland_R.ipynb (same row order as our data)
geo = pd.read_csv('scotland_geo.csv')
polys, pareas = [], []
for (a, p), g in geo.groupby(['area', 'part']):
polys.append(g[['x', 'y']].values); pareas.append(int(a))
pareas = np.array(pareas)
smr = O / E
rr = np.exp(al.mean() + be.mean()*x + out['theta'].mean(0) + out['phi'].mean(0))
brks = [0, 0.5, 1, 1.5, 2, 3, 8]
cmap = mpl.colors.ListedColormap(['#ffffcc','#ffeda0','#fd8d3c','#f03b20','#bd0026','#800026'])
norm = mpl.colors.BoundaryNorm(brks, cmap.N)
fig, ax = plt.subplots(1, 2, figsize=(11, 6.5))
for k, (v, t) in enumerate([(smr, 'Raw SMR (O/E)'), (rr, 'Smoothed RR (BYM)')]):
pc = PolyCollection(polys, array=v[pareas], cmap=cmap, norm=norm, edgecolor='grey', lw=.3)
ax[k].add_collection(pc); ax[k].autoscale(); ax[k].set_aspect('equal'); ax[k].axis('off'); ax[k].set_title(t)
cb = fig.colorbar(pc, ax=ax, shrink=.6, ticks=brks); cb.set_label('rate ratio')
plt.savefig('scotland_maps.png', dpi=120, bbox_inches='tight'); plt.show()
Results & interpretation¶
- Outdoor work raises lip-cancer risk: β ≈ 0.30 per +10% in AFF (RR ≈ 1.35), with P(β>0) ≈ 0.99 — consistent with the sun-exposure hypothesis behind this classic dataset.
- The variation is almost entirely spatial: σ_φ ≈ 0.73 vs σ_θ ≈ 0.03, so ~99% of the area-level heterogeneity is structured — lip-cancer risk clusters geographically (high in the rural north — Skye-Lochalsh, Banff-Buchan, Caithness — low in the urban central belt), not as unstructured noise.
- Smoothing shrinks the unreliable rates: the raw SMR ranges 0–6.5, but the smoothed RR is pulled to 0.36–4.5. Small-$E$ districts (tiny expected counts ⇒ noisy SMR) are pulled hardest toward their neighbours — exactly what the left panel shows (small points move farthest off the diagonal).
Takeaways¶
- The CAR prior is "pooling with a map". Where the hierarchical-Poisson models borrowed strength from a global mean, the ICAR term borrows from adjacent areas — turning noisy small-area rates into a coherent risk surface.
- Two effects, two roles. θ (unstructured) and φ (spatial) compete to explain the area heterogeneity; here the data overwhelmingly assign it to φ. (The θ/φ split is only weakly identified individually — the BYM identifiability issue — but their sum, the area effect, and the variance partition are well estimated.)
- Cross-checks: PyMC (
pm.ICAR) and R (CARBayes::S.CARbym) follow.