Skip to content

Commit e830385

Browse files
committed
feat(v4): complete event-study df provenance - SunAbraham/dCDH event_study_df, LPDiD pooled_df (M-092 follow-up)
1 parent 5ced8b4 commit e830385

17 files changed

Lines changed: 582 additions & 14 deletions

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
metacharacter — so no path composed into a shell command can execute. `persist`
2929
self-cleans the whole invocation on any failure (no post-persist cleanup
3030
needed) and `abort` is strict (a missing state token is an error).
31+
- **df provenance completed for every event-study producer that exposes one
32+
(4.0 program Phase 2, ledger row M-092 follow-up).** `SunAbrahamResults` gains
33+
`event_study_df` (a per-relative-time dict: the per-event Bell-McCaffrey contrast df
34+
under `hc2_bm`, the survey design df - post-drop under replicate refits - on survey
35+
fits, NaN where inference is normal-theory or its BM DOF was non-finite), and
36+
`ChaisemartinDHaultfoeuilleResults` gains a scalar `event_study_df` (one value, since
37+
its effect and placebo rows are computed from the same design df and refreshed
38+
together under replicate weights). Both clear under bootstrap, whose percentile
39+
p-values never used a df; SunAbraham's clear is deliberately narrower than its
40+
`event_study_vcov` clear, since a replicate refit's rows genuinely used the replicate
41+
df. `LPDiDResults` gains `pooled_df` (`{"pre": ..., "post": ...}`), the headline
42+
pooled-window analogue of `DiDResults.inference_df`, surfaced in `to_dict()`; the
43+
native pooled frame schema is unchanged. With these, `EventStudyResults.df` is
44+
populated for every producer whose inference records a df.
3145
- **Docs: navigation and SEO polish.** A stable/latest version switcher in the navbar
3246
(`docs/_static/switcher.json`); a custom `robots.txt` that keeps the ~60 thin
3347
`_modules/` source-view pages out of crawlers (documentation pages allowed as
@@ -123,6 +137,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
123137
**Estimator equations, weighting, variance, and numerical output are unchanged.**
124138

125139
### Changed
140+
- **dCDH Phase 1 (`L_max=None`) event-study row is now re-synced by the final-df
141+
refresh.** Under replicate-weight designs the refresh recomputes `overall_*` with the
142+
final effective df, but the Phase 1 event-study row is a VALUE COPY of that inference
143+
taken beforehand, and the refresh's per-horizon loops cover only the Phase 2 surface -
144+
so the copy could in principle retain an intermediate df's t/p/CI while
145+
`survey_metadata.df_survey` reported the final one. The copy is now mirrored from the
146+
refreshed values, making the stored row agree with the overall estimate it duplicates
147+
by construction. No output change is reproducible on any configuration exercised in
148+
the test suite (the contributing sites agree on their valid-replicate counts, so the
149+
intermediate and final df coincide and the mirror is a no-op); this closes the
150+
structural hazard rather than a demonstrated defect.
126151
- **DiagnosticReport's parallel-trends check upgrades from the Bonferroni fallback to
127152
the joint Wald test for StackedDiD and TwoStageDiD event-study fits.** The check's
128153
joint-Wald path runs whenever the source fit exposes `event_study_vcov`; now that

TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ Related tracking surfaces:
2323
|-------|----------|--------|--------|----------|
2424
| `SyntheticControl` conformal (CWZ 2021) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies — the residual-permutation shortcut is only valid for time-permutation-invariant proxies (SC/Lasso/DiD); an AR proxy needs innovation permutation. | `diff_diff/conformal.py`, `diff_diff/synthetic_control_results.py` | CWZ-2021 | Heavy | Low |
2525
| `ContinuousDiD` CGBS-2024 remaining extensions (earlier phases — `covariates=` reg/dr, `treatment_type="discrete"`, single-cohort `control_group="lowest_dose"` with estimand `ATT(d)−ATT(d_L)` — are already supported; see REGISTRY Note #7). Remaining (all deferred `NotImplementedError`, documented): `estimation_method="ipw"` on the dose curve (scalar-adjustment / degenerate); `covariates=` × `survey_design=` (weighted OR + weighted nuisance IF); multi-cohort **heterogeneous-support** discrete aggregation (support-aware: average each dose only over the cohorts that observe it); **multi-cohort `lowest_dose`** (within-cohort `d_L` reference + support-aware cross-cohort aggregation); and **`covariates=` × `lowest_dose`** (conditional-PT-relative-to-`d_L` estimand). Single-cohort / 2-period / shared-support multi-cohort are supported. | `continuous_did.py` | CGBS-2024 | Heavy | Low |
26-
| `event_study_df` provenance for the remaining producers (4.0 program Phase 2 follow-up; the Stacked/TwoStage vcov + CS/MPD/Stacked/TwoStage/LPDiD df round-trip landed with the per-row `EventStudyResults.df` schema). Remaining: SunAbraham (per-event BM Satterthwaite df under `hc2_bm` at `sun_abraham.py:1206`, scalar survey df at `:1256` - mechanism identical to StackedDiD's recorded-at-use dict); dCDH (its event-study inference receives a `df_inference` argument, `chaisemartin_dhaultfoeuille.py:6311`, never stored - needs tracing to the stored rows); LPDiD pooled-window df (popped and discarded at fit assembly). All purely additive: their surfaces show df=NaN ("unexposed") until threaded. | `diff_diff/sun_abraham.py`, `diff_diff/chaisemartin_dhaultfoeuille.py`, `diff_diff/lpdid.py` | #M-092 | Quick | Low |
2726

2827
### Performance
2928

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4039,8 +4039,11 @@ def fit(
40394039
# and HonestDiD. Re-run safe_inference with the FINAL
40404040
# effective df so every surface agrees.
40414041
_final_eff_df = _effective_df_survey(resolved_survey, _replicate_n_valid_list)
4042+
# ONE canonical final inference df, shared by the refresh below and
4043+
# by the `event_study_df` provenance recorded at construction, so the
4044+
# two can never drift apart.
4045+
_final_inf_df = _inference_df(_final_eff_df, resolved_survey)
40424046
if _replicate_n_valid_list:
4043-
_final_inf_df = _inference_df(_final_eff_df, resolved_survey)
40444047
# Recompute `effective_overall_*` directly — that's what
40454048
# ships in results at line ~2776+. `effective_overall_att/se`
40464049
# may differ from the raw `overall_att/se` under the delta
@@ -4087,6 +4090,22 @@ def fit(
40874090
_info_r2["t_stat"] = _t_r2
40884091
_info_r2["p_value"] = _p_r2
40894092
_info_r2["conf_int"] = _ci_r2
4093+
else:
4094+
# Phase 1 (L_max=None): `event_study_effects[1]` is a VALUE
4095+
# COPY of the overall inference, built above from the
4096+
# PRE-refresh `overall_*`; the loop just above covers only
4097+
# the Phase 2 `multi_horizon_inference` surface. Without
4098+
# this mirror the stored l=1 row keeps the INTERMEDIATE
4099+
# df's t/p/CI while `overall_*` and
4100+
# `survey_metadata.df_survey` report the final (tightened)
4101+
# df - the same value-copy hazard the placebo mirror below
4102+
# documents. Re-sync from the refreshed `overall_*` so the
4103+
# single event-study row agrees with the overall estimate it
4104+
# is a copy of.
4105+
if event_study_effects and 1 in event_study_effects:
4106+
event_study_effects[1]["t_stat"] = overall_t
4107+
event_study_effects[1]["p_value"] = overall_p
4108+
event_study_effects[1]["conf_int"] = overall_ci
40904109
if placebo_horizon_inference is not None:
40914110
for _lag_r2, _info_r2 in list(placebo_horizon_inference.items()):
40924111
_t_r2, _p_r2, _ci_r2 = safe_inference(
@@ -4181,6 +4200,24 @@ def fit(
41814200
if survey_metadata is not None:
41824201
survey_metadata.df_survey = _final_eff_df
41834202

4203+
# df PROVENANCE (spec section 5, row M-092): the scalar df every
4204+
# stored event-study / placebo row's safe_inference received.
4205+
# Reuses `_final_inf_df` - the SAME value the refresh above applied
4206+
# to every stored row - so provenance and inference cannot drift.
4207+
# Recorded iff finite and > 0.
4208+
# Cleared under bootstrap, whose percentile p/CIs never used a df:
4209+
# required, not defensive, because TSL survey + bootstrap is a live
4210+
# mode (only REPLICATE + bootstrap is rejected) where this
4211+
# expression evaluates finite.
4212+
_es_df_provenance: Optional[float] = None
4213+
if (
4214+
bootstrap_results is None
4215+
and _final_inf_df is not None
4216+
and np.isfinite(_final_inf_df)
4217+
and _final_inf_df > 0
4218+
):
4219+
_es_df_provenance = float(_final_inf_df)
4220+
41844221
results = ChaisemartinDHaultfoeuilleResults(
41854222
overall_att=effective_overall_att,
41864223
overall_se=effective_overall_se,
@@ -4226,6 +4263,7 @@ def fit(
42264263
event_study_effects=event_study_effects,
42274264
L_max=L_max,
42284265
placebo_event_study=placebo_event_study_dict,
4266+
event_study_df=_es_df_provenance,
42294267
twfe_weights=twfe_weights_df,
42304268
twfe_fraction_negative=twfe_fraction_negative,
42314269
twfe_sigma_fe=twfe_sigma_fe,

diff_diff/chaisemartin_dhaultfoeuille_results.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,15 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults):
372372
``1..L_max`` when ``L_max >= 1``. When ``L_max >= 1``, uses the
373373
per-group ``DID_{g,l}`` path; when ``L_max=None``, uses the
374374
per-period ``DID_M`` path.
375+
event_study_df : float, optional
376+
Inference degrees-of-freedom PROVENANCE for the event-study and
377+
placebo surfaces: the single df every stored row's
378+
``safe_inference`` received. One scalar suffices because both
379+
surfaces are computed from the same design df (and are refreshed
380+
together to the final effective df under replicate weights).
381+
``None`` when the rows used normal theory (no survey design), when
382+
the df was undefined, or when bootstrap overrode the stored
383+
inference with percentile values that never used a df.
375384
normalized_effects : dict, optional
376385
Normalized estimator ``DID^n_l``. Populated when ``L_max >= 1``.
377386
cost_benefit_delta : dict, optional
@@ -567,6 +576,18 @@ class ChaisemartinDHaultfoeuilleResults(BaseResults):
567576
# Dynamic placebos DID^{pl}_l with negative horizon keys.
568577
# None in Phase 1; populated as {-1: {...}, -2: {...}} in Phase 2.
569578
placebo_event_study: Optional[Dict[int, Dict[str, Any]]] = field(default=None, repr=False)
579+
# event_study_df (spec section 5, row M-092): the ONE inference df every
580+
# stored event-study AND placebo row's ``safe_inference`` actually
581+
# received - a single scalar because both surfaces are computed from the
582+
# same design df (and, under replicate weights, are re-run together to
583+
# the final effective df). None means the rows used normal theory (no
584+
# survey design), the df was undefined (the df<=0 replicate sentinel,
585+
# which yields NaN inference), or bootstrap overrode the stored p/CIs
586+
# with percentile values that never used a df. Note the bootstrap clear
587+
# drops the whole channel even when a partial override leaves some rows
588+
# analytic - a conservative under-claim, consistent with the other
589+
# producers.
590+
event_study_df: Optional[float] = field(default=None, repr=False)
570591

571592
# --- TWFE decomposition diagnostic (Theorem 1 of AER 2020) ---
572593
twfe_weights: Optional[pd.DataFrame] = field(default=None, repr=False)

diff_diff/lpdid.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,6 +1460,7 @@ def fit(
14601460
pooled = None
14611461

14621462
event_study_df: Optional[Dict[int, float]] = None
1463+
pooled_df: Optional[Dict[str, float]] = None
14631464
if not only_pooled:
14641465
event_rows = []
14651466
event_study_df = {}
@@ -1528,11 +1529,17 @@ def fit(
15281529
absorb=absorb,
15291530
survey_design=survey_design,
15301531
)
1531-
# Discard the pooled windows' df provenance (native pooled frame
1532-
# schema unchanged; pooled-window df threading is a tracked
1533-
# follow-up in TODO.md).
1534-
pre_estimate.pop("df", None)
1535-
post_estimate.pop("df", None)
1532+
# Capture the pooled windows' df provenance BEFORE building the
1533+
# frame so the native pooled schema stays unchanged (it feeds
1534+
# summary()'s table). Each value is the df that window's
1535+
# safe_inference actually received; NaN when it used normal
1536+
# theory or the df was undefined.
1537+
_pre_df = pre_estimate.pop("df", None)
1538+
_post_df = post_estimate.pop("df", None)
1539+
pooled_df = {
1540+
"pre": float(_pre_df) if _pre_df is not None else float("nan"),
1541+
"post": float(_post_df) if _post_df is not None else float("nan"),
1542+
}
15361543
pooled = pd.DataFrame(
15371544
[
15381545
{
@@ -1566,6 +1573,7 @@ def fit(
15661573
event_study=event_study,
15671574
pooled=pooled,
15681575
event_study_df=event_study_df,
1576+
pooled_df=pooled_df,
15691577
n_obs=len(data),
15701578
n_treated_units=int(treatment_by_unit.gt(0).sum()),
15711579
n_control_units=int(treatment_by_unit.eq(0).sum()),

diff_diff/lpdid_results.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ class LPDiDResults(BaseResults):
2121
``control_group="clean"`` the realized control pool at each horizon also
2222
includes not-yet-treated cohorts, whose per-horizon counts live in the
2323
``n_obs`` / ``n_clusters`` columns of the tables.
24+
25+
``event_study_df`` and ``pooled_df`` carry inference degrees-of-freedom
26+
PROVENANCE - the df each row's ``safe_inference`` actually received -
27+
keyed by horizon and by ``"pre"``/``"post"`` window respectively. Both
28+
are NaN per entry where that row used normal theory or an undefined df.
29+
They are metadata only: the native tables above are unchanged, and
30+
``event_study_df`` is what the unified event-study surface reads.
2431
"""
2532

2633
event_study: Optional[pd.DataFrame]
@@ -63,6 +70,14 @@ class LPDiDResults(BaseResults):
6370
# of which the native tables record. The synthetic ``horizon == -1``
6471
# base row has no entry. None when no event study was fit.
6572
event_study_df: Optional[Dict[int, float]] = None
73+
# pooled_df: the same df PROVENANCE for the POOLED windows, keyed
74+
# ``"pre"`` / ``"post"`` to match the ``window`` column. The pooled-post
75+
# row is the headline ATT, which lives outside the unified event-study
76+
# surface, so this is standalone headline provenance (the
77+
# ``DiDResults.inference_df`` analogue) rather than a surface input. NaN
78+
# per window when that window's inference used normal theory or an
79+
# undefined df; None when no pooled windows were fit (``only_event``).
80+
pooled_df: Optional[Dict[str, float]] = None
6681

6782
# ------------------------------------------------------------------
6883
# internal helpers
@@ -164,6 +179,8 @@ def to_dict(self) -> Dict[str, Any]:
164179
result["cluster_name"] = self.cluster_name
165180
if self.n_clusters is not None:
166181
result["n_clusters"] = self.n_clusters
182+
if self.pooled_df is not None:
183+
result["pooled_df"] = self.pooled_df
167184
if self.non_absorbing is not None:
168185
result["non_absorbing"] = self.non_absorbing
169186
result["stabilization_window"] = self.stabilization_window

diff_diff/results_base.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,11 @@ class EventStudyResults(BaseResults):
214214
from the producer. Accepts None (no df exposed -> all-NaN column), a
215215
scalar (broadcast to every row - e.g. CallawaySantAnna, whose
216216
explicit-survey event study applies ONE conservative df, the minimum
217-
per-horizon effective df, to all rows), or a length-n array (per-row
218-
producers: StackedDiD ``hc2_bm`` per-event Bell-McCaffrey df, LPDiD
219-
per-horizon cluster df, MultiPeriodDiD ``hc2_bm`` per-period df).
217+
per-horizon effective df, to all rows; or de
218+
Chaisemartin-D'Haultfoeuille, whose effect and placebo rows share one
219+
design df), or a length-n array (per-row producers: StackedDiD and
220+
SunAbraham ``hc2_bm`` per-event Bell-McCaffrey df, LPDiD per-horizon
221+
cluster df, MultiPeriodDiD ``hc2_bm`` per-period df).
220222
NaN on any row means normal-theory inference, an undefined df,
221223
bootstrap-overridden inference, or a producer that records none;
222224
reference rows and rows with NaN p-values are always NaN.
@@ -949,6 +951,12 @@ def _from_dcdh(results: Any) -> EventStudyResults:
949951
cband_crit_value=cband_crit,
950952
alpha=getattr(results, "alpha", 0.05),
951953
source=type(results).__name__,
954+
# ONE scalar df for both merged surfaces: the effect rows and the
955+
# placebo rows are computed from the same design df (and, under
956+
# replicate weights, refreshed together to the final effective df),
957+
# so a scalar is faithful - the container broadcasts it and NaN-masks
958+
# the synthesized reference row at 0 plus any NaN-p rows.
959+
df=getattr(results, "event_study_df", None),
952960
)
953961

954962

diff_diff/stacked_did.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -840,9 +840,10 @@ def _refit_stacked(w_r):
840840
# failed OR noise-floor NaN guard fired), emit all-NaN inference
841841
# rather than fall back to normal-theory CIs/p-values. Mirrors
842842
# the fix in LinearRegression.get_inference() from PR #475 R7
843-
# (linalg.py:3689-3706). Without this, safe_inference(df=NaN)
844-
# would pass df comparison >= 0 (NaN < 0 is False) and emit
845-
# finite t_stat with NaN p/CI — silent wrong inference.
843+
# (linalg.py:3689-3706). `safe_inference` itself has guarded
844+
# non-finite / <= 0 df since PR #620 (utils.py) with the same
845+
# all-NaN result; this explicit branch predates that guard and
846+
# is kept for explicitness (it also skips the call entirely).
846847
_is_hc2bm_path = self.vcov_type == "hc2_bm" and not _uses_replicate_sd
847848
_bm_df = _bm_contrast_dof_per_event.get(h)
848849
if _is_hc2bm_path and (_bm_df is None or not np.isfinite(_bm_df)):

0 commit comments

Comments
 (0)