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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
identification).

### Changed
- **DiagnosticReport's event-study-gated checks now consume the post-fit
`results.aggregate('event_study')` surface** (the 3.9 M-020 family;
retires the TODO "diagnostic_report ES-gated checks" row): on a modern
plain fit whose raw `event_study_effects` field is absent, the report
derives the `EventStudyResults` container internally — once, cached,
fail-soft — so `parallel_trends`, `pretrends_power`, `sensitivity`
(CallawaySantAnna; the container is M-093-admitted into
`compute_pretrends_power`/`HonestDiD` with pinned raw-route parity) and
`heterogeneity` (ImputationDiD / TwoStageDiD / ContinuousDiD, whose
plain-fit heterogeneity previously skipped) run without the deprecated
fit-time kwarg. Derived-route sections carry the additive
`pre_period_source="aggregate_event_study"` schema key (BusinessReport
lifts it into its `pre_trends` block), and warnings re-emitted by a kit
recompute are captured and re-published on the consuming section.
Derivation failures fail closed to explicit per-check skip reasons — a
bootstrapped fit's `NotImplementedError`, a kit-less unpickle's
`ValueError` — and the remediation strings no longer steer users to the
dying kwarg (Wooldridge points at its own in-place
`results.aggregate(type='event_study')`; the staggered-DDD reason names
the fit-time kwarg as that surface's canonical route per M-140; a
one-pre-period MultiPeriodDiD fit gets an accurate
reference-period message). Deliberately KEPT: raw-field precedence (a
present `event_study_effects` — the requested-but-empty `{}` sentinel
included — is authoritative and never re-derived, preserving fit-time
`balance_e` semantics), dCDH's `placebo_event_study` branch, the
Spillover/Stacked estimator-accurate messages, the `EventStudyResults`
INPUT rejection at DR/BR construction (admission is its own TODO row),
the M-093 consumer-admission set (CS only on the derived route), and no
auto-call of Wooldridge's mutating `aggregate()`. `applicable_checks`
may now trigger the one cached derivation; the docstring/RST laziness
guarantee is amended accordingly. The report API pages
(`docs/api/diagnostic_report.rst`, `business_report.rst`) migrate their
examples off the fit-time kwarg.
- **Narrative docs migrated off the deprecated fit-time `aggregate=`** (the
3.9 M-020 family; TODO "fit-time aggregate= teachings" sweep, RST half):
`choosing_estimator.rst`, `python_comparison.rst` and `r_comparison.rst` now
Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ Related tracking surfaces:
| `ContinuousDiD.pscore_trim` still validates `0.0 <= x < 0.5`, i.e. it admits `0`, while `TripleDifference` tightened to `0 < x < 0.5` in phase 3(b) (row M-142) on the grounds that `trim=0` disables the `np.clip(pscore, trim, 1-trim)` overlap guard keeping the `1/(1-p)` weights finite. The same argument applies to ContinuousDiD; aligning it was out of scope for a DDD merge and is recorded in the REGISTRY staggered-mode Note rather than left as silent drift. `TripleDifference` additionally gained a TYPE guard in 3(b) (reject bool/non-real-scalar/non-finite BEFORE the range comparison) because a bare `0 < x < 0.5` raises an incidental `TypeError` on `None`/str/complex/list, an ambiguous-truth error on a multi-element array, and silently ACCEPTS a 1-element array as the parameter; `ContinuousDiD`'s `np.isfinite(self.pscore_trim) and ...` has the same hole. Aligning both is one change - promote the guard to a shared `utils.validate_pscore_trim(value, *, allow_zero)` alongside `validate_n_bootstrap` rather than copying it | `diff_diff/continuous_did.py`, `diff_diff/utils.py` | 3(b) | Quick | Low |
| Staggered-mode cluster-robust ANALYTICAL SEs: `cluster=` raises in `TripleDifference`'s staggered mode (and is accepted-then-ignored on the deprecated class), so clustered inference there is bootstrap-only. Implementing a clustered analytical path for the GMM-combined influence function would let the raise become a real lane | `diff_diff/_staggered_triple_diff_engine.py` | 3(b) | Heavy | Low |
| diagnostic_report admission for `EventStudyResults` surfaces (the TWFE event-study mode + `aggregate('event_study')` containers): DiagnosticReport/BusinessReport now REJECT the surface explicitly (Phase 3(a); previously a silent zero-check report / all-null headline) and practitioner_next_steps serves the generic fall-through - admission needs source-aware routing (the type-name-keyed `_APPLICABILITY`/`_HANDLERS` registries cannot discriminate the unified container's producers) and a scalar-vs-per-period headline design; MPD-native results received {parallel_trends, pretrends_power, sensitivity, bacon, design_effect} | `diff_diff/diagnostic_report.py`, `diff_diff/business_report.py`, `diff_diff/practitioner.py` | 3(a) | Mid | Medium |
| DiagnosticReport public skip bookkeeping omits RUNNER-level skips: `applicable_checks` reflects only gate outcomes, so a check whose runner returns `status="skipped"` (heterogeneity's empty/non-finite branches, `_pt_event_study`'s empty-coefs branch - a pre-existing pattern, now also reachable via a failed post-fit event-study derivation on bootstrapped / kit-less ImputationDiD/TwoStageDiD/ContinuousDiD fits) stays listed as applicable while `skipped_checks`/`schema["skipped"]` omit it, so automation reading the public fields can misclassify the check as completed; reconcile runner-returned skipped sections into the public bookkeeping (all checks, one convention) or resolve those availabilities at the gate | `diff_diff/diagnostic_report.py` | derived-ES review | Mid | Low |
| Align the NATIVE pretrends pre_periods= contract with the container routes' fail-closed validation: `_extract_pre_period_params`'s MPD branch silently filters an explicit `pre_periods=` selection (unknown labels, the reference, unusable-inference rows dropped without error, caller order preserved) while both container routes validate every requested label and enforce calendar chronology (the relative route since M-024; the calendar route since 3(a) R8) - the relative route also silently collapses DUPLICATE requested labels (the calendar route rejects them), and both filter on SE only while the calendar route additionally requires a finite EFFECT (3(a) R9); one contract across all three routes, with pinned rejection messages | `diff_diff/pretrends.py` | 3(a) R8 | Quick | Low |
| `EventStudyResults` inference-provenance fields: the container records no `vcov_type`/`cluster_name`/`n_clusters`/`df_convention`/Conley metadata, so a serialized surface cannot distinguish unit auto-clustering from explicit clustering, survey, Conley, or the one-way carve-out (3(a) R9 review). Adding them is a cross-producer M-092 schema amendment (six builders, to_dict/summary rendering, surface-suite pins) - follow the pre-cut amendment convention (optional fields appended last, ledger note same-diff) rather than bolting onto one producer | `diff_diff/results_base.py` | 3(a) R9 | Mid | Low |
| Opt-in singleton-group pruning for TwoWayFixedEffects (static + event-study mode; reghdfe parity): singleton units/periods are currently RETAINED class-wide - the within-demeaned row is zero so points are unchanged, but N/G/residual-df count it and CR1/finite-sample SEs shift (~0.41019 -> 0.40962 measured; REGISTRY "Deviation from R" Note, R5 review) - reghdfe iteratively drops singletons by default while fixest retains them (diff-diff matches fixest); an opt-in knob needs iterative unit+period pruning with consistent cluster/survey/replicate/Conley array subsetting and a default-flip decision protocol (moves published SEs) | `diff_diff/twfe.py`, `diff_diff/estimators.py`, `diff_diff/utils.py` | 3(a) R5 | Mid | Low |
| Cohort-timing validation input for the simultaneous-adoption event-study family (TWFE `event_study=True` + MultiPeriodDiD through 3.9): an optional `first_treat=`/`cohort=` column so simultaneous adoption becomes checkable under the contract-valid time-invariant `D_i` indicator - today the staggered-adoption advisory derives timing from within-unit 0->1 transitions, so it can only fire on off-contract time-varying `D_it` input, and with valid `D_i` adoption timing is not observable in the inputs at all (REGISTRY "staggered-adoption detection limit" Notes, both sections); design questions: validate-only vs steering error, and interplay with the M-011 removal | `diff_diff/twfe.py`, `diff_diff/estimators.py` | 3(a) R2 | Mid | Medium |
| EfficientDiD `aggregate()` recompute levels (event_study/group) on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence); wiring `BootstrapReplaySpec` (or retaining the n_bootstrap x n_gt draw matrix materialized at fit) would enable exact post-fit replay of percentile inference | `diff_diff/efficient_did_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3a | Mid | Low |
| ImputationDiD/TwoStageDiD `aggregate()` recompute levels on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence; M-021/M-022); ImputationDiD's per-target psi machinery makes seeded replay tractable (the panel-backed kit retains everything the psi precompute reads), TwoStageDiD's per-level GMM scores are function-locals and would need retention | `diff_diff/imputation_results.py`, `diff_diff/two_stage_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3b | Mid | Low |
| ContinuousDiD `aggregate('event_study')` on bootstrapped fits fails closed (M-025); a seeded post-fit bootstrap-ES replay is tractable - the multiplier draws are seeded (`np.random.default_rng(self.seed)`) - but needs the FULL per-cell `_bootstrap_info` (bread/ee_treated/Psi_eval/dPsi_*/beta_pred) the pruned kit deliberately drops, so shipping it means a kit-payload change with its own memory contract | `diff_diff/continuous_did_aggregation.py`, `diff_diff/continuous_did_results.py` | 2(b) PR-3c | Mid | Low |
| diagnostic_report's ES-gated checks read the raw `event_study_effects` field, which post-fit `results.aggregate()` never populates - their remediation strings steer users to the deprecated fit-time kwarg (qualified "deprecated but functional until 4.0" since 2(b) PR-3b); teach the checks to consume a post-fit container (or recompute via the kit) before 4.0 removes the kwarg. The report API pages deliberately keep their fit-time examples until this lands (post-fit-aggregated results would produce empty ES read-outs) | `diff_diff/diagnostic_report.py`, `docs/api/business_report.rst`, `docs/api/diagnostic_report.rst` | 2(b) PR-3b | Mid | Medium |
| EfficientDiD, ImputationDiD, ContinuousDiD and HeterogeneousAdoptionDiD are the outstanding M-092 event-study df-provenance holes: the container's per-row df is all-NaN even on survey fits where a finite `_survey_df` governed the p-values (the container-level scalar `df_survey` IS exposed - the hole is the PER-ROW column only; no event_study_df/df_inference field; pre-existing, NOT a regression of the M-023 PR - today's builder output is identical). The kits now retain the scalar (ImputationDiD's since 2(b) PR-3b, ContinuousDiD's since 2(b) PR-3c - same shape: scalar `df_survey` exposed, per-row column all-NaN, identical to each fit-time surface); threading it into the per-row channel is a contained follow-up | `diff_diff/efficient_did_results.py`, `diff_diff/imputation_results.py`, `diff_diff/continuous_did_results.py`, `diff_diff/results_base.py` | 2(b) PR-3a | Quick | Low |
| 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 |
Expand Down
15 changes: 9 additions & 6 deletions diff_diff/_reporting_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
the horizon / group target.
- ``CallawaySantAnna``: ``overall_att`` is cohort-size-weighted
across post-treatment ``ATT(g, t)`` cells regardless of the
fit-time ``aggregate`` kwarg. The event-study / group
aggregations live on dedicated fields
(``event_study_effects`` / ``group_effects``).
fit-time ``aggregate`` kwarg. The event-study / group tables are
produced post-fit via ``results.aggregate('event_study'/'group')``
(the deprecated fit-time ``aggregate=`` kwarg populates the legacy
``event_study_effects`` / ``group_effects`` fields until 4.0).
- ``ContinuousDiD``: the regime (PT vs. SPT) is a user-level
assumption, not a library setting. The ``definition`` names
both regime readings (``ATT^loc`` under PT,
Expand Down Expand Up @@ -137,9 +138,11 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
"A cohort-size-weighted average of group-time ATTs "
"``ATT(g, t)`` across post-treatment cells (``t >= g``). "
"``overall_att`` is the simple-aggregation headline regardless "
"of the fit-time ``aggregate`` kwarg; event-study and group "
"aggregations populate ``event_study_effects`` / "
"``group_effects`` fields when requested."
"of aggregation choices; event-study and group tables are "
"produced post-fit via "
"``results.aggregate('event_study'/'group')`` (the deprecated "
"fit-time ``aggregate=`` kwarg populates the legacy "
"``event_study_effects`` / ``group_effects`` fields until 4.0)."
),
"aggregation": "simple",
"headline_attribute": "overall_att",
Expand Down
28 changes: 23 additions & 5 deletions diff_diff/business_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@

