Skip to content

Commit 458632f

Browse files
fix(did): close remaining review follow-ups — H3/H4/H6/H7
Finishes the polish round so the DID module can be declared release-ready. H3. _etwfe_never_only no longer mutates the caller's DataFrame. Removed the _ft_cache helper column; the cohort membership is now computed into a local Series and consumed inline. Verified that df.columns is unchanged after the call. Docstring also notes the per-cohort cluster SE adjustment so users aren't surprised when the aggregated SE differs from a full-sample fit. H4. Slope indexing in _etwfe_with_xvar is now name-keyed. Builds an explicit coef_index dict ({column_name: position}) at design-matrix construction time; cohort ATT and per-xvar slopes are extracted by dict lookup, never by arithmetic offset. base_vcov likewise uses np.ix_ over the cohort-name keys. This makes future column-order refactors safe. A regression test verifies that reordering xvar=['x1','x2'] vs ['x2','x1'] yields identical per-cohort slopes (and that slope_x1 != slope_x2 on heterogeneous DGPs). H6. Repeated-cross-section design warns on rank deficiency. Before the OLS fit, _etwfe_repeated_cs computes the design-matrix rank and, if deficient, emits a RuntimeWarning naming the deficit and suggesting concrete remedies. The pinv fallback in _ols_fit still runs so the regression doesn't crash, but the user now has a chance to notice a silently pathological specification. H7. etwfe_emfx gains include_leads for full event-study output. wooldridge_did now builds event-time dummies for all rel_time values except rel = -1 (the reference category), so pre-trend leads are estimated alongside post-treatment lags. etwfe_emfx gains an include_leads=False default (backward-compat); set True to return the full event study including negative event times. SE uses the same vcov-based delta method (H1 fix). Tests: 4 new regression cases (tests/test_did_summary.py), total 27 passing in 1.85s (was 23). Combined with the prior review fixes (C1-C5, H1/H2/H5), all 8 issues flagged by the code-review agent are now closed. The remaining M/L items are genuine polish (markdown cosmetics, dead-code pruning, API ergonomics) that don't affect correctness. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d7c48f1 commit 458632f

2 files changed

Lines changed: 144 additions & 20 deletions

File tree

