Hierarchical Poisson — PyMC cross-check¶

pm.Poisson with a non-centered patient random intercept¶

Companion to hpois_python.ipynb (from-scratch Gibbs) and hpois_R.ipynb (lme4::glmer). Same Poisson GLMM, fit with PyMC/NUTS on the same data (hpois_synth.csv, epil.csv).

Non-centered parameterisation. A direct b_j ~ N(0, σ_b²) creates the funnel geometry that cripples NUTS. We instead sample standard-normal $z_j$ and set $b_j = \sigma_b z_j$ — the standard fix for hierarchical models, giving a smooth posterior NUTS can explore efficiently.

In [1]:
import os
_lib = r"C:\Users\user\anaconda3\envs\pymc-env\Library\bin"
if _lib not in os.environ.get("PATH", ""): os.environ["PATH"] = _lib + os.pathsep + os.environ.get("PATH", "")
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
os.environ.setdefault("PYTENSOR_FLAGS", "cxx=C:/Users/user/anaconda3/envs/pymc-env/Library/bin/g++.exe")
import numpy as np, pandas as pd, matplotlib.pyplot as plt, pymc as pm, arviz as az
import pymc.sampling.mcmc as _mcmc
class _Noop:
    def __init__(self,*a,**k): pass
    def __enter__(self): return self
    def __exit__(self,*a): pass
_mcmc.threadpool_limits = _Noop

def hpois_model(X, y, grp, J):
    with pm.Model() as m:
        beta = pm.Normal('beta', 0., 10., shape=X.shape[1])
        sigma_b = pm.HalfNormal('sigma_b', 1.0)
        z = pm.Normal('z', 0., 1., shape=J)                 # non-centered random intercepts
        b = pm.Deterministic('b', z * sigma_b)
        pm.Poisson('y', mu=pm.math.exp(pm.math.dot(X, beta) + b[grp]), observed=y)
    return m
print('ready')
ready

1. Synthetic validation¶

In [2]:
from hpois_gibbs import simulate_hpois
y, X, g, b_true = simulate_hpois(J=120, ni=6, beta=(0.5, 0.8, -0.4), sigma_b=0.7, seed=1)
with hpois_model(X, y, g, 120):
    idata_s = pm.sample(1000, tune=1000, chains=2, cores=1, target_accept=0.9, random_seed=1, progressbar=False)
print(az.summary(idata_s, var_names=['beta', 'sigma_b'])[['mean','sd','r_hat']].to_string())
print("\n(truth beta 0.5/0.8/-0.4, sigma_b 0.7;  from-scratch 0.525/0.811/-0.417, 0.679;  glmer 0.512/0.807/-0.415, 0.675)")
Initializing NUTS using jitter+adapt_diag...
Sequential sampling (2 chains in 1 job)
NUTS: [beta, sigma_b, z]
Sampling 2 chains for 1_000 tune and 1_000 draw iterations (2_000 + 2_000 draws total) took 2 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
           mean      sd r_hat
beta[0]   0.503   0.068  1.01
beta[1]   0.807  0.0274  1.00
beta[2]  -0.415  0.0242  1.00
sigma_b   0.685   0.057  1.00

(truth beta 0.5/0.8/-0.4, sigma_b 0.7;  from-scratch 0.525/0.811/-0.417, 0.679;  glmer 0.512/0.807/-0.415, 0.675)

1. three engines agree¶

PyMC: β = 0.503 / 0.807 / −0.415, σ_b = 0.685 (r̂ ≈ 1.00) — identical to the from-scratch Gibbs and lme4::glmer. The non-centered NUTS samples cleanly.


2. Epil: the treatment effect on the significance boundary¶

In [3]:
ep = pd.read_csv('epil.csv'); ye = ep['seizures'].values.astype(float)
Xe = np.column_stack([np.ones(len(ep)), np.log(ep['Base']/4), ep['Trt'], np.log(ep['Age']), ep['V4']])
ge = ep['patient'].values - 1
with hpois_model(Xe, ye, ge, 59):
    idata_e = pm.sample(1500, tune=1500, chains=2, cores=1, target_accept=0.95, random_seed=2, progressbar=False)