- Plain English, not academic jargon. The library ships this in addition to, not
in place of, the estimator's existing ``results.summary()`` academic output.
- No estimator fitting and no variance re-derivation. Every effect, SE, p-value,
CI, and sensitivity bound is either read from ``results`` or produced by an
existing diff-diff utility. The report layer does compose a few cross-period
- No estimator fitting. Every effect, SE, p-value, CI, and sensitivity bound
is either read from ``results``, derived by the auto-constructed
``DiagnosticReport`` from the result's own post-fit
``aggregate('event_study')`` surface (a view or retained-kit recompute,
used only when the raw ``event_study_effects`` field is absent; see the
``DiagnosticReport`` module docstring), or produced by an existing
diff-diff utility. The report layer does compose a few cross-period
summaries from per-period inputs already on the result (joint-Wald / Bonferroni
pre-trends p-value, MDV-to-ATT ratio, heterogeneity dispersion over
post-treatment effects); see ``docs/methodology/REPORTING.md`` for the full
Expand Down Expand Up @@ -351,8 +355,10 @@ def __init__(
"precomputed= contains keys that are not implemented: "
f"{sorted(_br_unsupported)}. Supported keys: "
f"{sorted(_br_supported_precomputed)}. ``design_effect``, "
"``heterogeneity``, and ``epv`` are read directly from the "
"fitted result and do not accept precomputed overrides."
"``heterogeneity``, and ``epv`` are read from the fitted "
"result (heterogeneity may also derive the post-fit "
"aggregate('event_study') surface) and do not accept "
"precomputed overrides."
)

