Accelerated Foundation in Applied Causal InferenceFor Economists · 15 modules · 45 lessons

13.2 Cluster Randomisation, Partial Interference and Saturation Designs

about 2 hoursAgriculture: fertiliser adoption spillovers; weather-insurance networks
Lesson 13.2 · Module 13
Cluster Randomisation, Partial Interference and Saturation Designs
Video coming soon. The written lesson and the Colab notebook are ready below.
Colab notebook (.ipynb)
By the end of this lesson you can
  • ✓Explain partial interference: spillovers within clusters but not between them
  • ✓Analyse a randomised saturation design with pure-control clusters
  • ✓Estimate the direct effect, the spillover on the untreated, and the total effect at each saturation
  • ✓Choose between individual and cluster randomisation for a pilot
Lesson plan (120 minutes)
Written lesson

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:

  1. Randomise clusters to a saturation (share of members treated): 0% (pure control), 30%, 70%…
  2. 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.

Python · Colab cell
!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.

Python · Colab cell
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.
Python · Colab cell
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

Key takeaway
  • 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).
Exercise

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
Python · Colab cell
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.