Partial interference
Full network data are rare. A practical compromise is partial interference (Hudgens and Halloran): units interfere within a cluster (a village, a school) but not across clusters. Then randomisation can be done in two stages:
- Randomise clusters to a saturation (share of members treated): 0% (pure control), 30%, 70%…
- Within each cluster, randomise individuals to treatment at that rate.
Pure-control clusters give the outcome with no treatment anywhere nearby, which is exactly the benchmark a naive within-village comparison lacks. Baird, Bohren, McIntosh and Özler (2018) formalise these "randomised saturation" designs; Miguel and Kremer's deworming study is the famous example of large spillovers in schools.
!pip install -q numpy pandas statsmodels matplotlib
import numpy as np, pandas as pd
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
rng = np.random.default_rng(13)
A two-stage randomised vaccination design
120 villages of 50 people. Villages are randomised to 0%, 30% or 70% vaccination coverage. Vaccination cuts an individual's infection risk directly; on top of that, higher coverage in the village protects everyone (herd effect), vaccinated or not.
V, M = 120, 50
saturation = rng.permutation(np.repeat([0.0, 0.3, 0.7], V // 3))
rows = []
for v in range(V):
s = saturation[v]
d = rng.permutation(np.r_[np.ones(int(s * M)), np.zeros(M - int(s * M))])
village_risk = rng.normal(0, 0.03)
for i in range(M):
p = 0.30 + village_risk - 0.15 * d[i] - 0.20 * s # direct -0.15; herd effect -0.20 x coverage
rows.append((v, s, int(d[i]), rng.binomial(1, np.clip(p, 0.01, 0.99))))
df = pd.DataFrame(rows, columns=["village", "saturation", "vaccinated", "infected"])
print(df.groupby(["saturation", "vaccinated"]).infected.agg(["mean", "size"]).round(3))
Direct, spillover and total effects
With the pure-control villages as the benchmark:
- Spillover on the untreated (at saturation s) = infection of unvaccinated people in s-villages − infection in pure-control villages.
- Direct effect (at s) = vaccinated − unvaccinated within s-villages.
- Total effect on the treated (at s) = vaccinated in s-villages − pure control.
def fit(formula, data):
return smf.ols(formula, data=data).fit(cov_type="cluster", cov_kwds={"groups": data["village"]})
df["sat30"] = (df.saturation == 0.3).astype(int)
df["sat70"] = (df.saturation == 0.7).astype(int)
m = fit("infected ~ vaccinated + sat30 + sat70 + vaccinated:sat70", df)
print(m.params.round(3))
pure = df[df.saturation == 0].infected.mean()
for s in (0.3, 0.7):
sub = df[df.saturation == s]
spill = sub[sub.vaccinated == 0].infected.mean() - pure
direct = sub[sub.vaccinated == 1].infected.mean() - sub[sub.vaccinated == 0].infected.mean()
total = sub[sub.vaccinated == 1].infected.mean() - pure
print(f"saturation {s:.0%}: spillover on unvaccinated {spill:+.3f} | direct {direct:+.3f} | total on vaccinated {total:+.3f}")
The within-village comparison (the "direct" column) misses the protection the unvaccinated received, which at 70% coverage is more than half the size of the direct effect. A study that randomised individuals within villages and compared them would substantially understate what the vaccine achieves at scale.
Design choices for your pilot
- Individual randomisation gives precise direct effects but is contaminated by within-cluster spillovers.
- Cluster randomisation (all or nothing per village) estimates the total effect at 100% coverage, at the cost of power (Lesson 4.3) and with nothing to say about other coverage levels.
- Randomised saturation estimates direct, spillover and total effects at several coverage levels, which is what a planner choosing a coverage target needs. It requires enough clusters per arm.
- Always cluster standard errors at the level of the first-stage randomisation (the village).
Simulate the same study with only 30 villages (10 per arm). Repeat it 200 times and record how often the spillover at 70% coverage is significant at 5%. What does that say about the number of clusters such a design needs?
Show a solution
def one_study(V=30, M=50):
sat = rng.permutation(np.repeat([0.0, 0.3, 0.7], V // 3)); r = []
for v in range(V):
d = rng.permutation(np.r_[np.ones(int(sat[v] * M)), np.zeros(M - int(sat[v] * M))]); vr = rng.normal(0, 0.03)
p = np.clip(0.30 + vr - 0.15 * d - 0.20 * sat[v], 0.01, 0.99)
r += [(v, sat[v], d[i], rng.binomial(1, p[i])) for i in range(M)]
s = pd.DataFrame(r, columns=["village", "saturation", "vaccinated", "infected"])
s = s[(s.vaccinated == 0) & s.saturation.isin([0.0, 0.7])].assign(high=lambda x: (x.saturation == 0.7).astype(int))
return fit("infected ~ high", s).pvalues["high"] < 0.05
print(f"Power to detect the 70% spillover with 30 villages: {np.mean([one_study() for _ in range(200)]):.2f}")
Checklist and recap
- I can state partial interference and design a two-stage randomisation.
- I can estimate direct, spillover and total effects using pure-control clusters.
- I can argue which design a pilot needs, given the policy question.