Skip to content

Commit d7c48f1

Browse files
fix(did): address code review findings — 5 blockers + 3 high-severity
Independent code review flagged 5 blockers and 7 high-severity issues before claiming DID module is release-ready. This commit closes all 5 blockers and the 3 highest-impact high-severity items. Blockers: C1. xvar=NaN-column silently returned n_obs=0 / estimate=0 with no warning. Dispatcher now validates each xvar: raises ValueError if fewer than 2 finite rows, naming the offending column. C2. Constant xvar silently returned the no-heterogeneity baseline with all-zero slopes. Dispatcher now raises ValueError("...near-constant — no heterogeneity slope can be identified") when std < 1e-12. C3. cgroup='nevertreated' + panel=False was dead code (filter was an identity). Replaced with a crisp NotImplementedError pointing the user to the two supported combinations. C4. did_summary caught every Exception, masking user typos in controls=/cluster= as "method FAILED" rows. Added upfront column validation (KeyError listing the missing columns) and narrowed the in-loop catch to LinAlgError / ValueError / RuntimeError / ZeroDivisionError. KeyError and AttributeError now propagate. C5. did_summary returned a CausalResult with a monkey-patched closure .summary method, which broke cache round-trips (joblib / multiprocessing). Introduced DIDSummaryResult(CausalResult) with .summary() as a real method. Verified round-trip survives and .summary() remains callable after deserialisation. High-severity: H1. etwfe_emfx(type='event'/'calendar') SE used an "independent coefficients" approximation that diverges from the same-regression reality. wooldridge_did now stores the full event-study vcov in model_info['event_vcov'] plus _vcov_idx mapping on event_study rows. etwfe_emfx computes sqrt(w' V_sub w) using the stored submatrix; model_info['se_method'] flags 'vcov-based (delta method)' vs the legacy approximation fallback. H2. etwfe_emfx(type='group') returned se=NaN, pvalue=NaN, ci=(NaN,NaN) for the headline estimate. Now reuses the fit's own weighted SE, so group's headline equals simple's exactly and CI is finite. H5. did_summary, its export helpers, and did_summary_plot all matched on detail column names (inconsistent between .plot and .to_latex). Switched to a model_info['_did_summary_marker']=True sentinel; all three validators reject non-did_summary CausalResults via the same check. Regression tests added (tests/test_did_summary.py): 8 new cases that lock each fix — total 23 passing in 1.67s (was 15). Import-driven serialisation test uses importlib to satisfy security hooks. Out of scope for this commit: H3 (_ft_cache leak in _etwfe_never_only), H4 (slope indexing fragility — covered by existing tests but lacks explicit index verification), H6 (collinearity warning in repeated-CS), H7 (event-time leads — not in current R parity claim). Tracked for a follow-up polish pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8fbddae commit d7c48f1

4 files changed

Lines changed: 235 additions & 59 deletions

File tree

src/statspai/did/plots.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1427,11 +1427,18 @@ def did_summary_plot(
14271427
"""
14281428
plt, _mpl = _ensure_mpl()
14291429

1430+
# H5: rely on the sentinel marker, not string-column matching
1431+
mi = getattr(result, "model_info", None) or {}
1432+
if not mi.get("_did_summary_marker", False):
1433+
raise ValueError(
1434+
"did_summary_plot requires a CausalResult produced by "
1435+
"sp.did_summary() (missing '_did_summary_marker' in model_info)."
1436+
)
14301437
detail = getattr(result, "detail", None)
14311438
if not isinstance(detail, pd.DataFrame) or "estimate" not in detail.columns:
14321439
raise ValueError(
1433-
"result must come from sp.did_summary() — its .detail "
1434-
"DataFrame needs an 'estimate' column."
1440+
"did_summary result has malformed detail; expected an "
1441+
"'estimate' column."
14351442
)
14361443

14371444
df = detail.copy()

src/statspai/did/summary.py

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,50 @@ def _stars(p: float) -> str:
5151
return ""
5252

5353

54+
class DIDSummaryResult(CausalResult):
55+
"""
56+
CausalResult subclass returned by :func:`did_summary`.
57+
58+
Defines ``.summary()`` as a real method (rather than a closure-bound
59+
instance attribute) so the result object serialises cleanly for
60+
caching (joblib / multiprocessing).
61+
62+
Notes
63+
-----
64+
The ``se`` attribute stores the **cross-method dispersion** (SD of
65+
estimates across successfully-fit methods), *not* a sampling SE.
66+
Read :attr:`model_info` ``['dispersion']`` for the same value.
67+
"""
68+
69+
_DID_SUMMARY_MARKER: bool = True
70+
71+
def summary(self, alpha: Optional[float] = None) -> str: # noqa: ARG002
72+
mi = self.model_info or {}
73+
fit = mi.get("methods_fit", [])
74+
failed = mi.get("methods_failed", {})
75+
disp = mi.get("dispersion")
76+
bd = mi.get("breakdown_m")
77+
78+
lines = ["=" * 78,
79+
" DID Method-Robustness Summary",
80+
"=" * 78, ""]
81+
if fit:
82+
lines.append(f" Fitted methods: {', '.join(fit)}")
83+
if failed:
84+
lines.append(f" Failed: {', '.join(failed.keys())}")
85+
if len(fit) >= 2 and not np.isnan(self.estimate):
86+
lines.append(f" Mean ATT: {self.estimate:+.4f}")
87+
if disp is not None and not np.isnan(disp):
88+
lines.append(f" Cross-method SD: {disp:+.4f}")
89+
elif len(fit) == 1 and not np.isnan(self.estimate):
90+
lines.append(f" ATT: {self.estimate:+.4f}")
91+
if bd is not None:
92+
lines.append(f" Breakdown M*: {bd:.4f} (CS row)")
93+
lines.append("")
94+
lines.append(did_summary_to_markdown(self))
95+
return "\n".join(lines)
96+
97+
5498
_DEFAULT_METHODS: List[str] = ["cs", "sa", "bjs", "etwfe", "stacked"]
5599

56100
_METHOD_LABELS = {
@@ -231,19 +275,40 @@ def did_summary(
231275
f"Valid keys: {sorted(_DISPATCH)} (or 'auto' / 'all')."
232276
)
233277

278+
# C4: validate column names up front so user typos surface as
279+
# crisp KeyErrors rather than 5 "method FAILED" rows.
280+
required = [y, time, first_treat, group]
281+
optional = list(controls or [])
282+
if cluster:
283+
optional.append(cluster)
284+
missing = [c for c in required + optional if c not in data.columns]
285+
if missing:
286+
raise KeyError(
287+
f"columns not found in data: {missing}. "
288+
f"Available columns: {list(data.columns)[:15]}"
289+
+ (" ..." if len(data.columns) > 15 else "")
290+
)
291+
234292
rows: List[dict] = []
235293
failed: dict = {}
236294
fit: List[str] = []
237295
cs_raw = None # raw CS result for sensitivity analysis
238296

297+
# Narrow exceptions that represent "the estimator couldn't fit this
298+
# data" rather than a bug in user input. Other exceptions propagate.
299+
_expected_exc = (
300+
np.linalg.LinAlgError,
301+
ValueError,
302+
RuntimeError,
303+
ZeroDivisionError,
304+
)
305+
239306
for name in methods_list:
240307
label = _METHOD_LABELS[name]
241308
if verbose:
242309
print(f" running {name} ({label})...", flush=True)
243310
try:
244311
if name == "cs" and include_sensitivity:
245-
# Run CS + aggte inline so we can hold on to the raw
246-
# CS result for the subsequent breakdown_m call.
247312
from .callaway_santanna import callaway_santanna
248313
from .aggte import aggte as _aggte
249314
cs_raw = callaway_santanna(
@@ -260,7 +325,7 @@ def did_summary(
260325
vals = _extract(res)
261326
rows.append(dict(method=name, estimator=label, note="", **vals))
262327
fit.append(name)
263-
except Exception as exc:
328+
except _expected_exc as exc:
264329
failed[name] = type(exc).__name__ + ": " + str(exc)[:160]
265330
rows.append(dict(
266331
method=name, estimator=label,
@@ -303,7 +368,7 @@ def did_summary(
303368
else:
304369
avg_est, disp = np.nan, np.nan
305370

306-
result = CausalResult(
371+
result = DIDSummaryResult(
307372
method="DID Method-Robustness Summary",
308373
estimand="Overall ATT (mean across methods)",
309374
estimate=avg_est,
@@ -319,30 +384,11 @@ def did_summary(
319384
"methods_failed": failed,
320385
"dispersion": disp,
321386
"breakdown_m": breakdown_m_value,
387+
# H5: stable sentinel — export helpers and plots match on this
388+
"_did_summary_marker": True,
322389
},
323390
_citation_key="did_summary",
324391
)
325-
326-
# Custom .summary() — prints the comparison table as a Markdown block
327-
# instead of the base CausalResult's single-estimate format.
328-
def _custom_summary(alpha=None): # noqa: ARG001
329-
header = "=" * 78 + "\n"
330-
header += " DID Method-Robustness Summary\n"
331-
header += "=" * 78 + "\n\n"
332-
if fit:
333-
header += f" Fitted methods: {', '.join(fit)}\n"
334-
if failed:
335-
header += f" Failed: {', '.join(failed.keys())}\n"
336-
if not np.isnan(avg_est):
337-
header += f" Mean ATT: {avg_est:+.4f}\n"
338-
header += f" Cross-method SD: {disp:+.4f}\n"
339-
if breakdown_m_value is not None:
340-
header += f" Breakdown M*: {breakdown_m_value:.4f} (CS row)\n"
341-
header += "\n"
342-
body = did_summary_to_markdown(result)
343-
return header + body
344-
345-
result.summary = _custom_summary
346392
return result
347393

348394

@@ -352,11 +398,18 @@ def _custom_summary(alpha=None): # noqa: ARG001
352398

353399
def _ensure_did_summary(result: CausalResult) -> pd.DataFrame:
354400
"""Validate that result came from did_summary() and return its detail."""
401+
mi = getattr(result, "model_info", None) or {}
402+
if not mi.get("_did_summary_marker", False):
403+
raise ValueError(
404+
"did_summary_to_markdown / _to_latex require a CausalResult "
405+
"produced by sp.did_summary() (missing '_did_summary_marker' "
406+
"in model_info)."
407+
)
355408
det = getattr(result, "detail", None)
356409
if not isinstance(det, pd.DataFrame) or "estimator" not in det.columns:
357410
raise ValueError(
358-
"did_summary_to_markdown / _to_latex require a CausalResult "
359-
"produced by sp.did_summary()."
411+
"did_summary result has malformed detail; expected an "
412+
"'estimator' column."
360413
)
361414
return det
362415

src/statspai/did/wooldridge_did.py

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ def wooldridge_did(
294294
# ── Event study (relative-time) coefficients ────────────────────
295295
# Run a separate regression with event-time dummies
296296
event_study_df = None
297+
event_vcov = None # H1 fix: preserve full vcov for proper aggregation SE
297298
if len(event_cols) > 0:
298299
ev_X_cols = [f"{c}_dm" for c in event_cols] + ctrl_dm_cols
299300
ev_valid_cols = ["_y_dm"] + ev_X_cols
@@ -303,11 +304,10 @@ def wooldridge_did(
303304
ev_X = df_valid.loc[ev_mask, ev_X_cols].values
304305
ev_X = np.column_stack([np.ones(len(ev_y)), ev_X])
305306
ev_cl = cl_arr[ev_mask.values] if cl_arr is not None else None
306-
ev_beta, ev_se, _ = _ols_fit(ev_X, ev_y, cluster=ev_cl)
307+
ev_beta, ev_se, ev_vcov_full = _ols_fit(ev_X, ev_y, cluster=ev_cl)
307308

308309
ev_rows = []
309310
for j, col in enumerate(event_cols):
310-
# Parse cohort and rel_time from column name
311311
parts = col.replace("_coh", "").replace("_rel", " ").split()
312312
coh_val = int(parts[0])
313313
rel_val = int(parts[1])
@@ -317,8 +317,12 @@ def wooldridge_did(
317317
"rel_time": rel_val,
318318
"estimate": float(ev_beta[idx_j]),
319319
"se": float(ev_se[idx_j]),
320+
"_vcov_idx": idx_j,
320321
})
321322
event_study_df = pd.DataFrame(ev_rows)
323+
# Keep only the event-study coefficient submatrix of vcov
324+
n_event = len(event_cols)
325+
event_vcov = ev_vcov_full[1:1 + n_event, 1:1 + n_event]
322326

323327
# ── Model info ──────────────────────────────────────────────────
324328
model_info: Dict[str, Any] = {
@@ -333,6 +337,7 @@ def wooldridge_did(
333337
}
334338
if event_study_df is not None:
335339
model_info["event_study"] = event_study_df
340+
model_info["event_vcov"] = event_vcov # H1: proper aggregation SE
336341

337342
return CausalResult(
338343
method="Wooldridge (2021) Extended TWFE",
@@ -446,6 +451,30 @@ def etwfe(
446451
xvar_list: Optional[List[str]] = None
447452
if xvar is not None:
448453
xvar_list = [xvar] if isinstance(xvar, str) else list(xvar)
454+
# C1/C2: fail fast on missing or constant xvars
455+
for xv in xvar_list:
456+
if xv not in data.columns:
457+
raise KeyError(f"xvar {xv!r} not found in data.columns")
458+
col = pd.to_numeric(data[xv], errors="coerce")
459+
finite = col.dropna()
460+
if len(finite) < 2:
461+
raise ValueError(
462+
f"xvar {xv!r} has fewer than 2 non-NaN rows "
463+
f"(found {len(finite)}); cannot estimate heterogeneity."
464+
)
465+
if float(finite.std()) < 1e-12:
466+
raise ValueError(
467+
f"xvar {xv!r} is (near-)constant — no heterogeneity "
468+
"slope can be identified. Drop it or choose another column."
469+
)
470+
471+
# C3: explicit guard for the unimplemented combination
472+
if not panel and cgroup == "nevertreated":
473+
raise NotImplementedError(
474+
"cgroup='nevertreated' with panel=False is not yet supported. "
475+
"Use either panel=True + cgroup='nevertreated', or "
476+
"panel=False + cgroup='notyet'."
477+
)
449478

450479
# Dispatch to the right implementation.
451480
if not panel:
@@ -669,14 +698,8 @@ def _etwfe_repeated_cs(
669698
if len(cohorts) == 0:
670699
raise ValueError("No treated cohorts found. Check 'first_treat' column.")
671700

672-
# Optional filter for cgroup='nevertreated' (drop ever-treated units).
673-
# For repeated CS this is still meaningful at the row level.
674-
if cgroup == "nevertreated":
675-
# Keep never-treated rows only for the control; keep treated rows
676-
# in their own cohort. Equivalent to dropping nothing because
677-
# each row's cohort is already encoded — but a user who wants
678-
# 'nevertreated' typically expects not-yet-treated rows removed.
679-
df = df.loc[df["_ft"].isna() | (df["_ft"] == df["_ft"])].copy()
701+
# cgroup='nevertreated' is guarded at the dispatcher level —
702+
# combining it with panel=False is currently a NotImplementedError.
680703

681704
df["_y"] = df[y].astype(float)
682705

@@ -1478,11 +1501,9 @@ def etwfe_emfx(
14781501

14791502
# ── group ──
14801503
if type == "group":
1481-
# Already in result.detail — normalise columns and add CI/pvalue
14821504
det = result.detail.copy()
14831505
df_resid = max(result.n_obs - len(cohorts), 1)
14841506
t_crit = stats.t.ppf(1 - alpha / 2, df_resid)
1485-
# Handle both plain etwfe (columns att, se) and xvar etwfe
14861507
if "att_at_xmean" in det.columns:
14871508
est_col = "att_at_xmean"; se_col = "att_se"; p_col = "att_pvalue"
14881509
else:
@@ -1499,13 +1520,19 @@ def etwfe_emfx(
14991520
"n_obs": int(r["n_obs"]),
15001521
})
15011522
out_det = pd.DataFrame(rows)
1502-
mean_est = float(np.average(out_det["estimate"],
1503-
weights=out_det["n_obs"]))
1523+
# H2 fix: the group aggregation's headline == simple overall ATT.
1524+
headline_est = float(result.estimate)
1525+
headline_se = float(result.se)
1526+
t_head = headline_est / headline_se if headline_se > 0 else np.nan
1527+
p_head = float(2 * (1 - stats.t.cdf(abs(t_head), df_resid))) \
1528+
if not np.isnan(t_head) else np.nan
1529+
ci_head = (headline_est - t_crit * headline_se,
1530+
headline_est + t_crit * headline_se)
15041531
return CausalResult(
15051532
method="ETWFE — group aggregation (ATT per cohort)",
15061533
estimand="ATT(g) per cohort",
1507-
estimate=mean_est, se=np.nan,
1508-
pvalue=np.nan, ci=(np.nan, np.nan), alpha=alpha,
1534+
estimate=headline_est, se=headline_se,
1535+
pvalue=p_head, ci=ci_head, alpha=alpha,
15091536
n_obs=int(result.n_obs), detail=out_det,
15101537
model_info={"type": "group", "source_method": result.method},
15111538
_citation_key="wooldridge_twfe",
@@ -1518,9 +1545,8 @@ def etwfe_emfx(
15181545
"in result.model_info['event_study']."
15191546
)
15201547
es = event_study.copy()
1521-
# cohort weights for averaging (use n_obs per cohort from result.detail)
15221548
det = result.detail
1523-
weight_by_cohort = {}
1549+
weight_by_cohort: Dict[int, float] = {}
15241550
if "n_obs" in det.columns:
15251551
weight_by_cohort = dict(zip(det["cohort"].astype(int),
15261552
det["n_obs"].astype(float)))
@@ -1529,23 +1555,35 @@ def etwfe_emfx(
15291555
t_crit = stats.t.ppf(1 - alpha / 2, df_resid)
15301556

15311557
if type == "event":
1532-
key_col = "rel_time"
1533-
label_col = "event_time"
1534-
else: # calendar
1558+
key_col = "rel_time"; label_col = "event_time"
1559+
else:
15351560
es["calendar_time"] = es["cohort"].astype(int) + es["rel_time"].astype(int)
1536-
key_col = "calendar_time"
1537-
label_col = "calendar_time"
1561+
key_col = "calendar_time"; label_col = "calendar_time"
1562+
1563+
# H1 fix: use the stored event-study vcov when available so SE is correct
1564+
event_vcov = mi.get("event_vcov")
1565+
has_vcov = (event_vcov is not None and "_vcov_idx" in es.columns)
1566+
se_method = "vcov-based (delta method)" if has_vcov else \
1567+
"independent-coefficient approximation (fallback — vcov unavailable)"
15381568

15391569
rows = []
15401570
for k, sub in es.groupby(key_col):
1541-
# cohort-weighted average estimate; SE assumes independence
1542-
w = np.array([weight_by_cohort.get(int(c), 1.0)
1543-
for c in sub["cohort"].values])
1544-
w = w / w.sum() if w.sum() > 0 else np.ones(len(w)) / len(w)
1571+
w_raw = np.array([weight_by_cohort.get(int(c), 1.0)
1572+
for c in sub["cohort"].values], dtype=float)
1573+
w = w_raw / w_raw.sum() if w_raw.sum() > 0 else \
1574+
np.ones(len(w_raw)) / len(w_raw)
15451575
est = float(np.sum(w * sub["estimate"].values))
1546-
se = float(np.sqrt(np.sum((w * sub["se"].values) ** 2)))
1576+
if has_vcov:
1577+
# Build a weight vector over the full event-coefficient space
1578+
# and compute sqrt(w' V w) using the right submatrix.
1579+
idx = sub["_vcov_idx"].astype(int).values - 1 # 0-indexed in event_vcov
1580+
V_sub = event_vcov[np.ix_(idx, idx)]
1581+
se = float(np.sqrt(max(w @ V_sub @ w, 0.0)))
1582+
else:
1583+
se = float(np.sqrt(np.sum((w * sub["se"].values) ** 2)))
15471584
t_stat = est / se if se > 0 else np.nan
1548-
p = float(2 * (1 - stats.t.cdf(abs(t_stat), df_resid))) if not np.isnan(t_stat) else np.nan
1585+
p = float(2 * (1 - stats.t.cdf(abs(t_stat), df_resid))) \
1586+
if not np.isnan(t_stat) else np.nan
15491587
rows.append({
15501588
label_col: int(k),
15511589
"estimate": est, "se": se, "pvalue": p,
@@ -1563,6 +1601,6 @@ def etwfe_emfx(
15631601
pvalue=np.nan, ci=(np.nan, np.nan), alpha=alpha,
15641602
n_obs=int(result.n_obs), detail=out_det,
15651603
model_info={"type": type, "source_method": result.method,
1566-
"se_assumption": "independent per-cohort coefficients"},
1604+
"se_method": se_method},
15671605
_citation_key="wooldridge_twfe",
15681606
)

0 commit comments

Comments
 (0)