print(az.summary(idata_e, var_names=['beta', 'sigma_b'])[['mean','sd','r_hat']].to_string())
B = idata_e.posterior['beta'].values.reshape(-1, 5); tr = B[:, 2]
print(f"\nTrt: RR {np.exp(tr.mean()):.2f}  95% CI [{np.exp(np.percentile(tr,2.5)):.2f}, {np.exp(np.percentile(tr,97.5)):.2f}]"
      f"   P(RR<1) = {(tr<0).mean():.3f}")
Initializing NUTS using jitter+adapt_diag...
Sequential sampling (2 chains in 1 job)
NUTS: [beta, sigma_b, z]
C:\Users\user\anaconda3\envs\pymc-env\Lib\site-packages\pymc\step_methods\hmc\quadpotential.py:321: RuntimeWarning: overflow encountered in dot
  return 0.5 * np.dot(x, v_out)
Sampling 2 chains for 1_500 tune and 1_500 draw iterations (3_000 + 3_000 draws total) took 29 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
           mean     sd r_hat
beta[0]    -0.9   1.27  1.00
beta[1]   1.025  0.105  1.00
beta[2]   -0.32   0.16  1.00
beta[3]    0.29   0.36  1.00
beta[4]  -0.163  0.052  1.00
sigma_b   0.553  0.067  1.00

Trt: RR 0.73  95% CI [0.52, 0.99]   P(RR<1) = 0.978
In [4]:
# The hierarchical treatment effect across the three engines -- it straddles RR = 1
engines = [('from-scratch Gibbs', -0.297, 0.180),
           ('PyMC (NUTS)',        tr.mean(), tr.std()),
           ('lme4::glmer',        -0.315, 0.150)]
fig, ax = plt.subplots(1, 2, figsize=(12, 4.2))
for i, (lab, est, se) in enumerate(engines):
    incl = (est-1.96*se) < 0 < (est+1.96*se)
    ax[0].errorbar(np.exp(est), i, xerr=[[np.exp(est)-np.exp(est-1.96*se)], [np.exp(est+1.96*se)-np.exp(est)]],
                   fmt='o', capsize=4, color='firebrick' if incl else 'steelblue')
ax[0].axvline(1.0, color='gray', ls='--'); ax[0].set_yticks(range(3)); ax[0].set_yticklabels([e[0] for e in engines])
ax[0].invert_yaxis(); ax[0].set_xlabel('treatment rate ratio (95% interval)')
ax[0].set_title('Hierarchical Trt effect straddles RR=1 (borderline)')
# data vs fitted, by arm (PyMC hierarchical)
bpost = B.mean(0); bj = idata_e.posterior['b'].values.reshape(-1, 59).mean(0)
ep['fit'] = np.exp(Xe @ bpost + bj[ge])
obs = ep.groupby(['visit','Trt'])['seizures'].mean().unstack(1); fitm = ep.groupby(['visit','Trt'])['fit'].mean().unstack(1)
sem = ep.groupby(['visit','Trt'])['seizures'].sem().unstack(1)
v = obs.index.values; cols = {0:'steelblue', 1:'firebrick'}; lab = {0:'placebo', 1:'progabide'}
for t in (0, 1):
    ax[1].errorbar(v, obs[t], yerr=sem[t], fmt='o', color=cols[t], capsize=3, label=f'{lab[t]} obs')
    ax[1].plot(v, fitm[t], '--', color=cols[t], lw=2, label=f'{lab[t]} fitted')
ax[1].set_xlabel('visit'); ax[1].set_ylabel('mean seizures / 2 weeks'); ax[1].set_xticks(v)
ax[1].set_title('Data vs fitted (PyMC hierarchical), by arm'); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.savefig('hpois_pymc.png', dpi=120, bbox_inches='tight'); plt.show()
No description has been provided for this image

2. Results: three engines, one borderline verdict¶

