Regression Discontinuity
The idea
Some treatments are assigned by a cutoff rule: you get a scholarship if your test score is above 80, you qualify for a program if your income is below $30k, you win an election if your vote share exceeds 50%.
Right around the cutoff, people just above and just below are nearly identical — they differ by a fraction of a point. The cutoff creates near-random assignment in a narrow window. That’s the identifying variation.
\[\tau_{RDD} = \lim_{x \downarrow c} E[Y \mid X = x] - \lim_{x \uparrow c} E[Y \mid X = x]\]
The treatment effect is the jump in the outcome at the cutoff. If the outcome is smooth everywhere except at the cutoff, that discontinuity is the causal effect.
Assumptions
- Continuity: the potential outcomes \(E[Y(0) \mid X = x]\) and \(E[Y(1) \mid X = x]\) are continuous at the cutoff — no other jump happens at exactly that point
- No manipulation: units cannot precisely control their running variable to sort across the cutoff. If they can, the “as good as random” logic breaks.
- Local randomization: units just above and just below the cutoff are comparable on all observed and unobserved characteristics
- Correct functional form: the relationship between the running variable and outcome is correctly modeled within the bandwidth window
When does RDD fail?
- Manipulation: if people can precisely control their score to land just above or below the cutoff, the “as good as random” logic breaks. Check for bunching at the cutoff (McCrary test).
- Wrong functional form: if you fit a straight line but the true relationship is curved, you might mistake curvature for a jump. Use local polynomials and check sensitivity to bandwidth.
#| standalone: true
#| viewerHeight: 650
library(shiny)
ui <- fluidPage(
tags$head(tags$style(HTML("
.stats-box {
background: #f0f4f8; border-radius: 6px; padding: 14px;
margin-top: 12px; font-size: 14px; line-height: 1.9;
}
.stats-box b { color: #2c3e50; }
.good { color: #27ae60; font-weight: bold; }
.bad { color: #e74c3c; font-weight: bold; }
"))),
sidebarLayout(
sidebarPanel(
width = 3,
sliderInput("n", "Sample size:",
min = 200, max = 2000, value = 500, step = 100),
sliderInput("tau", "True treatment effect:",
min = 0, max = 5, value = 2, step = 0.25),
sliderInput("sigma", "Noise (SD):",
min = 0.5, max = 4, value = 1.5, step = 0.25),
sliderInput("bw", "Bandwidth around cutoff:",
min = 0.05, max = 0.5, value = 0.2, step = 0.05),
selectInput("curve", "True relationship:",
choices = c("Linear" = "linear",
"Quadratic" = "quad",
"Flat" = "flat")),
actionButton("go", "New draw", class = "btn-primary", width = "100%"),
uiOutput("results")
),
mainPanel(
width = 9,
plotOutput("rdd_plot", height = "480px")
)
)
)
server <- function(input, output, session) {
dat <- reactive({
input$go
n <- input$n
tau <- input$tau
sigma <- input$sigma
bw <- input$bw
curve <- input$curve
# Running variable: uniform on [0, 1], cutoff at 0.5
x <- runif(n)
cutoff <- 0.5
treat <- as.numeric(x >= cutoff)
# Potential outcome (smooth function of x)
if (curve == "linear") {
mu <- 2 + 1.5 * x
} else if (curve == "quad") {
mu <- 2 + 3 * (x - 0.5)^2
} else {
mu <- rep(3, n)
}
y <- mu + tau * treat + rnorm(n, sd = sigma)
# Local linear regression within bandwidth
in_bw <- abs(x - cutoff) <= bw
x_bw <- x[in_bw]
y_bw <- y[in_bw]
t_bw <- treat[in_bw]
# Separate regressions left and right
left <- x_bw < cutoff
right <- x_bw >= cutoff
if (sum(left) > 2 && sum(right) > 2) {
fit_l <- lm(y_bw[left] ~ x_bw[left])
fit_r <- lm(y_bw[right] ~ x_bw[right])
# Predicted values at cutoff
pred_l <- coef(fit_l)[1] + coef(fit_l)[2] * cutoff
pred_r <- coef(fit_r)[1] + coef(fit_r)[2] * cutoff
rdd_est <- pred_r - pred_l
# Fitted lines for plotting
xseq_l <- seq(cutoff - bw, cutoff, length.out = 100)
xseq_r <- seq(cutoff, cutoff + bw, length.out = 100)
yhat_l <- coef(fit_l)[1] + coef(fit_l)[2] * xseq_l
yhat_r <- coef(fit_r)[1] + coef(fit_r)[2] * xseq_r
} else {
rdd_est <- NA
xseq_l <- xseq_r <- yhat_l <- yhat_r <- NULL
pred_l <- pred_r <- NA
}
list(x = x, y = y, treat = treat, cutoff = cutoff,
bw = bw, in_bw = in_bw, rdd_est = rdd_est,
tau = tau, sigma = sigma,
xseq_l = xseq_l, xseq_r = xseq_r,
yhat_l = yhat_l, yhat_r = yhat_r,
pred_l = pred_l, pred_r = pred_r)
})
output$rdd_plot <- renderPlot({
d <- dat()
par(mar = c(4.5, 4.5, 3, 1))
# Color by treatment
cols <- ifelse(d$treat == 1, adjustcolor("#3498db", 0.25),
adjustcolor("#e74c3c", 0.25))
# Dim points outside bandwidth
cols[!d$in_bw] <- adjustcolor("gray70", 0.15)
plot(d$x, d$y, pch = 16, cex = 0.5, col = cols,
xlab = "Running variable (X)", ylab = "Outcome (Y)",
main = "Regression Discontinuity Design")
# Cutoff line
abline(v = d$cutoff, lty = 2, col = "gray40", lwd = 1.5)
# Bandwidth shading
rect(d$cutoff - d$bw, par("usr")[3],
d$cutoff + d$bw, par("usr")[4],
col = adjustcolor("#f39c12", 0.08), border = NA)
# Local linear fits
if (!is.null(d$xseq_l)) {
lines(d$xseq_l, d$yhat_l, col = "#e74c3c", lwd = 3)
lines(d$xseq_r, d$yhat_r, col = "#3498db", lwd = 3)
# Jump arrow
arrows(d$cutoff + 0.01, d$pred_l, d$cutoff + 0.01, d$pred_r,
code = 3, lwd = 2.5, col = "#27ae60", length = 0.1)
text(d$cutoff + 0.03,
(d$pred_l + d$pred_r) / 2,
paste0("Jump = ", round(d$rdd_est, 2)),
col = "#27ae60", cex = 0.95, adj = 0, font = 2)
}
text(d$cutoff, par("usr")[4] * 0.98, "Cutoff",
col = "gray40", cex = 0.8, pos = 4)
legend("topleft", bty = "n", cex = 0.85,
legend = c("Control (below cutoff)", "Treated (above cutoff)",
"Estimation window"),
pch = c(16, 16, 15),
col = c("#e74c3c", "#3498db", adjustcolor("#f39c12", 0.3)))
})
output$results <- renderUI({
d <- dat()
if (is.na(d$rdd_est)) {
return(tags$div(class = "stats-box",
HTML("<b>Not enough observations in bandwidth.</b> Widen it.")))
}
bias <- d$rdd_est - d$tau
tags$div(class = "stats-box",
HTML(paste0(
"<b>True effect:</b> ", d$tau, "<br>",
"<b>RDD estimate:</b> ", round(d$rdd_est, 3), "<br>",
"<b>Bias:</b> <span class='", ifelse(abs(bias) < 0.3, "good", "bad"), "'>",
round(bias, 3), "</span><br>",
"<hr style='margin:6px 0'>",
"<small>Bandwidth: ±", d$bw, " around cutoff</small>"
))
)
})
}
shinyApp(ui, server)
Things to try
- Default settings: the jump at the cutoff is clear. The RDD estimate is close to the true effect.
- Narrow the bandwidth (0.05): fewer observations, noisier estimate — but less bias from misspecification. Widen it (0.5): more data, more precise, but you’re using observations far from the cutoff.
- Switch to quadratic: with a narrow bandwidth, the local linear fit still works fine. But widen the bandwidth with a quadratic DGP and the estimate gets biased — the line can’t capture the curve.
- Set true effect = 0: there should be no visible jump. If you see one, it’s noise (or a bad bandwidth choice).
- Crank up noise: the jump gets harder to see, and you need more data or a wider bandwidth to detect it. This is the bias-variance tradeoff of bandwidth selection.
The bandwidth tradeoff
Bandwidth is the central tuning parameter in RDD:
| Narrow bandwidth | Wide bandwidth | |
|---|---|---|
| Bias | Low — only using near-identical units | High — far-away units may differ systematically |
| Variance | High — few observations | Low — more data |
| Risk | Noisy, imprecise estimate | Precise but potentially wrong |
In practice, researchers use data-driven bandwidth selectors (Imbens & Kalyanaraman 2012, Calonico, Cattaneo & Titiunik 2014) that optimize this tradeoff. You should always check that your results are robust to different bandwidth choices.
In Stata
* Install rdrobust (Cattaneo, Idrobo & Titiunik)
* ssc install rdrobust
* Sharp RDD with optimal bandwidth
rdrobust outcome running_var, c(0)
* RDD plot
rdplot outcome running_var, c(0)
* Check for manipulation of the running variable
rddensity running_var, c(0)
* Fuzzy RDD (treatment is probabilistic at cutoff)
rdrobust outcome running_var, c(0) fuzzy(treatment)rdrobust handles bandwidth selection, bias correction, and robust standard errors automatically. Always show the rdplot — if you can’t see the jump visually, be skeptical of the estimate.
Did you know?
The RDD idea goes back to Thistlethwaite & Campbell (1960), who studied the effect of merit scholarships on career outcomes using the score cutoff. It was largely ignored for decades until economists rediscovered it in the late 1990s.
Lee (2008) used RDD to study the incumbency advantage in US elections — candidates who barely win vs barely lose. This paper helped establish RDD as a workhorse method in political economy.
Cattaneo, Idrobo & Titiunik wrote an excellent practical guide: A Practical Introduction to Regression Discontinuity Designs. It’s freely available and covers everything from basic plots to formal inference.