Accelerated Foundation in Applied Causal InferenceFor Economists · 15 modules · 45 lessons
Module 10: RDD/Lesson 10.3

10.3 Fuzzy RDD, Manipulation Tests and Regression Kinks

about 2 hoursLocal government: firm revenue at distress-score thresholds; close elections; Uruguay's PANES
Lesson 10.3 · Module 10
Fuzzy RDD, Manipulation Tests and Regression Kinks
Video coming soon. The written lesson and the Colab notebook are ready below.
Colab notebook (.ipynb)
By the end of this lesson you can
  • ✓Distinguish sharp from fuzzy designs and estimate a fuzzy RDD as IV at the cutoff
  • ✓Test for manipulation of the running variable with a density test (McCrary / rddensity)
  • ✓Interpret a density test carefully on real data with heaping and mass points
  • ✓Recognise a regression kink design, where the slope of a policy changes at a threshold
Lesson plan (120 minutes)
Written lesson

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.

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

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

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

Python · Colab cell
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)")
Exercise

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