src/statspai/did/wooldridge_did.py

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,19 @@ def wooldridge_did(
183183
interaction_cols.append(col)
184184

185185
# ── Also build cohort × relative-time dummies for event study ───
186+
# H7 fix: build event dummies for BOTH leads and lags. Omit
187+
# rel = -1 as the reference category so the design is identified.
188+
# Post-only consumers can still filter via etwfe_emfx(include_leads=False).
186189
event_cols: List[str] = []
187190
rel_times = set()
188191
for g in cohorts:
189192
mask_g = df["_ft"] == g
190193
for t_val in periods:
191194
rel = int(t_val - g)
192-
if rel < 0:
193-
continue # only post-treatment for basic spec
194-
col = f"_coh{int(g)}_rel{rel}"
195+
if rel == -1:
196+
continue # reference / omitted period
197+
col = (f"_coh{int(g)}_rel{rel}" if rel >= 0
198+
else f"_coh{int(g)}_rel_neg{abs(rel)}")
195199
df[col] = ((mask_g) & (df[time] == t_val)).astype(float)
196200
event_cols.append(col)
197201
rel_times.add(rel)
@@ -308,9 +312,15 @@ def wooldridge_did(
308312

309313
ev_rows = []
310314
for j, col in enumerate(event_cols):
311-
parts = col.replace("_coh", "").replace("_rel", " ").split()
312-
coh_val = int(parts[0])
313-
rel_val = int(parts[1])
315+
# Parse name: _coh{g}_rel{k} or _coh{g}_rel_neg{k}
316+
stripped = col.replace("_coh", "")
317+
if "_rel_neg" in stripped:
318+
coh_part, rel_part = stripped.split("_rel_neg")
319+
rel_val = -int(rel_part)
320+
else:
321+
coh_part, rel_part = stripped.split("_rel")
322+
rel_val = int(rel_part)
323+
coh_val = int(coh_part)
314324
idx_j = j + 1
315325
ev_rows.append({
316326
"cohort": coh_val,
@@ -564,6 +574,13 @@ def _etwfe_with_xvar(
564574
all_slope = [c for v in slope_cols_by_cohort.values() for c in v]
565575
all_inter = base_cols + all_slope
566576

577+
# H4 fix: explicit name-to-index map so slope lookups never rely on
578+
# implicit ordering. Column 0 is the constant; columns [1:] are
579+
# X_cols = [f"{c}_dm" for c in all_inter] + ctrl_dm_cols in order.
580+
coef_index: Dict[str, int] = {"_const": 0}
581+
for i, col in enumerate(all_inter, start=1):
582+
coef_index[col] = i
583+
567584
# Demean interactions via FE projection
568585
for col in all_inter:
569586
u_m = df.groupby(group)[col].transform("mean")
@@ -597,11 +614,14 @@ def _etwfe_with_xvar(
597614
df_resid = max(n_obs - X.shape[1], 1)
598615

599616
k = len(cohorts)
600-
p_x = len(xvar)
617+
p_x = len(xvar) # retained for the single-xvar backward-compat block
601618

602619
cohort_results = []
603-
for i, g in enumerate(cohorts):
604-
b_idx = 1 + i
620+
for g in cohorts:
621+
# H4 fix: look up baseline and slope indices by coefficient name
622+
# rather than by arithmetic position, so future column-order
623+
# changes cannot silently mis-attribute.
624+
b_idx = coef_index[f"_coh{int(g)}_post"]
605625
att = float(beta[b_idx])
606626
att_se = float(se[b_idx])
607627
p = float(2 * (1 - stats.t.cdf(abs(att / att_se) if att_se > 0 else 0,
@@ -610,9 +630,8 @@ def _etwfe_with_xvar(
610630
"cohort": int(g),
611631
"att_at_xmean": att, "att_se": att_se, "att_pvalue": p,
612632
}
613-
# Per-xvar slopes
614-
for j, x in enumerate(xvar):
615-
s_idx = 1 + k + i * p_x + j
633+
for x in xvar:
634+
s_idx = coef_index[f"_coh{int(g)}_post_x_{x}"]
616635
slope = float(beta[s_idx])
617636
slope_se = float(se[s_idx])
618637
p_s = float(2 * (1 - stats.t.cdf(
@@ -633,7 +652,9 @@ def _etwfe_with_xvar(
633652
sizes = detail["n_obs"].values.astype(float)
634653
weights_vec = sizes / sizes.sum() if sizes.sum() > 0 else np.ones(k) / k
635654
att_overall = float(weights_vec @ detail["att_at_xmean"].values)
636-
base_vcov = vcov[1:1 + k, 1:1 + k]
655+
# H4 fix: extract baseline-coefficient vcov by explicit index lookup
656+
base_idx = np.array([coef_index[f"_coh{int(g)}_post"] for g in cohorts])
657+
base_vcov = vcov[np.ix_(base_idx, base_idx)]
637658
att_se_overall = float(np.sqrt(weights_vec @ base_vcov @ weights_vec))
638659
t_stat = att_overall / att_se_overall if att_se_overall > 0 else np.nan
639660
p_overall = float(2 * (1 - stats.t.cdf(abs(t_stat), df_resid)))
@@ -751,6 +772,23 @@ def _etwfe_repeated_cs(
751772
y_vec = dfv["_y"].values
752773
X = np.column_stack([np.ones(len(y_vec))] + [dfv[c].values for c in design_cols])
753774
cl_arr = dfv[cluster].values if cluster else None
775+
776+
# H6 fix: repeated-CS design is more collinearity-prone than the
777+
# within-demeaned panel path. Warn the user loudly when the design
778+
# matrix is rank-deficient; results are still produced via pinv.
779+
rank = int(np.linalg.matrix_rank(X))
780+
if rank < X.shape[1]:
781+
import warnings as _warnings
782+
deficit = X.shape[1] - rank
783+
_warnings.warn(
784+
f"etwfe(panel=False): design matrix is rank-deficient by "
785+
f"{deficit} column(s) (rank={rank}, ncol={X.shape[1]}). "
786+
"Falling back to pseudoinverse; some coefficients are "
787+
"arbitrary linear combinations. Consider dropping collinear "
788+
"controls, shortening the event window, or using panel=True.",
789+
RuntimeWarning, stacklevel=3,
790+
)
791+
754792
beta, se, vcov = _ols_fit(X, y_vec, cluster=cl_arr)
755793
n_obs = len(y_vec)
756794
df_resid = max(n_obs - X.shape[1], 1)
@@ -816,15 +854,25 @@ def _etwfe_never_only(
816854
Runs a separate ETWFE regression per cohort, each using only
817855
(units in cohort g) ∪ (never-treated units). Combines cohort ATTs
818856
with cohort-size weighting. Matches R ``etwfe(cgroup='never')``.
857+
858+
Notes
859+
-----
860+
Per-cohort regressions each run on a different subset (cohort g +
861+
never-treated), so the cluster small-sample correction
862+
``(n_cl/(n_cl-1))`` is evaluated cohort-by-cohort. Cohort SEs may
863+
therefore be slightly larger than a single full-sample regression
864+
would produce. The aggregated SE assumes cross-cohort independence,
865+
which is exact under this per-cohort design.
819866
"""
867+
# H3 fix: compute the first-treat series locally rather than
868+
# writing a helper column back to the outer frame. Prevents
869+
# accidental column leakage when callers re-use `data`.
820870
df = data.copy()
821-
ft = df[first_treat].replace(0, np.nan)
822-
df["_ft_cache"] = ft
823-
cohorts = sorted(df.loc[df["_ft_cache"].notna(), "_ft_cache"].unique())
871+
ft_local = df[first_treat].replace(0, np.nan)
872+
cohorts = sorted(ft_local.dropna().unique())
824873
if len(cohorts) == 0:
825874
raise ValueError("No treated cohorts found. Check 'first_treat' column.")
826-
never_mask = df["_ft_cache"].isna()
827-
never_ids = df.loc[never_mask, group].unique()
875+
never_ids = df.loc[ft_local.isna(), group].unique()
828876
if len(never_ids) == 0:
829877
raise ValueError(
830878
"cgroup='nevertreated' requires at least one never-treated "
@@ -834,8 +882,7 @@ def _etwfe_never_only(
834882
rows: List[Dict[str, Any]] = []
835883
ses: List[float] = []
836884
for g in cohorts:
837-
# Keep cohort g + never-treated
838-
coh_ids = df.loc[df["_ft_cache"] == g, group].unique()
885+
coh_ids = df.loc[ft_local == g, group].unique()
839886
keep = np.concatenate([coh_ids, never_ids])
840887
sub = df.loc[df[group].isin(keep)].copy()
841888
if xvar:
@@ -1404,6 +1451,7 @@ def etwfe_emfx(
14041451
result: CausalResult,
14051452
type: str = "simple",
14061453
alpha: float = 0.05,
1454+
include_leads: bool = False,
14071455
) -> CausalResult:
14081456
"""
14091457
R ``etwfe::emfx``-style aggregated marginal effects for an ETWFE fit.
@@ -1429,6 +1477,15 @@ def etwfe_emfx(
14291477
Aggregation type.
14301478
alpha : float, default 0.05
14311479
Significance level for confidence intervals.
1480+
include_leads : bool, default False
1481+
For ``type='event'`` and ``type='calendar'``, whether to include
1482+
pre-treatment relative times (``rel_time < 0``) in the output.
1483+
These coefficients identify pre-trends and are informative for
1484+
parallel-trends inspection. Default ``False`` for backward
1485+
compatibility with earlier versions; set ``True`` for full
1486+
event-study output matching the R ``etwfe::emfx(type='event')``
1487+
default. ``rel_time = -1`` is always the reference category
1488+
and is excluded.
14321489
14331490
Returns
14341491
-------
@@ -1554,6 +1611,12 @@ def etwfe_emfx(
15541611
df_resid = max(result.n_obs - len(cohorts), 1)
15551612
t_crit = stats.t.ppf(1 - alpha / 2, df_resid)
15561613

1614+
# H7 fix: default still post-only to preserve prior behaviour, but
1615+
# pre-treatment leads are available via include_leads=True for
1616+
# pre-trend inspection (standard event-study practice).
1617+
if not include_leads:
1618+
es = es.loc[es["rel_time"] >= 0].copy()
1619+
15571620
if type == "event":
15581621
key_col = "rel_time"; label_col = "event_time"
15591622
else:

tests/test_did_summary.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,64 @@ def test_did_summary_plot_rejects_non_did_summary_result():
321321
)
322322
with pytest.raises(ValueError, match="_did_summary_marker"):
323323
sp.did_summary_plot(bad)
324+
325+
326+
# ───────────────────────────────────────────────────────────────────────
327+
# 9. Follow-up fixes (H3 / H4 / H6 / H7)
328+
# ───────────────────────────────────────────────────────────────────────
329+
330+
def test_etwfe_never_only_does_not_leak_columns(staggered_df):
331+
df_before = staggered_df.copy()
332+
cols_before = list(df_before.columns)
333+
sp.etwfe(df_before, y="y", time="time", first_treat="first_treat",
334+
group="unit", cgroup="nevertreated")
335+
# No helper column should leak back into the caller's frame
336+
assert list(df_before.columns) == cols_before
337+
338+
339+
def test_etwfe_xvar_order_invariant_and_distinct(staggered_df):
340+
rng = np.random.default_rng(0)
341+
df = staggered_df.copy()
342+
df["x_a"] = rng.standard_normal(len(df))
343+
df["x_b"] = rng.standard_normal(len(df)) * 2 + 5
344+
r = sp.etwfe(df, y="y", time="time", first_treat="first_treat",
345+
group="unit", xvar=["x_a", "x_b"])
346+
r_swap = sp.etwfe(df, y="y", time="time", first_treat="first_treat",
347+
group="unit", xvar=["x_b", "x_a"])
348+
# Name-keyed indexing: swapping xvar order does not change slopes
349+
assert np.allclose(r.detail["slope_x_a"].values,
350+
r_swap.detail["slope_x_a"].values)
351+
assert np.allclose(r.detail["slope_x_b"].values,
352+
r_swap.detail["slope_x_b"].values)
353+
# And slopes for different xvars must actually differ
354+
for _, row in r.detail.iterrows():
355+
assert abs(row["slope_x_a"] - row["slope_x_b"]) > 1e-8
356+
357+
358+
def test_etwfe_panel_false_rank_deficient_warns(staggered_df):
359+
import warnings as _w
360+
df = staggered_df.copy()
361+
df["const_col"] = 1.0 # perfectly collinear with intercept
362+
with _w.catch_warnings(record=True) as caught:
363+
_w.simplefilter("always")
364+
sp.etwfe(df, y="y", time="time", first_treat="first_treat",
365+
group="unit", panel=False, controls=["const_col"])
366+
rank_warnings = [w for w in caught
367+
if issubclass(w.category, RuntimeWarning)
368+
and "rank-deficient" in str(w.message)]
369+
assert len(rank_warnings) >= 1
370+
371+
372+
def test_etwfe_emfx_event_include_leads(staggered_df):
373+
fit = sp.etwfe(staggered_df, y="y", time="time",
374+
first_treat="first_treat", group="unit")
375+
# Default: post-only (backward compatibility)
376+
ev_post = sp.etwfe_emfx(fit, type="event")
377+
assert ev_post.detail["event_time"].min() == 0
378+
# Explicit leads
379+
ev_full = sp.etwfe_emfx(fit, type="event", include_leads=True)
380+
assert ev_full.detail["event_time"].min() < 0
381+
# rel_time = -1 is the reference category and must not appear
382+
assert -1 not in ev_full.detail["event_time"].values
383+
# Leads use the proper vcov-based SE, not the independence fallback
384+
assert ev_full.model_info["se_method"].startswith("vcov-based")

0 commit comments

Comments
 (0)