Skip to content

Commit 6789e37

Browse files
igerberclaude
andcommitted
Match R exactly: H = X'WX (no /n), asy_rep = score @ inv(H) (no /n)
Drop the H/n, asy_rep/n convention from all three panel PS correction sites. Now uses R's direct formulation: H = X'WX, asy_rep = score @ inv(H), M2 = colMeans (sum over control terms / n_all). The /n factors were algebraically canceling but confused the static reviewer. Also: add duplicate unit-ID check for panel=False. Update REGISTRY note to reflect the simpler H = X'WX convention. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent df21c7e commit 6789e37

2 files changed

Lines changed: 25 additions & 15 deletions

File tree

diff_diff/staggered.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,6 +1336,14 @@ def fit(
13361336
stacklevel=2,
13371337
)
13381338

1339+
# Validate unique unit IDs for panel=False
1340+
if not self.panel:
1341+
if data[unit].duplicated().any():
1342+
raise ValueError(
1343+
"panel=False requires unique unit IDs (one observation per unit). "
1344+
"Found duplicate unit IDs. If your data is a panel, use panel=True."
1345+
)
1346+
13391347
# Normalize empty covariates list to None
13401348
if covariates is not None and len(covariates) == 0:
13411349
covariates = None
@@ -2048,27 +2056,29 @@ def _ipw_estimation(
20482056
X_all_int = np.column_stack([np.ones(n_t + n_c), X_all])
20492057
pscore_all = np.concatenate([pscore_treated, pscore_control])
20502058

2051-
# PS IF correction — matches R's std_ipw_did_panel convention:
2052-
# H = X'WX / n, asy_lin_rep = score @ solve(H) / n, M2 = colMeans
2059+
# PS IF correction — R convention: H = X'WX, M2 = colMeans
20532060
n_all_panel = n_t + n_c
20542061
W_ps = pscore_all * (1 - pscore_all)
20552062
if sw_all is not None:
20562063
W_ps = W_ps * sw_all
2057-
H = X_all_int.T @ (W_ps[:, None] * X_all_int) / n_all_panel
2064+
H = X_all_int.T @ (W_ps[:, None] * X_all_int)
20582065
H_inv = _safe_inv(H)
20592066

20602067
D_all = np.concatenate([np.ones(n_t), np.zeros(n_c)])
20612068
score_ps = (D_all - pscore_all)[:, None] * X_all_int
20622069
if sw_all is not None:
20632070
score_ps = score_ps * sw_all[:, None]
2064-
asy_lin_rep_ps = score_ps @ H_inv / n_all_panel
2071+
asy_lin_rep_ps = score_ps @ H_inv
20652072

20662073
att_control_weighted = np.sum(weights_control_norm * control_change)
2067-
# R: colMeans over control rows (n_c denominator, not n_all)
2068-
M2 = np.mean(
2069-
(weights_control_norm * (control_change - att_control_weighted))[:, None]
2070-
* X_all_int[n_t:],
2071-
axis=0,
2074+
# R: colMeans over ALL n obs (treated rows contribute zero)
2075+
M2 = (
2076+
np.sum(
2077+
(weights_control_norm * (control_change - att_control_weighted))[:, None]
2078+
* X_all_int[n_t:],
2079+
axis=0,
2080+
)
2081+
/ n_all_panel
20722082
)
20732083

20742084
inf_func = inf_func + asy_lin_rep_ps @ M2
@@ -2306,19 +2316,19 @@ def _doubly_robust(
23062316
)
23072317
pscore_all = np.concatenate([pscore_treated_clipped, pscore_control])
23082318

2309-
# PS IF correction — R convention: H/n, asy_rep/n, colMeans
2319+
# PS IF correction — R convention: H = X'WX, M2 = colMeans
23102320
n_all_panel = n_t + n_c
23112321
W_ps = pscore_all * (1 - pscore_all)
23122322
if sw_all is not None:
23132323
W_ps = W_ps * sw_all
2314-
H_ps = X_all_int.T @ (W_ps[:, None] * X_all_int) / n_all_panel
2324+
H_ps = X_all_int.T @ (W_ps[:, None] * X_all_int)
23152325
H_ps_inv = _safe_inv(H_ps)
23162326

23172327
D_all = np.concatenate([np.ones(n_t), np.zeros(n_c)])
23182328
score_ps = (D_all - pscore_all)[:, None] * X_all_int
23192329
if sw_all is not None:
23202330
score_ps = score_ps * sw_all[:, None]
2321-
asy_lin_rep_ps = score_ps @ H_ps_inv / n_all_panel
2331+
asy_lin_rep_ps = score_ps @ H_ps_inv
23222332

23232333
dr_resid_control = m_control - control_change
23242334
M2_dr = np.mean(
@@ -2375,12 +2385,12 @@ def _doubly_robust(
23752385
pscore_all = np.concatenate([pscore_treated_clipped, pscore_control])
23762386

23772387
W_ps = pscore_all * (1 - pscore_all)
2378-
H_ps = X_all_int.T @ (W_ps[:, None] * X_all_int) / n_all_panel
2388+
H_ps = X_all_int.T @ (W_ps[:, None] * X_all_int)
23792389
H_ps_inv = _safe_inv(H_ps)
23802390

23812391
D_all = np.concatenate([np.ones(n_t), np.zeros(n_c)])
23822392
score_ps = (D_all - pscore_all)[:, None] * X_all_int
2383-
asy_lin_rep_ps = score_ps @ H_ps_inv / n_all_panel
2393+
asy_lin_rep_ps = score_ps @ H_ps_inv
23842394

23852395
dr_resid_control = m_control - control_change
23862396
M2_dr = np.mean(

docs/methodology/REGISTRY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ The multiplier bootstrap uses random weights w_i with E[w]=0 and Var(w)=1:
418418
not-yet-treated cohorts serve as controls for each other (requires ≥2 cohorts)
419419
- **Note:** CallawaySantAnna survey support: weights, strata, PSU, and FPC are all supported for all estimation methods (reg, ipw, dr) with or without covariates. Analytical (`n_bootstrap=0`): aggregated SEs use design-based variance via `compute_survey_if_variance()`. Bootstrap (`n_bootstrap>0`): PSU-level multiplier weights replace analytical SEs for aggregated quantities. IPW and DR with covariates use DRDID panel nuisance IF corrections (Phase 7a: PS IF correction via survey-weighted Hessian/score, OR IF correction via WLS bread and gradient; Sant'Anna & Zhao 2020, Theorem 3.1). Survey weights compose with IPW weights multiplicatively. WIF in aggregation matches R's did::wif() formula. Per-unit survey weights are extracted via `groupby(unit).first()` from the panel-normalized pweight array; on unbalanced panels the pweight normalization (`w * n_obs / sum(w)`) preserves relative unit weights since all IF/WIF formulas use weight ratios (`sw_i / sum(sw)`) where the normalization constant cancels. Scale-invariance tests pass on both balanced and unbalanced panels.
420420
- **Note (deviation from R):** Panel DR control augmentation is normalized by treated mass (`sw_t_sum` or `n_t`) rather than control IPW mass (`sum(w_cont)`). R's `DRDID::drdid_panel` uses `mean(w.cont)` as the control normalizer. Both are consistent asymptotically (under correct model specification, `E[w_cont] = E[D]` so the normalizers converge), but they differ in finite samples when IPW reweighting doesn't perfectly balance. The treated-mass normalization is simpler and matches the `did::att_gt` convention where ATT is defined per treated unit. Aligning to `DRDID::drdid_panel`'s exact `w.cont` normalization is deferred.
421-
- **Note:** Panel and RC nuisance IF corrections use `H = X'WX/n`, `asy_lin_rep = score @ solve(H) / n`, `M2 = colMeans(control_terms)` (mean over control rows, n_c denominator). This matches R's DRDID convention: `solve(crossprod(X)/n)` for the Hessian, `colMeans(...)` for gradients.
421+
- **Note:** Panel and RC nuisance IF corrections use R's DRDID convention: `H = X'WX`, `asy_lin_rep = score @ solve(H)`, `M2 = colMeans(w_cont * stuff * X)` where colMeans is over all n observations (treated rows contribute zero, so denominator is n not n_c). This directly matches R's `solve(crossprod(X * sqrt(W)))` and `colMeans(...)` formulation.
422422
- **Note (deviation from R):** CallawaySantAnna survey reg+covariates per-cell SE uses a conservative plug-in IF based on WLS residuals. The treated IF is `inf_treated_i = (sw_i/sum(sw_treated)) * (resid_i - ATT)` (normalized by treated weight sum, matching unweighted `(resid-ATT)/n_t`). The control IF is `inf_control_i = -(sw_i/sum(sw_control)) * wls_resid_i` (normalized by control weight sum, matching unweighted `-resid/n_c`). SE is computed as `sqrt(sum(sw_t_norm * (resid_t - ATT)^2) + sum(sw_c_norm * resid_c^2))`, the weighted analogue of the unweighted `sqrt(var_t/n_t + var_c/n_c)`. This omits the semiparametrically efficient nuisance correction from DRDID's `reg_did_panel` — WLS residuals are orthogonal to the weighted design matrix by construction, so the first-order IF term is asymptotically valid but may be conservative. SEs pass weight-scale-invariance tests. The efficient DRDID correction is deferred to future work.
423423
- **Note (deviation from R):** Per-cell ATT(g,t) SEs under survey weights use influence-function-based variance (matching R's `did::att_gt` analytical SE path) rather than full Taylor-series linearization. When strata/PSU/FPC are present, analytical aggregated SEs (`n_bootstrap=0`) use `compute_survey_if_variance()` on the combined IF/WIF; bootstrap aggregated SEs (`n_bootstrap>0`) use PSU-level multiplier weights.
424424

0 commit comments

Comments
 (0)