Skip to content

Commit af10cc4

Browse files
igerberclaude
andcommitted
stacked_did: address CI codex R2 P2 — clustered variance label
R2 verdict was ✅ + 1 P2: the summary's Variance: line (added in R1) mislabelled clustered StackedDiD fits as one-way ("HC1 heteroskedasticity-robust" / "HC2 + Bell-McCaffrey DOF (one-way, n=N)") because I passed cluster_name=None to _format_vcov_label. StackedDiD is intrinsically clustered. Fix: - Added `cluster_name` and `n_clusters` fields to StackedDiDResults dataclass. - Populated both at fit() time: cluster_name=self.cluster, n_clusters= np.unique(cluster_ids).size (matches the actual realized count, not config). - summary() now passes cluster_name + n_clusters into _format_vcov_label, rendering "CR1 cluster-robust at unit, G=50" for hc1 and "CR2 Bell-McCaffrey cluster-robust at unit, G=50" for hc2_bm. New regression test test_summary_renders_clustered_variance_label verifies: - hc1 → label contains "CR1 cluster-robust at unit" - hc2_bm → label contains "CR2 Bell-McCaffrey cluster-robust at unit" - Neither contains "(one-way" (catches the R2 regression directly) - G= cluster count present - cluster="unit_subexp" → label contains "at unit_subexp" 90 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f0aa060 commit af10cc4

3 files changed

Lines changed: 52 additions & 10 deletions

File tree

diff_diff/stacked_did.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,8 @@ def _refit_stacked(w_r):
835835
alpha=self.alpha,
836836
anticipation=self.anticipation,
837837
vcov_type=self.vcov_type,
838+
cluster_name=self.cluster,
839+
n_clusters=int(np.unique(cluster_ids).size),
838840
survey_metadata=survey_metadata,
839841
)
840842

diff_diff/stacked_did_results.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ class StackedDiDResults:
9898
# survey_design= is supplied the survey TSL/replicate variance overrides
9999
# the analytical family; this field still records the configured value.
100100
vcov_type: str = "hc1"
101+
# Cluster identity ("unit" or "unit_subexp") and realized cluster count
102+
# at fit time. Used by summary() to render the correct CR1/CR2-BM label
103+
# via `_format_vcov_label(cluster_name=, n_clusters=)`. Per CI codex R2
104+
# P2: passing cluster_name=None mislabelled clustered StackedDiD fits
105+
# as one-way HC1/HC2-BM. StackedDiD is intrinsically clustered.
106+
cluster_name: Optional[str] = None
107+
n_clusters: Optional[int] = None
101108
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
102109
survey_metadata: Optional[Any] = field(default=None)
103110

@@ -177,21 +184,17 @@ def summary(self, alpha: Optional[float] = None) -> str:
177184
]
178185

179186
# Variance family label (per CI codex R1 P2): surface the analytical
180-
# vcov_type when the survey path didn't override. Mirrors the shared
181-
# `_format_vcov_label` helper from results.py used by DiD/MPD/TWFE.
182-
# Suppress under survey_metadata since the survey block above already
183-
# reports the inference source.
187+
# vcov_type when the survey path didn't override. Per R2 P2: pass
188+
# cluster_name + n_clusters so the label renders as "CR1 cluster-
189+
# robust at unit, G=N" rather than the one-way "HC1 heteroskedasticity-
190+
# robust" — StackedDiD is intrinsically clustered.
184191
if self.survey_metadata is None and self.vcov_type:
185192
from diff_diff.results import _format_vcov_label
186193

187-
# StackedDiD is intrinsically clustered; the cluster name is
188-
# either "unit" or "unit_subexp" (config-time, not stored
189-
# explicitly on results — derive from the stacked data if
190-
# available, else fall back to generic label).
191194
label = _format_vcov_label(
192195
self.vcov_type,
193-
cluster_name=None, # cluster identity is config; print plain label
194-
n_clusters=None,
196+
cluster_name=self.cluster_name,
197+
n_clusters=self.n_clusters,
195198
n_obs=self.n_stacked_obs,
196199
)
197200
if label is not None:

tests/test_stacked_did.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,3 +1678,40 @@ def test_hc1_vs_hc2_bm_differ_under_anticipation(self, staggered_data):
16781678
"Under anticipation=1, hc1 vs hc2_bm SEs must differ — "
16791679
"leverage/DOF adjustment didn't fire"
16801680
)
1681+
1682+
def test_summary_renders_clustered_variance_label(self, staggered_data):
1683+
"""Per CI codex R2 P2: summary() must render the clustered CR1/CR2-BM
1684+
label ('CR1 cluster-robust at unit, G=N' / 'CR2 Bell-McCaffrey
1685+
cluster-robust at unit, G=N'), NOT the one-way label ('HC1
1686+
heteroskedasticity-robust' / 'HC2 + Bell-McCaffrey DOF (one-way)').
1687+
StackedDiD is intrinsically clustered."""
1688+
kwargs = dict(outcome="outcome", unit="unit", time="period", first_treat="first_treat")
1689+
for vcov, expected_substr in [
1690+
("hc1", "CR1 cluster-robust at unit"),
1691+
("hc2_bm", "CR2 Bell-McCaffrey cluster-robust at unit"),
1692+
]:
1693+
est = StackedDiD(kappa_pre=2, kappa_post=2, vcov_type=vcov, cluster="unit")
1694+
res = est.fit(staggered_data, **kwargs)
1695+
s = res.summary()
1696+
assert "Variance:" in s, f"summary() missing Variance: line for {vcov}"
1697+
variance_lines = [line for line in s.split("\n") if "Variance:" in line]
1698+
assert any(expected_substr in line for line in variance_lines), (
1699+
f"summary() variance label for vcov_type={vcov} should contain "
1700+
f"'{expected_substr}', got: {variance_lines}"
1701+
)
1702+
# MUST NOT render as one-way (would mislead users)
1703+
assert not any(
1704+
"(one-way" in line for line in variance_lines
1705+
), f"vcov_type={vcov} mislabeled as one-way: {variance_lines}"
1706+
# G count present
1707+
assert any(
1708+
"G=" in line for line in variance_lines
1709+
), f"summary() should include cluster count G= for {vcov}: {variance_lines}"
1710+
# Also test unit_subexp cluster level
1711+
est = StackedDiD(kappa_pre=2, kappa_post=2, vcov_type="hc2_bm", cluster="unit_subexp")
1712+
res = est.fit(staggered_data, **kwargs)
1713+
s = res.summary()
1714+
variance_lines = [line for line in s.split("\n") if "Variance:" in line]
1715+
assert any(
1716+
"at unit_subexp" in line for line in variance_lines
1717+
), f"cluster='unit_subexp' should render in label: {variance_lines}"

0 commit comments

Comments
 (0)