Skip to content

Commit 0266d59

Browse files
igerberclaude
andcommitted
Fix CI review R10: within-group-constant strata/PSU + sanitize SE helper
- P1 #1/#2: Add _validate_group_constant_strata_psu() helper and call it from fit() after the weight_type/replicate-weights checks. The dCDH IF expansion psi_i = U[g] * (w_i / W_g) treats each group as the effective sampling unit; when strata or PSU vary within group it silently spreads horizon-specific IF mass across observations in different PSUs, contaminating the stratified-PSU variance. Walk back the overstated claim at the old line 669 comment to match. Within- group-varying weights remain supported. - P1 #3: _survey_se_from_group_if now filters zero-weight rows before np.unique/np.bincount so NaN / non-comparable group IDs on excluded subpopulation rows cannot crash SE factorization. psi stays full- length with zeros in excluded positions to preserve alignment with resolved.strata / resolved.psu inside compute_survey_if_variance. - REGISTRY.md line 652 Note updated: explicitly states the within-group-constant strata/PSU requirement and the within-group-varying weights support. - Tests: new TestSurveyWithinGroupValidation class (4 tests — rejects varying PSU, rejects varying strata, accepts varying weights, and ignores zero-weight rows during the constancy check) plus TestZeroWeightSubpopulation.test_zero_weight_row_with_nan_group_id. All 268 targeted tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d045987 commit 0266d59

3 files changed

Lines changed: 185 additions & 12 deletions

File tree

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -666,9 +666,15 @@ def fit(
666666
"Use strata/PSU/FPC for design-based inference via Taylor "
667667
"Series Linearization."
668668
)
669-
# No group-constant survey validation: the IF expansion
670-
# psi_i = U[g] * (w_i / W_g) handles observation-level
671-
# variation in weights, strata, and PSU within groups.
669+
# Within-group-constant PSU/strata is required: the IF
670+
# expansion psi_i = U[g] * (w_i / W_g) supports within-group
671+
# variation in WEIGHTS (each obs contributes proportionally),
672+
# but PSU and strata must be constant within group — the
673+
# group is treated as the effective sampling unit for the
674+
# Binder stratified-PSU variance formula.
675+
_validate_group_constant_strata_psu(
676+
resolved_survey, data, group, survey_weights,
677+
)
672678

673679
# Design-2 precondition: requires drop_larger_lower=False
674680
if design2 and self.drop_larger_lower:
@@ -4696,6 +4702,61 @@ def _plugin_se(U_centered: np.ndarray, divisor: int) -> float:
46964702
return float(np.sqrt(sigma_hat_sq) / np.sqrt(divisor))
46974703

46984704

