Skip to content

Commit 9ff21a2

Browse files
igerberclaude
andcommitted
Fix RC IF normalization scaling: M1 uses n_all denominator, PS M2 uses sum not mean
- _outcome_regression_rc: M1 denominator changed from sum_w_D to n_all (matching R colMeans convention); inf_cont_2 / sum_w_D then gives correct single normalization by mean_w_D * n_all = sum_w_D - _ipw_estimation_rc: PS M2 uses np.sum/n_all instead of np.mean (which divided by n_ct/n_cs instead of n_all, under-scaling the correction) - _doubly_robust_rc: PS M2 already correct (np.sum/n_all), no change Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 53cfd5d commit 9ff21a2

1 file changed

Lines changed: 23 additions & 12 deletions

File tree

diff_diff/staggered.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2914,12 +2914,13 @@ def _outcome_regression_rc(
29142914
bread_t = _safe_inv(X_ct_int.T @ (W_ct[:, None] * X_ct_int))
29152915
bread_s = _safe_inv(X_cs_int.T @ (W_cs[:, None] * X_cs_int))
29162916

2917-
# M1 = colMeans(w_D * X) / mean(w_D) — gradient, same X basis for both
2918-
# In R: M1 = colMeans(w.cont * out.x) / mean(w.cont)
2919-
# w.cont = i.weights * D across all obs; for treated obs, out.x is their X
2917+
# R: M1 = colMeans(w.cont * out.x) = sum(w_D * X) / n_all
2918+
# The final control IF divides by mean_w_D = sum_w_D / n_all (once).
2919+
# In our split convention phi = psi / n_all, the estimation effect is
2920+
# asy_lin_rep @ M1 / sum_w_D (where M1 uses n_all denominator).
29202921
M1 = (
29212922
np.sum(w_D_gt[:, None] * X_gt_int, axis=0) + np.sum(w_D_gs[:, None] * X_gs_int, axis=0)
2922-
) / sum_w_D
2923+
) / n_all
29232924

29242925
# asy_lin_rep_ols_t: nonzero only for control-t obs
29252926
# = W_i * (1-D_i) * 1{T=t} * (y_i - X_i'*beta_t) * X_i @ bread_t
@@ -2975,6 +2976,7 @@ def _ipw_estimation_rc(
29752976
n_gs = len(y_gs)
29762977
n_ct = len(y_ct)
29772978
n_cs = len(y_cs)
2979+
n_all = n_gt + n_gs + n_ct + n_cs
29782980

29792981
# Pool treated and control for propensity score
29802982
X_all = np.vstack([X_gt, X_gs, X_ct, X_cs])
@@ -3067,20 +3069,29 @@ def _ipw_estimation_rc(
30673069
asy_lin_rep_ps = score_ps @ H_ps_inv # (n_all, p+1)
30683070

30693071
# M2: gradient of IPW ATT w.r.t. PS parameters
3070-
# Control IPW residuals from both periods
3072+
# R: M2 = colMeans(w_ipw * (y-mu)/mean_w * X) over ALL n obs (zeros for treated).
3073+
# In our split convention phi = psi/n_all, so M2_rc = R's M2 / n_all.
3074+
# R's M2 = sum(w_ct_norm * (y-mu) * X_ct) [the mean_w normalization cancels].
3075+
# So M2_rc = sum(...) / n_all. Old code used np.mean → sum/n_ct (wrong).
30713076
ipw_resid_ct = w_ct_norm * (y_ct - mu_ct_ipw)
30723077
ipw_resid_cs = w_cs_norm * (y_cs - mu_cs_ipw)
30733078
# Zero for treated observations
30743079
M2_rc = np.zeros(X_all_int.shape[1])
3075-
# Control-t contribution
3076-
M2_rc += np.mean(
3077-
ipw_resid_ct[:, None] * X_all_int[n_gt + n_gs : n_gt + n_gs + n_ct],
3078-
axis=0,
3080+
# Control-t contribution: sum / n_all (NOT np.mean which divides by n_ct)
3081+
M2_rc += (
3082+
np.sum(
3083+
ipw_resid_ct[:, None] * X_all_int[n_gt + n_gs : n_gt + n_gs + n_ct],
3084+
axis=0,
3085+
)
3086+
/ n_all
30793087
)
30803088
# Control-s contribution (opposite sign -- base period)
3081-
M2_rc -= np.mean(
3082-
ipw_resid_cs[:, None] * X_all_int[n_gt + n_gs + n_ct :],
3083-
axis=0,
3089+
M2_rc -= (
3090+
np.sum(
3091+
ipw_resid_cs[:, None] * X_all_int[n_gt + n_gs + n_ct :],
3092+
axis=0,
3093+
)
3094+
/ n_all
30843095
)
30853096

30863097
inf_all = inf_all + asy_lin_rep_ps @ M2_rc

0 commit comments

Comments
 (0)