Fuzzy RDD: eligibility is not participation
In a sharp design the probability of treatment jumps from 0 to 1 at the cutoff. Often it jumps by less: crossing a poverty line makes a household eligible for a programme, but it still has to apply, and some ineligible households get in through appeals. This is a fuzzy RDD.
The cutoff is then an instrument for participation (Module 9):
effect = jump in outcome at c / jump in participation at c
which identifies a LATE for compliers at the cutoff: households that participate because they are eligible.
!pip install -q numpy pandas rdrobust rddensity matplotlib causaldata
import numpy as np, pandas as pd
from rdrobust import rdrobust
from rddensity import rddensity
import matplotlib.pyplot as plt
rng = np.random.default_rng(1)
N = 6_000
income = rng.uniform(-1, 1, N) # centred: eligible if income < 0
eligible = (income < 0).astype(int)
p_take = np.where(eligible == 1, 0.70, 0.10) # take-up jumps by 60 points
takeup = rng.binomial(1, p_take)
consumption = 50 - 8 * income + 6 * takeup + rng.normal(0, 4, N)
# Itemise the two jumps, then their ratio; rdrobust's fuzzy= option does it with correct inference
itt = rdrobust(y=consumption, x=-income, c=0) # flip sign so eligibility is on the right
fs = rdrobust(y=takeup, x=-income, c=0)
fz = rdrobust(y=consumption, x=-income, c=0, fuzzy=takeup)
print(f"Jump in consumption (ITT): {itt.coef.iloc[0, 0]:.2f}")
print(f"Jump in take-up (1st stage): {fs.coef.iloc[0, 0]:.2f}")
print(f"Ratio: {itt.coef.iloc[0, 0] / fs.coef.iloc[0, 0]:.2f}")
print(f"Fuzzy RD (rdrobust): {fz.coef.iloc[0, 0]:.2f}, robust CI "
f"[{fz.ci.iloc[2, 0]:.2f}, {fz.ci.iloc[2, 1]:.2f}] (truth 6)")
Manipulation and the density test
If units can control their score precisely, those just above the cutoff are no longer comparable to those
just below. The tell-tale sign is bunching: too many observations just on the favourable side. The
McCrary test, refined by Cattaneo, Jansson and Ma (rddensity), tests whether the density of the
running variable is continuous at the cutoff.
def simulate_scores(manipulate, n=10_000):
s = rng.normal(50, 12, n)
if manipulate: # 30% of firms scoring 45-50 nudge themselves just above 50
movers = (s > 45) & (s < 50) & (rng.uniform(size=n) < 0.3)
s[movers] = 50 + rng.uniform(0, 1.5, movers.sum())
return s
fig, axes = plt.subplots(1, 2, figsize=(13, 4), sharey=True)
for ax, manip in zip(axes, [False, True]):
s = simulate_scores(manip)
test = rddensity(X=s, c=50)
ax.hist(s, bins=np.arange(20, 81, 1), color="#94a3b8")
ax.axvline(50, color="red", ls="--")
ax.set(title=f"{'Manipulated' if manip else 'No manipulation'}: density test p = {test.test['p_jk']:.4f}")
plt.tight_layout(); plt.show()
Real data: Uruguay's PANES transfer programme
Manacorda, Miguel and Vigorito (2011) studied Uruguay's PANES programme, whose eligibility was decided by
a predicted-income score with a cutoff. gov_transfers has households near the cutoff, with Support for
the government as the outcome. gov_transfers_density has the full score distribution.
from causaldata import gov_transfers, gov_transfers_density
g = gov_transfers.load_pandas().data.dropna()
r = rdrobust(y=g.Support, x=-g.Income_Centered, c=0) # eligibility (income below 0) on the right
print(f"Jump in government support at eligibility: {r.coef.iloc[0, 0]:.3f}, "
f"robust CI [{r.ci.iloc[2, 0]:.3f}, {r.ci.iloc[2, 1]:.3f}]")
dens = gov_transfers_density.load_pandas().data.Income_Centered.to_numpy()
test = rddensity(X=dens, c=0)
print(f"Density test on {len(dens):,} scores: p = {test.test['p_jk']:.2g}")
plt.hist(dens[np.abs(dens) < 0.05], bins=100, color="#94a3b8"); plt.axvline(0, color="red", ls="--")
plt.title("PANES income score near the cutoff"); plt.show()
The support estimate is imprecise in this subsample. The density test rejects continuity with 52,000
observations. Before concluding that households manipulated their scores, check the alternatives: the
score is computed from a formula households did not know, the running variable has mass points
(repeated values, which rddensity warns about), and with very large samples tiny, economically irrelevant
departures become "significant". Look at the histogram, compare the size of the discontinuity with the
estimate, and report the test with that context rather than as a verdict.
Regression kink designs
Some policies change the slope, not the level, at a threshold: unemployment benefits rise with past earnings up to a cap, then stay flat. In a regression kink design (RKD) the effect of the benefit on outcomes is the change in the outcome's slope divided by the change in the policy's slope at the kink.
n_k = 40_000
earnings = rng.uniform(20, 80, n_k) # past earnings, $000
benefit = np.where(earnings < 50, 0.5 * earnings, 25.0) # replacement rate 50% up to a $50k cap
duration = 10 + 0.4 * benefit + 0.05 * earnings + rng.normal(0, 2, n_k) # weeks unemployed
slope_y = rdrobust(y=duration, x=earnings, c=50, deriv=1) # change in slope of the outcome
slope_b = -0.5 # known change in the benefit slope at the kink
print(f"Kink in duration slope: {slope_y.coef.iloc[0, 0]:.3f} | implied effect of $1k more benefit: "
f"{slope_y.coef.iloc[0, 0] / slope_b:.2f} weeks (truth 0.40)")
In the fuzzy simulation, shrink the take-up jump from 60 to 15 percentage points (for example, 0.25 vs 0.10). What happens to the fuzzy RD confidence interval? Connect your answer to weak instruments in Module 9.
Show a solution
take_weak = rng.binomial(1, np.where(eligible == 1, 0.25, 0.10))
cons_weak = 50 - 8 * income + 6 * take_weak + rng.normal(0, 4, N)
fz_weak = rdrobust(y=cons_weak, x=-income, c=0, fuzzy=take_weak)
print(f"Weak first stage: {fz_weak.coef.iloc[0, 0]:.2f}, CI [{fz_weak.ci.iloc[2, 0]:.2f}, {fz_weak.ci.iloc[2, 1]:.2f}]")
# The interval widens dramatically: a fuzzy RD with a small jump in take-up is a weak-instrument problem.
Checklist and recap
- I can estimate a fuzzy RDD as a ratio of jumps and with
rdrobust(..., fuzzy=). - I run and interpret a density (manipulation) test, with care on real data.
- I can recognise a regression kink design and compute its effect.