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.

Sharp vs Fuzzy

  • Sharp RDD: treatment is a deterministic function of the running variable. Score \(\geq\) 80 → treated, period. Everyone complies.
  • Fuzzy RDD: the cutoff changes the probability of treatment, but not perfectly. Some people above the cutoff don’t take treatment, some below do. This is essentially an IV problem — the cutoff is the instrument.

Why fuzzy RDD is IV. Map it directly: the instrument \(Z\) is “above the cutoff” (yes/no), the endogenous variable \(D\) is actually receiving treatment, and \(Y\) is your outcome. The cutoff satisfies the IV assumptions — it’s relevant (shifts treatment probability), excludable (scoring 80 vs 79 doesn’t directly change outcomes), and monotone (crossing the cutoff doesn’t make anyone less likely to be treated). The fuzzy RDD estimate is the Wald estimator: \(\tau = \frac{\text{jump in outcome at cutoff}}{\text{jump in treatment probability at cutoff}}\) — reduced form divided by first stage. Like IV, this estimates a LATE for compliers (people whose treatment status actually changes at the cutoff).

Assumptions

  1. 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
  2. No manipulation: units cannot precisely control their running variable to sort across the cutoff. If they can, the “as good as random” logic breaks.
  3. Local randomization: units just above and just below the cutoff are comparable on all observed and unobserved characteristics
  4. 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: &plusmn;", 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.