From 4c8e76daf0b22db77a3ce7cba45bcea02ca287e7 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 6 Sep 2026 18:07:37 -0400 Subject: [PATCH 1/3] Add DurationDiD for absorbing individual outcomes --- METHODOLOGY_REVIEW.md | 13 +- README.md | 8 +- ROADMAP.md | 11 +- changelog.d/20260906-duration-did.md | 7 + diff_diff/__init__.py | 5 + diff_diff/_reporting_helpers.py | 48 +- diff_diff/business_report.py | 159 +++- diff_diff/diagnostic_report.py | 53 +- diff_diff/duration_did.py | 626 ++++++++++++++++ diff_diff/duration_did_results.py | 326 ++++++++ diff_diff/guides/llms-autonomous.txt | 48 +- diff_diff/guides/llms-full.txt | 150 +++- diff_diff/guides/llms-practitioner.txt | 37 +- diff_diff/guides/llms.txt | 12 +- diff_diff/practitioner.py | 49 ++ .../_autosummary/diff_diff.DurationDiD.rst | 17 + .../diff_diff.DurationDiDPretestResults.rst | 40 + .../diff_diff.DurationDiDResults.rst | 64 ++ docs/api/business_report.rst | 42 ++ docs/api/diagnostic_report.rst | 46 ++ docs/api/duration_did.rst | 204 +++++ docs/api/index.rst | 4 + docs/choosing_estimator.rst | 24 +- docs/doc-deps.yaml | 82 ++ docs/index.rst | 2 + docs/methodology/REGISTRY.md | 68 ++ docs/methodology/REPORTING.md | 39 + docs/practitioner_decision_tree.rst | 48 +- docs/practitioner_getting_started.rst | 8 +- docs/references.rst | 8 + docs/survey-roadmap.md | 1 + docs/tutorials/33_duration_did.ipynb | 705 ++++++++++++++++++ docs/tutorials/index.rst | 7 + tests/helpers/results_foundation.py | 28 + tests/test_base_estimator.py | 1 + tests/test_diagnostic_marker.py | 1 + tests/test_doc_deps_integrity.py | 38 + tests/test_docs_ia.py | 40 + tests/test_duration_did.py | 244 ++++++ tests/test_duration_did_reporting.py | 270 +++++++ tests/test_duration_did_results.py | 140 ++++ tests/test_guides.py | 66 ++ tests/test_methodology_duration_did.py | 208 ++++++ tests/test_naming_guard.py | 5 + tests/test_results_serialization.py | 1 + tests/test_v4_inference_policy.py | 4 + 46 files changed, 3957 insertions(+), 50 deletions(-) create mode 100644 changelog.d/20260906-duration-did.md create mode 100644 diff_diff/duration_did.py create mode 100644 diff_diff/duration_did_results.py create mode 100644 docs/api/_autosummary/diff_diff.DurationDiD.rst create mode 100644 docs/api/_autosummary/diff_diff.DurationDiDPretestResults.rst create mode 100644 docs/api/_autosummary/diff_diff.DurationDiDResults.rst create mode 100644 docs/api/duration_did.rst create mode 100644 docs/tutorials/33_duration_did.ipynb create mode 100644 tests/test_duration_did.py create mode 100644 tests/test_duration_did_reporting.py create mode 100644 tests/test_duration_did_results.py create mode 100644 tests/test_methodology_duration_did.py diff --git a/METHODOLOGY_REVIEW.md b/METHODOLOGY_REVIEW.md index a4baf8cce..233514f18 100644 --- a/METHODOLOGY_REVIEW.md +++ b/METHODOLOGY_REVIEW.md @@ -24,7 +24,7 @@ A **Complete** entry has a documented review pass against the primary academic s The catalog grew incrementally over several quarters, so formats vary across the existing Complete entries; the consistent invariant is that someone walked through the implementation against the academic source and captured the result here. New reviews going forward should aim for the fuller structure (Verified Components + Corrections Made + Deviations + dedicated methodology test file) used by the more recent entries. -**In Progress** entries have a REGISTRY.md section and unit-test coverage but no formal walk-through captured here yet, carrying a "Documentation in place" / "Outstanding for promotion" pair until promoted. **As of 2026-06-27 no In Progress rows remain** — every estimator, diagnostic, and cross-cutting inference feature has been reviewed to Complete (Survey Data Support was the last, promoted 2026-06-27). The band description is retained for surfaces that enter the tracker later. +**In Progress** entries have a REGISTRY.md section and unit-test coverage but no formal walk-through captured here yet, carrying a "Documentation in place" / "Outstanding for promotion" pair until promoted. The earlier consolidation completed on 2026-06-27. DurationDiD now enters as **In Progress** pending a recorded primary-source implementation walk-through. **Not Started** entries have neither a tracker walk-through nor an REGISTRY.md section. This tracker no longer carries any Not Started rows; new estimators are expected to enter as In Progress when their REGISTRY entry lands. @@ -39,6 +39,7 @@ The catalog grew incrementally over several quarters, so formats vary across the | DifferenceInDifferences | `estimators.py` | `fixest::feols()` | **Complete** | 2026-01-24 | | MultiPeriodDiD | `estimators.py` | `fixest::feols()` | **Complete** | 2026-02-02 | | TwoWayFixedEffects | `twfe.py` | `fixest::feols()` | **Complete** | 2026-02-08 | +| DurationDiD | `duration_did.py` | Deaner–Ku paper (no executed software parity) | **In Progress** | — | ### Staggered Treatment Estimators @@ -93,13 +94,19 @@ The catalog grew incrementally over several quarters, so formats vary across the **Status legend** (matches the contract in [§ What "Complete" means in this tracker](#what-complete-means-in-this-tracker) above): - **Not Started**: No REGISTRY.md entry yet. Reserved for future surfaces; this tracker currently carries no Not Started rows. -- **In Progress**: REGISTRY.md entry and unit-test coverage exist, but no formal walk-through has been captured in this document yet (a "Documentation in place" / "Outstanding for promotion" pair tracks the gap). No rows currently carry this status — the tracker is fully Complete as of 2026-06-27. +- **In Progress**: REGISTRY.md entry and unit-test coverage exist, but no formal walk-through has been captured in this document yet (a "Documentation in place" / "Outstanding for promotion" pair tracks the gap). DurationDiD currently carries this status. - **Complete**: A documented review pass against the primary academic source is captured here (minimum: Corrections Made, Deviations or `(None)`, and Verified Components / Edge Cases Verified / R Comparison Results in some form). --- ## Detailed Review Notes +### DurationDiD (Core DiD) — In Progress + +**Documentation in place:** REGISTRY.md DurationDiD entry, dated Deaner–Ku source review, API and executed tutorial; `tests/test_methodology_duration_did.py` checks paper-numbered identities and both bootstrap algorithms. + +**Outstanding for promotion:** Capture a formal primary-source-to-implementation walk-through, verified components, corrections and deviations in this tracker. Unit tests and workflow approval alone do not establish Complete. No executed author-software parity or application replication is claimed. + ### Core DiD Estimators #### DifferenceInDifferences @@ -1514,7 +1521,7 @@ more graceful handling of edge cases while still signaling invalid inference to ### Priority Order (updated 2026-06-27) -**No In Progress entries remain.** **Survey Data Support** was promoted to Complete on 2026-06-27 — the last consolidation-pass row (PlaceboTests was promoted 2026-06-26). The methodology-review tracker is now Complete across all core/staggered/continuous/triple-difference/synthetic estimators, diagnostics, and cross-cutting inference features. +**DurationDiD remains In Progress.** **Survey Data Support** was promoted to Complete on 2026-06-27 — the last consolidation-pass row (PlaceboTests was promoted 2026-06-26). Those consolidation-pass entries remain Complete; the newly added DurationDiD entry awaits its documented promotion evidence. - Going forward, a new surface enters as **In Progress** when its REGISTRY.md entry lands and is promoted via a documented review pass (primary-source fidelity walk → dedicated methodology test file with paper-equation-numbered Verified Components → R-parity / deviation documentation), per the contract in [§ What "Complete" means in this tracker](#what-complete-means-in-this-tracker). diff --git a/README.md b/README.md index c85a6da35..d235f2835 100644 --- a/README.md +++ b/README.md @@ -89,15 +89,19 @@ For rigorous DiD analysis, follow these 8 steps. Skipping diagnostic steps produ 2. **State identification assumptions** - which parallel trends variant (unconditional, conditional, PT-GT-Nev, PT-GT-NYT), no-anticipation, overlap. 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 - for DifferenceInDifferences pass `cluster=`; TwoWayFixedEffects auto-clusters at unit level). +5. **Estimate** - `estimator.fit(data, ...)`. For estimators supporting cluster inference, print the cluster count first and choose inference method based on the result (cluster-robust if >= 50 clusters, wild bootstrap if fewer - for DifferenceInDifferences pass `cluster=`; TwoWayFixedEffects auto-clusters at unit level). 6. **Sensitivity analysis** - `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; the TwoWayFixedEffects `event_study=True` surface and a StackedDiD `results.aggregate('event_study')` container also admit - Stacked 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); EDiD: `results.aggregate(...)` post-fit from retained EIFs (3.9); ImputationDiD/TwoStageDiD: `results.aggregate(...)` post-fit from panel-backed kits (3.9); ContinuousDiD: `results.aggregate('dose'/'simple'/'event_study')` post-fit (3.9; dose/simple are views, event_study recomputes); 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. +8. **Robustness** - compare 2-3 estimators (CS vs SA vs BJS), report with and without covariates where supported (shows whether conditioning drives identification), present pre-trends and sensitivity bounds. + +For DurationDiD, use hazard assumptions and the stored `pretrend_test()`, pooled individual bootstrap, and comparisons of CD/PH or calibration dates. Covariates and higher-level clustering are unsupported. Full guide: `diff_diff.get_llm_guide("practitioner")`. ## Estimators +- [DurationDiD](https://diff-diff.readthedocs.io/en/stable/api/duration_did.html) - Deaner & Ku (2026) cumulative absorption ATT under common dynamics or proportional hazards; balanced individual panels, common timing, pooled bootstrap and stored hazard pretest + - [DifferenceInDifferences](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - basic 2x2 DiD with robust/cluster-robust SEs, wild bootstrap, formula interface, and fixed effects - [TwoWayFixedEffects](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - panel data DiD with unit and time fixed effects via within-transformation or dummies - [MultiPeriodDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - event study design with period-specific treatment effects for dynamic analysis (deprecated 3.9 - use TwoWayFixedEffects `event_study=True`) diff --git a/ROADMAP.md b/ROADMAP.md index efd956d3b..b7b35a550 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,11 @@ Forward-looking plan for diff-diff, organized as queued work, candidates under c ## Shipping Next +DurationDiD now provides the two-group common-timing core for absorbing individual +outcomes, with CD/PH hazards, pooled bootstrap and native pretests. Covariate, +staggered, censoring, survey and clustering extensions remain outside this release; +see [the API](docs/api/duration_did.rst) and the dated methodology review. + Queued work, ordered by expected leverage. Each item is its own PR. Ordering is priority-sequenced, not time-committed. ### 4.0 API unification @@ -81,12 +86,6 @@ Long-running program, framed as "building toward" rather than with discrete ship Frontier methods that may graduate to Under Consideration given time and research signals. -### Causal Duration Analysis with DiD - -Extends DiD to duration / survival outcomes where standard methods fail (hazard rates, time-to-event). Duration analogue of parallel trends; avoids distributional and hazard-function assumptions. - -**Reference**: Deaner & Ku (2025), *AEA Conference Paper*. - ### CATT Meta-Learner for Heterogeneous Effects ML-powered conditional ATT, using a doubly robust meta-learner to discover which units benefit most from treatment. diff --git a/changelog.d/20260906-duration-did.md b/changelog.d/20260906-duration-did.md new file mode 100644 index 000000000..7ea65de49 --- /dev/null +++ b/changelog.d/20260906-duration-did.md @@ -0,0 +1,7 @@ +### Added +- **Duration DiD**: `DurationDiD` estimates cumulative absorption effects in balanced + individual panels with common treatment timing under common dynamics or proportional + untreated hazards. Includes pooled individual-bootstrap pointwise/simultaneous inference, + a stored fixed-anchor hazard pretest, owned results and event-study aggregation, native + reporting, and an executed tutorial. Unsupported domains and failed bootstrap families + retain explicit availability metadata; no survey, covariate or cluster extensions. diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py index 868ec0b63..2a4a11249 100644 --- a/diff_diff/__init__.py +++ b/diff_diff/__init__.py @@ -98,6 +98,8 @@ ) from diff_diff.dml_did import DMLDiD from diff_diff.dml_did_results import DMLDiDResults +from diff_diff.duration_did import DurationDiD +from diff_diff.duration_did_results import DurationDiDPretestResults, DurationDiDResults from diff_diff.efficient_did import ( EDiDBootstrapResults, EfficientDiD, @@ -468,6 +470,9 @@ def __getattr__(name: str) -> _Any: "LWDiD", "LWDiDResults", # DMLDiD (Chang 2020 double/debiased ML DiD) + "DurationDiD", + "DurationDiDResults", + "DurationDiDPretestResults", "DMLDiD", "DMLDiDResults", "SieveLearner", diff --git a/diff_diff/_reporting_helpers.py b/diff_diff/_reporting_helpers.py index e0f102d1c..d580fad99 100644 --- a/diff_diff/_reporting_helpers.py +++ b/diff_diff/_reporting_helpers.py @@ -9,6 +9,8 @@ Current contents: +- ``_duration_native_diagnostics(results)`` — extracts the stored DurationDiD + hazard payload for DiagnosticReport emission and BusinessReport input validation. - ``describe_target_parameter(results)`` — returns the ``target_parameter`` block documenting what scalar the headline represents. Introduced for BR/DR gap #6 (target-parameter @@ -21,7 +23,42 @@ from typing import Any, Dict -from diff_diff.results_base import Diagnostic +from diff_diff.results_base import Diagnostic, _coverage_pct + + +def _duration_native_diagnostics(results: Any) -> Dict[str, Any]: + """Extract DurationDiD's stored native payload without recomputing inference.""" + return { + "status": "ran", + "estimator": "DurationDiD", + "method": results.method, + "pretrend_test": results.pretrend_test().to_dict(), + "estimation_status": results.estimation_status, + "inference_status": dict(results.inference_status), + "inference_reasons": {k: list(v) for k, v in results.inference_reasons.items()}, + "support_warnings": list(results.support_warnings), + "n_bootstrap_valid": results.n_bootstrap_valid, + } + + +def _duration_hazard_sentence(native: Dict[str, Any]) -> str: + """Describe the stored hazard decision consistently across BR and DR.""" + if native.get("estimator") != "DurationDiD" or native.get("status") != "ran": + return "" + diagnostic = native.get("pretrend_test") or {} + level = _coverage_pct(diagnostic["alpha"]) + if diagnostic.get("status") != "available" or diagnostic.get("reject") is None: + reasons = "; ".join(diagnostic.get("reasons", [])) or "no usable test statistic" + return ( + f"Hazard pretest unavailable at the {level}% simultaneous confidence level: " + f"{reasons}. No rejection decision is available." + ) + decision = "rejects" if diagnostic["reject"] else "does not reject" + return ( + f"The fixed-anchor hazard pretest {decision} at the {level}% simultaneous " + f"confidence level (p={diagnostic['p_value']:.4g}). " + "Non-rejection does not establish identification or adequate power." + ) def describe_target_parameter(results: Any) -> Dict[str, Any]: @@ -78,6 +115,15 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]: """ name = type(results).__name__ + if name == "DurationDiDResults": + return { + "name": "Mean cumulative absorption ATT", + "definition": "Uniform average across declared post-periods of cumulative absorption effects on the whole treated population, including baseline-absorbed individuals. Positive effects increase absorption.", + "aggregation": "uniform_post_periods", + "headline_attribute": "att", + "reference": "Deaner and Ku (2026); REGISTRY.md DurationDiD", + } + if name == "DiDResults": # Covers both ``DifferenceInDifferences`` (2x2 DiD) and # ``TwoWayFixedEffects`` (TWFE with unit + time FE). Both diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index c08e4f6c8..46d37eed2 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -41,12 +41,17 @@ from __future__ import annotations import re +from copy import deepcopy from dataclasses import dataclass from typing import Any, Dict, FrozenSet, List, Optional, Union import numpy as np -from diff_diff._reporting_helpers import describe_target_parameter +from diff_diff._reporting_helpers import ( + _duration_hazard_sentence, + _duration_native_diagnostics, + describe_target_parameter, +) from diff_diff.diagnostic_report import DiagnosticReport, DiagnosticReportResults from diff_diff.results_base import Diagnostic, _coverage_level, _coverage_pct @@ -240,6 +245,18 @@ def __init__( # auto-constructed ``DiagnosticReport`` (which now also # rejects it at construction time — round-21 P1 CI review on # PR #318). + if type(results).__name__ == "DurationDiDResults" and precomputed: + raise ValueError( + "DurationDiD accepts no generic precomputed diagnostics; use its stored hazard pretest." + ) + if type(results).__name__ == "DurationDiDResults" and honest_did_results is not None: + raise ValueError( + "DurationDiDResults does not accept honest_did_results: HonestDiD bounds " + "are incompatible with its hazard restrictions. Use pretrend_test() for " + "the stored hazard diagnostic and compare method and fit_periods " + "specifications. BusinessReport surfaces the stored pretest under " + "estimator_native_diagnostics." + ) if honest_did_results is not None and type(results).__name__ in { "SyntheticDiDResults", "TROPResults", @@ -443,10 +460,16 @@ def _resolve_diagnostics(self) -> Optional[DiagnosticReportResults]: """Return the DiagnosticReportResults to embed, or ``None`` if skipped.""" if self._diagnostics_arg is not None: if isinstance(self._diagnostics_arg, DiagnosticReportResults): - return self._diagnostics_arg - if isinstance(self._diagnostics_arg, DiagnosticReport): - return self._diagnostics_arg.run_all() - raise TypeError("diagnostics= must be a DiagnosticReport or DiagnosticReportResults") + supplied = self._diagnostics_arg + elif isinstance(self._diagnostics_arg, DiagnosticReport): + supplied = self._diagnostics_arg.run_all() + else: + raise TypeError( + "diagnostics= must be a DiagnosticReport or DiagnosticReportResults" + ) + if type(self._results).__name__ == "DurationDiDResults": + self._validate_duration_diagnostics(supplied.schema) + return supplied if not self._auto_diagnostics: return None # Round-43 P2 CI review on PR #318: forward the user's @@ -475,6 +498,36 @@ def _resolve_diagnostics(self) -> Optional[DiagnosticReportResults]: ) return dr.run_all() + def _validate_duration_diagnostics(self, schema: Dict[str, Any]) -> None: + """Admit only native diagnostics matching this DurationDiD fit's stored payload.""" + if schema.get("estimator") != "DurationDiDResults": + raise ValueError("DurationDiD diagnostics= requires a DurationDiDResults report.") + for check in ( + "parallel_trends", + "sensitivity", + "pretrends_power", + "bacon", + "design_effect", + "heterogeneity", + "epv", + "placebo", + ): + section = schema.get(check, {}) + if not isinstance(section, dict) or ( + section and section.get("status") not in {"not_applicable", "skipped"} + ): + raise ValueError( + f"DurationDiD diagnostics= cannot include a computed generic {check} section; " + "use its stored hazard pretest." + ) + if schema.get("estimator_native_diagnostics") != _duration_native_diagnostics( + self._results + ): + raise ValueError( + "DurationDiD diagnostics= must match this fit's stored native hazard diagnostic " + "and availability metadata. Build DiagnosticReport from these fitted results." + ) + def _build_schema(self) -> Dict[str, Any]: """Assemble the structured schema. @@ -1078,6 +1131,8 @@ def _lift_robustness(dr: Optional[Dict[str, Any]]) -> Dict[str, Any]: "estimator": native.get("estimator"), "pre_treatment_fit": native.get("pre_treatment_fit"), } + if native.get("estimator") == "DurationDiD": + native_block = deepcopy(native) # Classic SCM exposes pre_rmspe + donor-weight concentration + the (opt-in) # in-space placebo rather than SDiD's pre_treatment_fit; surface those so the # top-level robustness block is not empty for SyntheticControl. @@ -1208,6 +1263,12 @@ def _apply_anticipation_to_assumption(block: Dict[str, Any], results: Any) -> Di def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str, Any]: """Return the identifying-assumption block for an estimator.""" + if estimator_name == "DurationDiDResults": + return { + "parallel_trends_variant": "untreated_hazards", + "no_anticipation": True, + "description": f"Identification uses {getattr(results, 'method', 'the selected')} untreated hazards, no anticipation, unaffected controls, absorbing outcomes, fixed populations and common treatment timing. Pooled individual-bootstrap inference additionally assumes independent individuals.", + } if estimator_name in { "SyntheticDiDResults", }: @@ -1690,7 +1751,15 @@ def _build_caveats( # Few treated units. nt = sample.get("n_treated") - if nt is not None and nt <= 3: + if nt is not None and nt <= 3 and type(_results).__name__ == "DurationDiDResults": + caveats.append( + { + "severity": "warning", + "topic": "few_treated", + "message": f"Only {nt} treated individuals: pooled individual-bootstrap support and reliability can be poor. Inspect survivor/exit support and bootstrap-family availability and failure counts.", + } + ) + elif nt is not None and nt <= 3: caveats.append( { "severity": "warning", @@ -1704,6 +1773,42 @@ def _build_caveats( } ) + if type(_results).__name__ == "DurationDiDResults": + messages = [("duration_support", msg) for msg in _results.support_warnings] + if _results.estimation_status != "ok": + messages.append( + ( + "duration_counterfactual", + "Invalid counterfactual survival: canonical causal effects are unavailable; inspect raw extrapolations.", + ) + ) + messages.extend( + ( + "duration_inference", + f"{family} inference {state}: {'; '.join(_results.inference_reasons[family])}", + ) + for family, state in _results.inference_status.items() + if state != "available" + ) + if _results.n_bootstrap_valid != _results.n_bootstrap: + messages.append( + ( + "duration_bootstrap", + f"Pooled individual bootstrap: {_results.n_bootstrap_valid}/{_results.n_bootstrap} valid effect draws.", + ) + ) + if _results.pretrend_results.status != "available": + messages.append( + ( + "duration_pretest", + "Hazard pretest unavailable: " + "; ".join(_results.pretrend_results.reasons), + ) + ) + caveats.extend( + {"severity": "warning", "topic": topic, "message": msg} + for topic, msg in dict.fromkeys(messages) + ) + # Non-trivial design effect. survey = sample.get("survey") if survey and not survey.get("is_trivial"): @@ -1971,6 +2076,13 @@ def _references_for(estimator_name: str) -> List[Dict[str, str]]: ), }, ] + if estimator_name == "DurationDiDResults": + return [ + { + "role": "estimator", + "citation": "Deaner, B., and Ku, H. (2026). Causal Duration Analysis with Diff-in-Diff. arXiv:2405.05220v2.", + } + ] + [item for item in base if item["role"] != "sensitivity"] estimator_refs = { "CallawaySantAnnaResults": { "role": "estimator", @@ -2242,9 +2354,14 @@ def _render_headline_sentence(schema: Dict[str, Any]) -> str: return f"{treatment_sentence} {verb} {outcome}{by_clause}{ci_str}." +def _duration_diagnostic_sentence(schema: Dict[str, Any]) -> str: + native = (schema.get("robustness") or {}).get("estimator_native") or {} + return _duration_hazard_sentence(native) + + def _render_summary(schema: Dict[str, Any]) -> str: """Render the short-form stakeholder summary paragraph.""" - sentences: List[str] = [] + sentences: List[str] = [_duration_diagnostic_sentence(schema)] ctx = schema.get("context", {}) question = ctx.get("business_question") if question: @@ -2555,8 +2672,28 @@ def _render_summary(schema: Dict[str, Any]) -> str: caveats = schema.get("caveats", []) warning_caveats = [c for c in caveats if c.get("severity") == "warning"] if warning_caveats: - top = warning_caveats[0] - sentences.append(f"Caveat: {top.get('message')}") + ordinary = [c for c in warning_caveats if not c.get("topic", "").startswith("duration_")] + if ordinary: + top = ordinary[0] + sentences.append(f"Caveat: {top.get('message')}") + duration = [c for c in warning_caveats if c.get("topic", "").startswith("duration_")] + if duration: + # Keep all availability categories visible, with bounded prose even + # when individual dates contribute hundreds of support/failure reasons. + compact = [] + support_count = sum(c["topic"] == "duration_support" for c in duration) + for caveat in duration: + topic, message = caveat["topic"], caveat["message"] + if topic == "duration_support": + continue + if topic in {"duration_inference", "duration_pretest"}: + message = message.partition(":")[0] + compact.append(message.rstrip(".")) + if support_count: + compact.append(f"{support_count} survivor/exit support warnings") + sentences.append( + "Caveat: " + "; ".join(compact) + ". See full_report() for all warning details." + ) return " ".join(s for s in sentences if s) @@ -2609,6 +2746,10 @@ def _render_full_report(schema: Dict[str, Any]) -> str: lines.append(f"- {tp['definition']}") lines.append("") + duration_sentence = _duration_diagnostic_sentence(schema) + if duration_sentence: + lines.extend(["## Hazard Diagnostic", "", duration_sentence, ""]) + # Identifying assumption lines.append("## Identifying Assumption") lines.append("") diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index 2be30b326..c5430dc6d 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -48,7 +48,11 @@ import numpy as np import pandas as pd -from diff_diff._reporting_helpers import describe_target_parameter # noqa: E402 (top-level import) +from diff_diff._reporting_helpers import ( # noqa: E402 (top-level import) + _duration_hazard_sentence, + _duration_native_diagnostics, + describe_target_parameter, +) from diff_diff.results_base import Diagnostic, _coverage_pct DIAGNOSTIC_REPORT_SCHEMA_VERSION = "2.0" @@ -117,6 +121,7 @@ "epv", } ), + "DurationDiDResults": frozenset({"estimator_native"}), # DMLDiD (Chang 2020) mirrors the CS staggered ATT(g,t) surface via # the DERIVED post-fit event-study container (fit-time # event_study_effects is never populated): ``pretrends_power`` and @@ -333,17 +338,29 @@ def summary(self) -> str: return self.interpretation def to_dataframe(self) -> pd.DataFrame: - """Return one row per check with status and headline metric.""" + """Return one row per check with extraction status and headline metric. + + DurationDiD's native headline is the stored hazard-test p-value when + available; otherwise it is missing and ``reason`` explains availability. + Native ``status='ran'`` denotes extraction, not a usable statistical test. + """ rows = [] for check in _CHECK_NAMES: section_key = "estimator_native_diagnostics" if check == "estimator_native" else check section = self.schema.get(section_key, {}) + reason = section.get("reason") + if check == "estimator_native" and section.get("estimator") == "DurationDiD": + diagnostic = section.get("pretrend_test") or {} + if section.get("status") == "ran" and diagnostic.get("status") != "available": + reason = ( + "; ".join(diagnostic.get("reasons", [])) or "Hazard pretest unavailable." + ) rows.append( { "check": check, "status": section.get("status"), "headline": _check_headline(check, section), - "reason": section.get("reason"), + "reason": reason, } ) return pd.DataFrame(rows) @@ -565,6 +582,10 @@ def __init__( # native-routing contract documented in REPORTING.md. # Round-21 P1 CI review on PR #318 flagged this bypass. _result_name = type(self._results).__name__ + if _result_name == "DurationDiDResults" and self._precomputed: + raise ValueError( + "DurationDiD accepts no generic precomputed diagnostics; use its stored hazard pretest." + ) _native_routed_names = {"SyntheticDiDResults", "TROPResults", "SyntheticControlResults"} if _result_name in _native_routed_names: _incompatible_keys = [] @@ -1306,7 +1327,12 @@ def _instance_skip_reason(self, check: str) -> Optional[str]: continue return "No group/event-study effects available to compute heterogeneity." if check == "estimator_native": - if name not in {"SyntheticDiDResults", "TROPResults", "SyntheticControlResults"}: + if name not in { + "SyntheticDiDResults", + "TROPResults", + "SyntheticControlResults", + "DurationDiDResults", + }: return f"{name} does not expose native validation methods." return None return None @@ -2800,6 +2826,8 @@ def _check_estimator_native(self) -> Dict[str, Any]: """ r = self._results name = type(r).__name__ + if name == "DurationDiDResults": + return _duration_native_diagnostics(r) if name == "SyntheticDiDResults": return self._sdid_native(r) if name == "TROPResults": @@ -4344,6 +4372,9 @@ def _check_headline(check: str, section: Dict[str, Any]) -> Optional[Any]: if check == "epv": return section.get("min_epv") if check == "estimator_native": + if section.get("estimator") == "DurationDiD": + diagnostic = section.get("pretrend_test") or {} + return diagnostic.get("p_value") if diagnostic.get("status") == "available" else None # SDiD reports ``pre_treatment_fit``; classic SCM reports ``pre_rmspe`` (its # design-enforced pre-fit) — fall back so SCM's tabular headline is not None. fit = section.get("pre_treatment_fit") @@ -4463,6 +4494,12 @@ def _render_overall_interpretation(schema: Dict[str, Any], labels: Dict[str, str sentences.append(f"{base} {reason}") else: sentences.append(base + " (by design)") + elif est == "DurationDiDResults" and not val_finite: + sentences.append( + f"On {est}, the counterfactual survival path is invalid and canonical causal " + "effects are unavailable. Inspect survival_curve for raw extrapolations and " + "counterfactual validity reasons; compare method and fit_periods specifications." + ) elif val is not None and not val_finite: sentences.append( f"On {est}, {treatment}'s effect on {outcome} is non-finite " @@ -4508,6 +4545,10 @@ def _render_overall_interpretation(schema: Dict[str, Any], labels: Dict[str, str if tp_name: sentences.append(f"Target parameter: {tp_name}.") + hazard_sentence = _duration_hazard_sentence(schema.get("estimator_native_diagnostics") or {}) + if hazard_sentence: + sentences.append(hazard_sentence) + # Sentence 3: parallel trends + power (method-aware prose per the # round-8 CI review on PR #318; PT method can be slope_difference # (2x2), joint_wald / bonferroni (event study), hausman (EfficientDiD @@ -4808,6 +4849,10 @@ def _render_dr_full_report(results: "DiagnosticReportResults") -> str: if isinstance(v, (dict, list)): continue lines.append(f"- {k}: `{v}`") + if key == "estimator_native_diagnostics": + hazard_sentence = _duration_hazard_sentence(section) + if hazard_sentence: + lines.append(f"- {hazard_sentence}") lines.append("") if schema.get("next_steps"): diff --git a/diff_diff/duration_did.py b/diff_diff/duration_did.py new file mode 100644 index 000000000..38650d805 --- /dev/null +++ b/diff_diff/duration_did.py @@ -0,0 +1,626 @@ +"""Duration DiD under common dynamics or proportional untreated hazards. + +Deaner and Ku (2026), Sections 2--3 and Appendix B. The committed methodology +review resolves the printed PH ambiguity in favor of the mean of ratios and +pins the fixed-anchor diagnostic. No author-software parity is claimed. +""" + +from collections.abc import Mapping +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + +from diff_diff._base import BaseEstimator +from diff_diff.duration_did_results import ( + _CONTRAST_COLUMNS, + _EFFECT_COLUMNS, + _FAILURE_COLUMNS, + _SURVIVAL_COLUMNS, + DurationDiDPretestResults, + DurationDiDResults, +) +from diff_diff.utils import safe_inference, validate_n_bootstrap + + +class _DomainError(ValueError): + def __init__(self, reasons: Dict[Optional[int], str]): + self.reasons = reasons + super().__init__(str(reasons)) + + +def _survival(y: np.ndarray, group: np.ndarray) -> np.ndarray: + if not (np.any(group == 0) and np.any(group == 1)): + raise _DomainError({None: "resample lost a treatment group"}) + return np.array([1.0 - y[group == k].mean(axis=0) for k in (0, 1)]) + + +def _moments(s: np.ndarray, method: str) -> Tuple[np.ndarray, np.ndarray]: + # Evaluate only defined logs. In particular, factual treated post-period + # extinction is not a log-domain failure for the effect estimator. + r = np.full_like(s, np.nan) + with np.errstate(divide="ignore", invalid="ignore", over="ignore"): + np.log(s, out=r, where=s > 0) + r = -r + d = r - r[:, :1] + moment = np.full(s.shape[1], np.nan) + if method == "common_dynamics": + moment[1:] = (d[1, 1:] - d[0, 1:]) / np.arange(1, s.shape[1]) + else: + np.divide(d[1], d[0], out=moment, where=d[0] > 0) + return moment, d + + +def _curve( + s: np.ndarray, method: str, support: np.ndarray, weights: np.ndarray, start: int +) -> Tuple[float, np.ndarray, np.ndarray, np.ndarray, Dict[int, str]]: + if np.any(s[:, 0] <= 0): + raise _DomainError({0: "both groups need positive baseline survival"}) + moment, d = _moments(s, method) + bad = { + int(i): "unsupported calibration moment (required survival or PH control increment)" + for i in support + if not np.isfinite(moment[i]) + } + bad.update( + { + i: "positive control survival required for extrapolation" + for i in range(start, s.shape[1]) + if s[0, i] <= 0 + } + ) + if bad: + raise _DomainError(bad) + coefficient = float(np.dot(weights, moment[support])) + if not np.isfinite(coefficient) or (method == "proportional_hazards" and coefficient <= 0): + raise _DomainError({None: "fitted coefficient is unidentified or PH ratio is not positive"}) + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + r_cf = -np.log(s[1, 0]) + ( + d[0, start:] + np.arange(start, s.shape[1]) * coefficient + if method == "common_dynamics" + else coefficient * d[0, start:] + ) + s_cf = np.exp(-r_cf) + att = s_cf - s[1, start:] + previous = np.r_[s[1, start - 1], s_cf[:-1]] + invalid = {} + for j, value in enumerate(s_cf): + reasons = [] + if not np.isfinite(value) or value < -1e-12 or value > 1 + 1e-12: + reasons.append("counterfactual survival outside finite probability bounds") + if value > previous[j] + 1e-12: + reasons.append("counterfactual survival increases from preceding survival") + if reasons: + invalid[start + j] = "; ".join(reasons) + return coefficient, r_cf, s_cf, att, invalid + + +def _contrasts( + s: np.ndarray, method: str, start: int +) -> Tuple[np.ndarray, Dict[Optional[int], str]]: + if start < 3: + return np.empty(0), {None: "at least three pre-periods required for a hazard pretest"} + moments, _ = _moments(s, method) + with np.errstate(invalid="ignore"): + values = moments[1 : start - 1] - moments[start - 1] + bad = { + i: "unsupported hazard diagnostic moment or anchor" + for i in range(1, start) + if not np.isfinite(moments[i]) + } + return values, bad + + +def _quantile(values: np.ndarray, alpha: float) -> Any: + """Inverse empirical CDF, without interpolation or a finite-B adjustment.""" + return np.sort(values, axis=0)[int(np.ceil((1 - alpha) * len(values))) - 1] + + +def _inference(theta: np.ndarray, draws: np.ndarray, alpha: float) -> Dict[str, Any]: + """Algorithm 1: SD, centered absolute tails and studentized maxima.""" + with np.errstate(over="ignore", invalid="ignore", divide="ignore"): + se = np.std(draws, axis=0, ddof=1) + centered = np.abs(draws - theta) + result: Dict[str, Any] = { + key: np.full(len(theta), np.nan) + for key in ( + "t_stat", + "p_value", + "conf_int_lower", + "conf_int_upper", + "pointwise_crit_value", + "cband_lower", + "cband_upper", + ) + } + result["se"] = se + result["cband_crit_value"] = np.nan + result["statistic"] = np.nan + result["simultaneous_p_value"] = np.nan + good = np.isfinite(se) & (se > 0) & np.isfinite(theta) + for j in range(len(theta)): + stat, p, interval = safe_inference(theta[j], se[j], alpha=alpha) + if good[j]: + with np.errstate(over="ignore", invalid="ignore", divide="ignore"): + crit = float(_quantile(centered[:, j] / se[j], alpha)) + width = crit * se[j] + if not (np.isfinite(stat) and np.isfinite(crit) and np.isfinite(width)): + good[j] = False + continue + p = float(np.mean(centered[:, j] >= abs(theta[j]))) + interval = (theta[j] - width, theta[j] + width) + result["pointwise_crit_value"][j] = crit + result["t_stat"][j] = stat + result["p_value"][j] = p + result["conf_int_lower"][j], result["conf_int_upper"][j] = interval + if len(theta) and np.all(good): + with np.errstate(over="ignore", invalid="ignore", divide="ignore"): + maxima = np.max(centered / se, axis=1) + crit = float(_quantile(maxima, alpha)) + statistic = float(np.max(np.abs(result["t_stat"]))) + lower, upper = theta - crit * se, theta + crit * se + if np.all(np.isfinite(lower)) and np.all(np.isfinite(upper)): + result.update( + cband_lower=lower, + cband_upper=upper, + cband_crit_value=crit, + statistic=statistic, + simultaneous_p_value=float(np.mean(maxima >= statistic)), + ) + return result + + +def _selectors(values: Any, periods: List[Any], name: str) -> List[Any]: + if isinstance(values, (str, bytes)): + raise ValueError(f"{name} must be a sequence of observed dates") + try: + selected = list(values) + if len(selected) != len(set(selected)) or not set(selected).issubset(periods): + raise ValueError(f"{name} contains duplicate or unknown dates") + except TypeError as exc: + raise ValueError(f"{name} must be a sequence of observed dates") from exc + return [t for t in periods if t in selected] + + +def _panel( + data: pd.DataFrame, outcome: str, treatment: str, unit: str, time: str +) -> Tuple[np.ndarray, np.ndarray, List[Any], Any]: + cols = [outcome, treatment, unit, time] + if not isinstance(data, pd.DataFrame) or data.empty: + raise ValueError("data must be a nonempty DataFrame") + if len(set(cols)) != 4 or any(col not in data.columns for col in cols): + raise ValueError("outcome, treatment, unit and time must name distinct existing columns") + if data[cols].isna().any().any(): + raise ValueError("panel columns must not contain missing values") + if data.duplicated([unit, time]).any(): + raise ValueError("duplicate (unit, time) observations are not supported") + for col in (outcome, treatment): + if not data[col].isin([0, 1]).all(): + raise ValueError(f"{col} must be finite binary 0/1") + clock = pd.Index(data[time].unique()) + try: + if isinstance(clock, (pd.DatetimeIndex, pd.TimedeltaIndex)): + clock = clock.sort_values() + differences = [clock[i] - clock[i - 1] for i in range(1, len(clock))] + if ( + not differences + or differences[0] <= pd.Timedelta(0) + or any(x != differences[0] for x in differences) + ): + raise ValueError("datetime/timedelta dates must have equal actual spacing") + step = differences[0] + else: + if any( + isinstance(t, (bool, np.bool_)) + or not isinstance(t, (int, float, np.integer, np.floating)) + for t in clock + ): + raise ValueError("time labels must be numeric, Timestamp, or Timedelta") + clock = clock.sort_values() + if not np.isfinite(np.asarray(clock, dtype=float)).all() or len(clock) < 2: + raise ValueError("time requires at least two finite dates") + differences = np.array([float(clock[i] - clock[i - 1]) for i in range(1, len(clock))]) + step = float(differences[0]) + if ( + step <= 0 + or not np.isfinite(step) + or not np.allclose(differences, step, rtol=1e-9, atol=abs(step) * 1e-12) + ): + raise ValueError("numeric dates must have equal spacing") + except (TypeError, OverflowError) as exc: + raise ValueError("unsupported or irregular observation clock") from exc + periods = list(clock) + codes, units = pd.factorize(data[unit], sort=False) + if len(data) != len(units) * len(periods): + raise ValueError("a balanced complete individual panel is required; dropout is unsupported") + times = clock.get_indexer(data[time]) + y = np.empty((len(units), len(periods)), dtype=float) + memberships = np.empty_like(y) + y[codes, times] = data[outcome].to_numpy(dtype=float) + memberships[codes, times] = data[treatment].to_numpy(dtype=float) + if np.any(memberships != memberships[:, :1]): + raise ValueError("treatment must be constant group membership within each individual") + if np.any(np.diff(y, axis=1) < 0): + raise ValueError("outcome must be absorbing: no transition from 1 back to 0") + group = memberships[:, 0] + if len(np.unique(group)) != 2: + raise ValueError("both treated and control individuals are required") + return y, group, periods, step + + +class DurationDiD(BaseEstimator): + """Two-group, common-timing Duration DiD (Deaner and Ku, 2026). + + Parameters + ---------- + method : {"common_dynamics", "proportional_hazards"}, default "common_dynamics" + Additive or proportional restriction on untreated hazards. + n_bootstrap : int, default 1000 + Number of pooled individual history draws, at least two. No retries. + alpha : float, default 0.05 + Significance level used for all stored intervals and hazard diagnostics. + seed : int or None, default None + Nonnegative seed for numpy's Generator. + + Notes + ----- + Supports complete balanced individual panels only. No covariates, survey + weights, censoring, staggered adoption, or higher-level clustering. + Bootstrap failures suppress inference for the affected family, not valid + original point estimates. See the methodology Registry for domain policy. + """ + + def __init__( + self, + method: str = "common_dynamics", + n_bootstrap: int = 1000, + alpha: float = 0.05, + seed: Optional[int] = None, + ): + if method not in ("common_dynamics", "proportional_hazards"): + raise ValueError("method must be 'common_dynamics' or 'proportional_hazards'") + validate_n_bootstrap(n_bootstrap) + if n_bootstrap < 2: + raise ValueError("n_bootstrap must be >= 2 for DurationDiD") + if ( + isinstance(alpha, (bool, np.bool_)) + or not isinstance(alpha, (int, float, np.integer, np.floating)) + or not np.isfinite(alpha) + or not 0 < alpha < 1 + ): + raise ValueError("alpha must be finite and between 0 and 1") + if seed is not None and ( + isinstance(seed, (bool, np.bool_)) + or not isinstance(seed, (int, np.integer)) + or seed < 0 + ): + raise ValueError("seed must be a nonnegative integer or None") + self.method = method + self.n_bootstrap = n_bootstrap + self.alpha = alpha + self.seed = seed + self.results_: Optional[DurationDiDResults] = None + self.is_fitted_ = False + + def fit( + self, + data: pd.DataFrame, + outcome: str, + treatment: str, + unit: str, + time: str, + *, + post_periods: Any, + fit_periods: Any = None, + time_weights: Any = None, + ) -> DurationDiDResults: + """Estimate cumulative absorption ATT and fixed-anchor hazard diagnostics. + + Parameters + ---------- + data : pandas.DataFrame + Complete long individual panel; rows may be in any order. + outcome : str + Absorbing binary outcome column (1 means already absorbed). + treatment : str + Fixed binary group indicator, not a time-varying exposure column. + unit, time : str + Individual identifier and equally spaced observation clock columns. + post_periods : sequence + Required nonempty observed suffix beginning at first treatment. + fit_periods : sequence or None + Calibration dates after baseline and before treatment. None selects + all original-sample eligible dates. Diagnostic dates remain fixed. + time_weights : mapping or None + Nonnegative calibration weights keyed by the selected dates. Zero + weights remove moments before evaluation. Not observation weights. + + Returns + ------- + DurationDiDResults + Stored effects, raw survival paths, bootstrap and diagnostic metadata. + """ + y, group, periods, step = _panel(data, outcome, treatment, unit, time) + post = _selectors(post_periods, periods, "post_periods") + if not post: + raise ValueError("post_periods must be a nonempty observed suffix") + start = periods.index(post[0]) + if post != periods[start:] or start < 2: + raise ValueError("post_periods must be a suffix leaving at least two pre-periods") + s = _survival(y, group) + if np.any(s[:, 0] <= 0): + raise ValueError( + f"positive baseline survival required in both groups at {periods[0]!r}" + ) + moments, _ = _moments(s, self.method) + excluded = {} + requested = None if fit_periods is None else _selectors(fit_periods, periods, "fit_periods") + if requested is not None and ( + not requested or any(t not in periods[1:start] for t in requested) + ): + raise ValueError( + "fit_periods must select dates strictly after baseline and before treatment" + ) + selected = [] + for t in periods[1:start] if requested is None else requested: + if requested is None and not np.isfinite(moments[periods.index(t)]): + excluded[t] = "unsupported original-sample calibration moment" + else: + selected.append(t) + if time_weights is None: + w = np.ones(len(selected)) + else: + if not isinstance(time_weights, Mapping) or set(time_weights) != set(selected): + raise ValueError( + "time_weights keys must match selected fit_periods or default eligible dates" + ) + try: + w = np.array([time_weights[t] for t in selected], dtype=float) + except (TypeError, ValueError) as exc: + raise ValueError("time_weights must contain finite nonnegative weights") from exc + if not len(w) or not np.isfinite(w).all() or np.any(w < 0) or not np.any(w > 0): + raise ValueError("calibration requires finite nonnegative weights with positive total") + # Scale before summing so finite large weights cannot overflow the total. + originally_positive = w > 0 + w = w / np.max(w) + if np.any(originally_positive & (w == 0)): + raise ValueError("positive calibration weight underflows during normalization") + for t, weight in zip(selected, w): + if weight == 0: + excluded[t] = "zero calibration weight" + positive = w > 0 + selected = [t for t, keep in zip(selected, positive) if keep] + w = w[positive] / w[positive].sum() + if np.any(w == 0): + raise ValueError("positive calibration weight underflows during normalization") + support = np.array([periods.index(t) for t in selected], dtype=int) + try: + coefficient, r_cf, s_cf, raw, invalid = _curve(s, self.method, support, w, start) + except _DomainError as exc: + raise ValueError( + "; ".join( + f"{periods[i] if i is not None else 'fit'}: {reason}" + for i, reason in exc.reasons.items() + ) + ) from exc + contrasts, diagnostic_errors = _contrasts(s, self.method, start) + b = self.n_bootstrap + draws = np.full((b, len(post)), np.nan) + diagnostic_draws = np.full((b, len(contrasts)), np.nan) + failures = [] + + def record(draw: int, family: str, errors: Dict[Any, str]) -> None: + failures.extend( + { + "draw": draw, + "family": family, + "period": None if i is None else periods[i], + "reason": reason, + } + for i, reason in errors.items() + ) + + rng = np.random.default_rng(self.seed) + for draw in range(b): + indices = rng.integers(0, len(y), size=len(y)) + try: + sampled = _survival(y[indices], group[indices]) + except _DomainError as exc: + record(draw, "effects", exc.reasons) + if not diagnostic_errors: + record(draw, "diagnostics", exc.reasons) + continue + try: + _, _, _, effect, errors = _curve(sampled, self.method, support, w, start) + if errors: + record(draw, "effects", errors) + else: + draws[draw] = effect + except _DomainError as exc: + record(draw, "effects", exc.reasons) + if not diagnostic_errors: + delta, errors = _contrasts(sampled, self.method, start) + if errors: + record(draw, "diagnostics", errors) + else: + diagnostic_draws[draw] = delta + failure_table = pd.DataFrame(failures, columns=_FAILURE_COLUMNS) + failure_table["period"] = pd.Series([item["period"] for item in failures], dtype=object) + valid = int(np.isfinite(draws).all(axis=1).sum()) + effects = pd.DataFrame(np.nan, index=range(len(post)), columns=_EFFECT_COLUMNS) + effects["period"] = post + effects["event_time"] = np.arange(len(post)) + effects["att"] = np.nan if invalid else raw + reason = ( + "invalid original counterfactual" + if invalid + else ( + "one or more effect bootstrap draws failed" + if valid != b + else "zero or undefined bootstrap standard error" + ) + ) + effects["inference_status"] = "unavailable" + effects["reason"] = reason + att = np.nan if invalid else float(np.mean(raw)) + se = np.nan + stat, pvalue, ci = safe_inference(att, se, alpha=self.alpha) + vcov = None + crit = np.nan + if not invalid and valid == b: + inference = _inference(raw, draws, self.alpha) + for key in effects.columns: + if key in inference: + effects[key] = inference[key] + available = np.isfinite(effects["p_value"]) + effects.loc[available, "inference_status"] = "available" + effects.loc[available, "reason"] = None + crit = inference["cband_crit_value"] + vcov = np.atleast_2d(np.cov(draws, rowvar=False, ddof=1)) + headline = _inference(np.array([att]), draws.mean(axis=1)[:, None], self.alpha) + se, stat, pvalue = (float(headline[key][0]) for key in ("se", "t_stat", "p_value")) + ci = (float(headline["conf_int_lower"][0]), float(headline["conf_int_upper"][0])) + n_available = int((effects["inference_status"] == "available").sum()) + statuses = { + "pointwise": ( + "available" + if n_available == len(post) + else ("partial" if n_available else "unavailable") + ), + "simultaneous": "available" if np.isfinite(crit) else "unavailable", + "simple": "available" if np.isfinite(pvalue) else "unavailable", + } + reasons = {key: [] if value == "available" else [reason] for key, value in statuses.items()} + diagnostic = self._diagnostic( + contrasts, diagnostic_errors, diagnostic_draws, failure_table, periods, start + ) + counts = np.array([np.sum(1 - y[group == k], axis=0) for k in (0, 1)], dtype=int) + support_warnings = [] + # A reporting heuristic, separate from the strict log-domain guard. + for k, label in ((0, "control"), (1, "treated")): + for i, count in enumerate(counts[k]): + if count < 5: + support_warnings.append( + f"{label} survivors at {periods[i]!r}: {count} (<5 support heuristic)" + ) + if self.method == "proportional_hazards": + for i in support: + exits = counts[0, 0] - counts[0, i] + if exits < 5: + support_warnings.append( + f"control exits since baseline at {periods[i]!r}: {exits} (<5 PH support heuristic)" + ) + curve = pd.DataFrame( + { + "period": periods, + "elapsed_time": np.arange(len(periods), dtype=float), + "treated_survival": s[1], + "control_survival": s[0], + "treated_survivors": counts[1], + "control_survivors": counts[0], + "raw_counterfactual_cumulative_hazard": np.r_[np.full(start, np.nan), r_cf], + "raw_counterfactual_survival": np.r_[np.full(start, np.nan), s_cf], + "raw_att": np.r_[np.full(start, np.nan), raw], + "counterfactual_status": ["not_estimated"] * start + + ["invalid" if i in invalid else "valid" for i in range(start, len(periods))], + "reason": [invalid.get(i) for i in range(len(periods))], + }, + columns=_SURVIVAL_COLUMNS, + ) + result = DurationDiDResults( + att=att, + se=se, + t_stat=stat, + p_value=pvalue, + conf_int=ci, + method=self.method, + alpha=self.alpha, + seed=self.seed, + n_bootstrap=b, + n_obs=len(data), + n_units=len(y), + n_treated=int(np.sum(group == 1)), + n_control=int(np.sum(group == 0)), + periods=periods, + pre_periods=periods[:start], + post_periods=post, + fit_periods=selected, + requested_fit_periods=requested, + time_weights=dict(zip(selected, map(float, w))), + excluded_fit_periods=excluded, + time_origin=periods[0], + time_step=step, + coefficient=coefficient, + effects=effects, + survival_curve=curve, + bootstrap_effects=draws, + n_bootstrap_valid=valid, + bootstrap_failures=failure_table, + vcov=vcov, + cband_crit_value=crit, + estimation_status="invalid_counterfactual" if invalid else "ok", + inference_status=statuses, + inference_reasons=reasons, + support_warnings=support_warnings, + pretrend_results=diagnostic, + ) + self.results_ = result + self.is_fitted_ = True + return result + + def _diagnostic( + self, + values: np.ndarray, + errors: Dict[Any, str], + draws: np.ndarray, + failures: pd.DataFrame, + periods: List[Any], + start: int, + ) -> DurationDiDPretestResults: + reasons = [ + f"{periods[i] if i is not None else 'pretest'}: {reason}" + for i, reason in errors.items() + ] + attempted = 0 if errors else self.n_bootstrap + valid = 0 if errors else int(np.isfinite(draws).all(axis=1).sum()) + table = pd.DataFrame(np.nan, index=range(len(values)), columns=_CONTRAST_COLUMNS) + table["period"] = periods[1 : start - 1] + table["elapsed_time"] = np.arange(1, start - 1, dtype=float) + table["contrast"] = values + statistic = pvalue = critical = np.nan + reject = None + if not errors and valid == self.n_bootstrap: + inference = _inference(values, draws, self.alpha) + table["se"] = inference["se"] + critical = inference["cband_crit_value"] + if np.isfinite(critical): + table["cband_lower"] = inference["cband_lower"] + table["cband_upper"] = inference["cband_upper"] + statistic, pvalue = inference["statistic"], inference["simultaneous_p_value"] + reject = bool(np.any(table["cband_lower"] > 0) or np.any(table["cband_upper"] < 0)) + else: + reasons.append("zero or undefined diagnostic bootstrap standard error") + elif not errors: + reasons.append("one or more diagnostic bootstrap draws failed") + status = "available" if reject is not None else "unavailable" + table["status"] = status + table["reason"] = None if not reasons else "; ".join(reasons) + return DurationDiDPretestResults( + method=self.method, + alpha=self.alpha, + anchor_period=periods[start - 1], + contrasts=table, + statistic=statistic, + p_value=pvalue, + critical_value=critical, + reject=reject, + status=status, + reasons=reasons, + n_bootstrap=self.n_bootstrap, + n_bootstrap_attempted=attempted, + n_bootstrap_valid=valid, + bootstrap_contrasts=draws, + bootstrap_failures=failures.loc[failures["family"] == "diagnostics"].reset_index( + drop=True + ), + ) diff --git a/diff_diff/duration_did_results.py b/diff_diff/duration_did_results.py new file mode 100644 index 000000000..e66f77f2d --- /dev/null +++ b/diff_diff/duration_did_results.py @@ -0,0 +1,326 @@ +"""Owned result containers for Deaner--Ku Duration DiD.""" + +from copy import deepcopy +from dataclasses import dataclass, fields +from typing import Any, ClassVar, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + +from diff_diff.aggregation import AggregationMixin, AggregationResult +from diff_diff.results_base import ( + BaseResults, + Diagnostic, + EventStudyResults, + _coverage_pct, + _json_safe_label, + _require_fit_alpha, +) + +_SUMMARY_ALPHA_MESSAGE = ( + "This result stores centered-bootstrap bands computed at alpha={fit_alpha}; " + "summary() never recomputes or relabels stored inference (requested alpha={alpha}). " + "Re-fit with the desired alpha to obtain the corresponding bootstrap critical values." +) + +_FAILURE_COLUMNS = ["draw", "family", "period", "reason"] +_CONTRAST_COLUMNS = [ + "period", + "elapsed_time", + "contrast", + "se", + "cband_lower", + "cband_upper", + "status", + "reason", +] +_EFFECT_COLUMNS = [ + "period", + "event_time", + "att", + "se", + "t_stat", + "p_value", + "conf_int_lower", + "conf_int_upper", + "cband_lower", + "cband_upper", + "pointwise_crit_value", + "inference_status", + "reason", +] +_SURVIVAL_COLUMNS = [ + "period", + "elapsed_time", + "treated_survival", + "control_survival", + "treated_survivors", + "control_survivors", + "raw_counterfactual_cumulative_hazard", + "raw_counterfactual_survival", + "raw_att", + "counterfactual_status", + "reason", +] + + +def _json_value(value: Any) -> Any: + """Serialize nested statistical metadata without non-standard JSON numbers.""" + if isinstance(value, DurationDiDPretestResults): + return value.to_dict() + if isinstance(value, pd.DataFrame): + return _json_value(value.to_dict(orient="records")) + if isinstance(value, np.ndarray): + return _json_value(value.tolist()) + if isinstance(value, dict): + return {key: _json_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_value(item) for item in value] + if isinstance(value, (float, np.floating)): + return float(value) if np.isfinite(value) else None + if isinstance(value, (bool, np.bool_)): + return bool(value) + if isinstance(value, (int, np.integer)): + return int(value) + return _json_safe_label(value) + + +def _own_fields(result: Any) -> None: + for item in fields(result): + setattr(result, item.name, deepcopy(getattr(result, item.name))) + + +@dataclass +class DurationDiDPretestResults(Diagnostic): + """Stored fixed-anchor hazard diagnostic (Deaner--Ku, Appendix B Algorithm 2). + + ``contrasts`` covers interior pre-treatment dates, excluding baseline and + the last untreated anchor. Its bands are simultaneous at ``1-alpha``. + ``reject=None`` denotes an unavailable test, never a non-rejection. + ``bootstrap_contrasts`` retains all requested draw slots; failed slots are + NaN. No test is recomputed by the accessor, summary, or serialization. + """ + + method: str + alpha: float + anchor_period: Any + contrasts: pd.DataFrame + statistic: float + p_value: float + critical_value: float + reject: Optional[bool] + status: str + reasons: List[str] + n_bootstrap: int + n_bootstrap_attempted: int + n_bootstrap_valid: int + bootstrap_contrasts: np.ndarray + bootstrap_failures: pd.DataFrame + + def __post_init__(self) -> None: + _own_fields(self) + + def to_dict(self) -> Dict[str, Any]: + """Return complete strict-JSON-safe statistics and availability metadata.""" + return {item.name: _json_value(getattr(self, item.name)) for item in fields(self)} + + def to_dataframe(self) -> pd.DataFrame: + """Return an owned copy of the interior-date contrast table.""" + return self.contrasts.copy(deep=True) + + def summary(self, alpha: Optional[float] = None) -> str: + """Describe the stored simultaneous hazard pretest at its fitted level.""" + _require_fit_alpha(alpha, self.alpha, message=_SUMMARY_ALPHA_MESSAGE) + decision = ( + "unavailable" + if self.reject is None + else ("rejected" if self.reject else "not rejected") + ) + return ( + f"DurationDiD hazard pretest ({self.method})\n" + f"{_coverage_pct(self.alpha)}% simultaneous bands; {decision}\n" + f"Statistic: {self.statistic:.6g}; p-value: {self.p_value:.6g}\n" + f"Bootstrap draws: {self.n_bootstrap_valid}/{self.n_bootstrap_attempted} valid " + f"({self.n_bootstrap} requested)" + + "".join("\n" + reason for reason in self.reasons) + + "\nNon-rejection does not establish identification or adequate power." + ) + + +@dataclass +class DurationDiDResults(BaseResults, AggregationMixin): + """Duration DiD cumulative absorption effects and pooled-bootstrap inference. + + The headline is the uniform average of post-period absorption ATTs over + the whole treated population, including people absorbed at baseline. + Positive effects increase cumulative absorption. ``effects`` holds post + estimates; ``survival_curve`` preserves raw extrapolations even when the + counterfactual is invalid. ``coefficient`` is a normalized-interval hazard + gap for common dynamics and a dimensionless ratio for proportional hazards. + + Inference is stored at ``alpha``; failed bootstrap families do not use a + filtered distribution. ``pretrend_results`` is a separate hazard diagnostic, + not a set of pre-treatment outcome effects. See the API page for all field + and table schemas. + """ + + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] + method: str + alpha: float + seed: Optional[int] + n_bootstrap: int + n_obs: int + n_units: int + n_treated: int + n_control: int + periods: List[Any] + pre_periods: List[Any] + post_periods: List[Any] + fit_periods: List[Any] + requested_fit_periods: Optional[List[Any]] + time_weights: Dict[Any, float] + excluded_fit_periods: Dict[Any, str] + time_origin: Any + time_step: Any + coefficient: float + effects: pd.DataFrame + survival_curve: pd.DataFrame + bootstrap_effects: np.ndarray + n_bootstrap_valid: int + bootstrap_failures: pd.DataFrame + vcov: Optional[np.ndarray] + cband_crit_value: float + estimation_status: str + inference_status: Dict[str, str] + inference_reasons: Dict[str, List[str]] + support_warnings: List[str] + pretrend_results: DurationDiDPretestResults + + _AGGREGATE_SUPPORTED: ClassVar[Tuple[str, ...]] = ("simple", "event_study") + _AGGREGATE_BALANCE_E_TYPES: ClassVar[Tuple[str, ...]] = () + + def __post_init__(self) -> None: + _own_fields(self) + + @property + def inference_method(self) -> str: + """The estimator's sole inference method.""" + return "bootstrap" + + @property + def raw_att(self) -> float: + """Uniform mean of raw post-period effects, including invalid extrapolations.""" + return float( + np.mean( + self.survival_curve.loc[ + self.survival_curve["counterfactual_status"] != "not_estimated", "raw_att" + ].to_numpy() + ) + ) + + def pretrend_test(self) -> DurationDiDPretestResults: + """Return a defensive copy of the stored hazard Diagnostic; never refit.""" + return deepcopy(self.pretrend_results) + + def to_dict(self) -> Dict[str, Any]: + """Return complete metadata, raw paths and inference as strict JSON values.""" + result = {} + for item in fields(self): + value = getattr(self, item.name) + if item.name == "conf_int": + result["conf_int_lower"] = _json_value(value[0]) + result["conf_int_upper"] = _json_value(value[1]) + elif item.name in {"time_weights", "excluded_fit_periods"}: + result[item.name] = [ + {"period": _json_value(k), "value": _json_value(v)} for k, v in value.items() + ] + else: + result[item.name] = _json_value(value) + result.update(inference_method=self.inference_method, raw_att=_json_value(self.raw_att)) + return result + + def to_dataframe(self, level: str = "event_study") -> pd.DataFrame: + """Return event-study (default), simple, survival, or diagnostics tables.""" + if level in self._AGGREGATE_SUPPORTED: + return self.aggregate(level).to_dataframe() + if level == "survival": + return self.survival_curve.copy(deep=True) + if level == "diagnostics": + return self.pretrend_results.to_dataframe() + raise ValueError("level must be 'event_study', 'simple', 'survival', or 'diagnostics'") + + def summary(self, alpha: Optional[float] = None) -> str: + """Summarize stored whole-population absorption ATT and availability.""" + _require_fit_alpha(alpha, self.alpha, message=_SUMMARY_ALPHA_MESSAGE) + units = ( + "hazard gap per normalized observation interval" + if self.method == "common_dynamics" + else "dimensionless hazard ratio" + ) + return ( + f"DurationDiD ({self.method})\n" + f"Coefficient ({units}): {self.coefficient:.6g}\n" + f"Mean cumulative absorption ATT: {self.att:.6g}; SE: {self.se:.6g}\n" + f"{_coverage_pct(self.alpha)}% CI: {self.conf_int}; p-value: {self.p_value:.6g}\n" + f"Estimation: {self.estimation_status}; inference: {self.inference_status}\n" + f"Pooled individual bootstrap: {self.n_bootstrap_valid}/{self.n_bootstrap} valid effect draws" + + "".join( + "\n" + reason for values in self.inference_reasons.values() for reason in values + ) + + "".join("\n" + warning for warning in self.support_warnings) + + "\n" + + self.pretrend_results.summary() + ) + + def _aggregate_compute( + self, level: str, *, weights: Optional[str], balance_e: Optional[int] + ) -> Any: + if level == "simple": + return AggregationResult( + level="simple", + label=np.array(["overall"]), + target=np.array(["att"]), + att=np.array([self.att]), + se=np.array([self.se]), + t_stat=np.array([self.t_stat]), + p_value=np.array([self.p_value]), + conf_int_lower=np.array([self.conf_int[0]]), + conf_int_upper=np.array([self.conf_int[1]]), + n=np.array([self.n_treated]), + df=np.array([np.nan]), + alpha=self.alpha, + n_kind="units", + weight=np.array([1.0]), + estimator="DurationDiD", + ) + e = self.effects + + def column(name: str, reference: float = np.nan) -> np.ndarray: + return np.r_[reference, e[name].to_numpy(dtype=float)] + + return EventStudyResults( + event_time=np.arange(-1, len(e)), + att=column("att", 0.0), + se=column("se"), + t_stat=column("t_stat"), + p_value=column("p_value"), + conf_int_lower=column("conf_int_lower"), + conf_int_upper=column("conf_int_upper"), + is_reference=np.r_[True, np.zeros(len(e), dtype=bool)], + n=np.r_[np.nan, np.full(len(e), self.n_treated)], + n_kind="units", + reference_period=-1, + time_scale="relative", + event_time_convention="e0_first_treated", + vcov=None if self.vcov is None else self.vcov.copy(), + vcov_index=None if self.vcov is None else np.arange(len(e)), + cband_lower=column("cband_lower"), + cband_upper=column("cband_upper"), + cband_crit_value=self.cband_crit_value, + alpha=self.alpha, + source="DurationDiDResults", + ) diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index 07ac6624e..beedc2479 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -338,6 +338,7 @@ supported / out of scope; `warn` supported but with documented caveats; | Estimator | binary absorbing | staggered | continuous | triple-diff | never-treated required | covariate adjustment | few-treated (synthetic) | heterogeneous adoption | clustered SE | |---|---|---|---|---|---|---|---|---|---| +| `DurationDiD` | partial | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | | `DifferenceInDifferences` | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | | `MultiPeriodDiD` (deprecated 3.9 → `TwoWayFixedEffects` `event_study=True`) | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | | `TwoWayFixedEffects` | ✓ | warn | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | @@ -360,6 +361,13 @@ supported / out of scope; `warn` supported but with documented caveats; | `RegressionDiscontinuity` | ✗ (cross-sectional; sharp: treatment = running >= cutoff; fuzzy: observed take-up via `takeup=`) | ✗ | ✗ | ✗ | ✗ | ✓ (precision only - estimand unchanged, unlike the DiD conditional-PT role; `covariates=`) | ✗ | ✗ | ✗ (follow-up) | **Footnotes.** +- `DurationDiD`: common timing only. The matrix describes treatment shape; an + absorbing binary OUTCOME is an additional requirement. fit() takes fixed group + membership plus explicit post_periods. Profile treatment shape alone cannot + establish eligibility. Unaffected never-treated controls and independent people + are required; no covariates, survey weights, higher-level clusters or synthetic + few-treated inference. + - `LWDiD` + never-treated: requires at least one never-treated unit under `control_group='not_yet_treated'` and at least two under `'never_treated'`; all-eventually-treated designs raise under both @@ -425,7 +433,7 @@ supported / out of scope; `warn` supported but with documented caveats; exactly one observation per `(unit, time)` cell with every unit observed in every period: `ContinuousDiD`, `EfficientDiD`, `SyntheticDiD`, `HeterogeneousAdoptionDiD`, -`StaggeredTripleDifference`. Gate these on BOTH +`StaggeredTripleDifference`, `DurationDiD`. Gate these on BOTH `PanelProfile.is_balanced == True` AND the absence of the `duplicate_unit_time_rows` alert (`is_balanced` is computed from the unique-key support and stays `True` when duplicates exist; the @@ -434,7 +442,9 @@ conditions as hard gates: `EfficientDiD` and `HeterogeneousAdoptionDiD` raise `ValueError` at `fit()` on duplicate cells, and `ContinuousDiD`'s precompute path resolves duplicates with last-row-wins (silent overwrite that can change -the estimand). If either condition fails, pre-process with +the estimand). For DurationDiD, do not establish eligibility by filling missing outcomes or +dropping histories: the fixed-population/no-dropout assumptions need justification. +For the other methods, if either condition fails, pre-process with `diff_diff.prep.balance_panel()` and a `drop_duplicates([unit, time])` pass, or pick a balance-tolerant estimator from the remaining rows (CS/SA/dCDH/Imputation/TwoStage/ @@ -470,6 +480,10 @@ case; the choice between them is mostly about output shape: ### §4.2 Multi-period single-cohort (event-study without staggering) +For absorbing individual OUTCOMES with common timing, see DurationDiD in §4.11; +it requires at least two pre-dates and one post-date, a balanced complete panel, +and credible untreated-hazard restrictions. + When `is_staggered == False` and `n_periods > 2`, event-study dynamics can be estimated but cohort-mixing bias is moot: @@ -803,6 +817,19 @@ docs explicitly say otherwise. When routing, also: ### §4.11 Outcome-shape considerations +`DurationDiD` targets cumulative absorption ATT when a binary outcome stays 1 +after the event. It needs complete balanced individual histories, unaffected +controls, common intervention timing and either common dynamics or proportional +untreated hazards; at least two pre-dates and one post-date. Keep baseline-absorbed +people. fit() takes fixed treatment group membership and explicit post_periods, +not the time-varying exposure indicator a generic profile may describe. Verify +absorption from histories separately; bounded outcome values alone are insufficient. +The headline uniformly averages post-date effects on the whole treated population. +No censoring/dropout, covariates, staggered timing, repeated cross-sections, survey +weights or higher-level clusters are supported. Pooled individual bootstrap assumes +independent people; inspect support/failure counts and the stored hazard pretest, +not generic outcome-mean pretrends or HonestDiD. See the DurationDiD API/full guide. + The matrix in §3 routes by treatment shape. Outcome shape is a separate axis: a panel that is binary-absorbing and staggered may still have a count-shaped outcome (e.g., number of incidents per unit-period), and @@ -838,9 +865,8 @@ calibration. `PanelProfile.outcome_shape` exposes the relevant facts: profile. - `outcome_shape.is_bounded_unit == True` (values in `[0, 1]`, e.g. a proportion) - linear DiD can produce predictions outside - `[0, 1]` and inference at the boundary is questionable. No - estimator in the suite handles this differently from a numeric - outcome; flag the consideration in the write-up. + `[0, 1]` and inference at the boundary is questionable. DurationDiD handles the specific absorbing binary individual-panel case above, + not arbitrary proportions; flag the distinction in the write-up. - `outcome_shape.is_integer_valued == True` without `is_count_like` (e.g., 0/1 binary, ordinal Likert) - the binary case has its own caveats (logit/log-odds alternative per Roth @@ -1127,6 +1153,12 @@ section is the API-reference index. Some estimators expose diagnostics as methods on the result object: +- `DurationDiDResults.pretrend_test()` returns a defensive copy of the stored + fixed-anchor hazard Diagnostic, without refitting. Three pre-dates are needed; + unavailable tests have reject=None. This is independent of effect-family validity + and does not establish identification/power from a non-rejection. Generic + outcome-pretrend/sensitivity procedures do not admit DurationDiD results. + - `SyntheticDiDResults.in_time_placebo()` - placebo treatment applied in a pre-treatment period. - `SyntheticDiDResults.sensitivity_to_zeta_omega()` - regularization- @@ -1169,6 +1201,12 @@ close to zero support parallel trends. ## §7. How to read BusinessReport / DiagnosticReport output +DurationDiD: native outer status=ran describes extraction; nested pretrend_test +status/reject/reasons describe hazard-test availability at the fitted alpha. +BusinessReport retains native fields, support warnings and failure counts; even +with auto_diagnostics=False its fit-level caveats remain. No generic precomputed +overrides or honest_did_results are accepted. + `BusinessReport(results)` and `DiagnosticReport(results)` are experimental in the 3.2 line. Their schema is versioned (`BUSINESS_REPORT_SCHEMA_VERSION` and `DIAGNOSTIC_REPORT_SCHEMA_VERSION`, both `"2.0"` at time of writing) diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 39e3cb9f5..21e3a3d4d 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -34,6 +34,9 @@ print(f"ATT: {results.att:.3f} (SE: {results.se:.3f})") ## Practitioner Workflow (based on Baker et al. 2025) For rigorous DiD analysis, follow the 8-step framework (call `diff_diff.get_llm_guide("practitioner")`). +For DurationDiD, use hazard assumptions and the stored hazard pretest, pooled +individual-bootstrap inference, and method/calibration comparisons. Covariates, +survey weights, higher-level clusters and generic sensitivity procedures are unsupported. After estimation, call: ```python @@ -46,6 +49,112 @@ testing, sensitivity analysis, heterogeneity checks, robustness comparisons). ## Estimators +### DurationDiD + +Deaner & Ku (2026), Causal Duration Analysis with Diff-in-Diff, arXiv:2405.05220v2. +Whole-treated-population cumulative absorption ATT (including baseline-absorbed +people); positive effects increase absorption. Headline = uniform mean across all +post-period ATTs. Requires complete balanced individual histories, absorbing 0/1 +outcomes, unaffected controls and common timing; two pre-dates and one post-date +minimum. Identification uses common dynamics or proportional untreated hazards, +no anticipation and fixed populations. Individual independence additionally +underlies inference. No outcome-mean parallel-trends assumption is imposed. + +```python +DurationDiD(method="common_dynamics", n_bootstrap=1000, alpha=0.05, seed=None) +est.fit(data, outcome, treatment, unit, time, *, post_periods, + fit_periods=None, time_weights=None) -> DurationDiDResults +``` + +`treatment` is fixed binary group membership; `post_periods` is an explicit +nonempty observed suffix. Equal-spaced numeric/Timestamp/Timedelta clock. The +preceding date is last untreated; never infer timing from absorption. +`fit_periods` selects pre-dates strictly after baseline, leaving population, +baseline, post horizon and diagnostic window intact. `time_weights` keys match +selected dates (or default eligible dates); finite nonnegative weights normalize +to one. Zero weights remove moments before arithmetic. Explicit unsupported +positive moments raise; default exclusions carry reasons. Support is frozen for +all bootstrap draws. PH needs positive control increments and positive c. + +With R=-log(S), baseline increments D and normalized elapsed d, CD estimates +c=sum w*(D1-D0)/d and Rcf=R1baseline+D0+d*c; PH estimates the reviewed mean of +ratios c=sum w*D1/D0 and Rcf=R1baseline+c*D0. ATT=exp(-Rcf)-S1. The CD coefficient +is a hazard gap per normalized observation interval; PH is a dimensionless +hazard ratio and never acquires time units when the observation clock changes. + +Inference: exactly B pooled individual history draws, nuisance re-estimation, +ddof=1 SD/covariance, centered absolute bootstrap pivots, inverse empirical-CDF +quantiles (ceil((1-alpha)B)-1). Pointwise bands use coordinate pivots; simultaneous +bands use post-family maxima. Headline weights apply inside each draw. Centered +absolute-tail p-values count equality; they are discrete library companions to +the paper's bands, not raw percentile or normal inference. No retries, stratified +replacement, epsilon substitution or successful-draw filtering. Any failed effect +draw disables the entire effect family's inference and headline inference while +preserving valid original points; diagnostic-family validity is independent. +Zero SE disables associated t/p/CI and uniform bands, not other valid coordinates. + +Scope: no covariates, staggered adoption, censoring/dropout, repeated cross-sections, +survey weights or higher-level clustering. Calibration time_weights are not sampling +weights. Required logs must be defined; zero factual treated post-survival is valid. +Invalid counterfactual bounds or increasing survival (including the last-pre +boundary) preserve raw paths but suppress canonical causal output, without clipping +or horizon trimming. Support warnings (<5 survivors, or PH control exits since +baseline at selected dates) are reporting heuristics, not trimming rules. + +### DurationDiDResults + +Flat fields `att`, `se`, `t_stat`, `p_value`, `conf_int`; configuration `method`, +`alpha`, `seed`, `n_bootstrap`; counts `n_obs` (rows), `n_units`, `n_treated`, +`n_control` (people). Date lists: `periods`, `pre_periods`, `post_periods`, effective +`fit_periods`, optional `requested_fit_periods`; mappings `time_weights`, +`excluded_fit_periods`; clock `time_origin`, `time_step`; method-specific `coefficient`. + +Owns `effects`, `survival_curve`, `bootstrap_failures`, B-by-post `bootstrap_effects`, +`n_bootstrap_valid`, optional post-ordered `vcov`, and `cband_crit_value`. +`estimation_status`: ok/invalid_counterfactual. `inference_status` and +`inference_reasons` have pointwise/simultaneous/simple keys; available/unavailable, +plus partial for pointwise. `support_warnings` and `pretrend_results` remain +inspectable. Properties: `inference_method='bootstrap'`, uniform raw post `raw_att`. + +Native table column order (chronological RangeIndex): +- effects (post only): period, event_time, att, se, t_stat, p_value, conf_int_lower, + conf_int_upper, cband_lower, cband_upper, pointwise_crit_value, inference_status, reason. +- survival_curve (all dates): period, elapsed_time, treated_survival, control_survival, + treated_survivors, control_survivors, raw_counterfactual_cumulative_hazard, + raw_counterfactual_survival, raw_att, counterfactual_status, reason. Pre raw values + are NaN/not_estimated; post status is valid/invalid. Invalid original curves make + every canonical post ATT and the headline unavailable, preserving raw paths. +- bootstrap_failures: draw (zero-based), family (effects/diagnostics), period + (nullable), reason. Multiple reasons do not double-count draws; failed effect rows are all NaN. + +`to_dataframe(level='event_study')` defaults to EVENT_STUDY_SCHEMA; simple uses +AGGREGATION_SCHEMA, survival/diagnostics return owned native tables. Only +`aggregate('simple')` / `aggregate('event_study')`; no weights or balance_e. +Event-study reference -1 has zero effect and undefined inference/count, post +relative times start at zero; preserve post covariance, its index and simultaneous +bands. Source is DurationDiDResults, event_time_convention=e0_first_treated, +n_kind=units (treated people), df=NaN. No generic HonestDiD/PreTrendsPower admission. + +### DurationDiDPretestResults + +`pretrend_test()` returns an owned copy of this stored Diagnostic, never a refit. +Fields: method, alpha, anchor_period, contrasts, statistic, p_value, critical_value, +reject, status, reasons, n_bootstrap, n_bootstrap_attempted, n_bootstrap_valid, +bootstrap_contrasts (B by tested dates), bootstrap_failures (diagnostic subset). +Contrast columns: period, elapsed_time, contrast, se, cband_lower, cband_upper, +status, reason. Tests all interior pre-dates against the last untreated anchor, +excluding baseline/anchor, independent of calibration. These are hazard contrasts, +not pre-treatment outcome ATTs. Three pre-dates needed; unavailable tests retain +raw contrasts and reject=None. Invalid original diagnostics attempt zero draws. + +Both containers implement summary(alpha=None), to_dict(), to_dataframe(); the +Diagnostic dataframe is its contrast table. Summaries preserve fit-level confidence +and reject changed alpha. Serialization includes all public metadata, nested +diagnostics and bootstrap arrays, emits scalar conf_int_lower/conf_int_upper, +serializes period maps as period/value records, dates as ISO and nonfinite numbers +as null (strict JSON). Containers own arrays/frames. Non-rejection does not establish +identification or adequate power. API: https://diff-diff.readthedocs.io/en/stable/api/duration_did.html. + ### DifferenceInDifferences Basic 2x2 Difference-in-Differences estimator. @@ -2695,7 +2804,7 @@ sd_female, data_female = sd.subpopulation(data, mask=lambda df: df['sex'] == 'F' **Key features:** - Taylor Series Linearization (TSL) variance with strata + PSU + FPC -- Replicate weight variance: BRR, Fay's BRR, JK1, JKn, SDR (14 of 24 estimators, including dCDH and DMLDiD) +- Replicate weight variance: BRR, Fay's BRR, JK1, JKn, SDR (14 of 25 estimators, including dCDH and DMLDiD) - Survey-aware bootstrap: multiplier at PSU (Hall-Mammen wild; dCDH, staggered) or Rao-Wu rescaled (SunAbraham, SyntheticDiD, TROP). SyntheticDiD bootstrap composes Rao-Wu rescaled per-draw weights with the weighted Frank-Wolfe variant of `_sc_weight_fw` (PR #355): each draw solves `min ||A·diag(rw)·ω - b||² + ζ²·Σ rw_i ω_i²` and composes `ω_eff = rw·ω/Σ(rw·ω)` for the SDID estimator. Pweight-only fits use constant `rw = w_control`; full designs use Rao-Wu. SDID's placebo (stratified permutation + weighted FW) and jackknife (PSU-level LOO with stratum aggregation, Rust & Rao 1996) paths also support pweight-only and full strata/PSU/FPC designs - DEFF diagnostics, subpopulation analysis, weight trimming (`trim_weights`) - Repeated cross-sections: `CallawaySantAnna(panel=False)` @@ -2901,6 +3010,7 @@ DIFF_DIFF_BACKEND=rust pytest # Force Rust (fail if unavailable) | Scenario | Recommended Estimator | |----------|----------------------| +| Absorbing binary outcomes, balanced individual panel, common timing and CD/PH untreated-hazard assumptions | `DurationDiD` | | Classic 2x2 design (one treated group, one time split) | `DifferenceInDifferences` | | Panel data with unit + time FE | `TwoWayFixedEffects` | | Event study with multiple periods (simultaneous adoption) | `TwoWayFixedEffects` with `event_study=True` (`spec="pooled"` reproduces the deprecated `MultiPeriodDiD`) | @@ -2920,6 +3030,22 @@ DIFF_DIFF_BACKEND=rust pytest # Force Rust (fail if unavailable) ## BusinessReport +DurationDiD uses only estimator_native routing. It extracts the stored hazard +pretest without refitting: outer status=ran denotes extraction, while nested +pretrend_test.status/reject/reasons define availability. Effect-family and +diagnostic-family validity are independent. Preserve its fitted confidence level, +bootstrap failure counts and support warnings. All four generic precomputed +keys are rejected; BusinessReport also rejects honest_did_results. Even when +auto_diagnostics=False, fit-level failure and support caveats remain visible. +Few treated individuals imply individual-bootstrap support concerns, not +large-cluster asymptotics or automatic synthetic/permutation alternatives. +Supplied BusinessReport diagnostics= (live or detached) must name DurationDiD, +match its stored native hazard payload and availability metadata, and contain no +computed generic diagnostics. Use pretrend_test() and method/fit_periods comparisons. +Short summaries count support warnings and summarize unavailable families; to_dict() +and full_report() retain every per-date warning and reason. + + Plain-English stakeholder narrative from any fitted result type. Renders `summary()` (short paragraph), `full_report()` (multi-section markdown), and `to_dict()` (stable AI-legible schema — single source of @@ -2972,6 +3098,28 @@ no-scalar branch. ## DiagnosticReport +DurationDiD uses only estimator_native routing. It extracts the stored hazard +pretest without refitting: outer status=ran denotes extraction, while nested +pretrend_test.status/reject/reasons define availability. Effect-family and +diagnostic-family validity are independent. Preserve its fitted confidence level, +bootstrap failure counts and support warnings. All four generic precomputed +keys are rejected; BusinessReport also rejects honest_did_results. Even when +auto_diagnostics=False, fit-level failure and support caveats remain visible. +Few treated individuals imply individual-bootstrap support concerns, not +large-cluster asymptotics or automatic synthetic/permutation alternatives. +Supplied BusinessReport diagnostics= (live or detached) must name DurationDiD, +match its stored native hazard payload and availability metadata, and contain no +computed generic diagnostics. Use pretrend_test() and method/fit_periods comparisons. +Short summaries count support warnings and summarize unavailable families; to_dict() +and full_report() retain every per-date warning and reason. +DiagnosticReport.summary() (also on returned results) and full_report() state the +stored hazard verdict at its fitted simultaneous confidence level; unavailable +tests include reasons and have no rejection decision. The native to_dataframe() +row keeps extraction status=ran, with headline equal to the available hazard +p-value, otherwise missing with availability reasons in reason. Invalid curves +point to survival_curve raw extrapolations and method/fit_periods comparisons. + + Unified diagnostic runner orchestrating `check_parallel_trends`, `compute_pretrends_power`, `HonestDiD.sensitivity`, `BaconDecomposition`, plus estimator-native surfaces for SyntheticDiD (`pre_treatment_fit`, diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index 3ab24ef67..d1473a1d9 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -95,6 +95,11 @@ results.se # alias of overall_se / avg_se (or overall_att_se for ## Step 2: State Identification Assumptions +DurationDiD exception: state the selected untreated-hazard restriction (common_dynamics +or proportional_hazards), absorbing outcomes, fixed population, no anticipation, +unaffected controls and common timing. Individual independence additionally underlies +its pooled bootstrap; outcome-mean parallel trends is not its identifying restriction. + Explicitly state which assumptions you are invoking and argue why they are plausible in your setting. @@ -173,6 +178,11 @@ large a violation would need to be to overturn your results. ## Step 4: Choose Estimation Method +For absorbing binary individual histories in a balanced complete panel with common +timing and credible CD/PH hazard assumptions, consider DurationDiD. Treatment is fixed +group membership and post_periods declares timing. It has no covariate, staggered, +censoring, survey or higher-level cluster extension. See its full-guide/API contract. + Use this decision tree to select the appropriate estimator: ``` @@ -293,11 +303,13 @@ print(bacon_result.summary()) ## Step 5: Estimate -Before fitting, you MUST check the cluster count and choose inference -accordingly. Do not assume — always print and decide based on the data. +For estimators supporting cluster inference, check the cluster count and choose +inference accordingly. The following cluster workflow applies to those estimators. +DurationDiD instead uses pooled individual bootstrap only; inspect survivor/exit +support and failed-draw counts. It has no higher-level cluster selector. ```python -# ALWAYS check the cluster count before choosing inference: +# For estimators supporting cluster inference, check the cluster count: cluster_col = 'county_id' # the level at which treatment is assigned n_clusters = data[cluster_col].nunique() print(f"Number of clusters: {n_clusters}") @@ -383,6 +395,12 @@ print(results.summary()) ## Step 6: Sensitivity Analysis +DurationDiD: inspect results.pretrend_test(), the stored fixed-anchor simultaneous +hazard Diagnostic (no refit), and compare CD/PH or explicit fit_periods choices. +Unavailable means reject=None, never non-rejection. Non-rejection does not establish +identification or power. Generic HonestDiD and outcome placebo procedures below do +not apply to DurationDiD, including its event-study aggregation. + This step is CRITICAL and most often skipped. Run at least one of: ### HonestDiD (Rambachan & Roth 2023) - recommended @@ -544,10 +562,11 @@ print(f"SA ATT: {sa_result.overall_att:.4f} (SE: {sa_result.overall_se:.4f})") print(f"BJS ATT: {bjs_result.overall_att:.4f} (SE: {bjs_result.overall_se:.4f})") ``` -### Report with and without covariates — REQUIRED +### Report with and without covariates where supported -You MUST report estimates both with and without covariates. This is not -optional — it shows whether covariate conditioning is driving identification +For estimators supporting covariates, report estimates both with and without +them. DurationDiD has no covariate option: compare methods/calibration choices. +For covariate-capable estimators, the comparison shows whether covariate conditioning is driving identification or merely improving precision. A large shift between the two specifications signals that covariates are substantively important for the parallel trends assumption and warrants discussion. @@ -573,7 +592,7 @@ Your analysis report MUST include all of the following: - [ ] Sensitivity analysis results (HonestDiD bounds or placebo tests) - [ ] Event study plot (if applicable) - [ ] Comparison across at least 2-3 estimators -- [ ] Estimates with and without covariates (REQUIRED) +- [ ] Estimates with and without covariates where supported; DurationDiD method/calibration comparisons ### One-call reporting via BusinessReport + DiagnosticReport @@ -650,6 +669,10 @@ Executed end-to-end examples: tutorials 29 (PyMC-Marketing lift test) and ## Common Pitfalls +For DurationDiD, outcome parallel-trends tests, generic HonestDiD/placebo procedures, +covariate comparisons and cluster-count inference selection are inapplicable. Use +the stored hazard pretest and pooled individual-bootstrap support/failure metadata. + 1. **Using TWFE with staggered adoption**: TWFE assigns negative weights to some group-time cells when treatment timing varies. Always use CS, SA, or another heterogeneity-robust estimator for staggered designs. diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index f9220d90c..38dc68d82 100644 --- a/diff_diff/guides/llms.txt +++ b/diff_diff/guides/llms.txt @@ -2,7 +2,7 @@ > A Python library for Difference-in-Differences (DiD) causal inference analysis. Provides sklearn-like estimators with statsmodels-style summary output for econometric analysis. -diff-diff offers 24 estimators covering basic 2x2 DiD, modern staggered adoption methods, reversible (non-absorbing) treatments, advanced panel estimators, nonlinear models, sharp regression discontinuity designs, and diagnostic tools. It supports robust and cluster-robust standard errors, wild cluster bootstrap, formula and column-name interfaces, fixed effects (dummy and absorbed), complex survey designs (strata/PSU/FPC, replicate weights, design-based variance), and publication-ready output. The optional Rust backend accelerates compute-intensive estimators like Synthetic DiD and TROP. +diff-diff offers 25 estimators covering basic 2x2 DiD, modern staggered adoption methods, reversible (non-absorbing) treatments, advanced panel estimators, nonlinear models, sharp regression discontinuity designs, and diagnostic tools. It supports robust and cluster-robust standard errors, wild cluster bootstrap, formula and column-name interfaces, fixed effects (dummy and absorbed), complex survey designs (strata/PSU/FPC, replicate weights, design-based variance), and publication-ready output. The optional Rust backend accelerates compute-intensive estimators like Synthetic DiD and TROP. - Install: `pip install diff-diff` - License: MIT @@ -19,10 +19,12 @@ diagnostic steps produces unreliable results. 2. **State identification assumptions** — which parallel trends variant (unconditional, conditional, PT-GT-Nev, PT-GT-NYT), no-anticipation, overlap. 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 — for DifferenceInDifferences pass `cluster=`; TwoWayFixedEffects auto-clusters at unit level). +5. **Estimate** — `estimator.fit(data, ...)`. For estimators supporting cluster inference, print the cluster count first and choose inference method based on the result (cluster-robust if >= 50 clusters, wild bootstrap if fewer — for DifferenceInDifferences pass `cluster=`; TwoWayFixedEffects auto-clusters at unit level). 6. **Sensitivity analysis** — `compute_honest_did(results)` for bounds under PT violations (MultiPeriodDiD, CS, or dCDH natively; the TwoWayFixedEffects `event_study=True` surface and a StackedDiD `results.aggregate('event_study')` container also admit - Stacked needs `kappa_pre >= 2` so estimated pre-periods exist), `run_all_placebo_tests()` for 2x2 falsification, specification comparisons for staggered designs. 7. **Heterogeneity** — CS: `results.aggregate('group')`/`.aggregate('event_study')` post-fit, no refit (fit-time `aggregate=`/`balance_e=` are deprecated since 3.9, removed in 4.0; `compute_honest_did` / `compute_pretrends_power` / `plot_event_study` all accept the post-fit `results.aggregate('event_study')` container directly; BOOTSTRAPPED CS fits included: the recompute levels `'event_study'`/`'group'` REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; container carries vcov=None), while `.aggregate('simple')` and, where supported, `.aggregate('total')` relay the stored bootstrap inference (NaN df column); only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message). NEW in 3.10: `.aggregate('total')` on CS/EfficientDiD/ImputationDiD/TwoStageDiD - the estimator-owned total incremental outcome (exact relay C x overall over the finite-masked complete-case support; single target='total' row; bootstrap-safe RELAY; panel non-survey fits only - repeated-cross-section and declared survey_design fits raise NotImplementedError with the reason); dCDH: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (bootstrap fits included — pure views); SA: `results.event_study_effects`/`to_dataframe(level='cohort')`; StackedDiD: `results.aggregate('event_study')`/`.aggregate('simple')` post-fit views (the surface is ALWAYS computed at fit since 3.9 - row M-024 - and the container admits into `compute_honest_did`/`compute_pretrends_power` with `kappa_pre >= 2`); EDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit, RECOMPUTED from retained EIFs (3.9, row M-023; fit-time `aggregate=`/`balance_e=` deprecated; on bootstrapped EDiD fits the recompute levels REPLAY the fit-time multiplier bootstrap from the kit's retained RNG state (percentile inference matching a fit-time aggregation; only pre-replay legacy pickles and cross-weight-backend artifacts fail closed with a refit message) while `.aggregate('simple')` relays the stored bootstrap inference; EDiD containers are NOT admitted into honest/pretrends - no joint ES covariance); BJS/TwoStageDiD: `results.aggregate('event_study')`/`.aggregate('group')`/`.aggregate('simple')` post-fit on ImputationDiD and TwoStageDiD too (3.9, rows M-021/M-022; recomputed from panel-backed kits, `balance_e=` on `aggregate('event_study')`; on bootstrapped fits the recompute levels raise while `.aggregate('simple')` relays the stored bootstrap inference - use the deprecated fit-time aggregation for a bootstrapped ES/group surface; their containers are not admitted into honest/pretrends - Imputation by design, TwoStage deferred pending a normalization derivation); CGBS continuous: ContinuousDiD is a MIXED adopter (3.9, row M-025) - `results.aggregate('dose')` (ATT(d)+ACRT(d) rows) and `.aggregate('simple')` (att+acrt rows) are views over the always-computed curves and work on ANY fit incl. bootstrapped, while `.aggregate('event_study')` recomputes the binarized event study from a pruned per-cell IF kit and raises on bootstrapped fits (use the deprecated fit-time `aggregate='eventstudy'` there until 4.0; its container is not admitted into honest/pretrends - no joint ES covariance and no reference normalization); HAD: `results.aggregate('simple')` (overall two-period fits; the target column carries the WAS estimand label) / `.aggregate('event_study')` (multi-period fits) - pure views, work on any fit (3.9, rows M-027/M-139; fit() selects the mode from the panel shape; HAD containers are not admitted into honest/pretrends - no joint cross-horizon covariance, deferred); subgroup re-estimation. -8. **Robustness** — compare 2-3 estimators (CS vs SA vs BJS), MUST report with and without covariates (shows whether conditioning drives identification), present pre-trends and sensitivity bounds. +8. **Robustness** — compare 2-3 estimators (CS vs SA vs BJS), report with and without covariates where supported (shows whether conditioning drives identification), present pre-trends and sensitivity bounds. + +For DurationDiD, use hazard assumptions and the stored `pretrend_test()`, pooled individual bootstrap, and comparisons of CD/PH or calibration dates. Covariates and higher-level clustering are unsupported. After estimation, call `practitioner_next_steps(results)` for context-aware guidance on remaining steps. @@ -57,6 +59,8 @@ The site is organized into 5 sections, each with a landing page: ## Estimators +- [DurationDiD](https://diff-diff.readthedocs.io/en/stable/api/duration_did.html): Deaner & Ku (2026) cumulative absorption ATT in balanced individual panels with common timing; common dynamics / proportional hazards, pooled individual bootstrap and stored hazard pretest + - [DifferenceInDifferences](https://diff-diff.readthedocs.io/en/stable/api/estimators.html): Basic 2x2 DiD with robust/cluster-robust SEs, wild bootstrap, formula interface, and fixed effects - [TwoWayFixedEffects](https://diff-diff.readthedocs.io/en/stable/api/estimators.html): Panel data DiD with unit and time fixed effects via within-transformation or dummies; `event_study=True` estimates per-period effects (spec='within'|'pooled') returning the unified EventStudyResults surface - [MultiPeriodDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html): Event study design with period-specific treatment effects for dynamic analysis (deprecated 3.9 - use TwoWayFixedEffects event_study=True; spec='pooled' reproduces this design) @@ -86,6 +90,8 @@ The site is organized into 5 sections, each with a landing page: ## Diagnostics and Sensitivity Analysis +- [DurationDiD hazard pretest](https://diff-diff.readthedocs.io/en/stable/api/duration_did.html): `results.pretrend_test()` returns a defensive copy of the stored `DurationDiDPretestResults` Diagnostic; fixed-anchor simultaneous hazard contrasts, no refit; unavailable means `reject=None` + - [RD Plots](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html): Calonico, Cattaneo & Titiunik (2015) optimal data-driven RD plots (`RDPlot`), parity with R rdrobust 4.0.0's `rdplot()` - all 8 `binselect` bin-count selectors (evenly/quantile spaced x IMSE-optimal/mimicking-variance x spacings/polynomial-regression), implied scale + WIMSE weights per Supplement S.1, optional covariate adjustment reusing the RD estimator's pipeline, optional lazy-matplotlib rendering (matplotlib not a dependency) - [Manipulation Testing](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html): Cattaneo, Jansson & Ma (2020) density-discontinuity manipulation test (`RDDensityTest`), parity with R rddensity 3.0 - boundary-adaptive local polynomial density estimation at the cutoff, robust bias-corrected inference, unrestricted/restricted models, jackknife/plugin variances, data-driven bandwidths, mass-point adjustment - [Parallel Trends Testing](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html): Simple and Wasserstein-robust parallel trends tests, equivalence testing (TOST) diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index 15b4ca119..1def12af1 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -31,6 +31,7 @@ # Estimator name mapping # --------------------------------------------------------------------------- _ESTIMATOR_NAMES: Dict[str, str] = { + "DurationDiDResults": "DurationDiD", "DiDResults": "DifferenceInDifferences", "MultiPeriodDiDResults": "MultiPeriodDiD (Event Study)", "CallawaySantAnnaResults": "CallawaySantAnna", @@ -183,6 +184,15 @@ def practitioner_next_steps( if type_name == "ChangesInChangesResults": pre_estimation[1] = _cic_assumptions_step(results) + if type_name == "DurationDiDResults": + pre_estimation[1] = _step( + baker_step=2, + label="State hazard identification assumptions", + why=f"Use {results.method} untreated hazards, no anticipation, unaffected controls, absorbing outcomes, a fixed population and common timing. Pooled bootstrap inference assumes independent individuals.", + code="# Justify the selected untreated-hazard restriction and individual independence.", + priority="high", + step_name="assumptions", + ) if not diagnostic_input: steps = pre_estimation + steps @@ -2306,7 +2316,46 @@ def _handle_generic(results: Any): # Handler registry — maps result type *names* (not classes) to avoid # import-time circular dependencies # --------------------------------------------------------------------------- +def _handle_duration(results: Any) -> Any: + diagnostic = results.pretrend_results + warnings = list(results.support_warnings) + warnings.extend(reason for values in results.inference_reasons.values() for reason in values) + warnings.extend(diagnostic.reasons) + if results.estimation_status != "ok": + warnings.append( + "Invalid counterfactual: canonical causal estimates are unavailable; inspect raw survival paths." + ) + steps = [ + _step( + baker_step=3, + label="Inspect the stored hazard pretest", + why="Fixed-anchor simultaneous hazard contrasts assess pre-treatment restrictions. Non-rejection does not establish identification or adequate power.", + code="print(results.pretrend_test().summary())", + priority="high", + step_name="parallel_trends", + ), + _step( + baker_step=7, + label="Inspect survival and absorption effects", + why="Inspect the raw counterfactual, support and post-period effects together.", + code="print(results.to_dataframe(level='survival'))\nprint(results.to_dataframe())", + priority="high", + step_name="heterogeneity", + ), + _step( + baker_step=8, + label="Compare hazard specifications and calibration dates", + why="Compare common_dynamics and proportional_hazards or explicit fit_periods choices under their identifying assumptions. Check pooled individual-bootstrap failures; covariate and higher-level cluster options are unsupported.", + code="# Refit with explicit method / fit_periods choices; inspect support and availability.", + priority="medium", + step_name="robustness", + ), + ] + return steps, list(dict.fromkeys(warnings)) + + _HANDLERS = { + "DurationDiDResults": _handle_duration, "DiDResults": _handle_did, "MultiPeriodDiDResults": _handle_multi_period, "CallawaySantAnnaResults": _handle_cs, diff --git a/docs/api/_autosummary/diff_diff.DurationDiD.rst b/docs/api/_autosummary/diff_diff.DurationDiD.rst new file mode 100644 index 000000000..c22f55022 --- /dev/null +++ b/docs/api/_autosummary/diff_diff.DurationDiD.rst @@ -0,0 +1,17 @@ +diff\_diff.DurationDiD +====================== + +.. currentmodule:: diff_diff + +.. autoclass:: DurationDiD + :no-members: + + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiD.__init__ + ~DurationDiD.fit + ~DurationDiD.get_params + ~DurationDiD.set_params diff --git a/docs/api/_autosummary/diff_diff.DurationDiDPretestResults.rst b/docs/api/_autosummary/diff_diff.DurationDiDPretestResults.rst new file mode 100644 index 000000000..56d94c94b --- /dev/null +++ b/docs/api/_autosummary/diff_diff.DurationDiDPretestResults.rst @@ -0,0 +1,40 @@ +diff\_diff.DurationDiDPretestResults +==================================== + +.. currentmodule:: diff_diff + +.. autoclass:: DurationDiDPretestResults + :no-members: + + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiDPretestResults.__init__ + ~DurationDiDPretestResults.summary + ~DurationDiDPretestResults.to_dataframe + ~DurationDiDPretestResults.to_dict + + + + + .. rubric:: Attributes + + .. autosummary:: + + ~DurationDiDPretestResults.method + ~DurationDiDPretestResults.alpha + ~DurationDiDPretestResults.anchor_period + ~DurationDiDPretestResults.contrasts + ~DurationDiDPretestResults.statistic + ~DurationDiDPretestResults.p_value + ~DurationDiDPretestResults.critical_value + ~DurationDiDPretestResults.reject + ~DurationDiDPretestResults.status + ~DurationDiDPretestResults.reasons + ~DurationDiDPretestResults.n_bootstrap + ~DurationDiDPretestResults.n_bootstrap_attempted + ~DurationDiDPretestResults.n_bootstrap_valid + ~DurationDiDPretestResults.bootstrap_contrasts + ~DurationDiDPretestResults.bootstrap_failures diff --git a/docs/api/_autosummary/diff_diff.DurationDiDResults.rst b/docs/api/_autosummary/diff_diff.DurationDiDResults.rst new file mode 100644 index 000000000..f7018ac40 --- /dev/null +++ b/docs/api/_autosummary/diff_diff.DurationDiDResults.rst @@ -0,0 +1,64 @@ +diff\_diff.DurationDiDResults +============================= + +.. currentmodule:: diff_diff + +.. autoclass:: DurationDiDResults + :no-members: + + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiDResults.__init__ + ~DurationDiDResults.aggregate + ~DurationDiDResults.pretrend_test + ~DurationDiDResults.summary + ~DurationDiDResults.to_dataframe + ~DurationDiDResults.to_dict + + + + + .. rubric:: Attributes + + .. autosummary:: + + ~DurationDiDResults.inference_method + ~DurationDiDResults.raw_att + ~DurationDiDResults.att + ~DurationDiDResults.se + ~DurationDiDResults.t_stat + ~DurationDiDResults.p_value + ~DurationDiDResults.conf_int + ~DurationDiDResults.method + ~DurationDiDResults.alpha + ~DurationDiDResults.seed + ~DurationDiDResults.n_bootstrap + ~DurationDiDResults.n_obs + ~DurationDiDResults.n_units + ~DurationDiDResults.n_treated + ~DurationDiDResults.n_control + ~DurationDiDResults.periods + ~DurationDiDResults.pre_periods + ~DurationDiDResults.post_periods + ~DurationDiDResults.fit_periods + ~DurationDiDResults.requested_fit_periods + ~DurationDiDResults.time_weights + ~DurationDiDResults.excluded_fit_periods + ~DurationDiDResults.time_origin + ~DurationDiDResults.time_step + ~DurationDiDResults.coefficient + ~DurationDiDResults.effects + ~DurationDiDResults.survival_curve + ~DurationDiDResults.bootstrap_effects + ~DurationDiDResults.n_bootstrap_valid + ~DurationDiDResults.bootstrap_failures + ~DurationDiDResults.vcov + ~DurationDiDResults.cband_crit_value + ~DurationDiDResults.estimation_status + ~DurationDiDResults.inference_status + ~DurationDiDResults.inference_reasons + ~DurationDiDResults.support_warnings + ~DurationDiDResults.pretrend_results diff --git a/docs/api/business_report.rst b/docs/api/business_report.rst index 883902ca2..ef2d25eb7 100644 --- a/docs/api/business_report.rst +++ b/docs/api/business_report.rst @@ -117,3 +117,45 @@ API :show-inheritance: .. autodata:: diff_diff.BUSINESS_REPORT_SCHEMA_VERSION + +DurationDiD native hazard diagnostics +------------------------------------- + +For ``DurationDiDResults``, the stored fixed-anchor hazard pretest is extracted +without raw data, refitting, or recomputing diagnostics. The +``estimator_native_diagnostics`` section has outer ``status='ran'`` for successful +extraction; its nested ``pretrend_test.status`` determines availability and +``reject=None`` denotes an unavailable test. Its confidence level is the fit's +``alpha``, independently of report-level phrasing. Non-rejection never establishes +identification or adequate power. Effect and diagnostic bootstrap validity are +independent; inspect estimation/inference statuses, reasons and support warnings. + +All generic ``precomputed`` overrides (parallel_trends, sensitivity, +pretrends_power, bacon) are rejected for DurationDiD. Its hazard contrasts are +not pre-treatment outcome ATTs and do not admit generic HonestDiD/PreTrendsPower. + +BusinessReport preserves this payload in ``robustness.estimator_native`` and +renders hazard-test availability or rejection in summary and full reports. +``honest_did_results`` is also rejected. A report alpha override preserves the +fitted bootstrap confidence intervals and identifies their actual confidence +level; it cannot reconstruct intervals from the reported SE. + +An explicit ``diagnostics=`` argument, whether a live ``DiagnosticReport`` or a +detached ``DiagnosticReportResults``, must name DurationDiD and contain its +matching stored native hazard diagnostic and availability metadata. Foreign +reports, altered native payloads and computed generic diagnostic sections raise +``ValueError``. Construct the diagnostic report from the fitted results being +reported. Use ``pretrend_test()`` and comparisons of ``method`` and ``fit_periods`` +for the supported diagnostic and specification workflow. + +``auto_diagnostics=False`` skips automatic DiagnosticReport construction, but +fit-level caveats remain visible: invalid counterfactual curves, unavailable +inference, failed bootstrap counts, support warnings and unavailable hazard +pretests. When at most three treated people are present, the caveat describes +pooled individual-bootstrap support and reliability, not large-cluster +asymptotics, synthetic weighting or exact permutation inference. See +:doc:`duration_did` and :doc:`diagnostic_report` for the fitting example. + +The short summary groups support warnings by count and summarizes unavailable +inference families. Every warning and reason remains in ``to_dict()`` and +``full_report()``, including details for each date. diff --git a/docs/api/diagnostic_report.rst b/docs/api/diagnostic_report.rst index f7cf8976d..cacf23952 100644 --- a/docs/api/diagnostic_report.rst +++ b/docs/api/diagnostic_report.rst @@ -90,3 +90,49 @@ API :show-inheritance: .. autodata:: diff_diff.DIAGNOSTIC_REPORT_SCHEMA_VERSION + +DurationDiD native hazard diagnostics +------------------------------------- + +For ``DurationDiDResults``, the stored fixed-anchor hazard pretest is extracted +without raw data, refitting, or recomputing diagnostics. The +``estimator_native_diagnostics`` section has outer ``status='ran'`` for successful +extraction; its nested ``pretrend_test.status`` determines availability and +``reject=None`` denotes an unavailable test. Its confidence level is the fit's +``alpha``, independently of report-level phrasing. Non-rejection never establishes +identification or adequate power. Effect and diagnostic bootstrap validity are +independent; inspect estimation/inference statuses, reasons and support warnings. + +``summary()`` (including on the returned ``DiagnosticReportResults``) and +``full_report()`` describe the stored hazard decision at its fitted simultaneous +confidence level. An unavailable pretest includes its reasons and explicitly has +no rejection decision. The native row in ``to_dataframe()`` retains extraction +``status='ran'``: its ``headline`` is the stored hazard-test p-value when available, +otherwise missing, with the availability reasons in ``reason``. These views never +recompute the diagnostic or replace its fitted confidence level with report alpha. +For invalid counterfactual curves, the narrative directs readers to +``survival_curve`` and specification comparisons using ``method`` and ``fit_periods``. + +All generic ``precomputed`` overrides (parallel_trends, sensitivity, +pretrends_power, bacon) are rejected for DurationDiD. Its hazard contrasts are +not pre-treatment outcome ATTs and do not admit generic HonestDiD/PreTrendsPower. + +A self-contained example: + +.. code-block:: python + + import pandas as pd + from diff_diff import DurationDiD, DiagnosticReport + + rows = [] + for group, counts in enumerate(([96, 80, 64, 48, 32, 16], [90, 75, 60, 45, 20, 8])): + for person in range(400): + for date, survivors in enumerate(counts): + rows.append((group * 400 + person, date, group, int(person >= 4 * survivors))) + panel = pd.DataFrame(rows, columns=["person", "date", "group", "absorbed"]) + fitted = DurationDiD(n_bootstrap=19, seed=33).fit( + panel, "absorbed", "group", "person", "date", post_periods=[4, 5] + ) + native = DiagnosticReport(fitted).to_dict()["estimator_native_diagnostics"] + assert native["estimator"] == "DurationDiD" + print(fitted.pretrend_test().summary()) diff --git a/docs/api/duration_did.rst b/docs/api/duration_did.rst new file mode 100644 index 000000000..88d6bc803 --- /dev/null +++ b/docs/api/duration_did.rst @@ -0,0 +1,204 @@ +Duration DiD +============ + +``DurationDiD`` estimates cumulative absorption effects for two groups of +individuals observed on a complete, balanced panel with common treatment timing. +The outcome is 0 before absorption and 1 afterward. Treatment is a fixed group +indicator; ``post_periods`` separately declares the treated observation suffix. +Keep baseline-absorbed people in the panel. Positive ATT increases absorption. + +Identification and supported designs +------------------------------------ + +Identification assumes no anticipation, unaffected controls, absorbing outcomes, +a fixed population, common timing and either common dynamics (an additive gap +between untreated hazards) or proportional untreated hazards. These are hazard +restrictions, not parallel trends in the mean binary outcome. Independence +across individuals is an additional bootstrap inference assumption. + +At least two pre-periods and one post-period are required; the stored hazard +pretest needs three pre-periods. Numeric, Timestamp and Timedelta clocks must +have equal actual spacing. Numeric spacing comparisons use relative tolerance +1e-9 and absolute tolerance 1e-12 times the first spacing; datetime/timedelta +spacings must agree exactly. The estimator works on normalized observation +intervals and records ``time_origin`` / ``time_step``. + +Covariates, staggered adoption, censoring/dropout, repeated cross-sections, +survey/sampling weights and higher-level clustering are unsupported. No +``survey_design`` or ``cluster`` parameter exists. ``time_weights`` weights +calibration dates, not individuals. Do not drop incomplete histories or fill +outcomes to claim the fixed-population assumptions have been established. + +Calibration and point estimates +-------------------------------- + +``fit_periods`` selects dates after the original baseline and before treatment; +it never changes the diagnostic window or post horizon. ``time_weights`` keys +must match explicit selected dates, or all default eligible dates. Weights must +be finite and nonnegative with positive total. Zero-weight moments are removed +before arithmetic. Positive weights that underflow during normalization raise +instead of silently changing support. The effective positive-weight support remains fixed in every +bootstrap draw. Unsupported explicit positive-weight moments raise; default +exclusions carry reasons. + +For whole-group survival S, R=-log(S), baseline increments D, normalized elapsed +durations d, treated group 1 and control group 0: + +.. math:: + + \hat c_{CD}=\sum_t w_t(D_{1t}-D_{0t})/d_t,\qquad + \hat R^0_{1t}=\hat R_{1b}+D_{0t}+d_t\hat c_{CD}. + +.. math:: + + \hat c_{PH}=\sum_t w_t D_{1t}/D_{0t},\qquad + \hat R^0_{1t}=\hat R_{1b}+\hat c_{PH}D_{0t}. + +.. math:: + + \widehat{ATT}_t=\exp(-\hat R^0_{1t})-S_{1t}. + +The CD coefficient is a hazard gap per normalized observation interval; the PH +coefficient is a dimensionless hazard ratio. PH uses the mean of ratios and +requires a positive fitted coefficient; it is not the inverted printed slope. +The headline averages all declared post-period absorption ATTs uniformly. + +Zero factual treated post-survival is valid. Required baseline, calibration and +control extrapolation log domains must remain defined. Invalid counterfactual +probabilities or increasing survival (including the factual treated last-pre boundary) suppress +canonical causal output while preserving raw extrapolations. Curves are never +clipped, monotonized or silently shortened. Roundoff tolerance is 1e-12. +This conservative library gate can fail from sampling noise under correctly +specified population hazards: calibration need not interpolate the factual +last-pre point. Inspect original-curve status and bootstrap failure reasons. + +Inference and hazard pretest +---------------------------- + +Draw exactly B pooled samples of individuals, retaining full histories and +multiplicities, re-estimating nuisance coefficients and all effects. Use sample +SD/covariance (ddof=1), centered absolute bootstrap deviations, and inverse +empirical-CDF quantiles at sorted index ceil((1-alpha)B)-1. Pointwise bands use +coordinate pivots; simultaneous bands use the maximum over all declared post +dates. Apply uniform post weights within every draw for headline inference. +These are centered-bootstrap bands, not raw percentile or normal intervals. + +P-values count centered absolute deviations at least as large as the absolute +estimate; simultaneous p-values compare the corresponding maxima. Equality is +counted conservatively. These finite-bootstrap, potentially discrete p-values +are a library companion to the paper's bands, not an additional paper theorem. + +No retries, stratification, epsilon replacements or filtered-success inference +are used. Any failed effect draw suppresses the entire effect family's inference +and headline inference, retaining valid original points. Zero SE makes the +associated t/p/interval fields unavailable and disables simultaneous bands; +other valid pointwise rows and the scalar can remain available. Diagnostics +have independent bootstrap validity and cannot erase valid effect inference. + +``results.pretrend_test()`` returns a defensive copy of the stored +``DurationDiDPretestResults`` Diagnostic. It never refits. Algorithm 2 contrasts +each interior pre-date's average hazard gap (CD) or cumulative hazard ratio +(PH) with the last untreated anchor, excluding baseline and anchor from the +simultaneous family. Every interior pre-date is used regardless of calibration. +Missing diagnostic moments, failed draws or invalid SEs make the entire test +unavailable, with ``reject=None``; non-rejection does not establish identification +or adequate power. Hazard contrasts are not pre-treatment outcome ATTs. + +The support heuristic flags fewer than five survivors for either group at any +observed date, including factual treated post extinction. PH also flags fewer +than five control exits since baseline at each positive-weight calibration date. +Counts and warnings are reported without trimming or changing validity gates. + +Results and tables +------------------ + +Both results own their arrays/frames and expose ``summary(alpha=None)``, +``to_dict()`` and ``to_dataframe()``. Summaries reject alpha changes; bootstrap +intervals are stored at fit time. Strict JSON serialization replaces nonfinite +numbers by null, preserves date labels as ISO strings, and serializes period-keyed +mappings as period/value records. Native tables have chronological RangeIndex. + +``DurationDiDResults`` fields: + +- Headline: ``att``, ``se``, ``t_stat``, ``p_value``, ``conf_int``. +- Configuration: ``method``, ``alpha``, ``seed``, ``n_bootstrap``. +- Counts: ``n_obs`` (rows), ``n_units``, ``n_treated``, ``n_control`` (individuals). +- Dates: ``periods``, ``pre_periods``, ``post_periods``, effective ``fit_periods``, + ``requested_fit_periods``, normalized ``time_weights``, ``excluded_fit_periods``, + ``time_origin``, ``time_step`` and method-specific ``coefficient``. +- Tables: ``effects``, ``survival_curve``, ``bootstrap_failures``. +- Bootstrap: ``bootstrap_effects`` (B by post dates), ``n_bootstrap_valid`` + (complete effect draws), optional post-ordered ``vcov``, ``cband_crit_value``. +- Availability: ``estimation_status`` (ok/invalid_counterfactual), + ``inference_status`` and ``inference_reasons`` keyed by pointwise, simultaneous, + simple; available/unavailable, with partial additionally allowed for pointwise. + ``support_warnings`` and stored ``pretrend_results`` remain inspectable. +- Properties: ``inference_method='bootstrap'`` and uniform raw post mean ``raw_att``. + +Native schemas, in column order: + +- ``effects`` (post only): period, event_time, att, se, t_stat, p_value, + conf_int_lower, conf_int_upper, cband_lower, cband_upper, pointwise_crit_value, + inference_status, reason. +- ``survival_curve`` (all dates): period, elapsed_time, treated_survival, + control_survival, treated_survivors, control_survivors, + raw_counterfactual_cumulative_hazard, raw_counterfactual_survival, raw_att, + counterfactual_status, reason. Pre-date extrapolations are NaN/not_estimated; + post status is valid/invalid. Invalid original curves suppress all canonical + effects and the headline; raw values remain here. +- ``bootstrap_failures``: draw, family, period, reason. Draws are zero-based; + family is effects/diagnostics; period is nullable for group-level failure. + Multiple reasons do not double-count draws. Failed effect-draw rows are all NaN. + +``DurationDiDPretestResults`` holds ``method``, ``alpha``, ``anchor_period``, +``contrasts``, ``statistic``, ``p_value``, ``critical_value``, ``reject``, +``status``, ``reasons``, ``n_bootstrap``, ``n_bootstrap_attempted``, +``n_bootstrap_valid``, ``bootstrap_contrasts`` (B by tested dates), and diagnostic +``bootstrap_failures`` with the same failure schema. The contrast table has: +period, elapsed_time, contrast, se, cband_lower, cband_upper, status, reason. +It retains meaningful raw contrasts even when unavailable. With two pre-dates it +is empty with the same schema. An invalid original diagnostic attempts zero +bootstrap diagnostics and retains NaN draw slots. + +``to_dataframe()`` defaults to the shared event-study schema; +``level='simple'`` returns AGGREGATION_SCHEMA, while ``'survival'`` and +``'diagnostics'`` return copies of native tables. ``aggregate('simple')`` and +``aggregate('event_study')`` are pure views, rejecting custom weights and +``balance_e``. Event-study results include reference -1 (zero effect, undefined +inference/count) and post event times starting at zero, with post covariance, +its explicit index and simultaneous bands. Counts mean treated individuals; +degrees of freedom are NaN. HonestDiD/PreTrendsPower do not admit these results. + +Reporting and tutorial +---------------------- + +BusinessReport and DiagnosticReport expose the stored hazard diagnostic via the +native section. They do not run generic outcome parallel-trends tests. See +:doc:`business_report`, :doc:`diagnostic_report`, and +:doc:`/tutorials/33_duration_did` for availability and reporting examples. + +API +--- + +.. autoclass:: diff_diff.DurationDiD + :no-index: + :members: + :show-inheritance: + +.. autoclass:: diff_diff.DurationDiDResults + :no-index: + :members: + :show-inheritance: + +.. autoclass:: diff_diff.DurationDiDPretestResults + :no-index: + :members: + :show-inheritance: + +References +---------- + +Deaner, B. and Ku, H. (2026), Causal Duration Analysis with Diff-in-Diff, +`arXiv:2405.05220v2 `_. The source audit and +PH/diagnostic resolutions are in ``docs/methodology/papers/deaner-ku-2026-review.md``; +the implementation contract is in :doc:`/methodology/REGISTRY`. diff --git a/docs/api/index.rst b/docs/api/index.rst index dd2917402..a407a4499 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -36,6 +36,7 @@ regression discontinuity, and the Goodman-Bacon decomposition diagnostic: diff_diff.QDiD diff_diff.LWDiD diff_diff.DMLDiD + diff_diff.DurationDiD diff_diff.BaconDecomposition diff_diff.StaggeredTripleDifference diff_diff.RegressionDiscontinuity @@ -81,6 +82,8 @@ Result containers returned by estimators: diff_diff.changes_in_changes_results.ChangesInChangesResults diff_diff.lwdid_results.LWDiDResults diff_diff.dml_did_results.DMLDiDResults + diff_diff.DurationDiDResults + diff_diff.DurationDiDPretestResults diff_diff.Comparison2x2 diff_diff.StaggeredTripleDiffResults diff_diff.TWFEWeightsResult @@ -369,6 +372,7 @@ Estimators spillover wooldridge_etwfe lpdid + duration_did changes_in_changes lwdid dml_did diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 2bbd7cdfa..4a8eab565 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -883,7 +883,7 @@ Survey Design Support Most estimators support an optional ``survey_design`` parameter in ``fit()`` (``SyntheticControl`` accepts the parameter but raises ``NotImplementedError``; -``LWDiD`` accepts no ``survey_design`` parameter at all — +``LWDiD`` and ``DurationDiD`` accept no ``survey_design`` parameter at all — passing it raises ``TypeError``). Pass a :class:`~diff_diff.SurveyDesign` object to get design-based variance estimation. The depth of support varies by estimator and variance method: @@ -1010,6 +1010,11 @@ estimation. The depth of support varies by estimator and variance method: - Full (Binder TSL) - -- - -- + * - ``DurationDiD`` + - N/A (no survey support) + - N/A + - -- + - -- * - ``LWDiD`` - N/A (no survey support) - N/A @@ -1064,3 +1069,20 @@ estimation. The depth of support varies by estimator and variance method: For the full walkthrough with code examples, see the `survey tutorial `_. For deferred work and remaining limitations, see ``docs/survey-roadmap.md``. + +Duration DiD: Absorbing Individual Outcomes +------------------------------------------- + +Choose :class:`~diff_diff.DurationDiD` when the outcome records whether an +individual has already experienced absorption, the complete balanced panel has +unaffected controls and common treatment timing, and a common-dynamics or +proportional untreated-hazard restriction is credible. At least two pre-dates +and one post-date are needed. Group membership is fixed; ``post_periods`` declares +timing. This does not support arbitrary proportions, censoring, staggered timing, +covariates, survey weights or higher-level clustering. + +Inference is pooled individual bootstrap only: pointwise and simultaneous bands, +a uniformly time-averaged headline, and an independent stored hazard pretest. +Failed families report unavailable inference; very few treated individuals do +not gain exact or synthetic-control inference. Inspect support and failure counts. +See :doc:`api/duration_did` and :doc:`tutorials/33_duration_did`. diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index 7beb3e6c3..63ff0e383 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -21,6 +21,9 @@ # Members resolve to the first entry (the primary module) for doc lookup. # ────────────���───────────────────────────────────────────────────────── groups: + duration_did: + - diff_diff/duration_did.py + - diff_diff/duration_did_results.py staggered: - diff_diff/staggered.py - diff_diff/staggered_aggregation.py @@ -105,6 +108,76 @@ groups: # Source-to-docs mappings # ────────���─────────────────────────��─────────────────────────────────── sources: + diff_diff/duration_did.py: + drift_risk: high + docs: + - path: docs/api/duration_did.rst + type: api_reference + section: "Estimator and result contracts" + - path: docs/api/business_report.rst + type: api_reference + section: "DurationDiD native diagnostics and opt-out caveats" + - path: docs/api/diagnostic_report.rst + type: api_reference + section: "DurationDiD native hazard pretest" + - path: docs/api/index.rst + type: api_reference + section: "Estimator/results catalog" + - path: README.md + type: user_guide + section: "Estimators and Practitioner Workflow exceptions" + - path: docs/index.rst + type: user_guide + section: "Supported Estimators" + - path: docs/tutorials/index.rst + type: user_guide + section: "Duration DiD card and navigation" + - path: docs/tutorials/33_duration_did.ipynb + type: tutorial + section: "Absorbing outcomes and native diagnostics" + - path: docs/references.rst + type: user_guide + section: "Duration DiD" + - path: docs/choosing_estimator.rst + type: user_guide + section: "Duration DiD and Survey Design Support" + - path: docs/practitioner_decision_tree.rst + type: user_guide + section: "Absorbing Individual Outcomes and survey exclusions" + - path: docs/practitioner_getting_started.rst + type: user_guide + section: "What If You Have Survey Data?" + - path: docs/survey-roadmap.md + type: roadmap + section: "Current Limitations" + - path: diff_diff/guides/llms.txt + type: user_guide + section: "Estimator catalog, diagnostics and workflow" + - path: diff_diff/guides/llms-full.txt + type: user_guide + section: "DurationDiD, results, selection, reporting and workflow" + - path: diff_diff/guides/llms-practitioner.txt + type: user_guide + section: "Hazard assumptions, Step 5 and native robustness" + - path: diff_diff/guides/llms-autonomous.txt + type: user_guide + section: "Estimator-support matrix, §4 design discussion and native reporting" + - path: docs/methodology/REGISTRY.md + type: methodology + section: "DurationDiD" + - path: docs/methodology/REPORTING.md + type: methodology + section: "DurationDiD" + - path: docs/methodology/papers/deaner-ku-2026-review.md + type: methodology + section: "Binding dated source review (preserve historical framing)" + - path: METHODOLOGY_REVIEW.md + type: internal + section: "DurationDiD In Progress" + - path: ROADMAP.md + type: roadmap + section: "DurationDiD scope" + # ── Base estimators ──────��─────────────────────────────────────────── @@ -1188,6 +1261,9 @@ sources: diff_diff/business_report.py: drift_risk: medium docs: + - path: diff_diff/guides/llms-autonomous.txt + type: user_guide + section: "DurationDiD workflow and reporting" - path: docs/methodology/REPORTING.md type: methodology note: "Phrasing rules, pre-trends verdict thresholds, unit-translation policy, schema stability." @@ -1524,6 +1600,12 @@ sources: diff_diff/practitioner.py: drift_risk: low docs: + - path: diff_diff/guides/llms.txt + type: user_guide + section: "DurationDiD workflow and reporting" + - path: README.md + type: user_guide + section: "DurationDiD workflow and reporting" - path: diff_diff/guides/llms-practitioner.txt type: user_guide - path: diff_diff/guides/llms-full.txt diff --git a/docs/index.rst b/docs/index.rst index 5df12e3b8..48ffb22ed 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -185,6 +185,8 @@ Supported Estimators - Athey & Imbens (2006) distributional DiD with quantile treatment effects * - :class:`~diff_diff.LWDiD` - Lee & Wooldridge (2025, 2026) rolling-transformation DiD; ``rolling='detrend'`` handles heterogeneous linear trends + * - :class:`~diff_diff.DurationDiD` + - Common-timing cumulative absorption effects under common dynamics or proportional hazards * - :class:`~diff_diff.DMLDiD` - Chang (2020) double/debiased ML DiD; staggered ATT(g,t) with cross-fitted nuisance learners (panel or declared repeated cross sections; survey/cluster support) * - :class:`~diff_diff.QDiD` diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index e19c8601c..10195b8b4 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -10,6 +10,7 @@ This document provides the academic foundations and key implementation requireme - [DifferenceInDifferences](#differenceindifferences) - [MultiPeriodDiD](#multiperioddid) - [TwoWayFixedEffects](#twowayfixedeffects) + - [DurationDiD](#durationdid) 2. [Modern Staggered Estimators](#modern-staggered-estimators) - [CallawaySantAnna](#callawaysantanna) - [ChaisemartinDHaultfoeuille](#chaisemartindhaultfoeuille) @@ -46,6 +47,73 @@ This document provides the academic foundations and key implementation requireme # Core DiD Estimators +## DurationDiD + +**Reference:** Deaner and Ku (2026), *Causal Duration Analysis with Diff-in-Diff*, +arXiv:2405.05220v2. Binding source audit: +`docs/methodology/papers/deaner-ku-2026-review.md`. Module: `duration_did.py`; +results: `duration_did_results.py`. Dedicated checks: `test_methodology_duration_did.py`. + +**Design and estimand:** Two groups, complete balanced individual histories, +common intervention time, absorbing binary outcomes, no anticipation and unaffected +controls. Keep baseline-absorbed people. With group survival S, R=-log(S), baseline +increments D and normalized elapsed d, CD uses `c=sum w*(D1-D0)/d` and +`Rcf=R1baseline+D0+d*c` (3.2–3.4); PH uses `c=sum w*D1/D0` and +`Rcf=R1baseline+c*D0` (Theorem 1, 2.16). `ATT=exp(-Rcf)-S1` measures increased +cumulative absorption on the whole treated population. The headline uniformly +averages all declared post-date ATTs. CD c is per normalized observation interval; +PH c is dimensionless. No outcome-mean parallel-trends assumption is imposed. + +- **Note:** PH mean-of-ratios follows the review's resolution of printed 3.5; + neither the inverted printed slope nor a repaired least-squares slope is used. + Require a positive coefficient and positive control increments for required moments. +- **Note:** Default calibration equally weights original eligible pre-dates strictly + after baseline. Explicit nonnegative weights normalize over positive support; + zero weights remove moments before evaluation; unrepresentable positive normalized + weights raise rather than silently changing support. Unsupported explicit positive-weight + moments raise; default exclusions retain reasons. Freeze support for every draw. +- **Note:** Appendix B Algorithm 2 is the fixed-anchor diagnostic: every interior + pre-date's gap/ratio minus the last-pre anchor, baseline and anchor excluded from + studentization. It is separate from outcome effects and from calibration selection. + At least three pre-dates are needed; unavailable tests have `reject=None`. +- **Note:** Algorithm 1 uses exactly B pooled individual history draws with full + nuisance re-estimation; independent individuals are assumed. Use ddof=1, centered + absolute pivots and inverse empirical-CDF index ceil((1-alpha)B)-1. Simultaneous + families are all declared post-dates (effects) or all interior pre-dates (diagnostic). + Headline weights are applied inside each draw. Centered absolute-tail p-values + count equality and are a finite-bootstrap library companion to the bands. +- **Note:** Never retry, stratify, substitute epsilons, or infer from filtered + successful draws. Any failed draw suppresses its family's inference; preserve + valid original points. Effect and diagnostic validity are independent. Zero SE + makes associated t/p/CI undefined and disables joint bands; other valid pointwise + coordinates and headline inference remain available. Covariance requires all effect draws. +- **Note:** Required logs must have positive survival; zero factual treated post + survival is valid. Counterfactual probability and monotonicity checks include the + factual treated last-pre boundary with 1e-12 tolerance. This conservative library + gate can fail under sampling noise even when population hazard restrictions hold; + the calibrated curve need not interpolate the factual last-pre point. + Invalid original curves retain raw paths + but suppress all canonical post effects and headline; no clipping or horizon trimming. +- **Note:** Support warnings (<5 survivors in either group at every date; PH also + <5 control exits since baseline at selected calibration dates) are library heuristics, + not theorem thresholds. They do not trim or replace strict domain/failure gates. +- **Note:** Numeric grid equality uses rtol=1e-9 and atol=1e-12 times the first + spacing; datetime/timedelta grids require equal actual durations. All estimation + uses normalized observation intervals; original labels and spacing remain recorded. + +**Results and scope:** Flat native inference quintet; owned frames/arrays and strict +JSON metadata. Only simple/event-study aggregation; the event study has a reference +-1 and post times starting at zero with post covariance and simultaneous bands. +Stored `pretrend_test()` returns a Diagnostic copy, never recomputes. Native reporting +rejects generic PT/sensitivity overrides and supplied diagnostic reports whose +native payload differs from the fitted result's stored hazard diagnostic or +availability metadata. Covariates, staggered timing, censoring, +repeated cross-sections, survey weights and higher-level clustering are unsupported. +No author-code parity or application replication is claimed. + +--- + + ## DifferenceInDifferences **Primary source:** Canonical econometrics textbooks diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md index 71bcf94f1..cf5933d75 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -573,3 +573,42 @@ The phrasing rules follow the guidance in: - Roth, J. (2022). *Pretest with Caution: Event-study Estimates after Testing for Parallel Trends.* American Economic Review: Insights. (Motivates the power-aware phrasing tiers.) + +## DurationDiD + +`DurationDiDResults` has only estimator-native DiagnosticReport applicability. +The stored fixed-anchor hazard pretest is extracted without refitting under +`estimator_native_diagnostics.pretrend_test`; outer `status=ran` describes +extraction, nested status/reject/reasons describe statistical availability. +The full serialized diagnostic retains its fitted alpha and bootstrap counts. +Effect and diagnostic families are independently valid. BusinessReport relays +this block to `robustness.estimator_native` and renders it in both report forms. +DiagnosticReport and its returned results summary also render the stored hazard +decision, fitted simultaneous confidence level and unavailability reasons; the +full report includes this narrative in its native section. The native dataframe +headline is the available hazard-test p-value, otherwise missing with the stored +availability reasons in `reason`. Its outer `status=ran` still means extraction. +Invalid-counterfactual guidance points to raw `survival_curve` extrapolations and +method/calibration comparisons. Rendering does not recompute diagnostic inference +or turn an unavailable test into a non-rejection. The warnings channel retains its +existing role of reporting captured execution warnings. + +The target parameter is the uniform mean across declared post-dates of cumulative +absorption effects on the whole treated population (baseline-absorbed included). +Positive ATT increases absorption. Identification uses the selected untreated-hazard +restriction, no anticipation, unaffected controls, fixed population, absorption +and common timing; individual independence additionally underlies bootstrap inference. +Do not describe this as outcome-level parallel trends or infer identification/power +from a non-rejection. Generic precomputed overrides and `honest_did_results` are rejected. +Supplied `diagnostics=` containers must name DurationDiD, have no computed generic +checks, and match the fitted result's complete stored native hazard payload and +availability metadata. This applies to both live DiagnosticReport objects and +detached DiagnosticReportResults. Use `pretrend_test()` and comparisons of `method` +and `fit_periods` for supported diagnostic and specification guidance. + +Report alpha overrides preserve fitted intervals. Even with `auto_diagnostics=False`, +invalid-curve, unavailable-family, failed-draw and support caveats remain visible. +Few-treated counts mean people: warn about individual-bootstrap support and reliability, +without the generic large-cluster or SyntheticDiD/exact-permutation recommendation. +Short summaries count support warnings and summarize each unavailable inference +family; all per-date warnings and reasons remain in `to_dict()` and `full_report()`. diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index bec4b5159..8a188197d 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -49,6 +49,11 @@ Which of these best describes your situation? Universal rollout with dose-only variation. Go to :ref:`section-no-untreated`. +8. **I track whether each person has experienced a one-time event** + + Complete absorbing histories with common intervention timing. Go to + :ref:`section-duration`. + .. tip:: In academic literature, "rolling out in waves" is called *staggered adoption*, @@ -90,6 +95,9 @@ change in your test markets to the before/after change in your control markets. *ATT* (Average Treatment Effect on the Treated) - it tells you the average lift among the markets that received the campaign. +For absorbing individual histories, first consider :ref:`section-duration`; +the ordinary outcome parallel-trends assumption is a different restriction. + **When to upgrade:** - If you have many time periods and want unit-level controls: @@ -99,6 +107,31 @@ change in your test markets to the before/after change in your control markets. (``MultiPeriodDiD`` is deprecated in 3.9) +.. _section-duration: + +Absorbing Individual Outcomes +----------------------------- + +**Your situation:** You track the same individuals at equally spaced dates and +record whether each has already experienced an absorbing event. There are +unaffected controls, and intervention begins at a common date for treated people. +The panel is balanced: every individual has a complete history; keep people absorbed at baseline. + +**Method:** :class:`~diff_diff.DurationDiD` under common dynamics or proportional +untreated hazards. These hazard restrictions need substantive justification. +Positive effects increase cumulative absorption; the headline averages all +post-date ATTs over the whole treated population. At least two pre-dates and one +post-date are required; three pre-dates permit the stored hazard pretest. + +Inspect ``results.pretrend_test()``, survival paths, support and bootstrap failures. +Non-rejection does not establish identification. Inference uses pooled individual +histories, assuming independent individuals. Covariates, staggered timing, +censoring/dropout, repeated cross-sections, survey weights and higher-level +clustering are unsupported. This is not a shortcut for few treated markets or +arbitrary proportion outcomes. See :doc:`api/duration_did` and +:doc:`tutorials/33_duration_did`. + + .. _section-staggered: Staggered Rollout @@ -438,8 +471,8 @@ satisfaction, NPS, or similar. The survey uses stratified sampling, clustering ( by geography), or probability weights. **Answer:** Use a survey-capable method above, combined with -:class:`~diff_diff.SurveyDesign`. (:class:`~diff_diff.LWDiD` is the -exception: it accepts no ``survey_design`` parameter at all — see the +:class:`~diff_diff.SurveyDesign`. (:class:`~diff_diff.LWDiD` and :class:`~diff_diff.DurationDiD` are +exceptions: they accept no ``survey_design`` parameter at all — see the :ref:`survey-design-support` matrix. :class:`~diff_diff.DMLDiD` supports pweight full-design TSL, replicate-weight designs (IF-reweighting), and ``cluster=`` on both lanes.) @@ -528,19 +561,22 @@ At a Glance * - Only a few test markets - ``SyntheticDiD`` or ``LWDiD`` - ``SyntheticDiD`` builds a synthetic counterfactual; ``LWDiD`` adds exact small-sample inference (under classical error assumptions — see :ref:`section-few-markets`) - * - Survey data (any design above) - - Any survey-capable estimator above (not ``LWDiD``) + ``SurveyDesign`` + * - Absorbing individual histories, common timing + - ``DurationDiD`` + - Cumulative absorption ATT under CD/PH hazard restrictions; balanced panel + * - Survey data (supported designs above) + - Any survey-capable estimator above (not ``LWDiD`` or ``DurationDiD``) + ``SurveyDesign`` - Correct confidence intervals; see the :ref:`survey-design-support` matrix What About the Other Estimators? -------------------------------- -diff-diff has 24 estimators covering advanced scenarios: Sun-Abraham for +diff-diff has 25 estimators covering advanced scenarios: Sun-Abraham for interaction-weighted estimation, Imputation DiD and Two-Stage DiD for alternative staggered approaches, Local Projections DiD, Stacked DiD, Efficient DiD, Triple Difference, TROP, Changes-in-Changes for distributional/quantile effects, and more. -The six scenarios above cover the most common business use cases. +The scenarios above cover the most common business use cases. - **Want rolling-transformation approach?** → :class:`~diff_diff.LWDiD` (Lee & Wooldridge 2025, 2026) diff --git a/docs/practitioner_getting_started.rst b/docs/practitioner_getting_started.rst index 8acc83ef4..3543092b4 100644 --- a/docs/practitioner_getting_started.rst +++ b/docs/practitioner_getting_started.rst @@ -290,8 +290,12 @@ If your outcome comes from a survey (brand awareness, NPS, customer satisfaction your data likely has a complex sampling design with strata, clusters, and weights. Ignoring these makes your confidence intervals too narrow. -diff-diff handles this via :class:`~diff_diff.SurveyDesign` - pass it to any estimator's -``fit()`` method. +diff-diff handles this via :class:`~diff_diff.SurveyDesign` - pass it to a survey-capable estimator's +``fit()`` method. ``DurationDiD`` has no ``survey_design`` argument and supports +only unweighted complete individual panels with pooled individual-bootstrap +inference. Its calibration ``time_weights`` are not survey weights. See the +:ref:`survey-design-support` matrix; choosing another estimator also requires +compatible identifying assumptions and a compatible target parameter. If your data is **individual-level microdata** - one row per respondent, with sampling weights and strata/PSU columns (BRFSS, ACS, CPS, NHANES) - use diff --git a/docs/references.rst b/docs/references.rst index 5f7f7347d..342f4d0d3 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -400,3 +400,11 @@ General Causal Inference - **Imbens, G. W., & Rubin, D. B. (2015).** *Causal Inference for Statistics, Social, and Biomedical Sciences: An Introduction*. Cambridge University Press. - **Cunningham, S. (2021).** *Causal Inference: The Mixtape*. Yale University Press. https://mixtape.scunning.com/ + +Duration DiD +------------ + +Deaner, B. and Ku, H. (2026). Causal Duration Analysis with Diff-in-Diff. +`arXiv:2405.05220v2 `_. See +:doc:`api/duration_did` for the two-group implementation and +``docs/methodology/papers/deaner-ku-2026-review.md`` for the source audit. diff --git a/docs/survey-roadmap.md b/docs/survey-roadmap.md index ffdb16b8d..189553e9d 100644 --- a/docs/survey-roadmap.md +++ b/docs/survey-roadmap.md @@ -270,6 +270,7 @@ the limitation and suggested alternative. | Estimator | Limitation | Alternative | |-----------|-----------|-------------| +| DurationDiD | Any `survey_design` / sampling weights / higher-level clustering | No survey-aware DurationDiD path; these keywords raise `TypeError` because no argument exists (also an exception to the preamble). `time_weights` only calibrates dates. Use a survey-capable estimator only when its estimand and identifying assumptions fit the study; do not discard the survey design to use the individual bootstrap. | | LWDiD | Any `survey_design` / sampling weights | No weight argument exists on any path, so the failure mode is a bare `TypeError: unexpected keyword argument` rather than a descriptive error (the exception to the preamble above). The LW papers derive the transformation and exact-inference layer for unweighted panels; a weighted counterpart is DEFERRED pending user demand. Use `CallawaySantAnna` (or another survey-capable staggered estimator) when design-based variance is required. | | SyntheticDiD | Replicate weights | Pre-existing limitation: no replicate-weight survey support on SDID. All three variance methods (bootstrap, placebo, jackknife) now support pweight-only and strata/PSU/FPC designs; replicate-weight designs remain rejected. | | TROP | Replicate weights | Use strata/PSU/FPC design with Rao-Wu rescaled bootstrap | diff --git a/docs/tutorials/33_duration_did.ipynb b/docs/tutorials/33_duration_did.ipynb new file mode 100644 index 000000000..d9ae020c4 --- /dev/null +++ b/docs/tutorials/33_duration_did.ipynb @@ -0,0 +1,705 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d344e6f5", + "metadata": {}, + "source": [ + "# Duration DiD: absorbing individual outcomes\n", + "\n", + "DurationDiD estimates how intervention changes cumulative absorption in a fixed\n", + "population observed at equally spaced dates. Positive effects mean more people\n", + "have experienced the event. The headline averages effects across the declared\n", + "post-periods, including people already absorbed at baseline in the population.\n", + "\n", + "This example constructs complete histories with illustrative survivor counts.\n", + "It is not an empirical application or author-software replication. Identification\n", + "requires common dynamics (CD) or proportional untreated hazards (PH), no anticipation,\n", + "unaffected controls and common timing. These are hazard restrictions, not parallel\n", + "trends in the mean binary outcome. Inference additionally assumes independent people." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3438199b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-06T20:28:48.001965Z", + "iopub.status.busy": "2026-09-06T20:28:48.001901Z", + "iopub.status.idle": "2026-09-06T20:28:48.757715Z", + "shell.execute_reply": "2026-09-06T20:28:48.757339Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
group01
date
00.040.10
10.200.25
20.360.40
30.520.55
40.680.80
50.840.92
\n", + "
" + ], + "text/plain": [ + "group 0 1\n", + "date \n", + "0 0.04 0.10\n", + "1 0.20 0.25\n", + "2 0.36 0.40\n", + "3 0.52 0.55\n", + "4 0.68 0.80\n", + "5 0.84 0.92" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "from diff_diff import DurationDiD, DiagnosticReport, BusinessReport, plot_event_study\n", + "\n", + "rows = []\n", + "for group, counts in enumerate(([96, 80, 64, 48, 32, 16], [90, 75, 60, 45, 20, 8])):\n", + " for person in range(400):\n", + " for date, survivors in enumerate(counts):\n", + " rows.append((400 * group + person, date, group, int(person >= 4 * survivors)))\n", + "panel = pd.DataFrame(rows, columns=[\"person\", \"date\", \"group\", \"absorbed\"])\n", + "# Complete fixed populations, including baseline-absorbed people.\n", + "assert not panel.duplicated([\"person\", \"date\"]).any()\n", + "assert panel.groupby(\"person\").size().eq(6).all()\n", + "panel.groupby([\"group\", \"date\"])[\"absorbed\"].mean().unstack(\"group\")" + ] + }, + { + "cell_type": "markdown", + "id": "f840e1c6", + "metadata": {}, + "source": [ + "## Fit the common-dynamics specification\n", + "\n", + "`group` is fixed membership; `post_periods=[4, 5]` supplies intervention timing.\n", + "The default calibration uses eligible dates after baseline and before intervention.\n", + "Calibration never changes the diagnostic window. The CD coefficient is a hazard\n", + "gap per normalized observation interval. These fits use a reproducible pooled\n", + "individual bootstrap with 199 draws; production defaults request 1,000 draws." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1b3799da", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-06T20:28:48.759160Z", + "iopub.status.busy": "2026-09-06T20:28:48.759058Z", + "iopub.status.idle": "2026-09-06T20:28:48.779866Z", + "shell.execute_reply": "2026-09-06T20:28:48.779529Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DurationDiD (common_dynamics)\n", + "Coefficient (hazard gap per normalized observation interval): 5.85951e-17\n", + "Mean cumulative absorption ATT: 0.085; SE: 0.0238052\n", + "95% CI: (0.041743657151780106, 0.12825634284821985); p-value: 0\n", + "Estimation: ok; inference: {'pointwise': 'available', 'simultaneous': 'available', 'simple': 'available'}\n", + "Pooled individual bootstrap: 199/199 valid effect draws\n", + "DurationDiD hazard pretest (common_dynamics)\n", + "95% simultaneous bands; not rejected\n", + "Statistic: 1.5609e-15; p-value: 1\n", + "Bootstrap draws: 199/199 valid (199 requested)\n", + "Non-rejection does not establish identification or adequate power.\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
event_timeattset_statp_valueconf_int_lowerconf_int_uppercband_lowercband_upperndfis_referenceestimand
0-10.00NaNNaNNaNNaNNaNNaNNaNNaNNaNTrueatt
100.100.0279983.5717010.00.0449760.1550240.0402650.159735400.0NaNFalseatt
210.070.0238362.9367310.00.0232320.1167680.0191440.120856400.0NaNFalseatt
\n", + "
" + ], + "text/plain": [ + " event_time att se t_stat p_value conf_int_lower \\\n", + "0 -1 0.00 NaN NaN NaN NaN \n", + "1 0 0.10 0.027998 3.571701 0.0 0.044976 \n", + "2 1 0.07 0.023836 2.936731 0.0 0.023232 \n", + "\n", + " conf_int_upper cband_lower cband_upper n df is_reference estimand \n", + "0 NaN NaN NaN NaN NaN True att \n", + "1 0.155024 0.040265 0.159735 400.0 NaN False att \n", + "2 0.116768 0.019144 0.120856 400.0 NaN False att " + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cd = DurationDiD(n_bootstrap=199, seed=33).fit(\n", + " panel, \"absorbed\", \"group\", \"person\", \"date\", post_periods=[4, 5]\n", + ")\n", + "np.testing.assert_allclose(cd.effects[\"att\"], [0.10, 0.07])\n", + "print(cd.summary())\n", + "cd.to_dataframe()" + ] + }, + { + "cell_type": "markdown", + "id": "1d039d71", + "metadata": {}, + "source": [ + "## Inspect survival and effect paths\n", + "\n", + "Counterfactual survival is the survival that treated people would have had without\n", + "intervention under the selected hazard restriction. Invalid probabilities or\n", + "increasing counterfactual survival suppress canonical causal estimates; raw\n", + "extrapolations remain inspectable. Zero factual treated post-survival is valid.\n", + "The effect plot contains a reference and post-date effects, not hazard contrasts\n", + "masquerading as pre-treatment outcome effects." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5bb57c96", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-06T20:28:48.780824Z", + "iopub.status.busy": "2026-09-06T20:28:48.780764Z", + "iopub.status.idle": "2026-09-06T20:28:48.896146Z", + "shell.execute_reply": "2026-09-06T20:28:48.895769Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "survival = cd.to_dataframe(\"survival\")\n", + "fig, ax = plt.subplots(figsize=(7, 4))\n", + "ax.plot(survival.period, survival.treated_survival, \"o-\", label=\"Treated factual\")\n", + "ax.plot(survival.period, survival.control_survival, \"o-\", label=\"Control factual\")\n", + "ax.plot(survival.period, survival.raw_counterfactual_survival, \"o--\", label=\"Treated counterfactual\")\n", + "ax.axvline(3.5, color=\"gray\", linestyle=\":\")\n", + "ax.set(xlabel=\"Observation date\", ylabel=\"Whole-group survival\", ylim=(0, 1))\n", + "ax.legend()\n", + "plt.tight_layout()\n", + "plt.show()\n", + "plot_event_study(cd.aggregate(\"event_study\"), show=False)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "22e6700c", + "metadata": {}, + "source": [ + "## Stored hazard pretest and proportional hazards\n", + "\n", + "The Appendix B fixed-anchor pretest compares interior pre-date hazard gaps or\n", + "ratios with the last untreated anchor. Its simultaneous family excludes baseline\n", + "and anchor. Three pre-dates are necessary, and missing support, failed diagnostic\n", + "draws or zero SE make the entire pretest unavailable (`reject=None`). Non-rejection\n", + "does not establish identification or adequate power.\n", + "\n", + "PH estimates a weighted mean of cumulative-hazard ratios. Its coefficient is\n", + "dimensionless. Comparing CD and PH is a specification assessment under different\n", + "hazard restrictions; it is not an automatic selection rule." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "02eea887", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-06T20:28:48.897277Z", + "iopub.status.busy": "2026-09-06T20:28:48.897195Z", + "iopub.status.idle": "2026-09-06T20:28:48.901225Z", + "shell.execute_reply": "2026-09-06T20:28:48.900908Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DurationDiD hazard pretest (common_dynamics)\n", + "95% simultaneous bands; not rejected\n", + "Statistic: 1.5609e-15; p-value: 1\n", + "Bootstrap draws: 199/199 valid (199 requested)\n", + "Non-rejection does not establish identification or adequate power.\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
periodelapsed_timecontrastsecband_lowercband_upperstatusreason
011.04.625929e-170.029636-0.0668510.066851availableNone
122.01.850372e-170.020149-0.0454500.045450availableNone
\n", + "
" + ], + "text/plain": [ + " period elapsed_time contrast se cband_lower cband_upper \\\n", + "0 1 1.0 4.625929e-17 0.029636 -0.066851 0.066851 \n", + "1 2 2.0 1.850372e-17 0.020149 -0.045450 0.045450 \n", + "\n", + " status reason \n", + "0 available None \n", + "1 available None " + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "diagnostic = cd.pretrend_test() # Owned copy of the stored Diagnostic; no refit.\n", + "print(diagnostic.summary())\n", + "diagnostic.to_dataframe()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "94bed4a6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-06T20:28:48.902234Z", + "iopub.status.busy": "2026-09-06T20:28:48.902161Z", + "iopub.status.idle": "2026-09-06T20:28:48.923281Z", + "shell.execute_reply": "2026-09-06T20:28:48.922932Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
methodcoefficientattsevalid_effect_drawshazard_test
0common_dynamics5.859510e-170.0850.023805199available
1proportional_hazards1.000000e+000.0850.031248199available
\n", + "
" + ], + "text/plain": [ + " method coefficient att se valid_effect_draws \\\n", + "0 common_dynamics 5.859510e-17 0.085 0.023805 199 \n", + "1 proportional_hazards 1.000000e+00 0.085 0.031248 199 \n", + "\n", + " hazard_test \n", + "0 available \n", + "1 available " + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ph = DurationDiD(method=\"proportional_hazards\", n_bootstrap=199, seed=33).fit(\n", + " panel, \"absorbed\", \"group\", \"person\", \"date\", post_periods=[4, 5],\n", + " fit_periods=[1, 2, 3], time_weights={1: 1, 2: 1, 3: 1},\n", + ")\n", + "pd.DataFrame([\n", + " {\"method\": r.method, \"coefficient\": r.coefficient, \"att\": r.att, \"se\": r.se,\n", + " \"valid_effect_draws\": r.n_bootstrap_valid, \"hazard_test\": r.pretrend_results.status}\n", + " for r in (cd, ph)\n", + "])" + ] + }, + { + "cell_type": "markdown", + "id": "6854dd56", + "metadata": {}, + "source": [ + "## Report availability honestly\n", + "\n", + "Exactly B pooled history draws are attempted without retries or stratification.\n", + "Any failed draw disables inference for its family; successful draws are not\n", + "filtered into a new inferential distribution. Effect and diagnostic families\n", + "have independent availability. Inspect survivor/exit support and failure records.\n", + "\n", + "DiagnosticReport extracts the stored hazard test without raw data or refitting.\n", + "Its outer native status describes extraction, and the nested status describes\n", + "statistical availability. BusinessReport preserves fitted confidence levels and\n", + "keeps fit-level validity caveats visible when automatic diagnostics are disabled." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a861cce7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-06T20:28:48.924354Z", + "iopub.status.busy": "2026-09-06T20:28:48.924291Z", + "iopub.status.idle": "2026-09-06T20:28:48.929995Z", + "shell.execute_reply": "2026-09-06T20:28:48.929698Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Effect inference: {'pointwise': 'available', 'simultaneous': 'available', 'simple': 'available'}\n", + "Hazard pretest: available\n", + "The fixed-anchor hazard pretest does not reject at the 95% simultaneous confidence level (p=1). Non-rejection does not establish identification or adequate power. The treatment increased Cumulative absorption by 0.085 (95% CI: 0.0417 to 0.128). Target parameter: Mean cumulative absorption ATT. Statistically, the direction of the effect is strongly supported by the data. Sample: 4,800 observations (400 treated, 400 control).\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
drawfamilyperiodreason
\n", + "
" + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [draw, family, period, reason]\n", + "Index: []" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "native = DiagnosticReport(cd).to_dict()[\"estimator_native_diagnostics\"]\n", + "assert native[\"pretrend_test\"] == diagnostic.to_dict()\n", + "print(\"Effect inference:\", native[\"inference_status\"])\n", + "print(\"Hazard pretest:\", native[\"pretrend_test\"][\"status\"])\n", + "print(BusinessReport(cd, outcome_label=\"Cumulative absorption\").summary())\n", + "cd.bootstrap_failures" + ] + }, + { + "cell_type": "markdown", + "id": "0ef7d939", + "metadata": {}, + "source": [ + "## Scope and sources\n", + "\n", + "Only complete balanced individual panels with two groups and common treatment\n", + "timing are supported. Covariates, staggered adoption, censoring/dropout, repeated\n", + "cross-sections, survey weights and higher-level clustering require extensions.\n", + "`time_weights` calibrates dates and does not provide survey weighting. Do not\n", + "manufacture complete histories by filling missing outcomes or dropping people.\n", + "Generic HonestDiD, outcome parallel-trends and cluster-bootstrap workflows do not\n", + "apply to this estimator.\n", + "\n", + "- Deaner and Ku (2026), [Causal Duration Analysis with Diff-in-Diff](https://arxiv.org/abs/2405.05220v2).\n", + "- [DurationDiD API](../api/duration_did.rst).\n", + "- Dated methodology review: `docs/methodology/papers/deaner-ku-2026-review.md` and\n", + " [implementation Registry](../methodology/REGISTRY.md).\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index 63467834c..1501dd7d9 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -238,6 +238,12 @@ Modern estimators for designs the basic toolkit cannot handle. Sharp and fuzzy RD from plot to estimate, when a naive cutoff comparison overstates the effect fivefold. + .. grid-item-card:: Duration DiD + :link: 33_duration_did + :link-type: doc + + Absorbing individual outcomes, hazard assumptions, bootstrap bands and native diagnostics. + .. grid-item-card:: LWDiD Rolling Transformations :link: 31_lwdid :link-type: doc @@ -268,6 +274,7 @@ Modern estimators for designs the basic toolkit cannot handle. Wooldridge ETWFE <16_wooldridge_etwfe> Synthetic Control for Policy <25_synthetic_control_policy> Regression Discontinuity (RDD) <28_rdd_scholarship_illusion> + Duration DiD <33_duration_did> LWDiD Rolling Transformations <31_lwdid> Double ML DiD (Chang 2020) <32_dml_did> diff --git a/tests/helpers/results_foundation.py b/tests/helpers/results_foundation.py index c7168bb81..4479c49c4 100644 --- a/tests/helpers/results_foundation.py +++ b/tests/helpers/results_foundation.py @@ -76,6 +76,34 @@ def make_constructed_diagnostics() -> Dict[str, Any]: ) instances: Dict[str, Any] = { + "DurationDiDPretestResults": diff_diff.DurationDiDPretestResults( + method="common_dynamics", + alpha=0.05, + anchor_period=1, + contrasts=pd.DataFrame( + columns=[ + "period", + "elapsed_time", + "contrast", + "se", + "cband_lower", + "cband_upper", + "status", + "reason", + ] + ), + statistic=np.nan, + p_value=np.nan, + critical_value=np.nan, + reject=None, + status="unavailable", + reasons=["at least three pre-periods required"], + n_bootstrap=2, + n_bootstrap_attempted=0, + n_bootstrap_valid=0, + bootstrap_contrasts=np.empty((2, 0)), + bootstrap_failures=pd.DataFrame(columns=["draw", "family", "period", "reason"]), + ), "RDPlotResult": diff_diff.RDPlotResult( coef=coef, vars_bins=rng_bins, diff --git a/tests/test_base_estimator.py b/tests/test_base_estimator.py index 62bd8f059..a4f650c00 100644 --- a/tests/test_base_estimator.py +++ b/tests/test_base_estimator.py @@ -79,6 +79,7 @@ def _discover(): # (value-level rollback lane). Classes absent here are still covered by the # unknown-key atomicity lane. BAD_VALUES = { + "DurationDiD": {"n_bootstrap": 1}, "DifferenceInDifferences": {"vcov_type": "hc99"}, "DMLDiD": {"n_folds": 0}, "TwoWayFixedEffects": {"vcov_type": "hc99"}, diff --git a/tests/test_diagnostic_marker.py b/tests/test_diagnostic_marker.py index 1b43397cc..13981daff 100644 --- a/tests/test_diagnostic_marker.py +++ b/tests/test_diagnostic_marker.py @@ -30,6 +30,7 @@ # separately: it is produced by a real fit below (its container is only # meaningful when populated by the decomposition). DIAGNOSTIC_ROSTER = [ + "DurationDiDPretestResults", "BaconDecompositionResults", "RDPlotResult", "RDDensityTestResult", diff --git a/tests/test_doc_deps_integrity.py b/tests/test_doc_deps_integrity.py index 6a49214a9..39e7ff3b7 100644 --- a/tests/test_doc_deps_integrity.py +++ b/tests/test_doc_deps_integrity.py @@ -186,3 +186,41 @@ def test_group_primaries_have_sources_entry(): + "\n\nGroup members resolve to the group's first entry; add a `sources:` entry for that " "primary module in docs/doc-deps.yaml." ) + + +@pytest.mark.parametrize( + "module", ["diff_diff/duration_did.py", "diff_diff/duration_did_results.py"] +) +def test_duration_modules_resolve_complete_documentation(module): + text = DOC_DEPS.read_text() + members = _parse_groups()["duration_did"] + assert module in members and members[0] == "diff_diff/duration_did.py" + section = text.split(" " + members[0] + ":\n", 1)[1].split("\n diff_diff/", 1)[0] + mapped = { + m.group(1).strip().strip('"') + for line in section.splitlines() + if (m := _PATH_RE.match(line)) + } + required = { + "README.md", + "docs/index.rst", + "docs/api/index.rst", + "docs/api/duration_did.rst", + "docs/api/business_report.rst", + "docs/api/diagnostic_report.rst", + "docs/references.rst", + "docs/choosing_estimator.rst", + "docs/practitioner_decision_tree.rst", + "docs/practitioner_getting_started.rst", + "docs/survey-roadmap.md", + "docs/tutorials/index.rst", + "docs/tutorials/33_duration_did.ipynb", + "docs/methodology/REGISTRY.md", + "docs/methodology/REPORTING.md", + "docs/methodology/papers/deaner-ku-2026-review.md", + "diff_diff/guides/llms.txt", + "diff_diff/guides/llms-full.txt", + "diff_diff/guides/llms-autonomous.txt", + "diff_diff/guides/llms-practitioner.txt", + } + assert required <= mapped diff --git a/tests/test_docs_ia.py b/tests/test_docs_ia.py index f232d171d..fc6174e3b 100644 --- a/tests/test_docs_ia.py +++ b/tests/test_docs_ia.py @@ -738,3 +738,43 @@ def test_r_argument_table_is_complete_and_real(): f"R argument table is missing an in-scope package block: " f"{sorted(_R_TABLE_REQUIRED_PACKAGES - packages)}" ) + + +def test_duration_public_selection_and_survey_scope(): + selection = (DOCS / "practitioner_decision_tree.rst").read_text() + duration = _section_body(selection, "Absorbing Individual Outcomes") + for term in ("DurationDiD", "balanced", "individual", "common", "hazard"): + assert term in duration + assert "DurationDiD" in _section_body(selection, "At a Glance") + assert "25 estimators" in selection + survey = (DOCS / "survey-roadmap.md").read_text().split("## Current Limitations", 1)[1] + row = next(line for line in survey.splitlines() if line.startswith("| DurationDiD |")) + assert "TypeError" in row and "time_weights" in row and "No survey-aware" in row + start_here = _section_body( + (DOCS / "practitioner_getting_started.rst").read_text(), "What If You Have Survey Data?" + ) + assert "survey-capable estimator" in start_here and "DurationDiD" in start_here + assert "any estimator's" not in start_here + + +def test_duration_public_reporting_and_readme_workflow(): + report = _section_body( + (DOCS / "api/business_report.rst").read_text(), "DurationDiD native hazard diagnostics" + ) + for term in ( + "auto_diagnostics=False", + "reject=None", + "honest_did_results", + "confidence", + "bootstrap", + ): + assert term in report + readme = ( + (REPO_ROOT / "README.md") + .read_text() + .split("## Practitioner Workflow", 1)[1] + .split("## Estimators", 1)[0] + ) + assert "For estimators supporting cluster inference" in readme + assert "covariates where supported" in readme + assert "For DurationDiD, use hazard assumptions" in readme diff --git a/tests/test_duration_did.py b/tests/test_duration_did.py new file mode 100644 index 000000000..0f7a1bbf0 --- /dev/null +++ b/tests/test_duration_did.py @@ -0,0 +1,244 @@ +"""Duration panel, calibration, bootstrap and estimator API contracts.""" + +import numpy as np +import pandas as pd +import pytest + +from diff_diff import DurationDiD + + +def duration_panel( + control=(96, 80, 64, 48, 32, 16), treated=(90, 75, 60, 45, 20, 8), n=100, scale=4 +): + """Independent fixture: nested survivor sets define complete absorbing histories.""" + rows = [] + for g, counts in enumerate((control, treated)): + for i in range(n * scale): + rows.extend( + (g * n * scale + i, t, g, int(i >= count * scale)) for t, count in enumerate(counts) + ) + return pd.DataFrame(rows, columns=["id", "date", "group", "absorbed"]) + + +def fit_duration(data=None, **kwargs): + return DurationDiD(**kwargs).fit( + duration_panel() if data is None else data, + "absorbed", + "group", + "id", + "date", + post_periods=[4, 5], + ) + + +@pytest.mark.parametrize("method", ["common_dynamics", "proportional_hazards"]) +def test_happy_path(method, ci_params): + result = fit_duration(method=method, n_bootstrap=ci_params.bootstrap(99), seed=12) + assert result.estimation_status == "ok" + assert result.att == pytest.approx((0.30 - 0.20 + 0.15 - 0.08) / 2) + assert result.n_treated == result.n_control == 400 + assert result.n_units == 800 and result.n_obs == 4800 + assert result.n_bootstrap_valid == result.n_bootstrap + assert result.inference_status == dict( + pointwise="available", simultaneous="available", simple="available" + ) + assert result.pretrend_results.status == "available" + assert result.pretrend_results.anchor_period == 3 + assert list(result.pretrend_results.contrasts.period) == [1, 2] + + +@pytest.mark.parametrize("bad", [True, -1, 0, 1, 1.5, None]) +def test_bootstrap_validation(bad): + with pytest.raises(ValueError, match="n_bootstrap"): + DurationDiD(n_bootstrap=bad) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"method": "other"}, + {"alpha": 0}, + {"alpha": np.nan}, + {"alpha": True}, + {"seed": True}, + {"seed": -1}, + {"seed": 1.5}, + ], +) +def test_constructor_transactions(kwargs): + est = DurationDiD(n_bootstrap=5, seed=2) + old = est.get_params() + with pytest.raises(ValueError): + est.set_params(**kwargs) + assert old == est.get_params() + + +def test_refits_and_shuffle_reproducibility(): + data = duration_panel() + estimator = DurationDiD(n_bootstrap=15, seed=23) + r = estimator.fit(data, "absorbed", "group", "id", "date", post_periods=[5, 4]) + assert estimator.is_fitted_ and estimator.results_ is r + again = estimator.fit(data, "absorbed", "group", "id", "date", post_periods=[4, 5]) + np.testing.assert_array_equal(r.bootstrap_effects, again.bootstrap_effects) + shuffled = estimator.fit( + data.sample(frac=1, random_state=1), "absorbed", "group", "id", "date", post_periods=[4, 5] + ) + assert r.att == pytest.approx(shuffled.att) + assert r.coefficient == pytest.approx(shuffled.coefficient) + assert estimator.results_ is shuffled + + +def test_calibration_selection_zero_weights_and_diagnostic_window(): + data = duration_panel() + kwargs = dict( + data=data, + outcome="absorbed", + treatment="group", + unit="id", + time="date", + post_periods=[4, 5], + ) + a = DurationDiD(n_bootstrap=10, seed=1).fit( + **kwargs, fit_periods=[3, 2, 1], time_weights={1: 0, 2: 2, 3: 2} + ) + b = DurationDiD(n_bootstrap=10, seed=1).fit(**kwargs, fit_periods=[2, 3]) + assert a.fit_periods == [2, 3] + assert a.time_weights == {2: 0.5, 3: 0.5} + assert a.excluded_fit_periods == {1: "zero calibration weight"} + np.testing.assert_array_equal(a.bootstrap_effects, b.bootstrap_effects) + np.testing.assert_array_equal( + a.pretrend_results.bootstrap_contrasts, b.pretrend_results.bootstrap_contrasts + ) + assert a.pre_periods == [0, 1, 2, 3] + + +@pytest.mark.parametrize( + "options", + [ + {"post_periods": []}, + {"post_periods": [3, 5]}, + {"post_periods": [4, 4, 5]}, + {"post_periods": [10]}, + {"post_periods": [1, 2, 3, 4, 5]}, + {"fit_periods": [0]}, + {"fit_periods": [4]}, + {"fit_periods": [1, 1]}, + {"fit_periods": []}, + {"time_weights": {1: 1}}, + {"time_weights": {1: 0, 2: 0, 3: 0}}, + {"time_weights": {1: -1, 2: 1, 3: 1}}, + {"time_weights": {1: np.inf, 2: 1, 3: 1}}, + ], +) +def test_period_validation(options): + kwargs = dict(post_periods=[4, 5]) + kwargs.update(options) + with pytest.raises(ValueError): + DurationDiD(n_bootstrap=2).fit( + duration_panel(), "absorbed", "group", "id", "date", **kwargs + ) + + +@pytest.mark.parametrize( + "mutation", + [ + "duplicate", + "missing_cell", + "missing_value", + "nonbinary", + "reversal", + "group_change", + "one_group", + ], +) +def test_invalid_panels(mutation): + data = duration_panel() + if mutation == "duplicate": + data = pd.concat([data, data.iloc[[0]]]) + elif mutation == "missing_cell": + data = data.iloc[1:] + elif mutation == "missing_value": + data.loc[0, "absorbed"] = np.nan + elif mutation == "nonbinary": + data.loc[0, "absorbed"] = 2 + elif mutation == "reversal": + data.loc[0, "absorbed"] = 1 + elif mutation == "group_change": + data.loc[0, "group"] = 1 + else: + data = data[data.group == 0] + with pytest.raises(ValueError): + fit_duration(data, n_bootstrap=2) + + +@pytest.mark.parametrize( + "labels", + [ + np.arange(6) * 0.1, + np.arange(6) * 7 + 1920, + pd.date_range("2020-01-01", periods=6, freq="2D"), + pd.timedelta_range("0 days", periods=6, freq="3h"), + ], +) +def test_clock_labels(labels): + data = duration_panel() + data["date"] = data.date.map(dict(enumerate(labels))) + r = DurationDiD(method="proportional_hazards", n_bootstrap=5, seed=1).fit( + data, "absorbed", "group", "id", "date", post_periods=list(labels[4:]) + ) + assert r.coefficient == pytest.approx(1) + assert r.att == pytest.approx(0.085) + assert r.periods == list(labels) + + +@pytest.mark.parametrize( + "labels", [[0, 1, 2, 3, 4, 6], list("abcdef"), [0, 0.1, 0.2, 0.3, 0.4, 0.51]] +) +def test_irregular_or_unsupported_clock(labels): + data = duration_panel() + data["date"] = data.date.map(dict(enumerate(labels))) + with pytest.raises(ValueError): + DurationDiD(n_bootstrap=2).fit( + data, "absorbed", "group", "id", "date", post_periods=labels[4:] + ) + + +@pytest.mark.parametrize( + "keyword", ["survey_design", "weights", "cluster", "covariates", "first_treat"] +) +def test_unsupported_extensions(keyword): + with pytest.raises(TypeError): + DurationDiD(n_bootstrap=2).fit( + duration_panel(), + "absorbed", + "group", + "id", + "date", + post_periods=[4, 5], + **{keyword: None}, + ) + + +def test_extreme_weights_preserve_support_or_raise(): + data = duration_panel() + est = DurationDiD(n_bootstrap=3, seed=1) + r = est.fit( + data, + "absorbed", + "group", + "id", + "date", + post_periods=[4, 5], + time_weights={1: 1e308, 2: 1e308, 3: 1e308}, + ) + assert r.time_weights == {1: 1 / 3, 2: 1 / 3, 3: 1 / 3} + with pytest.raises(ValueError, match="underflows"): + est.fit( + data, + "absorbed", + "group", + "id", + "date", + post_periods=[4, 5], + time_weights={1: 1e-300, 2: 1e300, 3: 1e300}, + ) diff --git a/tests/test_duration_did_reporting.py b/tests/test_duration_did_reporting.py new file mode 100644 index 000000000..0e4b7a030 --- /dev/null +++ b/tests/test_duration_did_reporting.py @@ -0,0 +1,270 @@ +"""Native hazard reporting without incompatible generic diagnostics.""" + +import json +from copy import deepcopy + +import pandas as pd +import pytest + +from diff_diff import ( + BusinessReport, + DiagnosticReport, + DifferenceInDifferences, + DurationDiD, + practitioner_next_steps, +) +from diff_diff._reporting_helpers import describe_target_parameter +from tests.test_duration_did import duration_panel, fit_duration + + +@pytest.mark.parametrize("method", ["common_dynamics", "proportional_hazards"]) +def test_native_payload_and_assumptions(method): + r = fit_duration(method=method, n_bootstrap=19, alpha=0.025, seed=7) + schema = DiagnosticReport(r).to_dict() + native = schema["estimator_native_diagnostics"] + assert native["status"] == "ran" + assert native["estimator"] == "DurationDiD" + assert native["pretrend_test"] == r.pretrend_test().to_dict() + assert native["inference_status"] == r.inference_status + br = BusinessReport(r) + body = br.to_dict() + assert body["robustness"]["estimator_native"]["pretrend_test"] == native["pretrend_test"] + for text in (br.summary(), br.full_report()): + assert "hazard pretest" in text.lower() + assert "97.5%" in text + assert "does not establish identification" in text + assert "large-cluster" not in text + assert method in body["assumption"]["description"] + assert "parallel trends" not in body["assumption"]["description"].lower() + assert not any(x["role"] == "sensitivity" for x in body["references"]) + guidance = practitioner_next_steps(r, verbose=False) + encoded = json.dumps(guidance) + assert "hazard" in encoded and "pretrend_test()" in encoded + assert "Which PT variant" not in encoded and "compute_honest_did" not in encoded + assert describe_target_parameter(r)["aggregation"] == "uniform_post_periods" + + +@pytest.mark.parametrize("method", ["common_dynamics", "proportional_hazards"]) +@pytest.mark.parametrize("state", ["rejecting", "nonrejecting", "unavailable"]) +def test_diagnostic_report_renders_stored_hazard_views(method, state): + if state == "rejecting": + data = duration_panel(control=(100, 80, 60, 40, 20, 10), treated=(100, 50, 40, 25, 10, 2)) + elif state == "unavailable": + data = duration_panel(control=(3, 2, 1, 1, 1, 1), treated=(3, 2, 1, 1, 1, 1), n=3, scale=1) + else: + data = duration_panel() + r = fit_duration(data, method=method, n_bootstrap=39, seed=19, alpha=0.025) + pretest = r.pretrend_test() + stored = r.to_dict() + dr = DiagnosticReport(r, alpha=0.1) + returned = dr.run_all() + expected_decision = {"rejecting": True, "nonrejecting": False, "unavailable": None}[state] + assert pretest.reject is expected_decision + expected_phrase = { + "rejecting": "hazard pretest rejects", + "nonrejecting": "hazard pretest does not reject", + "unavailable": "Hazard pretest unavailable", + }[state] + # Test the live report, returned container, and the full report's native section. + native_section = ( + dr.full_report() + .split("## Estimator-native diagnostics", 1)[1] + .split("## Placebo battery", 1)[0] + ) + for text in (dr.summary(), returned.summary(), dr.full_report(), native_section): + assert expected_phrase in text + assert "97.5% simultaneous confidence level" in text + if expected_decision is None: + assert "No rejection decision is available" in text + assert "does not reject" not in text + assert all(reason in text for reason in pretest.reasons) + else: + assert f"p={pretest.p_value:.4g}" in text + assert "Non-rejection does not establish identification or adequate power" in text + pd.testing.assert_frame_equal(dr.to_dataframe(), returned.to_dataframe()) + for table in (dr.to_dataframe(), returned.to_dataframe()): + row = table.set_index("check").loc["estimator_native"] + assert row["status"] == "ran" # Extraction status is distinct from test availability. + if expected_decision is None: + assert pd.isna(row["headline"]) + assert all(reason in row["reason"] for reason in pretest.reasons) + else: + assert row["headline"] == pretest.p_value + assert pd.isna(row["reason"]) + assert r.to_dict() == stored # Rendering never refits or alters stored inference. + assert returned.schema["estimator_native_diagnostics"]["pretrend_test"] == pretest.to_dict() + + +def test_diagnostic_report_invalid_counterfactual_guidance(): + data = duration_panel(control=(100, 70, 40, 20, 20, 20), treated=(100, 99, 98, 97, 96, 95)) + r = fit_duration(data, n_bootstrap=15, seed=2) + assert r.estimation_status == "invalid_counterfactual" + dr = DiagnosticReport(r) + for text in (dr.summary(), dr.run_all().summary(), dr.full_report()): + assert "counterfactual survival path is invalid" in text + assert "canonical causal effects are unavailable" in text + assert all(term in text for term in ("survival_curve", "raw extrapolations", "fit_periods")) + assert "rank deficiency" not in text + assert "survey-design collapse" not in text + + +@pytest.mark.parametrize("key", ["parallel_trends", "sensitivity", "pretrends_power", "bacon"]) +@pytest.mark.parametrize("automatic", [True, False]) +def test_reject_overrides(key, automatic): + r = fit_duration(n_bootstrap=2, seed=1) + with pytest.raises(ValueError, match="precomputed"): + DiagnosticReport(r, precomputed={key: {}}) + with pytest.raises(ValueError, match="precomputed"): + BusinessReport(r, precomputed={key: {}}, auto_diagnostics=automatic) + with pytest.raises(ValueError, match="honest_did_results") as error: + BusinessReport(r, honest_did_results={}, auto_diagnostics=automatic) + assert all(word in str(error.value) for word in ("pretrend_test()", "method", "fit_periods")) + assert "Synthetic" not in str(error.value) + + +@pytest.mark.parametrize("detached", [False, True]) +@pytest.mark.parametrize("automatic", [False, True]) +def test_supplied_foreign_diagnostics_rejected(detached, automatic): + data = duration_panel() + data["post"] = (data["date"] >= 4).astype(int) + foreign = DifferenceInDifferences().fit(data, "absorbed", "group", "post") + dr = DiagnosticReport(foreign) + supplied = dr.run_all() if detached else dr + r = fit_duration(n_bootstrap=2, seed=1) + with pytest.raises(ValueError, match="requires a DurationDiDResults report"): + BusinessReport(r, diagnostics=supplied, auto_diagnostics=automatic).to_dict() + + +@pytest.mark.parametrize("detached", [False, True]) +@pytest.mark.parametrize("available", [False, True]) +def test_supplied_same_fit_diagnostics_accepted(detached, available): + data = ( + None + if available + else duration_panel(control=(3, 2, 1, 1, 1, 1), treated=(3, 2, 1, 1, 1, 1), n=3, scale=1) + ) + r = fit_duration(data, n_bootstrap=13, seed=2, alpha=0.025) + dr = DiagnosticReport(r, alpha=0.1) + if detached: + supplied = dr.run_all() + # A detached container may carry a strict-JSON round-tripped schema. + supplied.schema.clear() + supplied.schema.update(json.loads(json.dumps(DiagnosticReport(r).to_dict()))) + else: + supplied = dr + body = BusinessReport(r, diagnostics=supplied, auto_diagnostics=False).to_dict() + assert body["robustness"]["estimator_native"]["pretrend_test"] == r.pretrend_test().to_dict() + + +@pytest.mark.parametrize("detached", [False, True]) +def test_supplied_different_duration_fit_rejected(detached): + r = fit_duration(n_bootstrap=13, seed=2) + dr = DiagnosticReport(fit_duration(n_bootstrap=13, seed=3)) + supplied = dr.run_all() if detached else dr + with pytest.raises(ValueError, match="must match this fit's stored native hazard diagnostic"): + BusinessReport(r, diagnostics=supplied).to_dict() + + +def test_validated_native_payload_is_owned_by_business_report(): + r = fit_duration(n_bootstrap=13, seed=2) + supplied = DiagnosticReport(r).run_all() + br = BusinessReport(r, diagnostics=supplied) + original = deepcopy(br.to_dict()["robustness"]["estimator_native"]) + supplied.schema["estimator_native_diagnostics"]["pretrend_test"]["reject"] = True + assert br.to_dict()["robustness"]["estimator_native"] == original + + +@pytest.mark.parametrize("detached", [False, True]) +@pytest.mark.parametrize("section", ["parallel_trends", "sensitivity", "pretrends_power", "bacon"]) +def test_supplied_generic_sections_rejected_even_with_duration_provenance(detached, section): + r = fit_duration(n_bootstrap=13, seed=2) + dr = DiagnosticReport(r) + report = dr.run_all() + report.schema[section] = {"status": "ran", "p_value": 0.99} + with pytest.raises(ValueError, match=f"computed generic {section}"): + BusinessReport(r, diagnostics=report if detached else dr).to_dict() + + +@pytest.mark.parametrize("detached", [False, True]) +@pytest.mark.parametrize("change", ["missing", "estimator", "method", "reject", "alpha", "counts"]) +def test_supplied_native_payload_mismatch_rejected(detached, change): + r = fit_duration(n_bootstrap=13, seed=2) + dr = DiagnosticReport(r) + report = dr.run_all() + native = report.schema["estimator_native_diagnostics"] + if change == "missing": + report.schema.pop("estimator_native_diagnostics") + elif change in {"estimator", "method"}: + native[change] = "foreign" + elif change == "counts": + native["n_bootstrap_valid"] = 0 + else: + native["pretrend_test"][change] = True if change == "reject" else 0.1 + with pytest.raises(ValueError, match="must match this fit's stored native hazard diagnostic"): + BusinessReport(r, diagnostics=report if detached else dr).to_dict() + + +@pytest.mark.parametrize("automatic", [False, True]) +def test_long_horizon_summary_bounds_support_prose_and_preserves_details(automatic): + lengths = [] + for n_dates in (6, 60): + counts = (3, 2) + (1,) * (n_dates - 2) + data = duration_panel(control=counts, treated=counts, n=3, scale=1) + r = DurationDiD(n_bootstrap=15, seed=2).fit( + data, "absorbed", "group", "id", "date", post_periods=list(range(4, n_dates)) + ) + br = BusinessReport(r, auto_diagnostics=automatic) + schema = deepcopy(br.to_dict()) + summary, full = br.summary(), br.full_report() + assert f"{len(r.support_warnings)} survivor/exit support warnings" in summary + assert "Pooled individual bootstrap" in summary + assert "Hazard pretest unavailable" in summary + assert summary.count("Caveat:") <= 2 + assert "See full_report()" in summary + assert all(warning in full for warning in r.support_warnings) + assert [ + c["message"] for c in schema["caveats"] if c["topic"] == "duration_support" + ] == r.support_warnings + assert br.to_dict() == schema # Compact rendering never truncates the structured data. + lengths.append(len(summary)) + assert lengths[1] < lengths[0] + 150 + + +@pytest.mark.parametrize("automatic", [True, False]) +def test_few_treated_and_failed_families_without_auto_diagnostics(automatic): + data = duration_panel(control=(3, 2, 1, 1, 1, 1), treated=(3, 2, 1, 1, 1, 1), n=3, scale=1) + r = fit_duration(data, n_bootstrap=15, seed=2) + br = BusinessReport(r, auto_diagnostics=automatic) + for text in (br.summary(), br.full_report()): + assert "3 treated individuals" in text + assert "Pooled individual bootstrap" in text + assert "Hazard pretest unavailable" in text + assert "large-cluster asymptotics" not in text + assert "Consider SyntheticDiD" not in text + assert "exact-permutation" not in text + + +def test_fit_level_confidence_preserved(): + r = fit_duration(n_bootstrap=13, alpha=0.025, seed=8) + h = BusinessReport(r, alpha=0.1).to_dict()["headline"] + assert h["ci_lower"] == r.conf_int[0] + assert h["ci_level"] == 97.5 + assert not h["alpha_was_honored"] + + +def test_invalid_curve_visible_without_auto_diagnostics(): + data = duration_panel(control=(100, 70, 40, 20, 20, 20), treated=(100, 99, 98, 97, 96, 95)) + r = fit_duration(data, n_bootstrap=10, seed=1) + report = BusinessReport(r, auto_diagnostics=False) + assert "Invalid counterfactual" in report.summary() + assert "Invalid counterfactual" in report.full_report() + + +def test_rejected_hazard_pretest_is_rendered_without_identification_claim(): + data = duration_panel(control=(100, 80, 60, 40, 20, 10), treated=(100, 50, 40, 25, 10, 2)) + r = fit_duration(data, n_bootstrap=39, seed=19) + assert r.pretrend_results.status == "available" + assert r.pretrend_results.reject is True + for text in (BusinessReport(r).summary(), BusinessReport(r).full_report()): + assert "hazard pretest rejects" in text + assert "does not establish identification" in text diff --git a/tests/test_duration_did_results.py b/tests/test_duration_did_results.py new file mode 100644 index 000000000..f5b7a12d5 --- /dev/null +++ b/tests/test_duration_did_results.py @@ -0,0 +1,140 @@ +"""Owned Duration results, serialization and v4 aggregation contracts.""" + +import json +from dataclasses import fields, replace + +import numpy as np +import pandas as pd +import pytest + +from diff_diff import ( + BaseResults, + Diagnostic, + EventStudyResults, + compute_honest_did, + compute_pretrends_power, + plot_event_study, +) +from diff_diff.aggregation import AGGREGATION_SCHEMA +from diff_diff.duration_did_results import ( + _CONTRAST_COLUMNS, + _EFFECT_COLUMNS, + _FAILURE_COLUMNS, + _SURVIVAL_COLUMNS, +) +from diff_diff.results_base import EVENT_STUDY_SCHEMA +from tests.test_duration_did import duration_panel, fit_duration + + +@pytest.fixture +def result(): + return fit_duration(n_bootstrap=13, seed=15, alpha=0.025) + + +def test_schemas_and_reference(result): + assert list(result.effects) == _EFFECT_COLUMNS + assert list(result.survival_curve) == _SURVIVAL_COLUMNS + assert list(result.bootstrap_failures) == _FAILURE_COLUMNS + assert list(result.pretrend_results.contrasts) == _CONTRAST_COLUMNS + assert tuple(result.to_dataframe()) == EVENT_STUDY_SCHEMA + assert tuple(result.to_dataframe("simple")) == AGGREGATION_SCHEMA + es = result.aggregate("event_study") + assert isinstance(es, EventStudyResults) + assert es.source == "DurationDiDResults" + np.testing.assert_array_equal(es.event_time, [-1, 0, 1]) + assert es.att[0] == 0 and es.is_reference.tolist() == [True, False, False] + assert np.isnan(es.se[0]) and np.isnan(es.n[0]) + np.testing.assert_array_equal(es.vcov_index, [0, 1]) + np.testing.assert_array_equal(es.vcov, result.vcov) + assert es.n_kind == "units" + assert np.isnan(es.df).all() + simple = result.aggregate("simple") + assert simple.att[0] == result.att + assert simple.conf_int_lower[0] == result.conf_int[0] + assert simple.weight[0] == 1 + assert isinstance(result, BaseResults) and not isinstance(result, Diagnostic) + assert isinstance(result.pretrend_test(), Diagnostic) + assert not any(f.name.startswith("_AGGREGATE") for f in fields(result)) + + +def test_copy_ownership(result): + clone = replace(result) + clone.effects.loc[0, "att"] = 100 + clone.bootstrap_effects[0, 0] = 100 + clone.time_weights[1] = 100 + clone.pretrend_results.contrasts.loc[0, "contrast"] = 100 + assert result.effects.att.iloc[0] != 100 + assert result.bootstrap_effects[0, 0] != 100 + assert result.time_weights[1] != 100 + assert result.pretrend_results.contrasts.contrast.iloc[0] != 100 + diagnostic_copy = result.pretrend_test() + diagnostic_copy.contrasts.loc[0, "contrast"] = 100 + survival_copy = result.to_dataframe("survival") + survival_copy.loc[0, "treated_survival"] = 100 + result.aggregate("event_study").vcov[0, 0] = 100 + assert result.vcov[0, 0] != 100 + assert result.survival_curve.treated_survival.iloc[0] == 0.9 + + +def test_serialization_confidence_and_metadata(result): + payload = result.to_dict() + json.dumps(payload, allow_nan=False) + assert payload["att"] == result.att + assert payload["conf_int_lower"] == result.conf_int[0] + assert payload["pretrend_results"]["alpha"] == 0.025 + assert payload["inference_method"] == "bootstrap" + assert payload["raw_att"] == pytest.approx(result.att) + for r in (result, result.pretrend_test()): + assert "97.5%" in r.summary() + with pytest.raises(ValueError, match="never recomputes"): + r.summary(alpha=0.05) + assert "normalized observation interval" in result.summary() + ph = fit_duration(method="proportional_hazards", n_bootstrap=13, seed=15) + assert "dimensionless hazard ratio" in ph.summary() + + +@pytest.mark.parametrize( + "clock", [pd.date_range("2020", periods=6), pd.timedelta_range("0 days", periods=6)] +) +def test_temporal_json_labels(clock): + from diff_diff import DurationDiD + + data = duration_panel() + data["date"] = data.date.map(dict(enumerate(clock))) + r = DurationDiD(n_bootstrap=2, seed=1).fit( + data, "absorbed", "group", "id", "date", post_periods=list(clock[4:]) + ) + decoded = json.loads(json.dumps(r.to_dict(), allow_nan=False)) + assert decoded["periods"][0] == clock[0].isoformat() + assert decoded["time_weights"][0]["period"] == clock[1].isoformat() + assert isinstance(decoded["time_step"], str) + + +@pytest.mark.parametrize("level", ["group", "calendar", "total", "nonsense"]) +def test_rejected_aggregation(result, level): + with pytest.raises(ValueError): + result.aggregate(level) + with pytest.raises(ValueError): + result.to_dataframe(level) + + +def test_no_custom_aggregation_or_generic_sensitivity(result): + with pytest.raises(ValueError): + result.aggregate("simple", weights="equal") + with pytest.raises(ValueError): + result.aggregate("event_study", balance_e=1) + for consumer in (compute_honest_did, compute_pretrends_power): + for value in (result, result.aggregate("event_study")): + with pytest.raises((TypeError, ValueError, NotImplementedError)): + consumer(value) + + +def test_plotting(result): + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + ax = plot_event_study(result.aggregate("event_study"), alpha=result.alpha, show=False) + assert ax is not None + plt.close("all") diff --git a/tests/test_guides.py b/tests/test_guides.py index ef39c2920..91a577772 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -1279,3 +1279,69 @@ def test_lwdid_inference_contracts_pinned(self): f"{surface_name} lost 'parallel trends' from its short " f"identifying-assumptions qualifier." ) + + +class TestDurationDiDGuides: + """Protect the actionable estimator-specific workflow and selection contracts.""" + + def test_concise_catalog_diagnostic_and_workflow(self): + text = get_llm_guide() + estimators = text.split("## Estimators", 1)[1].split("## ", 1)[0] + diagnostics = text.split("## Diagnostics and Sensitivity Analysis", 1)[1].split("## ", 1)[0] + workflow = text.split("## Practitioner Workflow", 1)[1].split("## ", 1)[0] + assert "DurationDiD" in estimators and "Deaner" in estimators + assert "DurationDiD hazard pretest" in diagnostics and "reject=None" in diagnostics + assert "pooled individual bootstrap" in workflow + assert "covariates where supported" in workflow + assert "For estimators supporting cluster inference" in workflow + + def test_full_contracts_and_selection(self): + text = get_llm_guide("full") + for heading in ( + "### DurationDiD\n", + "### DurationDiDResults\n", + "### DurationDiDPretestResults\n", + ): + assert heading in text + section = text.split("### DurationDiD\n", 1)[1].split("### DurationDiDResults", 1)[0] + import inspect + + from diff_diff import DurationDiD + + for name in inspect.signature(DurationDiD.fit).parameters: + if name != "self": + assert name in section + assert "dimensionless" in section and "normalized observation interval" in section + table = text.split("## Choosing an Estimator", 1)[1].split("## ", 1)[0] + row = next(line for line in table.splitlines() if "| `DurationDiD` |" in line) + assert "Absorbing" in row and "balanced individual" in row and "common timing" in row + for heading in ("## BusinessReport", "## DiagnosticReport"): + section = text.split(heading, 1)[1].split("## ", 1)[0] + assert "DurationDiD" in section and "pretrend_test.status" in section + + def test_autonomous_treatment_outcome_distinction_and_scope(self): + text = get_llm_guide("autonomous") + row = next(line for line in text.splitlines() if line.startswith("| `DurationDiD` |")) + assert row.count("✗") == 7 and "partial" in row + section = text.split("### §4.11", 1)[1].split("## §5.", 1)[0] + for term in ( + "absorbing", + "balanced", + "common", + "hazard", + "fixed treatment group membership", + "post_periods", + ): + assert term in section + native = text.split("### Estimator-native diagnostics", 1)[1].split("### ", 1)[0] + assert "DurationDiDResults.pretrend_test()" in native and "reject=None" in native + + def test_practitioner_step5_and_robustness_exceptions(self): + text = get_llm_guide("practitioner") + step5 = text.split("## Step 5: Estimate", 1)[1].split("## Step 6:", 1)[0] + assert "DurationDiD instead uses pooled individual bootstrap only" in step5 + assert "For estimators supporting cluster inference" in step5 + step6 = text.split("## Step 6:", 1)[1].split("## Step 7:", 1)[0] + assert "DurationDiD" in step6 and "stored fixed-anchor" in step6 + step8 = text.split("## Step 8:", 1)[1].split("\n## ", 1)[0] + assert "DurationDiD has no covariate option" in step8 diff --git a/tests/test_methodology_duration_did.py b/tests/test_methodology_duration_did.py new file mode 100644 index 000000000..69aa0e862 --- /dev/null +++ b/tests/test_methodology_duration_did.py @@ -0,0 +1,208 @@ +"""Independent Deaner--Ku equations / Algorithms 1--2 verification.""" + +import numpy as np +import pytest + +from diff_diff import DurationDiD +from diff_diff.duration_did import _inference +from tests.conftest import assert_nan_inference +from tests.test_duration_did import duration_panel, fit_duration + + +def test_eq_216_ph_ratio_direction_and_mean_of_ratios(): + data = duration_panel(control=(100, 80, 60, 40), treated=(100, 64, 36, 10), scale=1) + r = DurationDiD(method="proportional_hazards", n_bootstrap=10, seed=4).fit( + data, "absorbed", "group", "id", "date", post_periods=[3] + ) + assert r.coefficient == pytest.approx(2) + assert r.att == pytest.approx(0.4**2 - 0.1) + # Noisy non-proportional pre-increments distinguish mean ratios from slopes. + data = duration_panel(control=(100, 80, 60, 40), treated=(100, 70, 40, 10), scale=1) + r = DurationDiD(method="proportional_hazards", n_bootstrap=10, seed=4).fit( + data, "absorbed", "group", "id", "date", post_periods=[3] + ) + d1, d0 = -np.log([0.7, 0.4]), -np.log([0.8, 0.6]) + assert r.coefficient == pytest.approx(np.mean(d1 / d0)) + assert abs(r.coefficient - np.dot(d0, d1) / np.dot(d0, d0)) > 0.01 + + +def test_eq_32_cd_whole_population_denominators_and_baseline(): + r = fit_duration(n_bootstrap=5, seed=5) + assert r.survival_curve.treated_survival.iloc[0] == 0.9 + assert r.survival_curve.control_survival.iloc[0] == 0.96 + assert r.coefficient == pytest.approx(0, abs=1e-15) + np.testing.assert_allclose(r.effects.att, [0.1, 0.07]) + np.testing.assert_allclose(r.survival_curve.raw_counterfactual_survival.iloc[4:], [0.3, 0.15]) + + +def test_algorithm_1_manual_pooled_history_bootstrap(): + data = duration_panel() + b = 13 + seed = 81 + r = fit_duration(data, n_bootstrap=b, seed=seed) + histories = data.pivot(index="id", columns="date", values="absorbed").to_numpy() + group = data.groupby("id").group.first().to_numpy() + rng = np.random.default_rng(seed) + expected = [] + expected_contrasts = [] + for _ in range(b): + idx = rng.integers(len(histories), size=len(histories)) + sampled, g = histories[idx], group[idx] + s0, s1 = [1 - sampled[g == k].mean(axis=0) for k in (0, 1)] + d0, d1 = -np.log(s0 / s0[0]), -np.log(s1 / s1[0]) + gap = (d1[1:4] - d0[1:4]) / np.arange(1, 4) + c = gap.mean() + expected.append(np.exp(np.log(s1[0]) - d0[4:] - np.arange(4, 6) * c) - s1[4:]) + expected_contrasts.append(gap[:2] - gap[2]) + expected = np.array(expected) + np.testing.assert_allclose(r.bootstrap_effects, expected, atol=1e-15) + np.testing.assert_allclose( + r.pretrend_results.bootstrap_contrasts, expected_contrasts, atol=1e-15 + ) + np.testing.assert_allclose(r.vcov, np.cov(expected.T, ddof=1)) + assert r.se**2 == pytest.approx(np.ones(2) @ r.vcov @ np.ones(2) / 4) + sd = expected.std(axis=0, ddof=1) + qindex = int(np.ceil((1 - r.alpha) * b)) - 1 + centered = np.abs(expected - r.effects.att.to_numpy()) + widths = np.sort(centered, axis=0)[qindex] + np.testing.assert_allclose(r.effects.conf_int_upper - r.effects.att, widths) + q = np.sort((centered / sd).max(axis=1))[qindex] + assert r.cband_crit_value == pytest.approx(q) + np.testing.assert_allclose(r.effects.cband_upper - r.effects.att, q * sd) + np.testing.assert_allclose( + r.effects.p_value, (centered >= np.abs(r.effects.att.to_numpy())).mean(axis=0) + ) + + +def test_algorithm_2_fixed_anchor_independent_of_calibration(): + r = fit_duration(n_bootstrap=13, seed=11) + d = r.pretrend_results + sd = d.bootstrap_contrasts.std(axis=0, ddof=1) + v = d.contrasts.contrast.to_numpy() + maxima = np.max(abs(d.bootstrap_contrasts - v) / sd, axis=1) + q = np.sort(maxima)[int(np.ceil((1 - d.alpha) * d.n_bootstrap)) - 1] + np.testing.assert_allclose(d.contrasts.cband_upper, v + q * sd) + assert d.statistic == pytest.approx(max(abs(v / sd))) + assert d.p_value == np.mean(maxima >= d.statistic) + assert d.reject == bool( + np.any(d.contrasts.cband_lower > 0) or np.any(d.contrasts.cband_upper < 0) + ) + + +def test_centered_ties_and_zero_variance_companions(): + theta = np.array([1.0, 2.0, 3.0]) + draws = np.array([[0.0, 2.0, 2.0], [2.0, 2.0, 4.0]]) + r = _inference(theta, draws, 0.5) + assert r["p_value"][0] == 1 # Equality counted conservatively. + assert r["se"][1] == 0 + for key in ("t_stat", "p_value", "conf_int_lower", "conf_int_upper"): + assert np.isnan(r[key][1]) + assert np.isnan(r["cband_crit_value"]) + opposite = _inference(-theta, -draws, 0.5) + np.testing.assert_array_equal(r["p_value"], opposite["p_value"]) + + +def test_treated_post_extinction_valid_and_required_control_extinction_error(): + data = duration_panel(treated=(90, 75, 60, 45, 0, 0)) + r = fit_duration(data, n_bootstrap=10, seed=3) + np.testing.assert_allclose(r.effects.att, [0.3, 0.15]) + assert any("treated survivors at 4: 0" in w for w in r.support_warnings) + data = duration_panel(control=(96, 80, 64, 48, 0, 0)) + with pytest.raises(ValueError, match="control survival"): + fit_duration(data, n_bootstrap=2) + + +def test_ph_eligibility_and_diagnostic_failure_are_independent(): + data = duration_panel(control=(100, 100, 80, 60, 40, 20), treated=(100, 100, 70, 50, 20, 10)) + r = fit_duration(data, method="proportional_hazards", n_bootstrap=12, seed=3) + assert r.fit_periods == [2, 3] and 1 in r.excluded_fit_periods + assert np.isfinite(r.att) + assert r.pretrend_results.status == "unavailable" + assert r.pretrend_results.reject is None + assert r.pretrend_results.n_bootstrap_attempted == 0 + with pytest.raises(ValueError, match="calibration"): + DurationDiD(method="proportional_hazards", n_bootstrap=2).fit( + data, "absorbed", "group", "id", "date", post_periods=[4, 5], fit_periods=[1, 2] + ) + z = DurationDiD(method="proportional_hazards", n_bootstrap=2, seed=1).fit( + data, + "absorbed", + "group", + "id", + "date", + post_periods=[4, 5], + fit_periods=[1, 2], + time_weights={1: 0, 2: 1}, + ) + assert z.fit_periods == [2] + + +def test_two_pre_dates_unavailable_diagnostic(): + data = duration_panel(control=(100, 80, 60), treated=(100, 80, 40)) + r = DurationDiD(n_bootstrap=10, seed=3).fit( + data, "absorbed", "group", "id", "date", post_periods=[2] + ) + assert r.pretrend_results.contrasts.empty + assert r.pretrend_results.bootstrap_contrasts.shape == (10, 0) + assert r.pretrend_results.reject is None + assert r.att == pytest.approx(0.2) + + +def test_invalid_counterfactual_preserves_raw_and_suppresses_causal_output(): + data = duration_panel(control=(100, 70, 40, 20, 20, 20), treated=(100, 99, 98, 97, 96, 95)) + r = fit_duration(data, n_bootstrap=10, seed=5) + assert r.estimation_status == "invalid_counterfactual" + assert np.isnan(r.att) and r.effects.att.isna().all() + assert r.vcov is None + assert r.survival_curve.raw_att.iloc[4:].notna().all() + assert (r.survival_curve.counterfactual_status == "invalid").any() + + +def test_failed_draws_do_not_filter_or_retry(): + data = duration_panel(control=(3, 2, 1, 1, 1, 1), treated=(3, 2, 1, 1, 1, 1), n=3, scale=1) + r = fit_duration(data, n_bootstrap=100, seed=2) + assert 0 < r.n_bootstrap_valid < 100 + assert r.bootstrap_effects.shape == (100, 2) + assert np.isfinite(r.att) and np.isnan(r.se) + assert r.vcov is None + assert r.inference_status["simple"] == "unavailable" + mask = np.isnan(r.bootstrap_effects).all(axis=1) + assert mask.sum() == 100 - r.n_bootstrap_valid + assert r.bootstrap_failures.query("family == 'effects'").draw.nunique() == mask.sum() + assert r.pretrend_results.status == "unavailable" + + +def test_diagnostic_bootstrap_failure_preserves_valid_effect_family(): + data = duration_panel( + control=(100, 99, 70, 50, 30, 10), treated=(100, 99, 70, 50, 20, 5), scale=1 + ) + r = DurationDiD(method="proportional_hazards", n_bootstrap=40, seed=33).fit( + data, "absorbed", "group", "id", "date", post_periods=[4, 5], fit_periods=[2, 3] + ) + assert r.fit_periods == [2, 3] + assert r.n_bootstrap_valid == 40 + assert r.inference_status["simple"] == "available" + assert r.pretrend_results.n_bootstrap_valid < 40 + assert r.pretrend_results.status == "unavailable" + assert r.pretrend_results.reject is None + assert set(r.bootstrap_failures.family) == {"diagnostics"} + + +def test_complete_survival_zero_se_and_unidentified_ph(): + data = duration_panel(control=(100,) * 6, treated=(100,) * 6) + r = fit_duration(data, n_bootstrap=9, seed=4) + assert r.att == 0 and r.se == 0 + assert_nan_inference( + {name: getattr(r, name) for name in ("se", "t_stat", "p_value", "conf_int")} + ) + assert r.n_bootstrap_valid == 9 + assert r.pretrend_results.reject is None + with pytest.raises(ValueError, match="calibration"): + fit_duration(data, method="proportional_hazards", n_bootstrap=2) + + +def test_support_warning_thresholds(): + data = duration_panel(control=(6, 5, 4, 3, 2, 1), treated=(6, 5, 4, 3, 2, 1), n=6, scale=1) + r = fit_duration(data, n_bootstrap=2, seed=1) + assert not any("at 0:" in text or "at 1:" in text for text in r.support_warnings) + assert any("at 2: 4" in text for text in r.support_warnings) diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index 82444be4e..ad32c8df5 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -479,6 +479,7 @@ def _build_rowed_index(): # 2(c)/3(a) rename PRs work from - these are the LEGAL `time`s; the two # 0/1-post overloads are rowed as M-030/M-082). _RULE1_TIME_SURFACES = ( + "DurationDiD.fit[time]", # Observation clock, not a renamed treatment selector. "BaconDecomposition.fit[time]", "BusinessReport.__init__[time]", "CallawaySantAnna.diagnose_propensity[time]", @@ -995,6 +996,10 @@ def _token_family_code_refs(tok): ("estimator", "diff_diff/diagnostic_report.py"): ( 'report-schema "estimator" keys holding type(results).__name__' ), + ("estimator", "diff_diff/_reporting_helpers.py"): ( + 'native report-schema "estimator" key holding a class tag, ' + "not a read of ChangesInChangesResults' renamed method field" + ), ("estimator", "diff_diff/had.py"): ( "prose/schema use of the word, not a read of CiC's results field" ), diff --git a/tests/test_results_serialization.py b/tests/test_results_serialization.py index 993b9b240..7e3c0af53 100644 --- a/tests/test_results_serialization.py +++ b/tests/test_results_serialization.py @@ -29,6 +29,7 @@ # Every public estimator results class (must be BaseResults, never Diagnostic). ESTIMATOR_RESULTS = [ + "DurationDiDResults", "DiDResults", "SpilloverDiDResults", "MultiPeriodDiDResults", diff --git a/tests/test_v4_inference_policy.py b/tests/test_v4_inference_policy.py index 82117f71b..bb6f1820b 100644 --- a/tests/test_v4_inference_policy.py +++ b/tests/test_v4_inference_policy.py @@ -41,6 +41,7 @@ ContinuousDiD, DifferenceInDifferences, DMLDiD, + DurationDiD, EfficientDiD, HeterogeneousAdoptionDiD, ImputationDiD, @@ -458,6 +459,7 @@ def test_inference_exposed_by_exactly_the_wcr_roster(self): # =========================================================================== FLOOR_CLASSES = [ + (DurationDiD, {}), (TROP, {}), (SyntheticDiD, {}), (SyntheticDiD, {"variance_method": "jackknife"}), @@ -486,6 +488,8 @@ def test_set_params_rejects_and_rolls_back(self, cls, extra): def test_floors_and_carveouts_intact(self): # Floors keep their own messages after the type guard. + with pytest.raises(ValueError, match="n_bootstrap must be >= 2 for DurationDiD"): + DurationDiD(n_bootstrap=1) with pytest.raises(ValueError, match="n_bootstrap must be >= 2 for TROP"): TROP(n_bootstrap=1) with pytest.raises(ValueError, match=r"n_bootstrap must be >= 2 \(got 1\)"): From 5fb2266aee71bdeb4c91a0a474dfa4d44edc96e4 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 6 Sep 2026 20:30:17 -0400 Subject: [PATCH 2/3] Add DurationDiD for absorbing individual outcomes --- diff_diff/duration_did_results.py | 9 +++++---- diff_diff/guides/llms-full.txt | 2 ++ docs/api/duration_did.rst | 4 ++++ tests/test_duration_did_results.py | 13 +++++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/diff_diff/duration_did_results.py b/diff_diff/duration_did_results.py index e66f77f2d..2de9ec5d8 100644 --- a/diff_diff/duration_did_results.py +++ b/diff_diff/duration_did_results.py @@ -254,13 +254,16 @@ def to_dataframe(self, level: str = "event_study") -> pd.DataFrame: raise ValueError("level must be 'event_study', 'simple', 'survival', or 'diagnostics'") def summary(self, alpha: Optional[float] = None) -> str: - """Summarize stored whole-population absorption ATT and availability.""" + """Summarize stored absorption ATT and availability, printing shared reasons once.""" _require_fit_alpha(alpha, self.alpha, message=_SUMMARY_ALPHA_MESSAGE) units = ( "hazard gap per normalized observation interval" if self.method == "common_dynamics" else "dimensionless hazard ratio" ) + reasons = dict.fromkeys( + reason for values in self.inference_reasons.values() for reason in values + ) return ( f"DurationDiD ({self.method})\n" f"Coefficient ({units}): {self.coefficient:.6g}\n" @@ -268,9 +271,7 @@ def summary(self, alpha: Optional[float] = None) -> str: f"{_coverage_pct(self.alpha)}% CI: {self.conf_int}; p-value: {self.p_value:.6g}\n" f"Estimation: {self.estimation_status}; inference: {self.inference_status}\n" f"Pooled individual bootstrap: {self.n_bootstrap_valid}/{self.n_bootstrap} valid effect draws" - + "".join( - "\n" + reason for values in self.inference_reasons.values() for reason in values - ) + + "".join("\n" + reason for reason in reasons) + "".join("\n" + warning for warning in self.support_warnings) + "\n" + self.pretrend_results.summary() diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 21e3a3d4d..b40815673 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -115,6 +115,8 @@ Owns `effects`, `survival_curve`, `bootstrap_failures`, B-by-post `bootstrap_eff `inference_reasons` have pointwise/simultaneous/simple keys; available/unavailable, plus partial for pointwise. `support_warnings` and `pretrend_results` remain inspectable. Properties: `inference_method='bootstrap'`, uniform raw post `raw_att`. +`summary()` prints distinct inference-unavailability reasons once in first appearance +order; the full per-family `inference_reasons` mapping remains in the result and `to_dict()`. Native table column order (chronological RangeIndex): - effects (post only): period, event_time, att, se, t_stat, p_value, conf_int_lower, diff --git a/docs/api/duration_did.rst b/docs/api/duration_did.rst index 88d6bc803..ac67a77ca 100644 --- a/docs/api/duration_did.rst +++ b/docs/api/duration_did.rst @@ -135,6 +135,10 @@ mappings as period/value records. Native tables have chronological RangeIndex. ``support_warnings`` and stored ``pretrend_results`` remain inspectable. - Properties: ``inference_method='bootstrap'`` and uniform raw post mean ``raw_att``. +``summary()`` prints each distinct inference-unavailability reason once, in first +appearance order. The full per-family ``inference_reasons`` mapping is preserved +on the result and in ``to_dict()``. + Native schemas, in column order: - ``effects`` (post only): period, event_time, att, se, t_stat, p_value, diff --git a/tests/test_duration_did_results.py b/tests/test_duration_did_results.py index f5b7a12d5..773075a7c 100644 --- a/tests/test_duration_did_results.py +++ b/tests/test_duration_did_results.py @@ -93,6 +93,19 @@ def test_serialization_confidence_and_metadata(result): assert "dimensionless hazard ratio" in ph.summary() +def test_summary_reports_shared_inference_failure_once(): + data = duration_panel(control=(3, 2, 1, 1, 1, 1), treated=(3, 2, 1, 1, 1, 1), n=3, scale=1) + r = fit_duration(data, n_bootstrap=15, seed=2) + stored = r.to_dict() + reason = "one or more effect bootstrap draws failed" + assert all(reason in r.inference_reasons[family] for family in r.inference_status) + text = r.summary() + assert text.splitlines().count(reason) == 1 + assert all(warning in text for warning in r.support_warnings) + assert r.pretrend_results.summary() in text + assert r.to_dict() == stored + + @pytest.mark.parametrize( "clock", [pd.date_range("2020", periods=6), pd.timedelta_range("0 days", periods=6)] ) From d9eacb55f96006c1b53dabb5e1b8e4a6b4073e59 Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 7 Sep 2026 08:11:26 -0400 Subject: [PATCH 3/3] Add DurationDiD for absorbing individual outcomes --- changelog.d/20260906-duration-did.md | 2 + diff_diff/duration_did.py | 46 ++++++++++------ diff_diff/guides/llms-full.txt | 4 ++ docs/api/duration_did.rst | 5 ++ tests/test_duration_did.py | 80 ++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 17 deletions(-) diff --git a/changelog.d/20260906-duration-did.md b/changelog.d/20260906-duration-did.md index 7ea65de49..fa1043b9b 100644 --- a/changelog.d/20260906-duration-did.md +++ b/changelog.d/20260906-duration-did.md @@ -5,3 +5,5 @@ a stored fixed-anchor hazard pretest, owned results and event-study aggregation, native reporting, and an executed tutorial. Unsupported domains and failed bootstrap families retain explicit availability metadata; no survey, covariate or cluster extensions. + Constructor parameters are revalidated on every fit, including after direct attribute + updates; invalid configurations raise without replacing a previous fitted result. diff --git a/diff_diff/duration_did.py b/diff_diff/duration_did.py index 38650d805..f64e268c0 100644 --- a/diff_diff/duration_did.py +++ b/diff_diff/duration_did.py @@ -277,30 +277,34 @@ def __init__( alpha: float = 0.05, seed: Optional[int] = None, ): - if method not in ("common_dynamics", "proportional_hazards"): + self.method = method + self.n_bootstrap = n_bootstrap + self.alpha = alpha + self.seed = seed + self._validate_params() + self.results_: Optional[DurationDiDResults] = None + self.is_fitted_ = False + + def _validate_params(self) -> None: + """Apply the constructor contract again before fitting mutable parameters.""" + if self.method not in ("common_dynamics", "proportional_hazards"): raise ValueError("method must be 'common_dynamics' or 'proportional_hazards'") - validate_n_bootstrap(n_bootstrap) - if n_bootstrap < 2: + validate_n_bootstrap(self.n_bootstrap) + if self.n_bootstrap < 2: raise ValueError("n_bootstrap must be >= 2 for DurationDiD") if ( - isinstance(alpha, (bool, np.bool_)) - or not isinstance(alpha, (int, float, np.integer, np.floating)) - or not np.isfinite(alpha) - or not 0 < alpha < 1 + isinstance(self.alpha, (bool, np.bool_)) + or not isinstance(self.alpha, (int, float, np.integer, np.floating)) + or not np.isfinite(self.alpha) + or not 0 < self.alpha < 1 ): raise ValueError("alpha must be finite and between 0 and 1") - if seed is not None and ( - isinstance(seed, (bool, np.bool_)) - or not isinstance(seed, (int, np.integer)) - or seed < 0 + if self.seed is not None and ( + isinstance(self.seed, (bool, np.bool_)) + or not isinstance(self.seed, (int, np.integer)) + or self.seed < 0 ): raise ValueError("seed must be a nonnegative integer or None") - self.method = method - self.n_bootstrap = n_bootstrap - self.alpha = alpha - self.seed = seed - self.results_: Optional[DurationDiDResults] = None - self.is_fitted_ = False def fit( self, @@ -339,7 +343,15 @@ def fit( ------- DurationDiDResults Stored effects, raw survival paths, bootstrap and diagnostic metadata. + + Raises + ------ + ValueError + If constructor parameters or the panel/calibration inputs are invalid. + Parameters are revalidated on every call, including direct attribute + updates. Rejected parameters leave any previous fitted result intact. """ + self._validate_params() y, group, periods, step = _panel(data, outcome, treatment, unit, time) post = _selectors(post_periods, periods, "post_periods") if not post: diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index b40815673..801118b56 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -66,6 +66,10 @@ est.fit(data, outcome, treatment, unit, time, *, post_periods, fit_periods=None, time_weights=None) -> DurationDiDResults ``` +All four constructor parameters are validated on construction, through `set_params`, +and before each fit. Invalid direct attribute updates raise `ValueError` before panel +processing and preserve any previous fitted result; valid updates apply on the next fit. + `treatment` is fixed binary group membership; `post_periods` is an explicit nonempty observed suffix. Equal-spaced numeric/Timestamp/Timedelta clock. The preceding date is last untreated; never infer timing from absorption. diff --git a/docs/api/duration_did.rst b/docs/api/duration_did.rst index ac67a77ca..ebf4d6115 100644 --- a/docs/api/duration_did.rst +++ b/docs/api/duration_did.rst @@ -7,6 +7,11 @@ The outcome is 0 before absorption and 1 afterward. Treatment is a fixed group indicator; ``post_periods`` separately declares the treated observation suffix. Keep baseline-absorbed people in the panel. Positive ATT increases absorption. +Constructor parameters are validated on construction, through ``set_params()``, +and at the start of every ``fit()`` call. Invalid direct attribute updates raise +``ValueError`` before panel processing and leave any previous fitted result intact. +Valid parameter updates take effect on the next fit. + Identification and supported designs ------------------------------------ diff --git a/tests/test_duration_did.py b/tests/test_duration_did.py index 0f7a1bbf0..a0572b55a 100644 --- a/tests/test_duration_did.py +++ b/tests/test_duration_did.py @@ -73,6 +73,86 @@ def test_constructor_transactions(kwargs): assert old == est.get_params() +@pytest.mark.parametrize("previously_fitted", [False, True]) +@pytest.mark.parametrize( + "name,bad", + [ + ("method", "typo"), + ("method", None), + ("n_bootstrap", True), + ("n_bootstrap", np.bool_(True)), + ("n_bootstrap", -1), + ("n_bootstrap", 0), + ("n_bootstrap", 1), + ("n_bootstrap", 2.5), + ("n_bootstrap", None), + ("alpha", 0), + ("alpha", 1), + ("alpha", 5), + ("alpha", True), + ("alpha", np.bool_(True)), + ("alpha", np.nan), + ("alpha", np.inf), + ("alpha", "0.05"), + ("alpha", None), + ("seed", True), + ("seed", np.bool_(True)), + ("seed", -1), + ("seed", 1.5), + ("seed", "23"), + ], +) +def test_invalid_direct_parameters_match_constructor_validation(name, bad, previously_fitted): + data = duration_panel() + args = (data, "absorbed", "group", "id", "date") + est = DurationDiD(n_bootstrap=5, seed=2) + previous = est.fit(*args, post_periods=[4, 5]) if previously_fitted else None + snapshot = previous.to_dict() if previous is not None else None + original_params = est.get_params() + + with pytest.raises(ValueError, match=name) as constructor_error: + DurationDiD(**{**original_params, name: bad}) + with pytest.raises(ValueError, match=name) as set_params_error: + est.set_params(**{name: bad}) + assert est.get_params() == original_params + + setattr(est, name, bad) + with pytest.raises(ValueError, match=name) as fit_error: + est.fit(*args, post_periods=[4, 5]) + assert str(fit_error.value) == str(constructor_error.value) == str(set_params_error.value) + assert est.is_fitted_ is previously_fitted + assert est.results_ is previous + if previous is not None: + assert previous.to_dict() == snapshot + + +@pytest.mark.parametrize("previously_fitted", [False, True]) +@pytest.mark.parametrize("method", ["common_dynamics", "proportional_hazards"]) +def test_valid_direct_parameter_updates_match_fresh_fit(method, previously_fitted): + data = duration_panel() + args = (data, "absorbed", "group", "id", "date") + other_method = "proportional_hazards" if method == "common_dynamics" else "common_dynamics" + est = DurationDiD(method=other_method, n_bootstrap=5, seed=2) + previous = est.fit(*args, post_periods=[4, 5]) if previously_fitted else None + snapshot = previous.to_dict() if previous is not None else None + updated = dict( + method=method, n_bootstrap=np.int64(19), alpha=np.float64(0.1), seed=np.int64(23) + ) + for name, value in updated.items(): + setattr(est, name, value) + + result = est.fit(*args, post_periods=[4, 5]) + expected = DurationDiD(**updated).fit(*args, post_periods=[4, 5]) + assert result.to_dict() == expected.to_dict() + assert result.method == method + assert result.coefficient == pytest.approx(0 if method == "common_dynamics" else 1) + assert result.alpha == 0.1 and result.seed == 23 + assert result.n_bootstrap == 19 and result.bootstrap_effects.shape == (19, 2) + assert est.is_fitted_ and est.results_ is result + if previous is not None: + assert previous.to_dict() == snapshot + + def test_refits_and_shuffle_reproducibility(): data = duration_panel() estimator = DurationDiD(n_bootstrap=15, seed=23)