Every method so far assumed SUTVA
The potential-outcomes notation Y(1), Y(0) quietly assumes that my outcome depends only on my treatment (Lesson 4.1). In many policy settings that is false:
- Public health: my vaccination protects my neighbours.
- Agriculture: farmers copy successful neighbours, and runoff crosses field boundaries.
- Education: tutoring some pupils changes the classroom for everyone.
- Labour markets: training some job seekers can push others down the hiring queue.
With interference, "the" treatment effect is no longer one number. There is a direct effect (of my own treatment) and a spillover effect (of others' treatment on me), and a naive comparison of treated and control units mixes them.
!pip install -q networkx numpy pandas statsmodels matplotlib
import numpy as np, pandas as pd, networkx as nx
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
rng = np.random.default_rng(101)
A fertiliser subsidy in a farmer network
500 farmers are connected in a small-world network (dense local clusters with a few long links), a common model of village social ties. 30% of farmers are randomly given a subsidised high-yield fertiliser. Direct effect: +50 units of yield. Spillover: +30 for a farmer whose neighbours are all treated, proportionally less otherwise.
N = 500
G = nx.watts_strogatz_graph(n=N, k=6, p=0.1, seed=1)
treatment = rng.binomial(1, 0.3, N)
A = nx.to_numpy_array(G) # adjacency matrix
degree = A.sum(axis=1)
peer_exposure = (A @ treatment) / degree # share of neighbours treated
yields = rng.normal(100, 10, N) + 50 * treatment + 30 * peer_exposure + rng.normal(0, 5, N)
df = pd.DataFrame({"farmer": range(N), "treatment": treatment, "peer_exposure": peer_exposure,
"crop_yield": yields, "degree": degree})
pos = nx.spring_layout(G, seed=3)
plt.figure(figsize=(7, 6))
nx.draw_networkx(G.subgraph(range(80)), {i: pos[i] for i in range(80)}, with_labels=False, node_size=60,
node_color=["#ef4444" if t else "#94a3b8" for t in treatment[:80]])
plt.title("Part of the farmer network (red = subsidised)"); plt.axis("off"); plt.show()
Exposure mappings
An exposure mapping (Aronow and Samii) summarises how the whole treatment vector affects one unit. The choice is a modelling assumption, like a DAG. Common choices:
| Mapping | Definition | Assumes |
|---|---|---|
| Any treated neighbour | 1 if ≥ 1 neighbour treated | one treated contact is enough |
| Share of neighbours treated | (A·D)ᵢ / degreeᵢ | effect scales with the fraction |
| Number of treated neighbours | (A·D)ᵢ | effect scales with the count |
We combine own treatment with the neighbour share into four exposure conditions:
df["exposed"] = (df.peer_exposure > 0).astype(int)
df["condition"] = np.select(
[(df.treatment == 1) & (df.exposed == 1), (df.treatment == 1) & (df.exposed == 0),
(df.treatment == 0) & (df.exposed == 1)],
["treated & exposed", "treated only", "spillover only"], default="pure control")
print(df.groupby("condition").agg(n=("farmer", "size"), mean_yield=("crop_yield", "mean")).round(1))
Direct and spillover effects
naive = smf.ols("crop_yield ~ treatment", data=df).fit(cov_type="HC1")
network = smf.ols("crop_yield ~ treatment + peer_exposure", data=df).fit(cov_type="HC1")
print(f"Naive treated-vs-control difference: {naive.params['treatment']:.1f}")
print(f"Direct effect (network-aware): {network.params['treatment']:.1f} (truth 50)")
print(f"Spillover, all neighbours treated: {network.params['peer_exposure']:.1f} (truth 30)")
total_policy = 50 * 0.3 + 30 * df.peer_exposure.mean()
print(f"Average gain per farmer from the programme (direct + spillover): {total_policy:.1f}")
Here random assignment keeps the direct effect roughly unbiased, but the naive comparison still misses the spillovers entirely, so it understates the programme's total value: control farmers gained too. In other designs (for example, when treated units cluster together, or when spillovers are negative, as with displacement in labour markets), the naive estimate can be biased in either direction.
Policy decisions need the total effect of a roll-out at a given coverage, which includes spillovers. A pilot that treats a few scattered people and compares them with their neighbours estimates something quite different from what a national programme would achieve.
Exercise
Make the spillover negative (−30: neighbours compete for the same scarce buyers) and repeat. Then increase coverage from 30% to 70%. How do the naive estimate and the total programme value change?
Show a solution
for cover in (0.3, 0.7):
d = rng.binomial(1, cover, N)
expo = (A @ d) / degree
y = rng.normal(100, 10, N) + 50 * d - 30 * expo + rng.normal(0, 5, N)
sim = pd.DataFrame({"y": y, "d": d, "e": expo})
naive_c = smf.ols("y ~ d", data=sim).fit().params["d"]
print(f"coverage {cover:.0%}: naive {naive_c:.1f} | per-farmer programme value {50 * d.mean() - 30 * expo.mean():.1f}")
# The naive estimate stays near 50, but with negative spillovers the per-farmer value is far below
# what the direct effect implies (50 x coverage = 15 and 35): competition erodes most of the gain.
Checklist and recap
- I can name situations where SUTVA fails.
- I can define an exposure mapping and build exposure conditions from a network.
- I can estimate direct and spillover effects and explain why total programme value needs both.