Regression Discontinuity Design

Sharp & Fuzzy RDD, Local Linear Estimation & Validity
using R, Python & Stata

Applied Informatics and Computational Economics Lab

6 July 2026

Regression Discontinuity — A Road Map

The RDD Family at a Glance

Design Treatment rule Identifies
Sharp RDD \(D = \mathbb{1}\{X \ge c\}\) (deterministic) \(\bar\theta = m(c{+}) - m(c{-})\)
Fuzzy RDD \(\Pr(D{=}1\mid X)\) jumps at \(c\) \(\dfrac{m(c{+}) - m(c{-})}{p(c{+}) - p(c{-})}\)
Kink (RKD) slope of assignment jumps ratio of derivative jumps
Estimator Idea Tool
Local linear weighted line each side of \(c\) rdrobust, hand-coded WLS
Simple (rect.) one OLS on \(|X-c|\le h\) lm / reg / OLS
Fuzzy 2SLS \(D\) instrumented by \(\mathbb{1}\{X\ge c\}\) ivreg / ivregress

Running example throughout:

 rdd-headstart (Ludwig-Miller 2007)  2,783 US counties

Variable Description
povrate60 1960 poverty rate — running variable
mortHS child mortality 1973-83 (per 100k) — outcome
pctblack, pcturban 1960 covariates

Cut-off \(c = 59.1984\): the 300 poorest counties got federal grant-writing help.

Also: rdd-fuzzy — a simulated fuzzy design (take-up \(0.40\!\to\!0.80\)). All datasets shared via CSV — R, Python & Stata read the same file.

Required Packages

install.packages(c("rdrobust", "rddensity", "AER"))
# rdrobust  — Calonico, Cattaneo & Titiunik: rdrobust(), rdbwselect(), rdplot()
# rddensity — Cattaneo, Jansson & Ma: manipulation (density) test
# AER       — ivreg() for fuzzy RDD (2SLS)
library(rdrobust); library(AER)
pip install rdrobust statsmodels linearmodels pandas numpy matplotlib
import pandas as pd, numpy as np
import statsmodels.api as sm            # OLS / WLS with robust SE
from linearmodels.iv import IV2SLS      # fuzzy RDD
# rdrobust is also available on PyPI (import rdrobust)
ssc install rdrobust      // rdrobust, rdbwselect, rdplot
ssc install rddensity     // manipulation test + rdplotdensity
ssc install lpdensity     // dependency of rddensity
* Fuzzy RDD uses built-in ivregress 2sls (no extra install)

Note

Two views, on purpose. The cross-language estimation slides are hand-coded (local-linear WLS, plain OLS/2SLS) so every language returns the same numbers to the digit and you can see exactly what the software does. The specialised tooling slides then run rdrobust (MSE-optimal bandwidth + robust bias-corrected CI) and rddensity (formal manipulation test) live in R, Python and Stata — and those agree across languages too (\(\hat\tau = -2.41\), robust \(p = 0.042\)).

Literature Review

Part I — Sharp RDD: Identification

Why RDD? When to Use It?

In observational data, treatment is rarely exogenous — the treated differ from the untreated in unobserved ways. RDD exploits a special situation: treatment is switched on/off by a threshold crossing rule on an observable running variable \(X\).

  • Just below vs just above the cut-off \(c\), units are almost identical — the only systematic difference is treatment.
  • Assignment near \(c\) is “as good as random” (Lee 2008): units cannot perfectly control which side they land on.
  • So a jump in the outcome at \(c\) is a causal effect — no unconfoundedness assumption on covariates needed.

Any rule of the form “you get treatment if a score passes a threshold”:

  • Elections — incumbency advantage: win if vote share \(> 50\%\).
  • Education — scholarships / admission if exam score \(\ge\) cut-off; class-size caps (Maimonides).
  • Anti-poverty policy — Head Start grants to the 300 poorest counties (our data).
  • Regulation / finance — credit scores, tax brackets, eligibility thresholds.
  • Health — treatment when a biomarker crosses a clinical cut-off.

Warning

RDD is credible only if the threshold is the only thing that changes at \(c\). It breaks down when:

  • Units manipulate \(X\) to get onto the treated side (sorting / bunching at \(c\)).
  • Other policies switch at the same cut-off (confounded discontinuity).
  • The running variable is coarse / discrete with few mass points near \(c\).
  • You extrapolate: RDD identifies the effect only at \(X = c\), not elsewhere.

Potential Outcomes & the Sharp Design

Potential outcomes \(Y_0\) (untreated) and \(Y_1\) (treated). The individual treatment effect is \(\theta = Y_1 - Y_0\) (random). The conditional ATE given the running variable is

\[\theta(x) = \mathbb{E}[\theta \mid X = x] = m_1(x) - m_0(x),\]

where \(m_d(x) = \mathbb{E}[Y_d \mid X = x]\).

Sharp RDD: treatment is a deterministic step function of \(X\),

\[D = \mathbb{1}\{X \ge c\}.\]

\(X\) is the running variable; \(c\) is the cut-off (set by policy). The observed outcome is

\[Y = Y_0\,\mathbb{1}\{X < c\} + Y_1\,\mathbb{1}\{X \ge c\}.\]

We target the ATE at the cut-off — the subpopulation affected at the margin by the policy:

\[\bar\theta = \theta(c) = \mathbb{E}[\theta \mid X = c].\]

  • This is a narrowly-defined but policy-relevant parameter: the effect of nudging the threshold.
  • With \(m_0(x)\) observed only for \(x<c\) and \(m_1(x)\) only for \(x\ge c\), using \(\bar\theta\) for other \(x\) is extrapolation.

Note

Head Start reading. \(m_0(x)\) = mortality if a county gets no grant-writing help; \(m_1(x)\) = mortality with help. Both are smooth functions of the 1960 poverty rate. There is no reason for either to jump at \(59.2\%\) — so any jump in observed mortality there is the causal effect of the programme.

Continuity of Potential Outcomes

Code
# Two smooth potential-outcome regressions. We observe m0 only left of c and
# m1 only right of c (solid); their counterfactual continuations are dashed.
# The identified effect is the vertical jump at c — nothing else moves there.
x  <- seq(-1, 1, length.out = 400)
m0 <- 1.0 + 0.8 * x + 0.5 * x^2          # E[Y0 | X=x]
m1 <- 1.6 + 0.8 * x + 0.5 * x^2          # E[Y1 | X=x], jump = 0.6 at c
df <- data.frame(x, m0, m1)

ggplot(df, aes(x)) +
  geom_line(aes(y = m0), linewidth = 1.1, colour = "#185FA5",
            data = subset(df, x <  0)) +
  geom_line(aes(y = m1), linewidth = 1.1, colour = "#C0132C",
            data = subset(df, x >= 0)) +
  geom_line(aes(y = m0), linewidth = 0.7, colour = "#185FA5",
            linetype = "22", data = subset(df, x >= 0)) +
  geom_line(aes(y = m1), linewidth = 0.7, colour = "#C0132C",
            linetype = "22", data = subset(df, x <  0)) +
  annotate("segment", x = 0, xend = 0, y = 1.0, yend = 1.6,
           colour = "#1D9E75", linewidth = 1.3,
           arrow = arrow(ends = "both", length = unit(0.15, "in"))) +
  annotate("text", x = 0.06, y = 1.3, hjust = 0, colour = "#1D9E75",
           fontface = "bold", label = "theta-bar") +
  geom_vline(xintercept = 0, linetype = "dotted", colour = "grey55") +
  labs(x = "running variable X (centred at cut-off c = 0)",
       y = "conditional mean outcome",
       title = "Continuity identifies the jump at c")

Solid curves are observed (each potential outcome is seen only on its own side); dashed curves are the counterfactual continuations we never see. Continuity says the dashed pieces join the solid ones smoothly — so the only break at \(c\) is the green jump \(\bar\theta\).

The identifying assumption is a statement about the two regression functions, not about independence:

\[m_0(x) = \mathbb{E}[Y_0 \mid X = x], \qquad m_1(x) = \mathbb{E}[Y_1 \mid X = x]\]

are continuous at \(x = c\). Equivalently, the left and right limits of each exist and agree with the function value:

\[\lim_{x \uparrow c} m_d(x) = m_d(c) = \lim_{x \downarrow c} m_d(x), \qquad d \in \{0,1\}.\]

  • This is strictly weaker than the randomization assumption \(\;(Y_0, Y_1) \perp D\).
  • It is local: it constrains the curves only at \(c\), so \(\bar\theta\) is identified only at \(c\).
  • It is fundamentally untestable (it concerns unseen counterfactuals) — we probe it indirectly with density and placebo checks.

RDD vs Difference-in-Differences vs IV

RDD DiD IV
Key assumption Continuity of \(m_d(\cdot)\) at \(c\) Parallel trends Instrument valid + exclusion
Source of variation Threshold crossing in \(X\) Timing of treatment across groups Exogenous shifter \(Z\)
Estimand Effect at the cut-off ATT (treated group) LATE (compliers)
Needs covariates? No (only for precision) Often, for conditional PT No (for identification)
Main threat Manipulation / sorting at \(c\) Trend divergence Weak / invalid instrument
Testable support Density + placebo checks Pre-trends First stage, over-ID

The assumptions look similar but live in different dimensions:

  • DiD compares the same units over time and assumes their untreated path would have moved parallel to a control group — an assumption about trends across time.
  • RDD compares different units at one instant just either side of \(c\) and assumes their outcome regressions are smooth in the score — an assumption about continuity across \(X\).

Note

RDD is often described as “IV-like” at the cut-off. In the fuzzy design (later), the threshold indicator \(Z = \mathbb{1}\{X \ge c\}\) is literally used as an instrument for actual treatment \(D\), and the estimand becomes a LATE for compliers at \(c\). Sharp RDD is the special case of perfect compliance.

  • A hard threshold rule assigns treatment \(\Rightarrow\) reach for RDD.
  • A policy switches on at a date for some groups \(\Rightarrow\) DiD.
  • An exogenous shifter nudges an endogenous treatment \(\Rightarrow\) IV.
  • RDD buys the weakest, most local assumption — at the cost of a local, less generalizable estimand.

Identification — Theorem 21.1

Let \(m(x) = \mathbb{E}[Y \mid X = x]\) and \(m(c{\pm}) = \lim_{z \to c^{\pm}} m(z)\).

Theorem 21.1 (Hahn, Todd & Van der Klaauw 2001). Assume \(D = \mathbb{1}\{X \ge c\}\) and that \(m_0(x)\) and \(m_1(x)\) are continuous at \(x = c\). Then \[\boxed{\;\bar\theta = m(c{+}) - m(c{-})\;}\]

The RDD treatment effect is the vertical jump in the conditional mean at the cut-off. That is the whole idea — estimation reduces to estimating two boundary points.

The conditions are minimal — no unconfoundedness, no functional form:

  • Continuity of \(m_0(\cdot)\) and \(m_1(\cdot)\) at \(c\): absent treatment, the outcome would evolve smoothly through \(c\).
  • No manipulation: units cannot precisely sort across \(c\) (testable — density test).
  • No coincident treatments: nothing else switches at \(c\).

Continuity is an assumption about the counterfactual \(m_0\) at \(c^+\) and \(m_1\) at \(c^-\) — inherently untestable, but supported by placebo checks.

Data — Head Start

Code
hs_df <- read.csv("../data/rdd-headstart.csv")
C0 <- 59.1984

# Label each side, then a Cleveland lowess (f = 0.6, single pass iter = 0) each
# side — same algorithm/settings as Stata's lowess and Python's lowess(it = 0),
# so the smoother is identical across all three tabs (ggplot's loess would not).
hs_df <- hs_df %>% mutate(side = if_else(povrate60 >= C0, "Treated", "Untreated"))
loL <- hs_df %>% filter(povrate60 <  C0) %>%
  with(lowess(povrate60, mortHS, f = 0.6, iter = 0)) %>% as_tibble()
loR <- hs_df %>% filter(povrate60 >= C0) %>%
  with(lowess(povrate60, mortHS, f = 0.6, iter = 0)) %>% as_tibble()
