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.
!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
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.
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.
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.
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:
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})")
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
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.