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

10.2 Optimal Bandwidths and Robust Inference with rdrobust

about 2 hoursLocal government: firm revenue at distress-score thresholds; close elections; Uruguay's PANES
Lesson 10.2 · Module 10
Optimal Bandwidths and Robust Inference with rdrobust
Video coming soon. The written lesson and the Colab notebook are ready below.
Colab notebook (.ipynb)
By the end of this lesson you can
  • ✓Use rdrobust for MSE-optimal bandwidths and bias-corrected robust confidence intervals
  • ✓Estimate the incumbency advantage in US House elections (Lee, Moretti and Butler)
  • ✓Run the standard robustness checks: bandwidth sensitivity, placebo cutoffs, covariate continuity
  • ✓Present an RD analysis in a reproducible table
Lesson plan (120 minutes)
Written lesson

Why bandwidth choice needs theory

In Lesson 10.1, different bandwidths gave different answers. Picking the one you like is a form of p-hacking. Calonico, Cattaneo and Titiunik (CCT) provide:

  • an MSE-optimal bandwidth that balances bias and variance automatically, and
  • robust bias-corrected confidence intervals, which remain valid even though the MSE-optimal bandwidth leaves some bias in the point estimate.

The rdrobust package (Python, R and Stata) implements both. Report its "Robust" row for inference.

Python · Colab cell
!pip install -q numpy pandas rdrobust matplotlib causaldata
import numpy as np, pandas as pd
from rdrobust import rdrobust, rdbwselect, rdplot
import matplotlib.pyplot as plt
rng = np.random.default_rng(2026)

rdrobust on the grant data

Python · Colab cell
N, CUTOFF = 3_000, 50
score = rng.uniform(20, 80, N)
grant = (score >= CUTOFF).astype(int)
revenue = 100_000 - 500 * score + 4 * (score - 50) ** 2 + 12_000 * grant + rng.normal(0, 6_000, N)

rd = rdrobust(y=revenue, x=score, c=CUTOFF)
print(rd)

Read the output: the bandwidth h chosen on each side, the number of effective observations, the conventional estimate, and the robust 95% confidence interval.

Close elections: does the winning party change how members vote?

Lee, Moretti and Butler (2004) asked whether voters choose policies or merely elect politicians who then do as they please. In close House races, whether the Democrat narrowly won or lost is as good as random. Their outcome score is the ADA liberal voting score of the district's representative in the next Congress; the running variable is the Democratic vote share in the previous election.

Python · Colab cell
from causaldata import close_elections_lmb
lmb = close_elections_lmb.load_pandas().data.dropna(subset=["score", "lagdemvoteshare"])
lmb["margin"] = lmb.lagdemvoteshare - 0.5          # > 0: the Democrat won last time

rd_lmb = rdrobust(y=lmb.score, x=lmb.margin, c=0)
print(f"Jump in ADA score: {rd_lmb.coef.iloc[0, 0]:.1f} points | robust 95% CI "
      f"[{rd_lmb.ci.iloc[2, 0]:.1f}, {rd_lmb.ci.iloc[2, 1]:.1f}] | h = {rd_lmb.bws.iloc[0, 0]:.3f}")

rdplot(y=lmb.score, x=lmb.margin, c=0, binselect="es", nbins=[30, 30],
       title="Close elections: ADA score of the next representative", x_label="Democratic margin", y_label="ADA score")
plt.show()

A narrow Democratic win raises the next representative's liberal voting score by roughly 18–20 points. Voters in nearly identical districts get very different representation depending on who barely won.

Robustness checks

A credible RD paper shows that the result does not hinge on choices, and that nothing else jumps at the cutoff.

1. Bandwidth sensitivity: half and double the optimal bandwidth.

Python · Colab cell
h_opt = rd_lmb.bws.iloc[0, 0]
for mult in [0.5, 0.75, 1, 1.5, 2]:
    r = rdrobust(y=lmb.score, x=lmb.margin, c=0, h=h_opt * mult)
    print(f"h = {h_opt * mult:.3f}: {r.coef.iloc[0, 0]:6.2f}  robust CI [{r.ci.iloc[2, 0]:6.2f}, {r.ci.iloc[2, 1]:6.2f}]")

2. Placebo cutoffs: there should be no jump at thresholds where nothing changes. Use one side of the sample only, so the real jump does not contaminate the test.

Python · Colab cell
for fake in [-0.2, -0.1, 0.1, 0.2]:
    side = lmb[lmb.margin < 0] if fake < 0 else lmb[lmb.margin > 0]
    r = rdrobust(y=side.score, x=side.margin, c=fake)
    print(f"placebo cutoff {fake:+.1f}: {r.coef.iloc[0, 0]:6.2f} (robust p = {r.pv.iloc[2, 0]:.3f})")

3. Covariate continuity: pre-determined variables must not jump. Here, the previous representative's party (lagdemocrat) is determined by the previous election, so by construction it does jump; a fairer placebo is the outcome from before the election, if available. We illustrate the mechanics with the year of the election, which should be smooth:

Python · Colab cell
r = rdrobust(y=lmb.year, x=lmb.margin, c=0)
print(f"Jump in election year at the cutoff: {r.coef.iloc[0, 0]:.2f} (robust p = {r.pv.iloc[2, 0]:.3f})")
Exercise

Re-estimate the incumbency jump with a local quadratic (p=2) and with a uniform kernel (kernel="uniform"). Put all results (baseline, p = 2, uniform, half and double bandwidth) in one table with robust CIs. Would any of them change the conclusion?

Show a solution
Python · Colab cell
specs = {"baseline": {}, "p=2": {"p": 2}, "uniform kernel": {"kernel": "uniform"},
         "h/2": {"h": h_opt / 2}, "2h": {"h": h_opt * 2}}
rows = []
for name, kw in specs.items():
    r = rdrobust(y=lmb.score, x=lmb.margin, c=0, **kw)
    rows.append((name, r.coef.iloc[0, 0], r.ci.iloc[2, 0], r.ci.iloc[2, 1], r.bws.iloc[0, 0]))
print(pd.DataFrame(rows, columns=["spec", "estimate", "robust CI low", "robust CI high", "h"]).round(3))

Checklist and recap

  • I use rdrobust's MSE-optimal bandwidth and report robust bias-corrected intervals.
  • I can replicate the close-elections RD and show its plot.
  • I run bandwidth, placebo-cutoff and covariate-continuity checks.