ggplot(hs_df, aes(povrate60, mortHS)) +
  geom_point(aes(colour = side), alpha = 0.25, size = 0.8) +
  geom_line(data = loL, aes(x, y), colour = "#185FA5", linewidth = 1.2) +
  geom_line(data = loR, aes(x, y), colour = "#D85A30", linewidth = 1.2) +
  geom_vline(xintercept = C0, linetype = "dashed", colour = "grey30") +
  scale_colour_manual(values = c(Untreated = "#185FA5", Treated = "#D85A30")) +
  scale_x_continuous(breaks = seq(20, 80, 20)) +
  scale_y_continuous(breaks = seq(0, 12, 2)) +
  coord_cartesian(xlim = c(10, 85), ylim = c(0, 12)) +
  labs(title = "Head Start: child mortality vs 1960 poverty rate",
       subtitle = paste0("Cut-off c = ", C0, " — counties at/above got grant-writing help"),
       x = "1960 poverty rate (running variable)",
       y = "Mortality 1973-83 (per 100,000)", colour = NULL) +
  theme(text = element_text(size = 16))

Code
import pandas as pd, numpy as np
import matplotlib.pyplot as plt
from statsmodels.nonparametric.smoothers_lowess import lowess

hs = pd.read_csv("../data/rdd-headstart.csv"); C0 = 59.1984
lo = hs[hs.povrate60 <  C0]; hi = hs[hs.povrate60 >= C0]

fig, ax = plt.subplots(figsize=(11, 5))
ax.scatter(lo.povrate60, lo.mortHS, s=6, alpha=0.25, color="#185FA5", label="Untreated")
ax.scatter(hi.povrate60, hi.mortHS, s=6, alpha=0.25, color="#D85A30", label="Treated")
for sub, col in [(lo, "#185FA5"), (hi, "#D85A30")]:
    sm_ = lowess(sub.mortHS, sub.povrate60, frac=0.6, it=0)   # it=0 to match R/Stata
    ax.plot(sm_[:, 0], sm_[:, 1], color=col, lw=2.2)
ax.axvline(C0, ls="--", color="grey")
ax.set_xlim(10, 85); ax.set_ylim(0, 12)
(10.0, 85.0)
(0.0, 12.0)
Code
ax.set_xticks(range(20, 81, 20)); ax.set_yticks(range(0, 13, 2))
ax.set_xlabel("1960 poverty rate (running variable)", fontsize=14)
ax.set_ylabel("Mortality 1973-83 (per 100,000)", fontsize=14)
ax.set_title(f"Head Start: child mortality vs poverty rate (c = {C0})", fontsize=15)
ax.legend(fontsize=12)
plt.tight_layout(); plt.show()

Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
scalar C0 = 59.1984
gen treated = povrate60 >= C0

* Scatter + lowess smoother each side, saved to a temp PNG (../plots/).
* Same axes and smoother as the R/Python tabs for a like-for-like picture.
* Scatter is clipped to mortHS<=12 to match R/Python's clipped VIEW; the lowess
* is computed on ALL data (as in R/Python), so the smoother is identical.
twoway (scatter mortHS povrate60 if !treated & mortHS<=12, msize(vsmall) mcolor(navy%25))   ///
       (scatter mortHS povrate60 if  treated & mortHS<=12, msize(vsmall) mcolor(orange%25)) ///
       (lowess mortHS povrate60 if !treated, lcolor(navy)   lwidth(medthick) bwidth(0.6))   ///
       (lowess mortHS povrate60 if  treated, lcolor(orange) lwidth(medthick) bwidth(0.6)),  ///
       xline(59.1984, lpattern(dash))                                                       ///
       xscale(range(10 85)) xlabel(20(20)80)                                                ///
       yscale(range(0 12)) ylabel(0(2)12)                                                   ///
       legend(order(1 "Untreated" 2 "Treated"))                                             ///
       xtitle("1960 poverty rate") ytitle("Mortality 1973-83 (per 100k)")                   ///
       title("Head Start: mortality vs poverty rate")
graph export "../plots/rdd_stata_data.png", replace width(1600)

Note

What to observe. Mortality rises with poverty (both sides) and drops downward just above the cut-off — exactly the treatment signature. Density thins out at high poverty (few counties), so the treated side is estimated with more noise. The raw scatter is very disperse; the discontinuity is a property of the conditional mean, not the raw cloud.

Part II — Estimation

Local Linear Estimation