4705+
def _validate_group_constant_strata_psu(
4706+
resolved: Any,
4707+
data: pd.DataFrame,
4708+
group_col: str,
4709+
survey_weights: Optional[np.ndarray],
4710+
) -> None:
4711+
"""Reject survey designs where strata or PSU vary within group.
4712+
4713+
The dCDH IF expansion ``psi_i = U[g] * (w_i / W_g)`` treats the
4714+
group as the effective sampling unit for design-based variance.
4715+
When strata or PSU vary within group, the expansion silently spreads
4716+
horizon-specific IF mass onto observations whose survey stratum or
4717+
PSU differs from the rest of the group, contaminating the
4718+
stratified-PSU variance. Reject those designs with a clear error.
4719+
4720+
Zero-weight rows are excluded from the check (subpopulation
4721+
contract — an excluded row with a different stratum/PSU label does
4722+
not actually participate in the variance).
4723+
"""
4724+
if resolved is None:
4725+
return
4726+
pos_mask = np.asarray(survey_weights) > 0
4727+
g_eff = np.asarray(data[group_col].values)[pos_mask]
4728+
if resolved.strata is not None:
4729+
s_eff = np.asarray(resolved.strata)[pos_mask]
4730+
df_s = pd.DataFrame({"g": g_eff, "s": s_eff})
4731+
varying = df_s.groupby("g")["s"].nunique()
4732+
bad = varying[varying > 1]
4733+
if len(bad) > 0:
4734+
raise ValueError(
4735+
f"ChaisemartinDHaultfoeuille survey support requires "
4736+
f"strata to be constant within group, but "
4737+
f"{len(bad)} group(s) have multiple strata "
4738+
f"(examples: {bad.index.tolist()[:5]}). The IF "
4739+
f"expansion psi_i = U[g] * (w_i / W_g) treats the "
4740+
f"group as the effective sampling unit for stratified "
4741+
f"design-based variance."
4742+
)
4743+
if resolved.psu is not None:
4744+
p_eff = np.asarray(resolved.psu)[pos_mask]
4745+
df_p = pd.DataFrame({"g": g_eff, "p": p_eff})
4746+
varying = df_p.groupby("g")["p"].nunique()
4747+
bad = varying[varying > 1]
4748+
if len(bad) > 0:
4749+
raise ValueError(
4750+
f"ChaisemartinDHaultfoeuille survey support requires "
4751+
f"PSU to be constant within group, but "
4752+
f"{len(bad)} group(s) have multiple PSUs "
4753+
f"(examples: {bad.index.tolist()[:5]}). The IF "
4754+
f"expansion psi_i = U[g] * (w_i / W_g) treats the "
4755+
f"group as the effective sampling unit for stratified "
4756+
f"design-based variance."
4757+
)
4758+
4759+
46994760
def _compute_se(
47004761
U_centered: np.ndarray,
47014762
divisor: int,
@@ -4762,20 +4823,37 @@ def _survey_se_from_group_if(
47624823
weights = obs_survey_info["weights"]
47634824
resolved = obs_survey_info["resolved"]
47644825

4826+
# Zero-weight rows are out-of-sample (SurveyDesign.subpopulation()).
4827+
# Skip them before the group-ID factorization so NaN / non-comparable
4828+
# group IDs on excluded rows cannot crash np.unique. psi stays full-
4829+
# length with zeros in excluded positions so the alignment with
4830+
# resolved.strata / resolved.psu inside compute_survey_if_variance
4831+
# is preserved.
4832+
weights_arr = np.asarray(weights, dtype=np.float64)
4833+
pos_mask = weights_arr > 0
4834+
n_obs = len(weights_arr)
4835+
psi = np.zeros(n_obs, dtype=np.float64)
4836+
4837+
if not pos_mask.any():
4838+
return float("nan")
4839+
4840+
gids_eff = np.asarray(group_ids)[pos_mask]
4841+
w_eff = weights_arr[pos_mask]
4842+
47654843
# Build group → U_centered lookup (vectorized via factorization)
47664844
group_to_u = {gid: U_centered[idx] for idx, gid in enumerate(eligible_groups)}
47674845

4768-
# Map group IFs to observation level
4769-
u_obs = np.array([group_to_u.get(gid, 0.0) for gid in group_ids])
4846+
# Map group IFs to observation level (effective sample only)
4847+
u_obs_eff = np.array([group_to_u.get(gid, 0.0) for gid in gids_eff])
47704848

4771-
# Compute per-group weight totals W_g via bincount
4772-
unique_gids, inverse = np.unique(group_ids, return_inverse=True)
4773-
w_totals_per_group = np.bincount(inverse, weights=weights)
4774-
w_obs_total = w_totals_per_group[inverse]
4849+
# Compute per-group weight totals W_g via bincount on effective sample
4850+
unique_gids, inverse = np.unique(gids_eff, return_inverse=True)
4851+
w_totals_per_group = np.bincount(inverse, weights=w_eff)
4852+
w_obs_total_eff = w_totals_per_group[inverse]
47754853

47764854
# Expand to observation level: psi_i = U[g] * (w_i / W_g)
4777-
safe_w = np.where(w_obs_total > 0, w_obs_total, 1.0)
4778-
psi = u_obs * (weights / safe_w)
4855+
safe_w = np.where(w_obs_total_eff > 0, w_obs_total_eff, 1.0)
4856+
psi[pos_mask] = u_obs_eff * (w_eff / safe_w)
47794857

47804858
variance = compute_survey_if_variance(psi, resolved)
47814859
if not np.isfinite(variance) or variance < 0:

docs/methodology/REGISTRY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ Alternative: Multiplier bootstrap clustered at group via the `n_bootstrap` param
649649
- [x] Design-2 switch-in/switch-out descriptive wrapper (Web Appendix Section 1.6)
650650
- [x] HonestDiD (Rambachan-Roth 2023) integration on placebo + event study surface
651651
- [x] Survey design support: pweight with strata/PSU/FPC via Taylor Series Linearization, covering the main ATT surface, covariate adjustment (DID^X), heterogeneity testing, the TWFE diagnostic (fit and standalone `twowayfeweights()` helper), and HonestDiD bounds. Replicate weights and PSU-level bootstrap deferred.
652-
- **Note:** Survey IF expansion (`psi_i = U[g] * (w_i / W_g)`) is a library extension not in the dCDH papers. The paper's plug-in variance assumes iid sampling; the TSL variance accounts for complex survey design by expanding group-level influence functions to observation level proportionally to survey weights, then applying the standard Binder (1983) stratified PSU variance formula.
652+
- **Note:** Survey IF expansion (`psi_i = U[g] * (w_i / W_g)`) is a library extension not in the dCDH papers. The paper's plug-in variance assumes iid sampling; the TSL variance accounts for complex survey design by expanding group-level influence functions to observation level proportionally to survey weights, then applying the standard Binder (1983) stratified PSU variance formula. The expansion treats each group as the effective sampling unit; **strata and PSU must therefore be constant within group** (validated in `fit()` — designs with mixed strata or PSU labels within a single group raise `ValueError`). Within-group-varying **weights** are supported (each observation contributes proportionally).
653653
- **Note (survey + bootstrap fallback):** When `survey_design` and `n_bootstrap > 0` are both active, the multiplier bootstrap uses group-level Rademacher/Mammen/Webb weights rather than PSU-level resampling. A `UserWarning` is emitted from `fit()`. This is conservative when groups are finer than PSUs; a PSU-level survey bootstrap is deferred to a future release. For design-based analytical variance, the TSL path (non-bootstrap) is the recommended contract.
654654

655655
---

tests/test_survey_dcdh.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,26 @@ def test_zero_weight_row_with_nan_outcome(self, base_data):
861861
)
862862
assert np.isfinite(result.overall_att)
863863

864+
def test_zero_weight_row_with_nan_group_id(self, base_data):
865+
"""A zero-weight row with NaN group id must not crash the SE
866+
factorization. SurveyDesign.subpopulation() contract."""
867+
df_ = base_data.copy()
868+
df_["pw"] = 1.0
869+
# Cast group to object to allow NaN without coercion errors
870+
df_["group"] = df_["group"].astype(object)
871+
sample = df_.iloc[0].copy()
872+
sample["group"] = np.nan
873+
sample["pw"] = 0.0
874+
df_ = pd.concat([df_, pd.DataFrame([sample])], ignore_index=True)
875+
sd = SurveyDesign(weights="pw")
876+
# Must succeed — zero-weight row's NaN group id is out-of-sample
877+
result = ChaisemartinDHaultfoeuille(seed=1).fit(
878+
df_, outcome="outcome", group="group",
879+
time="period", treatment="treatment",
880+
survey_design=sd,
881+
)
882+
assert np.isfinite(result.overall_att)
883+
864884
def test_zero_weight_row_with_nan_control(self, base_data):
865885
"""A zero-weight row with NaN in a control column must not abort
866886
the DID^X path, and the covariate cell aggregation must use only
@@ -1010,3 +1030,78 @@ def test_survey_design2_runs(self):
10101030
# switch_in and switch_out mean effects should be finite
10111031
assert np.isfinite(r.design2_effects["switch_in"]["mean_effect"])
10121032
assert np.isfinite(r.design2_effects["switch_out"]["mean_effect"])
1033+
1034+
1035+
# ── Test: Within-group constancy of strata and PSU ──────────────────
1036+
1037+
1038+
class TestSurveyWithinGroupValidation:
1039+
"""Survey designs with strata or PSU varying within a single group
1040+
are rejected because the dCDH IF expansion treats the group as the
1041+
effective sampling unit."""
1042+
1043+
def test_rejects_varying_psu_within_group(self, base_data):
1044+
df_ = base_data.copy()
1045+
df_["pw"] = 1.0
1046+
df_["stratum"] = 0
1047+
# PSU varies within each group (alternates by period)
1048+
df_["psu"] = df_["period"] % 2
1049+
sd = SurveyDesign(weights="pw", strata="stratum", psu="psu")
1050+
with pytest.raises(ValueError, match="PSU to be constant within group"):
1051+
ChaisemartinDHaultfoeuille(seed=1).fit(
1052+
df_, outcome="outcome", group="group",
1053+
time="period", treatment="treatment",
1054+
survey_design=sd,
1055+
)
1056+
1057+
def test_rejects_varying_strata_within_group(self, base_data):
1058+
df_ = base_data.copy()
1059+
df_["pw"] = 1.0
1060+
# Stratum varies within each group
1061+
df_["stratum"] = df_["period"] % 2
1062+
# Give each obs a unique PSU label so the SurveyDesign resolver
1063+
# doesn't reject on cross-stratum PSU reuse — we want our
1064+
# within-group strata check to fire first.
1065+
df_["psu"] = np.arange(len(df_))
1066+
sd = SurveyDesign(weights="pw", strata="stratum", psu="psu")
1067+
with pytest.raises(ValueError, match="strata to be constant within group"):
1068+
ChaisemartinDHaultfoeuille(seed=1).fit(
1069+
df_, outcome="outcome", group="group",
1070+
time="period", treatment="treatment",
1071+
survey_design=sd,
1072+
)
1073+
1074+
def test_accepts_varying_weights_within_group(self, base_data):
1075+
"""Within-group-varying pweights remain supported — the expansion
1076+
psi_i = U[g] * (w_i / W_g) handles obs-level weight variation."""
1077+
df_ = base_data.copy()
1078+
rng = np.random.default_rng(7)
1079+
df_["pw"] = rng.uniform(0.5, 2.0, size=len(df_))
1080+
sd = SurveyDesign(weights="pw")
1081+
result = ChaisemartinDHaultfoeuille(seed=1).fit(
1082+
df_, outcome="outcome", group="group",
1083+
time="period", treatment="treatment",
1084+
survey_design=sd,
1085+
)
1086+
assert np.isfinite(result.overall_att)
1087+
1088+
def test_rejection_excludes_zero_weight_rows(self, base_data):
1089+
"""A zero-weight row with a different PSU from its group must
1090+
not trigger rejection — it is out-of-sample by the
1091+
subpopulation contract and does not enter the variance."""
1092+
df_ = base_data.copy()
1093+
df_["pw"] = 1.0
1094+
df_["stratum"] = 0
1095+
df_["psu"] = 0
1096+
# Inject a zero-weight row with a different PSU
1097+
sample = df_.iloc[0].copy()
1098+
sample["psu"] = 99 # would violate constancy if counted
1099+
sample["pw"] = 0.0
1100+
df_ = pd.concat([df_, pd.DataFrame([sample])], ignore_index=True)
1101+
sd = SurveyDesign(weights="pw", strata="stratum", psu="psu")
1102+
result = ChaisemartinDHaultfoeuille(seed=1).fit(
1103+
df_, outcome="outcome", group="group",
1104+
time="period", treatment="treatment",
1105+
survey_design=sd,
1106+
)
1107+
assert np.isfinite(result.overall_att)

0 commit comments

Comments
 (0)