Surgical — hierarchical binomial for institutional comparison¶

Shrinkage and ranking of hospital mortality; reuses the Seeds binomial-GLMM engine¶

Module: seeds_gibbs.py (same engine as the Seeds project — see it for the algorithm; this is an intercept-only binomial GLMM, so no new code). BUGS Vol I "Surgical": deaths in 12 hospitals performing cardiac surgery on infants. The goal is the classic "hospital league table" problem — which hospitals have unusually high mortality? — done right: shrinking unreliable small-hospital rates and showing the uncertainty in any ranking.

The data — infant cardiac-surgery mortality (BUGS Surgical)¶

12 hospitals; each reports deaths $r_i$ out of $n_i$ operations.

field meaning
r deaths
n operations (volume: 47 to 810 — very uneven)

Raw mortality ranges 0%–14%, but the volumes differ enormously, so the raw rates are not comparable: hospital 1's 0/47 = 0% is based on tiny numbers, while hospital 4's 5.7% rests on 810 operations. The hierarchical model pools information across hospitals to give each a stabilised rate.

Model¶

$$r_i\sim\text{Binomial}(n_i,p_i),\qquad \text{logit}\,p_i=\mu+b_i,\qquad b_i\sim N(0,\sigma^2).$$ A pure intercept-only binomial GLMM: $\mu$ is the population log-odds, $b_i$ the hospital deviation. This is exactly seeds_gibbs with the design matrix $X$ = a column of ones (one random effect per hospital = per row). The posterior hospital rate $p_i$ is a partial-pooling estimate — a precision-weighted blend of the hospital's own $r_i/n_i$ and the population rate, shrinking small-volume hospitals most.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from scipy.special import expit
from seeds_gibbs import seeds_gibbs

d = pd.read_csv('surgical.csv'); r = d['r'].to_numpy(float); n = d['n'].to_numpy(float)
X = np.ones((len(d), 1))                                       # intercept-only -> random-intercept model
o = seeds_gibbs(r, n, X, R=30000, burn=8000, seed=1)
mu = o['beta'][:, 0]; P = expit(mu[:, None] + o['bdraws'])    # per-hospital posterior rate draws
raw = r / n; shrunk = P.mean(0); pop = expit(mu).mean()
print('overall mortality %.1f%%   population rate (model) %.1f%%   sigma %.3f   (acc %.2f/%.2f)'
      % (100*r.sum()/n.sum(), 100*pop, o['sigma'].mean(), o['accept_beta'], o['accept_b']))
print('\n%-5s %4s %6s %8s %16s' % ('hosp','n','raw%','shrunk%','95% CrI'))
for i in range(len(d)):
    print('%-5d %4d %6.1f %8.1f   [%.1f, %.1f]' % (i+1, int(n[i]), 100*raw[i], 100*shrunk[i],
          100*np.percentile(P[:,i],2.5), 100*np.percentile(P[:,i],97.5)))
overall mortality 7.4%   population rate (model) 7.4%   sigma 0.321   (acc 0.45/0.57)

hosp     n   raw%  shrunk%          95% CrI
1       47    0.0      5.8   [2.4, 9.2]
2      148   12.2      9.9   [6.7, 14.9]
3      119    6.7      7.2   [4.3, 10.9]
4      810    5.7      6.1   [4.6, 7.8]
5      211    3.8      5.6   [3.1, 8.2]
6      196    6.6      7.1   [4.6, 9.9]
7      148    6.1      6.9   [4.1, 9.9]
8      215   14.4     11.6   [7.3, 16.5]
9      207    6.8      7.1   [4.6, 10.1]
10      97    8.2      7.9   [4.8, 11.9]
11     256   11.3      9.8   [6.9, 13.8]
12     360    6.7      7.0   [4.9, 9.3]
In [2]:
fig, ax = plt.subplots(1, 2, figsize=(12, 4.8))
# (A) shrinkage: raw rate -> partial-pooled rate, point size proportional to volume n
order = np.argsort(raw); xpos = np.arange(len(d))
for k, i in enumerate(order):
    ax[0].plot([k, k], [100*raw[i], 100*shrunk[i]], color='grey', lw=.8, zorder=1)
