Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 79 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **StackedDiD post-fit `aggregate()` + honest/pretrends container
admission** (v4 program 2(b) PR-2; ledger row [M-024] flips to
shimmed, row [M-093] amended a second time).
- The StackedDiD event-study surface is now ALWAYS computed at fit:
the pooled regression always included the event-time interactions,
so only the extraction and (under `hc2_bm`) the per-event
Bell-McCaffrey contrast DOFs were gated on fit-time `aggregate=` -
both gates are gone. Consequences on plain fits: `summary()`
renders the event-study table, `to_dataframe('event_study')` is
total, `plot_event_study` renders (previously a `TypeError`),
`event_study_effects`/`event_study_vcov`/`event_study_df` are
always populated, and DiagnosticReport's `heterogeneity` (always)
and `parallel_trends` (`kappa_pre >= 2`) checks now run - which
`BusinessReport(auto_diagnostics=True)` surfaces too. The `hc2_bm`
overall-ATT contrast now shares the multi-contrast DOF batch,
whose degeneracy guard is batch-relative: the noise-floor
carve-out on the R `Wald_test` HTZ parity claim widens
quantitatively (fail-closed NaN + a loud warning when tripped -
behavior unchanged, trigger condition wider; REGISTRY M-024 Note).
- `StackedDiD.fit(aggregate=)` is DEPRECATED ([M-024]): supplying it
(any value, `None` included) warns; the parameter is behaviorally
inert (CS-style warn-and-still-work - results are identical to a
plain fit's), and the `group`/`all`/unknown-value `ValueError`s
persist after the warning. The `stacked_did()` convenience wrapper
forwards the shim. `StackedDiDResults.aggregate()` ships as a pure
VIEW: `'event_study'` returns the unified container (with
`base_period='universal'` and singleton `reference_event_times`
provenance - one omitted reference per fit by construction);
`'simple'` relays `overall_att/se/t/p/CI` bit-exactly with
`target='att'` (the post-period average, not the per-event trimmed
aggregate ATT), `n = n_treated_units` (`'units'`; treated-only
scope - the treated and clean-control unit sets overlap across
sub-experiments, so no disjoint total exists) and
`df = inference_df`. Every stored inference mode relays (survey
TSL, replicate refit, `hc2_bm` fail-close); `group`/`calendar`
fail closed, `balance_e` applies to no level, `weights=` rejected.
- `compute_honest_did` and `compute_pretrends_power` now ADMIT
Stacked-sourced containers ([M-093] second pre-cut amendment):
requires `kappa_pre >= 2` (the default grid has no estimated
pre-periods) and, for HonestDiD, a non-singular FULL retained
event-study covariance - the pre+post sub-block, per
Rambachan-Roth Assumption 3 (keep `kappa_pre` small relative to
the cluster count).
Analytical Stacked containers carry `df_survey=None` - honest FLCI
critical values are normal-theory there (deliberate; REGISTRY
Note). Rows whose per-row inference the producer withheld (finite
`se`, non-finite `p_value` - the `hc2_bm` BM-DOF fail-close and
replicate-undefined designs) are admitted with a source-scoped
warning in BOTH consumers: bounds/power consume only the point
estimates and covariance, which remain valid (at the replicate
`0.0` sentinel, honest's identified-set bounds stay finite while
its FLCI CI endpoints are NaN). The seven producer-derived guard
messages in both container branches now derive the producer from
`surface.source` with producer-conditional remedies. A new
reference-support guard fails `fit()` closed when either omitted-
reference cell (treated / control) carries no positive COMPOSED
WLS weight - raw row presence is not support in a Q-weighted
regression: gapped panels empty the cell outright, aggregate
Q-weights zero a sub-experiment's controls wherever it has no
treated rows, and survey pweights can zero a cell. Previously
these rank-dropped and silently re-normalized while the surface
certified a `delta_0 = 0` reference that never existed.
- Cross-producer container hardening: `EventStudyResults`'s
`__post_init__` now COPIES `vcov`/`vcov_index` like every other
array field (`np.asarray` aliased the producer's stored matrix on
the post-fit view route, so mutating a container could corrupt the
fitted result; `vcov_index` keeps its native dtype - int labels
never become floats).
- The practitioner "Check sub-experiment balance" step moved off the
shared `step_name='heterogeneity'` key (now
`'sub_experiment_balance'`): with the heterogeneity check running
on every plain fit, the collision silently dropped that unrelated
advice from `next_steps`. Sibling producer-side collisions on
other estimators are recorded in TODO.md.
- **Event-study container consumers + dCDH post-fit `aggregate()`** (v4
program 2(b) PR-1; ledger row [M-026] flips to shimmed, rows [M-092] /
[M-093] amended).
Expand All @@ -20,11 +94,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
bounds are identical across routes (route-parity gated at 1e-14);
PreTrendsPower's extraction is bit-exact (its Monte-Carlo power
carries scipy's inherent MVN-CDF jitter either way). Admission in
HonestDiD/PreTrendsPower is SOURCE-SCOPED to CS-produced containers:
dCDH l1 containers are rejected BY DESIGN (their placebo semantics
need HonestDiD's native dCDH branch), other producers arrive with
their own `aggregate()` migrations; the plotters take no source
guard. Inputs whose coefficients mix reference normalizations fail
HonestDiD/PreTrendsPower is SOURCE-SCOPED (initially CS-only;
widened to Stacked-sourced containers with [M-024] below): dCDH l1
containers are rejected BY DESIGN (their placebo semantics need
HonestDiD's native dCDH branch), other producers arrive with their
own `aggregate()` migrations; the plotters take no source guard. Inputs whose coefficients mix reference normalizations fail
closed in HonestDiD and PreTrendsPower on BOTH routes - a container
with multiple marked reference rows, and any universal-base fit
whose cohorts carry more than one positional-base event time (the
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ For rigorous DiD analysis, follow these 8 steps. Skipping diagnostic steps produ
3. **Test parallel trends** - simple 2x2: `check_parallel_trends()`, `equivalence_test_trends()`; staggered: inspect CS event-study pre-period coefficients (generic PT tests are invalid for staggered designs). Insignificant pre-trends do NOT prove PT holds.
4. **Choose estimator** - staggered adoption -> CS/SA/BJS (NOT plain TWFE); few treated units -> SDiD; factor confounding -> TROP; simple 2x2 -> DiD. Run `BaconDecomposition` to diagnose TWFE bias.
5. **Estimate** - `estimator.fit(data, ...)`. Always print the cluster count first and choose inference method based on the result (cluster-robust if >= 50 clusters, wild bootstrap if fewer).
6. **Sensitivity analysis** - `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs.
7. **Heterogeneity** - CS: `results.aggregate('group')`/`'event_study'` (post-fit, no refit); SA: `results.event_study_effects` / `to_dataframe(level='cohort')`; subgroup re-estimation.
6. **Sensitivity analysis** - `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; a StackedDiD `results.aggregate('event_study')` container also admits - needs `kappa_pre >= 2`), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs.
7. **Heterogeneity** - CS: `results.aggregate('group')`/`'event_study'` (post-fit, no refit); SA: `results.event_study_effects` / `to_dataframe(level='cohort')`; Stacked: `results.aggregate('event_study')`/`'simple'` post-fit views (surface always computed since 3.9); subgroup re-estimation.
8. **Robustness** - compare 2-3 estimators (CS vs SA vs BJS), report with and without covariates (shows whether conditioning drives identification), present pre-trends and sensitivity bounds.

Full guide: `diff_diff.get_llm_guide("practitioner")`.
Expand Down
3 changes: 2 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Related tracking surfaces:

| Issue | Location | Origin | Effort | Priority |
|-------|----------|--------|--------|----------|
| practitioner `step_name="heterogeneity"` producer-side collisions: three OTHER estimators' advice steps reuse the key with non-heterogeneity labels (`:975` ContinuousDiD dose-response, `:1022` Triple placebo-group, `:1413` LPDiD WAS arrays), so DiagnosticReport's heterogeneity completion silently drops that unrelated advice from `next_steps` via `_filter_steps` - the same latent collision fixed for StackedDiD in M-024 (renamed to `sub_experiment_balance`). Renaming these changes those estimators' report output; audit + rename with per-estimator pins. | `diff_diff/practitioner.py` | 2(b) PR-2 review R9 | Quick | Low |
| PreTrendsPower `violation='linear'` on CS `base_period='varying'` input targets the wrong alternative: `δ_pre = M · \|t\|` assumes level coefficients against a common reference, but varying-base pre-treatment effects are consecutive-period comparisons (constant increments under a linear trend). Both CS-sourced routes now WARN (REGISTRY PreTrendsPower Note), and universal-base GAPPED grids fail closed via the `reference_event_times` common-reference guard; what remains is the varying-base resolution - either transforming the violation vector through each coefficient's actual base mapping (needs per-horizon base provenance) or requiring `base_period='universal'` for the linear benchmark - a per-estimator methodology decision with a hand-calculated linear-violation gate | `diff_diff/pretrends.py` | 2(b) PR-1 R5 | Mid | Medium |
| `run_placebo_test`/`run_all_placebo_tests` expose ONE overloaded `time` param (calendar for timing/group tests, 0/1 post dummy forwarded into the M-137/M-138 callees - works only because a two-period 0/1 calendar column is both); redesign the wrapper signatures (dual `time=`/`post=` params or a split) so each callee gets an honestly-named argument | `diff_diff/diagnostics.py` | 2(c)-ii PR-B | Mid | Medium |
| `absorbed_fe_rank` N>=3 general rank: the helper keeps `sum(levels-1)` for 3+ absorbed dims, exact for independent connected dims but an over-count for duplicated/nested triples (measured `a(5),b(4),c==b(4)`: true 7 vs formula 10) and for disconnected N-way graphs. Two-way is component-exact. Deriving general N-way FE rank is a hypergraph problem; do it with a reference (fixest's `fixef.rm`/reghdfe df_a) rather than a guess. See `docs/methodology/variance-conventions.md` D3. | `diff_diff/utils.py` | #variance-inventory | Mid | Low |
Expand Down Expand Up @@ -65,7 +66,7 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m
| MMM interop PR-B: calibration tutorial notebook (fit DiD/CS -> scope -> `to_pymc_marketing_lift_test` / `to_meridian_roi_prior`) + a `llms-practitioner.txt` Step 8 pointer to the exporters as the MMM hand-off. | `docs/tutorials/`, `diff_diff/guides/llms-practitioner.txt` | mmm-interop | Mid | Low |
| Tracking-file contract guard test: reject NEW active deferred-work pointers at `TODO.md` (deferred rows live in `DEFERRED.md`; allowlist for historical/past-tense prose and actionable-row pointers) and assert rows cross-linking a `docs/v4-deprecations.yaml` `M-xxx` id don't restate ledger status. Origin: tracking-split local review R2. | `tests/`, `TODO.md`, `DEFERRED.md` | tracking-split | Quick | Low |
| Real-data CI canary for dataset-backed replication tests: `test_methodology_lwdid.py`'s Prop 99 / Walmart goldens skip (visibly) when loaders fall back to synthetic; add a lane or canary asserting `df.attrs["source"] == "lwdid_ssc_ancillary"` in CI so network regressions cannot silently de-gate the replication tests. Follow-on from the loader-fallback repair (#723), which made provenance explicit but deliberately did not add a network-dependent CI lane. | `tests/test_methodology_lwdid.py`, `.github/workflows/` | LWDiD validation suite | Quick | Low |
| df-provenance completion follow-up (PR C descoping): a Wooldridge public per-row df channel (a per-key dict that can also carry the hc2_bm BM contrast dofs the scalar `_df_analytic_fallback` cannot represent — the unified surface's Wooldridge df column stays NaN until then); ImputationDiD lead-horizon `event_study_df` (needs the `_compute_lead_coefficients`/`_aggregate_event_study` return-path change plus bootstrap-clearing semantics for post rows); and the `inference_df` (DiD/MPD/SA/StackedDiD) vs `df_inference` (CS/dCDH) adapter-naming unification. | `diff_diff/wooldridge_results.py`, `diff_diff/imputation.py`, `diff_diff/results_base.py` | #variance-PR-C | Mid | Low |
| df-provenance completion follow-up (PR C descoping): a Wooldridge public per-row df channel (a per-key dict that can also carry the hc2_bm BM contrast dofs the scalar `_df_analytic_fallback` cannot represent — the unified surface's Wooldridge df column stays NaN until then); ImputationDiD lead-horizon `event_study_df` (needs the `_compute_lead_coefficients`/`_aggregate_event_study` return-path change plus bootstrap-clearing semantics for post rows); and the `inference_df` (DiD/MPD/SA/StackedDiD) vs `df_inference` (CS/dCDH) adapter-naming unification. NOTE (M-024): StackedDiD's `df_survey=None` on analytical fits is DELIBERATE - the container resolver's `df_inference` fallback not matching `inference_df` is load-bearing (normal-theory FLCI; see the REGISTRY StackedDiD M-024 Note and the pin in `tests/test_event_study_consumers.py`) - the unification must preserve that outcome or renegotiate it explicitly. | `diff_diff/wooldridge_results.py`, `diff_diff/imputation.py`, `diff_diff/results_base.py` | #variance-PR-C | Mid | Low |
| `worktree-rm` safety via a tested argv helper: the prose rewrite (ask-before-remove confirmation gate, detached-HEAD reachability/rescue, tip-identity force-delete guard) was reverted to the main version because editing the prose repeatedly reintroduced shell-injection (last: sourcing a state file built from a git-derived branch name). Restore those guards in a `worktree_rm.py` that takes the name via file ingress, invokes git through argv arrays, and has a metacharacter-branch/path injection regression test — the pattern that worked for `pr_prepare.py`/`premerge_scan.py`. | `.claude/commands/worktree-rm.md`, `.claude/scripts/` | skill-audit | Heavy | Medium |
| `premerge_scan.py` should scan the staged blob (`git show :path`) for staged methodology files, not the working-tree copy — a stage-then-revert-working-copy edit currently reads the safe working version and misses the staged violation. Union staged-index findings with unstaged/untracked filesystem findings. | `.claude/scripts/premerge_scan.py` | skill-audit | Mid | Low |
| Re-add committed-range methodology scanning to `/push-pr-update` §3b (clean tree, commits ahead) using `premerge_scan.py --range`, with the comparison ref passed as **data** (resolved into a quoted variable in one Bash call, never a raw `<placeholder>`). It was removed to avoid ref interpolation; the helper already implements and tests `--range`. | `.claude/commands/push-pr-update.md` | skill-audit | Quick | Low |
Expand Down
6 changes: 4 additions & 2 deletions benchmarks/python/benchmark_stacked_did.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,20 @@ def main():
kappa_pre=kappa_pre,
kappa_post=kappa_post,
weighting="aggregate",
clean_control="not_yet_treated",
control_group="not_yet_treated",
cluster="unit",
)

# The event-study surface is always computed at fit (3.9, row M-024);
# the deprecated aggregate= kwarg would emit a FutureWarning inside
# the timed block and contaminate the measurement.
with Timer() as estimation_timer:
results = est.fit(
df,
outcome="outcome",
unit="unit",
time="time",
first_treat="first_treat",
aggregate="event_study",
)

estimation_time = estimation_timer.elapsed
Expand Down
20 changes: 18 additions & 2 deletions diff_diff/diagnostic_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,20 @@ def _instance_skip_reason(self, check: str) -> Optional[str]:
"SpilloverDiD(..., event_study=True) to populate the "
"per-event-time direct-effect output."
)
# StackedDiD (3.9, row M-024): the event-study surface
# is ALWAYS computed, so ``aggregate='event_study'`` is
# inert (and deprecated - it now warns). Zero estimated
# pre-periods here means the event window has none:
# the default ``kappa_pre=1`` grid minus the reference
# leaves no pre-period columns. Estimator-accurate
# remediation, per the SpilloverDiD precedent above.
if name == "StackedDiDResults":
return (
"No pre-period event-study coefficients exist on "
"this fit: the event window has no estimated "
"pre-periods. Re-fit with kappa_pre >= 2 so "
"pre-treatment event-study coefficients exist."
)
return (
"No pre-period event-study coefficients are exposed on "
"this fit. For staggered estimators, re-fit with "
Expand Down Expand Up @@ -1774,8 +1788,10 @@ def _infer_cov_source(source_fit: Any) -> str:
``event_study_vcov is None`` (bootstrap or replicate-weight
CS / SA / TwoStageDiD fits, plus ImputationDiD / EfficientDiD /
etc. which don't yet expose ``event_study_vcov``; StackedDiD
persists its VCV in every inference mode, so it reaches this
fallback only when no event study was requested);
persists its VCV in every inference mode and, since 3.9
(row M-024), always materializes the event-study surface, so
post-3.9 StackedDiD fits never reach this fallback - only
pre-3.9 pickles can);
OR ``MultiPeriodDiDResults`` without ``interaction_indices``
(genuine diag-only path inside ``pretrends.py:_extract_pre_period_params``,
no "available but unused" concern, so no downgrade applies).
Expand Down
9 changes: 5 additions & 4 deletions diff_diff/guides/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,7 @@ stacked.fit(
unit: str,
time: str,
first_treat: str,
aggregate: str = None, # None, "simple", or "event_study"
aggregate: Any = NOT_SUPPLIED, # DEPRECATED (M-024): warns; surface always computed - use results.aggregate() post-fit
population: str = None, # Required when weighting="population"
covariates: list[str] = None, # Columns to balance (requires balance="entropy"); values read at t=a-1-anticipation; balanced windows only
) -> StackedDiDResults
Expand All @@ -1045,10 +1045,11 @@ from diff_diff import StackedDiD, plot_event_study

est = StackedDiD(kappa_pre=2, kappa_post=2)
results = est.fit(data, outcome='outcome', unit='unit',
time='period', first_treat='first_treat',
aggregate='event_study')
time='period', first_treat='first_treat')
results.print_summary()
plot_event_study(results)
es = results.aggregate('event_study') # unified container (M-024)
simple = results.aggregate('simple') # one-row overall relay
```

### EfficientDiD
Expand Down Expand Up @@ -1760,7 +1761,7 @@ ImputationDiDResults, TwoStageDiDResults, StackedDiDResults, and EfficientDiDRes
| `overall_t_stat` | `float` | T-statistic |
| `overall_p_value` | `float` | P-value |
| `overall_conf_int` | `tuple[float, float]` | CI |
| `event_study_effects` | `dict[int, dict]` | Event study effects (if aggregate includes event_study) |
| `event_study_effects` | `dict[int, dict]` | Event study effects (if aggregate includes event_study; ALWAYS populated for StackedDiD since 3.9 - row M-024) |
| `group_effects` | `dict` | Group-level effects (if aggregate includes group) |
| `groups` | `list` | Treatment cohorts |
| `time_periods` | `list` | All time periods |
Expand Down
Loading