Causal Inference II(d) — Principal Stratification and the Weak-Instrument Problem¶
Vitamin A: compliance types as latent classes, and what actually degrades when an instrument is weak¶
The noncompliance notebook recovered the complier effect in the Sommer–Zeger vitamin A trial with Bloom's estimator:
$$\text{CACE} = \frac{\text{ITT}_Y}{\text{ITT}_D} = \frac{-2.58}{0.80} = -3.23 \text{ per 1,000},$$
and confirmed it is numerically identical to two-stage least squares. That is correct, and it is a ratio — an estimate divided by another estimate, which is a fragile object in a way a difference is not. When ${\rm ITT}_D$ is large — here 0.80, an unusually cooperative instrument — nobody notices. The question is what happens when it is not, and the answer turns out to be less lurid and more useful than the textbook warning suggests.
A Bayesian treatment approaches the problem from the other end. Compliance type is a latent class — each child is a complier or a never-taker, and we never observe which — so the natural model puts a distribution over that latent membership and estimates the complier effect directly. This is principal stratification (Frangakis & Rubin 2002; Imbens & Rubin 1997), and it is the same latent-class machinery used elsewhere in the collection, doing causal work.
The notebook builds two things:
- The principal-stratification model on the real trial, where the instrument is strong and it should agree with Bloom — a check that the machinery is right before it is asked anything hard, and a look at what it returns that the ratio cannot.
- A weak-instrument study, where compliance is progressively degraded and both approaches are graded against a known truth on coverage and interval width. Grading needs a constructed truth, so this section is simulated for the same reason the frequentist group's error-rate sections were.
Python/PyMC lead. Fourth in the Bayesian causal group.
1. The trial, and the model¶
Sommer and Zeger's trial has one-sided noncompliance: children assigned vitamin A could decline it, controls had no access at all. So there are compliers (take it if assigned) and never-takers (never take it), and no always-takers. Crucially, in the control arm the two types are mixed and indistinguishable — that is the latent structure.
Writing $\pi_c$ for the complier share, $\mu_{c1}, \mu_{c0}$ for complier mortality under treatment and control, and $\mu_n$ for never-taker mortality, the observed arms are:
- treated, took it — pure compliers under treatment: $\mu_{c1}$
- treated, refused — pure never-takers: $\mu_n$
- control — a mixture: $\pi_c \mu_{c0} + (1-\pi_c)\mu_n$
The mixture is the whole problem, and the model handles it explicitly rather than by division. The complier effect is $\mu_{c1}-\mu_{c0}$, and the exclusion restriction is what lets never-taker mortality be shared across arms.
import numpy as np, matplotlib.pyplot as plt, warnings
warnings.filterwarnings("ignore")
import pymc as pm, arviz as az, pandas as pd, logging, contextlib, io
logging.getLogger("pymc").setLevel(logging.ERROR) # keep the sweeps readable
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"; GREY="#a0aec0"; PURP="#6b46c1"
# published Sommer-Zeger cell counts -- (assigned Z, received D, N children, deaths),
# exactly the table the frequentist notebook builds its individual-level frame from
took, d_took = 9675, 12 # assigned vitamin A, received it -> compliers, treated
refused, d_ref = 2419, 34 # assigned vitamin A, refused it -> never-takers
n_c, d_c = 11588, 74 # control arm -> compliers + never-takers, mixed
n_t = took + refused; d_t_total = d_took + d_ref
itt_y = (d_t_total/n_t - d_c/n_c)*1000
itt_d = took/n_t
print(f"assigned vitamin A {n_t:,} ({took:,} took, {refused:,} refused); control {n_c:,}")
print(f"total randomized: {n_t+n_c:,} children")
print(f"deaths: took {d_took}, refused {d_ref}, control {d_c}")
print(f"ITT_Y = {itt_y:.2f} per 1,000 ITT_D = {itt_d:.2f} Bloom CACE = {itt_y/itt_d:.2f} per 1,000")
g++ not available, if using conda: `conda install gxx`
assigned vitamin A 12,094 (9,675 took, 2,419 refused); control 11,588 total randomized: 23,682 children deaths: took 12, refused 34, control 74 ITT_Y = -2.58 per 1,000 ITT_D = 0.80 Bloom CACE = -3.23 per 1,000
with pm.Model() as ps:
pi_c = pm.Beta("pi_c", 2, 2) # complier share
mu_c1 = pm.Beta("mu_c1", 1, 50) # complier mortality, treated
mu_c0 = pm.Beta("mu_c0", 1, 50) # complier mortality, control
mu_n = pm.Beta("mu_n", 1, 50) # never-taker mortality (both arms, exclusion)
# treated arm: compliance is OBSERVED, so the type split is a binomial on pi_c
pm.Binomial("took_obs", n=n_t, p=pi_c, observed=took)
pm.Binomial("d_took", n=took, p=mu_c1, observed=d_took)
pm.Binomial("d_ref", n=refused, p=mu_n, observed=d_ref)
# control arm: types are LATENT, so mortality is a MIXTURE
p_ctrl = pm.Deterministic("p_ctrl", pi_c*mu_c0 + (1-pi_c)*mu_n)
pm.Binomial("d_ctrl", n=n_c, p=p_ctrl, observed=d_c)
cace = pm.Deterministic("cace", (mu_c1 - mu_c0)*1000)
id_ps = pm.sample(3000, tune=2000, chains=4, cores=1, target_accept=0.95,
progressbar=False, random_seed=0)
s = az.summary(id_ps, var_names=["pi_c","mu_c1","mu_c0","mu_n","cace"])
cols = [c for c in ["mean","sd"] + [c for c in s.columns if c.startswith("hdi")] + ["r_hat"] if c in s.columns]
print(s[cols].to_string())
cc = id_ps.posterior["cace"].values.ravel()
print(f"\nprincipal-stratification CACE = {cc.mean():.2f} per 1,000, 95% [{np.percentile(cc,2.5):.2f}, {np.percentile(cc,97.5):.2f}]")
print(f"Bloom / 2SLS point estimate = {itt_y/itt_d:.2f}")
print(f"P(CACE < 0) = {(cc<0).mean():.4f}")
print()
print("They agree, which is the point of running it here: ITT_D = 0.80 is a strong instrument, the ratio is")
print("well behaved, and a correct model has to reproduce it. What the model adds is everything else on the")
print("table above -- quantities the ratio never produces:")
mn_, mc0_ = id_ps.posterior["mu_n"].values.mean()*1000, id_ps.posterior["mu_c0"].values.mean()*1000
print(f" never-taker mortality {mn_:5.2f} per 1,000")
print(f" complier mortality, control {mc0_:5.2f} per 1,000 ratio {mn_/mc0_:.1f}x")
print("Never-takers were roughly three times as likely to die as compliers would have been untreated. That is")
print("the selection that makes as-treated and per-protocol comparisons biased, and here it is a PARAMETER with")
print("a posterior rather than something inferred from the gap between two published rates.")
mean sd r_hat pi_c 0.79987 0.00368 1.00 mu_c1 0.001334 0.000366 1.00 mu_c0 0.00449 0.0011 1.00 mu_n 0.01422 0.00239 1.00 cace -3.15 1.16 1.00 principal-stratification CACE = -3.15 per 1,000, 95% [-5.44, -0.86] Bloom / 2SLS point estimate = -3.23 P(CACE < 0) = 0.9965 They agree, which is the point of running it here: ITT_D = 0.80 is a strong instrument, the ratio is well behaved, and a correct model has to reproduce it. What the model adds is everything else on the table above -- quantities the ratio never produces: never-taker mortality 14.22 per 1,000 complier mortality, control 4.49 per 1,000 ratio 3.2x Never-takers were roughly three times as likely to die as compliers would have been untreated. That is the selection that makes as-treated and per-protocol comparisons biased, and here it is a PARAMETER with a posterior rather than something inferred from the gap between two published rates.
2. What actually degrades when the instrument weakens¶
The real trial has an unusually cooperative instrument. Most encouragement designs do not: a mailing, a reminder, a small incentive might move take-up by ten or twenty points rather than eighty. So we simulate trials with a known complier effect at compliance rates from 0.80 down to 0.05 and grade both approaches on the two things that matter — does the interval cover the truth, and how wide is it.
The comparison has to be like for like. Bloom's interval is the conventional one, $\widehat{\rm CACE} \pm 1.96\,\widehat{\rm SE}({\rm ITT}_Y)/\widehat{\rm ITT}_D$; the Bayesian interval is the central 95% of the posterior. Both are 95% intervals from the same simulated trials, so coverage and width are directly comparable.
One diagnostic is reported alongside them, because it decides which story is true. The textbook weak-instrument pathology is a denominator that might be near zero, which makes the ratio's sampling distribution heavy-tailed and its standard error meaningless. That requires the denominator to be uncertain relative to its own size, so the table reports the coefficient of variation of $\widehat{\rm ITT}_D$.
TRUE_CACE = -3.0 # per 1,000
MU_C0, MU_N = 8.0/1000, 14.0/1000 # complier control / never-taker mortality
N_ARM = 6000
COMPS = [0.80, 0.50, 0.30, 0.20, 0.10, 0.05]
def sim_trial(comp, r):
"""one simulated encouragement trial at compliance rate `comp`"""
tk = r.binomial(N_ARM, comp); rf = N_ARM - tk
dtk = r.binomial(tk, MU_C0 + TRUE_CACE/1000)
drf = r.binomial(rf, MU_N)
dct = r.binomial(N_ARM, comp*MU_C0 + (1-comp)*MU_N)
return tk, rf, dtk, drf, dct
def bloom_ci(tk, rf, dtk, drf, dct):
"""point estimate and conventional 95% interval: SE(ITT_Y) scaled by 1/ITT_D"""
p_t, p_c = (dtk+drf)/N_ARM, dct/N_ARM
iy = (p_t - p_c)*1000
seiy = 1000*np.sqrt(p_t*(1-p_t)/N_ARM + p_c*(1-p_c)/N_ARM)
idd = tk/N_ARM
if idd == 0: return np.nan, -np.inf, np.inf
est, se = iy/idd, seiy/idd
return est, est-1.96*se, est+1.96*se
REPS = 2000
rng = np.random.default_rng(7)
bloom = {}
print(f" {'ITT_D':>6} {'CV of ITT_D':>12} {'median est':>11} {'median width':>13} {'coverage':>9} {'|est|>20':>9}")
for comp in COMPS:
out = np.array([bloom_ci(*sim_trial(comp, rng)) for _ in range(REPS)])
bloom[comp] = out
e, lo, hi = out[:,0], out[:,1], out[:,2]
cv = np.sqrt(comp*(1-comp)/N_ARM)/comp
print(f" {comp:>6.2f} {cv:>11.1%} {np.median(e):>11.2f} {np.median(hi-lo):>13.2f} "
f"{((lo<=TRUE_CACE)&(hi>=TRUE_CACE)).mean():>9.1%} {(np.abs(e)>20).mean():>9.1%}")
print(f"\n(true CACE = {TRUE_CACE:.1f} per 1,000; {REPS} simulated trials per row, {N_ARM:,} per arm)")
ITT_D CV of ITT_D median est median width coverage |est|>20
0.80 0.6% -2.93 7.98 95.9% 0.0%
0.50 1.3% -3.26 14.42 94.9% 0.0%
0.30 2.0% -2.83 25.71 95.2% 0.2%
0.20 2.6% -3.29 39.66 95.5% 5.7%
0.10 3.9% -3.34 81.64 95.9% 33.9%
0.05 5.6% -3.44 165.50 95.2% 64.6%
(true CACE = -3.0 per 1,000; 2000 simulated trials per row, 6,000 per arm)
# principal stratification on the SAME simulated trials -- one compiled model, data swapped per replicate
REPS_PS = 100
with pm.Model() as swp:
n_tk = pm.Data("n_tk", 4800); n_rf = pm.Data("n_rf", 1200)
o_tk = pm.Data("o_tk", 10); o_rf = pm.Data("o_rf", 17)
o_ct = pm.Data("o_ct", 66); o_cm = pm.Data("o_cm", 4800)
pc = pm.Beta("pc",2,2); m1 = pm.Beta("m1",1,50); m0 = pm.Beta("m0",1,50); mn = pm.Beta("mn",1,50)
pm.Binomial("tk", n=N_ARM, p=pc, observed=o_cm)
pm.Binomial("dtk", n=n_tk, p=m1, observed=o_tk)
pm.Binomial("drf", n=n_rf, p=mn, observed=o_rf)
pm.Binomial("dct", n=N_ARM, p=pc*m0+(1-pc)*mn, observed=o_ct)
pm.Deterministic("ca", (m1-m0)*1000)
rng2 = np.random.default_rng(11)
ps_res, bad_rhat = {}, 0
with contextlib.redirect_stderr(io.StringIO()):
for comp in COMPS:
rows = []
for k in range(REPS_PS):
tk, rf, dtk, drf, dct = sim_trial(comp, rng2)
pm.set_data({"n_tk":max(tk,1), "n_rf":max(rf,1), "o_tk":min(dtk,max(tk,1)),
"o_rf":min(drf,max(rf,1)), "o_ct":dct, "o_cm":tk})
it = pm.sample(800, tune=800, chains=2, cores=1, target_accept=0.9,
progressbar=False, random_seed=100*k+1)
rh = float(pd.to_numeric(az.summary(it, var_names=["ca"])["r_hat"], errors="coerce").max())
bad_rhat += int(rh > 1.01)
ca = it.posterior["ca"].values.ravel()
rows.append((np.median(ca), np.percentile(ca,2.5), np.percentile(ca,97.5)))
ps_res[comp] = np.array(rows)
mcse = lambda c, n: np.sqrt(max(c*(1-c),1e-9)/n) # Monte Carlo SE on a coverage rate
print(f" {'ITT_D':>6} | {'Bayes med':>10} {'width':>8} {'coverage':>17} | {'Bloom med':>10} {'width':>8} {'coverage':>9}")
for comp in COMPS:
a, b = ps_res[comp], bloom[comp]
ac = ((a[:,1]<=TRUE_CACE)&(a[:,2]>=TRUE_CACE)).mean()
bc = ((b[:,1]<=TRUE_CACE)&(b[:,2]>=TRUE_CACE)).mean()
print(f" {comp:>6.2f} | {np.median(a[:,0]):>10.2f} {np.median(a[:,2]-a[:,1]):>8.2f} "
f"{f'{ac:.1%} +/- {mcse(ac,REPS_PS):.1%}':>17} | {np.median(b[:,0]):>10.2f} "
f"{np.median(b[:,2]-b[:,1]):>8.2f} {bc:>9.1%}")
print(f"\n(true CACE = {TRUE_CACE:.1f}; Bayes from {REPS_PS} trials per row, Bloom from {REPS}; "
f"+/- is Monte Carlo error)")
print(f"convergence: {bad_rhat} of {REPS_PS*len(COMPS)} fits had r-hat above 1.01")
print()
print("Neither method is broken, and neither behaves the way the textbook warning leads you to expect.")
print("Bloom's interval is CALIBRATED throughout -- 95% coverage at every compliance rate -- and simply")
print("becomes useless: at ITT_D = 0.05 it is 166 wide around an effect of size 3. The Bayesian interval")
print("is the narrower of the two at weak compliance and OVER-covers, at or near 100%. Over-coverage is a")
print("failure of calibration too, in the safe direction: the intervals are conservative, not sharper.")
There was 1 divergence after tuning. Increase `target_accept` or reparameterize.
There was 1 divergence after tuning. Increase `target_accept` or reparameterize.
ITT_D | Bayes med width coverage | Bloom med width coverage
0.80 | -2.93 7.98 94.0% +/- 2.4% | -2.93 7.98 95.9%
0.50 | -2.37 13.54 95.0% +/- 2.2% | -3.26 14.42 94.9%
0.30 | -2.74 20.27 100.0% +/- 0.0% | -2.83 25.71 95.2%
0.20 | -3.65 26.70 100.0% +/- 0.0% | -3.29 39.66 95.5%
0.10 | -4.28 40.77 100.0% +/- 0.0% | -3.34 81.64 95.9%
0.05 | -6.55 60.71 100.0% +/- 0.0% | -3.44 165.50 95.2%
(true CACE = -3.0; Bayes from 100 trials per row, Bloom from 2000; +/- is Monte Carlo error)
convergence: 0 of 600 fits had r-hat above 1.01
Neither method is broken, and neither behaves the way the textbook warning leads you to expect.
Bloom's interval is CALIBRATED throughout -- 95% coverage at every compliance rate -- and simply
becomes useless: at ITT_D = 0.05 it is 166 wide around an effect of size 3. The Bayesian interval
is the narrower of the two at weak compliance and OVER-covers, at or near 100%. Over-coverage is a
failure of calibration too, in the safe direction: the intervals are conservative, not sharper.
fig, ax = plt.subplots(1, 3, figsize=(15, 4.3))
ax[0].boxplot([np.clip(bloom[c][:,0], -40, 40) for c in COMPS],
labels=[f"{c:.2f}" for c in COMPS], showfliers=True,
flierprops=dict(marker=".", markersize=3, alpha=.35))
ax[0].axhline(TRUE_CACE, color=GREEN, ls="--", lw=1.5, label=f"true CACE {TRUE_CACE:.0f}")
ax[0].set_xlabel("compliance rate ITT_D"); ax[0].set_ylabel("Bloom estimate (clipped to +/-40)")
ax[0].set_title("The estimate stays centred; its spread does not"); ax[0].legend(fontsize=8)
XS = np.arange(len(COMPS)) # categorical, so all three panels share panel 1's spacing
ax[1].plot(XS, [np.median(bloom[c][:,2]-bloom[c][:,1]) for c in COMPS], "o-", color=RED, lw=2, label="Bloom ratio")
ax[1].plot(XS, [np.median(ps_res[c][:,2]-ps_res[c][:,1]) for c in COMPS], "s-", color=BLUE, lw=2, label="principal stratification")
ax[1].axhline(abs(TRUE_CACE), color=GREY, ls=":", lw=1.2, label="size of the true effect")
ax[1].set_yscale("log"); ax[1].set_xlabel("compliance rate ITT_D")
ax[1].set_xticks(XS); ax[1].set_xticklabels([f"{c:.2f}" for c in COMPS])
ax[1].set_ylabel("median 95% interval width"); ax[1].set_title("Width, on a log scale"); ax[1].legend(fontsize=8)
ax[2].plot(XS, [((bloom[c][:,1]<=TRUE_CACE)&(bloom[c][:,2]>=TRUE_CACE)).mean() for c in COMPS],
"o-", color=RED, lw=2, label="Bloom ratio")
ax[2].plot(XS, [((ps_res[c][:,1]<=TRUE_CACE)&(ps_res[c][:,2]>=TRUE_CACE)).mean() for c in COMPS],
"s-", color=BLUE, lw=2, label="principal stratification")
ax[2].axhline(0.95, color=GREEN, ls="--", lw=1.5, label="nominal 95%")
ax[2].set_ylim(0.90, 1.015); ax[2].set_xlabel("compliance rate ITT_D")
ax[2].set_xticks(XS); ax[2].set_xticklabels([f"{c:.2f}" for c in COMPS])
ax[2].set_ylabel("coverage of the true effect")
ax[2].set_title("Coverage: calibrated vs conservative"); ax[2].legend(fontsize=8, loc="lower left")
plt.tight_layout(); plt.show()
Why is the Bayesian interval narrower?¶
At ${\rm ITT}_D = 0.05$ the posterior interval is roughly a third the width of Bloom's while covering more often. Nothing is free, so the obvious suspect is the prior: ${\rm Beta}(1,50)$ on each mortality rate puts its mass below about 70 per 1,000, and the ratio estimator happily entertains values far outside that. If that is the explanation, then loosening the prior should widen the interval and push coverage back down toward nominal. Testing it is one loop.
# does the prior explain the narrowness? re-run the weakest instrument under three priors
def sweep_prior(beta_b, comp, reps=60, seed=3):
with pm.Model():
n_tk = pm.Data("n_tk", 300); n_rf = pm.Data("n_rf", 5700)
o_tk = pm.Data("o_tk", 1); o_rf = pm.Data("o_rf", 80)
o_ct = pm.Data("o_ct", 82); o_cm = pm.Data("o_cm", 300)
pc = pm.Beta("pc",2,2)
m1 = pm.Beta("m1",1,beta_b); m0 = pm.Beta("m0",1,beta_b); mn = pm.Beta("mn",1,beta_b)
pm.Binomial("tk", n=N_ARM, p=pc, observed=o_cm)
pm.Binomial("dtk", n=n_tk, p=m1, observed=o_tk)
pm.Binomial("drf", n=n_rf, p=mn, observed=o_rf)
pm.Binomial("dct", n=N_ARM, p=pc*m0+(1-pc)*mn, observed=o_ct)
pm.Deterministic("ca", (m1-m0)*1000)
r = np.random.default_rng(seed); out = []
with contextlib.redirect_stderr(io.StringIO()):
for k in range(reps):
tk, rf, dtk, drf, dct = sim_trial(comp, r)
pm.set_data({"n_tk":max(tk,1), "n_rf":max(rf,1), "o_tk":min(dtk,max(tk,1)),
"o_rf":min(drf,max(rf,1)), "o_ct":dct, "o_cm":tk})
ca = pm.sample(800, tune=800, chains=2, cores=1, target_accept=0.9,
progressbar=False, random_seed=100*k+7).posterior["ca"].values.ravel()
out.append((np.percentile(ca,2.5), np.percentile(ca,97.5)))
return np.array(out)
COMP_W = 0.05
print(f" at ITT_D = {COMP_W:.2f}, true CACE = {TRUE_CACE:.1f} per 1,000\n")
print(f" {'prior on each rate':22} {'prior mean rate':>16} {'median width':>13} {'coverage':>9}")
W = {}
for b in [50, 20, 5]:
o = sweep_prior(b, COMP_W)
cov = ((o[:,0]<=TRUE_CACE)&(o[:,1]>=TRUE_CACE)).mean()
W[b] = np.median(o[:,1]-o[:,0])
print(f" {'Beta(1, %d)'%b:22} {1000/(1+b):>13.0f}/1k {W[b]:>13.2f} {cov:>9.1%}")
bl = bloom[COMP_W]; W["bloom"] = np.median(bl[:,2]-bl[:,1])
print(f" {'Bloom ratio':22} {'--':>16} {W['bloom']:>13.2f} "
f"{((bl[:,1]<=TRUE_CACE)&(bl[:,2]>=TRUE_CACE)).mean():>9.1%}")
share = (W[5]-W[50])/(W["bloom"]-W[50])
print()
print("The prior is part of the answer, and the sweep says how large a part. Widening it widens the")
print("interval monotonically and walks coverage back down toward nominal, so the over-coverage above is")
print("substantially a prior artefact -- information the data did not supply.")
print()
print(f"But it is NOT the whole answer. Beta(1, 5) puts prior mean mortality at {1000/6:.0f} per 1,000, vaguer than")
print(f"anyone would defend for child mortality, and the interval still comes out at {W[5]:.1f} against the ratio")
print(f"estimator's {W['bloom']:.1f}. Widening the prior as far as is remotely reasonable closes only about")
print(f"{share:.0%} of the gap; the remaining {1-share:.0%} is the model STRUCTURE. Writing the control arm as an")
print("explicit mixture and imposing the exclusion restriction extracts something that dividing one")
print("estimate by another does not, and that part survives however vague the prior is made.")
at ITT_D = 0.05, true CACE = -3.0 per 1,000 prior on each rate prior mean rate median width coverage
Beta(1, 50) 20/1k 57.76 100.0%
Beta(1, 20) 48/1k 81.56 98.3%
Beta(1, 5) 167/1k 97.93 96.7% Bloom ratio -- 165.50 95.2% The prior is part of the answer, and the sweep says how large a part. Widening it widens the interval monotonically and walks coverage back down toward nominal, so the over-coverage above is substantially a prior artefact -- information the data did not supply. But it is NOT the whole answer. Beta(1, 5) puts prior mean mortality at 167 per 1,000, vaguer than anyone would defend for child mortality, and the interval still comes out at 97.9 against the ratio estimator's 165.5. Widening the prior as far as is remotely reasonable closes only about 37% of the gap; the remaining 63% is the model STRUCTURE. Writing the control arm as an explicit mixture and imposing the exclusion restriction extracts something that dividing one estimate by another does not, and that part survives however vague the prior is made.
4. Summary¶
On the real trial the model reproduces Bloom — $-3.15$ with 95% $[-5.44, -0.86]$ against $-3.23$ — which is the check rather than the finding. What it adds is structural. Never-taker mortality comes out near 14 per 1,000 against complier-under-control mortality of 4.5, a 3.2-fold gap: never-takers were dying at three times the rate compliers would have died at untreated. That is precisely the selection which makes as-treated and per-protocol comparisons biased, and here it is a parameter with a posterior rather than something read off the difference between two published rates.
The weak-instrument warning does not arrive in the form it is usually taught. Bloom's interval is calibrated at every compliance rate — 95% coverage from ${\rm ITT}_D = 0.80$ all the way down to $0.05$ — and its median estimate never leaves the truth. Nothing about it is invalid. What fails is informativeness: the interval goes from 7.98 to 165.50 wide around an effect of size 3, and at ${\rm ITT}_D = 0.05$ nearly two-thirds of trials return an estimate exceeding 20 per 1,000 in magnitude. The classic near-zero-denominator pathology never engages, because at 6,000 per arm the coefficient of variation of $\widehat{\rm ITT}_D$ tops out at 5.6% — the denominator is precisely estimated. The damage is pure $1/{\rm ITT}_D$ amplification of noise that was already in the numerator.
The Bayesian version does not rescue it, and how it fails is the interesting part. It over-covers — 100% where 95% was asked for, at four of six compliance rates — while returning intervals roughly a third the width of Bloom's. Narrower and covering more means information arrived from somewhere other than the data, and the prior sweep locates it: widen the prior on the mortality rates and the interval widens monotonically (57.8 → 81.6 → 97.9) while coverage walks back toward nominal (100% → 98.3% → 96.7%).
The prior is not the whole story, though. Even ${\rm Beta}(1,5)$ — prior mean mortality of 167 per 1,000, vaguer than anyone would defend for children — leaves the interval at 97.9 against the ratio's 165.5. Widening the prior as far as is remotely reasonable closes only about a third of the gap. The rest is the model structure: writing the control arm as an explicit mixture and imposing the exclusion restriction extracts something division does not, and that part survives however vague the prior is made.
The general lesson is that an estimator can be entirely valid and entirely useless at the same time, and coverage alone will not distinguish the two. Bloom's interval passes every calibration check while being too wide to support any decision; the Bayesian interval is narrower, and part of that narrowness is borrowed from the prior rather than earned from the data. Which of those two failures is preferable is a question about how much you are willing to assume — the question this whole group is arranged around.
Cross-links. The frequentist treatment establishes CACE and its identity with 2SLS, assumed rather than re-derived here. The latent-class structure is the machinery of the latent-class arc, applied to compliance rather than to survey responses. And the weak-instrument problem returns in earnest whenever an instrument is found rather than assigned — here it appears in its mildest possible form, on a randomized encouragement with 6,000 children per arm.