PyMC hierarchical fixed effects (matching from-scratch / glmer): log(Base/4) ≈ 1.03, Trt ≈ −0.32, log(Age) ≈ 0.29, V4 ≈ −0.16, σ_b ≈ 0.55.

The hierarchical treatment effect, across all three engines, lands right on the RR = 1 boundary:

engine RR 95% interval verdict
from-scratch Gibbs (Bayes) 0.74 [0.53, 1.07] just includes 1
PyMC NUTS (Bayes) 0.73 [0.52, 0.99] just excludes 1
lme4::glmer (freq., p=0.036) 0.73 [0.54, 0.98] just excludes 1

All three agree on the point estimate (RR ≈ 0.73) and the inflated SE (~0.15–0.18 vs the single-level NegBin's 0.10) — but they fall on both sides of the 5% line. That is the honest conclusion: once within-patient correlation is modelled, the progabide effect is borderline / fragile, not the clear effect the single-level analysis reported. PyMC's P(RR<1) ≈ 0.98 says the same thing probabilistically — it just clears the conventional one-sided 0.975 threshold, consistent with the 95% interval that just excludes 1.

Conclusion¶

PyMC (non-centered NUTS), the from-scratch Gibbs, and lme4::glmer give the same hierarchical Poisson fit (β, σ_b, the data-vs-fit by arm). The hierarchical model's headline — correlation widens the treatment uncertainty to the significance boundary — is reproduced by all three; the fact that they split on the verdict is the lesson that the effect is genuinely marginal.

Probability of benefit (PyMC posterior)¶

The clinically meaningful summary: the overlap of the placebo vs progabide rate posteriors, and P(rate ratio < 1) — the probability progabide lowers seizures.

In [5]:
from scipy.stats import gaussian_kde
mlb = np.log(ep['Base']/4).mean(); mla = np.log(ep['Age']).mean()
p0 = np.exp(B[:,0] + B[:,1]*mlb + B[:,3]*mla); p1 = p0 * np.exp(B[:,2]); rr = np.exp(B[:,2])
P_red = (B[:,2] < 0).mean(); P_20 = (rr < 0.8).mean()
fig, ax = plt.subplots(1, 2, figsize=(12, 4.2))
xs = np.linspace(min(p0.min(),p1.min()), max(p0.max(),p1.max()), 300)
ax[0].fill_between(xs, gaussian_kde(p0)(xs), alpha=.45, color='steelblue', label='placebo')
ax[0].fill_between(xs, gaussian_kde(p1)(xs), alpha=.45, color='firebrick', label='progabide')
ax[0].set_xlabel('adjusted mean seizures / 2 weeks'); ax[0].set_yticks([])
ax[0].set_title('Posterior rate: placebo vs progabide (overlap)'); ax[0].legend()
xr = np.linspace(rr.min(), rr.max(), 300); dr = gaussian_kde(rr)(xr)
ax[1].plot(xr, dr, 'k', lw=1.5); ax[1].fill_between(xr, dr, where=xr<1, color='seagreen', alpha=.5, label=f'RR<1: P = {P_red:.2f}')
ax[1].axvline(1.0, color='gray', ls='--'); ax[1].set_xlabel('treatment rate ratio  exp(Trt)'); ax[1].set_yticks([])
ax[1].set_title('P(progabide lowers seizures)'); ax[1].legend()
plt.tight_layout(); plt.savefig('hpois_pymc_prob.png', dpi=120, bbox_inches='tight'); plt.show()
print(f'P(RR<1) = {P_red:.3f}   P(RR<0.8) = {P_20:.3f}   (from-scratch: 0.943 / 0.647)')
No description has been provided for this image
P(RR<1) = 0.978   P(RR<0.8) = 0.715   (from-scratch: 0.943 / 0.647)

PyMC gives P(RR<1) ≈ 0.98 (vs the from-scratch 0.94) — both say progabide is very likely beneficial even though the 95% interval grazes 1. The small method gap (0.94 vs 0.98) is the same boundary sensitivity that flipped the significance verdict; the probability-of-benefit framing makes the agreement obvious where the binary verdict made them look contradictory.