Skip to content

Commit 6489270

Browse files
fix(rd): 3 critical + 3 high-priority bugs from code review
Critical fixes: - CER bandwidth formula in rdrobust.py: exponent was -1/((2p+3)(2p+4)), corrected to -1/((2p+3)(2p+5)) matching CCF 2020 Theorem 1 and bandwidth.py - locrand._polynomial_residuals: crash when p=0 with covariates due to empty np.column_stack. Now handles p=0 gracefully. - rd2d._signed_distance_to_vertical: sign was based on treatment status T (wrong for fuzzy designs), now based on position X1 relative to cutoff High-priority fixes: - rdrobust weights parameter: was silently ignored, now raises NotImplementedError with clear message - extrapolate Wald heterogeneity test: was using arithmetic mean (biased when SEs differ), now uses inverse-variance weighted mean (GLS-optimal) 79 RD tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b66f312 commit 6489270

4 files changed

Lines changed: 38 additions & 28 deletions

File tree

src/statspai/rd/extrapolate.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -740,9 +740,11 @@ def rd_multi_extrapolate(
740740
# Step 3: Heterogeneity test (Wald test: are all tau_j equal?)
741741
# ------------------------------------------------------------------
742742
if k >= 2:
743-
tau_mean = np.mean(tau_vals)
744-
# Wald statistic: sum of (tau_j - tau_bar)^2 / se_j^2
745-
wald_stat = np.sum((tau_vals - tau_mean) ** 2 / (se_vals ** 2))
743+
# Inverse-variance weighted mean (GLS-optimal for Wald test)
744+
iv_weights = 1.0 / (se_vals ** 2)
745+
tau_wbar = np.sum(iv_weights * tau_vals) / iv_weights.sum()
746+
# Wald statistic: sum of (tau_j - tau_wbar)^2 / se_j^2
747+
wald_stat = np.sum(iv_weights * (tau_vals - tau_wbar) ** 2)
746748
wald_df = k - 1
747749
wald_pval = 1.0 - sp_stats.chi2.cdf(wald_stat, wald_df)
748750
heterogeneity_test = {

src/statspai/rd/locrand.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,22 @@ def _polynomial_residuals(y: np.ndarray, x: np.ndarray, p: int,
7070
covs: Optional[np.ndarray] = None) -> np.ndarray:
7171
"""Partial out polynomial in X (and optional covariates) from Y."""
7272
n = len(y)
73-
# Build design: polynomial terms 1, x, x^2, ..., x^p
74-
X_design = np.column_stack([x ** k for k in range(1, p + 1)])
75-
if covs is not None and covs.shape[1] > 0:
76-
X_design = np.column_stack([X_design, covs])
77-
# Add intercept
73+
parts = []
74+
# Polynomial terms x^1, ..., x^p (if p > 0)
75+
if p > 0:
76+
parts.extend([x ** k for k in range(1, p + 1)])
77+
# Covariates
78+
if covs is not None:
79+
if covs.ndim == 1:
80+
parts.append(covs.reshape(-1, 1))
81+
elif covs.shape[1] > 0:
82+
parts.append(covs)
83+
84+
if len(parts) == 0:
85+
return y - np.mean(y)
86+
87+
X_design = np.column_stack(parts)
7888
X_design = np.column_stack([np.ones(n), X_design])
79-
# OLS residuals
8089
beta, _, _, _ = np.linalg.lstsq(X_design, y, rcond=None)
8190
return y - X_design @ beta
8291

src/statspai/rd/rd2d.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -618,12 +618,12 @@ def _signed_distance_to_vertical(
618618
"""
619619
Signed distance to vertical boundary x1 = cutoff.
620620
621-
For vertical boundary, distance is simply x1 - cutoff. Sign is
622-
positive for treated, negative for control.
621+
For vertical boundary, distance is simply x1 - cutoff.
622+
Sign is determined by position relative to cutoff (not treatment
623+
status), so it works correctly for both sharp and fuzzy designs.
624+
Positive = right of cutoff, negative = left.
623625
"""
624-
raw_dist = X1 - cutoff
625-
sign = np.where(T == 1, 1.0, -1.0)
626-
return np.abs(raw_dist) * sign
626+
return X1 - cutoff
627627

628628

629629
def _signed_distance_to_curve(

src/statspai/rd/rdrobust.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,11 @@ def rdrobust(
152152
Y, X_c, D, Z = _parse_data(data, y, x, c, fuzzy, covs)
153153

154154
# --- Observation-level weights ---
155-
W_obs = None
156155
if weights is not None:
157-
if weights not in data.columns:
158-
raise ValueError(f"Weights column '{weights}' not found in data")
159-
W_obs = data[weights].values.astype(float)
160-
# Apply same NaN filtering as _parse_data (valid mask)
161-
# W_obs is aligned after _parse_data filters
156+
raise NotImplementedError(
157+
"Observation-level weights are not yet supported in rdrobust. "
158+
"This parameter is reserved for a future release."
159+
)
162160

163161
# --- Donut hole: exclude observations within donut radius ---
164162
if donut > 0:
@@ -979,18 +977,19 @@ def _local_poly_wls(
979977
# ======================================================================
980978

981979
def _cer_factor(n: int, p: int = 1) -> float:
982-
"""CER shrinkage factor: h_CER = h_MSE * cer_factor.
980+
"""CER shrinkage factor: h_CER = h_MSE * n^{-1/((2p+3)(2p+5))}.
983981
982+
From Calonico, Cattaneo, Farrell (2020, Econometrics Journal, Theorem 1).
984983
The CER-optimal bandwidth shrinks the MSE-optimal bandwidth to
985984
improve coverage error of robust bias-corrected CIs.
986-
From Calonico, Cattaneo, Farrell (2020, Econometrics Journal):
987-
h_CER ~ h_MSE * n^{-1/(2p+3)} / n^{-1/(2p+4+epsilon)}
988-
For p=1: h_CER ~ h_MSE * n^{-1/20} approximately.
985+
986+
For p=1: exponent = -1/35 ≈ -0.02857.
987+
For p=2: exponent = -1/63 ≈ -0.01587.
989988
"""
990-
# Rate difference: MSE rate is n^{-1/(2p+3)}, CER rate is n^{-1/(2p+4)}
991-
mse_rate = 1.0 / (2 * p + 3)
992-
cer_rate = 1.0 / (2 * p + 4)
993-
return float(n ** (cer_rate - mse_rate))
989+
if n <= 1:
990+
return 1.0
991+
rate_exponent = 1.0 / ((2 * p + 3) * (2 * p + 5))
992+
return float(n ** (-rate_exponent))
994993

995994

996995
def _select_bandwidth(

0 commit comments

Comments
 (0)