Pumps — PyMC cross-check (gamma–Poisson hierarchical)¶
Companion to pumps_python.ipynb (from-scratch conjugate Gibbs). Same model, different engine: PyMC samples $(\theta,\alpha,\beta)$ jointly with NUTS. Note that PyMC does not exploit the gamma–Poisson conjugacy — it just HMC's the whole joint posterior — yet it must land on the same place. A good check that the closed-form Gibbs is correct.
Model¶
$$x_i \sim \text{Poisson}(\theta_i t_i),\quad \theta_i\sim\text{Gamma}(\alpha,\beta),\quad \alpha\sim\text{Exp}(1),\ \beta\sim\text{Gamma}(0.1,1.0).$$ Written directly in PyMC — the rates $\theta_i$ are explicit latent variables (no reparameterisation needed; the gamma random effect is already well-behaved for NUTS here).
In [1]:
import numpy as np, pymc as pm, arviz as az, matplotlib.pyplot as plt
from pumps_gibbs import pumps_data, pumps_gibbs
x, t = pumps_data(); n = len(x)
with pm.Model() as m:
alpha = pm.Exponential('alpha', 1.0)
beta = pm.Gamma('beta', alpha=0.1, beta=1.0)
theta = pm.Gamma('theta', alpha=alpha, beta=beta, shape=n)
pm.Poisson('x', mu=theta * t, observed=x)
idata = pm.sample(2000, tune=2000, chains=4, target_accept=0.95,
random_seed=1, progressbar=False)
post = idata.posterior
al = post['alpha'].values.ravel(); be = post['beta'].values.ravel()
th = post['theta'].values.reshape(-1, n)
rh = az.rhat(idata)
print(f"alpha = {al.mean():.3f} 95% CI [{np.percentile(al,2.5):.2f}, {np.percentile(al,97.5):.2f}] r_hat {float(rh['alpha']):.3f}")
print(f"beta = {be.mean():.3f} 95% CI [{np.percentile(be,2.5):.2f}, {np.percentile(be,97.5):.2f}] r_hat {float(rh['beta']):.3f}")
print(f"population mean rate alpha/beta = {(al/be).mean():.3f} (from-scratch: alpha~0.70, beta~0.93)")
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [alpha, beta, theta]
Sampling 4 chains for 2_000 tune and 2_000 draw iterations (8_000 + 8_000 draws total) took 6 seconds.
alpha = 0.699 95% CI [0.28, 1.34] r_hat 1.001 beta = 0.926 95% CI [0.18, 2.24] r_hat 1.001 population mean rate alpha/beta = 0.946 (from-scratch: alpha~0.70, beta~0.93)
In [2]:
# overlay PyMC posterior rates vs the from-scratch Gibbs (should coincide)
gib = pumps_gibbs(x, t, R=21000, burn=1000, seed=1); thg = gib['theta'].mean(0)
mle = x / t; pmean = th.mean(0)
print(f"{'pump':>4}{'x':>4}{'t':>8}{'MLE':>9}{'PyMC θ':>10}{'Gibbs θ':>10}")
for i in range(n):
print(f"{i+1:>4}{int(x[i]):>4}{t[i]:>8.2f}{mle[i]:>9.3f}{pmean[i]:>10.3f}{thg[i]:>10.3f}")
fig, ax = plt.subplots(1, 2, figsize=(12, 4.4))
pop = (al/be).mean(); sizes = 30 + 8*t
ax[0].scatter(mle, pmean, s=sizes, color='darkorange', zorder=3, edgecolor='k', lw=.4)
lo, hi = 0, mle.max()*1.05; ax[0].plot([lo,hi],[lo,hi],'k:',lw=1,label='no shrinkage (y=x)')
ax[0].axhline(pop, color='firebrick', ls='--', lw=1, label=f'population α/β = {pop:.2f}')
for i in range(n): ax[0].annotate(str(i+1),(mle[i],pmean[i]),fontsize=7,xytext=(3,3),textcoords='offset points')
ax[0].set_xlabel('MLE rate $x_i/t_i$'); ax[0].set_ylabel('PyMC posterior $\\hat\\theta_i$')
ax[0].set_title('PyMC shrinkage (size ∝ exposure)'); ax[0].legend(fontsize=8)
ax[1].plot([0,2.1],[0,2.1],'k:',lw=1); ax[1].scatter(thg, pmean, color='purple', zorder=3)
ax[1].set_xlabel('from-scratch Gibbs $\\hat\\theta_i$'); ax[1].set_ylabel('PyMC $\\hat\\theta_i$')
ax[1].set_title('Engine agreement (on y=x)')
plt.tight_layout(); plt.savefig('pumps_pymc.png', dpi=120, bbox_inches='tight'); plt.show()
pump x t MLE PyMC θ Gibbs θ 1 5 94.30 0.053 0.060 0.059 2 1 15.70 0.064 0.103 0.102 3 5 62.90 0.079 0.089 0.089 4 14 126.00 0.111 0.115 0.116 5 3 5.24 0.573 0.606 0.600 6 19 31.40 0.605 0.612 0.610 7 1 1.05 0.952 0.903 0.888 8 1 1.05 0.952 0.896 0.891 9 4 2.10 1.905 1.597 1.577 10 22 10.50 2.095 1.988 1.987
Results¶
- α ≈ 0.70, β ≈ 0.93; population mean rate (posterior mean of α/β) ≈ 0.95 — PyMC (NUTS, no conjugacy used) lands exactly where the closed-form Gibbs does, with r̂ ≈ 1.00. (The ratio of the point estimates, 0.70/0.93, is ≈ 0.75.)
- The per-pump rates match the from-scratch sampler to the third decimal (right panel sits on y=x), confirming the Gamma$(\alpha+x_i,\beta+t_i)$ conjugate draw is correct.
- Engine takeaway: the conjugacy that made the Gibbs sampler clean is a property of the model, not the algorithm — a general HMC sampler reaches the same posterior without it. The Gibbs version is simply cheaper and exact per-block. (Contrast Epil, where PyMC needed the non-centered trick to tame the funnel; here the explicit gamma rates are benign.)