resolved_alpha = alpha if alpha is not None else getattr(results, "alpha", 0.05)
Expand Down Expand Up @@ -941,6 +947,10 @@ def _lift_pre_trends(dr: Optional[Dict[str, Any]]) -> Dict[str, Any]:
return {
"status": pt.get("status", "not_run"),
"reason": pt.get("reason"),
# DR attaches derived-surface provenance to gate-skipped
# sections too (a successful-but-empty derivation) — the skip
# path must not drop it (None on raw routes, same as below).
"pre_period_source": pt.get("pre_period_source"),
}
return {
"status": "computed",
Expand All @@ -956,6 +966,14 @@ def _lift_pre_trends(dr: Optional[Dict[str, Any]]) -> Dict[str, Any]:
# ``verdict == "inconclusive"`` per ``_pt_event_study``'s
# inconclusive branch (``diagnostic_report.py:999``).
"n_dropped_undefined": pt.get("n_dropped_undefined"),
# Provenance of the pre-period surface: "aggregate_event_study"
# when DR derived it from the post-fit
# ``results.aggregate('event_study')`` container. BR always emits
# the key — ``None`` on raw-field routes (DR itself omits the key
# there; ``dict.get`` maps that to None). Lifted explicitly for
# the same reason as ``n_dropped_undefined`` — this function is a
# field whitelist.
"pre_period_source": pt.get("pre_period_source"),
"reason": pt.get("reason"),
# Carry the denominator df through when the survey F-reference
# branch was used so BR consumers can flag the finite-sample
Expand Down
Loading
Loading