ax[0].scatter(xpos, 100*raw[order], s=35, color='darkorange', zorder=2, label='raw $r/n$')
ax[0].scatter(xpos, 100*shrunk[order], s=15+0.12*n[order], color='steelblue', edgecolor='k', lw=.3, zorder=3, label='shrunk (partial pool)')
ax[0].axhline(100*pop, color='firebrick', ls='--', lw=1, label='population rate')
ax[0].set_xticks(xpos); ax[0].set_xticklabels([str(i+1) for i in order], fontsize=7)
ax[0].set_xlabel('hospital (sorted by raw rate)'); ax[0].set_ylabel('mortality %')
ax[0].set_title('Shrinkage toward the population (point size ∝ volume)'); ax[0].legend(fontsize=8)
# (B) ranking with uncertainty: posterior rate + 95% CrI, sorted -> intervals overlap heavily
order2 = np.argsort(shrunk); yy = np.arange(len(d))
ax[1].errorbar(100*shrunk[order2], yy, xerr=[100*(shrunk[order2]-np.percentile(P,2.5,0)[order2]),
               100*(np.percentile(P,97.5,0)[order2]-shrunk[order2])], fmt='o', color='steelblue', capsize=3)
ax[1].axvline(100*pop, color='firebrick', ls='--', lw=1, label='population rate')
ax[1].set_yticks(yy); ax[1].set_yticklabels(['H%d'%(i+1) for i in order2], fontsize=7)
ax[1].set_xlabel('posterior mortality % (95% CrI)'); ax[1].set_title('Ranking is uncertain (intervals overlap)'); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig('surgical.png', dpi=120, bbox_inches='tight'); plt.show()
No description has been provided for this image
In [3]:
# companion graphs: how much each hospital shrinks (vs volume), and P(worse than average)
fig, ax = plt.subplots(1, 2, figsize=(12, 4.4))
ax[0].scatter(n, 100*np.abs(raw - shrunk), s=40, color='purple', edgecolor='k', lw=.3)
for i in range(len(d)): ax[0].annotate(str(i+1), (n[i], 100*abs(raw[i]-shrunk[i])), fontsize=7, xytext=(3,2), textcoords='offset points')
ax[0].set_xlabel('hospital volume $n$'); ax[0].set_ylabel('shrinkage |raw − shrunk| (pct pts)')
ax[0].set_title('Small hospitals shrink most'); ax[0].set_xscale('log')
exc = (P > pop).mean(0)                                        # P(hospital rate > population rate)
o2 = np.argsort(exc); ax[1].barh(np.arange(len(d)), exc[o2], color=['firebrick' if e>0.5 else 'steelblue' for e in exc[o2]])
ax[1].axvline(0.5, color='k', ls=':', lw=1); ax[1].set_yticks(np.arange(len(d))); ax[1].set_yticklabels(['H%d'%(i+1) for i in o2], fontsize=7)
ax[1].set_xlabel('P(mortality > population average)'); ax[1].set_title('Probability each hospital is above average')
plt.tight_layout(); plt.savefig('surgical_extra.png', dpi=120, bbox_inches='tight'); plt.show()
No description has been provided for this image

Results & interpretation¶

  • Partial pooling stabilises the rates. Hospital 1's raw 0% (only 47 ops) is pulled up to ~5.8% — a far more believable estimate than "zero risk"; the high outliers (H8 14.4% → 11.6%, H2 12.2% → 9.9%) are pulled down. The large-volume hospital 4 (810 ops) barely moves — its raw rate is already reliable. Small hospitals shrink most (left companion panel).
  • Ranking is genuinely uncertain. The 95% credible intervals overlap heavily (right panel of the main figure) — the data do not support a confident "league table." Only H8 (and to a lesser extent H2/H11) are probably above the population average (exceedance probabilities), while most hospitals are indistinguishable.
  • Population mortality ≈ 7.4%, between-hospital SD σ ≈ 0.32 (on the logit scale) — real but moderate variation.

Takeaways¶

  • The institutional-comparison problem solved by shrinkage: raw league tables over-trust small units and manufacture spurious extremes; the hierarchical binomial gives stabilised rates and honest uncertainty about who is really worse.
  • Same engine as Seeds — an intercept-only binomial GLMM (X = ones). It's the binomial analogue of the conjugate Pumps Poisson shrinkage, on the logit scale.
  • Cross-checks: PyMC (pm.Binomial) and R (lme4::glmer(... ~ 1 + (1|hospital))) follow.