Skip to content

Commit 89738b5

Browse files
igerberclaude
andcommitted
fix(lpdid): report survey PSU count as headline G for only_event survey fits
CI-codex P2: under a survey design the effective variance cluster is the PSU (cluster_name reports the PSU column), but for only_event=True fits (pooled is None) headline_n_clusters fell back to the panel unit count -- so an explicit PSU design with n_psu != n_units could display the unit count mislabeled as G=<psu>. Per-row event-study n_clusters and inference were already computed on the realized survey design, so this was a metadata/labeling issue only, not a wrong SE/p-value. Fix: when a survey design is active, seed headline_n_clusters from the panel-level effective PSU count (the pooled-post override still prefers the realized survey-sample count when available). Regression test added (only_event=True, explicit PSU, n_psu != n_units). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ea9caf6 commit 89738b5

2 files changed

Lines changed: 25 additions & 2 deletions

File tree

diff_diff/lpdid.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,9 +1499,16 @@ def fit(
14991499
)
15001500

15011501
# Headline G = realized clusters in the pooled-post (headline ATT) sample
1502-
# when computed; otherwise the panel-level unit-cluster count. Per-horizon
1503-
# rows carry their own realized n_clusters in the event_study/pooled tables.
1502+
# when computed; otherwise the panel-level cluster count. Per-horizon rows
1503+
# carry their own realized n_clusters in the event_study/pooled tables.
15041504
headline_n_clusters = int(panel["_cluster"].nunique())
1505+
if survey_n_psu is not None:
1506+
# Under a survey design the effective variance cluster is the PSU, and
1507+
# cluster_name reports the PSU column. Use the panel-level PSU count so the
1508+
# headline G matches that label even for only_event fits (pooled is None);
1509+
# an explicit PSU design can have n_psu != n_units. The pooled-post override
1510+
# below still prefers the realized survey-sample count when available.
1511+
headline_n_clusters = survey_n_psu
15051512
if pooled is not None:
15061513
_post = pooled.loc[pooled["window"] == "post", "n_clusters"]
15071514
if not _post.empty and pd.notna(_post.iloc[0]):

tests/test_lpdid.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1864,6 +1864,22 @@ def test_get_params_excludes_survey_design(self):
18641864
c = clone.fit(_survey_panel(), survey_design=_full_design(), **_FIT_KW)
18651865
assert c.vcov_type == "survey_tsl" # survey applies because passed to fit()
18661866

1867+
def test_only_event_survey_headline_g_is_psu_count(self):
1868+
# only_event=True (pooled is None) must still report the survey PSU count as the
1869+
# headline G, matching cluster_name=PSU -- not the unit count. The panel design
1870+
# has 10 PSUs across ~80 units (n_psu != n_units).
1871+
df = _survey_panel()
1872+
n_units = df["unit"].nunique()
1873+
res = LPDiD(pre_window=2, post_window=2).fit(
1874+
df, survey_design=_full_design(), only_event=True, **_FIT_KW
1875+
)
1876+
assert res.pooled is None
1877+
assert res.cluster_name == "psu"
1878+
assert res.n_psu == 10
1879+
assert n_units != 10
1880+
assert res.n_clusters == 10 # headline G == PSU count, NOT n_units
1881+
assert "G=10" in res.summary()
1882+
18671883
# --- NaN-consistency: every stratum singleton + lonely_psu="remove" ---
18681884
def test_all_singleton_strata_remove_is_nan(self):
18691885
base = _survey_panel().drop(columns=["stratum", "psu", "fpc", "weight"])

0 commit comments

Comments
 (0)