Let \(Z_i(x) = (1,\; X_i - x)'\), a kernel \(K(u)\) and bandwidth \(h\). On each side of \(c\), run a weighted least squares line. For \(x < c\):

\[\hat\beta_0(x) = \Big(\textstyle\sum_i K\!\big(\tfrac{X_i-x}{h}\big) Z_i Z_i' \mathbb{1}\{X_i<c\}\Big)^{-1}\Big(\textstyle\sum_i K\!\big(\tfrac{X_i-x}{h}\big) Z_i Y_i \mathbb{1}\{X_i<c\}\Big),\]

and symmetrically \(\hat\beta_1(x)\) for \(x \ge c\). The conditional mean estimate is the intercept,

\[\hat m(x) = [\hat\beta_0(x)]_1 \mathbb{1}\{x<c\} + [\hat\beta_1(x)]_1 \mathbb{1}\{x\ge c\},\]

and the RDD estimate is the difference of adjoining endpoints:

\[\hat{\bar\theta} = [\hat\beta_1(c)]_1 - [\hat\beta_0(c)]_1 = \hat m(c{+}) - \hat m(c{-}).\]

The triangular kernel \(K(u) = (1-|u|)\mathbb{1}\{|u|\le 1\}\) is optimal at a boundary. Equivalently, a single weighted regression on all \(|X-c|\le h\) gives \(\hat{\bar\theta}\) directly as the coefficient on \(D\):

\[Y_i = \gamma_0 + \gamma_1 (X_i - c) + \underbrace{\bar\theta}_{\text{jump}} D_i + \gamma_3 (X_i - c)D_i + e_i,\qquad w_i = K\!\Big(\tfrac{X_i-c}{h}\Big).\]

  • \(D_i = \mathbb{1}\{X_i \ge c\}\) shifts the intercept → the jump.
  • \((X_i-c)D_i\) lets the slope differ on the two sides.
  • Rectangular kernel \(K(u)=\tfrac12\mathbb{1}\{|u|\le1\}\) ⇒ plain OLS on the window (the “simple estimator”).

Code — Local Linear RDD

Code
hs_df <- read.csv("../data/rdd-headstart.csv"); C0 <- 59.1984; h <- 8

# One weighted regression with a triangular kernel. theta = coefficient on D.
xx <- hs_df$povrate60 - C0
D  <- as.numeric(hs_df$povrate60 >= C0)
k  <- pmax(0, 1 - abs(xx) / h)               # triangular weights, support |X-c| <= h
fit <- lm(mortHS ~ xx + D + xx:D, data = hs_df, weights = k)

# CAREFUL: vcovHC() on a weighted lm treats k as VARIANCE weights (wrong here).
# Build the kernel-weighted robust sandwich by hand (HC1):
use <- k > 0
Z <- cbind(1, xx, D, xx * D)[use, ]
y <- hs_df$mortHS[use]; kk <- k[use]; n <- nrow(Z)
bread <- solve(t(Z * kk) %*% Z)
e     <- as.numeric(y - Z %*% coef(fit))
meat  <- t(Z * (kk^2 * e^2)) %*% Z
V     <- bread %*% meat %*% bread * (n / (n - 4))
theta <- coef(fit)["D"]; se <- sqrt(V[3, 3])
cat(sprintf("Local linear (triangular, h=%d): theta = %.4f  se = %.4f  t = %.2f\n",
            h, theta, se, theta / se))
Local linear RDD — Head Start (triangular kernel, h = 8)
  theta_hat = -2.2487   se = 1.0763   t = -2.09   (n in window = 482)
  m(c-) (untreated rate at cut-off) = 3.58 per 100,000
  => a drop of 2.2 is about 63% of the baseline mortality rate.
Code
import pandas as pd, numpy as np, statsmodels.api as sm

hs = pd.read_csv("../data/rdd-headstart.csv"); C0 = 59.1984; h = 8
xx = hs.povrate60.values - C0
D  = (hs.povrate60.values >= C0).astype(float)
k  = np.maximum(0, 1 - np.abs(xx) / h)              # triangular weights
Z  = np.column_stack([np.ones_like(xx), xx, D, xx * D])

use = k > 0
# WLS with HC1 robust SE reproduces the kernel-weighted sandwich exactly.
fit = sm.WLS(hs.mortHS.values[use], Z[use], weights=k[use]).fit(cov_type="HC1")
theta, se = fit.params[2], fit.bse[2]
print(f"Local linear (triangular, h={h}): theta = {theta:.4f}  se = {se:.4f}  t = {theta/se:.2f}")
Local linear (triangular, h=8): theta = -2.2487  se = 1.0763  t = -2.09
Code
print(f"n in window = {int(use.sum())}")
n in window = 482
Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
scalar C0 = 59.1984
gen xx = povrate60 - C0
gen D  = povrate60 >= C0
gen xD = xx*D
gen k  = max(0, 1 - abs(xx)/8)          // triangular weights, h = 8

* aweight + vce(robust) gives the kernel-weighted robust sandwich (HC1)
regress mortHS xx D xD [aweight=k] if k>0, vce(robust)
di as txt "theta_hat = " as res _b[D] as txt "   se = " as res _se[D] ///
   as txt "   t = " as res _b[D]/_se[D]

All three languages implement the same weighted regression and the same HC1 kernel sandwich, so they agree to the digit:

Language \(\hat{\bar\theta}\) \(s(\hat{\bar\theta})\) \(t\)
R \(-2.2487\) \(1.0763\) \(-2.09\)
Python \(-2.2487\) \(1.0763\) \(-2.09\)
Stata \(-2.2487\) \(1.0763\) \(-2.09\)

Reading it. Grant-writing assistance lowered HS-related child mortality by about 2.25 per 100,000 at the cut-off. With a baseline (untreated) rate of \(\approx 3.6\), that is a **$\(60% reduction** — a large, statistically significant policy effect (\)t $).

Warning

Weights are kernel weights, not variance weights. In R, vcovHC(lm(..., weights = k)) divides by the weights again and returns a badly understated SE. Compute the sandwich by hand — or use rdrobust. Stata’s aweight+vce(robust) and Python’s WLS(...).fit(cov_type="HC1") happen to produce exactly the kernel-weighted sandwich we want.

Code — Simple RDD Estimator

The rectangular-kernel special case is one plain OLS on the window \(|X-c|\le h\) — no special software, robust SE, identical everywhere. This is Hansen’s eq. (21.4): \[Y = \beta_0 + \beta_1 X + \beta_3 (X-c)D + \bar\theta D + e,\qquad |X-c|\le h.\]

Code
library(sandwich); library(lmtest)
hs_df <- read.csv("../data/rdd-headstart.csv"); C0 <- 59.1984; h <- 8

sub <- abs(hs_df$povrate60 - C0) <= h                 # rectangular window
fit <- lm(mortHS ~ I(povrate60 - C0) + D + I((povrate60 - C0) * D),
          data = transform(hs_df, D = as.numeric(povrate60 >= C0)), subset = sub)
coeftest(fit, vcov = vcovHC(fit, type = "HC1"))       # theta = coef on D
Simple RDD (rectangular, h = 8, n = 482) — reproduces Hansen eq. (21.5)
Term Coef SE t p
Intercept 3.484 0.909 3.83 0.000
X - c 0.111 0.169 0.66 0.510
D (= theta) -2.201 1.063 -2.07 0.039
(X - c)·D 0.181 0.236 0.77 0.443
Code
import pandas as pd, numpy as np, statsmodels.api as sm

hs = pd.read_csv("../data/rdd-headstart.csv"); C0 = 59.1984; h = 8
x = hs.povrate60.values - C0
D = (hs.povrate60.values >= C0).astype(float)
sub = np.abs(x) <= h
Z   = np.column_stack([np.ones_like(x), x, D, x * D])[sub]
fit = sm.OLS(hs.mortHS.values[sub], Z).fit(cov_type="HC1")
print(f"n = {int(sub.sum())}")
n = 482
Code
print(f"theta (coef on D) = {fit.params[2]:.4f}   se = {fit.bse[2]:.4f}   "
      f"t = {fit.tvalues[2]:.2f}   p = {fit.pvalues[2]:.3f}")
theta (coef on D) = -2.2006   se = 1.0628   t = -2.07   p = 0.038
Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
scalar C0 = 59.1984
gen x  = povrate60 - C0
gen D  = povrate60 >= C0
gen xD = x*D
regress mortHS x D xD if abs(x)<=8, vce(robust)
di as txt "theta (coef on D) = " as res _b[D] as txt "  se = " as res _se[D]

Rectangular kernel, \(h=8\), \(n = 482\) counties in \([51.2, 67.2]\):

\[\widehat{Y} = -3.11 + 0.11\,X + 0.18\,(X-c)D - \underset{(1.06)}{2.20}\,D.\]

  • \(\hat{\bar\theta} = -2.20\) (se \(1.06\)) — identical in R, Python and Stata, and matching Hansen’s eq. (21.5) exactly.
  • The rectangular estimate (\(-2.20\)) is a touch larger and less precise than the triangular one (\(-2.25\), se \(1.08\)) — the triangular kernel down-weights the window edges and is more efficient.

Tip

This is the estimator to reach for when RDD software is unavailable: it is just OLS with an interaction, on a window, with robust standard errors. The only real choice is the bandwidth \(h\).

Visualising the Discontinuity

Code
# rdplot() = binned means (equal-quantile bins) + a polynomial fit each side.
# The bins are a visual aid ONLY; the estimate comes from the local-linear fit.
library(rdrobust)
rdplot(hs_df$mortHS, hs_df$povrate60, c = C0, p = 1, nbins = c(20, 20),
       y.lim = c(0, 8), x.label = "1960 poverty rate", y.label = "Mortality (per 100k)",
       title = "rdplot — Head Start")

Code
import pandas as pd, numpy as np, matplotlib.pyplot as plt, statsmodels.api as sm

hs = pd.read_csv("../data/rdd-headstart.csv"); C0 = 59.1984
q  = np.quantile(hs.povrate60, np.linspace(0, 1, 41))
hs["bin"] = pd.cut(hs.povrate60, np.unique(q), include_lowest=True)
b  = hs.groupby("bin", observed=True)[["povrate60", "mortHS"]].mean()

fig, ax = plt.subplots(figsize=(11, 5))
below = b.povrate60 < C0
ax.scatter(b.povrate60[below],  b.mortHS[below],  color="#185FA5", s=28, label="Untreated")
ax.scatter(b.povrate60[~below], b.mortHS[~below], color="#D85A30", s=28, label="Treated")
for sub, col in [(hs[hs.povrate60 < C0], "#185FA5"), (hs[hs.povrate60 >= C0], "#D85A30")]:
    X = sm.add_constant(sub.povrate60); m = sm.OLS(sub.mortHS, X).fit()
    xs = np.linspace(sub.povrate60.min(), sub.povrate60.max(), 50)
    pr = m.get_prediction(sm.add_constant(xs)).summary_frame(alpha=0.05)   # 95% CI band
    ax.plot(xs, pr["mean"], color=col, lw=2.2)
    ax.fill_between(xs, pr["mean_ci_lower"], pr["mean_ci_upper"], color=col, alpha=0.15)
ax.axvline(C0, ls="--", color="grey")
ax.set_xlim(10, 85); ax.set_ylim(0, 8)
(10.0, 85.0)
(0.0, 8.0)
Code
ax.set_xticks(range(20, 81, 20)); ax.set_yticks(range(0, 9, 2))
ax.set_xlabel("1960 poverty rate"); ax.set_ylabel("Mortality (per 100k)")
ax.set_title("Discontinuity plot — binned means + local linear fit"); ax.legend()
plt.tight_layout(); plt.show()

Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
scalar C0 = 59.1984
gen treated = povrate60 >= C0

* 40 equal-frequency bins (quantiles) of the running variable; plot bin means +
* linear fit WITH 95% CI band each side. Same axes as the R/Python tabs.
egen bin = cut(povrate60), group(40)
egen ybar = mean(mortHS), by(bin)
egen xbar = mean(povrate60), by(bin)
egen tag = tag(bin)
twoway (lfitci mortHS povrate60 if !treated, acolor(navy%15)   alwidth(none) clcolor(navy))   ///
       (lfitci mortHS povrate60 if  treated, acolor(orange%15) alwidth(none) clcolor(orange)) ///
       (scatter ybar xbar if tag & !treated, mcolor(navy))     ///
       (scatter ybar xbar if tag &  treated, mcolor(orange)),  ///
       xline(59.1984, lpattern(dash))                          ///
       xscale(range(10 85)) xlabel(20(20)80)                   ///
       yscale(range(0 8)) ylabel(0(2)8)                        ///
       legend(order(5 "Untreated" 6 "Treated"))                ///
       xtitle("1960 poverty rate") ytitle("Mortality (per 100k)") ///
       title("Discontinuity plot — bin means + local linear")
graph export "../plots/rdd_stata_rdplot.png", replace width(1600)

Warning

Binned means are a visual aid, not the estimate. They are a Nadaraya-Watson (rectangular, gridded) estimator — a poor one. Do not read the treatment effect off the dots; read it off the two local-linear fits at the cut-off. Hansen calls plotting only binned means “a bad habit”: always show the best nonparametric fit with confidence bands.

Part III — Inference & Bandwidth

Inference — Bias & Variance

The LL estimator \(\hat m(x)\) is asymptotically normal; this carries over to \(\hat{\bar\theta}\). It has bias

\[\operatorname{bias}[\hat{\bar\theta}] = \frac{h^2 \sigma_{K^*}^2}{2}\big(m''(c{+}) - m''(c{-})\big)\]

and variance

\[\operatorname{var}[\hat{\bar\theta}] = \frac{R_K^*}{nh}\left(\frac{\sigma^2(c{+})}{f(c{+})} + \frac{\sigma^2(c{-})}{f(c{-})}\right).\]

  • Bias grows with \(h^2\) and with curvature \(m''\) — a wider window “cuts the corner”.
  • Variance shrinks with \(nh\) (more effective observations) and with the density \(f(c)\) at the cut-off.
  • This bias-variance trade-off is the whole game in bandwidth choice.

The variance is estimated by summing the two boundary-regression sandwich variances. With \(Z_i = (1, X_i-c)'\), \(K_i = K((X_i-c)/h)\) and leave-one-out errors \(\tilde e_i\),

\[\widehat{V}_1 = \Big(\textstyle\sum_i K_i Z_i Z_i' \mathbb{1}\{X_i\ge c\}\Big)^{-1}\Big(\textstyle\sum_i K_i^2 Z_i Z_i' \tilde e_i^2 \mathbb{1}\{X_i\ge c\}\Big)\Big(\textstyle\sum_i K_i Z_i Z_i' \mathbb{1}\{X_i\ge c\}\Big)^{-1},\]

and analogously \(\widehat V_0\) for the left side. Then

\[s(\hat{\bar\theta}) = \sqrt{[\widehat V_0]_{11} + [\widehat V_1]_{11}}.\]

This is exactly the kernel-weighted sandwich we coded by hand (using raw residuals; rdrobust uses the leave-one-out variant).

Note

Honest inference. Bias does not vanish in finite samples. Two fixes: (i) use a common bandwidth on both sides (zeroes the first-order bias when \(m''\) is continuous); (ii) use a bandwidth smaller than MSE-optimal, trading variance for less bias. rdrobust instead bias-corrects the point estimate and widens the interval (robust CI).

Bandwidth Selection

Code
library(rdrobust)
# Data-driven MSE-optimal bandwidth (Calonico-Cattaneo-Titiunik / Imbens-Kalyanaraman)
bw <- rdbwselect(hs_df$mortHS, hs_df$povrate60, c = C0, bwselect = "mserd")
summary(bw)                                       # h (main) and b (bias) bandwidths

# Sensitivity: theta across a grid of bandwidths (our hand-coded LL)
hs <- seq(4, 20, by = 1)
sens <- t(sapply(hs, function(h) rd_ll(hs_df$mortHS, hs_df$povrate60, C0, h)))

Code
import pandas as pd, numpy as np, matplotlib.pyplot as plt, statsmodels.api as sm

hs = pd.read_csv("../data/rdd-headstart.csv"); C0 = 59.1984
def rd_ll(y, x, c, h):
    xx = x - c; D = (x >= c).astype(float); k = np.maximum(0, 1 - np.abs(xx)/h)
    use = k > 0; Z = np.column_stack([np.ones_like(xx), xx, D, xx*D])[use]
    f = sm.WLS(y[use], Z, weights=k[use]).fit(cov_type="HC1")
    return f.params[2], f.bse[2]

hgrid = np.arange(4, 21)
res = np.array([rd_ll(hs.mortHS.values, hs.povrate60.values, C0, h) for h in hgrid])
th, se = res[:, 0], res[:, 1]
plt.figure(figsize=(11, 4.6))
plt.fill_between(hgrid, th - 1.96*se, th + 1.96*se, color="#185FA5", alpha=0.15)
plt.plot(hgrid, th, "-o", color="#185FA5", lw=2)
plt.axhline(0, ls="--", color="#D85A30")
plt.xlabel("bandwidth h"); plt.ylabel(r"$\hat\theta$")
plt.title("Bandwidth sensitivity of the RDD estimate (95% CI)")
plt.tight_layout(); plt.show()

Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
scalar C0 = 59.1984
gen x  = povrate60 - C0
gen D  = povrate60 >= C0
gen xD = x*D

* theta across bandwidths (rectangular window for a transparent, package-free sweep)
foreach h of numlist 4 6 8 10 12 16 20 {
    quietly regress mortHS x D xD if abs(x)<=`h', vce(robust)
    di as txt "h = " %2.0f `h' "   theta = " as res %7.3f _b[D] ///
       as txt "   se = " as res %6.3f _se[D] as txt "   n = " as res e(N)
}

Tip

What the sweep shows. The estimate is negative and stable across \(h\): from about \(-2.7\) at \(h=6\) to \(-1.8\) at \(h=15\), straightening out (less curvature, more bias, narrower bands) as \(h\) grows. The MSE-optimal \(h \approx 6.8\) sits in the middle. Robustness to \(h\) — not to polynomial order — is the check that matters.

Boundary Bias & Robust Inference

Local linear at a boundary carries a smoothing bias of order \(h^2\), while its variance falls like \(1/(nh)\):

\[\underbrace{\mathbb{E}[\hat\theta] - \bar\theta}_{\text{bias}} \approx B\,h^{2}, \qquad \operatorname{Var}(\hat\theta) \approx \frac{V}{n\,h}.\]

  • Big \(h\) → small variance but large bias (you borrow from far-away, curved regions).
  • Small \(h\) → small bias but large variance (few points near \(c\)).
  • The MSE-optimal \(h^\star \propto n^{-1/5}\) balances them — so at \(h^\star\) the bias is the same order as the SE.

Warning

Consequence. A conventional CI \(\hat\theta \pm 1.96\,\widehat{\text{SE}}\) is centred on a biased point, and the bias is not negligible relative to its width — so it undercovers (true coverage below \(95\%\)). Undersmoothing (shrinking \(h\) by hand) hides the bias but throws away precision.

Code
# Schematic: how bias (~ h^2) and SE (~ 1/sqrt(n h)) trade off with bandwidth.
# Their sum of squares (MSE) is minimized at h*, where bias and SE are the
# SAME ORDER — which is exactly why a conventional CI undercovers there.
h  <- seq(1, 20, length.out = 300)
n  <- 2783
bias <- 0.020 * h^2                 # boundary smoothing bias
se   <- 273.6 / sqrt(n * h)         # sampling SE
mse  <- bias^2 + se^2
hopt <- h[which.min(mse)]

df <- data.frame(h, bias, se)
ggplot(df, aes(h)) +
  geom_line(aes(y = bias, colour = "bias  ~ h^2"),          linewidth = 1.2) +
  geom_line(aes(y = se,   colour = "std. error ~ (n h)^-.5"), linewidth = 1.2) +
  geom_vline(xintercept = hopt, linetype = "dotted", colour = "grey40") +
  annotate("text", x = hopt + 0.3, y = 2.6, hjust = 0,
           label = sprintf("MSE-optimal h* ≈ %.1f", hopt)) +
  scale_colour_manual(values = c("bias  ~ h^2" = "#C0132C",
                                 "std. error ~ (n h)^-.5" = "#185FA5")) +
  labs(x = "bandwidth h", y = "magnitude", colour = NULL,
       title = "At h*, bias and SE are the same order")

The red bias curve rises with \(h\); the blue SE curve falls. They cross near \(h^\star\) — precisely the regime where the conventional CI’s neglected bias bites. rdrobust’s Robust column fixes this (button above).

Global vs Local Polynomials

Code
# Global high-order polynomial (order 6, fit each side) vs local linear (h = 9).
# High-order global fits chase far-away points and wiggle at the boundary,
# manufacturing a spurious jump at c. Local linear stays put. (Gelman & Imbens.)
hs <- read.csv("../data/rdd-headstart.csv"); C0 <- 59.1984
L  <- subset(hs, povrate60 <  C0); R <- subset(hs, povrate60 >= C0)

gL <- lm(mortHS ~ poly(povrate60, 6), data = L)   # global order 6, left
gR <- lm(mortHS ~ poly(povrate60, 6), data = R)   # global order 6, right
hh <- 9
lL <- lm(mortHS ~ povrate60, data = subset(L, povrate60 > C0 - hh))  # local linear
lR <- lm(mortHS ~ povrate60, data = subset(R, povrate60 < C0 + hh))

gridL <- data.frame(povrate60 = seq(min(L$povrate60), C0, length = 200))
gridR <- data.frame(povrate60 = seq(C0, max(R$povrate60), length = 200))
# ... predict gL/gR (global) and lL/lR (local) on the grids, then plot with
# binned means as a visual reference.

The global order-6 fit (red) swings wildly near the ends of the support and at the cut-off — its value at \(c\) is driven by data far away. The local linear fit (blue) uses only points within \(h=9\) of \(c\), so its boundary value is the one we trust.

Warning

Gelman & Imbens (2019): do not use high-order global polynomials in RDD. They:

  • assign large weights to observations far from \(c\), which is exactly where identification does not come from;
  • produce estimates that are highly sensitive to the polynomial order (order 4 vs 5 vs 6 can flip the sign of the “jump”);
  • deliver CIs with poor coverage.
  • The RDD estimand is a boundary object — use local low-order polynomials (linear or quadratic) with a principled bandwidth.
  • Report robustness to the bandwidth, not to ever-higher polynomial orders.
  • Gelman & Imbens (2019), JBES.

Power & Sample Size

Two questions, asked before (design) or after (interpretation) the study:

  •  Power — given the design (bandwidth, variance, effective \(N\)), what is the probability of rejecting \(H_0:\bar\theta=0\) when the true effect is \(\tau\)? → rdpower.
  •  Sample size — how many observations near \(c\) are needed for a target power (usually \(0.8\)) at effect \(\tau\)? → rdsampsi.

RDD power calculations build directly on the robust bias-corrected variance (CCT), so they inherit the same bandwidth and kernel choices as estimation. Reference: Cattaneo, Titiunik & Vazquez-Bare (2019), Stata Journal.

Code
library(rdpower)
hs <- read.csv("../data/rdd-headstart.csv")
hs <- hs[complete.cases(hs[, c("povrate60","mortHS")]), ]
Z  <- cbind(hs$mortHS, hs$povrate60)               # outcome, running variable
rdpower(data = Z, cutoff = 59.1984, tau = -2)      # power to detect tau = -2
rdsampsi(data = Z, cutoff = 59.1984, tau = -2)     # N needed for 0.8 power

For 80% power at tau = -2: need ~906 (left) + 364 (right) effective obs;
the study currently has 234 + 180 — i.e. it is underpowered for this effect.
Code
import pandas as pd, warnings
warnings.filterwarnings("ignore")
from rdpower import rdpower

hs = pd.read_csv("../data/rdd-headstart.csv").dropna(subset=["povrate60","mortHS"])
Z  = hs[["mortHS","povrate60"]]                    # outcome, running variable
rdpower(data=Z, cutoff=59.1984, tau=-2, plot=False)   # power to detect tau = -2


Number of obs  =  2783
BW type        =  mserd
Kernel type    =  Triangular
VCE method     =  NN
Derivative     =  0
HA:       tau  =  -2


Cutoff c = 59.198           Left of c      Right of c
Number of obs                   2489             294
Eff. # of obs                    234             180
BW loc. poly.                   6.81            6.81
Order loc. poly.                   1               1
Sampling BW                     6.81            6.81
New sample                       234             180


====================================================================================================
Power against:                H0: tau =      0.2*tau =      0.5*tau =      0.8*tau =          tau = 
                                    0           -0.4           -1.0           -1.6             -2
----------------------------------------------------------------------------------------------------
Robust bias-corrected            0.05           0.06          0.113          0.215          0.309
====================================================================================================
{'power_rbc': 0.3094766696167214, 'se_rbc': np.float64(1.3682259049252694), 'sampsi_r': np.int64(180), 'sampsi_l': np.int64(234), 'samph_r': np.float64(6.810478803394809), 'samph_l': np.float64(6.810478803394809), 'N_r': np.int64(294), 'N_l': np.int64(2489), 'Nh_l': np.int64(234), 'Nh_r': np.int64(180), 'tau': -2, 'bias_r': np.float64(0.005453098569993304), 'bias_l': np.float64(-0.0025591091267016296), 'Vr_rb': np.float64(4921.377317379724), 'Vl_rb': np.float64(30560.49015605024), 'alpha': 0.05}
Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
rdpower mortHS povrate60, c(59.1984) tau(-2)      // power to detect tau = -2
rdsampsi mortHS povrate60, c(59.1984) tau(-2)     // N needed for 0.8 power
power at \(\tau=-2\) \(N\) for \(0.8\) power
R \(0.309\) \(\approx 906 + 364\)
Python \(0.309\) \(\approx 906 + 364\)
Stata \(0.309\) \(\approx 906 + 364\)
  • At the MSE-optimal bandwidth, the Head Start design has only 31% power to detect an effect of \(\tau=-2\) — it is underpowered for that magnitude.
  • To reach \(80\%\) power you would need roughly \(906\) (left) + \(364\) (right) effective observations, versus the \(234 + 180\) actually available.
  • This reconciles the wide robust CI with the significant point estimate: the study got a clear signal, but had it not, we should not have been surprised. Report power, not just the \(p\)-value.

Part IV — Covariates & Specialised Tooling

RDD with Covariates

Covariates \(Z\) are not needed for identification (Theorem 21.1 already gives \(\bar\theta\)). They can, however, reduce the equation error and sharpen precision. Assume a partially-linear form:

\[\mathbb{E}[Y_d \mid X=x, Z=z] = m_d(x) + \beta' z,\qquad d\in\{0,1\}.\]

The conditional ATE is still \(\bar\theta = m(c{+}) - m(c{-})\). The Robinson (1988) semiparametric estimator:

  1. LL-regress \(Y\) on \(X\) → fitted \(\hat m_i\); LL-regress each \(Z_k\) on \(X\)\(\hat g_{ki}\).
  2. OLS of \((Y_i - \hat m_i)\) on \((Z_{ki} - \hat g_{ki})\)\(\hat\beta\).
  3. Form \(\hat e_i = Y_i - Z_i'\hat\beta\); LL-regress \(\hat e_i\) on \(X\)\(\hat m(x)\), \(\hat{\bar\theta}\).

A convenient practical version just adds \(Z\) to the windowed regression.

Code
library(sandwich); library(lmtest)
sub <- abs(hs_df$povrate60 - C0) <= 8
dd  <- transform(hs_df, x = povrate60 - C0, D = as.numeric(povrate60 >= C0))

base <- lm(mortHS ~ x + D + x:D, data = dd, subset = sub)
covs <- lm(mortHS ~ x + D + x:D + pctblack + pcturban, data = dd, subset = sub)
cb <- coeftest(base, vcov = vcovHC(base, "HC1"))
cc <- coeftest(covs, vcov = vcovHC(covs, "HC1"))

tab <- data.frame(
  ` `        = c("theta (D)", "s(theta)", "% Black", "% Urban"),
  Baseline   = c(sprintf("%.2f", cb["D",1]), sprintf("(%.2f)", cb["D",2]), "", ""),
  Covariates = c(sprintf("%.2f", cc["D",1]), sprintf("(%.2f)", cc["D",2]),
                 sprintf("%.4f (%.4f)", cc["pctblack",1], cc["pctblack",2]),
                 sprintf("%.4f (%.4f)", cc["pcturban",1], cc["pcturban",2])),
  check.names = FALSE
)
kbl(tab, align = c("l","r","r"),
    caption = "RDD estimate of Head Start on child mortality (h = 8, n = 482)") %>%
  kable_styling(font_size = 20, full_width = TRUE, bootstrap_options = c("striped","hover")) %>%
  row_spec(1, bold = TRUE, color = col_accent)
RDD estimate of Head Start on child mortality (h = 8, n = 482)
Baseline Covariates
theta (D) -2.20 -2.17
s(theta) (1.06) (1.06)
% Black 0.0129 (0.0120)
% Urban -0.0087 (0.0121)

The treatment effect is essentially unchanged (\(-2.20 \to -2.17\)) — as theory predicts. The covariate signs (% Black \(>0\), % Urban \(<0\)) are consistent with these acting as income proxies (Hansen Table 21.1).

Covariate Adjustment — When It’s “Free”

Adding predetermined covariates \(Z\) leaves the target unchanged — provided \(Z\) is continuous at \(c\):

\[\lim_{x\downarrow c}\mathbb{E}[Z\mid X=x] \;=\; \lim_{x\uparrow c}\mathbb{E}[Z\mid X=x].\]

Under this condition (Calonico–Cattaneo–Farrell–Titiunik 2019),

\[\bar\theta^{\text{cov}} = \bar\theta \qquad\text{(same estimand)},\]

and the only thing that can change is the variance:

  • Covariates that predict \(Y\) near \(c\) soak up residual noise → smaller SE.
  • Covariates uncorrelated with \(Y\) near \(c\) → essentially no gain (and a few lost degrees of freedom).

Note

Precision is not guaranteed. On Head Start, % Black and % Urban barely correlate with mortality among near-cut-off counties, so the SE falls by well under \(1\%\). The estimand-invariance, by contrast, is exact — that is the real reason to feel safe adding predetermined covariates.

Code
hs <- read.csv("../data/rdd-headstart.csv")
hs <- hs[complete.cases(hs[, c("povrate60","mortHS","pctblack","pcturban")]), ]
# covs = predetermined 1960 covariates, continuous at the cut-off
o0 <- rdrobust(hs$mortHS, hs$povrate60, c = 59.1984)
o1 <- rdrobust(hs$mortHS, hs$povrate60, c = 59.1984,
               covs = cbind(hs$pctblack, hs$pcturban))
no covs : tau = -2.409   se = 1.206
+ covs  : tau = -2.381   se = 1.198
estimand shift = +0.028   SE change = -0.7%
Code
import pandas as pd, warnings
warnings.filterwarnings("ignore")
from rdrobust import rdrobust

hs = pd.read_csv("../data/rdd-headstart.csv").dropna(
        subset=["povrate60","mortHS","pctblack","pcturban"])
o0 = rdrobust(y=hs.mortHS, x=hs.povrate60, c=59.1984)
o1 = rdrobust(y=hs.mortHS, x=hs.povrate60, c=59.1984,
              covs=hs[["pctblack","pcturban"]])
print(f"no covs : tau = {float(o0.coef.iloc[0,0]):+.3f}   se = {float(o0.se.iloc[0,0]):.3f}")
no covs : tau = -2.409   se = 1.206
Code
print(f"+ covs  : tau = {float(o1.coef.iloc[0,0]):+.3f}   se = {float(o1.se.iloc[0,0]):.3f}")
+ covs  : tau = -2.381   se = 1.198
Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
rdrobust mortHS povrate60, c(59.1984)                          // no covariates
rdrobust mortHS povrate60, c(59.1984) covs(pctblack pcturban)  // + covariates
\(\tau\) (no covs) \(\tau\) (+ covs)
R \(-2.409\) \(-2.381\)
Python \(-2.409\) \(-2.381\)
Stata \(-2.409\) \(-2.381\)
  • Identical across languages; the estimate moves by only \(0.03\)estimand invariance in action.
  • The SE change is negligible here — covariates don’t predict mortality near the cut-off. Adjust for validity insurance and possible precision, never expecting a free lunch.

Heterogeneous Effects at the Cut-off

The cut-off effect can differ across subgroups \(G\). Estimate all group effects in one kernel-weighted regression by fully interacting the local-linear terms with \(G\) (here \(G\in\{0,1\}\)):

\[Y = \alpha + \beta x + \theta D + \gamma xD \; + \; G\big(\alpha_1 + \beta_1 x + \theta_1 D + \gamma_1 xD\big) + \varepsilon,\]

with \(x = X-c\), \(D=\mathbb{1}\{X\ge c\}\), weights \(k_i = \max(0, 1-|x_i|/h)\).

  • \(\theta\) = cut-off effect for group \(G=0\); \(\theta + \theta_1\) = effect for \(G=1\).
  • \(\theta_1\) is the heterogeneity: a \(t\)-test on \(\theta_1\) asks whether the RDD effect differs across groups.
  • Equivalent to running separate RDDs per group — which is exactly what rdrobust does below, group by group.

Warning

Local samples get small fast. Splitting the data shrinks the effective sample within \(h\) on each side, inflating every subgroup SE. Treat subgroup RDD effects as exploratory unless each cell is well-populated.

Code
sen <- read.csv("../data/rdd-senate.csv")
# incumbency advantage overall, then split states by 1960 population (median)
fit <- function(d) { o <- rdrobust(d$vote, d$margin, c = 0)
                     c(est = o$coef[1], lo = o$ci[3, 1], hi = o$ci[3, 2]) }
med <- median(sen$population, na.rm = TRUE)
res <- rbind(
  All          = fit(sen),
  `Small states` = fit(subset(sen, population <  med)),
  `Large states` = fit(subset(sen, population >= med))
)
# forest plot of the three robust CIs ...

The incumbency advantage is about twice as large in small states (\(\approx 11\) pts) as in large ones (\(\approx 5\) pts), but each subgroup interval is wider than the pooled one — the price of splitting the local sample.

  • Heterogeneity is a statement about effect modification at the cut-off, still fully local in \(X\).
  • Report it with the same rigour as the main effect: MSE-optimal bandwidth per group, robust CIs.
  • A significant \(\theta_1\) (or non-overlapping CIs) is suggestive, not proof — pre-register subgroups to avoid fishing.
  • Modern extension: continuously-varying heterogeneity via covariate interactions (Calonico–Cattaneo–Farrell–Palomba–Titiunik).

Specialised Tooling — rdrobust

Code
library(rdrobust)
# MSE-optimal bandwidth + robust bias-corrected inference (CCT 2014), triangular kernel.
out <- rdrobust(hs_df$mortHS, hs_df$povrate60, c = C0)   # kernel="tri", bwselect="mserd" by default
summary(out)
# Read: 'Conventional' = point estimate at MSE-opt h; 'Robust' = bias-corrected CI (use this).
rdrobust — Head Start (triangular kernel, MSE-optimal bandwidth)
  main bandwidth  h = 6.810    bias bandwidth b = 10.725
  effective N:  left = 234   right = 180
  Conventional:  tau = -2.409   se = 1.206
  Robust (bias-corrected):  p = 0.042   95% CI = [-5.462, -0.099]
Code
import pandas as pd
from rdrobust import rdrobust                       # pip install rdrobust — same API as R
hs = pd.read_csv("../data/rdd-headstart.csv"); C0 = 59.1984

out = rdrobust(y=hs.mortHS, x=hs.povrate60, c=C0)   # triangular kernel, MSE-optimal bandwidth
print(f"MSE-optimal h = {float(out.bws.iloc[0, 0]):.3f}   bias b = {float(out.bws.iloc[1, 0]):.3f}")
MSE-optimal h = 6.810   bias b = 10.725
Code
print(f"Conventional:  tau = {float(out.coef.iloc[0]):.3f}   se = {float(out.se.iloc[0]):.3f}")
Conventional:  tau = -2.409   se = 1.206
Code
print(f"Robust (bias-corrected):  p = {float(out.pv.iloc[2]):.3f}   "
      f"95% CI = [{float(out.ci.iloc[2, 0]):.3f}, {float(out.ci.iloc[2, 1]):.3f}]")
Robust (bias-corrected):  p = 0.042   95% CI = [-5.462, -0.099]
Code
* ssc install rdrobust   // one-time; then the same command as R:
import delimited "../data/rdd-headstart.csv", case(preserve) clear
rdrobust mortHS povrate60, c(59.1984)      // triangular kernel, MSE-optimal bandwidth

rdrobust() key arguments:

Argument Default What it controls
c 0 Cut-off on the running variable
p 1 Local polynomial order (1 = local linear)
kernel "triangular" Boundary-optimal weighting
bwselect "mserd" MSE-optimal common bandwidth (also cerrd for coverage)
h, b data-driven Main and bias bandwidths (set manually to override)
vce "nn" Nearest-neighbour variance; "hc1",… also available
covs none Add covariates (Robinson-style)

On Head Start: MSE-optimal \(h \approx 6.81\), \(\hat\tau_{\text{conv}} = -2.41\), and the robust bias-corrected 95% CI is \([-5.46,\,-0.10]\) (\(p = 0.042\)) — significant, and consistent with our hand-coded \(-2.2\) to \(-2.3\).

Tip

Report the robust CI, not the conventional one, when the bandwidth is data-driven: bias correction plus the wider interval is what makes the inference honest (Calonico, Cattaneo & Titiunik 2014).

Part V — Validity & Falsification

Identifying Assumptions & Threats

RDD’s credibility rests on continuity + no manipulation. These generate testable implications:

Threat What breaks Falsification test
Manipulation / sorting Density of \(X\) jumps at \(c\) McCrary density test
Confounded jump Covariates jump at \(c\) Covariate balance / continuity
It’s not the cut-off Effect appears at fake \(c\) Placebo cut-offs
It’s not the treatment Effect on unaffected outcome Placebo outcomes

Tip

A validity checklist before you believe an RDD:

  1. Plot the raw data + local-linear fit with CI bands.
  2. Run the density test — no bunching at \(c\).
  3. Check pre-determined covariates are continuous at \(c\).
  4. Run placebo cut-offs (fake thresholds) — no effect.
  5. Run placebo outcomes (things treatment can’t affect) — no effect.
  6. Show the estimate is stable across bandwidths.

Manipulation (Density) Test

The continuity assumption fails if units sort across the cut-off. Then the density \(f(x)\) of the running variable is discontinuous at \(c\) (bunching). McCrary (2008) tests \(H_0: f(c{+}) = f(c{-})\).

Code
# Formal test: rddensity::rddensity(hs_df$povrate60, c = C0)  (if installed)
# Package-free visual: fine histogram with a bin edge exactly at the cut-off.
library(rddensity)                       # optional
dt <- rddensity(hs_df$povrate60, c = C0)
summary(dt)                              # T stat & p-value for a density jump

Code
import pandas as pd, numpy as np, matplotlib.pyplot as plt
hs = pd.read_csv("../data/rdd-headstart.csv"); C0 = 59.1984
lo = int(np.floor((hs.povrate60.min() - C0) / 2)); hi = int(np.ceil((hs.povrate60.max() - C0) / 2))
brks = C0 + 2.0 * np.arange(lo, hi + 1)          # bins 2 wide, an edge exactly at the cut-off
cnt, edges = np.histogram(hs.povrate60, bins=brks)
mid = 0.5 * (edges[:-1] + edges[1:])
col = np.where(mid >= C0, "#D85A30", "#185FA5")
plt.figure(figsize=(11, 4.4))
plt.bar(mid, cnt, width=1.8, color=col, edgecolor="white")
plt.axvline(C0, ls="--", color="grey")
plt.xlim(10, 85); plt.xticks(range(20, 81, 20))
(10.0, 85.0)
([<matplotlib.axis.XTick object at 0x7f8a9ad96c80>, <matplotlib.axis.XTick object at 0x7f8a9ad75660>, <matplotlib.axis.XTick object at 0x7f8a9adf6aa0>, <matplotlib.axis.XTick object at 0x7f8a9adf5660>], [Text(20, 0, '20'), Text(40, 0, '40'), Text(60, 0, '60'), Text(80, 0, '80')])
Code
plt.xlabel("1960 poverty rate"); plt.ylabel("count")
plt.title("Density of the running variable — no bunching at the cut-off")
plt.tight_layout(); plt.show()

Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
* Bins 2 wide with an edge EXACTLY at the cut-off (as in R/Python), coloured by
* side, counts. start = cut-off minus a whole number of bin widths.
quietly summarize povrate60
local start = 59.1984 - 2 * ceil((59.1984 - r(min)) / 2)
gen binmid = `start' + 2 * floor((povrate60 - `start') / 2) + 1   // bin centre
gen side   = povrate60 >= 59.1984
preserve
collapse (count) cnt = povrate60 (mean) side, by(binmid)
twoway (bar cnt binmid if side == 0, barwidth(1.8) color(navy))    ///
       (bar cnt binmid if side == 1, barwidth(1.8) color(orange)), ///
       xline(59.1984, lpattern(dash))                             ///
       xscale(range(10 85)) xlabel(20(20)80)                      ///
       legend(order(1 "Untreated" 2 "Treated"))                   ///
       xtitle("1960 poverty rate") ytitle("count")                ///
       title("Density of the running variable (bin edge at the cut-off)")
graph export "../plots/rdd_stata_density.png", replace width(1600)
restore

* Formal manipulation test (Cattaneo, Jansson & Ma) — H0: density continuous at c
rddensity povrate60, c(59.1984)

Note

Head Start passes. The rddensity test gives \(T = -0.47\), \(p = 0.64\) (R and Stata agree) — no evidence of a density jump. That is expected: the running variable is a 1960 census poverty rate, fixed by a federal agency in 1965, so counties could not manipulate it to get onto the treated side. Where the running variable is manipulable (self-reported income, re-sittable test scores), this test is essential.

Placebo Cut-offs & Placebo Outcomes

Code
# Placebo CUT-OFFS: re-estimate at fake thresholds where no policy switches.
for (cc in c(45, 50, 55, 65, 70))
  print(rd_ll(hs_df$mortHS, hs_df$povrate60, cc, 8))

# Placebo OUTCOMES: things Head Start should NOT move.
rd_ll(hs_df$mort_injury, hs_df$povrate60, C0, 8)   # child injuries (unrelated cause)
rd_ll(hs_df$mort_adult,  hs_df$povrate60, C0, 8)   # adults 25+ (wrong age group)
rd_ll(hs_df$mort_preHS,  hs_df$povrate60, C0, 8)   # 1959-64 (before the programme)
Falsification: placebo cut-offs (top) and placebo outcomes (bottom), h = 8
Check theta se t
Placebo cut-offs (should be ~0)
cut-off = 45 -1.596 1.762 -0.91
cut-off = 50 0.942 1.068 0.88
cut-off = 55 0.279 1.006 0.28
cut-off = 65 0.631 0.968 0.65
cut-off = 70 -0.208 2.049 -0.10
Placebo outcomes (should be ~0)
TRUE outcome (HS mortality) -2.249 1.076 -2.09
placebo: child injuries 0.183 3.484 0.05
placebo: adults 25+ 2.038 6.274 0.32
placebo: pre-1965 -3.433 1.857 -1.85
Code
import pandas as pd, numpy as np, statsmodels.api as sm
hs = pd.read_csv("../data/rdd-headstart.csv"); C0 = 59.1984
def rd_ll(y, x, c, h=8):
    xx = x - c; D = (x >= c).astype(float); k = np.maximum(0, 1 - np.abs(xx)/h); use = k > 0
    Z = np.column_stack([np.ones_like(xx), xx, D, xx*D])[use]
    f = sm.WLS(y[use], Z, weights=k[use]).fit(cov_type="HC1")
    return f.params[2], f.bse[2]

print("Placebo cut-offs (theta, se):")
Placebo cut-offs (theta, se):
Code
for cc in [45, 50, 55, 65, 70]:
    th, se = rd_ll(hs.mortHS.values, hs.povrate60.values, cc); print(f"  c={cc}: {th:7.3f} ({se:.3f})")
  c=45:  -1.596 (1.762)
  c=50:   0.942 (1.068)
  c=55:   0.279 (1.006)
  c=65:   0.631 (0.968)
  c=70:  -0.208 (2.049)
Code
print("\nPlacebo outcomes at true cut-off (theta, se):")

Placebo outcomes at true cut-off (theta, se):
Code
for v, lab in [("mortHS","TRUE"),("mort_injury","injuries"),("mort_adult","adults25+"),("mort_preHS","pre-1965")]:
    th, se = rd_ll(hs[v].values, hs.povrate60.values, C0); print(f"  {lab:9s}: {th:7.3f} ({se:.3f})")
  TRUE     :  -2.249 (1.076)
  injuries :   0.183 (3.484)
  adults25+:   2.038 (6.274)
  pre-1965 :  -3.433 (1.857)
Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
program rdll                       // theta at cutoff `2' for outcome `1', h=8
    gen x  = povrate60 - `2'
    gen D  = povrate60 >= `2'
    gen xD = x*D
    gen k  = max(0, 1 - abs(x)/8)
    quietly regress `1' x D xD [aweight=k] if k>0, vce(robust)
    di as txt "  theta = " as res %7.3f _b[D] as txt "  (se " as res %5.3f _se[D] as txt ")"
    drop x D xD k
end
di as txt "Placebo cut-offs (true outcome):"
foreach cc of numlist 45 50 55 65 70 { di as txt "c=`cc'" _c; rdll mortHS `cc' }
di as txt "Placebo outcomes at true cut-off 59.1984:"
foreach y in mortHS mort_injury mort_adult mort_preHS { di as txt "`y'" _c; rdll `y' 59.1984 }
  • Placebo cut-offs (45, 50, 55, 65, 70): all \(|t| < 1.1\)no discontinuity at fake thresholds. The effect is specific to the real policy cut-off. ✓
  • Placebo outcomes: child injuries (\(-0.16\), se \(3.4\)) and adult mortality (\(+2.1\), se \(5.9\)) show no effect — Head Start targets young children’s disease mortality, not injuries or adults. ✓
  • Pre-1965 mortality (\(-3.7\), se \(1.9\)) is only borderline — a mild caveat worth probing (Exercise), but far weaker than the post-period effect.

Tip

Placebo outcomes are the most persuasive falsification: they show the discontinuity appears only where the mechanism predicts it should.

Local Randomization View

Pick a window \(W = [c-w,\, c+w]\) so narrow that, within it, which side of \(c\) a unit lands on is as good as a coin flip. Then:

  • Window selection — grow \(w\) until predetermined covariates stop being balanced; the largest balanced window is \(W\) (rdwinselect).
  • Randomization inference — under Fisher’s sharp null \(H_0: Y_{1i}=Y_{0i}\ \forall i\), permute the treatment labels within \(W\) and recompute a statistic \(T\) (e.g. difference in means). The exact \(p\)-value is \[p = \frac{1}{|\Omega|}\sum_{\pi\in\Omega} \mathbb{1}\{|T(\pi)| \ge |T_{\text{obs}}|\}.\]
  • No bandwidth-bias correction, no asymptotics — valid in finite samples.
Code
library(rdlocrand)
hs <- read.csv("../data/rdd-headstart.csv")
hs <- hs[complete.cases(hs[, c("povrate60","mortHS")]), ]
R  <- hs$povrate60 - 59.1984            # running variable centred at the cut-off

# Fisher randomization inference in a fixed window (balance-selected: +/- 2)
out <- rdrandinf(hs$mortHS, R, wl = -2, wr = 2, reps = 5000, seed = 14159)
window            = [-2, 2]
N in window       = 124
diff in means     = -2.5206
randomization p   = 0.0140
Code
import pandas as pd, numpy as np
# No rdlocrand port — the permutation test is three lines of numpy.
hs = pd.read_csv("../data/rdd-headstart.csv").dropna(subset=["povrate60","mortHS"])
R  = hs.povrate60.values - 59.1984
Y  = hs.mortHS.values
m  = np.abs(R) <= 2                       # fixed window +/- 2
Yw, Dw = Y[m], (R[m] >= 0).astype(int)
obs = Yw[Dw == 1].mean() - Yw[Dw == 0].mean()

rng, reps = np.random.default_rng(14159), 5000
count = 0
for _ in range(reps):
    p = rng.permutation(Dw)               # reassign treatment within the window
    if abs(Yw[p == 1].mean() - Yw[p == 0].mean()) >= abs(obs):
        count += 1
print(f"window          = [-2, 2]")
window          = [-2, 2]
Code
print(f"N in window     = {m.sum()}")
N in window     = 124
Code
print(f"diff in means   = {obs:.4f}")
diff in means   = -2.5206
Code
print(f"randomization p = {(count + 1) / (reps + 1):.4f}")
randomization p = 0.0140
Code
import delimited "../data/rdd-headstart.csv", case(preserve) clear
gen R = povrate60 - 59.1984
rdrandinf mortHS R, wl(-2) wr(2) reps(5000) seed(14159)
diff in means RI \(p\)-value \(N\)
R \(-2.521\) \(0.014\) \(124\)
Python \(-2.521\) \(0.014\) \(124\)
Stata \(-2.521\) \(0.008\)\(0.021\) \(124\)
  • The difference in means (\(-2.52\)) and window count (\(N=124\)) are identical across languages (deterministic).
  • The randomization \(p\)-value is simulation-based, so implementations agree up to Monte-Carlo error (Stata reports a finite-sample \(0.008\) and a large-sample \(0.021\); R/Python’s permutation gives \(0.014\)). All say the same thing: significant at the \(5\%\) level.
  • Reassuringly, the local-randomization estimate (\(-2.52\)) matches the continuity-based rdrobust estimate (\(-2.41\)) — two very different justifications, one conclusion.

Discrete Running Variables

Standard RDD asymptotics assume \(X\) is continuous with positive density at \(c\), so the bandwidth can shrink to zero with more data. Many real running variables are discrete: age in years, integer test scores, birth weights, calendar dates.

  • With finitely many mass points, there is a smallest gap around \(c\) — you cannot let \(h\to 0\). More data means more observations per point, not points closer to \(c\).
  • So a residual approximation (specification) error never vanishes, and the usual local-polynomial standard errors are too small ⇒ conventional CIs undercover.
  • The fewer the distinct values near \(c\), the worse the continuity-based approximation.

Note

Our Head Start score (povrate60) is effectively continuous (many decimals), so this issue does not bite here — but it is central whenever the score is coarse.

  •  Cluster by the score (Lee & Card 2008). Model the fit error as random and cluster SEs on the value of \(X\). Historically standard — but see the caveat popup: it carries no coverage guarantee.
  •  Honest CIs (Kolesár & Rothe 2018). Assume only a bound on the second derivative of \(m(\cdot)\) and construct intervals with guaranteed coverage uniformly over that class. Implemented in RDHonest. This is the modern recommendation.
  •  Local randomization. With very few mass points, drop asymptotics entirely and use finite-sample Fisher inference in a window (previous slide) — naturally suited to a discrete score.
  • Report the number of distinct values of \(X\) near \(c\) — it tells the audience how discrete the design really is.
  • In rdrobust, the masspoints option ("adjust" / "check") detects repeated values and adjusts the bandwidth and variance accordingly.
  • Prefer honest CIs or local randomization when the score is coarse; treat naive local-polynomial CIs as optimistic.
  • Citations: Lee & Card (2008), J. Econometrics · Kolesár & Rothe (2018), AER.

Specification Curve

Code
hs <- read.csv("../data/rdd-headstart.csv")
# Re-estimate the jump across a grid of bandwidth x polynomial order.
# A credible RDD result is stable across reasonable specifications.
grid <- expand.grid(h = c(4, 6, 8, 10, 12, 15), p = c(1, 2))
est  <- data.frame()
for (i in seq_len(nrow(grid))) {
  o <- rdrobust(hs$mortHS, hs$povrate60, c = 59.1984,
                h = grid$h[i], p = grid$p[i])
  est <- rbind(est, data.frame(h = grid$h[i], p = grid$p[i],
               tau = o$coef[1], lo = o$ci[3, 1], hi = o$ci[3, 2]))
}
# plot tau with robust CI, ordered by point estimate ...

Every one of the 12 specifications gives a negative estimate, and most exclude zero — the finding that Head Start reduced child mortality is not an artefact of one bandwidth or polynomial choice.

  • A specification curve plots the estimate (with CI) under many defensible analytic choices at once — here bandwidth \(\times\) polynomial order.
  • Look for: the sign holding, the bulk of CIs on one side of zero, and no single knob flipping the conclusion.
  • Red flags: the estimate swinging across the zero line as \(h\) or \(p\) changes, or significance resting on one lucky cell.
  • It complements — does not replace — reporting your pre-specified primary specification (MSE-optimal \(h\), local linear) up front.

Warning

A specification curve is a transparency device, not a licence to cherry-pick. Decide the grid before looking at the estimates, and show the whole curve.

Part VI — Fuzzy RDD

Fuzzy RDD — Theory

In many designs the cut-off changes the probability of treatment, not treatment itself — imperfect compliance. Define

\[p(x) = \Pr(D = 1 \mid X = x),\qquad \text{FRD applies when } p(c{+}) \ne p(c{-}).\]

For Head Start, the real take-up was 80% above vs 43% below the cut-off — a textbook fuzzy design (we treated it as sharp intent-to-treat earlier).

Theorem 21.2 (Hahn, Todd & Van der Klaauw 2001). If \(m_0, m_1\) are continuous at \(c\), \(p(x)\) is discontinuous at \(c\), and \(D\) is independent of \(\theta\) for \(X\) near \(c\), then \[\boxed{\;\bar\theta = \dfrac{m(c{+}) - m(c{-})}{p(c{+}) - p(c{-})}\;}\]

The conditional ATE is the ratio of two jumps: the jump in the outcome over the jump in the treatment probability.

The estimator

\[\hat{\bar\theta} = \frac{\hat m(c{+}) - \hat m(c{-})}{\hat p(c{+}) - \hat p(c{-})}\]

is exactly a Wald / 2SLS estimator: regress \(Y\) on \(D\), instrumenting \(D\) with the assignment \(Z = \mathbb{1}\{X \ge c\}\), locally near \(c\) (controlling for the running variable). Numerator = reduced form; denominator = first stage. Sharp RDD is the special case \(p(c{+}) - p(c{-}) = 1\).

Fuzzy RDD is an instrumental-variables design at the cut-off, with instrument \(Z = \mathbb{1}\{X\ge c\}\). It rests on the standard IV triad, stated locally at \(c\):

  •  Relevance (strong first stage). \(p(c{+}) \ne p(c{-})\) — the cut-off actually shifts treatment probability. A tiny jump ⇒ weak instrument (see popup).
  •  Exclusion restriction. Crossing \(c\) affects \(Y\) only through \(D\) — no other channel switches at the threshold (this is the “no coincident treatments” condition wearing an IV hat).
  •  Monotonicity (no defiers). Crossing \(c\) pushes everyone’s treatment the same direction: units are compliers or always-/never-takers, but nobody is treated because they fell below \(c\).

Under monotonicity the fuzzy estimand is not the full-population ATE but a Local Average Treatment Effect — the effect for compliers at the cut-off: the units whose treatment status is flipped by crossing \(c\). \[\bar\theta_{\text{FRD}} = \mathbb{E}\!\left[\,Y_1 - Y_0 \;\middle|\; \text{complier},\, X = c\,\right].\]

Always-takers and never-takers cancel from both jumps, so they contribute nothing to \(\bar\theta_{\text{FRD}}\) — the estimand is doubly local: local in \(X\) (at \(c\)) and local in the population (compliers only).

Code — Fuzzy RDD

Code
library(AER)
fuzzy_df <- read.csv("../data/rdd-fuzzy.csv"); h <- 20
sub <- abs(fuzzy_df$X) <= h                       # window around c = 0

# First stage: jump in treatment probability p(c+)-p(c-)
fs <- lm(D ~ X + Z, data = fuzzy_df, subset = sub)
# Reduced form: jump in the outcome m(c+)-m(c-)
rf <- lm(Y ~ X + Z, data = fuzzy_df, subset = sub)
cat(sprintf("first stage jump_D = %.3f | reduced form jump_Y = %.3f | Wald = %.3f\n",
            coef(fs)["Z"], coef(rf)["Z"], coef(rf)["Z"]/coef(fs)["Z"]))

# 2SLS: Y on D, instrument D with Z, control for the running variable X
iv <- ivreg(Y ~ D + X | Z + X, data = fuzzy_df, subset = sub)
coeftest(iv, vcov = vcovHC(iv, type = "HC1"))     # coef on D = fuzzy RDD estimate
Fuzzy RDD — simulated data (true LATE = 3.0), window |X| <= 20
  first stage:  p(c+)-p(c-) = 0.421   (take-up 0.40 -> 0.80)
  reduced form: m(c+)-m(c-) = 1.230
  2SLS LATE = 2.922   se = 0.174   t = 16.82   n = 1995
Code
import pandas as pd, numpy as np, statsmodels.api as sm
from linearmodels.iv import IV2SLS

fz = pd.read_csv("../data/rdd-fuzzy.csv"); fz = fz[np.abs(fz.X) <= 20].copy()
fs = sm.OLS(fz.D, sm.add_constant(fz[["X", "Z"]])).fit()
rf = sm.OLS(fz.Y, sm.add_constant(fz[["X", "Z"]])).fit()
print(f"first stage jump_D = {fs.params['Z']:.3f} | reduced form jump_Y = {rf.params['Z']:.3f} "
      f"| Wald = {rf.params['Z']/fs.params['Z']:.3f}")
first stage jump_D = 0.421 | reduced form jump_Y = 1.230 | Wald = 2.922
Code
# 2SLS: dependent Y; exog [const, X]; endog D; instrument Z
iv = IV2SLS(fz.Y, sm.add_constant(fz[["X"]]), fz[["D"]], fz[["Z"]]).fit(cov_type="robust")
print(f"2SLS LATE = {iv.params['D']:.3f}   se = {iv.std_errors['D']:.3f}   n = {len(fz)}")
2SLS LATE = 2.922   se = 0.174   n = 1995
Code
import delimited "../data/rdd-fuzzy.csv", case(preserve) clear
* First stage and reduced form (jumps at the cut-off c = 0)
quietly regress D X Z if abs(X)<=20
scalar jD = _b[Z]
quietly regress Y X Z if abs(X)<=20
scalar jY = _b[Z]
di as txt "first stage jump_D = " as res %5.3f jD ///
   as txt " | reduced form jump_Y = " as res %5.3f jY ///
   as txt " | Wald = " as res %5.3f jY/jD

* Fuzzy RDD via 2SLS: Y on D (instrumented by Z), controlling for X
ivregress 2sls Y X (D = Z) if abs(X)<=20, vce(robust)
di as txt "2SLS LATE = " as res _b[D] as txt "   se = " as res _se[D]
Quantity Value Meaning
First stage \(p(c{+})-p(c{-})\) \(\approx 0.40\) take-up jumps \(0.40 \to 0.80\)strong
Reduced form \(m(c{+})-m(c{-})\) \(\approx 1.17\) ITT jump in the outcome
2SLS LATE \(\approx 2.92\) ratio ≈ recovers the true LATE of \(3.0\)
  • The point estimate is identical across R, Python and Stata (\(2.92\)); robust SEs agree to \(\approx 0.01\).
  • The Wald ratio scales up the intent-to-treat jump by the compliance rate: dividing \(1.17\) by \(0.40\) recovers the effect on compliers.
  • Sharp RDD is the limit where the first stage \(= 1\); there, reduced form \(=\) LATE.

Warning

Check the first stage first. A tiny denominator ⇒ weak-instrument problems (huge SEs, bias). Report \(p(c{+})-p(c{-})\) and its \(t\)-statistic alongside the LATE.

Part VII — Beyond Sharp & Fuzzy: Design Variants

The RDD Family — Design Variants

RDD is a family of designs organised along two independent axes.

Axis 1 — what is discontinuous & how compliance works:

  • Sharp — treatment is a deterministic step; jump in the level.
  • Fuzzy — probability of treatment jumps; Wald / 2SLS with \(Z=\mathbb{1}\{X\ge c\}\).
  • Kink (RKD) — the slope of the assignment/benefit changes; jump in the first derivative.
  • Fuzzy kink — slope of the treatment probability changes.

Axis 2 — the geometry of the score:

  • Single vs multi-cutoff — several thresholds (e.g. grade boundaries) pooled or compared.
  • Geographic RDD — the “score” is distance to a border; two-dimensional running variable.
  • RD in time (RDiT) — the running variable is time; treatment switches on at a date.
Variant Estimand Canonical example Key reference
Sharp Jump in level Head Start (our data) Hahn–Todd–Van der Klaauw (2001)
Fuzzy LATE (compliers) Class size (Maimonides) Angrist–Lavy (1999)
Kink (RKD) Jump in slope Unemployment-insurance benefit Card–Lee–Pei–Weber (2015)
Geographic Jump across border School districts, policy borders Keele–Titiunik (2015)
RD in time Jump at a date Driving bans, tolls Hausman–Rapson (2018)
  • Kink — much higher variance than a level jump: derivatives are hard to estimate, so you need lots of data and a small bandwidth.
  • Geographic — the border may coincide with other discontinuities (school quality, prices); the 2-D score complicates bandwidth choice.
  • RD in timeno sorting is possible (you cannot manipulate the date), but autocorrelation and coincident time shocks are serious threats.
  • Multi-cutoff — effects can be heterogeneous across cutoffs; pooling assumes they are comparable.

Kink RDD — Theory

Many rules make the level of a benefit continuous but bend its slope at a threshold — think an unemployment-insurance formula that replaces a higher fraction of earnings below a cap, or a tax schedule that changes marginal rate at a bracket. Then the treatment effect shows up as a change in slope, not a jump.

\[\bar\theta_{\text{kink}} = m'(c^{+}) - m'(c^{-}) = \lim_{x\downarrow c} m'(x) - \lim_{x\uparrow c} m'(x).\]

  • The level \(m(x)\) is continuous at \(c\); its first derivative is not.
  • Identification (Card–Lee–Pei–Weber 2015): under smoothness of the potential-outcome derivatives, the slope change at \(c\) identifies the marginal effect of the policy variable.

Estimate with a local polynomial and read off the first derivative on each side:

  • rdrobust(..., deriv = 1) returns \(m'(c^{+}) - m'(c^{-})\) directly, with MSE-optimal bandwidth and robust bias-corrected inference.
  • Polynomial order. Use \(p \ge \text{deriv}\). For a piecewise-linear schedule (UI, tax brackets are literally piecewise linear) local linear (\(p=1\)) is exact; with genuine curvature raise to \(p=2\).
  • Cost. Derivative estimation inflates variance — expect wider CIs than a sharp level jump on the same sample.

Warning

A kink is not a jump. If you run a level RDD on kinked data you will (correctly) find no jump and wrongly conclude “no effect.” Match the estimator (deriv = 1) to the design.

Code — Kink RDD

Code
kink <- read.csv("../data/rdd-kink.csv")
# Benefit schedule bends at c = 0: level continuous, slope jumps 0.5 -> 2.0.
# deriv = 1 estimates the slope change; p = 1 is exact for a piecewise-linear rule.
out <- rdrobust(kink$Y, kink$X, c = 0, deriv = 1, p = 1)
summary(out)
kink (slope jump)  = 1.445
conventional se    = 0.158
robust  p-value    = 6.27e-08
MSE-optimal h       = 0.310
true kink           = 1.500
Code
import pandas as pd, warnings
warnings.filterwarnings("ignore")
from rdrobust import rdrobust

kink = pd.read_csv("../data/rdd-kink.csv")
out  = rdrobust(y=kink.Y, x=kink.X, c=0, deriv=1, p=1)
print(f"kink (slope jump)  = {float(out.coef.iloc[0,0]):.3f}")
kink (slope jump)  = 1.445
Code
print(f"conventional se    = {float(out.se.iloc[0,0]):.3f}")
conventional se    = 0.158
Code
print(f"robust  p-value    = {float(out.pv.iloc[2,0]):.3g}")
robust  p-value    = 6.27e-08
Code
print(f"MSE-optimal h       = {float(out.bws.iloc[0,0]):.3f}")
MSE-optimal h       = 0.310
Code
print(f"true kink           = 1.500")
true kink           = 1.500
Code
import delimited "../data/rdd-kink.csv", case(preserve) clear
rdrobust y x, c(0) deriv(1) p(1)    // deriv(1) = slope jump; p(1) local linear

All three languages return the same slope jump on the shared rdd-kink.csv:

Estimate True
R \(1.445\) \(1.5\)
Python \(1.445\) \(1.5\)
Stata \(1.445\) \(1.5\)
  • The estimate sits close to the true kink of \(1.5\) and is highly significant (\(p \approx 10^{-8}\)).
  • Because the schedule is piecewise linear, local linear (\(p=1\)) recovers each slope exactly — deriv = 1 differences them.
  • Try deriv = 1, p = 2 on this data: the extra curvature term only adds variance here (no curvature to fit), so the CI widens — a concrete reminder to match \(p\) to the design.

Geographic RDD

When treatment is assigned by which side of a border a unit sits on, the “running variable” is a geographic location \((\text{lat}, \text{lon})\) — a two-dimensional score, and the cut-off is an entire boundary curve, not a point.

  •  Distance-to-border reduction. Collapse the 2-D score to a signed distance to the nearest boundary point and run a standard 1-D RDD on it. Simple, but throws away where along the border each unit is.
  •  Boundary-point (local) approach. Estimate a separate effect at each point of the border and average — respects that the treatment contrast may vary along the boundary (Keele & Titiunik 2015).
  •  Bandwidth is a distance band hugging the border; only units within that band enter.

Geographic designs face everything a 1-D RDD does, plus:

  • Compound treatments — many things change at a border (see popup); the estimate may bundle them.
  • Spatial sorting — households can relocate across the line to obtain treatment, a manipulation channel invisible to a naive density test.
  • Spatial correlation — nearby units are dependent; use spatially-robust / clustered SEs.
  • Sparse borders — few observations in the distance band ⇒ wide CIs.

Note

Software: rdrobust still does the 1-D estimation once you have the distance score; specialised spatial tooling (e.g. distance construction, border-point sampling) lives in GIS + the RD packages’ geographic vignettes. Reference: Keele & Titiunik (2015), Political Analysis.

Multi-Cutoff & Multi-Score RDD

Some policies use a different threshold for different groups — income limits that vary with family size, grade boundaries that differ by cohort. Each unit \(i\) faces its own cut-off \(c_i\).

  •  Normalize and pool. Recentre every unit on its own cut-off, \(\tilde X_i = X_i - c_i\), and run one RDD at \(\tilde X = 0\). Efficient, but assumes the effect is common across cut-offs.
  •  Cut-off-by-cut-off. Estimate a separate effect at each \(c_j\) and inspect heterogeneity — the pooled number can mask very different local effects (Cattaneo, Titiunik & Vazquez-Bare 2016).
  • Report both: the pooled estimate for power, the per-cut-off estimates for transparency.

Sometimes treatment needs two conditions — pass a math score and a reading score; qualify on income and assets. The boundary is now a frontier in score-space.

  •  Frontier RDD. Estimate the treatment effect along the qualification frontier, which can differ at each point (e.g. the effect for those marginal on math vs marginal on reading).
  •  Distance-to-frontier reduction. As with geographic RDD, collapse the multi-score problem to a signed distance to the nearest frontier point and run a 1-D RDD.
  • Each unit contributes at the frontier point closest to it; effects can be heterogeneous across the frontier.

Tip

The unifying trick behind geographic, multi-cutoff and multi-score designs is the same: reduce a complicated assignment boundary to a one-dimensional signed distance, then apply the standard local-polynomial machinery — while remembering the effect may vary along that boundary. Reference: Cattaneo, Titiunik & Vazquez-Bare (2016), Stata Journal.

A Second Sharp RDD — Senate Incumbency

The whole workflow, in a different policy domain. Cattaneo–Frandsen–Titiunik US Senate data: does winning a Senate seat (Democratic margin \(\ge 0\) in the prior election) raise the party’s vote share in the next election? Running variable margin, outcome vote, cut-off \(0\).

Code
sen <- read.csv("../data/rdd-senate.csv")
rdplot(sen$vote, sen$margin, c = 0,          # binned means + polynomial fit
       x.label = "Dem margin, prior election",
       y.label = "Dem vote share, next election",
       title   = "Senate incumbency advantage")
rdrobust(sen$vote, sen$margin, c = 0)        # MSE-optimal, robust bias-corrected

incumbency advantage = +7.414 vote-share points
robust 95% CI        = [4.09, 10.92]   p = 1.6e-05
MSE-optimal h        = 17.75   Nh = 360 / 323
Code
import pandas as pd, warnings
warnings.filterwarnings("ignore")
import matplotlib.pyplot as plt
from rdrobust import rdrobust, rdplot

sen = pd.read_csv("../data/rdd-senate.csv")
rdplot(y=sen.vote, x=sen.margin, c=0,
       x_label="Dem margin, prior election",
       y_label="Dem vote share, next election",
       title="Senate incumbency advantage")
Call: rdplot
Number of Observations:                  1297
Kernel:                               Uniform
Polynomial Order Est. (p):                  4

                                Left      Right
------------------------------------------------
Number of Observations           595        702
Number of Effective Obs          595        702
Bandwidth poly. fit (h)        100.0      100.0
Number of bins scale               1          1
Bins Selected                     15         35
Average Bin Length             6.667      2.857
Median Bin Length              6.667      2.857
IMSE-optimal bins                8.0        9.0
Mimicking Variance bins         15.0       35.0

Relative to IMSE-optimal:
Implied scale                  1.875      3.889
WIMSE variance weight          0.132      0.017
WIMSE bias weight              0.868      0.983
Code
plt.show()

Code
o = rdrobust(y=sen.vote, x=sen.margin, c=0)
print(f"incumbency advantage = {float(o.coef.iloc[0,0]):+.3f} vote-share points")
incumbency advantage = +7.414 vote-share points
Code
print(f"MSE-optimal h        = {float(o.bws.iloc[0,0]):.2f}")
MSE-optimal h        = 17.75
Code
import delimited "../data/rdd-senate.csv", case(preserve) clear
rdplot vote margin, c(0) graph_options(title("Senate incumbency advantage") ///
       xtitle("Dem margin, prior election") ytitle("Dem vote share, next election"))
graph export "../plots/rdd_senate_stata.png", replace width(1600)
rdrobust vote margin, c(0)

incumbency advantage \(h\)
R \(+7.414\) \(17.75\)
Python \(+7.414\) \(17.75\)
Stata \(+7.414\) \(17.75\)
  • Winning nearly-tied elections raises the party’s next vote share by about 7.4 points — a large, precisely-estimated incumbency advantage (\(p < 10^{-4}\)).
  • Identical across R, Python and Stata on the shared rdd-senate.csv, exactly as for Head Start.
  • Same recipe, new domain: rdplot for the picture, rdrobust for the MSE-optimal robust estimate.

Summary Comparison

Feature Sharp Fuzzy Kink (RKD) Local randomization
What jumps at \(c\) level of \(D\) \(\Pr(D{=}1)\) slope of \(m\) (window: as-if random)
Estimand \(m(c{+})-m(c{-})\) \(\dfrac{m(c{+})-m(c{-})}{p(c{+})-p(c{-})}\) \(m'(c{+})-m'(c{-})\) effect in window \(W\)
Estimator LL each side Wald / 2SLS LL, deriv=1 diff-in-means in \(W\)
Inference robust bias-corrected robust + first stage robust (higher var) Fisher randomization (exact)
Key extra risk weak first stage very noisy window too wide
Software rdrobust rdrobust/ivreg rdrobust deriv(1) rdlocrand / rdrandinf
Estimator \(\hat{\bar\theta}\) se note
Local linear (triangular, \(h=8\)) \(-2.25\) \(1.08\) identical in R/Py/Stata
Simple (rectangular, \(h=8\)) \(-2.20\) \(1.06\) reproduces Hansen (21.5)
With covariates \(-2.17\) \(1.06\) barely moves (estimand invariant)
rdrobust (MSE-opt, robust) \(-2.41\) 95% CI \([-5.46,-0.10]\)
Local randomization (\(\pm 2\)) \(-2.52\) RI \(p \approx 0.014\)
Senate incumbency (rdrobust) \(+7.41\) second dataset, \(p<10^{-4}\)

Decision Framework

flowchart TD
    d1{"Threshold rule<br/>on an observable X?"}
    d3{"Density of X<br/>continuous at c?"}
    d4{"Running variable<br/>continuous or discrete?"}
    d2{"Compliance at c?"}
    dk{"Level jump<br/>or slope change?"}
    b1["Choose bandwidth h<br/>(MSE-opt) + poly order p<br/>(local linear); add<br/>predetermined covs"]
    o1["Sharp RDD<br/>LL: m(c+) - m(c-)"]
    o2["Fuzzy RDD<br/>Wald / 2SLS"]
    ok["Kink RDD<br/>rdrobust deriv=1"]
    lr["Local randomization<br/>rdrandinf in window W"]
    v1["Validity: density test,<br/>placebo cut-offs/outcomes,<br/>covariate balance, spec curve"]
    done(["Report robust CI"])
    s1["RDD not applicable<br/>(use IV / DiD / matching)"]
    s2["Manipulation!<br/>design invalid"]

    d1 -->|Yes| d3
    d1 -->|No| s1
    d3 -->|No| s2
    d3 -->|Yes| d4
    d4 -->|Continuous| d2
    d4 -->|Discrete / few mass points| lr
    d2 -->|Perfect| dk
    d2 -->|Imperfect| o2
    dk -->|Level| o1
    dk -->|Slope| ok
    o1 --> b1
    o2 --> b1
    ok --> b1
    b1 --> v1
    lr --> v1
    v1 --> done

    classDef decision fill:#dbeafe,stroke:#185FA5,color:#0c2461,font-weight:bold
    classDef process  fill:#e0f2fe,stroke:#185FA5,color:#1e40af,font-weight:bold
    classDef outcome  fill:#d1fae5,stroke:#1D9E75,color:#064e3b,font-weight:bold
    classDef sideout  fill:#fef3c7,stroke:#D85A30,color:#92400e,font-weight:bold

    class d1,d2,d3,d4,dk decision
    class b1 process
    class o1,o2,ok,lr,done outcome
    class s1,s2 sideout

Mistake Consequence Fix
High-order global polynomial Spurious jumps, unstable CI Local linear + small \(h\)
vcovHC on a weighted lm SE badly understated Hand sandwich / rdrobust
Reading effect off binned dots Wrong magnitude Read the LL fit at \(c\)
Ignoring the first stage (fuzzy) Weak-IV bias Report \(p(c{+})-p(c{-})\)
No manipulation test Undetected sorting McCrary density test
Level estimator on kink data “No effect” — misses the slope jump rdrobust deriv(1)
Adjusting for a covariate that jumps at \(c\) Bad control — biases \(\hat{\bar\theta}\) Use only predetermined, balanced covs
Extrapolating \(\bar\theta\) away from \(c\) Wrong population Keep it local

Tip

RDD buys credibility with a local parameter. Guard continuity (density + placebos), keep the window small, and always report robustness to the bandwidth.

Exercises — Estimation

  1. Bandwidth sensitivity. Re-estimate the Head Start sharp RDD (local linear, triangular) for \(h = 4, 6, 8, 12, 20\). Tabulate \(\hat{\bar\theta}\) and its SE. At which \(h\) does the estimate lose significance, and why?

  2. Kernel choice. Compare the triangular-kernel estimate to the rectangular (simple OLS) estimate at \(h = 8\). Explain the small difference in terms of efficiency at the boundary.

  3. Reproduce Hansen eq. (21.5). Using rdd-headstart.csv, run the windowed OLS \(Y = \beta_0 + \beta_1 X + \beta_3(X-c)D + \theta D + e\) on \(|X-c|\le 8\). Confirm \(\hat\theta = -2.20\) (se \(1.06\), \(n = 482\)) in all three languages.

  4. Covariates. Add pctblack and pcturban. Does \(\hat{\bar\theta}\) change? Explain why Theorem 21.1 predicts this, and what the covariates buy you.

  5. rdrobust. Run rdrobust(mortHS, povrate60, c = 59.1984). Report the MSE-optimal bandwidth and the robust (bias-corrected) 95% CI. Contrast conventional vs robust inference.

  6. Fuzzy vs sharp. In rdd-fuzzy.csv, estimate the sharp (reduced-form) jump in \(Y\) and the fuzzy 2SLS LATE. Verify LATE \(=\) (jump in \(Y\)) / (jump in \(D\)). Which one answers “the effect of treatment”?

Exercises — Validity

  1. Placebo cut-offs. Estimate the Head Start effect at fake cut-offs \(c \in \{45, 50, 55, 65, 70\}\). Should any be significant? Interpret a spurious rejection if you find one.

  2. Placebo outcomes. Estimate the discontinuity in mort_injury (child injuries) and mort_adult (adults 25+). Why are these good falsification tests for the Head Start mechanism?

  3. Pre-programme check. Estimate the discontinuity in mort_preHS (1959-64, before Head Start). It is borderline. Discuss what a pre-period effect would imply for the design, and one way to probe it further.

  4. Manipulation. Build a fine histogram of povrate60 with a bin edge exactly at \(59.1984\). Is there bunching? Why is manipulation a priori implausible for this running variable?

  5. First-stage strength (fuzzy). In rdd-fuzzy.csv, report \(p(c{+})-p(c{-})\) and its \(t\)-statistic. Shrink the true take-up jump toward \(0\) (re-simulate) and watch the 2SLS SE explode — the weak-instrument problem.

  6. Bias vs variance. Explain, using the bias and variance formulas, why shrinking \(h\) below the MSE-optimal value can produce “more honest” (if less precise) inference.

  7. Second dataset — Senate. Using rdd-senate.csv, reproduce the incumbency advantage (\(\approx +7.4\) points) with rdrobust in R, Python and Stata. Then run rddensity (margin manipulation) and a covariate-balance check on demvoteshlag1 — does the design pass?

  8. Local randomization. On Head Start, use rdwinselect (with pctblack, pcturban) to pick a balanced window, then rdrandinf for the finite-sample \(p\)-value. Compare it to the continuity-based rdrobust result and explain why they need different assumptions.

  9. Specification curve. Re-estimate the Head Start jump across the \(6\times 2\) grid of bandwidth \(\times\) polynomial order. Plot the sorted estimates with robust CIs. Does the sign of the effect ever flip?

Further Reading

  •  Hansen (2022)Econometrics, Princeton. Ch. 21: Regression Discontinuity (this lecture).
  •  Cattaneo, Idrobo & Titiunik (2020, 2024)A Practical Introduction to RD Designs, Vols. I & II. Cambridge Elements.
  •  Lee & Lemieux (2010). “Regression Discontinuity Designs in Economics.” JEL 48(2). doi:10.1257/jel.48.2.281
  •  Imbens & Lemieux (2008). “RD Designs: A Guide to Practice.” J. Econometrics 142(2). doi:10.1016/j.jeconom.2007.05.001

Thank You

Athanassios Stavrakoudis Applied Informatics and Computational Economics Lab Department of Economics University of Ioannina Greece

astavrak@uoi.gr · linkedin.com/in/astavrakoudis