From d2e9d802e2e84709a4b34eb92fea2f19ffa59537 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 6 Sep 2026 23:20:38 -0400 Subject: [PATCH 1/6] feat(duration-did): add DurationDiD estimator (Deaner & Ku 2026 causal duration DiD) --- README.md | 3 +- ROADMAP.md | 6 - TODO.md | 3 + changelog.d/20260906-duration-did.md | 28 + diff_diff/__init__.py | 6 + diff_diff/business_report.py | 30 +- diff_diff/diagnostic_report.py | 16 + diff_diff/duration_did.py | 1239 +++++++++++++++++ diff_diff/duration_did_results.py | 592 ++++++++ diff_diff/guides/llms-autonomous.txt | 10 + diff_diff/guides/llms-full.txt | 30 +- diff_diff/guides/llms-practitioner.txt | 10 + diff_diff/guides/llms.txt | 3 +- diff_diff/practitioner.py | 178 +++ .../_autosummary/diff_diff.DurationDiD.rst | 30 + ..._did_results.DurationDiDPretestResults.rst | 41 + ...uration_did_results.DurationDiDResults.rst | 77 + docs/api/business_report.rst | 5 +- docs/api/diagnostic_report.rst | 5 +- docs/api/duration_did.rst | 216 +++ docs/api/index.rst | 4 + docs/choosing_estimator.rst | 68 + docs/dev-status.md | 1 + docs/doc-deps.yaml | 59 + docs/index.rst | 2 + docs/methodology/REGISTRY.md | 252 ++++ docs/methodology/REPORTING.md | 6 +- .../papers/deaner-ku-2026-review.md | 45 +- docs/methodology/variance-conventions.md | 11 +- docs/practitioner_decision_tree.rst | 12 +- docs/references.rst | 7 + docs/survey-roadmap.md | 1 + tests/helpers/results_foundation.py | 19 + tests/test_base_estimator.py | 1 + tests/test_diagnostic_marker.py | 1 + tests/test_doc_snippets.py | 8 +- tests/test_duration_did.py | 991 +++++++++++++ tests/test_guides.py | 54 + tests/test_methodology_duration_did.py | 340 +++++ tests/test_naming_guard.py | 1 + tests/test_results_serialization.py | 1 + tests/test_v4_inference_policy.py | 4 + tests/test_variance_conventions.py | 28 + 43 files changed, 4402 insertions(+), 42 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.duration_did_results.DurationDiDPretestResults.rst create mode 100644 docs/api/_autosummary/diff_diff.duration_did_results.DurationDiDResults.rst create mode 100644 docs/api/duration_did.rst create mode 100644 tests/test_duration_did.py create mode 100644 tests/test_methodology_duration_did.py diff --git a/README.md b/README.md index ed4667db3..bd7496f84 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Measuring campaign lift? Evaluating a product launch? Rolling out a policy in wa - [Brand awareness survey tutorial](https://diff-diff.readthedocs.io/en/stable/tutorials/17_brand_awareness_survey.html) - full example with complex survey design, brand funnel analysis, and staggered rollouts - Have BRFSS/ACS/CPS individual records? Use [`aggregate_survey()`](https://diff-diff.readthedocs.io/en/stable/api/prep.html) to roll respondent-level microdata into a geographic-period panel with inverse-variance precision weights for second-stage DiD -`BusinessReport` and `DiagnosticReport` are experimental preview classes that produce plain-English output and a structured `to_dict()` schema from any fitted result - wording and schema will evolve. See [docs/methodology/REPORTING.md](https://github.com/igerber/diff-diff/blob/main/docs/methodology/REPORTING.md) for usage and stability notes. +`BusinessReport` and `DiagnosticReport` are experimental preview classes that produce plain-English output and a structured `to_dict()` schema from any fitted estimator result except the two rejected by type (`EventStudyResults` surfaces and `DurationDiDResults`) - wording and schema will evolve. See [docs/methodology/REPORTING.md](https://github.com/igerber/diff-diff/blob/main/docs/methodology/REPORTING.md) for usage and stability notes. ## Practitioner Workflow (Baker et al. 2025) @@ -122,6 +122,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`. - [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator via `method="qdid"`; bootstrap inference; R qte parity. Alias `CiC` - [LWDiD](https://diff-diff.readthedocs.io/en/stable/api/lwdid.html) - Lee & Wooldridge (2025, 2026) rolling-transformation DiD: unit-specific demean/detrend converts panel to cross-section, staggered adoption, `estimation_method` in `reg`/`ipw`/`dr`/`psm` (the papers' RA/IPW/IPWRA plus propensity-score matching), exact small-N inference on the classical collapsed regression - [DMLDiD](https://diff-diff.readthedocs.io/en/stable/api/dml_did.html) - Chang (2020) double/debiased machine learning DiD: staggered ATT(g,t) with cross-fitted ML nuisance learners (DML2) and Neyman-orthogonal scores, for flexible/high-dimensional covariate adjustment under conditional parallel trends; panel or declared repeated cross sections (`panel=False`); survey/cluster support on both lanes (bad-control lane: panel only, `cluster=` only); Caetano, Callaway, Payne & Sant'Anna (2026) bad-control score via `fit(bad_control=, bad_control_covariates=)` +- [DurationDiD](https://diff-diff.readthedocs.io/en/stable/api/duration_did.html) - Deaner & Ku (2026) causal duration DiD for a binary absorbing outcome (spell ended) in a two-group common-timing design: restricts the groups' untreated hazards (`method="cd"` additive gap or `method="ph"` ratio) instead of outcome levels, imputes the treated counterfactual survival, reports the per-date absorption ATT with whole-individual bootstrap pointwise and simultaneous bands plus a fixed-anchor pre-treatment specification test - [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings ## Diagnostics & Sensitivity diff --git a/ROADMAP.md b/ROADMAP.md index efd956d3b..bfc8f6639 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -81,12 +81,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/TODO.md b/TODO.md index ac97adcaf..03d7dc176 100644 --- a/TODO.md +++ b/TODO.md @@ -63,6 +63,8 @@ Related tracking surfaces: | Remark-4-weighted overall ATT for the bad-control lane (`sum_g sum_{t>=g} [P(G=g given G in G_bar)/(T-g+1)] ATT(g,t)`: cohort mass among the ever-treated divided by the cohort's post-period count); the headline `att` is CS "simple" (REGISTRY DMLDiD Note). | `staggered_aggregation.py` | bad-controls PR-B | Mid | Low | | Allow empty `Z` on the bad-control lane (the paper's `Z` may be empty; DMLDiD's covariates-required gate currently applies). | `dml_did.py` | bad-controls PR-B | Quick | Low | | Recover the Supplementary Appendix DGPs 2 / 3 / 5 (nonlinear `W` / `X` terms) on the bad-control ML lane - needs a flexible-learner fixture via the duck-typed learner route (the parametric built-ins are inconsistent there by design, Table S1). | `tests/test_methodology_dml_did.py` | bad-controls PR-B | Mid | Low | +| DurationDiD alternative finite-sample PH estimators (the repaired cumulative-increment slope of Equation 3.5 and the average-hazard slope of Equation 3.6) as an opt-in beside the shipped mean-of-ratios coefficient; they coincide under exact PH and differ under sampling noise (review table, lines 306-311) | `duration_did.py` | #822 | Mid | Low | +| `DiagnosticReport` / `BusinessReport` admission of `DurationDiDResults`: both reject it by type today (their batteries are keyed to mean-outcome parallel-trends diagnostics); admission needs a hazard-restriction check family (the Algorithm 2 pretest, the invalid-curve statuses, per-family bootstrap failure counts) and a narrative for per-date absorption ATTs | `diagnostic_report.py`, `business_report.py` | #822 | Mid | Low | ### Performance @@ -102,3 +104,4 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m | `trop-silent-drop` criteria regression: the rewritten `criteria.md` dropped a silent-failure catch the OLD criteria had (arm A caught it, B/C missed — campaign-1 A-vs-B contrast). Patch the criteria + re-validate (re-opens the criteria identity, so gated behind a re-validation run). | `.claude/skills/plan-review/criteria.md`, `tools/plan-review-eval/` | campaign-1 verdict | Mid | Medium | | The Stata `jwdid` parity arms cover BALANCED panels only. `jwdid_alltreated` pins the all-eventually-treated cell set, `N` and SE ratio on the `mpdta` subset (191 units, 955 rows, 764 estimated), but every arm is a balanced frame, so REGISTRY's parity claim is scoped to that case. The comparison-support predicate's UNBALANCED behavior -- where `G_max` may be unobserved at later periods so the closed form `t < G_max - anticipation` does not hold and support is set by whichever cohorts are actually observed -- is verified against the predicate directly, not against Stata. Add an unbalanced `jwdid` arm to close that gap. | `benchmarks/stata/generate_etwfe_cs_golden.do`, `tests/test_etwfe_cs_stata_parity.py` | #729-followup | Mid | Low | | Bad-controls tutorial section for `docs/tutorials/32_dml_did.ipynb` (PR-C): the `fit(bad_control=)` workflow, the `ATT_X(g,t)` pre-test reading, and the Approach-1 comparison via CS base-period covariates. | `docs/tutorials/32_dml_did.ipynb` | bad-controls PR-B | Quick | Medium | +| DurationDiD tutorial notebook (Appendix C-style simulation showing the sign reversal of standard DiD on an absorbing outcome, CD vs PH, the fitting window, and the pretest), registered in `docs/tutorials/index.rst` with a drift test; deferred from the estimator PR, which relies on the executed API-page examples | `docs/tutorials/` | #822 | Mid | Low | diff --git a/changelog.d/20260906-duration-did.md b/changelog.d/20260906-duration-did.md new file mode 100644 index 000000000..930d18cd9 --- /dev/null +++ b/changelog.d/20260906-duration-did.md @@ -0,0 +1,28 @@ +### Added +- **DurationDiD estimator** (Deaner & Ku 2026, *Causal Duration Analysis with + Diff-in-Diff*, arXiv:2405.05220v2): two-group, common-timing difference-in- + differences for a binary absorbing outcome. `method="cd"` (constant additive + gap between the groups' untreated hazards) or `method="ph"` (constant hazard + ratio, mean-of-ratios estimator) is fitted on the pre-treatment cumulative + hazards — by default with equal weights over every eligible pre-treatment + date, or on a user window via `fit(pre_periods=..., pre_period_weights=...)` + — and the treated group's counterfactual survival is imputed from the + control group (Theorem 1). Reports the absorption ATT at every + post-treatment date plus its uniform average as `att`, with the paper's + whole-individual pooled bootstrap (Appendix B Algorithm 1: centered + absolute-deviation pointwise intervals and a simultaneous max-|t| band) and + the Algorithm 2 fixed-anchor pre-treatment specification test + (`results.pretest`, a `DurationDiDPretestResults` diagnostic). Every + inference family is either fully available or fully withheld with a named + `inference_status` (invalid imputed counterfactual curve, failed bootstrap + draws, zero SE); failed draws are never retried or silently dropped. + `results.aggregate("event_study")` returns the unified `EventStudyResults` + container (event time 0 = first post-treatment date, reference -1). + Covariates, staggered adoption, censoring, survey and cluster inference are + deferred. `DiagnosticReport` and `BusinessReport` reject `DurationDiDResults` + by type (their batteries are keyed to mean-outcome parallel-trends + diagnostics; admission is tracked in `TODO.md`); `practitioner_next_steps` + gains a DurationDiD handler with a hazard-restriction assumptions step. + A tutorial notebook is deferred (tracked in `TODO.md`), a documented + deviation from the new-estimator documentation checklist; the executed + examples on the API page are the hands-on reference. diff --git a/diff_diff/__init__.py b/diff_diff/__init__.py index 868ec0b63..1995b33ac 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, @@ -471,6 +473,10 @@ def __getattr__(name: str) -> _Any: "DMLDiD", "DMLDiDResults", "SieveLearner", + # DurationDiD (Deaner & Ku 2026 causal duration DiD) + "DurationDiD", + "DurationDiDResults", + "DurationDiDPretestResults", # Visualization "plot_bacon", "plot_event_study", diff --git a/diff_diff/business_report.py b/diff_diff/business_report.py index 845ea4285..1c29a5657 100644 --- a/diff_diff/business_report.py +++ b/diff_diff/business_report.py @@ -1,7 +1,10 @@ """ -BusinessReport — plain-English stakeholder narrative from any diff-diff result. +BusinessReport — plain-English stakeholder narrative from a diff-diff result. -Wraps any of the 16 fitted result types and produces: +Wraps the fitted result types it dispatches on (every scalar estimator +result except the two rejected by type: ``EventStudyResults`` surfaces and +``DurationDiDResults`` — see the constructor's rejection branches) and +produces: - ``summary()``: a short paragraph block suitable for an email or Slack message. - ``full_report()``: a multi-section markdown report with headline, assumptions, @@ -92,7 +95,11 @@ class BusinessContext: class BusinessReport: - """Produce a stakeholder-ready narrative from any diff-diff results object. + """Produce a stakeholder-ready narrative from a diff-diff results object. + + Accepts every scalar estimator result except the two rejected by type: + ``EventStudyResults`` surfaces and ``DurationDiDResults`` (see the + constructor's rejection branches). Parameters ---------- @@ -203,6 +210,23 @@ def __init__( "with HonestDiD, PreTrendsPower, or plot_event_study. " "EventStudyResults admission is tracked in TODO.md." ) + # DurationDiDResults (Deaner & Ku 2026) is rejected BY TYPE: the + # narrative's parallel-trends framing does not describe a + # duration-DiD fit (untreated-hazard restriction on an absorbing + # outcome) and its headline is a uniform average of per-date + # absorption ATTs with a separate hazard pretest. Admission is + # tracked in TODO.md. + from diff_diff.duration_did_results import DurationDiDResults as _DDR + + if isinstance(results, _DDR): + raise TypeError( + "BusinessReport does not support DurationDiDResults: the " + "narrative is keyed to mean-outcome parallel-trends estimators, " + "while DurationDiD identifies off an untreated-hazard " + "restriction on an absorbing outcome. Use results.summary(), " + "results.pretest, and results.aggregate('event_study') instead; " + "DurationDiD admission is tracked in TODO.md." + ) # Marked diagnostic results are rejected BY TYPE (spec section # 3.5, ledger row M-091): BusinessReport's primary input is a # fitted ESTIMATOR result carrying the canonical inference row. diff --git a/diff_diff/diagnostic_report.py b/diff_diff/diagnostic_report.py index 2be30b326..5866ebb75 100644 --- a/diff_diff/diagnostic_report.py +++ b/diff_diff/diagnostic_report.py @@ -475,6 +475,22 @@ def __init__( "the producing estimator). EventStudyResults admission is " "tracked in TODO.md." ) + # DurationDiDResults (Deaner & Ku 2026) is rejected BY TYPE: the + # battery is keyed to mean-outcome parallel-trends diagnostics, while + # duration DiD identifies off a restriction on the untreated hazards + # of a binary absorbing outcome, so an empty applicability set would + # again produce a zero-check report. Admission is tracked in TODO.md. + from diff_diff.duration_did_results import DurationDiDResults as _DDR + + if isinstance(results, _DDR): + raise TypeError( + "DiagnosticReport does not support DurationDiDResults: its " + "checks are keyed to mean-outcome parallel-trends diagnostics, " + "while DurationDiD identifies off an untreated-hazard " + "restriction on an absorbing outcome. Use results.pretest (the " + "Algorithm 2 hazard-restriction test) and results.summary(); " + "DurationDiD admission is tracked in TODO.md." + ) # Marked diagnostic results (spec section 3.5, ledger row M-091) # are rejected BY TYPE — except Bacon, whose dedicated read-out # is retained. Before the marker, such inputs silently produced diff --git a/diff_diff/duration_did.py b/diff_diff/duration_did.py new file mode 100644 index 000000000..3eca2a759 --- /dev/null +++ b/diff_diff/duration_did.py @@ -0,0 +1,1239 @@ +"""Duration Difference-in-Differences (Deaner & Ku 2026). + +Two-group, common-timing causal duration analysis for a binary ABSORBING +outcome (``Y_it = 1`` once the spell has ended). Under a restriction on the +UNTREATED hazards of the two groups — a constant additive gap (common +dynamics, ``method="cd"``) or a constant ratio (proportional hazards, +``method="ph"``) — the treated group's counterfactual survival is imputed +from the control group's cumulative hazard and the treated baseline, and +the absorption ATT ``E[Y_it - Y_it(0) | treated]`` is reported for every +post-treatment date (Theorem 1; Equations 3.1-3.4 with the mean-of-ratios PH +estimator). Inference is the whole-individual pooled bootstrap of Appendix B +Algorithm 1 (centered absolute-deviation pointwise and simultaneous bands); +the Algorithm 2 fixed-anchor pre-treatment specification test is reported +separately. See ``docs/methodology/REGISTRY.md`` (DurationDiD) and +``docs/methodology/papers/deaner-ku-2026-review.md``. + +Notation (review lines 94-108): ``S_kt`` group survival, ``R_kt = -log S_kt``, +``D_kt = R_kt - R_k1``, ``H_kt = D_kt / e_t`` with the actual elapsed time +``e_t = time_t - time_1``; ``tstar`` is the last untreated date. +""" + +from __future__ import annotations + +import math +import warnings +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from diff_diff._base import BaseEstimator +from diff_diff.bootstrap_chunking import compute_block_size +from diff_diff.duration_did_results import ( + DurationDiDPretestResults, + DurationDiDResults, + invalid_curve_message, +) +from diff_diff.utils import ( + safe_inference, + safe_inference_batch, + validate_binary, + validate_n_bootstrap, +) + +_VALID_METHODS = ("cd", "ph") +#: A used log-survival moment backed by fewer than this many survivors (but at +#: least one) is reported as weak numerical support — a warning only, no +#: behavior changes (review lines 917-918). A count rule: a survival +#: PROPORTION is never below ``1/n_group``, so a proportion threshold would be +#: unreachable at any realistic sample size. +_WEAK_SUPPORT_MIN_SURVIVORS = 5 +#: Relative tolerance of the equal-spacing check on the time grid. +_SPACING_RTOL = 1e-8 +#: Baseline + last pre-treatment date + one post-treatment date. +_MIN_PERIODS = 3 +#: Cap on bootstrap rows per chunk (the count matrix is ``(rows, n)`` float64). +_MAX_CHUNK_ROWS = 256 + + +def _errstate() -> Any: + """Silence every floating-point warning class (CONTRIBUTING: protect all arithmetic).""" + return np.errstate(divide="ignore", invalid="ignore", over="ignore", under="ignore") + + +# Per-family draw-failure reasons, in FIRST-MATCH precedence order. +_POST_FAILURE_ORDER = ( + "group_empty", + "zero_survival_baseline", + "zero_survival_last_pre", + "zero_control_increment", + "control_survival_zero", + "nonfinite_counterfactual", + "nonfinite_effect", +) +_PRETEST_FAILURE_ORDER = ( + "group_empty", + "zero_survival_baseline", + "zero_survival_last_pre", + "zero_control_increment", + "nonfinite_contrast", +) + + +# ============================================================================= +# Constructor validation +# ============================================================================= + + +def _validate_method(method: Any) -> None: + if not isinstance(method, str) or method not in _VALID_METHODS: + raise ValueError(f"method must be 'cd' or 'ph', got {method!r}") + + +def _validate_alpha(alpha: Any) -> None: + if isinstance(alpha, bool) or not isinstance(alpha, (int, float, np.floating)): + raise ValueError(f"alpha must be a float strictly between 0 and 1, got {alpha!r}") + if not (0.0 < float(alpha) < 1.0): + raise ValueError(f"alpha must be a float strictly between 0 and 1, got {alpha!r}") + + +def _validate_seed(seed: Any) -> None: + if seed is None: + return + if isinstance(seed, bool) or not isinstance(seed, (int, np.integer)) or seed < 0: + raise ValueError(f"seed must be None or a non-negative integer, got {seed!r}") + + +def _validate_draws(n_bootstrap: Any) -> None: + validate_n_bootstrap(n_bootstrap) + if int(n_bootstrap) == 1: + raise ValueError( + "n_bootstrap must be 0 (point estimates only, no inference) or at " + "least 2 (a bootstrap SD needs two draws); got 1" + ) + + +# ============================================================================= +# Numerical core (pure numpy; a leading draw axis where noted) +# ============================================================================= + + +def _validate_and_arrange( + data: pd.DataFrame, + outcome: str, + unit: str, + time: str, + treatment: str, + last_pre_period: Any, +) -> Dict[str, Any]: + """Validate the balanced absorbing panel and arrange it as arrays. + + Returns ``Y`` (n x T float 0/1, treated units first), ``n_treated``, + ``grid`` (the sorted common dates in their native numeric dtype), + ``elapsed`` (float, ``grid - grid[0]``) and ``tstar_idx``. + """ + if not isinstance(data, pd.DataFrame): + raise ValueError(f"data must be a pandas DataFrame, got {type(data).__name__}") + for name, col in ( + ("outcome", outcome), + ("unit", unit), + ("time", time), + ("treatment", treatment), + ): + if col not in data.columns: + raise ValueError(f"{name} column {col!r} not found in data") + + # Identifier checks BEFORE any grouping/pivot (no silent groupby drop, no + # phantom NaN unit). + if data[unit].isna().any(): + raise ValueError( + f"unit column {unit!r} contains missing values; every row needs a " + "unit identifier (no silent groupby drop)" + ) + + t_col = data[time] + if pd.api.types.is_datetime64_any_dtype(t_col) or pd.api.types.is_timedelta64_dtype(t_col): + raise ValueError( + f"time column {time!r} must be numeric; convert datetime/timedelta " + "values to a numeric elapsed scale (e.g. days since the spell " + "start) before fitting" + ) + if pd.api.types.is_bool_dtype(t_col) or not pd.api.types.is_numeric_dtype(t_col): + raise ValueError(f"time column {time!r} must be numeric (got dtype {t_col.dtype})") + if t_col.isna().any() or not np.all(np.isfinite(t_col.to_numpy(dtype=float))): + raise ValueError(f"time column {time!r} contains missing or non-finite values") + + grid = np.unique(t_col.to_numpy()) + if len(data) == 0 or len(grid) < _MIN_PERIODS: + raise ValueError( + "DurationDiD requires at least three distinct time periods (baseline, " + f"last_pre_period, and one post-period); found {len(grid)}" + ) + + if data.duplicated(subset=[unit, time]).any(): + raise ValueError( + f"DurationDiD requires exactly one row per (unit, period); found duplicate " + f"({unit!r}, {time!r}) combinations" + ) + counts = data.groupby(unit, sort=True)[time].size() + n_periods = len(grid) + incomplete = counts[counts != n_periods] + if len(incomplete) > 0: + bad = incomplete.index.tolist()[:5] + raise ValueError( + "Unbalanced panel: every individual must be observed at every date of the " + f"common time grid ({n_periods} periods); {len(incomplete)} unit(s) are not " + f"(e.g. {bad}). Late entry, dropout, and missing cells are not supported; " + "an administrative end of a complete window is fine." + ) + + grid_f = grid.astype(float) + diffs = np.diff(grid_f) + step = diffs[0] + if not (np.isfinite(step) and step > 0): + raise ValueError("time grid must have a positive finite common spacing") + if not np.allclose(diffs, step, rtol=_SPACING_RTOL, atol=0.0): + raise ValueError( + "DurationDiD requires an equally spaced time grid (relative tolerance " + f"{_SPACING_RTOL:g}); found spacings {np.unique(diffs).tolist()[:5]}" + ) + + # Binary columns: explicit float coercion, then missing/non-finite, then + # the 0/1 domain (validate_binary strips NaN before its membership test). + coerced: Dict[str, np.ndarray] = {} + for name, col in (("outcome", outcome), ("treatment", treatment)): + if not (pd.api.types.is_numeric_dtype(data[col]) or pd.api.types.is_bool_dtype(data[col])): + bad_vals = pd.unique(data[col].astype(object))[:5].tolist() + raise ValueError( + f"{name} column {col!r} must be a numeric 0/1 column (got dtype " + f"{data[col].dtype}; values such as {bad_vals}); numeric strings are not " + "coerced" + ) + try: + arr = data[col].to_numpy(dtype=float) + except (ValueError, TypeError) as exc: + bad_vals = pd.unique(data[col].astype(object))[:5].tolist() + raise ValueError( + f"{name} column {col!r} must be numeric 0/1; could not convert values " + f"such as {bad_vals} ({exc})" + ) from None + nonfinite = ~np.isfinite(arr) + if nonfinite.any(): + rows = data.loc[nonfinite, [unit, time]].head(5).values.tolist() + raise ValueError( + f"{name} column {col!r} contains {int(nonfinite.sum())} missing or " + f"non-finite value(s); first offending (unit, period) pairs: {rows}" + ) + validate_binary(arr, name) + coerced[name] = arr + + # Internal frame with fixed names, so a user column named like a temporary + # (or a role column named "unit"/"time") can never collide. + frame = pd.DataFrame( + { + "unit": data[unit].to_numpy(), + "time": data[time].to_numpy(), + "y": coerced["outcome"], + "g": coerced["treatment"], + } + ) + + g_nunique = frame.groupby("unit")["g"].nunique() + if (g_nunique > 1).any(): + bad = g_nunique[g_nunique > 1].index.tolist()[:5] + raise ValueError( + f"treatment column {treatment!r} must be a fixed 0/1 group indicator " + f"(constant within unit), not a time-varying received-treatment variable; " + f"units with varying values include {bad}" + ) + + y_wide = frame.pivot(index="unit", columns="time", values="y").reindex(columns=grid) + g_units = frame.groupby("unit")["g"].first().reindex(y_wide.index) + Y = y_wide.to_numpy(dtype=float) + G = g_units.to_numpy(dtype=float) + n_treated = int(np.sum(G == 1.0)) + n_control = int(np.sum(G == 0.0)) + if n_treated == 0 or n_control == 0: + raise ValueError( + "both groups are required: found " + f"{n_treated} treated and {n_control} control individual(s)" + ) + + reversal = np.diff(Y, axis=1) < 0 + if reversal.any(): + bad_units = y_wide.index[reversal.any(axis=1)].tolist()[:5] + raise ValueError( + "outcome must be absorbing (once 1, always 1 within each individual); " + f"found reversals (1 -> 0) for {int(reversal.any(axis=1).sum())} unit(s), " + f"e.g. {bad_units}" + ) + + if isinstance(last_pre_period, (bool, str)) or not isinstance( + last_pre_period, (int, float, np.integer, np.floating) + ): + raise ValueError( + f"last_pre_period must be a numeric value of the time column, got {last_pre_period!r}" + ) + tstar_val = float(last_pre_period) + matches = np.nonzero(grid_f == tstar_val)[0] + if len(matches) != 1: + raise ValueError( + f"last_pre_period {last_pre_period!r} is not a value of the time column " + f"(grid: {grid.tolist()[:8]}{'...' if len(grid) > 8 else ''})" + ) + tstar_idx = int(matches[0]) + if tstar_idx == 0: + raise ValueError( + "last_pre_period equals the first date; at least two pre-treatment dates " + "(the baseline and last_pre_period) are required" + ) + if tstar_idx == n_periods - 1: + raise ValueError( + "last_pre_period equals the last date; at least one post-treatment date is required" + ) + + order = np.argsort(-G, kind="stable") # treated first, stable within group + return { + "Y": np.ascontiguousarray(Y[order]), + "n_treated": n_treated, + "grid": grid, + "elapsed": grid_f - grid_f[0], + "tstar_idx": tstar_idx, + } + + +def _group_survival( + Y: np.ndarray, n_treated: int, W: np.ndarray +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Group survival curves for count-weight rows ``W`` (C x n). + + Returns ``S`` (C x 2 x T; treated row 0, control row 1), plus the + resampled group sizes ``n1``, ``n2`` (C,). Integer-valued sums below + ``2**53`` make the GEMM bit-identical to a per-draw loop. An empty group + yields NaN survival (caught by the draw-failure predicates). + """ + W = np.asarray(W, dtype=float) + W1, W2 = W[:, :n_treated], W[:, n_treated:] + n1, n2 = W1.sum(axis=1), W2.sum(axis=1) + with _errstate(): + S1 = 1.0 - (W1 @ Y[:n_treated]) / n1[:, None] + S2 = 1.0 - (W2 @ Y[n_treated:]) / n2[:, None] + return np.stack([S1, S2], axis=1), n1, n2 + + +def _log_survival_moments( + S: np.ndarray, elapsed: np.ndarray +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """``R = -log S``, ``D = R - R[..., :1]``, ``H = D / elapsed`` (baseline NaN).""" + with _errstate(): + R = -np.log(S) + D = R - R[..., :1] + H = D / elapsed + return R, D, H + + +def _estimate_from_survival( + S: np.ndarray, + R: np.ndarray, + D: np.ndarray, + elapsed: np.ndarray, + fit_idx: np.ndarray, + fit_weights: np.ndarray, + method: str, +) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Fitted coefficient and imputed counterfactual, vectorized over draws. + + Returns ``c`` (C,), ``R0`` (C x T), ``S0`` (C x T) and ``tau = S0 - S1`` + (C x T, every date; the caller slices the post dates). Under + ``method="cd"``: ``c = sum_t alpha_t (H_1t - H_2t)`` and + ``R0 = R_11 + D_2 + e*c`` (Equations 3.3-3.4); under ``method="ph"``: + ``c = sum_t alpha_t D_1t / D_2t`` (mean of ratios, Theorem 1) and + ``R0 = R_11 + c * D_2`` (Equation 2.16, the treated baseline outside the + exponent). + """ + S1 = S[:, 0, :] + R1, D1, D2 = R[:, 0, :], D[:, 0, :], D[:, 1, :] + w = np.asarray(fit_weights, dtype=float) + with _errstate(): + if method == "cd": + e_fit = elapsed[fit_idx] + H1 = D1[:, fit_idx] / e_fit + H2 = D2[:, fit_idx] / e_fit + c = (H1 - H2) @ w + R0 = R1[:, :1] + D2 + elapsed[None, :] * c[:, None] + else: + ratio = D1[:, fit_idx] / D2[:, fit_idx] + c = ratio @ w + R0 = R1[:, :1] + c[:, None] * D2 + S0 = np.exp(-R0) + tau = S0 - S1 + return c, R0, S0, tau + + +#: Relative roundoff tolerance of the curve-validity gate (scaled by the +#: magnitudes actually summed into ``R0``). +_CURVE_GATE_RTOL = 1e-12 + + +def _curve_tolerance( + R: np.ndarray, D: np.ndarray, elapsed: np.ndarray, c: np.ndarray, method: str +) -> np.ndarray: + """Per-date absolute tolerance for the curve-validity gate (leading draw axis). + + ``R0`` is a sum of ``R_11``, ``D_2t`` and ``e_t c`` (CD) or ``c D_2t`` + (PH); at a mathematical boundary (e.g. an exactly-zero imputed cumulative + hazard when the treated group has no pre-treatment exits) the sum can + land a few ulps on either side of the exact value, and which side + depends on the time labelling. The tolerance is ``1e-12`` times the + largest magnitude summed, floored at ``1e-12``. + """ + with _errstate(): + term = elapsed[None, :] * c[:, None] if method == "cd" else c[:, None] * D[:, 1, :] + scale = np.maximum.reduce( + [ + np.ones_like(term), + np.abs(np.broadcast_to(R[:, 0, :1], term.shape)), + np.abs(D[:, 1, :]), + np.abs(term), + ] + ) + return _CURVE_GATE_RTOL * scale + + +def _curve_status( + R0: np.ndarray, S2: np.ndarray, tstar_idx: int, tol: Optional[np.ndarray] = None +) -> List[str]: + """Validity of the imputed curve at every date (first match wins). + + Review lines 920-924: negative or decreasing imputed cumulative hazards + and survival outside [0, 1] are invalid. The gate is on the imputed + curve itself over the WHOLE path (fitted pre-dates included), never on + the observed treated hazard at ``tstar`` (that residual is Algorithm 2's + object). ``tol`` (per date, from :func:`_curve_tolerance`) keeps an + exact mathematical boundary — ``R0 == 0`` or a zero step — from being + flagged on roundoff; ``None`` means exact comparisons. + """ + n_periods = len(R0) + tol_arr = np.zeros(n_periods) if tol is None else np.asarray(tol, dtype=float) + status = ["ok"] * n_periods + for t in range(1, n_periods): + if t > tstar_idx and S2[t] <= 0: + status[t] = "control_survival_zero" + elif not np.isfinite(R0[t]): + status[t] = "counterfactual_nonfinite" + elif R0[t] < -tol_arr[t]: + status[t] = "counterfactual_survival_above_one" + elif np.isfinite(R0[t - 1]) and R0[t] < R0[t - 1] - tol_arr[t]: + status[t] = "counterfactual_nonmonotone" + return status + + +def _resolve_fit_periods( + S: np.ndarray, + D: np.ndarray, + method: str, + grid: np.ndarray, + tstar_idx: int, + pre_periods: Optional[Sequence[Any]], + pre_period_weights: Optional[Sequence[float]], +) -> Tuple[np.ndarray, np.ndarray, Dict[Any, str], List[str]]: + """Validate the fitting selectors and resolve the eligible fitting set. + + Returns the sorted fitting date indices ``F``, their normalized weights, + an exclusion dict ``{period: reason}`` and warning messages. Zero-weight + candidates are excluded first, then eligibility (positive survival in + both groups; under PH a positive control increment); surviving weights + are renormalized to sum to one. ``F`` and the weights are frozen for + every bootstrap draw. + """ + grid_f = grid.astype(float) + if pre_period_weights is not None and pre_periods is None: + raise ValueError("pre_period_weights requires pre_periods (the dates the weights refer to)") + + if pre_periods is None: + cand_idx = np.arange(1, tstar_idx + 1) + requested = False + else: + req = _selector_array(pre_periods, "pre_periods", "time values") + if req.size == 0: + raise ValueError("pre_periods must name at least one pre-treatment date") + idx_list: List[int] = [] + for p in req: + hit = np.nonzero(grid_f == p)[0] + if len(hit) != 1: + raise ValueError(f"Pre-period '{_fmt(p)}' not found in time column") + k = int(hit[0]) + if k < 1 or k > tstar_idx: + raise ValueError( + f"pre_periods value {_fmt(p)} must lie strictly after the baseline " + f"date {_label(grid, 0)!r} and at or before last_pre_period {_label(grid, tstar_idx)!r}" + ) + idx_list.append(k) + if len(set(idx_list)) != len(idx_list): + raise ValueError("pre_periods contains duplicate dates") + cand_idx = np.asarray(idx_list, dtype=int) + requested = True + + if pre_period_weights is None: + cand_w = np.ones(len(cand_idx), dtype=float) + else: + cand_w = _selector_array(pre_period_weights, "pre_period_weights", "nonnegative weights") + if cand_w.shape != (len(cand_idx),): + raise ValueError( + f"pre_period_weights must have one entry per pre_periods date " + f"({len(cand_idx)}), got {cand_w.shape[0]}" + ) + if not np.all(np.isfinite(cand_w)): + raise ValueError("pre_period_weights must be finite") + if np.any(cand_w < 0): + raise ValueError("pre_period_weights must be nonnegative") + if np.all(cand_w == 0): + raise ValueError("pre_period_weights must not all be zero") + + excluded: Dict[Any, str] = {} + keep_idx: List[int] = [] + keep_w: List[float] = [] + S1, S2, D2 = S[0], S[1], D[1] + for k, w in zip(cand_idx.tolist(), cand_w.tolist()): + label = grid[k].item() if hasattr(grid[k], "item") else grid[k] + if w == 0: + excluded[label] = "zero_weight" + elif S1[k] <= 0: + excluded[label] = "zero_treated_survival" + elif S2[k] <= 0: + excluded[label] = "zero_control_survival" + elif method == "ph" and D2[k] <= 0: + excluded[label] = "zero_control_increment" + else: + keep_idx.append(k) + keep_w.append(w) + if not keep_idx: + raise ValueError( + "no eligible fitting period: every candidate pre-treatment date was " + f"excluded ({excluded}). PH needs a positive control cumulative-hazard " + "increment at some pre-date after the baseline; both groups need positive " + "survival at the fitting dates." + ) + order = np.argsort(keep_idx) + fit_idx = np.asarray(keep_idx, dtype=int)[order] + weights = np.asarray(keep_w, dtype=float)[order] + # Scale-invariant normalization: the kept weights are finite and strictly + # positive, so dividing by the maximum first keeps the sum finite even for + # weights near the float64 limit (a bare sum could overflow to inf and + # silently normalize to zeros). + weights = weights / weights.max() + weights = weights / weights.sum() + + messages: List[str] = [] + elig_excluded = {k: v for k, v in excluded.items() if v != "zero_weight"} + if elig_excluded: + who = "requested" if requested else "default" + messages.append( + f"Excluded {len(elig_excluded)} {who} fitting period(s) as ineligible " + f"{elig_excluded}; the remaining fitting weights were renormalized to sum to one." + ) + if tstar_idx not in set(fit_idx.tolist()): + messages.append( + f"The fitting periods omit last_pre_period {_label(grid, tstar_idx)!r}; the " + "pre-treatment diagnostic keeps that date as its fixed anchor regardless." + ) + return fit_idx, weights, excluded, messages + + +def _selector_array(value: Any, name: str, kind: str) -> np.ndarray: + """Coerce a fit-time selector to a 1-d float array with a typed guard. + + Scalars, strings and bytes are rejected explicitly: ``list("34")`` would + otherwise split a numeric string into two different dates and silently + change the fitting set. Sets are rejected because ``pre_periods`` and + ``pre_period_weights`` are paired by position. + """ + if isinstance(value, (str, bytes)) or np.isscalar(value) or not hasattr(value, "__iter__"): + raise ValueError(f"{name} must be a list of {kind} (e.g. [...]), got {value!r}") + if isinstance(value, (set, frozenset)): + # Unordered: pre_periods and pre_period_weights are paired positionally. + raise ValueError( + f"{name} must be an ordered list of {kind} (a set has no positional order " + f"to align with its companion selector), got {value!r}" + ) + try: + arr = np.asarray(list(value), dtype=float).ravel() + except (TypeError, ValueError): + raise ValueError(f"{name} must be a list of numeric {kind}, got {value!r}") from None + return arr + + +def _label(grid: np.ndarray, k: int) -> Any: + """Native Python scalar for a grid date (never a numpy repr in messages).""" + v = grid[k] + return v.item() if hasattr(v, "item") else v + + +def _fmt(p: float) -> Any: + return int(p) if float(p).is_integer() else p + + +def _pretest_contrasts( + D: np.ndarray, H: np.ndarray, J: np.ndarray, tstar_idx: int, method: str +) -> np.ndarray: + """Algorithm 2 fixed-anchor contrasts over ``J`` (leading draw axis).""" + with _errstate(): + if method == "cd": + gap = H[:, 0, J] - H[:, 1, J] + anchor = H[:, 0, tstar_idx] - H[:, 1, tstar_idx] + else: + gap = D[:, 0, J] / D[:, 1, J] + anchor = D[:, 0, tstar_idx] / D[:, 1, tstar_idx] + return gap - anchor[:, None] + + +def _quantile_inverted_cdf(x: np.ndarray, p: float) -> float: + """Inverse empirical CDF quantile: the ``ceil(p*B)``-th order statistic. + + ``p*B`` is evaluated with a ``1e-9`` tie guard (``ceil(p*B - 1e-9)``) so a + product that lands within floating-point noise of an integer (e.g. + ``0.95 * 20 = 19.000000000000004``) selects that integer's order statistic, + matching the documented rule for the alphas users actually pass (the same + guard magnitude as ``utils._frac_gt``). + """ + xs = np.sort(np.asarray(x, dtype=float)) + n = xs.shape[0] + if n == 0: + return float("nan") + k = int(math.ceil(p * n - 1e-9)) + k = min(max(k, 1), n) + return float(xs[k - 1]) + + +def _centered_bootstrap_summary( + point: np.ndarray, draws: np.ndarray, alpha: float +) -> Dict[str, Any]: + """Centered absolute-deviation bootstrap summary on COMPLETE draws. + + ``se`` is derived from the diagonal of the covariance (never a separate + ``np.std``) so ``se == sqrt(diag(vcov))`` holds exactly. Pointwise + and simultaneous critical values are inverse-empirical-CDF quantiles of + ``|draw - point| / se`` and of its per-draw maximum; p-values are the + empirical tail fractions (equality counted). ``|point/se| > crit`` is + exactly ``p <= alpha``. + """ + point = np.asarray(point, dtype=float).ravel() + draws = np.asarray(draws, dtype=float).reshape(draws.shape[0], -1) + with _errstate(): + vcov = np.atleast_2d(np.cov(draws, rowvar=False, ddof=1)) + se = np.sqrt(np.diag(vcov)) + z = np.abs(draws - point[None, :]) / se[None, :] + crit = np.array([_quantile_inverted_cdf(z[:, k], 1.0 - alpha) for k in range(z.shape[1])]) + t_abs = np.abs(point / se) + p = np.mean(z >= t_abs[None, :], axis=0) + m = np.max(z, axis=1) + crit_sim = _quantile_inverted_cdf(m, 1.0 - alpha) + p_joint = float(np.mean(m >= np.max(t_abs))) + return { + "vcov": vcov, + "se": se, + "crit": crit, + "ci_lower": point - crit * se, + "ci_upper": point + crit * se, + "p": p, + "crit_sim": crit_sim, + "band_lower": point - crit_sim * se, + "band_upper": point + crit_sim * se, + "p_joint": p_joint, + "statistic": float(np.max(t_abs)), + } + + +def _draw_indices(rng: np.random.Generator, n: int, size: int) -> np.ndarray: + """Pooled whole-individual resample indices (``size`` x ``n``).""" + return rng.integers(0, n, size=(size, n)) + + +def _first_match(masks: Dict[str, np.ndarray], order: Tuple[str, ...], n: int) -> np.ndarray: + """Per-draw reason string: the first predicate in ``order`` that fires.""" + reason = np.full(n, "", dtype=object) + for name in order: + reason = np.where((reason == "") & masks[name], name, reason) + return reason + + +def _run_bootstrap( + Y: np.ndarray, + n_treated: int, + elapsed: np.ndarray, + tstar_idx: int, + fit_idx: np.ndarray, + fit_weights: np.ndarray, + method: str, + J: np.ndarray, + n_bootstrap: int, + rng: np.random.Generator, +) -> Dict[str, Any]: + """Algorithm 1: ``n_bootstrap`` pooled whole-history resamples. + + Every draw recomputes survival, moments, the coefficient, the + counterfactual, the post effects, the headline and the pretest + contrasts with the frozen fitting set. Failures are recorded per family + (first-match reason); failed draws are NaN rows. + """ + n, n_periods = Y.shape + post_idx = np.arange(tstar_idx + 1, n_periods) + n_post = len(post_idx) + tau_star = np.full((n_bootstrap, n_post), np.nan) + head_star = np.full(n_bootstrap, np.nan) + delta_star = np.full((n_bootstrap, len(J)), np.nan) + ok_post = np.zeros(n_bootstrap, dtype=bool) + ok_pretest = np.zeros(n_bootstrap, dtype=bool) + reason_post = np.full(n_bootstrap, "", dtype=object) + reason_pretest = np.full(n_bootstrap, "", dtype=object) + n_invalid_curve = 0 + + chunk = int(min(compute_block_size(n, n_bootstrap), _MAX_CHUNK_ROWS)) + pretest_dates = np.concatenate([J, [tstar_idx]]).astype(int) + for start in range(0, n_bootstrap, chunk): + size = min(chunk, n_bootstrap - start) + idx = _draw_indices(rng, n, size) + W = np.zeros((size, n), dtype=float) + for r in range(size): + W[r] = np.bincount(idx[r], minlength=n) + S, n1, n2 = _group_survival(Y, n_treated, W) + R, D, H = _log_survival_moments(S, elapsed) + c, R0, S0, tau = _estimate_from_survival(S, R, D, elapsed, fit_idx, fit_weights, method) + tau_post = tau[:, post_idx] + with _errstate(): + head = tau_post.mean(axis=1) + delta = ( + _pretest_contrasts(D, H, J, tstar_idx, method) if len(J) else np.zeros((size, 0)) + ) + + S1, S2 = S[:, 0, :], S[:, 1, :] + group_empty = (n1 == 0) | (n2 == 0) + base_zero = ~(np.nan_to_num(S1[:, 0], nan=0.0) > 0) | ~( + np.nan_to_num(S2[:, 0], nan=0.0) > 0 + ) + tstar_zero = ~(np.nan_to_num(S1[:, tstar_idx], nan=0.0) > 0) | ~( + np.nan_to_num(S2[:, tstar_idx], nan=0.0) > 0 + ) + with _errstate(): + if method == "ph": + zero_inc_post = ~np.all(D[:, 1, fit_idx] > 0, axis=1) + zero_inc_pre = ~np.all(D[:, 1, pretest_dates] > 0, axis=1) + else: + zero_inc_post = np.zeros(size, dtype=bool) + zero_inc_pre = np.zeros(size, dtype=bool) + ctrl_zero = ~np.all(np.nan_to_num(S2[:, post_idx], nan=0.0) > 0, axis=1) + nonfinite_cf = ( + ~np.isfinite(c) + | ~np.all(np.isfinite(R0[:, post_idx]), axis=1) + | ~np.all(np.isfinite(S0[:, post_idx]), axis=1) + ) + nonfinite_eff = ~np.all(np.isfinite(tau_post), axis=1) + if method == "cd": + inputs_ok = np.all(np.isfinite(H[:, :, pretest_dates]), axis=(1, 2)) + else: + inputs_ok = np.all(np.isfinite(D[:, :, pretest_dates]), axis=(1, 2)) + nonfinite_delta = ~(inputs_ok & np.all(np.isfinite(delta), axis=1)) + + r_post = _first_match( + { + "group_empty": group_empty, + "zero_survival_baseline": base_zero, + "zero_survival_last_pre": tstar_zero, + "zero_control_increment": zero_inc_post, + "control_survival_zero": ctrl_zero, + "nonfinite_counterfactual": nonfinite_cf, + "nonfinite_effect": nonfinite_eff, + }, + _POST_FAILURE_ORDER, + size, + ) + r_pre = _first_match( + { + "group_empty": group_empty, + "zero_survival_baseline": base_zero, + "zero_survival_last_pre": tstar_zero, + "zero_control_increment": zero_inc_pre, + "nonfinite_contrast": nonfinite_delta, + }, + _PRETEST_FAILURE_ORDER, + size, + ) + okp = r_post == "" + okq = r_pre == "" + sl = slice(start, start + size) + ok_post[sl] = okp + ok_pretest[sl] = okq + reason_post[sl] = r_post + reason_pretest[sl] = r_pre + tau_star[sl][okp] = tau_post[okp] + head_star[sl][okp] = head[okp] + if len(J): + delta_star[sl][okq] = delta[okq] + # Diagnostic count: complete draws whose imputed curve leaves the + # domain (finite S0 > 1 or a decreasing step) — not failures. + if okp.any(): + R0_ok = R0[okp] + tol_ok = _curve_tolerance(R, D, elapsed, c, method)[okp] + with _errstate(): + bad = (R0_ok < -tol_ok).any(axis=1) | (np.diff(R0_ok, axis=1) < -tol_ok[:, 1:]).any( + axis=1 + ) + n_invalid_curve += int(bad.sum()) + + return { + "tau_star": tau_star, + "head_star": head_star, + "delta_star": delta_star, + "ok_post": ok_post, + "ok_pretest": ok_pretest, + "reason_post": reason_post, + "reason_pretest": reason_pretest, + "n_draws_invalid_counterfactual": n_invalid_curve, + } + + +def _count_reasons(reasons: np.ndarray) -> Dict[str, int]: + out: Dict[str, int] = {} + for r in reasons.tolist(): + if r: + out[r] = out.get(r, 0) + 1 + return out + + +# ============================================================================= +# Estimator +# ============================================================================= + + +class DurationDiD(BaseEstimator): + """Duration difference-in-differences (Deaner & Ku 2026) for absorbing outcomes. + + Two-group, common-timing design: ``treatment`` is a FIXED 0/1 group + indicator, ``outcome`` is a binary absorbing spell-ended indicator on a + balanced, equally spaced numeric time grid, and ``last_pre_period`` is + the last untreated date. The counterfactual treated survival is imputed + from the control group under a restriction on the untreated hazards: + + - ``method="cd"`` (common dynamics): the untreated hazards differ by a + constant additive gap ``c`` (Equation 2.3); fitted as the weighted mean + of the pre-treatment average-hazard gaps (Equations 3.2-3.4). + - ``method="ph"`` (proportional hazards): the untreated hazards are + proportional, ratio ``c`` (Equation 2.4); fitted as the weighted mean of + the pre-treatment cumulative-increment ratios (Theorem 1). + + The reported effect at each post-treatment date is the absorption ATT + ``E[Y_it - Y_it(0) | treated]`` (positive = more cumulative exit); the + headline ``att`` is its uniform average over the post-treatment dates. + Inference is the Appendix B whole-individual pooled bootstrap with + centered absolute-deviation pointwise and simultaneous (max-|t|) bands, + plus the Algorithm 2 fixed-anchor pre-treatment specification test. + + Parameters + ---------- + method : {"cd", "ph"}, default="cd" + Untreated-hazard restriction. + n_bootstrap : int, default=1000 + Whole-individual bootstrap draws. ``0`` returns point estimates with + NaN inference; otherwise at least ``2``. + alpha : float, default=0.05 + Significance level for every band and the pretest. + seed : int, optional + Seed for ``numpy.random.default_rng``. + + Notes + ----- + Identification requires binary absorbing outcomes, a fixed population, + no anticipation before the common intervention, unaffected controls, and + the chosen hazard restriction on the UNTREATED hazards (not on outcome + levels). Bootstrap validity additionally assumes independence across + individuals with arbitrary serial dependence within each history. + Covariates, staggered adoption, censoring, survey weights and cluster + dependence are not supported in this version. Fitting dates default to + every eligible pre-treatment date after the baseline with equal weights; + ``fit(pre_periods=..., pre_period_weights=...)`` selects a subset and + nonnegative weights (see :meth:`fit`). + """ + + def __init__( + self, + method: str = "cd", + n_bootstrap: int = 1000, + alpha: float = 0.05, + seed: Optional[int] = None, + ): + _validate_method(method) + _validate_draws(n_bootstrap) + _validate_alpha(alpha) + _validate_seed(seed) + self.method = method + self.n_bootstrap = n_bootstrap + self.alpha = alpha + self.seed = seed + self.is_fitted_ = False + self.results_: Optional[DurationDiDResults] = None + + # get_params/set_params come from BaseEstimator. + + def fit( + self, + data: pd.DataFrame, + outcome: str, + unit: str, + time: str, + treatment: str, + *, + last_pre_period: Any, + pre_periods: Optional[Sequence[Any]] = None, + pre_period_weights: Optional[Sequence[float]] = None, + ) -> DurationDiDResults: + """Fit the estimator on a balanced long individual panel. + + Parameters + ---------- + data : pd.DataFrame + Long panel with exactly one row per (individual, date). + outcome : str + Binary absorbing spell-ended indicator column (0/1; once 1, + always 1 within an individual). Baseline absorption is allowed. + unit : str + Individual identifier column. + time : str + Numeric calendar or elapsed-duration column; every individual + must be observed at the same equally spaced dates. + treatment : str + Fixed 0/1 group indicator (constant within individual). + last_pre_period : value of ``time`` + The last untreated date (``tstar``); the intervention occurs + strictly afterwards. Never inferred from the data. + pre_periods : list of ``time`` values, optional + Pre-treatment dates used to fit the hazard relationship (strictly + after the baseline, at or before ``last_pre_period``). Default: + every eligible pre-treatment date after the baseline. + pre_period_weights : array-like, optional + Nonnegative weights aligned with ``pre_periods`` (normalized to + sum to one; a zero weight drops that date). Requires + ``pre_periods``. Default: equal weights over the eligible set. + + Returns + ------- + DurationDiDResults + """ + method = self.method + arranged = _validate_and_arrange(data, outcome, unit, time, treatment, last_pre_period) + Y: np.ndarray = arranged["Y"] + n_treated: int = arranged["n_treated"] + grid: np.ndarray = arranged["grid"] + elapsed: np.ndarray = arranged["elapsed"] + tstar_idx: int = arranged["tstar_idx"] + n, n_periods = Y.shape + n_control = n - n_treated + post_idx = np.arange(tstar_idx + 1, n_periods) + n_post = len(post_idx) + J = np.arange(1, tstar_idx) + messages: List[str] = [] + + # ---- survival and moments (original sample) -------------------- + S_all, _, _ = _group_survival(Y, n_treated, np.ones((1, n))) + S = S_all[0] + for k, label in ((0, "treated"), (1, "control")): + if S[k, 0] <= 0: + raise ValueError( + f"the {label} group has zero survival at the baseline date {_label(grid, 0)!r}; " + "the initial survival level is unidentified" + ) + if S[0, tstar_idx] <= 0: + raise ValueError( + f"the treated group is fully absorbed by last_pre_period {_label(grid, tstar_idx)!r}; " + "the absorption ATT is identically zero and no hazard relationship can be fitted" + ) + if S[1, tstar_idx] <= 0: + raise ValueError( + f"the control group is fully absorbed by last_pre_period {_label(grid, tstar_idx)!r}; " + "no post-treatment date has control survival" + ) + R_all, D_all, H_all = _log_survival_moments(S_all, elapsed) + D = D_all[0] + + # ---- fitting set (frozen for every draw) ----------------------- + fit_idx, fit_w, excluded, fit_msgs = _resolve_fit_periods( + S, D, method, grid, tstar_idx, pre_periods, pre_period_weights + ) + messages.extend(fit_msgs) + + # ---- core estimate -------------------------------------------- + c_arr, R0_arr, S0_arr, tau_arr = _estimate_from_survival( + S_all, R_all, D_all, elapsed, fit_idx, fit_w, method + ) + c = float(c_arr[0]) + R0, S0, tau_all = R0_arr[0], S0_arr[0], tau_arr[0] + ph_boundary = bool(method == "ph" and np.isfinite(c) and c == 0.0) + if ph_boundary: + messages.append( + "The fitted PH ratio is exactly zero (no treated exits over the fitting " + "dates): a boundary case outside the strict positive-hazard interpretation " + "and the interior regularity argument behind the bootstrap." + ) + + # ---- curve validity and retained values ------------------------- + tol = _curve_tolerance(R_all, D_all, elapsed, c_arr, method)[0] + curve_status = _curve_status(R0, S[1], tstar_idx, tol) + period_status = curve_status[tstar_idx + 1 :] + counterfactual = S0.astype(float).copy() + att_by_period = tau_all[post_idx].astype(float).copy() + for t, st in enumerate(curve_status): + if st in ("control_survival_zero", "counterfactual_nonfinite") or not np.isfinite( + counterfactual[t] + ): + counterfactual[t] = np.nan + for j, t in enumerate(post_idx.tolist()): + st = curve_status[t] + if st in ("control_survival_zero", "counterfactual_nonfinite") or not np.isfinite( + att_by_period[j] + ): + att_by_period[j] = np.nan + curve_ok = all(s == "ok" for s in curve_status) + if not curve_ok: + messages.append(invalid_curve_message(grid, curve_status, grid[tstar_idx])) + headline = float(np.mean(att_by_period)) if curve_ok else float("nan") + + # ---- weak support --------------------------------------------- + used_counts = np.concatenate( + [ + np.round(S[0, np.r_[0, tstar_idx, fit_idx]] * n_treated), + np.round(S[1, np.r_[0, tstar_idx, fit_idx, post_idx]] * n_control), + ] + ) + weak = used_counts[(used_counts > 0) & (used_counts < _WEAK_SUPPORT_MIN_SURVIVORS)] + if weak.size: + messages.append( + f"A used group survival is backed by fewer than {_WEAK_SUPPORT_MIN_SURVIVORS} " + f"survivors (minimum {int(weak.min())}): weak numerical support for the " + "log-survival moments. No cutoff-based adjustment is applied." + ) + + # ---- pretest contrasts (original sample) ---------------------- + pretest_status: Optional[str] = None + if len(J) == 0: + delta = np.zeros(0) + pretest_status = "unavailable_insufficient_pre_periods" + messages.append( + "Only two pre-treatment dates (the baseline and last_pre_period): the " + "hazard relationship is fitted from one moment and the Algorithm 2 " + "pre-treatment diagnostic is unavailable (it needs an interior pre-date)." + ) + else: + pretest_dates = np.concatenate([J, [tstar_idx]]) + if method == "ph" and not np.all(D[1, pretest_dates] > 0): + delta = np.full(len(J), np.nan) + pretest_status = "unavailable_ph_support" + else: + delta = _pretest_contrasts(D_all, H_all, J, tstar_idx, method)[0] + if not np.all(np.isfinite(delta)): + pretest_status = "unavailable_nonfinite_moments" + + # ---- bootstrap ------------------------------------------------ + n_boot = int(self.n_bootstrap) + boot: Optional[Dict[str, Any]] = None + if n_boot >= 2: + rng = np.random.default_rng(self.seed) + boot = _run_bootstrap( + Y, n_treated, elapsed, tstar_idx, fit_idx, fit_w, method, J, n_boot, rng + ) + + # ---- post-family inference ------------------------------------ + nan_p = np.full(n_post, np.nan) + se_by_period = nan_p.copy() + crit_pw = nan_p.copy() + p_pw = nan_p.copy() + ci_pw = np.full((n_post, 2), np.nan) + cband_lo = nan_p.copy() + cband_hi = nan_p.copy() + cband_crit = float("nan") + joint_p = float("nan") + vcov: Optional[np.ndarray] = None + se_head = float("nan") + p_head = float("nan") + ci_head: Tuple[float, float] = (float("nan"), float("nan")) + n_valid_post = int(boot["ok_post"].sum()) if boot is not None else 0 + n_valid_pre = int(boot["ok_pretest"].sum()) if boot is not None else 0 + + if n_boot == 0: + inference_status = "disabled" + elif not curve_ok: + inference_status = "unavailable_invalid_periods" + elif boot is not None and not bool(boot["ok_post"].all()): + inference_status = "unavailable_failed_draws" + else: + assert boot is not None + summ = _centered_bootstrap_summary(att_by_period, boot["tau_star"], self.alpha) + head_summ = _centered_bootstrap_summary( + np.array([headline]), boot["head_star"][:, None], self.alpha + ) + se_cand = np.concatenate([summ["se"], head_summ["se"]]) + if not np.all(np.isfinite(se_cand)) or np.any(se_cand <= 0): + inference_status = "unavailable_zero_se" + else: + inference_status = "ok" + se_by_period = summ["se"] + crit_pw = summ["crit"] + p_pw = summ["p"] + ci_pw = np.column_stack([summ["ci_lower"], summ["ci_upper"]]) + cband_lo = summ["band_lower"] + cband_hi = summ["band_upper"] + cband_crit = float(summ["crit_sim"]) + joint_p = float(summ["p_joint"]) + vcov = summ["vcov"] + se_head = float(head_summ["se"][0]) + p_head = float(head_summ["p"][0]) + ci_head = (float(head_summ["ci_lower"][0]), float(head_summ["ci_upper"][0])) + + # One safe_inference call (headline) and one safe_inference_batch call + # (per-period): the joint-NaN gate, then the centered-bootstrap p/CI + # override on an available family. A withheld family has NaN SEs, so + # every column is NaN through the gate. + t_head, p_gate, ci_gate = safe_inference(headline, se_head, alpha=self.alpha) + t_pw, p_gate_pw, _, _ = safe_inference_batch(att_by_period, se_by_period, alpha=self.alpha) + if inference_status == "ok": + p_value = p_head + conf_int = ci_head + p_value_by_period = p_pw + conf_int_by_period = ci_pw + else: + p_value = float(p_gate) + conf_int = (float(ci_gate[0]), float(ci_gate[1])) + p_value_by_period = np.asarray(p_gate_pw, dtype=float) + conf_int_by_period = np.full((n_post, 2), np.nan) + se_by_period = nan_p.copy() + se_head = float("nan") + + # ---- pretest inference ---------------------------------------- + n_J = len(J) + pre_se = np.full(n_J, np.nan) + pre_lo = np.full(n_J, np.nan) + pre_hi = np.full(n_J, np.nan) + pre_crit = float("nan") + pre_stat = float("nan") + pre_p = float("nan") + pre_reject: Optional[bool] = None + # Precedence (first applicable label wins): disabled > + # insufficient_pre_periods > ph_support > nonfinite_moments > + # failed_draws > zero_se > ok. + if n_boot == 0: + pretest_status = "disabled" + elif pretest_status is None: + assert boot is not None + if not bool(boot["ok_pretest"].all()): + pretest_status = "unavailable_failed_draws" + else: + psumm = _centered_bootstrap_summary(delta, boot["delta_star"], self.alpha) + if not np.all(np.isfinite(psumm["se"])) or np.any(psumm["se"] <= 0): + pretest_status = "unavailable_zero_se" + else: + pretest_status = "ok" + pre_se = psumm["se"] + pre_lo = psumm["band_lower"] + pre_hi = psumm["band_upper"] + pre_crit = float(psumm["crit_sim"]) + pre_stat = float(psumm["statistic"]) + pre_p = float(psumm["p_joint"]) + pre_reject = bool(pre_stat > pre_crit) + + pretest = DurationDiDPretestResults( + method=method, + periods=grid[J] if n_J else grid[:0], + anchor_period=( + grid[tstar_idx].item() if hasattr(grid[tstar_idx], "item") else grid[tstar_idx] + ), + contrast=np.asarray(delta, dtype=float), + se=pre_se, + band_lower=pre_lo, + band_upper=pre_hi, + crit_value=pre_crit, + statistic=pre_stat, + p_value=pre_p, + reject=pre_reject, + alpha=self.alpha, + n_bootstrap=n_boot, + n_bootstrap_valid=n_valid_pre, + status=pretest_status, + ) + + # ---- failure warnings ----------------------------------------- + failure_reasons: Dict[str, Dict[str, int]] = {"post": {}, "pretest": {}} + n_invalid_cf = 0 + boot_effects: Optional[np.ndarray] = None + if boot is not None: + failure_reasons = { + "post": _count_reasons(boot["reason_post"]), + "pretest": _count_reasons(boot["reason_pretest"]), + } + n_invalid_cf = int(boot["n_draws_invalid_counterfactual"]) + boot_effects = boot["tau_star"] + if failure_reasons["post"] or failure_reasons["pretest"]: + messages.append( + f"Bootstrap draws failed (post family: {failure_reasons['post']}; " + f"pretest family: {failure_reasons['pretest']}) out of {n_boot}. Fixed " + "draws without retries: any failed draw marks that family's inference " + "unavailable. Remedies: a larger sample, or an explicitly shorter horizon " + "(subset the data) when control survivors at the horizon are scarce." + ) + + for msg in messages: + warnings.warn(msg, UserWarning, stacklevel=2) + + def unit_label(k: int) -> Any: + return grid[k].item() if hasattr(grid[k], "item") else grid[k] + + results = DurationDiDResults( + att=headline, + se=se_head, + t_stat=float(t_head), + p_value=float(p_value), + conf_int=conf_int, + method=method, + alpha=self.alpha, + n_bootstrap=n_boot, + n_bootstrap_valid=n_valid_post, + n_bootstrap_valid_pretest=n_valid_pre, + seed=self.seed, + n_units=int(n), + n_obs=int(n * n_periods), + n_treated=int(n_treated), + n_control=int(n_control), + n_periods=int(n_periods), + periods=grid.copy(), + last_pre_period=unit_label(tstar_idx), + post_periods=grid[post_idx].copy(), + pre_periods=grid[fit_idx].copy(), + pre_period_weights=fit_w.copy(), + excluded_pre_periods=excluded, + coefficient=c, + ph_ratio_boundary=ph_boundary, + n_treated_survivors_at_last_pre=int(round(S[0, tstar_idx] * n_treated)), + n_control_survivors_at_horizon=int(round(S[1, -1] * n_control)), + survival_treated=S[0].copy(), + survival_control=S[1].copy(), + counterfactual_survival=counterfactual, + att_by_period=att_by_period, + se_by_period=np.asarray(se_by_period, dtype=float), + t_stat_by_period=np.asarray(t_pw, dtype=float), + p_value_by_period=np.asarray(p_value_by_period, dtype=float), + conf_int_by_period=np.asarray(conf_int_by_period, dtype=float), + pointwise_crit_values=np.asarray(crit_pw, dtype=float), + cband_lower=np.asarray(cband_lo, dtype=float), + cband_upper=np.asarray(cband_hi, dtype=float), + cband_crit_value=cband_crit, + joint_p_value=joint_p, + vcov=vcov, + curve_status=list(curve_status), + period_status=list(period_status), + inference_status=inference_status, + bootstrap_effects=boot_effects, + bootstrap_failure_reasons=failure_reasons, + n_draws_invalid_counterfactual=n_invalid_cf, + pretest=pretest, + ) + self.results_ = results + self.is_fitted_ = True + return results diff --git a/diff_diff/duration_did_results.py b/diff_diff/duration_did_results.py new file mode 100644 index 000000000..4f182555e --- /dev/null +++ b/diff_diff/duration_did_results.py @@ -0,0 +1,592 @@ +"""Results containers for :class:`~diff_diff.DurationDiD` (Deaner & Ku 2026). + +Two containers live here: + +- :class:`DurationDiDResults` — the estimator result (``BaseResults``): the + headline absorption ATT (uniform average over the post-treatment dates), + the per-date ATT path with pointwise and simultaneous centered-bootstrap + bands, the fitted hazard relationship, the survival curves, and the + bootstrap diagnostics. +- :class:`DurationDiDPretestResults` — the Appendix B Algorithm 2 + fixed-anchor pre-treatment specification test (``Diagnostic``). + +Every inference family (headline, post-period path, pretest) is either fully +available or fully withheld (joint NaN); there is no partially populated +state. Statuses name the reason. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + +from diff_diff.aggregation import AggregationMixin +from diff_diff.results import _get_significance_stars +from diff_diff.results_base import ( + BaseResults, + Diagnostic, + EventStudyResults, + _coverage_pct, + _json_safe_label, + _require_fit_alpha, +) + +_METHOD_TITLES = { + "cd": "common dynamics (CD): additive untreated-hazard gap", + "ph": "proportional hazards (PH): multiplicative untreated-hazard ratio", +} + + +def _to_list(arr: Any) -> Any: + """JSON-safe list form of an array-like (NaN kept as float NaN).""" + if arr is None: + return None + return np.asarray(arr).tolist() + + +def invalid_curve_message(periods: Any, curve_status: List[str], last_pre_period: Any) -> str: + """Diagnostic sentence for an invalid imputed counterfactual curve. + + Always names the flagged dates and statuses, then the remedy that can + actually help: a fitted PRE-date violation cannot be repaired by a shorter + horizon (the fit itself must change); a post-date violation admits an + explicit refit on dates at or before the last date strictly before the + first invalid post date, when at least one post date survives. + """ + labels = [p.item() if hasattr(p, "item") else p for p in np.asarray(periods).tolist()] + grid_f = np.asarray(periods, dtype=float) + tstar_idx = int(np.nonzero(grid_f == float(last_pre_period))[0][0]) + flagged = [(labels[t], s) for t, s in enumerate(curve_status) if s != "ok"] + msg = ( + "Invalid imputed counterfactual curve at " + + ", ".join(f"{p!r} ({s})" for p, s in flagged) + + ". No valid causal inference is reported for the post-period family; " + "raw extrapolations are retained where finite." + ) + pre_bad = any(s != "ok" for s in curve_status[1 : tstar_idx + 1]) + post_bad = [t for t in range(tstar_idx + 1, len(curve_status)) if curve_status[t] != "ok"] + if pre_bad: + msg += ( + " The fitted pre-treatment curve itself leaves the domain, so a shorter " + "horizon cannot repair it: change the fit (method, pre_periods / " + "pre_period_weights) or the design." + ) + if post_bad: + first_bad = post_bad[0] + if first_bad > tstar_idx + 1: + msg += ( + " A shorter horizon must be an explicit refit choice: subset the data " + f"to dates at or before {labels[first_bad - 1]!r} and refit." + ) + else: + msg += ( + " The first post-treatment date is already invalid, so no shorter " + "horizon with a valid counterfactual exists." + ) + return msg + + +def _invalid_draw_line(n_invalid: int, n_valid: int) -> str: + """Summary line for complete draws whose imputed curve left the domain.""" + if n_valid <= 0: + return " Out-of-domain imputed curves among complete draws: n/a (no complete draws)" + share = 100.0 * n_invalid / n_valid + return ( + f" Out-of-domain imputed curves among complete draws: {n_invalid}/{n_valid} " + f"({share:.1f}%) — retained as statistics, not failures; a large share signals " + "weak extrapolation support" + ) + + +def _scalar(x: Any) -> Any: + """Native Python scalar for numpy scalars; passthrough otherwise.""" + if isinstance(x, np.generic): + return x.item() + return x + + +@dataclass +class DurationDiDPretestResults(Diagnostic): + """Appendix B Algorithm 2 pre-treatment specification test. + + For every interior pre-treatment date ``t`` (strictly between the + baseline and ``anchor_period``, the last pre-treatment date) the + contrast compares the hazard relationship at ``t`` with its value at the + fixed anchor: the average-hazard GAP under ``method="cd"`` and the + cumulative-increment RATIO under ``method="ph"``. The whole-individual + bootstrap gives one SD per contrast, a simultaneous critical value from + the maximum absolute centered pivot, symmetric bands + ``contrast +/- crit_value * se`` and the max-|t| statistic. The test + rejects when any band excludes zero, which is exactly ``p_value <= + alpha`` under the inverse-empirical-CDF quantile. + + Attributes + ---------- + method : str + ``"cd"`` or ``"ph"``. + periods : np.ndarray + The tested interior pre-treatment dates (``J``). + anchor_period : Any + The last pre-treatment date (the fixed anchor). + contrast : np.ndarray + Point contrasts, one per tested date (NaN when unavailable). + se : np.ndarray + Bootstrap SDs of the contrasts (NaN when unavailable). + band_lower, band_upper : np.ndarray + Simultaneous bands at level ``1 - alpha``. + crit_value : float + Simultaneous critical value (NaN when unavailable). + statistic : float + ``max |contrast / se|`` over the tested dates. + p_value : float + Empirical tail fraction of the bootstrap maxima at or above + ``statistic`` (NaN when unavailable). + reject : bool or None + ``True`` when any band excludes zero; ``None`` when unavailable. + alpha : float + Significance level. + n_bootstrap, n_bootstrap_valid : int + Requested draws and complete draws for this family. + status : str + ``"ok"``, ``"disabled"`` (``n_bootstrap=0``), + ``"unavailable_insufficient_pre_periods"`` (no interior pre-date), + ``"unavailable_ph_support"`` (a zero control increment at a tested + date or the anchor under PH), ``"unavailable_nonfinite_moments"``, + ``"unavailable_failed_draws"``, or ``"unavailable_zero_se"``. + """ + + method: str + periods: np.ndarray + anchor_period: Any + contrast: np.ndarray + se: np.ndarray + band_lower: np.ndarray + band_upper: np.ndarray + crit_value: float + statistic: float + p_value: float + reject: Optional[bool] + alpha: float + n_bootstrap: int + n_bootstrap_valid: int + status: str + + def to_dataframe(self) -> pd.DataFrame: + """One row per tested pre-treatment date.""" + return pd.DataFrame( + { + "period": np.asarray(self.periods).tolist(), + "contrast": np.asarray(self.contrast, dtype=float), + "se": np.asarray(self.se, dtype=float), + "band_lower": np.asarray(self.band_lower, dtype=float), + "band_upper": np.asarray(self.band_upper, dtype=float), + } + ) + + def to_dict(self) -> Dict[str, Any]: + """JSON-serializable dictionary.""" + return { + "method": self.method, + "periods": [_json_safe_label(p) for p in np.asarray(self.periods).tolist()], + "anchor_period": _json_safe_label(self.anchor_period), + "contrast": _to_list(self.contrast), + "se": _to_list(self.se), + "band_lower": _to_list(self.band_lower), + "band_upper": _to_list(self.band_upper), + "crit_value": float(self.crit_value), + "statistic": float(self.statistic), + "p_value": float(self.p_value), + "reject": self.reject, + "alpha": float(self.alpha), + "n_bootstrap": int(self.n_bootstrap), + "n_bootstrap_valid": int(self.n_bootstrap_valid), + "status": self.status, + } + + def summary(self) -> str: + """Human-readable summary.""" + kind = "average-hazard gap" if self.method == "cd" else "cumulative-increment ratio" + lines = [ + "DurationDiD pre-treatment specification test (Algorithm 2, fixed anchor)", + f" Contrast: {kind} at each interior pre-date minus its value at " + f"the anchor {self.anchor_period!r}", + f" Status: {self.status}", + f" Tested dates: {np.asarray(self.periods).tolist()}", + ] + if self.status == "ok": + lines.append( + f" max|t| = {self.statistic:.4f}, simultaneous {_coverage_pct(self.alpha)}% " + f"critical value = {self.crit_value:.4f}, p-value = {self.p_value:.4f}, " + f"reject = {self.reject} ({self.n_bootstrap_valid}/{self.n_bootstrap} " + "complete draws)" + ) + lines.append(" Failure to reject does not establish post-treatment identification.") + else: + lines.append(" Inference unavailable for this diagnostic (see status).") + if len(np.asarray(self.periods)) > 0: + lines.append("") + lines.append(self.to_dataframe().to_string(index=False)) + return "\n".join(lines) + + +@dataclass +class DurationDiDResults(BaseResults, AggregationMixin): + """Results of :class:`~diff_diff.DurationDiD`. + + The headline ``att`` is the uniform average over the post-treatment + dates of the absorption ATT ``E[Y_t - Y_t(0) | treated]`` (positive = + more cumulative absorption/exit than the counterfactual). Per-date + effects and both band families live in the ``*_by_period`` / ``cband_*`` + fields. Inference is whole-individual pooled bootstrap only; every family + is either available (``inference_status == "ok"``) or fully withheld. + + Attributes + ---------- + att, se, t_stat, p_value, conf_int + Headline inference (centered-bootstrap SD, p-value and symmetric + interval). ``p_value <= alpha`` is exactly the band rule. + method : str + ``"cd"`` or ``"ph"``. + n_units : int + Individuals (the bootstrap resampling unit). + n_obs : int + Panel rows used (``n_units * n_periods``). + n_treated, n_control : int + Individuals per group. + periods : np.ndarray + The common time grid (all dates, sorted). + last_pre_period : Any + The last untreated date (``tstar``). + post_periods : np.ndarray + Dates strictly after ``last_pre_period`` (the declared post family). + pre_periods, pre_period_weights : np.ndarray + The realized fitting dates and their normalized weights. + excluded_pre_periods : dict + Candidate fitting dates that were excluded, with the reason. + coefficient : float + Fitted CD gap ``c`` (per unit of time) or PH ratio ``c``. + survival_treated, survival_control, counterfactual_survival : np.ndarray + Group survival curves and the imputed treated counterfactual over + the whole grid (pre-dates are the fitted values); NaN where no + counterfactual exists or the value is non-finite. + att_by_period, se_by_period, t_stat_by_period, p_value_by_period + Per post-date effect and pointwise inference. + conf_int_by_period : np.ndarray + ``(P, 2)`` pointwise intervals. + pointwise_crit_values, cband_lower, cband_upper, cband_crit_value + Pointwise critical values and the simultaneous band. + joint_p_value : float + Simultaneous p-value for the null that every post-date effect is zero. + vcov : np.ndarray or None + ``(P, P)`` bootstrap covariance of the post-date effects; ``None`` + whenever the post family is unavailable. + curve_status, period_status : list of str + Validity of the imputed counterfactual curve at every date / + every post-date (``"ok"``, ``"control_survival_zero"``, + ``"counterfactual_nonfinite"``, ``"counterfactual_survival_above_one"``, + ``"counterfactual_nonmonotone"``). + inference_status : str + ``"ok"``, ``"disabled"``, ``"unavailable_invalid_periods"``, + ``"unavailable_failed_draws"`` or ``"unavailable_zero_se"``. + bootstrap_effects : np.ndarray or None + ``(n_bootstrap, P)`` raw post-effect draws (NaN rows for failed + draws). Statistics of the completed draws are diagnostics, never + inference. + bootstrap_failure_reasons : dict + ``{"post": {reason: count}, "pretest": {reason: count}}``. + n_draws_invalid_counterfactual : int + Complete post-family draws whose imputed counterfactual curve left the + domain (survival above one or a decreasing cumulative hazard). Such + draws are well-defined statistics and are NOT failures, but a large + share signals weak support for the CD extrapolation; reported in + ``summary()`` as a count and a share of the complete draws. + pretest : DurationDiDPretestResults + The Algorithm 2 specification test (always populated). + """ + + att: float + se: float + t_stat: float + p_value: float + conf_int: Tuple[float, float] + method: str + alpha: float + n_bootstrap: int + n_bootstrap_valid: int + n_bootstrap_valid_pretest: int + seed: Optional[int] + n_units: int + n_obs: int + n_treated: int + n_control: int + n_periods: int + periods: np.ndarray + last_pre_period: Any + post_periods: np.ndarray + pre_periods: np.ndarray + pre_period_weights: np.ndarray + excluded_pre_periods: Dict[Any, str] + coefficient: float + ph_ratio_boundary: bool + n_treated_survivors_at_last_pre: int + n_control_survivors_at_horizon: int + survival_treated: np.ndarray + survival_control: np.ndarray + counterfactual_survival: np.ndarray + att_by_period: np.ndarray + se_by_period: np.ndarray + t_stat_by_period: np.ndarray + p_value_by_period: np.ndarray + conf_int_by_period: np.ndarray + pointwise_crit_values: np.ndarray + cband_lower: np.ndarray + cband_upper: np.ndarray + cband_crit_value: float + joint_p_value: float + vcov: Optional[np.ndarray] + curve_status: List[str] + period_status: List[str] + inference_status: str + bootstrap_effects: Optional[np.ndarray] + bootstrap_failure_reasons: Dict[str, Dict[str, int]] + n_draws_invalid_counterfactual: int + pretest: DurationDiDPretestResults + + _AGGREGATE_SUPPORTED: ClassVar[Tuple[str, ...]] = ("event_study",) + _AGGREGATE_BALANCE_E_TYPES: ClassVar[Tuple[str, ...]] = () + + # ------------------------------------------------------------------ # + # Convenience # + # ------------------------------------------------------------------ # + @property + def is_significant(self) -> bool: + """Band rule: the headline centered-bootstrap interval excludes zero. + + Under the inverse-empirical-CDF quantile this is exactly + ``p_value <= alpha``; ``False`` when inference is unavailable. + """ + return bool(np.isfinite(self.p_value) and self.p_value <= self.alpha) + + @property + def significance_stars(self) -> str: + """Significance stars for the headline p-value ("" when NaN).""" + return _get_significance_stars(self.p_value) + + # ------------------------------------------------------------------ # + # Serialization # + # ------------------------------------------------------------------ # + def to_dict(self) -> Dict[str, Any]: + """JSON-serializable dictionary (arrays as lists, scalars native).""" + return { + "att": float(self.att), + "se": float(self.se), + "t_stat": float(self.t_stat), + "p_value": float(self.p_value), + "conf_int_lower": float(self.conf_int[0]), + "conf_int_upper": float(self.conf_int[1]), + "method": self.method, + "alpha": float(self.alpha), + "inference_method": "bootstrap" if self.n_bootstrap > 0 else "none", + "inference_status": self.inference_status, + "n_bootstrap": int(self.n_bootstrap), + "n_bootstrap_valid": int(self.n_bootstrap_valid), + "n_bootstrap_valid_pretest": int(self.n_bootstrap_valid_pretest), + "seed": _scalar(self.seed), + "n_units": int(self.n_units), + "n_obs": int(self.n_obs), + "n_treated": int(self.n_treated), + "n_control": int(self.n_control), + "n_periods": int(self.n_periods), + "periods": [_json_safe_label(p) for p in np.asarray(self.periods).tolist()], + "last_pre_period": _json_safe_label(self.last_pre_period), + "post_periods": [_json_safe_label(p) for p in np.asarray(self.post_periods).tolist()], + "pre_periods": [_json_safe_label(p) for p in np.asarray(self.pre_periods).tolist()], + "pre_period_weights": _to_list(self.pre_period_weights), + "excluded_pre_periods": { + str(_json_safe_label(k)): v for k, v in self.excluded_pre_periods.items() + }, + "coefficient": float(self.coefficient), + "ph_ratio_boundary": bool(self.ph_ratio_boundary), + "n_treated_survivors_at_last_pre": int(self.n_treated_survivors_at_last_pre), + "n_control_survivors_at_horizon": int(self.n_control_survivors_at_horizon), + "survival_treated": _to_list(self.survival_treated), + "survival_control": _to_list(self.survival_control), + "counterfactual_survival": _to_list(self.counterfactual_survival), + "att_by_period": _to_list(self.att_by_period), + "se_by_period": _to_list(self.se_by_period), + "t_stat_by_period": _to_list(self.t_stat_by_period), + "p_value_by_period": _to_list(self.p_value_by_period), + "conf_int_by_period": _to_list(self.conf_int_by_period), + "pointwise_crit_values": _to_list(self.pointwise_crit_values), + "cband_lower": _to_list(self.cband_lower), + "cband_upper": _to_list(self.cband_upper), + "cband_crit_value": float(self.cband_crit_value), + "joint_p_value": float(self.joint_p_value), + "vcov": _to_list(self.vcov), + "curve_status": list(self.curve_status), + "period_status": list(self.period_status), + "bootstrap_failure_reasons": { + k: {kk: int(vv) for kk, vv in v.items()} + for k, v in self.bootstrap_failure_reasons.items() + }, + "n_draws_invalid_counterfactual": int(self.n_draws_invalid_counterfactual), + "pretest": self.pretest.to_dict(), + } + + def to_dataframe(self, level: str = "periods") -> pd.DataFrame: + """Tabular view. + + Parameters + ---------- + level : {"periods", "att"} + ``"periods"``: one row per post-treatment date with the effect, + pointwise inference, simultaneous band and validity status. + ``"att"``: the single headline row. + """ + if level == "att": + return pd.DataFrame( + [ + { + "att": self.att, + "se": self.se, + "t_stat": self.t_stat, + "p_value": self.p_value, + "conf_int_lower": self.conf_int[0], + "conf_int_upper": self.conf_int[1], + "inference_status": self.inference_status, + } + ] + ) + if level == "periods": + ci = np.asarray(self.conf_int_by_period, dtype=float).reshape(-1, 2) + return pd.DataFrame( + { + "period": np.asarray(self.post_periods).tolist(), + "att": np.asarray(self.att_by_period, dtype=float), + "se": np.asarray(self.se_by_period, dtype=float), + "t_stat": np.asarray(self.t_stat_by_period, dtype=float), + "p_value": np.asarray(self.p_value_by_period, dtype=float), + "conf_int_lower": ci[:, 0], + "conf_int_upper": ci[:, 1], + "cband_lower": np.asarray(self.cband_lower, dtype=float), + "cband_upper": np.asarray(self.cband_upper, dtype=float), + "status": list(self.period_status), + } + ) + raise ValueError(f"level must be 'periods' or 'att', got {level!r}") + + # ------------------------------------------------------------------ # + # Summary # + # ------------------------------------------------------------------ # + def summary(self, alpha: Optional[float] = None) -> str: + """Formatted summary. ``alpha`` must equal the fit alpha.""" + fit_alpha = _require_fit_alpha(alpha, self.alpha) + pct = _coverage_pct(fit_alpha) + lines = [ + "=" * 78, + "Duration Difference-in-Differences (Deaner & Ku 2026)".center(78), + "=" * 78, + f"Hazard restriction: {_METHOD_TITLES.get(self.method, self.method)}", + f"Individuals: {self.n_units} ({self.n_treated} treated, {self.n_control} control); " + f"Observations: {self.n_obs}; Periods: {self.n_periods}", + f"Last pre-treatment period: {self.last_pre_period!r}; " + f"post-treatment periods: {len(self.post_periods)}", + f"Fitting periods: {np.asarray(self.pre_periods).tolist()} " + f"(weights {np.round(np.asarray(self.pre_period_weights, dtype=float), 4).tolist()})", + f"Fitted coefficient: {self.coefficient:.6f}", + "", + f"Inference: whole-individual bootstrap ({self.n_bootstrap_valid}/{self.n_bootstrap} " + f"complete draws); status = {self.inference_status}", + _invalid_draw_line(self.n_draws_invalid_counterfactual, self.n_bootstrap_valid), + "-" * 78, + "Headline (uniform average of post-period absorption ATTs)", + f" ATT = {self.att:.6f} SE = {self.se:.6f} t = {self.t_stat:.4f} " + f"p = {self.p_value:.4f} {self.significance_stars}", + f" {pct}% CI: [{self.conf_int[0]:.6f}, {self.conf_int[1]:.6f}]", + "", + f"Post-period effects ({pct}% pointwise CI and simultaneous band; " + f"simultaneous crit = {self.cband_crit_value:.4f}, joint p = {self.joint_p_value:.4f})", + ] + frame = self.to_dataframe(level="periods") + lines.append(frame.to_string(index=False, float_format=lambda v: f"{v:.6f}")) + flagged = [ + (p, s) + for p, s in zip(np.asarray(self.periods).tolist(), self.curve_status) + if s != "ok" + ] + if flagged: + lines.append("") + lines.append( + invalid_curve_message(self.periods, self.curve_status, self.last_pre_period) + ) + if self.excluded_pre_periods: + lines.append("") + lines.append(f"Excluded fitting periods: {self.excluded_pre_periods}") + lines.append("") + lines.append(self.pretest.summary()) + lines.append("=" * 78) + return "\n".join(lines) + + def print_summary(self, alpha: Optional[float] = None) -> None: + """Print :meth:`summary`.""" + print(self.summary(alpha=alpha)) + + # ------------------------------------------------------------------ # + # Post-fit aggregation (AggregationMixin hook) # + # ------------------------------------------------------------------ # + def _aggregate_compute( + self, + level: str, + *, + weights: Optional[str], + balance_e: Optional[int], + ) -> Any: + if level != "event_study": # pragma: no cover - mixin validates first + raise ValueError(f"Unsupported aggregation method: {level!r}") + n_post = len(self.post_periods) + event_time = np.arange(-1, n_post) + nan = np.nan + att = np.concatenate([[0.0], np.asarray(self.att_by_period, dtype=float)]) + se = np.concatenate([[nan], np.asarray(self.se_by_period, dtype=float)]) + t_stat = np.concatenate([[nan], np.asarray(self.t_stat_by_period, dtype=float)]) + p_value = np.concatenate([[nan], np.asarray(self.p_value_by_period, dtype=float)]) + ci = np.asarray(self.conf_int_by_period, dtype=float).reshape(-1, 2) + ci_lo = np.concatenate([[nan], ci[:, 0]]) + ci_hi = np.concatenate([[nan], ci[:, 1]]) + is_reference = np.zeros(n_post + 1, dtype=bool) + is_reference[0] = True + has_band = bool(np.isfinite(self.cband_crit_value)) + has_vcov = self.vcov is not None + return EventStudyResults( + event_time=event_time, + att=att, + se=se, + t_stat=t_stat, + p_value=p_value, + conf_int_lower=ci_lo, + conf_int_upper=ci_hi, + is_reference=is_reference, + n=np.full(n_post + 1, float(self.n_units)), + n_kind="units", + time_scale="relative", + event_time_convention="e0_first_treated", + vcov=np.asarray(self.vcov, dtype=float) if has_vcov else None, + vcov_index=np.arange(n_post) if has_vcov else None, + cband_lower=( + np.concatenate([[nan], np.asarray(self.cband_lower, dtype=float)]) + if has_band + else None + ), + cband_upper=( + np.concatenate([[nan], np.asarray(self.cband_upper, dtype=float)]) + if has_band + else None + ), + cband_crit_value=float(self.cband_crit_value) if has_band else None, + alpha=self.alpha, + source="DurationDiDResults", + df=None, + ) diff --git a/diff_diff/guides/llms-autonomous.txt b/diff_diff/guides/llms-autonomous.txt index b87729b8a..cc65f8f37 100644 --- a/diff_diff/guides/llms-autonomous.txt +++ b/diff_diff/guides/llms-autonomous.txt @@ -350,6 +350,7 @@ supported / out of scope; `warn` supported but with documented caveats; | `WooldridgeDiD` (ETWFE) | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | | `LWDiD` | ✓ | ✓ | ✗ | ✗ | ✓ | warn | partial | ✗ | ✓ | | `DMLDiD` | ✓ | ✓ | ✗ | ✗ | partial | ✓ (REQUIRED) | ✗ | ✗ | ✓ (pweight full-design TSL + replicate weights + coarser cluster=; PSU-cohesive folds; panel=False = declared RCS; bad-control lane `fit(bad_control=)`: panel only, `cluster=` only) | +| `DurationDiD` | ✓ (absorbing 0/1 outcome REQUIRED; two groups, common timing) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (whole-individual bootstrap only; no cluster=/survey) | | `EfficientDiD` | ✓ | ✓ | ✗ | ✗ | partial | ✓ | ✗ | ✗ | ✓ | | `SyntheticDiD` | ✓ | ✗ | ✗ | ✗ | ✓ | ✓ | ✓ | ✗ | partial | | `TROP` | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | partial | @@ -529,6 +530,15 @@ estimators: (`results.bad_control_summary()`); panel only, `cluster=` only, `anticipation=0` + varying base only; never put the bad control in `covariates`. +- `DurationDiD` (Deaner & Ku 2026) - causal duration DiD for a binary + ABSORBING outcome in a two-group common-timing design: restricts the + groups' UNTREATED HAZARDS (`method='cd'` additive gap / `method='ph'` + ratio), never outcome levels. `fit(..., last_pre_period=)` is REQUIRED; + optional `pre_periods=`/`pre_period_weights=` fitting window. Per-date + absorption ATTs with whole-individual bootstrap pointwise + simultaneous + bands; `results.pretest` is the specification test (not + `check_parallel_trends`; HonestDiD does not apply). Every inference + family is fully available or fully withheld (`inference_status`). Diagnostic: `BaconDecomposition().fit(df, ...)` shows the weight allocation of a TWFE fit to 2×2 comparison types. Forbidden-comparison weight > 10% is a diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 8195c2e19..f08005a1a 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -1429,6 +1429,32 @@ Key contracts: - Bad controls (Caetano, Callaway, Payne & Sant'Anna 2026): `fit(..., bad_control='x', bad_control_covariates=[outcome])` swaps the cell score for the paper's Neyman-orthogonal doubly-robust score (Eq. 10; parallel trends conditional on the bad control's UNTREATED path, its untreated evolution identified by covariate unconfoundedness given the base-period bad control, W and Z; four cross-fitted nuisances incl. two nested second stages) and stores the per-cell `ATT_X(g,t)` pre-test (`results.bad_control_summary()`, `results.bad_control_diagnostics`; analytical SE only, never bootstrapped/aggregated). Panel lane only, bare `cluster=` only (`survey_design=` raises), `anticipation=0` and `base_period='varying'` only; the bad control may not appear in `covariates`; `bad_control_covariates=None` means no W. Headline `att` keeps the CS simple weighting (not the paper's Remark 4 overall). Approach 1 (condition on the PRE-treatment bad control) = pass it in `covariates` on the panel lane instead. - Survey/cluster support on BOTH designs (bad-control lane: panel only, `cluster=` only). `survey_design=` (pweight-only): weighted moment kernels (Hajek p-hat/lambda-hat/theta), sample_weight into the nuisance learners (user learner objects must accept sample_weight by keyword — rejected up front otherwise); two variance lanes. Full-design TSL (weights/strata/PSU/FPC): PSU-cohesive cross-fitting folds when the PSU is strictly coarser than the sampling unit, design-based per-cell/aggregate variance with df = n_PSU - n_strata t-inference. Replicate weights (BRR/Fay/JK1/JKn/SDR): per-cell AND aggregate IF-reweighting variance on the augmented cross-fitted scores with df = rank(replicate matrix) - 1 t-inference (nuisances not re-estimated per replicate; replicate + cluster= and replicate + bootstrap rejected). A documented library EXTENSION of Chang's i.i.d. theory (Theorem 2's coverage claim does not carry over on the weighted-lambda RCS lane — REGISTRY DMLDiD Notes). Bare cluster= keeps the kernels unweighted (variance/folds/df only). aggregate('total') fails closed on declared-survey fits. panel=False requires row-unique unit IDs and assumes stationary cross-sectional sampling (Assumption 2.3, warned, not data-checkable). Reproducibility: set seed (fold draws move point estimates; PSU folds consume the RNG differently than stratified folds). +### DurationDiD + +Deaner & Ku (2026, arXiv:2405.05220v2) causal duration analysis with diff-in-diff: a two-group, common-timing design for a binary ABSORBING outcome (Y_it = 1 once the spell has ended, e.g. job found, subscription cancelled). Standard DiD on the cumulative event indicator imposes a constant gap in event PROBABILITIES, which forces the survivors' hazards to diverge (Appendix A.1); DurationDiD instead restricts the two groups' UNTREATED HAZARDS — a constant additive gap c (`method='cd'`, common dynamics) or a constant ratio c (`method='ph'`, proportional hazards, the mean-of-ratios estimator supported by Theorem 1 and the authors' code) — fitted on the pre-treatment cumulative hazards (default: equal weights over every eligible pre-treatment date after the baseline; `pre_periods=`/`pre_period_weights=` select a window with nonnegative weights). The treated counterfactual survival is imputed from the control cumulative hazard and the treated baseline, and the ABSORPTION ATT E[Y_t - Y_t(0) | treated] (positive = more cumulative exit) is reported at every post-treatment date; `att` is its uniform average. Inference is the paper's whole-individual pooled bootstrap (Appendix B Algorithm 1): centered absolute-deviation pointwise intervals, a simultaneous max-|t| band (`cband_*`, `joint_p_value`), and the Algorithm 2 fixed-anchor pre-treatment specification test in `results.pretest`. Baseline-absorbed individuals stay in the estimand. + +```python +DurationDiD( + method='cd', # 'cd' (additive untreated-hazard gap) | 'ph' (hazard ratio) + n_bootstrap=1000, # whole-individual draws; 0 = point estimates only (NaN inference); else >= 2 + alpha=0.05, + seed=None, +).fit( + data, outcome, unit, time, treatment, + last_pre_period=..., # REQUIRED: the last untreated date (a value of `time`); never inferred + pre_periods=None, # fitting dates strictly after the baseline date and at or before last_pre_period + pre_period_weights=None, # nonnegative weights aligned with pre_periods (requires pre_periods) +) -> DurationDiDResults +``` + +Key contracts: +- Input: exactly one row per (individual, date) on a common, equally spaced numeric time grid (datetime must be converted to an elapsed scale); `treatment` is a FIXED 0/1 group indicator; `outcome` is 0/1 and absorbing (reversals raise). Missing cells, late entry, and dropout raise; an administrative end of a complete window is fine. +- `results.att_by_period`, `se_by_period`, `conf_int_by_period` (pointwise), `cband_lower/upper` + `cband_crit_value` (simultaneous), `joint_p_value`, `vcov` (P x P bootstrap covariance), `coefficient` (fitted c), `survival_treated/control`, `counterfactual_survival`, `pre_periods`/`pre_period_weights` (realized), `excluded_pre_periods`. +- Every inference family is fully available or fully withheld: `inference_status` in `ok` / `disabled` (n_bootstrap=0) / `unavailable_invalid_periods` (the CD model extrapolated an invalid counterfactual curve — survival above one or a decreasing cumulative hazard — `curve_status`/`period_status` name the dates; the warning names the applicable remedy: a fitted pre-date violation cannot be repaired by a shorter horizon — change `method` or the fitting window; a later post-date violation admits an explicit refit on the dates before the first invalid post date; a first-post-date violation admits no valid shorter horizon) / `unavailable_failed_draws` (any failed draw; fixed draws, no retries, `bootstrap_failure_reasons` per family) / `unavailable_zero_se`. `n_draws_invalid_counterfactual` counts complete draws whose imputed curve left the domain (not failures; a large share signals weak extrapolation support — shown in `summary()`). `p_value <= alpha` is exactly the band rule (`is_significant`). +- `results.pretest` (`DurationDiDPretestResults`, a Diagnostic): contrasts of the CD gap / PH ratio at every interior pre-date against the fixed anchor `last_pre_period`, simultaneous band, `statistic`, `p_value`, `reject`, `status` (needs at least three pre-dates). A non-rejection does not establish identification. +- `results.aggregate('event_study')` returns the unified `EventStudyResults` (event time 0 = first post-treatment date, reference -1, `n_kind='units'`); no pre-treatment ATT rows exist (the pretest is not an event study). Do NOT run `check_parallel_trends` or HonestDiD on this estimator; `DiagnosticReport`/`BusinessReport` reject `DurationDiDResults` by type; `practitioner_next_steps` has a dedicated handler. +- Not supported in this version: covariates, staggered adoption, censoring, survey weights, cluster dependence, repeated cross-sections. + ### TROP Triply Robust Panel estimator (Athey, Imbens, Qu & Viviano 2025). Combines nuclear norm regularization, distance-based unit weights, and time decay weights. @@ -2699,7 +2725,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 (the survey-capable estimators listed in the Survey Design Support table of docs/choosing_estimator.rst, 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)` @@ -2924,7 +2950,7 @@ DIFF_DIFF_BACKEND=rust pytest # Force Rust (fail if unavailable) ## BusinessReport -Plain-English stakeholder narrative from any fitted result type. +Plain-English stakeholder narrative from a fitted estimator result (every scalar estimator result except the two rejected by type: `EventStudyResults` surfaces and `DurationDiDResults`). Renders `summary()` (short paragraph), `full_report()` (multi-section markdown), and `to_dict()` (stable AI-legible schema — single source of truth; prose renders from the dict). diff --git a/diff_diff/guides/llms-practitioner.txt b/diff_diff/guides/llms-practitioner.txt index bd67bcaa9..3b7959350 100644 --- a/diff_diff/guides/llms-practitioner.txt +++ b/diff_diff/guides/llms-practitioner.txt @@ -213,6 +213,16 @@ see REGISTRY HeterogeneousAdoptionDiD edge cases): | (BRFSS / CPS / NHANES shape), see Tutorial 22: | docs/tutorials/22_had_survey_design.ipynb. | +Is the outcome a binary ABSORBING event (spell ended: job found, churned, +discharged) observed over time for a treated and a control group? +|-- YES: Use DurationDiD (Deaner & Ku 2026) -- two groups, common +| timing. Identification is a restriction on the UNTREATED +| HAZARDS (method='cd' additive gap, method='ph' ratio), not on +| outcome levels; whole-individual bootstrap pointwise + +| simultaneous bands; fit(..., last_pre_period=) is REQUIRED; +| read results.pretest (fixed-anchor specification test) instead +| of check_parallel_trends(); HonestDiD does not apply. +| Is treatment adoption staggered (multiple cohorts, different timing)? |-- YES: Do NOT use plain TWFE. Use one of: | |-- CallawaySantAnna (CS) -- most general, doubly robust, recommended default diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index 2ffd140af..98a416fee 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 a catalog of estimators (the `## Estimators` list below is the authoritative roster) covering basic 2x2 DiD, causal duration analysis for absorbing outcomes, 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 @@ -82,6 +82,7 @@ The site is organized into 5 sections, each with a landing page: - [QDiD](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): **Deprecated 3.9, removed 4.0 - use `ChangesInChanges(method="qdid")`.** Athey & Imbens (2006) quantile DiD comparison estimator (additive quantile-by-quantile DiD, matching R `qte::QDiD()` including its covariate branch via `covariates=`); same bootstrap machinery as ChangesInChanges. The paper recommends CiC over QDiD (scale-dependent model with testable restrictions; a non-monotonicity warning fires when violated - unconditional fits only, the covariate-path counterfactual quantile curve is monotone by construction). - [LWDiD](https://diff-diff.readthedocs.io/en/stable/api/lwdid.html): Lee & Wooldridge (2025, 2026) rolling-transformation DiD — unit-specific demean/detrend converts panel to cross-section, supports staggered adoption with flexible control groups. Signature: `LWDiD(rolling='demean', estimation_method='reg', vcov_type='hc1', cluster=None, control_group='not_yet_treated', alpha=0.05, n_bootstrap=0, seed=None, pscore_trim=0.01, n_neighbors=1, caliper=None, with_replacement=True, n_jobs=1).fit(data, outcome, unit, time, treatment, first_treat=None, covariates=None)`. `estimation_method` values: `reg` (papers' RA), `ipw`, `dr` (papers' IPWRA, doubly robust), `psm`; `vcov_type` values: `classical`/`hc1`/`hc2`/`hc3` for `reg`; `ipw`/`dr` accept `hc1` only (influence-function variance); `psm` accepts `hc1` as configuration only - PSM inference is unavailable (NaN) pending an Abadie-Imbens matching variance; cluster-robust inference via the constructor's `cluster=` column (hc1/CR1 only, not a `vcov_type` value; rejected for `psm`). Per-period effects: post-fit `results.aggregate('event_study')`. - [DMLDiD](https://diff-diff.readthedocs.io/en/stable/api/dml_did.html): Chang (2020) double/debiased machine learning DiD — staggered ATT(g,t) with cross-fitted ML nuisances (DML2) and Neyman-orthogonal scores; covariates REQUIRED (conditional parallel trends). Signature: `DMLDiD(propensity_learner='logit', outcome_learner='linear', n_folds=5, control_group='never_treated', anticipation=0, alpha=0.05, n_bootstrap=0, bootstrap_weights=None, seed=None, base_period='varying', cband=True, pscore_trim=0.01, panel=True, cluster=None).fit(data, outcome, unit, time, first_treat, covariates, survey_design=None, bad_control=None, bad_control_covariates=None)`. `panel=False` = declared repeated cross sections (Chang Case 2: level outcomes, row-unique unit IDs, lambda-corrected variance). Survey/cluster support on BOTH designs (bad-control lane: panel only, `cluster=` only): `survey_design=` (pweight full-design TSL — weighted moments, PSU-cohesive folds, design-based variance with t-inference; a library extension of Chang's i.i.d. theory), replicate-weight designs (BRR/Fay/JK1/JKn/SDR — per-cell AND aggregate IF-reweighting variance, df = rank-1; cluster= and bootstrap combinations rejected), and coarser-than-unit `cluster=` (variance/folds only, kernels stay unweighted). Bad controls (Caetano, Callaway, Payne & Sant'Anna 2026): `fit(..., bad_control='x', bad_control_covariates=[outcome])` runs the paper's Neyman-orthogonal doubly-robust score (Eq. 10) - parallel trends conditional on the bad control's untreated path plus covariate unconfoundedness given its base-period value, W and Z - and stores the per-cell `ATT_X(g,t)` pre-test (`results.bad_control_summary()`); a bad control must NOT be in `covariates`. Learners: string names (`linear`/`ridge`/`sieve` regressors, `logit` classifier) or any object with fit/predict(_proba) (sklearn-compatible); `SieveLearner(k_max, criterion)` is exported for adaptive polynomial nuisances. Aggregation is POST-FIT: `results.aggregate('event_study'/'group'/'simple')`, plus `'total'` on panel non-survey fits (RCS and declared-survey fits fail 'total' closed); sup-t bands via bootstrap replay. With seed=None point estimates vary across fits (random folds); set seed for reproducibility. +- [DurationDiD](https://diff-diff.readthedocs.io/en/stable/api/duration_did.html): Deaner & Ku (2026) causal duration DiD — two-group, common-timing design for a binary ABSORBING outcome (spell ended; once 1, always 1). Identifies off a restriction on the groups' UNTREATED HAZARDS, never outcome levels: `method='cd'` (constant additive hazard gap) or `method='ph'` (constant hazard ratio, mean-of-ratios estimator), fitted on the pre-treatment cumulative hazards (default equal weights over every eligible pre-treatment date; a window via `pre_periods=`/`pre_period_weights=`). Reports the absorption ATT at every post-treatment date and its uniform average as `att`, with the paper's whole-individual pooled bootstrap (centered pointwise intervals + simultaneous max-|t| band) and a fixed-anchor pre-treatment specification test in `results.pretest`. Signature: `DurationDiD(method='cd', n_bootstrap=1000, alpha=0.05, seed=None).fit(data, outcome, unit, time, treatment, last_pre_period=..., pre_periods=None, pre_period_weights=None)`; `last_pre_period` (the last untreated date) is REQUIRED and never inferred. Balanced equally spaced numeric grid, fixed 0/1 group indicator; no covariates, staggering, censoring, survey or cluster support. Every inference family is fully available or fully withheld (`inference_status`). Not admitted by DiagnosticReport/BusinessReport; `results.aggregate('event_study')` returns the unified container. - [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html): Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings ## Diagnostics and Sensitivity Analysis diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index 546aa9b86..faaa4e21f 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -49,6 +49,7 @@ "HeterogeneousAdoptionDiDResults": "HeterogeneousAdoptionDiD (HAD)", "HeterogeneousAdoptionDiDEventStudyResults": "HeterogeneousAdoptionDiD (Event Study)", "ChangesInChangesResults": "ChangesInChanges / QDiD", + "DurationDiDResults": "DurationDiD (Deaner & Ku 2026 duration DiD)", } @@ -182,6 +183,12 @@ def practitioner_next_steps( # (same step_name, so completed_steps filtering is unchanged). if type_name == "ChangesInChangesResults": pre_estimation[1] = _cic_assumptions_step(results) + # DurationDiDResults: identification rests on a restriction on the + # UNTREATED HAZARDS of a binary absorbing outcome (Deaner & Ku 2026), + # not on mean parallel trends - swap in the hazard-restriction + # statement (same step_name, so completed_steps filtering is unchanged). + if type_name == "DurationDiDResults": + pre_estimation[1] = _duration_did_assumptions_step(results) if not diagnostic_input: steps = pre_estimation + steps @@ -2319,6 +2326,176 @@ def _handle_generic(results: Any): return steps, warnings +# --------------------------------------------------------------------------- +# DurationDiD (Deaner & Ku 2026) handler +# --------------------------------------------------------------------------- + + +def _duration_did_assumptions_step(results: Any) -> Dict[str, Any]: + """Step-2 (assumptions) override for ``DurationDiDResults``. + + The generic Step 2 asks for a parallel-trends VARIANT; DurationDiD's + identifying restriction is on the untreated hazards of an absorbing + outcome, so the parallel-trends prompt would be the wrong question. + Same baker_step/step_name, so ``completed_steps`` filtering is unchanged. + """ + method = getattr(results, "method", "cd") + restriction = ( + "a constant ADDITIVE gap between the two groups' untreated hazards " + "(common dynamics, Equation 2.3)" + if method == "cd" + else "a constant RATIO between the two groups' untreated hazards " + "(proportional hazards, Equation 2.4)" + ) + return _step( + baker_step=2, + label="State identification assumptions (untreated-hazard restriction)", + why=( + "Name the duration-DiD assumptions you are invoking - not a mean " + f"parallel-trends variant: {restriction}; a binary ABSORBING outcome " + "on a fixed population; no anticipation before last_pre_period; " + "controls unaffected by the intervention; and, for the " + "whole-individual bootstrap, independence across individuals " + "(arbitrary serial dependence within a history is fine). The " + "restriction is on untreated hazards, never on outcome levels " + "(Deaner & Ku 2026, Section 2 and Appendix A.1)." + ), + code=( + "# Which untreated-hazard restriction (CD gap / PH ratio)? Absorbing " + "outcome? No anticipation before last_pre_period? Unaffected controls?" + ), + priority="high", + step_name="assumptions", + ) + + +def _handle_duration_did(results: Any): + """DurationDiD guidance (Deaner & Ku 2026). + + HonestDiD and ``check_parallel_trends`` are deliberately never + recommended: the estimator's identification is a hazard restriction on + an absorbing outcome, and its event-study container carries no + pre-treatment ATT rows. The anticipation placebo is emitted only when the + fit has a third pre-date; its code truncates the frame to dates at or + before the original anchor BEFORE refitting one step earlier, so the + placebo family never contains a genuinely treated date. + """ + method = getattr(results, "method", "cd") + other = "ph" if method == "cd" else "cd" + pretest = getattr(results, "pretest", None) + pretest_status = getattr(pretest, "status", "unknown") + last_pre = getattr(results, "last_pre_period", None) + raw_periods = list(getattr(results, "periods", [])) + periods = [float(p) for p in raw_periods] + n_pre = sum(1 for p in periods if last_pre is not None and p <= float(last_pre)) + + steps = [ + _step( + baker_step=3, + label="Read the Algorithm 2 pre-treatment specification test", + why=( + "DurationDiD fits the hazard restriction on pre-treatment " + "moments and tests it with a fixed-anchor contrast at every " + "interior pre-date (simultaneous centered-bootstrap band). " + f"Current status: {pretest_status!r}. A rejection is evidence " + "against the chosen restriction; a non-rejection cannot " + "establish post-treatment identification or rule out low power. " + "Do NOT run check_parallel_trends() on the binary outcome: " + "constant outcome-level gaps are not the identifying assumption " + "(Appendix A.1)." + ), + code=( + "print(results.pretest.summary())\n" + "results.pretest.to_dataframe() # contrast, se, simultaneous band" + ), + step_name="parallel_trends", + ), + _step( + baker_step=6, + label="Sensitivity: refit under the other hazard restriction and window", + why=( + "CD and PH need not agree outside settings where the pre-treatment " + "hazards are close; report both, and vary the fitting window " + "(pre_periods=/pre_period_weights=) to show the imputed " + "counterfactual is not driven by early pre-dates." + ), + code=( + f"alt = DurationDiD(method='{other}', n_bootstrap={getattr(results, 'n_bootstrap', 1000)}, " + "seed=42).fit(\n" + " data, outcome='exited', unit='unit', time='time', treatment='treated',\n" + f" last_pre_period={last_pre!r})\n" + "# Narrow the fitting window to the last k pre-dates:\n" + "# ... .fit(..., pre_periods=[...last k dates...])" + ), + step_name="sensitivity", + ), + ] + if n_pre >= 3 and last_pre is not None: + # The preceding anchor is the exact stored grid scalar (never a + # floating-point subtraction, which would miss a decimal grid value). + anchor_idx = periods.index(float(last_pre)) + earlier_raw = raw_periods[anchor_idx - 1] + earlier_repr = repr(earlier_raw.item() if hasattr(earlier_raw, "item") else earlier_raw) + steps.append( + _step( + baker_step=6, + label="Anticipation placebo (last pre-date as a fake post-date)", + why=( + "Truncate the panel to dates at or before the original " + "last_pre_period, then refit with the anchor one date " + "earlier: the placebo post family is exactly the original " + "last pre-date and contains no genuinely treated date. A " + "significant placebo effect suggests anticipation (the " + "paper's days-203-209 check)." + ), + code=( + f"placebo_data = data[data['time'] <= {last_pre!r}] # drop every treated date\n" + f"placebo = DurationDiD(method='{method}', n_bootstrap=" + f"{getattr(results, 'n_bootstrap', 1000)}, seed=42).fit(\n" + " placebo_data, outcome='exited', unit='unit', time='time', " + "treatment='treated',\n" + f" last_pre_period={earlier_repr})\n" + "print(placebo.att, placebo.conf_int)" + ), + step_name="placebo", + ) + ) + else: + steps.append( + _step( + baker_step=6, + label="Anticipation placebo: not applicable", + why=( + "The anticipation placebo needs a third pre-date (baseline, " + "a fake post-date, and an earlier anchor); this fit has only " + "two pre-treatment dates. Extend the pre-treatment window to " + "run it." + ), + code="# Extend the pre-treatment window, then rerun practitioner_next_steps().", + priority="medium", + step_name="placebo", + ) + ) + steps.append( + _step( + baker_step=7, + label="Report the simultaneous band with the per-date effects", + why=( + "Per-date absorption ATTs are a path; the simultaneous " + "(max-|t|) band is the paper's uniform inference. Report " + "inference_status and period_status: a withheld family means " + "no valid causal inference on that path." + ), + code=( + "results.to_dataframe() # att, pointwise CI, simultaneous band, status\n" + "results.aggregate('event_study').to_dataframe()" + ), + step_name="heterogeneity", + ) + ) + return steps, _check_nan_att(results) + + # --------------------------------------------------------------------------- # Handler registry — maps result type *names* (not classes) to avoid # import-time circular dependencies @@ -2342,6 +2519,7 @@ def _handle_generic(results: Any): "HeterogeneousAdoptionDiDResults": _handle_had, "HeterogeneousAdoptionDiDEventStudyResults": _handle_had_event_study, "ChangesInChangesResults": _handle_cic, + "DurationDiDResults": _handle_duration_did, } diff --git a/docs/api/_autosummary/diff_diff.DurationDiD.rst b/docs/api/_autosummary/diff_diff.DurationDiD.rst new file mode 100644 index 000000000..8ef45b07f --- /dev/null +++ b/docs/api/_autosummary/diff_diff.DurationDiD.rst @@ -0,0 +1,30 @@ +diff\_diff.DurationDiD +====================== + +.. currentmodule:: diff_diff + +.. autoclass:: DurationDiD + :no-members: + + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiD.__init__ + ~DurationDiD.fit + ~DurationDiD.get_params + ~DurationDiD.set_params + + + + + .. rubric:: Attributes + + .. autosummary:: + + ~DurationDiD.method + ~DurationDiD.n_bootstrap + ~DurationDiD.alpha + ~DurationDiD.seed + diff --git a/docs/api/_autosummary/diff_diff.duration_did_results.DurationDiDPretestResults.rst b/docs/api/_autosummary/diff_diff.duration_did_results.DurationDiDPretestResults.rst new file mode 100644 index 000000000..21bf94fc4 --- /dev/null +++ b/docs/api/_autosummary/diff_diff.duration_did_results.DurationDiDPretestResults.rst @@ -0,0 +1,41 @@ +diff\_diff.duration\_did\_results.DurationDiDPretestResults +=========================================================== + +.. currentmodule:: diff_diff.duration_did_results + +.. autoclass:: DurationDiDPretestResults + :no-members: + + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiDPretestResults.__init__ + ~DurationDiDPretestResults.summary + ~DurationDiDPretestResults.to_dataframe + ~DurationDiDPretestResults.to_dict + + + + + .. rubric:: Attributes + + .. autosummary:: + + ~DurationDiDPretestResults.method + ~DurationDiDPretestResults.periods + ~DurationDiDPretestResults.anchor_period + ~DurationDiDPretestResults.contrast + ~DurationDiDPretestResults.se + ~DurationDiDPretestResults.band_lower + ~DurationDiDPretestResults.band_upper + ~DurationDiDPretestResults.crit_value + ~DurationDiDPretestResults.statistic + ~DurationDiDPretestResults.p_value + ~DurationDiDPretestResults.reject + ~DurationDiDPretestResults.alpha + ~DurationDiDPretestResults.n_bootstrap + ~DurationDiDPretestResults.n_bootstrap_valid + ~DurationDiDPretestResults.status + diff --git a/docs/api/_autosummary/diff_diff.duration_did_results.DurationDiDResults.rst b/docs/api/_autosummary/diff_diff.duration_did_results.DurationDiDResults.rst new file mode 100644 index 000000000..575cc2e91 --- /dev/null +++ b/docs/api/_autosummary/diff_diff.duration_did_results.DurationDiDResults.rst @@ -0,0 +1,77 @@ +diff\_diff.duration\_did\_results.DurationDiDResults +==================================================== + +.. currentmodule:: diff_diff.duration_did_results + +.. autoclass:: DurationDiDResults + :no-members: + + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiDResults.__init__ + ~DurationDiDResults.aggregate + ~DurationDiDResults.print_summary + ~DurationDiDResults.summary + ~DurationDiDResults.to_dataframe + ~DurationDiDResults.to_dict + + + + + .. rubric:: Attributes + + .. autosummary:: + + ~DurationDiDResults.is_significant + ~DurationDiDResults.significance_stars + ~DurationDiDResults.att + ~DurationDiDResults.se + ~DurationDiDResults.t_stat + ~DurationDiDResults.p_value + ~DurationDiDResults.conf_int + ~DurationDiDResults.method + ~DurationDiDResults.alpha + ~DurationDiDResults.n_bootstrap + ~DurationDiDResults.n_bootstrap_valid + ~DurationDiDResults.n_bootstrap_valid_pretest + ~DurationDiDResults.seed + ~DurationDiDResults.n_units + ~DurationDiDResults.n_obs + ~DurationDiDResults.n_treated + ~DurationDiDResults.n_control + ~DurationDiDResults.n_periods + ~DurationDiDResults.periods + ~DurationDiDResults.last_pre_period + ~DurationDiDResults.post_periods + ~DurationDiDResults.pre_periods + ~DurationDiDResults.pre_period_weights + ~DurationDiDResults.excluded_pre_periods + ~DurationDiDResults.coefficient + ~DurationDiDResults.ph_ratio_boundary + ~DurationDiDResults.n_treated_survivors_at_last_pre + ~DurationDiDResults.n_control_survivors_at_horizon + ~DurationDiDResults.survival_treated + ~DurationDiDResults.survival_control + ~DurationDiDResults.counterfactual_survival + ~DurationDiDResults.att_by_period + ~DurationDiDResults.se_by_period + ~DurationDiDResults.t_stat_by_period + ~DurationDiDResults.p_value_by_period + ~DurationDiDResults.conf_int_by_period + ~DurationDiDResults.pointwise_crit_values + ~DurationDiDResults.cband_lower + ~DurationDiDResults.cband_upper + ~DurationDiDResults.cband_crit_value + ~DurationDiDResults.joint_p_value + ~DurationDiDResults.vcov + ~DurationDiDResults.curve_status + ~DurationDiDResults.period_status + ~DurationDiDResults.inference_status + ~DurationDiDResults.bootstrap_effects + ~DurationDiDResults.bootstrap_failure_reasons + ~DurationDiDResults.n_draws_invalid_counterfactual + ~DurationDiDResults.pretest + diff --git a/docs/api/business_report.rst b/docs/api/business_report.rst index 883902ca2..303108ced 100644 --- a/docs/api/business_report.rst +++ b/docs/api/business_report.rst @@ -1,7 +1,10 @@ BusinessReport ============== -``BusinessReport`` wraps any fitted diff-diff result object and produces +``BusinessReport`` wraps a fitted diff-diff estimator result object (every +scalar estimator result except the two rejected by type: ``EventStudyResults`` +surfaces and ``DurationDiDResults`` — use ``results.summary()``, +``results.pretest`` and ``results.aggregate("event_study")`` there) and produces stakeholder-ready output: - ``summary()`` — a short paragraph block suitable for an email or Slack. diff --git a/docs/api/diagnostic_report.rst b/docs/api/diagnostic_report.rst index f7cf8976d..7cf561b71 100644 --- a/docs/api/diagnostic_report.rst +++ b/docs/api/diagnostic_report.rst @@ -5,7 +5,10 @@ DiagnosticReport functions (parallel trends, pre-trends power, HonestDiD sensitivity, Goodman-Bacon, design-effect, EPV, heterogeneity, and estimator-native checks for SyntheticDiD and TROP) into a single report with a stable -AI-legible schema. +AI-legible schema. ``EventStudyResults`` surfaces and ``DurationDiDResults`` +are rejected by type (the battery is keyed to scalar mean-outcome +parallel-trends estimators; for DurationDiD use ``results.pretest`` and +``results.summary()``). Construction is free; accessing ``applicable_checks`` may derive the fit's post-fit event-study surface once (a view or kit recompute via diff --git a/docs/api/duration_did.rst b/docs/api/duration_did.rst new file mode 100644 index 000000000..8ac2fdad7 --- /dev/null +++ b/docs/api/duration_did.rst @@ -0,0 +1,216 @@ +Duration DiD (Deaner & Ku) +========================== + +Causal duration analysis with difference-in-differences for a binary +**absorbing** outcome — a spell that ends (job found, subscription cancelled, +patient discharged) and stays ended — in a two-group, common-timing design. +Standard DiD on the cumulative event indicator imposes a constant gap in +event *probabilities*, which mechanically forces the survivors' hazards to +diverge (Deaner & Ku 2026, Appendix A.1). ``DurationDiD`` instead restricts +the two groups' **untreated hazards**: + +- ``method="cd"`` (common dynamics): a constant additive hazard gap ``c``. +- ``method="ph"`` (proportional hazards): a constant hazard ratio ``c``. + +The coefficient is fitted from the pre-treatment cumulative hazards (default: +equal weights over every eligible pre-treatment date after the baseline; +``pre_periods=`` / ``pre_period_weights=`` select a window), the treated +group's counterfactual survival is imputed from the control group's cumulative +hazard and the treated baseline (Theorem 1), and the **absorption ATT** +``E[Y_t - Y_t(0) | treated]`` is reported at every post-treatment date +(positive = more cumulative exit than the counterfactual). The headline +``att`` is the uniform average over the post-treatment dates. + +Inference is the paper's whole-individual pooled bootstrap (Appendix B, +Algorithm 1): each draw resamples complete histories, recomputes everything, +and the reported pointwise intervals and simultaneous (max-|t|) band are +centered absolute-deviation bands. The Algorithm 2 fixed-anchor +pre-treatment specification test is reported separately in +``results.pretest``. Every inference family is either fully available or +fully withheld with a named ``inference_status``; failed draws are never +retried or silently dropped. + +.. note:: + + Requirements: exactly one row per (individual, date) on a common, equally + spaced numeric time grid; a fixed 0/1 group indicator; a 0/1 absorbing + outcome (baseline absorption is allowed and those individuals stay in the + estimand). ``last_pre_period`` (the last untreated date) is required and + never inferred. Covariates, staggered adoption, censoring, survey weights + and cluster dependence are not supported in this version; the CD model + can extrapolate an invalid counterfactual curve (survival above one or a + decreasing cumulative hazard), which is reported through + ``curve_status`` / ``period_status`` with the post-period inference + withheld. The warning names the remedy that applies: a violation at a + fitted pre-treatment date cannot be repaired by a shorter horizon (change + ``method`` or the fitting window); a violation at a later post date admits + an explicit refit on the dates before the first invalid post date; a + violation at the first post date admits no valid shorter horizon. See + ``docs/methodology/REGISTRY.md`` for the full contract. + +**When to use DurationDiD:** + +- A binary, absorbing outcome observed over time for a treated and a + control group, with a common intervention date +- You want the effect on the probability of having exited by each + post-treatment date, identified through hazards rather than outcome levels +- Individuals are independent draws (the bootstrap resamples individuals) + +**Reference:** Deaner, B., & Ku, H. (2026). Causal Duration Analysis with +Diff-in-Diff. arXiv:2405.05220v2. +https://arxiv.org/abs/2405.05220v2 + +.. module:: diff_diff.duration_did + +DurationDiD +----------- + +Main estimator class. + +.. autoclass:: diff_diff.DurationDiD + :no-index: + :members: + :undoc-members: + :show-inheritance: + :inherited-members: + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiD.fit + ~DurationDiD.get_params + ~DurationDiD.set_params + +DurationDiDResults +------------------ + +Results container: headline inference, per-date effects with pointwise and +simultaneous bands, survival curves, fitted coefficient, bootstrap +diagnostics, and the pretest. + +.. autoclass:: diff_diff.duration_did_results.DurationDiDResults + :no-index: + :members: + :undoc-members: + :show-inheritance: + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiDResults.summary + ~DurationDiDResults.to_dict + ~DurationDiDResults.to_dataframe + ~DurationDiDResults.aggregate + +DurationDiDPretestResults +------------------------- + +The Algorithm 2 fixed-anchor pre-treatment specification test. + +.. autoclass:: diff_diff.duration_did_results.DurationDiDPretestResults + :no-index: + :members: + :undoc-members: + :show-inheritance: + + .. rubric:: Methods + + .. autosummary:: + + ~DurationDiDPretestResults.summary + ~DurationDiDPretestResults.to_dataframe + ~DurationDiDPretestResults.to_dict + +Example Usage +------------- + +Every block below builds its own absorbing panel from population survival +curves (one uniform draw per individual), so it runs on its own. + +Basic CD fit with bootstrap inference: + +.. code-block:: python + + import numpy as np + import pandas as pd + from diff_diff import DurationDiD + + rng = np.random.default_rng(0) + n, T = 500, 8 + control = 0.8 * np.exp(-np.cumsum(np.r_[0.0, 0.15 + 0.02 * np.arange(2, T + 1)])) + treated = 0.6 * np.exp(-np.cumsum(np.r_[0.0, 0.20 + 0.02 * np.arange(2, T + 1) + + 0.3 * (np.arange(2, T + 1) > 4)])) + group = np.repeat([1, 0], n) + curves = np.where(group[:, None] == 1, treated[None, :], control[None, :]) + exited = (rng.uniform(size=2 * n)[:, None] > curves).astype(int) + data = pd.DataFrame({ + "unit": np.repeat(np.arange(2 * n), T), + "time": np.tile(np.arange(1, T + 1), 2 * n), + "treated": np.repeat(group, T), + "exited": exited.ravel(), + }) + + results = DurationDiD(method="cd", n_bootstrap=200, seed=42).fit( + data, outcome="exited", unit="unit", time="time", treatment="treated", + last_pre_period=4, + ) + print(results.summary()) + print(results.to_dataframe()) # per-date ATT, CI, simultaneous band + print(results.pretest.summary()) # Algorithm 2 specification test + +Proportional hazards with a fitting window on the last two pre-treatment +dates and explicit weights: + +.. code-block:: python + + import numpy as np + import pandas as pd + from diff_diff import DurationDiD + + rng = np.random.default_rng(1) + n, T = 400, 8 + control = 0.8 * np.exp(-np.cumsum(np.r_[0.0, 0.15 + 0.02 * np.arange(2, T + 1)])) + treated = 0.6 * np.exp(-np.cumsum(np.r_[0.0, 1.5 * (0.15 + 0.02 * np.arange(2, T + 1)) + + 0.3 * (np.arange(2, T + 1) > 4)])) + group = np.repeat([1, 0], n) + curves = np.where(group[:, None] == 1, treated[None, :], control[None, :]) + data = pd.DataFrame({ + "unit": np.repeat(np.arange(2 * n), T), + "time": np.tile(np.arange(1, T + 1), 2 * n), + "treated": np.repeat(group, T), + "exited": (rng.uniform(size=2 * n)[:, None] > curves).astype(int).ravel(), + }) + + results = DurationDiD(method="ph", n_bootstrap=100, seed=7).fit( + data, outcome="exited", unit="unit", time="time", treatment="treated", + last_pre_period=4, pre_periods=[3, 4], pre_period_weights=[1, 3], + ) + print(results.coefficient) # fitted hazard ratio + print(results.pre_periods, results.pre_period_weights) + es = results.aggregate("event_study") # unified event-study container + print(es.to_dataframe()) + +Comparison with related estimators +---------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 40 35 + + * - Estimator + - Identifying assumption + - Outcome / design + * - ``DurationDiD`` + - Constant gap (CD) or ratio (PH) between the groups' *untreated hazards* + - Binary absorbing outcome, two groups, common timing + * - ``DifferenceInDifferences`` / ``TwoWayFixedEffects`` + - Parallel trends in outcome *levels* + - Any outcome; on an absorbing indicator this forces diverging hazards + * - ``ChangesInChanges`` + - Distributional (monotone outcome model) + - Continuous outcomes, 2x2 + * - ``CallawaySantAnna`` and the staggered family + - Parallel trends, possibly conditional, per cohort + - Staggered adoption; not a duration model diff --git a/docs/api/index.rst b/docs/api/index.rst index dd2917402..ad05dee06 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.duration_did_results.DurationDiDResults + diff_diff.duration_did_results.DurationDiDPretestResults diff_diff.Comparison2x2 diff_diff.StaggeredTripleDiffResults diff_diff.TWFEWeightsResult @@ -372,6 +375,7 @@ Estimators changes_in_changes lwdid dml_did + duration_did bacon Infrastructure diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 3268eaf7c..9100847e2 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -140,6 +140,10 @@ Quick Reference - 2x2 distributional effects (which quantiles moved, not just the mean) - h(u, t) monotone in a scalar unobservable; U time-invariant within groups - ATT + quantile treatment effects (bootstrap inference) + * - ``DurationDiD`` + - Binary absorbing outcome (spell ended), two groups, common timing + - Constant gap (CD) or ratio (PH) between the groups' untreated hazards + - Per-date absorption ATT + uniform average (whole-individual bootstrap bands, pretest) * - ``QDiD`` (deprecated 3.9; use ``ChangesInChanges(method="qdid")``) - 2x2 quantile-DiD comparison alongside ChangesInChanges - Additive quantile model (scale-dependent, testable restrictions) @@ -711,6 +715,65 @@ exceeding CS's per-cell convention). first_treat='first_treat', covariates=['x1', 'x2']) print(results.aggregate('event_study').to_dataframe()) +DurationDiD (Deaner & Ku 2026) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**When to use**: The outcome is a binary ABSORBING event observed over time +(a spell that ends and stays ended: reemployment, churn, discharge), there +are two groups with a common intervention date, and you want the effect on +the probability of having exited by each post-treatment date. Standard DiD +on the cumulative indicator imposes a constant gap in event probabilities, +which forces the survivors' hazards to diverge (Appendix A.1); DurationDiD +restricts the groups' *untreated hazards* instead. + +**Key features**: + +- ``method="cd"`` (constant additive untreated-hazard gap) or + ``method="ph"`` (constant hazard ratio), fitted on the pre-treatment + cumulative hazards — equal weights over every eligible pre-treatment date + by default, or a window via ``pre_periods=`` / ``pre_period_weights=`` +- Per-date absorption ATT with the paper's whole-individual bootstrap: + centered pointwise intervals and a simultaneous max-|t| band; the headline + ``att`` is the uniform average over the post-treatment dates +- ``results.pretest``: the fixed-anchor pre-treatment specification test + (Algorithm 2) — the estimator's own diagnostic; ``check_parallel_trends`` + and HonestDiD do not apply +- Every inference family is fully available or fully withheld with a named + ``inference_status`` (invalid extrapolated counterfactual curve, failed + draws, zero SE); ``last_pre_period=`` is required and never inferred + +**vs DifferenceInDifferences / TwoWayFixedEffects**: those identify off +parallel trends in outcome *levels*; on an absorbing indicator that is a +restriction on event probabilities, not hazards. **vs ChangesInChanges**: +distributional DiD for continuous outcomes, not a duration model. + +**Example**: + +.. code-block:: python + + import numpy as np + import pandas as pd + from diff_diff import DurationDiD + + rng = np.random.default_rng(3) + n, T = 300, 8 + control = 0.8 * np.exp(-np.cumsum(np.r_[0.0, 0.15 + 0.02 * np.arange(2, T + 1)])) + treated = 0.6 * np.exp(-np.cumsum(np.r_[0.0, 0.20 + 0.02 * np.arange(2, T + 1) + + 0.3 * (np.arange(2, T + 1) > 4)])) + group = np.repeat([1, 0], n) + curves = np.where(group[:, None] == 1, treated[None, :], control[None, :]) + panel = pd.DataFrame({ + "unit": np.repeat(np.arange(2 * n), T), + "time": np.tile(np.arange(1, T + 1), 2 * n), + "treated": np.repeat(group, T), + "exited": (rng.uniform(size=2 * n)[:, None] > curves).astype(int).ravel(), + }) + est = DurationDiD(method="cd", n_bootstrap=100, seed=42) + results = est.fit(panel, outcome="exited", unit="unit", time="time", + treatment="treated", last_pre_period=4) + print(results.to_dataframe()) + print(results.pretest.summary()) + Bacon Decomposition ~~~~~~~~~~~~~~~~~~~ @@ -1034,6 +1097,11 @@ estimation. The depth of support varies by estimator and variance method: - -- - -- - -- + * - ``DurationDiD`` + - N/A (no survey support) + - N/A + - -- + - -- * - ``SpilloverDiD`` - pweight only - Full (Binder TSL + Conley) diff --git a/docs/dev-status.md b/docs/dev-status.md index 96add53e5..499906b8a 100644 --- a/docs/dev-status.md +++ b/docs/dev-status.md @@ -59,6 +59,7 @@ Target: ideally < 1000 lines per module; modules ≥3000 lines are candidates fo | `datasets.py` | 1224 | Acceptable | | `rdd.py` | 1218 | Acceptable | | `staggered_aggregation.py` | 1204 | Acceptable | +| `duration_did.py` | 1239 | Acceptable — validation + numerical core + bootstrap + inference assembly in one module (results split into `duration_did_results.py`) | | `chaisemartin_dhaultfoeuille_bootstrap.py` | 1175 | Acceptable | | `conley.py` | 1140 | Acceptable | | `rdplot.py` | 1135 | Acceptable | diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index 66047f445..026dbf261 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -83,6 +83,9 @@ groups: dml_did: - diff_diff/dml_did.py - diff_diff/dml_did_results.py + duration_did: + - diff_diff/duration_did.py + - diff_diff/duration_did_results.py lwdid: - diff_diff/lwdid.py - diff_diff/lwdid_results.py @@ -831,6 +834,62 @@ sources: - path: docs/migration-4.0.md type: user_guide + # ── DurationDiD (duration_did group) ──────────────────────────────── + + diff_diff/duration_did.py: + drift_risk: medium + docs: + - path: docs/methodology/REGISTRY.md + section: "DurationDiD" + type: methodology + - path: docs/methodology/papers/deaner-ku-2026-review.md + type: methodology + note: "Source review; ship-time status and requirements checklist" + - path: docs/methodology/variance-conventions.md + section: "duration_did inventory row + L3 bullet" + type: methodology + - path: docs/api/duration_did.rst + type: api_reference + - path: README.md + section: "Estimators (one-line catalog entry)" + type: user_guide + - path: docs/references.rst + type: user_guide + - path: diff_diff/guides/llms.txt + section: "Estimators" + type: user_guide + - path: diff_diff/guides/llms-full.txt + section: "DurationDiD" + type: user_guide + - path: diff_diff/guides/llms-practitioner.txt + section: "Step 4 decision tree" + type: user_guide + - path: diff_diff/guides/llms-autonomous.txt + section: "Estimator-support matrix" + type: user_guide + - path: docs/choosing_estimator.rst + type: user_guide + - path: docs/practitioner_decision_tree.rst + type: user_guide + - path: docs/survey-roadmap.md + section: "Current Limitations" + type: user_guide + - path: docs/index.rst + section: "Supported Estimators (one-line catalog row)" + type: user_guide + + diff_diff/duration_did_results.py: + drift_risk: low + docs: + - path: docs/api/duration_did.rst + type: api_reference + - path: docs/methodology/REGISTRY.md + section: "DurationDiD" + type: methodology + - path: docs/methodology/REPORTING.md + section: "Module (DurationDiDResults rejected by type)" + type: methodology + # ── ChangesInChanges + QDiD (changes_in_changes group) ──────────── diff_diff/changes_in_changes.py: diff --git a/docs/index.rst b/docs/index.rst index c561e490c..08f7380af 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -187,6 +187,8 @@ Supported Estimators - Lee & Wooldridge (2025, 2026) rolling-transformation DiD; ``rolling='detrend'`` handles heterogeneous linear trends * - :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); Caetano et al. (2026) bad-control score via ``fit(bad_control=)`` (bad-control lane: panel only, ``cluster=`` only) + * - :class:`~diff_diff.DurationDiD` + - Deaner & Ku (2026) causal duration DiD for binary absorbing outcomes; untreated-hazard restriction (common dynamics or proportional hazards), whole-individual bootstrap bands * - :class:`~diff_diff.QDiD` - Quantile DiD comparison estimator applying DiD quantile-by-quantile (deprecated 3.9 - use :class:`~diff_diff.ChangesInChanges` with ``method="qdid"``) * - :class:`~diff_diff.RegressionDiscontinuity` diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index a2718955f..af53f6abf 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -30,6 +30,7 @@ This document provides the academic foundations and key implementation requireme - [TROP](#trop) - [HeterogeneousAdoptionDiD](#heterogeneousadoptiondid) - [ChangesInChanges (CiC) + QDiD](#changesinchanges-cic) + - [DurationDiD](#durationdid) - [SpilloverDiD](#spilloverdid) 4. [Regression Discontinuity](#regression-discontinuity) - [RegressionDiscontinuity](#regressiondiscontinuity) @@ -5799,6 +5800,257 @@ where `Q7` is the R default type-7 (linear-interpolation) quantile (numpy `metho --- +## DurationDiD + +**Primary source:** Deaner, B., & Ku, H. (2026). *Causal Duration Analysis with Diff-in-Diff*. arXiv:2405.05220v2 (working paper revision, May 2026). https://arxiv.org/abs/2405.05220v2 — reviewed in `docs/methodology/papers/deaner-ku-2026-review.md` (all page/line references below are to that review, which reconciles the paper's printed formulas). + +Two-group, common-timing difference-in-differences for a binary **absorbing** +outcome (`Y_it = 1` once the spell has ended, e.g. reemployment, churn, +discharge). Ordinary DiD on the cumulative indicator imposes a constant gap in +event *probabilities*, which mechanically forces the survivors' hazards to +diverge (Appendix A.1, review lines 484-499). `DurationDiD` restricts the two +groups' **untreated hazards** instead — a constant additive gap (`method="cd"`, +common dynamics, Equation 2.3) or a constant ratio (`method="ph"`, proportional +hazards, Equation 2.4) — fits the coefficient on the pre-treatment cumulative +hazards, imputes the treated group's counterfactual survival from the control +group (Theorem 1), and reports the absorption ATT +`tau[t] = E[Y_it - Y_it(0) | treated]` at every post-treatment date (positive = +more cumulative exit). The headline `att` is the uniform average of the +post-date effects — a library aggregate of probability effects across dates, +not a paper-defined overall ATT or a mean-duration effect. Baseline-absorbed +individuals stay in the estimand (their effects are zero under the assumptions). + +**Key implementation requirements:** + +*Assumption checks / warnings:* + +- **Absorbing binary outcomes on a fixed population (Assumption 1):** validated + (once 1, always 1 within an individual; reversals raise). Both groups must be + present; `treatment` must be constant within individual (a group indicator, + not received treatment). +- **No anticipation before the common intervention and unaffected controls + (Assumption 2):** untestable; `last_pre_period` (`tstar`, the last untreated + date) is REQUIRED and never inferred from absorption. +- **Untreated-hazard restriction (2.3 / 2.4):** on counterfactual hazards, never + on outcome levels. Tested separately by the Algorithm 2 pretest below; a + non-rejection does not establish identification. +- **Data shape:** exactly one row per (individual, date); every individual on the + same equally spaced numeric time grid (relative check + `allclose(diffs, diffs[0], rtol=1e-8, atol=0)`, at least three distinct dates: + baseline, `tstar`, one post-date); missing cells, late entry, dropout, + datetime `time`, NaN identifiers/cells, non-numeric binary columns all raise. + Baseline survival and survival at `tstar` must be positive in both groups. +- **Bootstrap validity:** independent individuals with arbitrary serial + dependence within a history (Section 3.2); `n_bootstrap=0` gives point + estimates only, `1` is rejected (a SD needs two draws), `>= 2` runs. + +*Estimator equation (Theorem 1; Equations 3.1-3.4 with the PH choice below), as implemented with the actual elapsed time `e_t = time_t - time_1` (Remark 3):* + + S_hat[k,t] = 1 - mean(Y[i,t] | G[i]=k) fixed whole-group denominator n_k + R_hat[k,t] = -log(S_hat[k,t]); D_hat[k,t] = R_hat[k,t] - R_hat[k,1]; H_hat[k,t] = D_hat[k,t] / e_t + CD: c_hat = sum_t alpha[t] (H_hat[1,t] - H_hat[2,t]); R0_hat[1,t] = R_hat[1,1] + D_hat[2,t] + e_t c_hat + PH: c_hat = sum_t alpha[t] D_hat[1,t] / D_hat[2,t]; R0_hat[1,t] = R_hat[1,1] + c_hat D_hat[2,t] + tau_hat[t] = exp(-R0_hat[1,t]) - S_hat[1,t], t > tstar + +with `alpha` the normalized fitting weights over the realized fitting set `F`. +The treated baseline `R_hat[1,1]` sits outside the PH exponent +(`S_1t(0) = S_11 (S_2t/S_21)^c`, review lines 258-266). + +*Fitting dates and weights (`fit(pre_periods=, pre_period_weights=)`):* + +- Default: every eligible pre-treatment date strictly after the baseline and at + or before `tstar`, equal weights. Eligibility: positive survival in both groups + (implied by positive survival at `tstar`) and, under PH, a positive control + cumulative-hazard increment. `pre_periods` selects a subset (an ordered list of + values of `time`, validated against the grid; duplicates, sets, scalars, + strings and baseline/post dates raise); `pre_period_weights` (requires + `pre_periods`; finite, nonnegative, not all zero) is normalized to sum to one + scale-invariantly (divided by its maximum before its sum, so weights near the + float64 limit cannot overflow); a zero weight drops that date. Eligibility + exclusions warn and renormalize the remaining weights; an empty fitting set + raises. The realized set and weights are frozen for every bootstrap draw and + echoed as `results.pre_periods` / `results.pre_period_weights` / + `results.excluded_pre_periods`. + +*Standard errors (Section 3.2; Appendix B Algorithm 1), whole-individual pooled bootstrap:* + +1. `n_bootstrap` draws of `n` indices with replacement from the pooled sample + (`numpy.random.default_rng(seed)`); complete histories travel together; + duplicates are separate sampled individuals (count-weight GEMM, bit-identical + to a per-draw loop). +2. Every draw recomputes survival, log-survival moments, the coefficient on the + frozen fitting set, the counterfactual, the post effects, the headline and the + pretest contrasts. +3. On the COMPLETE draws of a family: `vcov = cov(draws, ddof=1)`, + `se = sqrt(diag(vcov))` (derived from the covariance, so `se == sqrt(diag(vcov))` + holds exactly by construction), centered pivots `z = |draw - point| / se`, + pointwise critical value `Q_{1-alpha}(z[:,t])` and simultaneous critical + value `Q_{1-alpha}(max_t z[:,t])` with an inverse-empirical-CDF quantile + (`ceil((1-alpha) B)`-th order statistic), symmetric bands `point +/- crit * se`, + p-values as the empirical tail fractions (equality counted). The headline + family is the per-draw uniform mean of the post effects. No wild/multiplier + weights, no stratification, no percentile intervals. +4. `results.aggregate("event_study")` relays the post rows into + `EventStudyResults` (event time 0 = first post date, reference `-1`, + `n_kind="units"`, `vcov` + `vcov_index` over the post rows, `cband_*`). + +*Pre-treatment diagnostic (Appendix B Algorithm 2, fixed anchor), `results.pretest`:* + + CD: delta_hat[t] = (H_hat[1,t] - H_hat[2,t]) - (H_hat[1,tstar] - H_hat[2,tstar]) + PH: delta_hat[t] = D_hat[1,t] / D_hat[2,t] - D_hat[1,tstar] / D_hat[2,tstar] + for the interior pre-dates J = {2, ..., tstar-1}; M = max_J |delta_hat / sigma_delta|, + simultaneous band delta_hat +/- Q_{1-alpha}(M*) sigma_delta, p = mean(M* >= M) + +Same `alpha` as the effect inference (the paper's application used 60% +diagnostic coverage; not adopted). `reject` is the band rule (`M > crit`), +equivalently `p <= alpha`. + +*Edge cases:* + +- **Invalid imputed counterfactual curve (review lines 920-924):** the validity + gate is on the imputed curve `R0` itself over the WHOLE path (fitted pre-dates + included), evaluated per date in this order: `control_survival_zero` (a post + date with zero control survival; `R0 = +inf`), `counterfactual_nonfinite`, + `counterfactual_survival_above_one` (`R0 < 0`), `counterfactual_nonmonotone` + (a decreasing step, i.e. an implied negative untreated hazard), else `ok`. + Both sign comparisons use a scale-aware roundoff tolerance, `1e-12` times + the largest magnitude summed into `R0` (`R_11`, `D_2t`, `e_t c` or `c D_2t`), + floored at `1e-12`, so an exact mathematical boundary — an imputed + cumulative hazard of exactly zero when the treated group has no + pre-treatment exits, or a zero step — is never flagged on the few ulps of + roundoff that a time relabelling can flip in sign; the same tolerance + governs the `n_draws_invalid_counterfactual` count + (`results.curve_status` over all dates, `results.period_status` over the post + dates). If ANY date is not `ok`, `inference_status = "unavailable_invalid_periods"` + and the whole post-period family is withheld (headline, per-date `se/t/p/CI`, + pointwise critical values, simultaneous band, `vcov=None`); the pretest family + is unaffected. `att_by_period` and `counterfactual_survival` keep the raw + extrapolation only where it is finite and a counterfactual exists (NaN for + `control_survival_zero`, `counterfactual_nonfinite`, and any overflowed + value). A `UserWarning` (and `summary()`) names the flagged dates and the + remedy that can actually help, in three branches: a violation at a FITTED + PRE-treatment date cannot be repaired by a shorter horizon — the fit must + change (`method`, `pre_periods` / `pre_period_weights`) or the design; a + violation at a later post date admits an explicit refit on the dates at or + before the last date strictly preceding the first invalid post date + (subset the data and refit); a violation at the first post date admits no + shorter horizon with a valid counterfactual. Under PH the curve is structurally + valid (`R_11 >= 0`, `c >= 0`, `D_2` nondecreasing), so the flags are a CD + phenomenon in practice. Complete bootstrap draws whose imputed curve leaves + the domain are NOT failures (the effect is a well-defined statistic); their + count is reported as `n_draws_invalid_counterfactual` in `summary()` and + `to_dict()` as a diagnostic of extrapolation support. +- **Failed bootstrap draws:** fixed draws, no retries, no stratification. Two + complete-draw masks, one per family. Post-family failure reasons in first-match + order: `group_empty`, `zero_survival_baseline`, `zero_survival_last_pre`, + `zero_control_increment` (PH), `control_survival_zero`, + `nonfinite_counterfactual` (a non-finite `c*`, `R0*` or `S0*` — a `+inf` + coefficient yields a FINITE artifact effect, so the effect alone cannot detect + it), `nonfinite_effect`. Pretest-family reasons: `group_empty`, + `zero_survival_baseline`, `zero_survival_last_pre`, `zero_control_increment`, + `nonfinite_contrast`. Any failed draw marks that family's inference + unavailable (`unavailable_failed_draws`); counts are reported per family in + `bootstrap_failure_reasons`, the raw draw matrix is retained as a diagnostic + (statistics of the completed draws are never inference), and one `UserWarning` + names the counts and remedies. +- **Zero or non-finite SE in any column:** the whole family is withheld + (`unavailable_zero_se`): the entire SE vector is set to NaN before the single + `safe_inference` / `safe_inference_batch` gate, so every column is jointly NaN + (the helper gates per element and would otherwise keep good columns). +- **Status precedence** (first applicable label wins, pipeline order): + `disabled` > `unavailable_invalid_periods` > `unavailable_failed_draws` > + `unavailable_zero_se` > `ok`; pretest: `disabled` > + `unavailable_insufficient_pre_periods` (only two pre-dates; also a fit-time + warning) > `unavailable_ph_support` (a zero control increment at a tested date + or the anchor) > `unavailable_nonfinite_moments` > `unavailable_failed_draws` > + `unavailable_zero_se` > `ok`. `results.pretest` is always populated. +- **PH boundary:** a fitted ratio of exactly zero (no treated exits over the + fitting dates) is reported (`ph_ratio_boundary=True`) with a warning; inference + is mechanically defined but outside the interior regularity argument. +- **Zero treated post-survival** is fine (the ATT uses survival directly, no + treated post-log is needed). + +**Implementation choices / deviations (Notes):** + +- **Note:** PH coefficient is the mean of usable ratios `sum alpha[t] D1[t]/D2[t]` + (Theorem 1 direction, matching the authors' MATLAB/Stata code), not the paper's + printed Equation 3.5 (which returns the reciprocal under exact PH) nor the + repaired cumulative-increment or average-hazard least-squares slopes; the three + finite-sample choices coincide under exact PH and differ under sampling noise + (review lines 281-323). The alternatives are a `TODO.md` row. +- **Note:** Default fitting weights are equal over every eligible pre-treatment + date after the baseline; optional `pre_periods` / `pre_period_weights` (fit-time + arguments, following the library's `post_periods` / `violation_weights` + conventions) reproduce the application's "last k pre-dates with zero earlier + weights" choice. Zero-weight dates are excluded, eligibility exclusions + renormalize the remaining weights with a warning. The selectors never change + the diagnostic's scope: `J` is the review's unconditional interior set. +- **Note:** The pretest is Appendix B Algorithm 2's fixed-anchor contrast (anchor + = `tstar`, always, even when the fitting set omits it), not the main text's + moving final-window contrast (review lines 449-465); both are valid null + contrasts, only one is implemented. It uses the same `alpha` as the bands, not + the application's 60% coverage. +- **Note:** Whole-path curve-validity gate on the imputed `R0` (finite, `R0 >= 0`, + nondecreasing steps, fitted pre-dates included), with the whole post family + withheld on any violation and the branch-specific remedy above. The observed + treated hazard at `tstar` is never the anchor of that gate (the + fitted-versus-observed gap is the pretest's object). +- **Note:** Per-family complete-draw failure masks with first-match reasons; an + in-draw finite invalid curve is counted (`n_draws_invalid_counterfactual`), + not failed; a `+inf` coefficient is caught on `c*`/`R0*`/`S0*`, not on the + effect. +- **Note:** Band-based decisions: `is_significant` and `pretest.reject` follow the + paper's "band excludes zero" rule, which is exactly `p_value <= alpha` under the + inverse-empirical-CDF quantile (the empirical tail fraction with equality + counted). The order-statistic index `ceil((1-alpha) B)` is evaluated with a + `1e-9` tie guard so a product within floating-point noise of an integer + selects that integer (the `utils._frac_gt` convention). Finite-bootstrap + discreteness: with `B` draws the p-value has resolution `1/B`. +- **Note:** Equally spaced numeric time grid required in this version (elapsed + durations are used internally, so unequal spacing is a later relaxation, not a + formula change). +- **Note:** `n_bootstrap=0` = point estimates with joint-NaN inference (library + convention shared with CiC/ContinuousDiD); `n_bootstrap=1` rejected. +- **Note:** `n_units` = individuals (the resampling unit), `n_obs` = panel rows + (`n_units * n_periods`, the library-wide "observations" meaning printed by + `summary()`); the event-study `n`/`n_kind` use `n_units`/`"units"`. +- **Note:** Weak-support warning: when the SURVIVOR COUNT backing a used + log-survival moment (a group's survivors at the baseline, at `tstar`, at a + fitting date, or the control group at a post date) is positive but below + `5` individuals, `fit()` warns "weak numerical support". The count rule (a + survival proportion is never below `1/n_group`, so a proportion threshold + would be unreachable) and the constant are library choices — the paper + declines to supply a cutoff (review lines 917-918) — and change no behavior. +- **Note:** M-080 unit auto-cluster is satisfied by construction: the bootstrap + resamples individuals. No `cluster=`, `vcov_type=`, survey design, covariates, + staggered adoption, censoring, or repeated cross-sections in this version + (the review's deferred extensions). +- **Note:** `DiagnosticReport` and `BusinessReport` reject `DurationDiDResults` + by type (their batteries are keyed to mean-outcome parallel-trends + diagnostics; a `TODO.md` row tracks admission); `practitioner_next_steps` has a + dedicated handler with a hazard-restriction assumptions step and an + anticipation placebo that truncates the frame before refitting one date earlier + (emitted only with at least three pre-dates). HonestDiD does not apply. + +**Reference implementation(s):** Authors' MATLAB `durationDiD.m` and Stata +`durationdid.ado` at the pinned commit (static audit only in the review; not +executed, not vendored). Differences adopted deliberately: the baseline row is +excluded from the moments (Stata's default `burnin=1` includes it), zero SDs are +never replaced by `1e-100`, and the pretest statistic is `max|delta/SD|` rather +than `|max(delta/SD)|`. + +**Requirements checklist:** +- [x] Balanced individual-panel validation and explicit exit-ATT estimand +- [x] CD weighted gap and PH mean-ratio coefficient with baseline normalization; optional fitting dates and weights +- [x] Whole-individual bootstrap with complete nuisance re-estimation +- [x] Coherent centered-bootstrap pointwise, simultaneous, and scalar inference +- [x] Separate fixed-anchor pre-treatment diagnostics and unavailable-test states +- [x] Domain, invalid-curve, failed-draw, and joint-NaN inference handling +- [x] `BaseEstimator`/results/serialization/event-study integration and documentation +- [x] Regression scenarios (`tests/test_duration_did.py`, `tests/test_methodology_duration_did.py`) + +--- + # Diagnostics and Sensitivity ## PlaceboTests diff --git a/docs/methodology/REPORTING.md b/docs/methodology/REPORTING.md index bf11dc8d5..4b35d469f 100644 --- a/docs/methodology/REPORTING.md +++ b/docs/methodology/REPORTING.md @@ -2,7 +2,7 @@ This document records the methodology choices embedded in `BusinessReport` and `DiagnosticReport` — the convenience layer that -produces plain-English stakeholder narratives from any diff-diff result. +produces plain-English stakeholder narratives from a diff-diff estimator result (every scalar estimator result except the two rejected by type: `EventStudyResults` surfaces and `DurationDiDResults` — see the constructor rejection branches). Methodology for estimators lives in `REGISTRY.md`. This file is the single source for reporting-layer decisions; `REGISTRY.md` cross-links @@ -15,7 +15,7 @@ here rather than duplicating content. `DiagnosticReportResults`. Both modules dispatch by `type(results).__name__` lookup to avoid -circular imports across the 16 result classes. They do no estimator +circular imports across the result classes named in `_APPLICABILITY` / the per-class handlers. `DurationDiDResults` (Deaner & Ku 2026) is rejected by type: its identification is an untreated-hazard restriction on an absorbing outcome, so the mean-outcome parallel-trends battery and narrative do not apply — use `results.summary()`, `results.pretest` and `results.aggregate("event_study")`; admission is tracked in `TODO.md`. They do no estimator fitting; every effect, SE, p-value, CI, and sensitivity bound is either read from the fitted result, derived from the result's own post-fit `aggregate('event_study')` surface, or produced by an @@ -89,7 +89,7 @@ not new inference. The BusinessReport and DiagnosticReport schemas both carry a top-level `target_parameter` block that names what scalar the -headline number actually represents. The 16 result classes have +headline number actually represents. The dispatched result classes have meaningfully different estimands — a stakeholder reading `overall_att = -0.0214` on a Callaway-Sant'Anna fit cannot tell whether that is the simple-weighted average across `ATT(g,t)` diff --git a/docs/methodology/papers/deaner-ku-2026-review.md b/docs/methodology/papers/deaner-ku-2026-review.md index 36aba8649..98e5b589c 100644 --- a/docs/methodology/papers/deaner-ku-2026-review.md +++ b/docs/methodology/papers/deaner-ku-2026-review.md @@ -15,8 +15,9 @@ The reviewed version was submitted May 25, 2026; its title page is dated May 26, This is not evidence of acceptance or journal publication; the citation above deliberately identifies the reviewed working paper revision. -This document is a methodology foundation, not a shipped estimator. The -maintainer-approved first implementation is a two-group `DurationDiD` with common +This document is the methodology foundation for the shipped `DurationDiD` +estimator (`diff_diff/duration_did.py`; production registry entry in +`REGISTRY.md`). The maintainer-approved first implementation is a two-group `DurationDiD` with common treatment timing, common dynamics and proportional hazards, individual bootstrap inference, and pre-treatment diagnostics. Covariate adjustment and staggered adoption are deferred from that implementation. They, and all other appendices, @@ -65,9 +66,10 @@ digests were verified against the cached files. ## Methodology Registry Entry -This prospective entry follows [REGISTRY.md](../REGISTRY.md)'s format. Keep it -here until the estimator ships; no public API, catalog, or production registry -entry is added by this review. +This entry, written in [REGISTRY.md](../REGISTRY.md)'s format before the +estimator existed, was transplanted into the production registry when +`DurationDiD` shipped (with the implemented conventions recorded there as +Notes). It is kept here as the review record. ## DurationDiD @@ -162,14 +164,18 @@ numerical equivalence have not been established. **Requirements checklist:** -- [ ] Balanced individual-panel validation and explicit exit-ATT estimand. -- [ ] CD weighted gap and PH mean-ratio coefficient with baseline normalization. -- [ ] Whole-individual bootstrap with complete nuisance re-estimation. -- [ ] Coherent centered-bootstrap pointwise, simultaneous, and scalar inference. -- [ ] Separate fixed-anchor pre-treatment diagnostics and unavailable-test states. -- [ ] Domain, invalid-curve, failed-draw, and joint-NaN inference handling. -- [ ] `BaseEstimator`/results/serialization/event-study integration and documentation. -- [ ] Regression scenarios and independent reference checks specified below. +- [x] Balanced individual-panel validation and explicit exit-ATT estimand. +- [x] CD weighted gap and PH mean-ratio coefficient with baseline normalization. +- [x] Whole-individual bootstrap with complete nuisance re-estimation. +- [x] Coherent centered-bootstrap pointwise, simultaneous, and scalar inference. +- [x] Separate fixed-anchor pre-treatment diagnostics and unavailable-test states. +- [x] Domain, invalid-curve, failed-draw, and joint-NaN inference handling. +- [x] `BaseEstimator`/results/serialization/event-study integration and documentation. +- [x] Regression scenarios and independent reference checks specified below. + +All eight items shipped with the `DurationDiD` estimator PR (fitting dates and +weights, `pre_periods`/`pre_period_weights`, were added at the maintainer's +request; the tutorial notebook is deferred and tracked in `TODO.md`). ## Identification and estimator derivation @@ -888,7 +894,7 @@ spells, covariates, staggered adoption, survey weights, and arbitrary clustering in this first interface rather than silently interpreting them. Use `DurationDiD(BaseEstimator)` and `DurationDiDResults(BaseResults)` under -current 3.x conventions; no interface is implemented in this PR. The time path +current 3.x conventions (implemented in the estimator PR that followed this review). The time path is primary. A proposed headline scalar is the uniform average of explicitly reported post-period absorption ATTs, with those same weights applied within every bootstrap draw. This is an average of probability effects across dates, @@ -964,8 +970,9 @@ preserved. No Rust work is necessary for this documentation foundation. ### Tuning Parameters -All defaults in this table are recommendations for subsequent estimator design, -not parameters currently available in diff-diff. +All defaults in this table were recommendations for the estimator design; the +shipped `DurationDiD` follows them except where its REGISTRY.md Notes record a +different convention (fitting dates and weights are `fit()` arguments). | Parameter / decision | Type | Proposed default | Basis / restriction | |---|---|---|---| @@ -1034,9 +1041,9 @@ CD post absorption effects: [-0.01044616 -0.01754019] PH mean/increment-slope/average-slope: [2.5 2.9 2.692308] ``` -The later estimator PR should test these identities on admissible individual -panels and add the following behavioral scenarios, with requirements still -unchecked above: +The estimator PR tests these identities on admissible individual panels and +covers the following behavioral scenarios (`tests/test_duration_did.py`, +`tests/test_methodology_duration_did.py`): - No treatment effect under both models, unequal initial survival, non-unit PH ratio, known exit-effect sign, and finite-sample PH choices that differ. diff --git a/docs/methodology/variance-conventions.md b/docs/methodology/variance-conventions.md index 1e020181f..9ee376549 100644 --- a/docs/methodology/variance-conventions.md +++ b/docs/methodology/variance-conventions.md @@ -41,6 +41,7 @@ produced wrong figures three separate times while this inventory was drafted). | `dml_did` | — | None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None | **legitimate** | L3: Chang (2020) Thm 2 augmented-score plug-in variance (per-unit influence function; normal-theory safe_inference on no-design fits; survey_design=/cluster= fits use the design-based CR1/weighted-IF per-cell variance — replicate designs the IF-reweighting per-cell variance — with df=df_survey t-inference outside the shared CR1 sandwich) | | `dml_did_bad_control` | — | None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None | **legitimate** | L3: Caetano et al. (2026) Eq. 11 augmented-score plug-in variance (same psi_bar - D*theta/pi centering as Chang Thm 2; per-unit influence function; normal-theory safe_inference on no-design fits; cluster= fits use the CR1 per-cell variance with df=df_survey t-inference) and the per-cell ATT_X diagnostic tuple sharing the same branch and df (one extra safe_inference call per retained cell) | | `dml_did_rcs` | — | None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None | **legitimate** | L3: Chang (2020) Thm 2 lambda-corrected augmented-score plug-in variance (Case 2; per-observation influence function; normal-theory safe_inference on no-design fits; survey_design=/cluster= fits use the design-based CR1/weighted-IF per-cell variance — replicate designs the IF-reweighting per-cell variance — with df=df_survey t-inference outside the shared CR1 sandwich) | +| `duration_did` | — | None, None | **legitimate** | L3: Deaner & Ku (2026) whole-individual pooled bootstrap (centered absolute-deviation pointwise and simultaneous bands); no CR1 sandwich, normal-theory safe_inference gate only | cr1_k is the sorted multiset of K_reference counts reaching the shared clustered CR1 denominator — visible columns + the signed cluster_k_adjustment (linalg._compute_robust_vcov_numpy with @@ -130,9 +131,13 @@ output). the library-wide v4 target — bit-identical, no numbers moved), with "residual"/"normal" as opt-ins; the degenerate lanes (unclustered refit, RA G<=1, saturated early return) keep literal df=None under every value. -- **L3 — CallawaySantAnna / DMLDiD / TwoStageDiD / ImputationDiD (default)**: - different variance theory (influence functions / Chang (2020) augmented-score - plug-in / two-stage / BJS imputation), never the shared CR1 sandwich. CS is +- **L3 — CallawaySantAnna / DMLDiD / TwoStageDiD / ImputationDiD (default) / + DurationDiD**: different variance theory (influence functions / Chang (2020) + augmented-score plug-in / two-stage / BJS imputation / Deaner & Ku (2026) + whole-individual pooled bootstrap with centered absolute-deviation bands), + never the shared CR1 sandwich. DurationDiD is bootstrap-only: its single + `safe_inference` + `safe_inference_batch` gate passes `df=None` and the + centered-bootstrap p-values/intervals override the normal-theory values. CS is anchored to Stata csdid outright; DMLDiD's panel lane to DoubleML at machine precision, while its repeated-cross-section lane (panel=False) is characterization-anchored only (DoubleML's RCS score differs and omits the diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index 871d5cc30..57c5b6495 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -536,7 +536,7 @@ At a Glance What About the Other Estimators? -------------------------------- -diff-diff has 24 estimators covering advanced scenarios: Sun-Abraham for +diff-diff has many more estimators (the full roster is the API catalog) 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. @@ -564,6 +564,16 @@ The six scenarios above cover the most common business use cases. influence-function inference, not the exact t. Compare ``rolling='demean'`` vs ``rolling='detrend'`` as a built-in specification robustness check. +- **Is the outcome a spell that ends and stays ended (churn, reemployment, discharge)?** → :class:`~diff_diff.DurationDiD` (Deaner & Ku 2026) + + Causal duration DiD for a binary absorbing outcome in a two-group, + common-timing design. The identifying restriction is on the groups' + *untreated hazards* (``method="cd"`` additive gap or ``method="ph"`` + ratio), not on outcome levels; the per-date absorption ATT comes with + whole-individual bootstrap pointwise and simultaneous bands and a + fixed-anchor pre-treatment specification test (``results.pretest``). + ``last_pre_period=`` (the last untreated date) is required. + - **Need flexible / high-dimensional covariate adjustment?** → :class:`~diff_diff.DMLDiD` (Chang 2020) Double/debiased machine learning DiD: per-(g, t)-cell cross-fitted diff --git a/docs/references.rst b/docs/references.rst index 13f0fcafa..29c162e93 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -331,6 +331,13 @@ Rolling-Transformation DiD (Lee-Wooldridge) Exact small-sample inference layer: collapsed cross-sectional regressions with exact ``t`` reference distributions (valid down to a single treated unit), HC3 and randomization-inference alternatives, and the composite-outcome aggregate regression for staggered designs. Reference Stata package ``lwdid`` (Hur, Lee & Wooldridge; SSC s459672), whose MIT-licensed ancillary datasets back ``load_prop99()`` and ``load_walmart()``. Paper review on file at ``docs/methodology/papers/lee-wooldridge-2026-review.md``. +Causal Duration DiD +------------------- + +- **Deaner, B., & Ku, H. (2026).** "Causal Duration Analysis with Diff-in-Diff." arXiv:2405.05220v2 (working paper revision, May 2026). https://arxiv.org/abs/2405.05220v2 + + Primary source for the ``DurationDiD`` estimator: two-group, common-timing difference-in-differences for a binary absorbing outcome under a restriction on the groups' untreated hazards (common dynamics or proportional hazards, Theorem 1), the whole-individual pooled bootstrap with centered pointwise and simultaneous bands (Appendix B, Algorithm 1), and the fixed-anchor pre-treatment specification test (Algorithm 2). Paper review on file at ``docs/methodology/papers/deaner-ku-2026-review.md``. + Changes-in-Changes / Distributional DiD --------------------------------------- diff --git a/docs/survey-roadmap.md b/docs/survey-roadmap.md index 84607a4f0..4af07e847 100644 --- a/docs/survey-roadmap.md +++ b/docs/survey-roadmap.md @@ -271,6 +271,7 @@ the limitation and suggested alternative. | Estimator | Limitation | Alternative | |-----------|-----------|-------------| | 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. | +| DurationDiD | Any `survey_design` / sampling weights / `cluster=` | No weight or cluster argument exists (a bare `TypeError: unexpected keyword argument`). Deaner & Ku (2026) derive identification and the whole-individual bootstrap for independent individuals; survey and cluster inference are DEFERRED pending a derivation. | | 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 | | BaconDecomposition | Replicate weights | Diagnostic only, no inference | diff --git a/tests/helpers/results_foundation.py b/tests/helpers/results_foundation.py index c7168bb81..09b3dbff7 100644 --- a/tests/helpers/results_foundation.py +++ b/tests/helpers/results_foundation.py @@ -75,7 +75,26 @@ def make_constructed_diagnostics() -> Dict[str, Any]: n_obs=50, ) + duration_pretest = diff_diff.DurationDiDPretestResults( + method="cd", + periods=np.array([2, 3]), + anchor_period=4, + contrast=np.array([0.01, -0.02]), + se=np.array([0.05, 0.04]), + band_lower=np.array([-0.1, -0.11]), + band_upper=np.array([0.12, 0.07]), + crit_value=2.2, + statistic=0.5, + p_value=0.8, + reject=False, + alpha=0.05, + n_bootstrap=99, + n_bootstrap_valid=99, + status="ok", + ) + instances: Dict[str, Any] = { + "DurationDiDPretestResults": duration_pretest, "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..177ae8618 100644 --- a/tests/test_base_estimator.py +++ b/tests/test_base_estimator.py @@ -94,6 +94,7 @@ def _discover(): "StackedDiD": {"control_group": "not_a_mode"}, "LPDiD": {"alpha": 5.0}, "ChangesInChanges": {"alpha": 5.0}, + "DurationDiD": {"method": "not_a_method"}, "QDiD": {"alpha": 5.0}, "HeterogeneousAdoptionDiD": {"design": "not_a_design"}, "RegressionDiscontinuity": {"kernel": "not_a_kernel"}, diff --git a/tests/test_diagnostic_marker.py b/tests/test_diagnostic_marker.py index 1b43397cc..c390abcec 100644 --- a/tests/test_diagnostic_marker.py +++ b/tests/test_diagnostic_marker.py @@ -48,6 +48,7 @@ "StuteJointResult", "HADPretestReport", "DiagnosticReportResults", + "DurationDiDPretestResults", ] # Representative ESTIMATOR results: marked with BaseResults, never Diagnostic. diff --git a/tests/test_doc_snippets.py b/tests/test_doc_snippets.py index da2d06cc1..79586aff3 100644 --- a/tests/test_doc_snippets.py +++ b/tests/test_doc_snippets.py @@ -33,6 +33,7 @@ "api/utils.rst", "api/prep.rst", "api/two_stage.rst", + "api/duration_did.rst", "api/bacon.rst", "api/visualization.rst", "api/honest_did.rst", @@ -433,9 +434,13 @@ def _restore_datasets_module(): "test_id, code, skip_reason", [pytest.param(tid, c, s, id=tid) for tid, c, s in _CASES], ) -def test_doc_snippet(test_id: str, code: str, skip_reason: Optional[str]): +def test_doc_snippet(test_id: str, code: str, skip_reason: Optional[str], tmp_path, monkeypatch): """Execute a documentation code snippet and assert no API/runtime errors. + Runs in a temporary working directory so ``savefig``-bearing snippets + (visualization / honest_did / pretrends pages) never write PNGs into the + repository root. + ``os.environ`` is snapshot/restored around the exec: snippets may legitimately mutate the environment (e.g. the troubleshooting backend-override block sets ``DIFF_DIFF_BACKEND='python'``), and an @@ -446,6 +451,7 @@ def test_doc_snippet(test_id: str, code: str, skip_reason: Optional[str]): if skip_reason: pytest.skip(skip_reason) + monkeypatch.chdir(tmp_path) ns = _build_namespace() env_snapshot = os.environ.copy() try: diff --git a/tests/test_duration_did.py b/tests/test_duration_did.py new file mode 100644 index 000000000..89c1307cd --- /dev/null +++ b/tests/test_duration_did.py @@ -0,0 +1,991 @@ +"""API, validation, inference-contract and results-surface tests for DurationDiD. + +Covers constructor validation, the sklearn-style parameter surface, fit +input validation (panel hygiene, binary/absorbing outcome, time grid, +fitting selectors), the joint-NaN inference contract, bootstrap seeding and +the per-family draw-failure policy, the curve-validity gate, every warning, +the results API (summary/to_dict/to_dataframe/aggregate), and the reporting +consumers' contract. Paper identities live in +``tests/test_methodology_duration_did.py``. +""" + +from __future__ import annotations + +import json +import math +import warnings + +import numpy as np +import pandas as pd +import pytest + +import diff_diff.duration_did as dd_module +from diff_diff import ( + BusinessReport, + DiagnosticReport, + DurationDiD, + DurationDiDPretestResults, + DurationDiDResults, +) +from diff_diff.practitioner import practitioner_next_steps +from diff_diff.results_base import EventStudyResults +from tests.conftest import assert_nan_inference + +# --------------------------------------------------------------------------- +# Panel builders +# --------------------------------------------------------------------------- + + +def build_from_survivors(n1, s1, n2, s2, times=None): + """Long absorbing panel from per-date survivor COUNTS (treated, control). + + Unit ``i`` of a group survives date ``t`` iff ``i < survivors[t]`` (so + counts must be nonincreasing). ``n`` above the first count means + baseline absorption. + """ + n_periods = len(s1) + times = np.arange(1, n_periods + 1) if times is None else np.asarray(times) + rows = [] + uid = 0 + for g, n, surv in ((1, n1, s1), (0, n2, s2)): + for i in range(n): + for t in range(n_periods): + rows.append((uid, times[t], g, int(i >= surv[t]))) + uid += 1 + return pd.DataFrame(rows, columns=["unit", "time", "treated", "exited"]) + + +def micro_panel(times=None): + """The hand-computed micro panel: CD holds exactly with exp(-c) = 2/3.""" + return build_from_survivors(30, [18, 9, 4, 1, 0], 10, [8, 6, 4, 3, 2], times=times) + + +def simulate_panel(n=400, n_periods=8, tstar_idx=3, c=0.05, beta=0.3, seed=0, method="cd"): + """Exact-population absorbing DGP (one uniform per unit).""" + rng = np.random.default_rng(seed) + s = np.arange(2, n_periods + 1) + lam2 = 0.15 + 0.02 * s + lam1 = lam2 + c if method == "cd" else c * lam2 + lam1_fact = lam1.copy() + lam1_fact[tstar_idx:] += beta + S2 = 0.8 * np.exp(-np.concatenate([[0.0], np.cumsum(lam2)])) + S1 = 0.6 * np.exp(-np.concatenate([[0.0], np.cumsum(lam1_fact)])) + u = rng.uniform(size=2 * n) + g = np.repeat([1, 0], n) + S_fact = np.where(g[:, None] == 1, S1[None, :], S2[None, :]) + Y = (u[:, None] > S_fact).astype(int) + return pd.DataFrame( + { + "unit": np.repeat(np.arange(2 * n), n_periods), + "time": np.tile(np.arange(1, n_periods + 1), 2 * n), + "treated": np.repeat(g, n_periods), + "exited": Y.ravel(), + } + ) + + +FIT_KW = dict(outcome="exited", unit="unit", time="time", treatment="treated") + + +def fit_quiet(est, df, **kw): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return est.fit(df, **FIT_KW, **kw) + + +@pytest.fixture(scope="module") +def sim_df(): + return simulate_panel() + + +@pytest.fixture(scope="module") +def fitted(sim_df): + return fit_quiet(DurationDiD(n_bootstrap=60, seed=3), sim_df, last_pre_period=4) + + +# --------------------------------------------------------------------------- +# Constructor validation and parameter surface +# --------------------------------------------------------------------------- + + +class TestConstructorValidation: + @pytest.mark.parametrize("bad", ["not_a_method", "CD", 1, None]) + def test_method_rejected(self, bad): + with pytest.raises(ValueError, match="method must be 'cd' or 'ph'"): + DurationDiD(method=bad) + + @pytest.mark.parametrize("bad", [-1, 2.5, None, True]) + def test_n_bootstrap_shared_message(self, bad): + with pytest.raises(ValueError, match="n_bootstrap must be a non-negative integer"): + DurationDiD(n_bootstrap=bad) + + def test_n_bootstrap_one_rejected(self): + with pytest.raises(ValueError, match="at least 2"): + DurationDiD(n_bootstrap=1) + + def test_n_bootstrap_zero_and_two_legal(self): + assert DurationDiD(n_bootstrap=0).n_bootstrap == 0 + assert DurationDiD(n_bootstrap=2).n_bootstrap == 2 + + @pytest.mark.parametrize("bad", [0.0, 1.0, 5.0, "0.05", True]) + def test_alpha_rejected(self, bad): + with pytest.raises(ValueError, match="alpha"): + DurationDiD(alpha=bad) + + @pytest.mark.parametrize("bad", [-1, 1.5, "7", True]) + def test_seed_rejected(self, bad): + with pytest.raises(ValueError, match="seed"): + DurationDiD(seed=bad) + + +class TestParamSurface: + def test_get_params_round_trip(self): + est = DurationDiD(method="ph", n_bootstrap=10, alpha=0.1, seed=5) + params = est.get_params() + assert params == {"method": "ph", "n_bootstrap": 10, "alpha": 0.1, "seed": 5} + assert DurationDiD(**params).get_params() == params + + def test_set_params_transactional(self): + est = DurationDiD() + with pytest.raises(ValueError): + est.set_params(method="bogus", alpha=0.2) + assert est.method == "cd" and est.alpha == 0.05 + est.set_params(method="ph") + assert est.method == "ph" + + def test_selectors_are_fit_time_not_params(self): + assert "pre_periods" not in DurationDiD().get_params() + assert "last_pre_period" not in DurationDiD().get_params() + + +# --------------------------------------------------------------------------- +# Fit validation +# --------------------------------------------------------------------------- + + +class TestFitValidation: + def test_missing_column(self): + df = micro_panel() + with pytest.raises(ValueError, match="outcome column 'nope' not found"): + DurationDiD(n_bootstrap=0).fit(df, "nope", "unit", "time", "treated", last_pre_period=3) + + def test_missing_unit_identifier(self): + df = micro_panel().astype({"unit": float}) + df.loc[df.index[:2], "unit"] = np.nan + with pytest.raises(ValueError, match="unit column 'unit' contains missing values"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_datetime_time_rejected(self): + df = micro_panel() + df["time"] = pd.to_datetime("2020-01-01") + pd.to_timedelta(df["time"], unit="D") + with pytest.raises(ValueError, match="must be numeric; convert datetime"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_string_time_rejected(self): + df = micro_panel() + df["time"] = df["time"].astype(str) + with pytest.raises(ValueError, match="time column 'time' must be numeric"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + @pytest.mark.parametrize("n_dates", [0, 1, 2]) + def test_fewer_than_three_dates(self, n_dates): + df = micro_panel() + df = df[df["time"] <= n_dates] if n_dates else df.iloc[0:0] + with pytest.raises(ValueError, match="at least three distinct time periods"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=1) + + def test_duplicate_cells(self): + df = pd.concat([micro_panel(), micro_panel().iloc[:1]]) + with pytest.raises(ValueError, match="exactly one row per"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_unbalanced_panel(self): + df = micro_panel().iloc[1:] + with pytest.raises(ValueError, match="Unbalanced panel"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_unequal_spacing(self): + df = micro_panel(times=[1, 2, 3, 5, 6]) + with pytest.raises(ValueError, match="equally spaced"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_small_magnitude_unequal_spacing_rejected(self): + # Relative check with atol=0: numpy's default atol=1e-8 would accept + # a 100% spacing difference on a denormal-scale grid. + df = micro_panel(times=[0.0, 1e-12, 3e-12, 4e-12, 5e-12]) + with pytest.raises(ValueError, match="equally spaced"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3e-12) + + def test_string_outcome_rejected(self): + df = micro_panel() + df["exited"] = df["exited"].map({0: "no", 1: "yes"}) + with pytest.raises(ValueError, match="outcome column 'exited' must be a numeric 0/1"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_string_treatment_rejected(self): + df = micro_panel() + df["treated"] = df["treated"].map({0: "control", 1: "treated"}) + with pytest.raises(ValueError, match="treatment column 'treated' must be a numeric"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + @pytest.mark.parametrize("col", ["exited", "treated"]) + def test_numeric_string_columns_rejected(self, col): + # Object-dtype "0"/"1" is never coerced (REGISTRY: non-numeric binary + # columns raise). + df = micro_panel() + df[col] = df[col].astype(str) + with pytest.raises(ValueError, match=f"column '{col}' must be a numeric 0/1 column"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_bool_columns_accepted(self): + df = micro_panel() + df["exited"] = df["exited"].astype(bool) + df["treated"] = df["treated"].astype(bool) + r = fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + assert r.coefficient == pytest.approx(math.log(1.5)) + + def test_nan_outcome_cell_rejected(self): + # utils.validate_binary alone strips NaN before its membership test. + df = micro_panel().astype({"exited": float}) + df.loc[df.index[7], "exited"] = np.nan + with pytest.raises(ValueError, match="outcome column 'exited' contains 1 missing"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_nan_treatment_rejected(self): + df = micro_panel().astype({"treated": float}) + df.loc[df.index[0], "treated"] = np.nan + with pytest.raises(ValueError, match="treatment column 'treated' contains 1 missing"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_nonbinary_outcome(self): + df = micro_panel() + df.loc[df.index[0], "exited"] = 2 + with pytest.raises(ValueError, match="outcome must be binary"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_absorption_reversal(self): + df = micro_panel() + # unit 29 (treated, absorbed at date 1) -> revive at date 3 + df.loc[(df["unit"] == 29) & (df["time"] == 3), "exited"] = 0 + with pytest.raises(ValueError, match="absorbing"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_treatment_varies_within_unit(self): + df = micro_panel() + df.loc[(df["unit"] == 0) & (df["time"] == 5), "treated"] = 0 + with pytest.raises(ValueError, match="fixed 0/1 group indicator"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_absent_group(self): + df = micro_panel() + df["treated"] = 1 + with pytest.raises(ValueError, match="both groups are required"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + @pytest.mark.parametrize("bad", [1, 5, 2.5, "3"]) + def test_last_pre_period_bad(self, bad): + with pytest.raises(ValueError, match="last_pre_period"): + fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=bad) + + def test_baseline_zero_survival(self): + df = build_from_survivors(5, [0, 0, 0, 0, 0], 10, [8, 6, 4, 3, 2]) + with pytest.raises(ValueError, match="zero survival at the baseline"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_treated_exhausted_before_treatment(self): + df = build_from_survivors(10, [8, 4, 0, 0, 0], 10, [8, 6, 4, 3, 2]) + with pytest.raises(ValueError, match="treated group is fully absorbed"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + def test_control_exhausted_before_treatment(self): + df = build_from_survivors(10, [8, 6, 4, 3, 2], 10, [8, 4, 0, 0, 0]) + with pytest.raises(ValueError, match="control group is fully absorbed"): + fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + + @pytest.mark.parametrize( + "pre, msg", + [ + ([9], "Pre-period '9' not found"), + ([1], "strictly after the baseline"), + ([4], "strictly after the baseline"), + ([2, 2], "duplicate"), + ([], "at least one"), + ], + ) + def test_pre_periods_rejected(self, pre, msg): + with pytest.raises(ValueError, match=msg): + fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3, pre_periods=pre) + + @pytest.mark.parametrize("bad", [3, 2.0, "23", b"23", {"a": 1}]) + def test_pre_periods_scalar_or_string_rejected(self, bad): + # A string would otherwise be split character-wise into different dates. + with pytest.raises(ValueError, match="pre_periods must be a list of"): + fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3, pre_periods=bad) + + @pytest.mark.parametrize("bad", [1.0, 2, "11"]) + def test_pre_period_weights_scalar_or_string_rejected(self, bad): + with pytest.raises(ValueError, match="pre_period_weights must be a list of"): + fit_quiet( + DurationDiD(n_bootstrap=0), + micro_panel(), + last_pre_period=3, + pre_periods=[2, 3], + pre_period_weights=bad, + ) + + def test_pre_periods_set_rejected(self): + # Sets have no positional order to align with pre_period_weights. + with pytest.raises(ValueError, match="ordered list"): + fit_quiet( + DurationDiD(n_bootstrap=0), + micro_panel(), + last_pre_period=3, + pre_periods={3, 2}, + pre_period_weights=[1, 3], + ) + with pytest.raises(ValueError, match="ordered list"): + fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3, pre_periods={2}) + + def test_pre_periods_non_numeric_entries_rejected(self): + with pytest.raises(ValueError, match="pre_periods must be a list of numeric"): + fit_quiet( + DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3, pre_periods=["a"] + ) + + @pytest.mark.parametrize( + "pre, w, msg", + [ + (None, [1.0], "requires pre_periods"), + ([2, 3], [1.0], "one entry per pre_periods"), + ([2, 3], [1.0, -1.0], "nonnegative"), + ([2, 3], [1.0, np.nan], "finite"), + ([2, 3], [0.0, 0.0], "not all be zero"), + ], + ) + def test_pre_period_weights_rejected(self, pre, w, msg): + with pytest.raises(ValueError, match=msg): + fit_quiet( + DurationDiD(n_bootstrap=0), + micro_panel(), + last_pre_period=3, + pre_periods=pre, + pre_period_weights=w, + ) + + +# --------------------------------------------------------------------------- +# Fitting dates and weights +# --------------------------------------------------------------------------- + + +class TestFittingPeriods: + def test_default_equals_explicit_equal_weights(self): + df = micro_panel() + r0 = fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + r1 = fit_quiet( + DurationDiD(n_bootstrap=0), + df, + last_pre_period=3, + pre_periods=[2, 3], + pre_period_weights=[1, 1], + ) + assert r0.coefficient == r1.coefficient + np.testing.assert_array_equal(r0.att_by_period, r1.att_by_period) + np.testing.assert_array_equal(r0.pre_periods, [2, 3]) + np.testing.assert_allclose(r0.pre_period_weights, [0.5, 0.5]) + + def test_unequal_weights_change_coefficient_by_hand(self): + df = micro_panel() + r = fit_quiet( + DurationDiD(method="ph", n_bootstrap=0), + df, + last_pre_period=3, + pre_periods=[2, 3], + pre_period_weights=[3, 1], + ) + ratios = np.array([2.4094208, 2.1699250]) + assert r.coefficient == pytest.approx(0.75 * ratios[0] + 0.25 * ratios[1], abs=1e-6) + np.testing.assert_allclose(r.pre_period_weights, [0.75, 0.25]) + + def test_zero_weight_date_excluded(self): + df = micro_panel() + r = fit_quiet( + DurationDiD(n_bootstrap=0), + df, + last_pre_period=3, + pre_periods=[2, 3], + pre_period_weights=[0, 1], + ) + np.testing.assert_array_equal(r.pre_periods, [3]) + assert r.excluded_pre_periods == {2: "zero_weight"} + # Fitting on the anchor alone: c = H_13 - H_23 at date 3. + assert r.coefficient == pytest.approx(math.log(1.5)) + + def test_weight_normalization_is_scale_invariant(self, sim_df): + # Weights near the float64 limit must not overflow the sum to inf and + # silently normalize to zeros. + kw = dict(last_pre_period=4, pre_periods=[2, 3, 4]) + a = fit_quiet( + DurationDiD(n_bootstrap=20, seed=1), sim_df, **kw, pre_period_weights=[1, 1, 1] + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + b = DurationDiD(n_bootstrap=20, seed=1).fit( + sim_df, **FIT_KW, **kw, pre_period_weights=[1e308, 1e308, 1e308] + ) + np.testing.assert_array_equal(a.pre_periods, b.pre_periods) + np.testing.assert_array_equal(a.pre_period_weights, b.pre_period_weights) + assert a.coefficient == b.coefficient + np.testing.assert_array_equal(a.att_by_period, b.att_by_period) + assert a.se == b.se + assert b.pre_period_weights.sum() == pytest.approx(1.0) + + def test_role_columns_named_like_internals(self): + # Column names that collide with internal temporaries are fine. + df = micro_panel().rename( + columns={"unit": "_y", "time": "_g", "exited": "y", "treated": "g"} + ) + r = DurationDiD(n_bootstrap=0).fit( + df, outcome="y", unit="_y", time="_g", treatment="g", last_pre_period=3 + ) + assert r.coefficient == pytest.approx(math.log(1.5)) + + def test_last_k_window(self): + df = simulate_panel(n_periods=10, tstar_idx=5) + r = fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=6, pre_periods=[5, 6]) + np.testing.assert_array_equal(r.pre_periods, [5, 6]) + # Pretest scope is unconditional: every interior pre-date is tested. + np.testing.assert_array_equal(r.pretest.periods, [2, 3, 4, 5]) + + def test_ph_ineligible_date_excluded_and_renormalized(self): + # Control has no exit between dates 1 and 2: PH cannot use date 2. + df = build_from_survivors(30, [18, 9, 4, 1, 0], 10, [8, 8, 4, 3, 2]) + with pytest.warns(UserWarning, match="renormalized"): + r = DurationDiD(method="ph", n_bootstrap=0).fit( + df, **FIT_KW, last_pre_period=3, pre_periods=[2, 3], pre_period_weights=[1, 1] + ) + assert r.excluded_pre_periods == {2: "zero_control_increment"} + np.testing.assert_array_equal(r.pre_periods, [3]) + np.testing.assert_allclose(r.pre_period_weights, [1.0]) + + def test_no_eligible_period_raises(self): + df = build_from_survivors(30, [18, 9, 4, 1, 0], 10, [8, 8, 8, 3, 2]) + with pytest.raises(ValueError, match="no eligible fitting period"): + fit_quiet(DurationDiD(method="ph", n_bootstrap=0), df, last_pre_period=3) + + def test_realized_selectors_echoed_in_to_dict(self): + r = fit_quiet( + DurationDiD(n_bootstrap=0), + micro_panel(), + last_pre_period=3, + pre_periods=[3], + pre_period_weights=[2.0], + ) + d = r.to_dict() + assert d["pre_periods"] == [3] + assert d["pre_period_weights"] == [1.0] + + +# --------------------------------------------------------------------------- +# Inference contract +# --------------------------------------------------------------------------- + + +class TestInferenceContract: + def test_n_bootstrap_zero_joint_nan(self): + r = fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3) + assert r.inference_status == "disabled" + assert np.isfinite(r.att) + assert_nan_inference( + {"se": r.se, "t_stat": r.t_stat, "p_value": r.p_value, "conf_int": r.conf_int} + ) + assert np.all(np.isnan(r.se_by_period)) and np.all(np.isnan(r.p_value_by_period)) + assert np.all(np.isnan(r.pointwise_crit_values)) + assert np.isnan(r.cband_crit_value) and np.isnan(r.joint_p_value) + assert r.vcov is None and r.bootstrap_effects is None + assert r.pretest.status == "disabled" + assert np.all(np.isfinite(r.pretest.contrast)) + assert r.pretest.reject is None + assert r.is_significant is False and r.significance_stars == "" + + def test_seed_determinism(self, sim_df): + a = fit_quiet(DurationDiD(n_bootstrap=30, seed=7), sim_df, last_pre_period=4) + b = fit_quiet(DurationDiD(n_bootstrap=30, seed=7), sim_df, last_pre_period=4) + c = fit_quiet(DurationDiD(n_bootstrap=30, seed=8), sim_df, last_pre_period=4) + assert a.se == b.se and a.p_value == b.p_value + np.testing.assert_array_equal(a.bootstrap_effects, b.bootstrap_effects) + assert a.se != c.se + + def test_available_family_is_coherent(self, fitted): + r = fitted + assert r.inference_status == "ok" + assert r.n_bootstrap_valid == r.n_bootstrap + assert np.all(np.isfinite(r.se_by_period)) and np.all(r.se_by_period > 0) + assert r.vcov.shape == (len(r.post_periods), len(r.post_periods)) + assert np.array_equal(r.se_by_period, np.sqrt(np.diag(r.vcov))) + assert np.all( + r.cband_upper - r.cband_lower >= r.conf_int_by_period[:, 1] - r.conf_int_by_period[:, 0] + ) + assert r.cband_crit_value >= np.max(r.pointwise_crit_values) + # Band rule <=> p <= alpha (headline and every period) + assert r.is_significant == (not (r.conf_int[0] <= 0 <= r.conf_int[1])) + for k in range(len(r.post_periods)): + excl = not (r.conf_int_by_period[k, 0] <= 0 <= r.conf_int_by_period[k, 1]) + assert excl == (r.p_value_by_period[k] <= r.alpha) + assert r.n_obs == r.n_units * r.n_periods + assert r.n_units == r.n_treated + r.n_control + + def test_single_post_date(self): + df = simulate_panel(n=300, n_periods=5, tstar_idx=3) + r = fit_quiet(DurationDiD(n_bootstrap=40, seed=1), df, last_pre_period=4) + assert r.vcov.shape == (1, 1) + np.testing.assert_array_equal(r.cband_lower, r.conf_int_by_period[:, 0]) + assert r.cband_crit_value == r.pointwise_crit_values[0] + es = r.aggregate("event_study") + assert es.vcov.shape == (1, 1) + + def test_deterministic_per_family_masks(self, monkeypatch): + df = ( + micro_panel() + ) # treated 0..29 (tstar survivors 0..3), control 30..39 (horizon survivors 30, 31) + + def fake_draws(rng, n, size): + base = np.arange(n) + d2 = base.copy() + d2[[30, 31]] = [32, 33] # no control horizon survivor; tstar survivors intact + d4 = base.copy() + d4[:4] = 4 # no treated tstar survivor: both families fail + return np.stack([base, d2, base.copy(), d4])[:size] + + monkeypatch.setattr(dd_module, "_draw_indices", fake_draws) + with pytest.warns(UserWarning, match="Bootstrap draws failed"): + r = DurationDiD(n_bootstrap=4, seed=0).fit(df, **FIT_KW, last_pre_period=3) + assert r.inference_status == "unavailable_failed_draws" + assert r.bootstrap_failure_reasons == { + "post": {"control_survival_zero": 1, "zero_survival_last_pre": 1}, + "pretest": {"zero_survival_last_pre": 1}, + } + assert r.n_bootstrap_valid == 2 and r.n_bootstrap_valid_pretest == 3 + assert r.pretest.status == "unavailable_failed_draws" + assert np.isfinite(r.att) and np.all(np.isnan(r.se_by_period)) + assert r.vcov is None and np.all(np.isnan(r.pointwise_crit_values)) + # Failed rows are NaN; the identity draws reproduce the point estimate. + assert np.all(np.isnan(r.bootstrap_effects[1])) + np.testing.assert_array_equal(r.bootstrap_effects[0], r.att_by_period) + + def test_pretest_family_survives_post_failure(self, monkeypatch): + df = micro_panel() + + def fake_draws(rng, n, size): + base = np.arange(n) + d2 = base.copy() + d2[[30, 31]] = [32, 33] + return np.stack([base, d2, base.copy()])[:size] + + monkeypatch.setattr(dd_module, "_draw_indices", fake_draws) + r = fit_quiet(DurationDiD(n_bootstrap=3, seed=0), df, last_pre_period=3) + assert r.inference_status == "unavailable_failed_draws" + assert r.n_bootstrap_valid_pretest == 3 + # Identity draws give zero pretest SD -> zero-SE gate for that family. + assert r.pretest.status == "unavailable_zero_se" + + def test_whole_histories_travel_together(self, monkeypatch): + df = micro_panel() + seen = {} + + def fake_draws(rng, n, size): + idx = np.full((size, n), 5) # every draw = 40 copies of treated unit 5 ... group_empty + seen["shape"] = idx.shape + return idx + + monkeypatch.setattr(dd_module, "_draw_indices", fake_draws) + r = fit_quiet(DurationDiD(n_bootstrap=2, seed=0), df, last_pre_period=3) + assert seen["shape"] == (2, 40) + assert r.bootstrap_failure_reasons["post"] == {"group_empty": 2} + + def test_nonfinite_coefficient_draw_is_a_failure(self, monkeypatch): + # Denormal-scale grid passes the relative spacing check; a draw with a + # positive treated increment makes H = D/elapsed overflow -> c = +inf, + # S0 = 0 and a FINITE artifact tau; the predicate must still fail it. + # Treated increment log(10) at date 2 over elapsed 1e-308 overflows. + df = build_from_survivors( + 30, [30, 3, 1, 1, 0], 10, [8, 6, 4, 3, 2], times=[0.0, 1e-308, 2e-308, 3e-308, 4e-308] + ) + calls = {"n": 0} + + def fake_draws(rng, n, size): + calls["n"] += 1 + return np.tile(np.arange(n), (size, 1)) + + monkeypatch.setattr(dd_module, "_draw_indices", fake_draws) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + warnings.simplefilter("ignore", UserWarning) + r = DurationDiD(n_bootstrap=2, seed=0).fit(df, **FIT_KW, last_pre_period=2e-308) + assert not np.isfinite(r.coefficient) + assert "counterfactual_nonfinite" in r.curve_status + assert r.inference_status == "unavailable_invalid_periods" + assert r.bootstrap_failure_reasons["post"] == {"nonfinite_counterfactual": 2} + assert r.pretest.status == "unavailable_nonfinite_moments" + + def test_zero_se_gate_every_column(self, monkeypatch): + df = micro_panel() + monkeypatch.setattr( + dd_module, "_draw_indices", lambda rng, n, size: np.tile(np.arange(n), (size, 1)) + ) + r = fit_quiet(DurationDiD(n_bootstrap=3, seed=0), df, last_pre_period=3) + assert r.inference_status == "unavailable_zero_se" + assert np.all(np.isnan(r.se_by_period)) and np.all(np.isnan(r.p_value_by_period)) + assert np.all(np.isnan(r.pointwise_crit_values)) and r.vcov is None + assert np.all(np.isnan(r.cband_lower)) and np.isnan(r.cband_crit_value) + assert_nan_inference( + {"se": r.se, "t_stat": r.t_stat, "p_value": r.p_value, "conf_int": r.conf_int} + ) + + +class TestCurveValidity: + @pytest.mark.parametrize("n_boot", [0, 200]) + def test_boundary_is_time_label_invariant(self, n_boot): + # Single fitting date with no treated pre-period exits: R0 at the + # fitting date is exactly zero mathematically; roundoff must not flag + # it, and a relabelled grid must give the same fit. + s1, s2 = [200, 200, 160], [200, 160, 100] + a = fit_quiet( + DurationDiD(n_bootstrap=n_boot, seed=4), + build_from_survivors(200, s1, 200, s2, times=[0, 1, 2]), + last_pre_period=1, + ) + b = fit_quiet( + DurationDiD(n_bootstrap=n_boot, seed=4), + build_from_survivors(200, s1, 200, s2, times=[0, 1.3, 2.6]), + last_pre_period=1.3, + ) + for r in (a, b): + assert r.curve_status == ["ok"] * 3 + assert r.inference_status == ("ok" if n_boot else "disabled") + assert r.counterfactual_survival[1] == pytest.approx(1.0, abs=1e-12) + np.testing.assert_allclose(a.att_by_period, b.att_by_period, rtol=0, atol=1e-12) + assert a.att == pytest.approx(b.att, abs=1e-12) + if n_boot: + assert a.se == pytest.approx(b.se, abs=1e-12) + np.testing.assert_allclose(a.se_by_period, b.se_by_period, rtol=0, atol=1e-12) + assert a.n_draws_invalid_counterfactual == b.n_draws_invalid_counterfactual == 0 + + def test_negative_gap_cd_nonmonotone(self): + df = build_from_survivors(30, [18, 18, 18, 18, 18], 10, [8, 6, 4, 4, 4]) + with pytest.warns(UserWarning, match="Invalid imputed counterfactual curve"): + r = DurationDiD(n_bootstrap=20, seed=0).fit(df, **FIT_KW, last_pre_period=3) + assert r.coefficient < 0 + assert "counterfactual_nonmonotone" in r.period_status + assert r.inference_status == "unavailable_invalid_periods" + assert np.isnan(r.att) + assert np.all(np.isnan(r.se_by_period)) and np.all(np.isnan(r.p_value_by_period)) + assert np.all(np.isnan(r.pointwise_crit_values)) and np.all(np.isnan(r.cband_lower)) + assert np.isnan(r.cband_crit_value) and np.isnan(r.joint_p_value) and r.vcov is None + # Raw finite extrapolations are retained on the flagged dates. + assert np.all(np.isfinite(r.att_by_period)) + assert r.pretest.status == "ok" + assert r.bootstrap_effects is not None + + def test_pre_date_out_of_domain_flags_whole_path(self): + # Fitted pre-date survival 1.5 (R0 < 0) with every post step positive. + n = 1000 + df = build_from_survivors( + n, [1000, 1000, 900, 800, 700], n, [1000, 200, 100, 10, 1], times=np.arange(5) + ) + with pytest.warns(UserWarning, match="Invalid imputed counterfactual"): + r = DurationDiD(n_bootstrap=0).fit(df, **FIT_KW, last_pre_period=2) + assert r.curve_status[2] == "counterfactual_survival_above_one" + assert r.period_status == ["ok", "ok"] + assert r.counterfactual_survival[2] == pytest.approx(1.5) + assert np.isnan(r.att) + + def test_ph_counterexample_is_valid(self): + # D1=[0,1,6,7], D2=[0,1,2,2.01]: mean-ratio c=2 puts the fitted R0 at the + # first post date (4.02) below the OBSERVED R_1,tstar (6); the gate is on + # the imputed curve, so every status is "ok". + big = 200_000 + s1 = [big] + [int(round(big * math.exp(-d))) for d in (1, 6, 7)] + s2 = [big] + [int(round(big * math.exp(-d))) for d in (1, 2, 2.01)] + df = build_from_survivors(big, s1, big, s2) + r = fit_quiet(DurationDiD(method="ph", n_bootstrap=0), df, last_pre_period=3) + assert r.coefficient == pytest.approx(2.0, abs=1e-3) + assert r.curve_status == ["ok"] * 4 + assert -math.log(r.counterfactual_survival[3]) < -math.log(r.survival_treated[2]) + + @pytest.mark.parametrize("seed", range(6)) + def test_ph_never_flags(self, seed): + df = simulate_panel(n=150, seed=seed, method="ph", c=1.5) + r = fit_quiet(DurationDiD(method="ph", n_bootstrap=0), df, last_pre_period=4) + assert r.curve_status == ["ok"] * r.n_periods + + def test_control_survival_zero_masks_att_and_curve(self): + df = build_from_survivors(30, [18, 9, 4, 2, 1], 10, [8, 6, 4, 2, 0]) + with pytest.warns(UserWarning, match="control_survival_zero"): + r = DurationDiD(n_bootstrap=10, seed=0).fit(df, **FIT_KW, last_pre_period=3) + assert r.period_status == ["ok", "control_survival_zero"] + assert np.isfinite(r.att_by_period[0]) and np.isnan(r.att_by_period[1]) + assert np.isnan(r.counterfactual_survival[4]) + assert r.inference_status == "unavailable_invalid_periods" + assert r.n_control_survivors_at_horizon == 0 + + def test_extreme_negative_cd_overflow_is_clean(self): + # Tiny control hazard, large negative gap: exp(-R0) overflows to inf. + n1, n2 = 400, 400 + s1 = [400, 400, 400, 400, 400] + s2 = [400, 1, 1, 1, 1] + df = build_from_survivors(n1, s1, n2, s2, times=[0, 1000, 2000, 3000, 4000]) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + with pytest.warns(UserWarning, match="Invalid imputed counterfactual"): + r = DurationDiD(n_bootstrap=0).fit(df, **FIT_KW, last_pre_period=2000) + assert "counterfactual_survival_above_one" in r.curve_status + flagged = [ + i for i, s in enumerate(r.curve_status) if s == "counterfactual_survival_above_one" + ] + assert all( + np.isnan(r.counterfactual_survival[i]) or r.counterfactual_survival[i] > 1 + for i in flagged + ) + assert not np.any(np.isinf(r.att_by_period)) + assert not np.any(np.isinf(r.counterfactual_survival)) + + +# --------------------------------------------------------------------------- +# Warnings +# --------------------------------------------------------------------------- + + +class TestInvalidCurveMessage: + def _message(self, df, last_pre, method="cd"): + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + r = DurationDiD(method=method, n_bootstrap=0).fit( + df, **FIT_KW, last_pre_period=last_pre + ) + msgs = [ + str(w.message) for w in rec if "Invalid imputed counterfactual curve" in str(w.message) + ] + assert len(msgs) == 1, [str(w.message) for w in rec] + return r, msgs[0] + + def test_all_post_control_zero_keeps_prefix(self): + df = build_from_survivors(30, [18, 9, 4, 2, 1], 10, [8, 6, 4, 0, 0]) + r, msg = self._message(df, 3) + assert msg.startswith( + "Invalid imputed counterfactual curve at 4 (control_survival_zero), 5" + ) + assert "first post-treatment date is already invalid" in msg + assert "at or before" not in msg + assert r.summary().count("Invalid imputed counterfactual curve") == 1 + + def test_post_violation_recommends_effective_horizon(self): + # Control survival first reaches zero at date 5: the recommended + # horizon is the last date strictly before it (4), which keeps one + # valid post date. + df = build_from_survivors(30, [18, 9, 4, 2, 1], 10, [8, 6, 4, 2, 0]) + r, msg = self._message(df, 3) + assert "subset the data to dates at or before 4 and refit" in msg + assert "cannot repair" not in msg + refit = fit_quiet(DurationDiD(n_bootstrap=0), df[df["time"] <= 4], last_pre_period=3) + assert refit.curve_status == ["ok"] * 4 + + def test_pre_date_violation_says_change_the_fit(self): + n = 1000 + df = build_from_survivors( + n, [1000, 1000, 900, 800, 700], n, [1000, 200, 100, 10, 1], times=np.arange(5) + ) + r, msg = self._message(df, 2) + assert msg.startswith( + "Invalid imputed counterfactual curve at 2 (counterfactual_survival_above_one)" + ) + assert "cannot repair it: change the fit" in msg + assert "at or before" not in msg + assert "cannot repair" in r.summary() + + def test_negative_gap_mixed_violation(self): + df = build_from_survivors(30, [18, 18, 18, 18, 18], 10, [8, 6, 4, 4, 4]) + r, msg = self._message(df, 3) + assert "cannot repair it: change the fit" in msg + assert "first post-treatment date is already invalid" in msg + assert "at or before" not in msg + + +class TestWarnings: + def test_ph_support_pretest_unavailable_with_finite_att(self): + # PH with a zero control increment at an interior pre-date: the point + # estimate stands on the remaining fitting date, the diagnostic is withheld. + df = build_from_survivors(30, [18, 9, 4, 1, 0], 10, [8, 8, 4, 3, 2]) + r = fit_quiet(DurationDiD(method="ph", n_bootstrap=20, seed=0), df, last_pre_period=3) + assert r.pretest.status == "unavailable_ph_support" + assert np.all(np.isnan(r.pretest.contrast)) and r.pretest.reject is None + assert np.isfinite(r.att) and np.all(np.isfinite(r.att_by_period)) + + def test_two_pre_dates_warns_and_pretest_unavailable(self): + df = build_from_survivors(30, [18, 9, 4, 1], 10, [8, 6, 4, 3]) + with pytest.warns(UserWarning, match="Only two pre-treatment dates"): + r = DurationDiD(n_bootstrap=10, seed=0).fit(df, **FIT_KW, last_pre_period=2) + assert r.pretest.status == "unavailable_insufficient_pre_periods" + assert len(r.pretest.periods) == 0 + assert np.isfinite(r.att) + + def test_fit_omits_anchor_warns(self): + with pytest.warns(UserWarning, match="omit last_pre_period"): + DurationDiD(n_bootstrap=0).fit( + micro_panel(), **FIT_KW, last_pre_period=3, pre_periods=[2] + ) + + def test_ph_boundary_warns(self): + df = build_from_survivors(30, [18, 18, 18, 10, 5], 10, [8, 6, 4, 3, 2]) + with pytest.warns(UserWarning, match="PH ratio is exactly zero"): + r = DurationDiD(method="ph", n_bootstrap=0).fit(df, **FIT_KW, last_pre_period=3) + assert r.ph_ratio_boundary is True and r.coefficient == 0.0 + + def test_weak_support_warns_on_few_survivors(self): + # Count rule: the micro panel's control group has 2 survivors at the + # horizon, below the 5-survivor floor, at every sample size. + assert dd_module._WEAK_SUPPORT_MIN_SURVIVORS == 5 + with pytest.warns(UserWarning, match="fewer than 5 survivors \\(minimum 2\\)"): + r = DurationDiD(n_bootstrap=0).fit(micro_panel(), **FIT_KW, last_pre_period=3) + assert r.n_control_survivors_at_horizon == 2 # warning only, nothing changes + + def test_weak_support_silent_with_ample_survivors(self, sim_df): + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + DurationDiD(n_bootstrap=0).fit(sim_df, **FIT_KW, last_pre_period=4) + + +# --------------------------------------------------------------------------- +# Results API +# --------------------------------------------------------------------------- + + +class TestResultsAPI: + def test_summary_reports_out_of_domain_draws(self, fitted): + line = [ln for ln in fitted.summary().splitlines() if "Out-of-domain imputed curves" in ln] + assert len(line) == 1 + assert f"{fitted.n_draws_invalid_counterfactual}/{fitted.n_bootstrap_valid}" in line[0] + r0 = fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3) + assert "n/a (no complete draws)" in r0.summary() + + def test_types(self, fitted): + assert isinstance(fitted, DurationDiDResults) + assert isinstance(fitted.pretest, DurationDiDPretestResults) + + def test_summary_alpha_guard(self, fitted): + text = fitted.summary() + assert "Duration Difference-in-Differences" in text and "95% CI" in text + assert "specification test" in text + with pytest.raises(ValueError, match="never recomputes"): + fitted.summary(alpha=0.10) + assert fitted.summary(alpha=0.05) == text + + def test_to_dict_json(self, fitted): + d = fitted.to_dict() + assert {"att", "se", "t_stat", "p_value", "conf_int_lower", "conf_int_upper"} <= set(d) + assert not [k for k in d if k.startswith("overall_") or k.startswith("avg_")] + json.dumps(d) + assert d["conf_int_lower"] == fitted.conf_int[0] + assert d["n_units"] == fitted.n_units and d["n_obs"] == fitted.n_obs + assert d["pretest"]["status"] == "ok" + + def test_to_dataframe_levels(self, fitted): + periods = fitted.to_dataframe() + assert list(periods["period"]) == list(fitted.post_periods) + assert {"att", "se", "cband_lower", "cband_upper", "status"} <= set(periods.columns) + att = fitted.to_dataframe(level="att") + assert att.shape[0] == 1 and att.loc[0, "att"] == fitted.att + with pytest.raises(ValueError, match="level must be"): + fitted.to_dataframe(level="bogus") + + def test_pretest_surface(self, fitted): + p = fitted.pretest + frame = p.to_dataframe() + assert list(frame.columns) == ["period", "contrast", "se", "band_lower", "band_upper"] + assert "Algorithm 2" in p.summary() + json.dumps(p.to_dict()) + assert p.reject == (p.statistic > p.crit_value) == (p.p_value <= p.alpha) + assert not hasattr(p, "att") and not hasattr(p, "conf_int") + + def test_event_study_aggregate(self, fitted): + es = fitted.aggregate("event_study") + assert isinstance(es, EventStudyResults) + n_post = len(fitted.post_periods) + np.testing.assert_array_equal(es.event_time, np.arange(-1, n_post)) + assert es.is_reference[0] and not es.is_reference[1:].any() + assert es.att[0] == 0.0 and np.isnan(es.se[0]) + assert es.n.shape == (n_post + 1,) and es.n_kind == "units" + assert np.all(es.n[1:] == fitted.n_units) + assert es.event_time_convention == "e0_first_treated" + assert es.source == "DurationDiDResults" + np.testing.assert_array_equal(es.att[1:], fitted.att_by_period) + assert np.allclose(np.diag(es.vcov), es.se[1:] ** 2, rtol=1e-6, atol=0.0) + assert es.cband_crit_value == fitted.cband_crit_value + with pytest.raises(ValueError, match="balance_e"): + fitted.aggregate("event_study", balance_e=1) + with pytest.raises(ValueError, match="weights"): + fitted.aggregate("event_study", weights="cell") + with pytest.raises(ValueError, match="Unsupported aggregation type"): + fitted.aggregate("simple") + + def test_event_study_without_inference(self): + r = fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3) + es = r.aggregate("event_study") + assert es.vcov is None and es.cband_lower is None and es.cband_crit_value is None + assert np.all(np.isnan(es.se)) + + +# --------------------------------------------------------------------------- +# Consumers +# --------------------------------------------------------------------------- + + +class TestConsumers: + def test_diagnostic_report_rejects(self, fitted, sim_df): + with pytest.raises(TypeError, match="DurationDiDResults"): + DiagnosticReport(fitted, data=sim_df) + + def test_business_report_rejects(self, fitted): + with pytest.raises(TypeError, match="DurationDiDResults"): + BusinessReport(fitted) + + def test_practitioner_handler(self, fitted): + out = practitioner_next_steps(fitted, verbose=False) + steps = out["next_steps"] + text = "\n".join(s["why"] + "\n" + s["code"] for s in steps) + assert "parallel trends variant" not in text + assert "compute_honest_did" not in text + assert any("pretest" in s["code"] for s in steps) + assumptions = [s for s in steps if s["baker_step"] == 2] + assert assumptions and "hazard" in assumptions[0]["why"] + placebo = [s for s in steps if "last_pre_period" in s["code"] and "<=" in s["code"]] + assert placebo, "anticipation placebo must truncate the frame before refitting" + assert out["estimator"].startswith("DurationDiD") + + def test_practitioner_placebo_not_applicable_on_two_pre_dates(self): + df = build_from_survivors(30, [18, 9, 4, 1], 10, [8, 6, 4, 3]) + r = fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=2) + steps = practitioner_next_steps(r, verbose=False)["next_steps"] + placebo = [s for s in steps if s["baker_step"] == 6 and "placebo" in s["label"].lower()] + assert placebo and "third pre-date" in placebo[0]["why"] + + def test_practitioner_placebo_exact_anchor_on_decimal_grid(self): + df = micro_panel(times=[0.1, 0.2, 0.3, 0.4, 0.5]) + r = fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=0.3) + steps = practitioner_next_steps(r, verbose=False)["next_steps"] + code = [s["code"] for s in steps if "last_pre_period" in s["code"] and "<=" in s["code"]][0] + assert "last_pre_period=0.2)" in code + ns = {"data": df, "DurationDiD": DurationDiD, "np": np} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + exec(code, ns) + assert ns["placebo"].last_pre_period == 0.2 + + def test_practitioner_placebo_code_runs(self, sim_df, fitted): + steps = practitioner_next_steps(fitted, verbose=False)["next_steps"] + code = [s["code"] for s in steps if "last_pre_period" in s["code"] and "<=" in s["code"]][0] + ns = {"data": sim_df, "DurationDiD": DurationDiD, "np": np} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + exec(code, ns) + assert "placebo" in ns diff --git a/tests/test_guides.py b/tests/test_guides.py index 822209749..ca80828bd 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -766,6 +766,60 @@ def test_llms_full_had_pretests_assumption_labels_correct(self): ) +class TestLLMsFullDurationDiDCoverage: + """Pin the DurationDiD section of llms-full.txt to the real API. + + Adding a public parameter to DurationDiD.__init__ or DurationDiD.fit() + requires updating diff_diff/guides/llms-full.txt — these tests catch drift. + """ + + def _section(self): + text = get_llm_guide("full") + start = text.index("### DurationDiD") + nxt = text.index("\n### ", start + 1) + return text[start:nxt] + + def test_llms_full_has_duration_section(self): + assert "### DurationDiD" in get_llm_guide("full") + + def test_llms_full_duration_constructor_signature_matches_real_api(self): + import inspect + + from diff_diff import DurationDiD + + sig_params = set(inspect.signature(DurationDiD.__init__).parameters) + sig_params.discard("self") + section = self._section() + block_start = section.index("DurationDiD(") + block_end = section.index(").fit(", block_start) + ctor_block = section[block_start:block_end] + for param in sig_params: + assert f"{param}=" in ctor_block, ( + f"DurationDiD constructor block in llms-full.txt is missing the real " + f"public parameter {param!r}." + ) + + def test_llms_full_duration_fit_signature_set_equality(self): + import inspect + import re + + from diff_diff import DurationDiD + + sig_params = set(inspect.signature(DurationDiD.fit).parameters) + sig_params.discard("self") + section = self._section() + fit_start = section.index(").fit(") + fit_block = section[fit_start : section.index("DurationDiDResults", fit_start)] + # Strip trailing comments so prose never masquerades as a parameter. + fit_code = "\n".join(line.split("#", 1)[0] for line in fit_block.splitlines()) + documented = set(re.findall(r"\b([a-z_]+)(?=\s*(?:=|,|\)))", fit_code)) + documented.discard("fit") + assert documented == sig_params, ( + f"llms-full.txt DurationDiD fit() block documents {sorted(documented)} but the " + f"real signature is {sorted(sig_params)}" + ) + + class TestLLMsFullDMLDiDCoverage: """Pin the DMLDiD section of llms-full.txt to the real API. diff --git a/tests/test_methodology_duration_did.py b/tests/test_methodology_duration_did.py new file mode 100644 index 000000000..895e8f267 --- /dev/null +++ b/tests/test_methodology_duration_did.py @@ -0,0 +1,340 @@ +"""Methodology tests for DurationDiD against Deaner & Ku (2026). + +Checks the Theorem 1 identities on exact population survival curves, the +hand-computed micro panel (CD holds exactly in sample), the PH mean-of-ratios +choice against the printed reciprocal, the centered-bootstrap conventions on +fixed replicate matrices (Appendix B Algorithm 1), the fixed-anchor Algorithm +2 contrast, whole-individual resampling, time-rescaling invariance, and +sampling behavior on the exact absorbing DGP. Imports the private seams of +``diff_diff.duration_did`` directly. API/validation tests live in +``tests/test_duration_did.py``. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pandas as pd +import pytest + +import diff_diff.duration_did as dd_module +from diff_diff import DurationDiD +from diff_diff.duration_did import ( + _centered_bootstrap_summary, + _curve_status, + _estimate_from_survival, + _group_survival, + _log_survival_moments, + _pretest_contrasts, + _quantile_inverted_cdf, +) +from tests.test_duration_did import build_from_survivors, fit_quiet, micro_panel + +# --------------------------------------------------------------------------- +# Exact population DGP (per-period integrated hazard increments) +# --------------------------------------------------------------------------- + + +def population_curves(n_periods=8, tstar_idx=3, c=0.05, beta=0.3, method="cd", s11=0.6, s21=0.8): + """Exact group survival curves under CD (additive) or PH (ratio). + + Returns ``S1_fact`` (treated factual), ``S1_cf`` (treated counterfactual), + ``S2`` (control) and the population post-date effects ``tau``. + """ + s = np.arange(2, n_periods + 1) + lam2 = 0.15 + 0.02 * s + lam1 = lam2 + c if method == "cd" else c * lam2 + lam1_fact = lam1.copy() + lam1_fact[tstar_idx:] += beta + S2 = s21 * np.exp(-np.concatenate([[0.0], np.cumsum(lam2)])) + S1_cf = s11 * np.exp(-np.concatenate([[0.0], np.cumsum(lam1)])) + S1_fact = s11 * np.exp(-np.concatenate([[0.0], np.cumsum(lam1_fact)])) + return S1_fact, S1_cf, S2, (S1_cf - S1_fact)[tstar_idx + 1 :] + + +def simulate(n, S1, S2, seed, n_periods=None): + rng = np.random.default_rng(seed) + n_periods = len(S1) if n_periods is None else n_periods + u = rng.uniform(size=2 * n) + g = np.repeat([1, 0], n) + S_fact = np.where(g[:, None] == 1, S1[None, :], S2[None, :]) + Y = (u[:, None] > S_fact).astype(int) + return pd.DataFrame( + { + "unit": np.repeat(np.arange(2 * n), n_periods), + "time": np.tile(np.arange(1, n_periods + 1), 2 * n), + "treated": np.repeat(g, n_periods), + "exited": Y.ravel(), + } + ) + + +def core_on_curves(S1, S2, elapsed, fit_idx, weights, method): + S = np.stack([S1, S2])[None] + R, D, H = _log_survival_moments(S, elapsed) + c, R0, S0, tau = _estimate_from_survival(S, R, D, elapsed, fit_idx, weights, method) + return c[0], R0[0], S0[0], tau[0], D[0], H[0] + + +# --------------------------------------------------------------------------- +# Population identities (Theorem 1) +# --------------------------------------------------------------------------- + + +class TestPopulationIdentities: + @pytest.mark.parametrize("method, c", [("cd", 0.05), ("cd", -0.05), ("ph", 1.5)]) + @pytest.mark.parametrize("weights", [None, np.array([0.7, 0.2, 0.1])]) + def test_exact_recovery(self, method, c, weights): + S1_fact, S1_cf, S2, tau_true = population_curves(c=c, method=method) + elapsed = np.arange(8, dtype=float) + fit_idx = np.array([1, 2, 3]) + w = np.ones(3) / 3 if weights is None else weights + c_hat, R0, S0, tau, _, _ = core_on_curves(S1_fact, S2, elapsed, fit_idx, w, method) + assert c_hat == pytest.approx(c, abs=1e-12) + np.testing.assert_allclose(S0, S1_cf, atol=1e-12) + np.testing.assert_allclose(tau[4:], tau_true, atol=1e-12) + + def test_unequal_baseline_and_ph_baseline_outside_exponent(self): + # S_1t(0) = S_11 (S_2t / S_21)^c: the treated baseline is a factor, + # not inside the exponent (review lines 262-265). + S1_fact, S1_cf, S2, _ = population_curves(method="ph", c=1.5, s11=0.6, s21=0.8) + elapsed = np.arange(8, dtype=float) + _, _, S0, _, _, _ = core_on_curves( + S1_fact, S2, elapsed, np.array([1, 2, 3]), np.ones(3) / 3, "ph" + ) + np.testing.assert_allclose(S0, 0.6 * (S2 / 0.8) ** 1.5, atol=1e-12) + assert S0[0] == pytest.approx(0.6) + + def test_ph_mean_ratio_vs_printed_reciprocal(self): + # Review lines 1015-1019: the printed 3.5 slope returns 1/c under exact PH. + d2 = np.array([0.0, 0.10, 0.30, 0.45, 0.60]) + d1 = 2 * d2 + pre = np.array([1, 2]) + ratio = np.mean(d1[pre] / d2[pre]) + printed = np.dot(d1[pre], d2[pre]) / np.dot(d1[pre], d1[pre]) + assert ratio == 2 and printed == 0.5 + + def test_three_finite_sample_ph_choices_differ(self): + # Review lines 1020-1023. + x, y, dt = np.array([0.1, 0.3]), np.array([0.2, 0.9]), np.array([1, 2]) + choices = ( + np.mean(y / x), + np.dot(x, y) / np.dot(x, x), + np.dot(x / dt, y / dt) / np.dot(x / dt, x / dt), + ) + np.testing.assert_allclose(choices, [2.5, 2.9, 2.6923076923076925]) + # The estimator implements the first (mean of ratios). + S1 = np.exp(-np.array([0.0, 0.2, 0.9])) + S2 = np.exp(-np.array([0.0, 0.1, 0.3])) + c_hat, *_ = core_on_curves( + S1, S2, np.array([0.0, 1.0, 2.0]), np.array([1, 2]), np.array([0.5, 0.5]), "ph" + ) + assert c_hat == pytest.approx(2.5) + + def test_max_abs_sign_symmetry(self): + z = np.array([-4.0, 1.0]) + assert np.max(np.abs(z)) == 4 and abs(np.max(z)) == 1 + + +# --------------------------------------------------------------------------- +# Hand-computed micro panel +# --------------------------------------------------------------------------- + + +class TestMicroPanel: + def test_cd_exact(self): + r = fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3) + assert r.coefficient == pytest.approx(math.log(1.5), abs=1e-14) + np.testing.assert_allclose(r.att_by_period, [1 / 30, 8 / 270], atol=1e-14) + assert r.survival_treated[-1] == 0.0 # zero treated post-survival is fine + assert abs(r.pretest.contrast[0]) < 1e-12 + assert r.curve_status == ["ok"] * 5 + assert r.n_treated_survivors_at_last_pre == 4 and r.n_control_survivors_at_horizon == 2 + + def test_ph_values(self): + r = fit_quiet(DurationDiD(method="ph", n_bootstrap=0), micro_panel(), last_pre_period=3) + assert r.coefficient == pytest.approx(2.2896729, abs=1e-6) + np.testing.assert_allclose(r.att_by_period, [0.0301737, 0.0250975], atol=1e-6) + assert r.pretest.contrast[0] == pytest.approx(0.2394958, abs=1e-6) + assert r.curve_status == ["ok"] * 5 + + def test_gemm_path_bit_identical_to_loop(self): + df = micro_panel() + arranged = dd_module._validate_and_arrange(df, "exited", "unit", "time", "treated", 3) + Y, n1 = arranged["Y"], arranged["n_treated"] + rng = np.random.default_rng(0) + idx = rng.integers(0, Y.shape[0], size=(7, Y.shape[0])) + W = np.stack([np.bincount(row, minlength=Y.shape[0]) for row in idx]).astype(float) + S, _, _ = _group_survival(Y, n1, W) + for b in range(7): + Yb = Y[idx[b]] + gb = np.concatenate([np.ones(n1), np.zeros(Y.shape[0] - n1)])[idx[b]] + S1 = 1 - Yb[gb == 1].mean(axis=0) + S2 = 1 - Yb[gb == 0].mean(axis=0) + assert np.array_equal(S[b, 0], S1) and np.array_equal(S[b, 1], S2) + + def test_algorithm2_anchor_distinct_from_moving_window(self): + # Fixed anchor (Algorithm 2) vs the main-text moving final window at t=2 + # (review lines 449-465): both are valid null contrasts but differ. + df = build_from_survivors(30, [18, 9, 5, 1, 0], 10, [8, 6, 4, 3, 2]) + arranged = dd_module._validate_and_arrange(df, "exited", "unit", "time", "treated", 3) + S, _, _ = _group_survival(arranged["Y"], arranged["n_treated"], np.ones((1, 40))) + R, D, H = _log_survival_moments(S, arranged["elapsed"]) + fixed = _pretest_contrasts(D, H, np.array([1]), 2, "cd")[0, 0] + # moving window: (D_12 - D_22)/1 - [(R_13 - R_12) - (R_23 - R_22)]/1 + moving = (D[0, 0, 1] - D[0, 1, 1]) - ((R[0, 0, 2] - R[0, 0, 1]) - (R[0, 1, 2] - R[0, 1, 1])) + assert fixed != pytest.approx(moving) + + +# --------------------------------------------------------------------------- +# Centered bootstrap conventions (fixed replicate matrices) +# --------------------------------------------------------------------------- + + +class TestCenteredBootstrap: + @pytest.mark.parametrize("n", [20, 100, 999, 1000]) + def test_quantile_inverted_cdf(self, n): + x = np.arange(1, n + 1, dtype=float) + rng = np.random.default_rng(n) + rng.shuffle(x) + assert _quantile_inverted_cdf(x, 0.95) == math.ceil(0.95 * n) + assert _quantile_inverted_cdf(x, 0.5) == math.ceil(0.5 * n) + assert _quantile_inverted_cdf(x, 1e-9) == 1.0 + + def test_fixed_matrix_conventions(self): + rng = np.random.default_rng(1) + draws = rng.normal(size=(20, 3)) * np.array([1.0, 2.0, 0.5]) + np.array([0.5, -0.2, 0.1]) + point = np.array([0.4, -0.1, 0.1]) + s = _centered_bootstrap_summary(point, draws, alpha=0.05) + np.testing.assert_array_equal(s["se"], np.sqrt(np.diag(s["vcov"]))) + np.testing.assert_allclose(s["se"], np.std(draws, axis=0, ddof=1), rtol=1e-12) + z = np.abs(draws - point) / s["se"] + for k in range(3): + assert s["crit"][k] == np.sort(z[:, k])[math.ceil(0.95 * 20) - 1] + assert s["crit_sim"] >= s["crit"][k] + assert s["band_upper"][k] - s["band_lower"][k] >= s["ci_upper"][k] - s["ci_lower"][k] + assert s["p_joint"] == np.mean(z.max(axis=1) >= np.max(np.abs(point / s["se"]))) + + def test_band_p_duality_including_ties(self): + # B=20, alpha=0.05: crit = 19th order statistic; band excludes zero + # iff p <= 0.05 iff at most one draw is >= |t|. + z_vals = np.linspace(0.1, 2.0, 20) # exact centered pivots we will force + for t_abs in [z_vals[18] + 1e-9, z_vals[18], z_vals[18] - 1e-9, z_vals[17]]: + draws = t_abs + z_vals[:, None] # point + z (se ~ std of z) + point = np.array([t_abs]) + s = _centered_bootstrap_summary(point, draws, alpha=0.05) + se = s["se"][0] + z = np.abs(draws[:, 0] - t_abs) / se + t_stat = abs(t_abs / se) + band_excludes = t_stat > s["crit"][0] + p = np.mean(z >= t_stat) + assert band_excludes == (p <= 0.05) + assert s["p"][0] == p + + def test_single_column_covariance_shape(self): + s = _centered_bootstrap_summary( + np.array([0.3]), np.random.default_rng(0).normal(size=(10, 1)), 0.05 + ) + assert s["vcov"].shape == (1, 1) + assert s["crit_sim"] == s["crit"][0] + + +# --------------------------------------------------------------------------- +# Sampling behavior on the exact DGP +# --------------------------------------------------------------------------- + + +class TestSampling: + @pytest.mark.parametrize("method, c", [("cd", 0.05), ("ph", 1.5)]) + def test_point_estimates_near_truth(self, method, c): + S1, _, S2, tau_true = population_curves(c=c, method=method) + df = simulate(20_000, S1, S2, seed=11) + r = fit_quiet(DurationDiD(method=method, n_bootstrap=0), df, last_pre_period=4) + np.testing.assert_allclose(r.att_by_period, tau_true, atol=0.03) + assert r.coefficient == pytest.approx(c, abs=0.05 if method == "cd" else 0.4) + + def test_null_effect(self): + S1, _, S2, tau_true = population_curves(beta=0.0) + assert np.allclose(tau_true, 0) + df = simulate(20_000, S1, S2, seed=5) + r = fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=4) + np.testing.assert_allclose(r.att_by_period, 0, atol=0.03) + + def test_se_calibration(self, ci_params): + S1, _, S2, _ = population_curves() + n_rep = ci_params.bootstrap(300, min_n=60) + ests = np.array( + [ + fit_quiet( + DurationDiD(n_bootstrap=0), simulate(1000, S1, S2, seed=s), last_pre_period=4 + ).att_by_period + for s in range(n_rep) + ] + ) + mc_sd = ests.std(axis=0, ddof=1) + r = fit_quiet( + DurationDiD(n_bootstrap=ci_params.bootstrap(300, min_n=60), seed=0), + simulate(1000, S1, S2, seed=999), + last_pre_period=4, + ) + ratio = r.se_by_period / mc_sd + assert np.all((ratio > 0.7) & (ratio < 1.4)), ratio + + def test_time_rescaling_invariance(self): + df = micro_panel() + r1 = fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + df3 = df.copy() + df3["time"] = 10 + 3 * (df3["time"] - 1) + r3 = fit_quiet(DurationDiD(n_bootstrap=0), df3, last_pre_period=16) + np.testing.assert_allclose(r3.att_by_period, r1.att_by_period, atol=1e-12) + assert r3.coefficient == pytest.approx(r1.coefficient / 3) + rp1 = fit_quiet(DurationDiD(method="ph", n_bootstrap=0), df, last_pre_period=3) + rp3 = fit_quiet(DurationDiD(method="ph", n_bootstrap=0), df3, last_pre_period=16) + assert rp3.coefficient == pytest.approx(rp1.coefficient) + + def test_headline_uses_same_weights_in_every_draw(self): + S1, _, S2, _ = population_curves() + r = fit_quiet( + DurationDiD(n_bootstrap=30, seed=2), simulate(300, S1, S2, seed=3), last_pre_period=4 + ) + assert r.att == pytest.approx(np.mean(r.att_by_period)) + head_draws = r.bootstrap_effects.mean(axis=1) + assert r.se == pytest.approx(np.std(head_draws, ddof=1), rel=1e-10) + + def test_curve_status_helper(self): + assert ( + _curve_status(np.array([0.0, 0.5, 1.0, 1.5]), np.array([1, 0.5, 0.3, 0.1]), 1) + == ["ok"] * 4 + ) + assert _curve_status(np.array([0.0, -0.1, 1.0, 1.5]), np.array([1, 0.5, 0.3, 0.1]), 1)[ + 1 + ] == ("counterfactual_survival_above_one") + assert _curve_status(np.array([0.0, 0.5, 0.4, 1.5]), np.array([1, 0.5, 0.3, 0.1]), 1)[ + 2 + ] == ("counterfactual_nonmonotone") + assert _curve_status(np.array([0.0, 0.5, np.inf, np.inf]), np.array([1, 0.5, 0.0, 0.0]), 1)[ + 2: + ] == [ + "control_survival_zero", + "control_survival_zero", + ] + assert _curve_status(np.array([0.0, np.nan, 1.0, 1.5]), np.array([1, 0.5, 0.3, 0.1]), 1)[ + 1 + ] == ("counterfactual_nonfinite") + + +@pytest.mark.slow +class TestCoverage: + def test_uniform_coverage_near_nominal(self, ci_params): + S1, _, S2, tau_true = population_curves() + n_rep = ci_params.bootstrap(200, min_n=40) + covered = 0 + for s in range(n_rep): + r = fit_quiet( + DurationDiD(n_bootstrap=200, seed=s), + simulate(500, S1, S2, seed=1000 + s), + last_pre_period=4, + ) + covered += int(np.all((r.cband_lower <= tau_true) & (tau_true <= r.cband_upper))) + assert abs(covered / n_rep - 0.95) < 0.06 diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index 82444be4e..94ebcfb32 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -485,6 +485,7 @@ def _build_rowed_index(): "CallawaySantAnna.fit[time]", "ChaisemartinDHaultfoeuille.fit[time]", "DMLDiD.fit[time]", + "DurationDiD.fit[time]", "ChangesInChanges.fit[time]", "ContinuousDiD.fit[time]", "DiagnosticReport.__init__[time]", diff --git a/tests/test_results_serialization.py b/tests/test_results_serialization.py index 993b9b240..0b9bf7a95 100644 --- a/tests/test_results_serialization.py +++ b/tests/test_results_serialization.py @@ -49,6 +49,7 @@ "TROPResults", "SyntheticControlResults", "ChangesInChangesResults", + "DurationDiDResults", "HeterogeneousAdoptionDiDResults", "HeterogeneousAdoptionDiDEventStudyResults", "RegressionDiscontinuityResults", diff --git a/tests/test_v4_inference_policy.py b/tests/test_v4_inference_policy.py index 82117f71b..e587676a1 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, @@ -106,6 +107,9 @@ def _floor_msg(n: int) -> str: TripleDifference, ChangesInChanges, QDiD, + # Bootstrap-only whole-individual resampling: 0 is legal (point + # estimates, NaN inference), 1 is rejected by a local floor, >= 2 runs. + DurationDiD, # Type-guard alignment (post-M-081 follow-up): dCDH's floor is 0, so it # satisfies the full roster contract (zero legal at construction). ChaisemartinDHaultfoeuille, diff --git a/tests/test_variance_conventions.py b/tests/test_variance_conventions.py index 2f5e2a01e..c021f25f0 100644 --- a/tests/test_variance_conventions.py +++ b/tests/test_variance_conventions.py @@ -520,9 +520,37 @@ def snapshot(self): "CR1 sandwich)" ), ), + dict( + key="duration_did", + # Deterministic absorbing exit indicator derived from the shared + # fixture: unit-specific exit date 2 + unit % 7 (values 2..8) on the + # 1..6 grid, so both groups (grp = unit % 2) have full baseline + # survival, positive survival at last_pre_period=3, and survivors at + # every post date. + fit=lambda df: diff_diff.DurationDiD(n_bootstrap=0).fit( + df.assign(exited=(df["time"] >= 2 + df["unit"] % 7).astype(int)), + outcome="exited", + unit="unit", + time="time", + treatment="grp", + last_pre_period=3, + ), + cr1_k=(), + # One safe_inference (headline) + one safe_inference_batch (per-period) + # call per fit, both df=None: the centered-bootstrap p/CI override + # never passes a df. + tail_df=(None, None), + status="legitimate", + reason=( + "L3: Deaner & Ku (2026) whole-individual pooled bootstrap " + "(centered absolute-deviation pointwise and simultaneous bands); " + "no CR1 sandwich, normal-theory safe_inference gate only" + ), + ), ] _FAST_KEYS = { + "duration_did", "did_absorb_hc1_cluster_unit", "did_fixed_effects_hc1_cluster_unit", "twfe_hc1_cluster_unit_time_post", From 65eeba35dff6a144c3fb2892e921a62f40043b4e Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 7 Sep 2026 07:27:55 -0400 Subject: [PATCH 2/6] feat(duration-did): add DurationDiD estimator (Deaner & Ku 2026 causal duration DiD) --- diff_diff/duration_did.py | 27 ++++++++++++++++++++++----- docs/dev-status.md | 2 +- tests/test_duration_did.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/diff_diff/duration_did.py b/diff_diff/duration_did.py index 3eca2a759..af7a74513 100644 --- a/diff_diff/duration_did.py +++ b/diff_diff/duration_did.py @@ -114,6 +114,19 @@ def _validate_draws(n_bootstrap: Any) -> None: ) +def _validate_all_params(params: Dict[str, Any]) -> None: + """Validate the full hyperparameter dict (used by ``__init__`` and ``fit``). + + ``fit()`` re-runs this on ``get_params()`` before touching the data, so a + direct attribute mutation after construction (``est.method = "typo"``) + raises instead of silently selecting an estimation branch. + """ + _validate_method(params["method"]) + _validate_draws(params["n_bootstrap"]) + _validate_alpha(params["alpha"]) + _validate_seed(params["seed"]) + + # ============================================================================= # Numerical core (pure numpy; a leading draw axis where noted) # ============================================================================= @@ -362,10 +375,12 @@ def _estimate_from_survival( H2 = D2[:, fit_idx] / e_fit c = (H1 - H2) @ w R0 = R1[:, :1] + D2 + elapsed[None, :] * c[:, None] - else: + elif method == "ph": ratio = D1[:, fit_idx] / D2[:, fit_idx] c = ratio @ w R0 = R1[:, :1] + c[:, None] * D2 + else: # pragma: no cover - fit() validates method first + raise ValueError(f"method must be 'cd' or 'ph', got {method!r}") S0 = np.exp(-R0) tau = S0 - S1 return c, R0, S0, tau @@ -862,10 +877,9 @@ def __init__( alpha: float = 0.05, seed: Optional[int] = None, ): - _validate_method(method) - _validate_draws(n_bootstrap) - _validate_alpha(alpha) - _validate_seed(seed) + _validate_all_params( + {"method": method, "n_bootstrap": n_bootstrap, "alpha": alpha, "seed": seed} + ) self.method = method self.n_bootstrap = n_bootstrap self.alpha = alpha @@ -919,6 +933,9 @@ def fit( ------- DurationDiDResults """ + # Re-validate the configuration BEFORE any data work: attributes can + # be mutated directly after construction (bypassing set_params). + _validate_all_params(self.get_params()) method = self.method arranged = _validate_and_arrange(data, outcome, unit, time, treatment, last_pre_period) Y: np.ndarray = arranged["Y"] diff --git a/docs/dev-status.md b/docs/dev-status.md index 499906b8a..905bc5e22 100644 --- a/docs/dev-status.md +++ b/docs/dev-status.md @@ -59,7 +59,7 @@ Target: ideally < 1000 lines per module; modules ≥3000 lines are candidates fo | `datasets.py` | 1224 | Acceptable | | `rdd.py` | 1218 | Acceptable | | `staggered_aggregation.py` | 1204 | Acceptable | -| `duration_did.py` | 1239 | Acceptable — validation + numerical core + bootstrap + inference assembly in one module (results split into `duration_did_results.py`) | +| `duration_did.py` | 1256 | Acceptable — validation + numerical core + bootstrap + inference assembly in one module (results split into `duration_did_results.py`) | | `chaisemartin_dhaultfoeuille_bootstrap.py` | 1175 | Acceptable | | `conley.py` | 1140 | Acceptable | | `rdplot.py` | 1135 | Acceptable | diff --git a/tests/test_duration_did.py b/tests/test_duration_did.py index 89c1307cd..5ac6ab1aa 100644 --- a/tests/test_duration_did.py +++ b/tests/test_duration_did.py @@ -153,6 +153,38 @@ def test_set_params_transactional(self): est.set_params(method="ph") assert est.method == "ph" + @pytest.mark.parametrize( + "attr, bad, msg", + [ + ("method", "typo", "method must be 'cd' or 'ph'"), + ("method", "PH", "method must be 'cd' or 'ph'"), + ("n_bootstrap", 1, "at least 2"), + ("n_bootstrap", -5, "n_bootstrap must be a non-negative integer"), + ("alpha", 1.5, "alpha"), + ("alpha", "0.05", "alpha"), + ("seed", -1, "seed"), + ("seed", 2.5, "seed"), + ], + ) + def test_direct_attribute_mutation_rejected_at_fit(self, attr, bad, msg): + # Bypassing set_params must not reach an estimation branch: an unknown + # method would otherwise fall through to PH. + est = DurationDiD(n_bootstrap=0) + setattr(est, attr, bad) + with pytest.raises(ValueError, match=msg): + fit_quiet(est, micro_panel(), last_pre_period=3) + + def test_mutation_is_checked_before_any_data_work(self): + # The configuration error wins over a data error, proving the check + # runs before the panel is read. + est = DurationDiD(n_bootstrap=0) + est.method = "typo" + with pytest.raises(ValueError, match="method must be 'cd' or 'ph'"): + est.fit(pd.DataFrame(), "exited", "unit", "time", "treated", last_pre_period=3) + est.method = "cd" + with pytest.raises(ValueError, match="outcome column 'exited' not found"): + est.fit(pd.DataFrame(), "exited", "unit", "time", "treated", last_pre_period=3) + def test_selectors_are_fit_time_not_params(self): assert "pre_periods" not in DurationDiD().get_params() assert "last_pre_period" not in DurationDiD().get_params() From 57646dad1e4667c636c137b5290aee55715ee872 Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 7 Sep 2026 12:28:11 -0400 Subject: [PATCH 3/6] fix(duration-did): preserve native time identity and validate real inputs --- changelog.d/20260906-duration-did.md | 8 +- diff_diff/duration_did.py | 142 ++++++--- diff_diff/duration_did_results.py | 45 +-- diff_diff/guides/llms-full.txt | 3 +- diff_diff/practitioner.py | 13 +- docs/api/duration_did.rst | 19 +- docs/methodology/REGISTRY.md | 28 +- tests/test_duration_did.py | 433 +++++++++++++++++++++++++++ 8 files changed, 603 insertions(+), 88 deletions(-) diff --git a/changelog.d/20260906-duration-did.md b/changelog.d/20260906-duration-did.md index 930d18cd9..16d1e84d6 100644 --- a/changelog.d/20260906-duration-did.md +++ b/changelog.d/20260906-duration-did.md @@ -6,7 +6,13 @@ ratio, mean-of-ratios estimator) is fitted on the pre-treatment cumulative hazards — by default with equal weights over every eligible pre-treatment date, or on a user window via `fit(pre_periods=..., pre_period_weights=...)` - — and the treated group's counterfactual survival is imputed from the + — with exact numeric date selection and finite real fitting weights. + Integer dates preserve their identity across supported signed/unsigned + dtype ranges: spacings and offsets are subtracted before float64 elapsed + arithmetic, which must remain finite and strictly increasing. Floating + dates must be losslessly representable as float64; complex inputs are + rejected. Result labels, JSON and practitioner guidance retain the same + dates. The treated group's counterfactual survival is imputed from the control group (Theorem 1). Reports the absorption ATT at every post-treatment date plus its uniform average as `att`, with the paper's whole-individual pooled bootstrap (Appendix B Algorithm 1: centered diff --git a/diff_diff/duration_did.py b/diff_diff/duration_did.py index af7a74513..a2d73fa2e 100644 --- a/diff_diff/duration_did.py +++ b/diff_diff/duration_did.py @@ -23,7 +23,7 @@ import math import warnings -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import numpy as np import pandas as pd @@ -33,6 +33,7 @@ from diff_diff.duration_did_results import ( DurationDiDPretestResults, DurationDiDResults, + _native_time_label, invalid_curve_message, ) from diff_diff.utils import ( @@ -62,6 +63,25 @@ def _errstate() -> Any: return np.errstate(divide="ignore", invalid="ignore", over="ignore", under="ignore") +def _time_scalar(value: Any, name: str) -> Union[int, float]: + """Validate a real date without rounding integer identity or floating labels.""" + if isinstance(value, (bool, np.bool_)) or not isinstance( + value, (int, float, np.integer, np.floating) + ): + raise ValueError(f"{name} must be a numeric real time value, got {value!r}") + if isinstance(value, (int, np.integer)): + # Selectors can be arbitrarily large absent integers. Never send them + # through float() or isfinite(), even just to validate them. + return int(value) + if not np.isfinite(value): + raise ValueError(f"{name} must be a finite numeric time value, got {value!r}") + with _errstate(): + native = float(value) + if not math.isfinite(native) or type(value)(native) != value: + raise ValueError(f"{name} floating time values must be exactly representable as float64") + return native + + # Per-family draw-failure reasons, in FIRST-MATCH precedence order. _POST_FAILURE_ORDER = ( "group_empty", @@ -172,12 +192,17 @@ def _validate_and_arrange( "values to a numeric elapsed scale (e.g. days since the spell " "start) before fitting" ) - if pd.api.types.is_bool_dtype(t_col) or not pd.api.types.is_numeric_dtype(t_col): + if ( + pd.api.types.is_bool_dtype(t_col) + or pd.api.types.is_complex_dtype(t_col) + or not pd.api.types.is_numeric_dtype(t_col) + ): raise ValueError(f"time column {time!r} must be numeric (got dtype {t_col.dtype})") - if t_col.isna().any() or not np.all(np.isfinite(t_col.to_numpy(dtype=float))): + if t_col.isna().any(): raise ValueError(f"time column {time!r} contains missing or non-finite values") grid = np.unique(t_col.to_numpy()) + labels = [_time_scalar(p, f"time column {time!r}") for p in grid] if len(data) == 0 or len(grid) < _MIN_PERIODS: raise ValueError( "DurationDiD requires at least three distinct time periods (baseline, " @@ -201,22 +226,30 @@ def _validate_and_arrange( "an administrative end of a complete window is fine." ) - grid_f = grid.astype(float) - diffs = np.diff(grid_f) + # Subtract native scalars BEFORE conversion: machine integers can wrap, + # while casting absolute dates to float64 can erase entire time steps. + diffs = [right - left for left, right in zip(labels, labels[1:])] step = diffs[0] - if not (np.isfinite(step) and step > 0): + if not all(math.isfinite(d) and d > 0 for d in diffs): raise ValueError("time grid must have a positive finite common spacing") - if not np.allclose(diffs, step, rtol=_SPACING_RTOL, atol=0.0): + if any(abs(d - step) > _SPACING_RTOL * abs(step) for d in diffs): raise ValueError( "DurationDiD requires an equally spaced time grid (relative tolerance " - f"{_SPACING_RTOL:g}); found spacings {np.unique(diffs).tolist()[:5]}" + f"{_SPACING_RTOL:g}); found spacings {sorted(set(diffs))[:5]}" + ) + elapsed = np.asarray([p - labels[0] for p in labels], dtype=float) + if not np.all(np.isfinite(elapsed)) or np.any(elapsed[1:] <= elapsed[:-1]): + raise ValueError( + "time grid must give finite, strictly increasing float64 elapsed durations" ) # Binary columns: explicit float coercion, then missing/non-finite, then # the 0/1 domain (validate_binary strips NaN before its membership test). coerced: Dict[str, np.ndarray] = {} for name, col in (("outcome", outcome), ("treatment", treatment)): - if not (pd.api.types.is_numeric_dtype(data[col]) or pd.api.types.is_bool_dtype(data[col])): + if pd.api.types.is_complex_dtype(data[col]) or not ( + pd.api.types.is_numeric_dtype(data[col]) or pd.api.types.is_bool_dtype(data[col]) + ): bad_vals = pd.unique(data[col].astype(object))[:5].tolist() raise ValueError( f"{name} column {col!r} must be a numeric 0/1 column (got dtype " @@ -243,10 +276,15 @@ def _validate_and_arrange( # Internal frame with fixed names, so a user column named like a temporary # (or a role column named "unit"/"time") can never collide. + # Pandas indexes need float64 for some floating dtypes (e.g. float16). + # This conversion is lossless after _time_scalar validation; retain the + # original grid dtype in results. Integer labels never enter this path. + floating_clock = pd.api.types.is_float_dtype(t_col) + index_grid = grid.astype(float) if floating_clock else grid frame = pd.DataFrame( { "unit": data[unit].to_numpy(), - "time": data[time].to_numpy(), + "time": t_col.to_numpy(dtype=float) if floating_clock else t_col.to_numpy(), "y": coerced["outcome"], "g": coerced["treatment"], } @@ -261,7 +299,7 @@ def _validate_and_arrange( f"units with varying values include {bad}" ) - y_wide = frame.pivot(index="unit", columns="time", values="y").reindex(columns=grid) + y_wide = frame.pivot(index="unit", columns="time", values="y").reindex(columns=index_grid) g_units = frame.groupby("unit")["g"].first().reindex(y_wide.index) Y = y_wide.to_numpy(dtype=float) G = g_units.to_numpy(dtype=float) @@ -282,20 +320,13 @@ def _validate_and_arrange( f"e.g. {bad_units}" ) - if isinstance(last_pre_period, (bool, str)) or not isinstance( - last_pre_period, (int, float, np.integer, np.floating) - ): - raise ValueError( - f"last_pre_period must be a numeric value of the time column, got {last_pre_period!r}" - ) - tstar_val = float(last_pre_period) - matches = np.nonzero(grid_f == tstar_val)[0] - if len(matches) != 1: + tstar_val = _time_scalar(last_pre_period, "last_pre_period") + if tstar_val not in labels: raise ValueError( f"last_pre_period {last_pre_period!r} is not a value of the time column " f"(grid: {grid.tolist()[:8]}{'...' if len(grid) > 8 else ''})" ) - tstar_idx = int(matches[0]) + tstar_idx = labels.index(tstar_val) if tstar_idx == 0: raise ValueError( "last_pre_period equals the first date; at least two pre-treatment dates " @@ -311,7 +342,7 @@ def _validate_and_arrange( "Y": np.ascontiguousarray(Y[order]), "n_treated": n_treated, "grid": grid, - "elapsed": grid_f - grid_f[0], + "elapsed": elapsed, "tstar_idx": tstar_idx, } @@ -462,7 +493,7 @@ def _resolve_fit_periods( are renormalized to sum to one. ``F`` and the weights are frozen for every bootstrap draw. """ - grid_f = grid.astype(float) + labels = [_native_time_label(p) for p in grid] if pre_period_weights is not None and pre_periods is None: raise ValueError("pre_period_weights requires pre_periods (the dates the weights refer to)") @@ -475,10 +506,9 @@ def _resolve_fit_periods( raise ValueError("pre_periods must name at least one pre-treatment date") idx_list: List[int] = [] for p in req: - hit = np.nonzero(grid_f == p)[0] - if len(hit) != 1: + if p not in labels: raise ValueError(f"Pre-period '{_fmt(p)}' not found in time column") - k = int(hit[0]) + k = labels.index(p) if k < 1 or k > tstar_idx: raise ValueError( f"pre_periods value {_fmt(p)} must lie strictly after the baseline " @@ -511,7 +541,7 @@ def _resolve_fit_periods( keep_w: List[float] = [] S1, S2, D2 = S[0], S[1], D[1] for k, w in zip(cand_idx.tolist(), cand_w.tolist()): - label = grid[k].item() if hasattr(grid[k], "item") else grid[k] + label = _label(grid, k) if w == 0: excluded[label] = "zero_weight" elif S1[k] <= 0: @@ -557,7 +587,7 @@ def _resolve_fit_periods( def _selector_array(value: Any, name: str, kind: str) -> np.ndarray: - """Coerce a fit-time selector to a 1-d float array with a typed guard. + """Flatten ordered inputs, preserving date identity separately from weights. Scalars, strings and bytes are rejected explicitly: ``list("34")`` would otherwise split a numeric string into two different dates and silently @@ -573,20 +603,26 @@ def _selector_array(value: Any, name: str, kind: str) -> np.ndarray: f"to align with its companion selector), got {value!r}" ) try: - arr = np.asarray(list(value), dtype=float).ravel() - except (TypeError, ValueError): - raise ValueError(f"{name} must be a list of numeric {kind}, got {value!r}") from None + raw = np.asarray(list(value), dtype=object).ravel() + if name == "pre_periods": + arr = np.asarray([_time_scalar(p, name) for p in raw], dtype=object) + else: + if any(isinstance(p, (complex, np.complexfloating)) for p in raw): + raise ValueError("weights must be real") + with _errstate(): + arr = np.asarray(raw, dtype=float) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"{name} must be a list of numeric {kind}, got {value!r}: {exc}") from None return arr def _label(grid: np.ndarray, k: int) -> Any: """Native Python scalar for a grid date (never a numpy repr in messages).""" - v = grid[k] - return v.item() if hasattr(v, "item") else v + return _native_time_label(grid[k]) -def _fmt(p: float) -> Any: - return int(p) if float(p).is_integer() else p +def _fmt(p: Union[int, float]) -> Union[int, float]: + return int(p) if isinstance(p, float) and p.is_integer() else p def _pretest_contrasts( @@ -908,24 +944,37 @@ def fit( data : pd.DataFrame Long panel with exactly one row per (individual, date). outcome : str - Binary absorbing spell-ended indicator column (0/1; once 1, + Real numeric or boolean absorbing spell-ended indicator (0/1; once 1, always 1 within an individual). Baseline absorption is allowed. unit : str Individual identifier column. time : str - Numeric calendar or elapsed-duration column; every individual - must be observed at the same equally spaced dates. + Real numeric calendar or elapsed-duration column; every individual + must be observed at the same equally spaced dates (relative + tolerance 1e-8, zero absolute tolerance). Signed/unsigned integer + labels, including nullable integer dtypes without missing values, + retain exact identity across their dtype's range. Differences are + computed before conversion to float64 elapsed durations, which + must remain finite and strictly increasing. Floating labels must + be finite and exactly representable as float64; this includes + float32 and exactly representable longdouble values. Object, + string, boolean and complex time columns are not supported. + Precision already lost in caller-created floats cannot be recovered. treatment : str - Fixed 0/1 group indicator (constant within individual). + Fixed real numeric or boolean 0/1 group indicator (constant + within individual). last_pre_period : value of ``time`` The last untreated date (``tstar``); the intervention occurs - strictly afterwards. Never inferred from the data. + strictly afterwards. Matched by exact numeric identity, never + inferred from the data. Boolean, string, complex and nonfinite + date selectors are rejected. pre_periods : list of ``time`` values, optional Pre-treatment dates used to fit the hazard relationship (strictly after the baseline, at or before ``last_pre_period``). Default: - every eligible pre-treatment date after the baseline. + every eligible pre-treatment date after the baseline. Dates use + the same exact numeric matching as ``last_pre_period``. pre_period_weights : array-like, optional - Nonnegative weights aligned with ``pre_periods`` (normalized to + Finite real nonnegative weights aligned with ``pre_periods`` (normalized to sum to one; a zero weight drops that date). Requires ``pre_periods``. Default: equal weights over the eligible set. @@ -1159,9 +1208,7 @@ def fit( pretest = DurationDiDPretestResults( method=method, periods=grid[J] if n_J else grid[:0], - anchor_period=( - grid[tstar_idx].item() if hasattr(grid[tstar_idx], "item") else grid[tstar_idx] - ), + anchor_period=_label(grid, tstar_idx), contrast=np.asarray(delta, dtype=float), se=pre_se, band_lower=pre_lo, @@ -1199,9 +1246,6 @@ def fit( for msg in messages: warnings.warn(msg, UserWarning, stacklevel=2) - def unit_label(k: int) -> Any: - return grid[k].item() if hasattr(grid[k], "item") else grid[k] - results = DurationDiDResults( att=headline, se=se_head, @@ -1220,7 +1264,7 @@ def unit_label(k: int) -> Any: n_control=int(n_control), n_periods=int(n_periods), periods=grid.copy(), - last_pre_period=unit_label(tstar_idx), + last_pre_period=_label(grid, tstar_idx), post_periods=grid[post_idx].copy(), pre_periods=grid[fit_idx].copy(), pre_period_weights=fit_w.copy(), diff --git a/diff_diff/duration_did_results.py b/diff_diff/duration_did_results.py index 4f182555e..6160a5479 100644 --- a/diff_diff/duration_did_results.py +++ b/diff_diff/duration_did_results.py @@ -18,7 +18,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, ClassVar, Dict, List, Optional, Tuple +from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union import numpy as np import pandas as pd @@ -30,7 +30,6 @@ Diagnostic, EventStudyResults, _coverage_pct, - _json_safe_label, _require_fit_alpha, ) @@ -56,9 +55,8 @@ def invalid_curve_message(periods: Any, curve_status: List[str], last_pre_period explicit refit on dates at or before the last date strictly before the first invalid post date, when at least one post date survives. """ - labels = [p.item() if hasattr(p, "item") else p for p in np.asarray(periods).tolist()] - grid_f = np.asarray(periods, dtype=float) - tstar_idx = int(np.nonzero(grid_f == float(last_pre_period))[0][0]) + labels = [_native_time_label(p) for p in np.asarray(periods).tolist()] + tstar_idx = labels.index(_native_time_label(last_pre_period)) flagged = [(labels[t], s) for t, s in enumerate(curve_status) if s != "ok"] msg = ( "Invalid imputed counterfactual curve at " @@ -108,6 +106,12 @@ def _scalar(x: Any) -> Any: return x +def _native_time_label(value: Any) -> Union[int, float]: + """Python scalar for an already validated numeric clock (including longdouble).""" + value = _scalar(value) + return value if isinstance(value, int) else float(value) + + @dataclass class DurationDiDPretestResults(Diagnostic): """Appendix B Algorithm 2 pre-treatment specification test. @@ -178,7 +182,7 @@ def to_dataframe(self) -> pd.DataFrame: """One row per tested pre-treatment date.""" return pd.DataFrame( { - "period": np.asarray(self.periods).tolist(), + "period": [_native_time_label(p) for p in np.asarray(self.periods).tolist()], "contrast": np.asarray(self.contrast, dtype=float), "se": np.asarray(self.se, dtype=float), "band_lower": np.asarray(self.band_lower, dtype=float), @@ -190,8 +194,8 @@ def to_dict(self) -> Dict[str, Any]: """JSON-serializable dictionary.""" return { "method": self.method, - "periods": [_json_safe_label(p) for p in np.asarray(self.periods).tolist()], - "anchor_period": _json_safe_label(self.anchor_period), + "periods": [_native_time_label(p) for p in np.asarray(self.periods).tolist()], + "anchor_period": _native_time_label(self.anchor_period), "contrast": _to_list(self.contrast), "se": _to_list(self.se), "band_lower": _to_list(self.band_lower), @@ -214,7 +218,7 @@ def summary(self) -> str: f" Contrast: {kind} at each interior pre-date minus its value at " f"the anchor {self.anchor_period!r}", f" Status: {self.status}", - f" Tested dates: {np.asarray(self.periods).tolist()}", + f" Tested dates: {[_native_time_label(p) for p in np.asarray(self.periods).tolist()]}", ] if self.status == "ok": lines.append( @@ -400,13 +404,13 @@ def to_dict(self) -> Dict[str, Any]: "n_treated": int(self.n_treated), "n_control": int(self.n_control), "n_periods": int(self.n_periods), - "periods": [_json_safe_label(p) for p in np.asarray(self.periods).tolist()], - "last_pre_period": _json_safe_label(self.last_pre_period), - "post_periods": [_json_safe_label(p) for p in np.asarray(self.post_periods).tolist()], - "pre_periods": [_json_safe_label(p) for p in np.asarray(self.pre_periods).tolist()], + "periods": [_native_time_label(p) for p in np.asarray(self.periods).tolist()], + "last_pre_period": _native_time_label(self.last_pre_period), + "post_periods": [_native_time_label(p) for p in np.asarray(self.post_periods).tolist()], + "pre_periods": [_native_time_label(p) for p in np.asarray(self.pre_periods).tolist()], "pre_period_weights": _to_list(self.pre_period_weights), "excluded_pre_periods": { - str(_json_safe_label(k)): v for k, v in self.excluded_pre_periods.items() + str(_native_time_label(k)): v for k, v in self.excluded_pre_periods.items() }, "coefficient": float(self.coefficient), "ph_ratio_boundary": bool(self.ph_ratio_boundary), @@ -464,7 +468,9 @@ def to_dataframe(self, level: str = "periods") -> pd.DataFrame: ci = np.asarray(self.conf_int_by_period, dtype=float).reshape(-1, 2) return pd.DataFrame( { - "period": np.asarray(self.post_periods).tolist(), + "period": [ + _native_time_label(p) for p in np.asarray(self.post_periods).tolist() + ], "att": np.asarray(self.att_by_period, dtype=float), "se": np.asarray(self.se_by_period, dtype=float), "t_stat": np.asarray(self.t_stat_by_period, dtype=float), @@ -494,7 +500,7 @@ def summary(self, alpha: Optional[float] = None) -> str: f"Observations: {self.n_obs}; Periods: {self.n_periods}", f"Last pre-treatment period: {self.last_pre_period!r}; " f"post-treatment periods: {len(self.post_periods)}", - f"Fitting periods: {np.asarray(self.pre_periods).tolist()} " + f"Fitting periods: {[_native_time_label(p) for p in np.asarray(self.pre_periods).tolist()]} " f"(weights {np.round(np.asarray(self.pre_period_weights, dtype=float), 4).tolist()})", f"Fitted coefficient: {self.coefficient:.6f}", "", @@ -512,12 +518,7 @@ def summary(self, alpha: Optional[float] = None) -> str: ] frame = self.to_dataframe(level="periods") lines.append(frame.to_string(index=False, float_format=lambda v: f"{v:.6f}")) - flagged = [ - (p, s) - for p, s in zip(np.asarray(self.periods).tolist(), self.curve_status) - if s != "ok" - ] - if flagged: + if any(status != "ok" for status in self.curve_status): lines.append("") lines.append( invalid_curve_message(self.periods, self.curve_status, self.last_pre_period) diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index f08005a1a..36cd692e5 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -1448,7 +1448,8 @@ DurationDiD( ``` Key contracts: -- Input: exactly one row per (individual, date) on a common, equally spaced numeric time grid (datetime must be converted to an elapsed scale); `treatment` is a FIXED 0/1 group indicator; `outcome` is 0/1 and absorbing (reversals raise). Missing cells, late entry, and dropout raise; an administrative end of a complete window is fine. +- Input: exactly one row per (individual, date) on a common, equally spaced real numeric time grid (datetime must be converted to an elapsed scale); `treatment` is a FIXED 0/1 group indicator; `outcome` is 0/1 and absorbing (reversals raise). Both binary columns require real numeric or boolean dtype. Missing cells, late entry, and dropout raise; an administrative end of a complete window is fine. +- Time identity: signed/unsigned integer labels (including nullable integer columns without missing values) retain exact identity across their dtype ranges. Subtract native dates before float64 elapsed arithmetic; check spacing on the unrounded differences with rtol=1e-8 and atol=0. Elapsed offsets must be finite and strictly increasing. Floating labels must be finite and losslessly representable as float64, including float32 and exactly representable longdouble values; object/string/bool/complex time columns are rejected. Date selectors match exact numeric values and reject bool/string/complex/nonfinite entries. Inference cannot reconstruct caller precision already lost in floats. Fitting weights must be finite, real and nonnegative. - `results.att_by_period`, `se_by_period`, `conf_int_by_period` (pointwise), `cband_lower/upper` + `cband_crit_value` (simultaneous), `joint_p_value`, `vcov` (P x P bootstrap covariance), `coefficient` (fitted c), `survival_treated/control`, `counterfactual_survival`, `pre_periods`/`pre_period_weights` (realized), `excluded_pre_periods`. - Every inference family is fully available or fully withheld: `inference_status` in `ok` / `disabled` (n_bootstrap=0) / `unavailable_invalid_periods` (the CD model extrapolated an invalid counterfactual curve — survival above one or a decreasing cumulative hazard — `curve_status`/`period_status` name the dates; the warning names the applicable remedy: a fitted pre-date violation cannot be repaired by a shorter horizon — change `method` or the fitting window; a later post-date violation admits an explicit refit on the dates before the first invalid post date; a first-post-date violation admits no valid shorter horizon) / `unavailable_failed_draws` (any failed draw; fixed draws, no retries, `bootstrap_failure_reasons` per family) / `unavailable_zero_se`. `n_draws_invalid_counterfactual` counts complete draws whose imputed curve left the domain (not failures; a large share signals weak extrapolation support — shown in `summary()`). `p_value <= alpha` is exactly the band rule (`is_significant`). - `results.pretest` (`DurationDiDPretestResults`, a Diagnostic): contrasts of the CD gap / PH ratio at every interior pre-date against the fixed anchor `last_pre_period`, simultaneous band, `statistic`, `p_value`, `reject`, `status` (needs at least three pre-dates). A non-rejection does not establish identification. diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index faaa4e21f..82c17fba3 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -2380,14 +2380,16 @@ def _handle_duration_did(results: Any): before the original anchor BEFORE refitting one step earlier, so the placebo family never contains a genuinely treated date. """ + from diff_diff.duration_did_results import _native_time_label + method = getattr(results, "method", "cd") other = "ph" if method == "cd" else "cd" pretest = getattr(results, "pretest", None) pretest_status = getattr(pretest, "status", "unknown") last_pre = getattr(results, "last_pre_period", None) - raw_periods = list(getattr(results, "periods", [])) - periods = [float(p) for p in raw_periods] - n_pre = sum(1 for p in periods if last_pre is not None and p <= float(last_pre)) + last_pre = _native_time_label(last_pre) if last_pre is not None else None + periods = [_native_time_label(p) for p in getattr(results, "periods", [])] + n_pre = sum(1 for p in periods if last_pre is not None and p <= last_pre) steps = [ _step( @@ -2433,9 +2435,8 @@ def _handle_duration_did(results: Any): if n_pre >= 3 and last_pre is not None: # The preceding anchor is the exact stored grid scalar (never a # floating-point subtraction, which would miss a decimal grid value). - anchor_idx = periods.index(float(last_pre)) - earlier_raw = raw_periods[anchor_idx - 1] - earlier_repr = repr(earlier_raw.item() if hasattr(earlier_raw, "item") else earlier_raw) + anchor_idx = periods.index(last_pre) + earlier_repr = repr(periods[anchor_idx - 1]) steps.append( _step( baker_step=6, diff --git a/docs/api/duration_did.rst b/docs/api/duration_did.rst index 8ac2fdad7..53441e7c4 100644 --- a/docs/api/duration_did.rst +++ b/docs/api/duration_did.rst @@ -33,10 +33,25 @@ retried or silently dropped. .. note:: Requirements: exactly one row per (individual, date) on a common, equally - spaced numeric time grid; a fixed 0/1 group indicator; a 0/1 absorbing + spaced real numeric time grid; a fixed real numeric or boolean 0/1 group + indicator; a real numeric or boolean 0/1 absorbing outcome (baseline absorption is allowed and those individuals stay in the estimand). ``last_pre_period`` (the last untreated date) is required and - never inferred. Covariates, staggered adoption, censoring, survey weights + never inferred. Dates are matched by exact numeric identity. Integer + labels retain their identity across the supported signed/unsigned dtype + ranges (including nullable integer columns without missing values). + Spacings and offsets are subtracted before conversion to float64 elapsed + durations; the spacing check uses relative tolerance 1e-8 and zero absolute + tolerance, and elapsed durations must remain finite and strictly increasing. + Floating labels must be finite and exactly representable as float64 + (including float32 and exactly representable longdouble values). Object, + string, boolean and complex time columns are rejected. Inference uses + float64 elapsed arithmetic and cannot reconstruct precision already lost + when callers created floating labels. Date selectors reject booleans, + strings, complex and nonfinite values; fitting weights must be finite, + real and nonnegative. + + Covariates, staggered adoption, censoring, survey weights and cluster dependence are not supported in this version; the CD model can extrapolate an invalid counterfactual curve (survival above one or a decreasing cumulative hazard), which is reported through diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index af53f6abf..04226aabe 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -5835,10 +5835,13 @@ individuals stay in the estimand (their effects are zero under the assumptions). on outcome levels. Tested separately by the Algorithm 2 pretest below; a non-rejection does not establish identification. - **Data shape:** exactly one row per (individual, date); every individual on the - same equally spaced numeric time grid (relative check - `allclose(diffs, diffs[0], rtol=1e-8, atol=0)`, at least three distinct dates: + same equally spaced real numeric time grid (relative check on native + differences, `abs(diff - first_diff) <= 1e-8 * abs(first_diff)`, zero absolute + tolerance; at least three distinct dates: baseline, `tstar`, one post-date); missing cells, late entry, dropout, - datetime `time`, NaN identifiers/cells, non-numeric binary columns all raise. + datetime/object/string/boolean/complex `time`, NaN identifiers/cells, and + non-real binary columns all raise. Binary columns must have real numeric or + boolean dtype, with values 0/1; numeric strings are not coerced. Baseline survival and survival at `tstar` must be positive in both groups. - **Bootstrap validity:** independent individuals with arbitrary serial dependence within a history (Section 3.2); `n_bootstrap=0` gives point @@ -5862,9 +5865,10 @@ The treated baseline `R_hat[1,1]` sits outside the PH exponent or before `tstar`, equal weights. Eligibility: positive survival in both groups (implied by positive survival at `tstar`) and, under PH, a positive control cumulative-hazard increment. `pre_periods` selects a subset (an ordered list of - values of `time`, validated against the grid; duplicates, sets, scalars, - strings and baseline/post dates raise); `pre_period_weights` (requires - `pre_periods`; finite, nonnegative, not all zero) is normalized to sum to one + values of `time`, matched by exact numeric identity against the grid; + duplicates, sets, scalars, strings, boolean/complex/nonfinite date values + and baseline/post dates raise); `pre_period_weights` (requires + `pre_periods`; finite, real, nonnegative, not all zero) is normalized to sum to one scale-invariantly (divided by its maximum before its sum, so weights near the float64 limit cannot overflow); a zero weight drops that date. Eligibility exclusions warn and renormalize the remaining weights; an empty fitting set @@ -6008,7 +6012,17 @@ equivalently `p <= alpha`. discreteness: with `B` draws the p-value has resolution `1/B`. - **Note:** Equally spaced numeric time grid required in this version (elapsed durations are used internally, so unequal spacing is a later relaxation, not a - formula change). + formula change). Signed/unsigned integer labels retain exact identity across + their supported column dtype ranges, including nullable integer columns + without missing values; object columns remain unsupported. Native scalar + subtraction precedes conversion of offsets to float64, avoiding integer + overflow and loss from large absolute origins. Spacing is checked before + this conversion, and float64 elapsed offsets must remain finite and strictly + increasing. Absolute floating labels must be finite and exactly representable + as float64 (float32 and exactly representable longdouble values are supported). + Date selectors use exact numeric identity; a rounded float cannot select an + adjacent integer. Inference arithmetic uses float64 elapsed durations and + cannot reconstruct precision already lost by callers when creating floats. - **Note:** `n_bootstrap=0` = point estimates with joint-NaN inference (library convention shared with CiC/ContinuousDiD); `n_bootstrap=1` rejected. - **Note:** `n_units` = individuals (the resampling unit), `n_obs` = panel rows diff --git a/tests/test_duration_did.py b/tests/test_duration_did.py index 5ac6ab1aa..07d35c900 100644 --- a/tests/test_duration_did.py +++ b/tests/test_duration_did.py @@ -1021,3 +1021,436 @@ def test_practitioner_placebo_code_runs(self, sim_df, fitted): warnings.simplefilter("ignore") exec(code, ns) assert "placebo" in ns + + +# --------------------------------------------------------------------------- +# Numeric clock identity, real-input guards and downstream date consumers +# --------------------------------------------------------------------------- + + +def _relabel_panel(data, clock, dtype=None): + """Assign the full typed array so pandas row construction cannot hide its dtype.""" + result = data.copy() + values = np.tile(clock, len(data) // len(clock)) + result["time"] = pd.array(values, dtype=dtype) if dtype is not None else values + return result + + +class TestNumericClock: + @pytest.mark.parametrize("method", ["cd", "ph"]) + @pytest.mark.parametrize( + "dtype, origin", + [ + ("int64", 2**53), + ("int64", 2**53 + 1), + ("int64", -(2**63)), + ("int64", 2**63 - 15), + ("uint64", 2**64 - 15), + ("Int64", 2**53 + 1), + ("UInt64", 2**64 - 15), + ], + ) + def test_integer_translation_preserves_full_inference(self, method, dtype, origin): + data = simulate_panel(n=600, method=method, c=0.05 if method == "cd" else 1.3) + small = np.arange(8) * 2 + clock = np.array([origin + int(t) for t in small], dtype=dtype.lower()) + original = fit_quiet( + DurationDiD(method=method, n_bootstrap=40, seed=7), + _relabel_panel(data, small), + last_pre_period=6, + pre_periods=[2, 6], + pre_period_weights=[1, 3], + ) + translated = fit_quiet( + DurationDiD(method=method, n_bootstrap=40, seed=7), + _relabel_panel(data, clock, dtype), + last_pre_period=int(clock[3]), + pre_periods=[clock[1], int(clock[3])], + pre_period_weights=[1, 3], + ) + assert original.inference_status == translated.inference_status == "ok" + assert original.pretest.status == translated.pretest.status == "ok" + for field in ( + "coefficient", + "att", + "se", + "t_stat", + "p_value", + "conf_int", + "att_by_period", + "counterfactual_survival", + "se_by_period", + "conf_int_by_period", + "cband_lower", + "cband_upper", + "cband_crit_value", + "joint_p_value", + "vcov", + "bootstrap_effects", + ): + np.testing.assert_array_equal(getattr(translated, field), getattr(original, field)) + for field in ( + "contrast", + "se", + "band_lower", + "band_upper", + "statistic", + "p_value", + "reject", + ): + np.testing.assert_array_equal( + getattr(translated.pretest, field), getattr(original.pretest, field) + ) + assert translated.curve_status == original.curve_status + assert translated.period_status == original.period_status + assert translated.bootstrap_failure_reasons == original.bootstrap_failure_reasons + assert translated.n_draws_invalid_counterfactual == original.n_draws_invalid_counterfactual + expected = [origin + int(t) for t in small] + assert translated.periods.tolist() == expected + labels = translated.to_dict() + assert labels["periods"] == expected + assert all(type(p) is int for p in labels["periods"]) + assert labels["last_pre_period"] == expected[3] + assert json.loads(json.dumps(labels))["periods"] == expected + + @pytest.mark.parametrize("method", ["cd", "ph"]) + def test_native_unequal_large_origin_rejected(self, method): + clock = [2**53, 2**53 + 2, 2**53 + 3] + data = build_from_survivors(100, [100, 80, 50], 100, [100, 90, 70], times=clock) + with pytest.raises(ValueError, match="equally spaced"): + DurationDiD(method=method, n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=clock[1]) + + @pytest.mark.parametrize("origin", [0, 2**53 + 1]) + @pytest.mark.parametrize( + "deviation, accepted", [(-11, False), (-9, True), (9, True), (11, False)] + ) + def test_spacing_tolerance_uses_first_native_step(self, origin, deviation, accepted): + clock = [origin, origin + 10**9, origin + 2 * 10**9 + deviation] + data = build_from_survivors(100, [100, 80, 50], 100, [100, 90, 70], times=clock) + if accepted: + result = fit_quiet(DurationDiD(n_bootstrap=0), data, last_pre_period=clock[1]) + # Keep the actual duration even when its spacing is within tolerance. + c = (math.log(100 / 80) - math.log(100 / 90)) / 10**9 + expected = math.exp(-math.log(100 / 70) - (2 * 10**9 + deviation) * c) - 0.5 + assert result.att == pytest.approx(expected, abs=1e-14) + else: + with pytest.raises(ValueError, match="equally spaced"): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=clock[1]) + + @pytest.mark.parametrize("sign", [-1, 1]) + @pytest.mark.parametrize("margin, accepted", [(-1, True), (1, False)]) + def test_large_spacing_comparison_precedes_float_rounding(self, sign, margin, accepted): + deviation = sign * (10**10 + margin) + clock = [0, 10**18, 2 * 10**18 + deviation] + data = build_from_survivors(100, [100, 80, 50], 100, [100, 90, 70], times=clock) + assert (abs(deviation) <= 10**10) == accepted + if accepted: + result = fit_quiet(DurationDiD(n_bootstrap=0), data, last_pre_period=clock[1]) + assert np.isfinite(result.att) + else: + with pytest.raises(ValueError, match="equally spaced"): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=clock[1]) + + @pytest.mark.parametrize( + "delta, accepted", [(9e-9, True), (11e-9, False), (-9e-9, True), (-11e-9, False)] + ) + def test_float_spacing_boundary(self, delta, accepted): + data = build_from_survivors( + 100, [100, 80, 50], 100, [100, 90, 70], times=[0.0, 1.0, 2.0 + delta] + ) + if accepted: + assert np.isfinite(fit_quiet(DurationDiD(n_bootstrap=0), data, last_pre_period=1.0).att) + else: + with pytest.raises(ValueError, match="equally spaced"): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=1.0) + + def test_elapsed_offsets_do_not_wrap_signed_integer_range(self): + step = 2**62 + 1 + clock = np.array([-(2**63) + k * step for k in range(4)], dtype=np.int64) + data = build_from_survivors(100, [100, 80, 60, 40], 100, [100, 90, 80, 70], times=clock) + result = fit_quiet( + DurationDiD(n_bootstrap=0), + data, + last_pre_period=int(clock[2]), + pre_period_weights=[1, 3], + pre_periods=[int(clock[1]), int(clock[2])], + ) + elapsed = np.array([int(t) - int(clock[0]) for t in clock], dtype=float) + reference = sum( + w * (math.log(100 / s1) - math.log(100 / s2)) / e + for w, s1, s2, e in zip([0.25, 0.75], [80, 60], [90, 80], elapsed[1:3]) + ) + np.testing.assert_allclose(result.coefficient, reference, rtol=1e-14, atol=0) + arranged = dd_module._validate_and_arrange(data, **FIT_KW, last_pre_period=int(clock[2])) + np.testing.assert_array_equal(arranged["elapsed"], elapsed) + assert int(clock[2]) - int(clock[0]) > np.iinfo(np.int64).max + + @pytest.mark.parametrize("clock", [[1, 2, 3], [1.0, 2.0, 3.0], [10**400 + k for k in range(3)]]) + def test_numeric_object_clock_remains_unsupported(self, clock): + data = build_from_survivors(100, [100, 80, 50], 100, [100, 90, 70]) + data["time"] = pd.Series( + np.tile(np.array(clock, dtype=object), len(data) // 3), dtype=object + ) + with pytest.raises(ValueError, match="time column 'time' must be numeric.*object"): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=clock[1]) + + @pytest.mark.parametrize("clock", [[-1.7e308, 0.0, 1.7e308], [-1.7e308, -1.6e308, 1.7e308]]) + def test_unrepresentable_elapsed_clock_rejected(self, clock): + data = build_from_survivors(100, [100, 80, 50], 100, [100, 90, 70], times=clock) + with pytest.raises(ValueError, match="finite"): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=clock[1]) + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) + def test_nonfinite_clock_rejected_before_panel_arrangement(self, value): + data = micro_panel() + data["time"] = data["time"].astype(float) + data.loc[0, "time"] = value + with pytest.raises(ValueError, match="time column 'time'.*finite"): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=3) + + @pytest.mark.parametrize("dtype", [np.float16, np.float32, np.float64, np.longdouble]) + def test_representable_float_clock_and_json(self, dtype): + clock = np.array([0.125, 0.25, 0.375, 0.5, 0.625], dtype=dtype) + data = _relabel_panel(micro_panel(), clock) + assert data.time.to_numpy().dtype.type is dtype + result = fit_quiet( + DurationDiD(n_bootstrap=0), + data, + last_pre_period=dtype(0.375), + pre_periods=[dtype(0.25), dtype(0.375)], + pre_period_weights=[0, 1], + ) + assert result.periods.dtype.type is dtype + assert type(result.last_pre_period) is float + assert type(result.pretest.anchor_period) is float + assert list(result.excluded_pre_periods) == [0.25] + assert type(next(iter(result.excluded_pre_periods))) is float + d = json.loads(json.dumps(result.to_dict())) + p = json.loads(json.dumps(result.pretest.to_dict())) + assert d["periods"] == [0.125, 0.25, 0.375, 0.5, 0.625] + assert d["excluded_pre_periods"] == {"0.25": "zero_weight"} + assert p["anchor_period"] == d["last_pre_period"] == 0.375 + assert p["periods"] == [0.25] + assert "np." not in result.summary() + result.pretest.summary() + for frame in [result.to_dataframe(), result.pretest.to_dataframe()]: + assert np.isfinite(frame["period"].to_numpy()).all() + steps = practitioner_next_steps(result, verbose=False)["next_steps"] + code = next(s["code"] for s in steps if "placebo_data =" in s["code"]) + namespace = {"data": data, "DurationDiD": DurationDiD} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + exec(code, namespace) + assert namespace["placebo"].last_pre_period == 0.25 + + @pytest.mark.skipif( + np.finfo(np.longdouble).nmant == np.finfo(float).nmant, + reason="longdouble has no extra mantissa bits on this platform", + ) + @pytest.mark.parametrize("field", ["time", "last_pre_period", "pre_periods"]) + def test_lossy_wide_float_rejected(self, field): + extra = np.longdouble(1) + np.finfo(np.longdouble).eps + data = micro_panel() + kw = dict(last_pre_period=3) + if field == "time": + clock = np.arange(1, 6, dtype=np.longdouble) + np.finfo(np.longdouble).eps + data = _relabel_panel(data, clock) + assert data.time.to_numpy().dtype.type is np.longdouble + else: + kw[field] = extra if field == "last_pre_period" else [extra] + with pytest.raises(ValueError, match="exactly representable as float64"): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, **kw) + + +class TestExactDateSelectors: + @pytest.mark.parametrize("field", ["last_pre_period", "pre_periods"]) + @pytest.mark.parametrize( + "value", [True, False, np.bool_(True), np.bool_(False), "1", 1 + 0j, np.complex128(1)] + ) + def test_non_real_date_rejected(self, field, value): + data = micro_panel(times=[-1, 0, 1, 2, 3]) + kw = dict(last_pre_period=1) + kw[field] = value if field == "last_pre_period" else [value] + with pytest.raises(ValueError, match="numeric"): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, **kw) + + @pytest.mark.parametrize("field", ["last_pre_period", "pre_periods"]) + @pytest.mark.parametrize( + "value", + [ + float("nan"), + float("inf"), + float("-inf"), + np.float32("nan"), + np.float64("inf"), + np.float64("-inf"), + ], + ) + def test_nonfinite_date_rejected_before_lookup(self, field, value): + kw = dict(last_pre_period=3) + kw[field] = value if field == "last_pre_period" else [value] + with pytest.raises(ValueError, match="finite numeric time"): + DurationDiD(n_bootstrap=0).fit(micro_panel(), **FIT_KW, **kw) + + @pytest.mark.parametrize("field", ["last_pre_period", "pre_periods"]) + def test_oversized_absent_integer_is_reported_exactly(self, field): + absent = 10**400 + kw = dict(last_pre_period=3) + kw[field] = absent if field == "last_pre_period" else [absent] + with pytest.raises(ValueError) as err: + DurationDiD(n_bootstrap=0).fit(micro_panel(), **FIT_KW, **kw) + assert str(absent) in str(err.value) + assert "not " in str(err.value) + + @pytest.mark.parametrize("field", ["last_pre_period", "pre_periods"]) + def test_rounded_float_cannot_select_odd_integer(self, field): + clock = np.array([2**53 + 1 + 2 * k for k in range(5)], dtype=np.int64) + kw = dict(last_pre_period=int(clock[2])) + value = float(int(clock[2])) + assert value != int(clock[2]) + kw[field] = value if field == "last_pre_period" else [value] + with pytest.raises(ValueError, match="not (a value|found)"): + DurationDiD(n_bootstrap=0).fit(_relabel_panel(micro_panel(), clock), **FIT_KW, **kw) + + @pytest.mark.parametrize("field", ["last_pre_period", "pre_periods"]) + @pytest.mark.parametrize("scalar_type", [int, np.int64]) + def test_absent_integer_neighbor_does_not_match(self, field, scalar_type): + clock = np.array([2**53 + 1 + 2 * k for k in range(5)], dtype=np.int64) + absent = scalar_type(2**53 + 4) + kw = dict(last_pre_period=int(clock[2])) + kw[field] = absent if field == "last_pre_period" else [absent] + with pytest.raises(ValueError, match="not (a value|found)"): + DurationDiD(n_bootstrap=0).fit(_relabel_panel(micro_panel(), clock), **FIT_KW, **kw) + + @pytest.mark.parametrize("field", ["last_pre_period", "pre_periods"]) + def test_integer_cannot_alias_neighboring_float_grid(self, field): + clock = np.array([2**53 + 2 * k for k in range(5)], dtype=float) + absent = 2**53 + 3 + kw = dict(last_pre_period=float(clock[2])) + kw[field] = absent if field == "last_pre_period" else [absent] + with pytest.raises(ValueError, match="not (a value|found)"): + DurationDiD(n_bootstrap=0).fit(_relabel_panel(micro_panel(), clock), **FIT_KW, **kw) + + def test_mixed_selector_list_preserves_adjacent_large_integers(self): + origin = 2**53 + clock = np.array([origin + k for k in range(5)], dtype=np.int64) + result = fit_quiet( + DurationDiD(n_bootstrap=0), + _relabel_panel(micro_panel(), clock), + last_pre_period=np.int64(origin + 3), + pre_periods=[origin + 1, float(origin + 2), np.int64(origin + 3)], + pre_period_weights=[0, 1, 3], + ) + assert result.pre_periods.tolist() == [origin + 2, origin + 3] + assert result.last_pre_period == origin + 3 + assert result.excluded_pre_periods == {origin + 1: "zero_weight"} + assert result.to_dict()["excluded_pre_periods"] == {str(origin + 1): "zero_weight"} + np.testing.assert_array_equal(result.pre_period_weights, [0.25, 0.75]) + + @pytest.mark.parametrize("shape", ["2d", "generator"]) + def test_ordered_selector_flattening_preserves_alignment(self, shape): + periods = [[3, 2]] if shape == "2d" else (p for p in [3, 2]) + weights = [[3, 1]] if shape == "2d" else (w for w in [3, 1]) + result = fit_quiet( + DurationDiD(n_bootstrap=0), + micro_panel(), + last_pre_period=3.0, + pre_periods=periods, + pre_period_weights=weights, + ) + reference = fit_quiet( + DurationDiD(n_bootstrap=0), + micro_panel(), + last_pre_period=3, + pre_periods=[3.0, 2.0], + pre_period_weights=[3, 1], + ) + np.testing.assert_array_equal(result.pre_periods, reference.pre_periods) + np.testing.assert_array_equal(result.pre_period_weights, reference.pre_period_weights) + assert result.coefficient == reference.coefficient + + +class TestRealInputs: + @pytest.mark.parametrize( + "column, message", [("exited", "outcome"), ("treated", "treatment"), ("time", "time")] + ) + @pytest.mark.parametrize("imaginary", [0, 1]) + def test_complex_columns_rejected_before_coercion(self, column, message, imaginary): + data = micro_panel() + data[column] = data[column].astype(complex) + imaginary * 1j + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(ValueError, match=message): + DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=3) + + @pytest.mark.parametrize( + "weights", + [ + np.array([1 + 1j, 3 + 2j]), + np.array([1, 3 + 0j], dtype=object), + [1, np.complex64(3 + 1j)], + ], + ) + def test_complex_weights_rejected_before_coercion(self, weights): + with warnings.catch_warnings(): + warnings.simplefilter("error") + with pytest.raises(ValueError, match="pre_period_weights.*real"): + DurationDiD(n_bootstrap=0).fit( + micro_panel(), + **FIT_KW, + last_pre_period=3, + pre_periods=[2, 3], + pre_period_weights=weights, + ) + + @pytest.mark.parametrize("dtype", [bool, int, float]) + def test_real_binary_inputs_keep_the_same_fit(self, dtype): + data = micro_panel() + data[["exited", "treated"]] = data[["exited", "treated"]].astype(dtype) + reference = fit_quiet(DurationDiD(n_bootstrap=0), micro_panel(), last_pre_period=3) + result = fit_quiet(DurationDiD(n_bootstrap=0), data, last_pre_period=3) + assert result.att == reference.att + assert result.coefficient == reference.coefficient + + +class TestTranslatedDateConsumers: + @pytest.mark.parametrize("anchor_idx", [1, 2, 3]) + def test_placebo_eligibility_and_executable_anchor(self, anchor_idx): + origin = 2**53 + clock = np.array([origin + k for k in range(5)], dtype=np.int64) + data = _relabel_panel(micro_panel(), clock) + result = fit_quiet(DurationDiD(n_bootstrap=0), data, last_pre_period=int(clock[anchor_idx])) + steps = practitioner_next_steps(result, verbose=False)["next_steps"] + step = next(s for s in steps if "placebo" in s["label"].lower()) + if anchor_idx == 1: + assert "not applicable" in step["label"] + else: + namespace = {"data": data, "DurationDiD": DurationDiD} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + exec(step["code"], namespace) + assert namespace["placebo"].last_pre_period == int(clock[anchor_idx - 1]) + assert namespace["placebo"].post_periods.tolist() == [int(clock[anchor_idx])] + + @pytest.mark.parametrize("pre_violation", [False, True]) + def test_invalid_curve_message_keeps_exact_pre_post_boundary(self, pre_violation): + origin = 2**53 + 1 + clock = np.array([origin + k for k in range(5)], dtype=np.int64) + if pre_violation: + data = build_from_survivors( + 1000, [1000, 1000, 900, 800, 700], 1000, [1000, 200, 100, 10, 1] + ) + else: + data = build_from_survivors(30, [18, 9, 4, 2, 1], 10, [8, 6, 4, 2, 0]) + data = _relabel_panel(data, clock) + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + result = DurationDiD(n_bootstrap=0).fit(data, **FIT_KW, last_pre_period=int(clock[2])) + message = next(str(w.message) for w in captured if "Invalid imputed" in str(w.message)) + if pre_violation: + assert f"at {int(clock[2])} (counterfactual_survival_above_one)" in message + assert "cannot repair it: change the fit" in message + assert "at or before" not in message + else: + assert f"at or before {int(clock[3])} and refit" in message + assert "cannot repair" not in message + assert message in result.summary() From 4dfa437bb591b7f4a7ef5e99fed23b633e23ab4a Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 7 Sep 2026 12:43:13 -0400 Subject: [PATCH 4/6] test(duration-did): stabilize coverage validation and fix API markup --- diff_diff/duration_did.py | 2 +- diff_diff/duration_did_results.py | 2 +- diff_diff/synthetic_control.py | 4 ++-- docs/api/_autosummary/diff_diff.DurationDiD.rst | 9 --------- docs/api/duration_did.rst | 2 +- docs/choosing_estimator.rst | 2 +- tests/test_methodology_duration_did.py | 7 +++++-- 7 files changed, 11 insertions(+), 17 deletions(-) diff --git a/diff_diff/duration_did.py b/diff_diff/duration_did.py index a2d73fa2e..6b2e9ba90 100644 --- a/diff_diff/duration_did.py +++ b/diff_diff/duration_did.py @@ -877,7 +877,7 @@ class DurationDiD(BaseEstimator): ``E[Y_it - Y_it(0) | treated]`` (positive = more cumulative exit); the headline ``att`` is its uniform average over the post-treatment dates. Inference is the Appendix B whole-individual pooled bootstrap with - centered absolute-deviation pointwise and simultaneous (max-|t|) bands, + centered absolute-deviation pointwise and simultaneous (``max-|t|``) bands, plus the Algorithm 2 fixed-anchor pre-treatment specification test. Parameters diff --git a/diff_diff/duration_did_results.py b/diff_diff/duration_did_results.py index 6160a5479..a8f15b222 100644 --- a/diff_diff/duration_did_results.py +++ b/diff_diff/duration_did_results.py @@ -123,7 +123,7 @@ class DurationDiDPretestResults(Diagnostic): cumulative-increment RATIO under ``method="ph"``. The whole-individual bootstrap gives one SD per contrast, a simultaneous critical value from the maximum absolute centered pivot, symmetric bands - ``contrast +/- crit_value * se`` and the max-|t| statistic. The test + ``contrast +/- crit_value * se`` and the ``max-|t|`` statistic. The test rejects when any band excludes zero, which is exactly ``p_value <= alpha`` under the inverse-empirical-CDF quantile. diff --git a/diff_diff/synthetic_control.py b/diff_diff/synthetic_control.py index 526a3842e..bc61afbcc 100644 --- a/diff_diff/synthetic_control.py +++ b/diff_diff/synthetic_control.py @@ -282,8 +282,8 @@ def fit( predictor_window : list, optional Pre-periods over which ``predictors`` are averaged. Defaults to all pre periods. Must be a non-empty subset of the pre periods. - special_predictors : list of (var, periods, op), optional - Per-variable special predictors, each averaged over its own periods + special_predictors : list of tuple, optional + ``(var, periods, op)`` special predictors, each averaged over its own periods with its own operator (mirrors R ``Synth`` ``special.predictors``). pre_period_outcomes : "all" or list, optional Use individual pre-period outcomes as predictor rows ("all" = every diff --git a/docs/api/_autosummary/diff_diff.DurationDiD.rst b/docs/api/_autosummary/diff_diff.DurationDiD.rst index 8ef45b07f..6f480c58b 100644 --- a/docs/api/_autosummary/diff_diff.DurationDiD.rst +++ b/docs/api/_autosummary/diff_diff.DurationDiD.rst @@ -19,12 +19,3 @@ - .. rubric:: Attributes - - .. autosummary:: - - ~DurationDiD.method - ~DurationDiD.n_bootstrap - ~DurationDiD.alpha - ~DurationDiD.seed - diff --git a/docs/api/duration_did.rst b/docs/api/duration_did.rst index 53441e7c4..cb2f50f74 100644 --- a/docs/api/duration_did.rst +++ b/docs/api/duration_did.rst @@ -23,7 +23,7 @@ hazard and the treated baseline (Theorem 1), and the **absorption ATT** Inference is the paper's whole-individual pooled bootstrap (Appendix B, Algorithm 1): each draw resamples complete histories, recomputes everything, -and the reported pointwise intervals and simultaneous (max-|t|) band are +and the reported pointwise intervals and simultaneous (``max-|t|``) band are centered absolute-deviation bands. The Algorithm 2 fixed-anchor pre-treatment specification test is reported separately in ``results.pretest``. Every inference family is either fully available or diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 9100847e2..ff5cab0e8 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -733,7 +733,7 @@ restricts the groups' *untreated hazards* instead. cumulative hazards — equal weights over every eligible pre-treatment date by default, or a window via ``pre_periods=`` / ``pre_period_weights=`` - Per-date absorption ATT with the paper's whole-individual bootstrap: - centered pointwise intervals and a simultaneous max-|t| band; the headline + centered pointwise intervals and a simultaneous ``max-|t|`` band; the headline ``att`` is the uniform average over the post-treatment dates - ``results.pretest``: the fixed-anchor pre-treatment specification test (Algorithm 2) — the estimator's own diagnostic; ``check_parallel_trends`` diff --git a/tests/test_methodology_duration_did.py b/tests/test_methodology_duration_did.py index 895e8f267..e79c3c074 100644 --- a/tests/test_methodology_duration_did.py +++ b/tests/test_methodology_duration_did.py @@ -326,9 +326,12 @@ def test_curve_status_helper(self): @pytest.mark.slow class TestCoverage: - def test_uniform_coverage_near_nominal(self, ci_params): + def test_uniform_coverage_near_nominal(self): S1, _, S2, tau_true = population_curves() - n_rep = ci_params.bootstrap(200, min_n=40) + # This fixed-tolerance Monte Carlo rate needs the full replication + # count on both backends: 40 datasets are too noisy for a 0.06 margin. + # DurationDiD uses the same NumPy core in either mode. + n_rep = 200 covered = 0 for s in range(n_rep): r = fit_quiet( From d7f5c7fe381cbd36cdc40461af085e7edb631491 Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 7 Sep 2026 13:29:32 -0400 Subject: [PATCH 5/6] Withhold DurationDiD inference for exactly constant bootstrap draws --- diff_diff/duration_did.py | 29 +++++++--- docs/dev-status.md | 2 +- docs/methodology/REGISTRY.md | 5 +- tests/test_duration_did.py | 80 +++++++++++++++++++++++++- tests/test_methodology_duration_did.py | 30 ++++++++++ 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/diff_diff/duration_did.py b/diff_diff/duration_did.py index 6b2e9ba90..a17cd2fa7 100644 --- a/diff_diff/duration_did.py +++ b/diff_diff/duration_did.py @@ -673,14 +673,27 @@ def _centered_bootstrap_summary( draws = np.asarray(draws, dtype=float).reshape(draws.shape[0], -1) with _errstate(): vcov = np.atleast_2d(np.cov(draws, rowvar=False, ddof=1)) + # Summing identical floats can round their mean away from the stored + # value, creating spurious positive variance. Enforce exact degeneracy + # without treating legitimate small variation as zero. + constant = np.all(draws == draws[0], axis=0) + vcov[constant, :] = 0.0 + vcov[:, constant] = 0.0 se = np.sqrt(np.diag(vcov)) - z = np.abs(draws - point[None, :]) / se[None, :] - crit = np.array([_quantile_inverted_cdf(z[:, k], 1.0 - alpha) for k in range(z.shape[1])]) - t_abs = np.abs(point / se) - p = np.mean(z >= t_abs[None, :], axis=0) - m = np.max(z, axis=1) - crit_sim = _quantile_inverted_cdf(m, 1.0 - alpha) - p_joint = float(np.mean(m >= np.max(t_abs))) + crit = np.full(point.shape, np.nan) + p = np.full(point.shape, np.nan) + crit_sim = p_joint = statistic = float("nan") + if np.all(np.isfinite(se)) and np.all(se > 0): + z = np.abs(draws - point[None, :]) / se[None, :] + crit = np.array( + [_quantile_inverted_cdf(z[:, k], 1.0 - alpha) for k in range(z.shape[1])] + ) + t_abs = np.abs(point / se) + p = np.mean(z >= t_abs[None, :], axis=0) + m = np.max(z, axis=1) + crit_sim = _quantile_inverted_cdf(m, 1.0 - alpha) + p_joint = float(np.mean(m >= np.max(t_abs))) + statistic = float(np.max(t_abs)) return { "vcov": vcov, "se": se, @@ -692,7 +705,7 @@ def _centered_bootstrap_summary( "band_lower": point - crit_sim * se, "band_upper": point + crit_sim * se, "p_joint": p_joint, - "statistic": float(np.max(t_abs)), + "statistic": statistic, } diff --git a/docs/dev-status.md b/docs/dev-status.md index 905bc5e22..f1797453b 100644 --- a/docs/dev-status.md +++ b/docs/dev-status.md @@ -55,11 +55,11 @@ Target: ideally < 1000 lines per module; modules ≥3000 lines are candidates fo | `_nprobust_port.py` | 1425 | Acceptable | | `bacon.py` | 1376 | Acceptable | | `local_linear.py` | 1325 | Acceptable | +| `duration_did.py` | 1313 | Acceptable — validation + numerical core + bootstrap + inference assembly in one module (results split into `duration_did_results.py`) | | `trop_global.py` | 1298 | Acceptable | | `datasets.py` | 1224 | Acceptable | | `rdd.py` | 1218 | Acceptable | | `staggered_aggregation.py` | 1204 | Acceptable | -| `duration_did.py` | 1256 | Acceptable — validation + numerical core + bootstrap + inference assembly in one module (results split into `duration_did_results.py`) | | `chaisemartin_dhaultfoeuille_bootstrap.py` | 1175 | Acceptable | | `conley.py` | 1140 | Acceptable | | `rdplot.py` | 1135 | Acceptable | diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 04226aabe..7499b79be 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -5960,7 +5960,10 @@ equivalently `p <= alpha`. - **Zero or non-finite SE in any column:** the whole family is withheld (`unavailable_zero_se`): the entire SE vector is set to NaN before the single `safe_inference` / `safe_inference_batch` gate, so every column is jointly NaN - (the helper gates per element and would otherwise keep good columns). + (the helper gates per element and would otherwise keep good columns). Exactly + constant bootstrap columns have their covariance rows and columns set to + zero before standardization, avoiding spurious positive SEs from mean + roundoff. No tolerance-based cutoff is applied to distinct draws. - **Status precedence** (first applicable label wins, pipeline order): `disabled` > `unavailable_invalid_periods` > `unavailable_failed_draws` > `unavailable_zero_se` > `ok`; pretest: `disabled` > diff --git a/tests/test_duration_did.py b/tests/test_duration_did.py index 07d35c900..93a7d61d9 100644 --- a/tests/test_duration_did.py +++ b/tests/test_duration_did.py @@ -660,19 +660,93 @@ def fake_draws(rng, n, size): assert r.bootstrap_failure_reasons["post"] == {"nonfinite_counterfactual": 2} assert r.pretest.status == "unavailable_nonfinite_moments" - def test_zero_se_gate_every_column(self, monkeypatch): - df = micro_panel() + @pytest.mark.parametrize("method", ["cd", "ph"]) + @pytest.mark.parametrize("n_boot", [3, 1000]) + @pytest.mark.parametrize("panel", ["micro", "simulation"]) + def test_zero_se_gate_every_column(self, monkeypatch, method, n_boot, panel): + df = micro_panel() if panel == "micro" else simulate_panel(n=800) + last_pre = 3 if panel == "micro" else 4 monkeypatch.setattr( dd_module, "_draw_indices", lambda rng, n, size: np.tile(np.arange(n), (size, 1)) ) - r = fit_quiet(DurationDiD(n_bootstrap=3, seed=0), df, last_pre_period=3) + r = fit_quiet( + DurationDiD(method=method, n_bootstrap=n_boot, seed=0), df, last_pre_period=last_pre + ) + assert np.all(r.bootstrap_effects == r.bootstrap_effects[0]) + assert r.n_bootstrap_valid == n_boot and r.n_bootstrap_valid_pretest == n_boot assert r.inference_status == "unavailable_zero_se" assert np.all(np.isnan(r.se_by_period)) and np.all(np.isnan(r.p_value_by_period)) + assert np.all(np.isnan(r.t_stat_by_period)) and np.all(np.isnan(r.conf_int_by_period)) assert np.all(np.isnan(r.pointwise_crit_values)) and r.vcov is None assert np.all(np.isnan(r.cband_lower)) and np.isnan(r.cband_crit_value) + assert np.all(np.isnan(r.cband_upper)) and np.isnan(r.joint_p_value) assert_nan_inference( {"se": r.se, "t_stat": r.t_stat, "p_value": r.p_value, "conf_int": r.conf_int} ) + assert r.pretest.status == "unavailable_zero_se" + assert np.all(np.isnan(r.pretest.se)) + assert np.all(np.isnan(r.pretest.band_lower)) + assert np.all(np.isnan(r.pretest.band_upper)) + assert np.isnan(r.pretest.statistic) and np.isnan(r.pretest.p_value) + assert np.isnan(r.pretest.crit_value) and r.pretest.reject is None + + def test_seeded_constant_draws_withhold_inference(self): + # Real pooled resamples, with no mocking: all three estimates have the + # same non-binary-exact value. Mean roundoff previously admitted an + # SE around 7e-17, a zero p-value and a point CI as valid inference. + df = build_from_survivors(3, [3, 2, 0], 3, [3, 2, 1]) + r = fit_quiet(DurationDiD(n_bootstrap=3, seed=11461), df, last_pre_period=2) + assert r.n_bootstrap_valid == 3 + assert np.all(r.bootstrap_effects == r.bootstrap_effects[0]) + assert r.bootstrap_effects[0, 0] == pytest.approx(1.0 / 3.0) + assert r.inference_status == "unavailable_zero_se" + assert r.vcov is None and np.all(np.isnan(r.se_by_period)) + assert np.all(np.isnan(r.p_value_by_period)) + assert np.all(np.isnan(r.conf_int_by_period)) + assert_nan_inference( + {"se": r.se, "t_stat": r.t_stat, "p_value": r.p_value, "conf_int": r.conf_int} + ) + + @pytest.mark.parametrize("constant_statistic", ["period", "headline", "pretest"]) + def test_constant_statistic_withholds_only_its_family(self, monkeypatch, constant_statistic): + # Inject controlled replicate statistics after otherwise valid pooled + # resampling, isolating each gate while the other family still varies. + original_bootstrap = dd_module._run_bootstrap + + def controlled_bootstrap(*args, **kwargs): + boot = original_bootstrap(*args, **kwargs) + assert boot["ok_post"].all() and boot["ok_pretest"].all() + if constant_statistic == "pretest": + boot["delta_star"][:, 0] = 1.0 / 3.0 + else: + if constant_statistic == "period": + boot["tau_star"][:, 0] = 1.0 / 3.0 + else: + offset = np.arange(1, 4) / 64.0 + boot["tau_star"] = np.column_stack([1.0 / 3.0 - offset, 1.0 / 3.0 + offset]) + assert np.all(np.ptp(boot["tau_star"], axis=0) > 0) + boot["head_star"] = boot["tau_star"].mean(axis=1) + if constant_statistic == "headline": + assert np.all(boot["head_star"] == boot["head_star"][0]) + return boot + + monkeypatch.setattr(dd_module, "_run_bootstrap", controlled_bootstrap) + r = fit_quiet( + DurationDiD(n_bootstrap=3, seed=3), + simulate_panel(n=800, n_periods=6), + last_pre_period=4, + ) + if constant_statistic == "pretest": + assert r.inference_status == "ok" and np.isfinite(r.se) + assert r.pretest.status == "unavailable_zero_se" + assert np.all(np.isnan(r.pretest.se)) and np.isnan(r.pretest.p_value) + else: + assert r.pretest.status == "ok" and np.isfinite(r.pretest.p_value) + assert r.inference_status == "unavailable_zero_se" + assert r.vcov is None and np.all(np.isnan(r.se_by_period)) + assert_nan_inference( + {"se": r.se, "t_stat": r.t_stat, "p_value": r.p_value, "conf_int": r.conf_int} + ) class TestCurveValidity: diff --git a/tests/test_methodology_duration_did.py b/tests/test_methodology_duration_did.py index e79c3c074..f4807df4f 100644 --- a/tests/test_methodology_duration_did.py +++ b/tests/test_methodology_duration_did.py @@ -239,6 +239,36 @@ def test_single_column_covariance_shape(self): assert s["vcov"].shape == (1, 1) assert s["crit_sim"] == s["crit"][0] + @pytest.mark.parametrize("value", [1.0 / 3.0, 0.1]) + @pytest.mark.parametrize("constant_column", [0, 1, 2]) + def test_constant_columns_have_exact_zero_covariance(self, value, constant_column): + # Nonconstant columns have hand-calculated sample variance 1 and 4, + # covariance 2. A constant column must have zero covariance with both. + draws = np.insert( + np.array([[-1.0, -2.0], [0.0, 0.0], [1.0, 2.0]]), + constant_column, + value, + axis=1, + ) + s = _centered_bootstrap_summary(np.zeros(3), draws, 0.05) + varying = [i for i in range(3) if i != constant_column] + assert s["se"][constant_column] == 0.0 + np.testing.assert_array_equal(s["vcov"][constant_column], np.zeros(3)) + np.testing.assert_array_equal(s["vcov"][:, constant_column], np.zeros(3)) + np.testing.assert_array_equal(s["vcov"][np.ix_(varying, varying)], [[1.0, 2.0], [2.0, 4.0]]) + np.testing.assert_array_equal(s["se"], np.sqrt(np.diag(s["vcov"]))) + + def test_small_nonzero_variance_is_preserved(self): + # Neighboring floats are distinct draws, even though their spread is + # tiny. There is no absolute or relative "almost constant" cutoff. + value = 1.0 / 3.0 + gap = np.spacing(value) + draws = np.array([[value - gap], [value], [value + gap]]) + s = _centered_bootstrap_summary(np.array([value]), draws, 0.05) + assert s["se"][0] > 0 + assert s["se"][0] == pytest.approx(gap, rel=1e-12, abs=0) + assert np.isfinite(s["crit_sim"]) and np.isfinite(s["p_joint"]) + # --------------------------------------------------------------------------- # Sampling behavior on the exact DGP From 80c86d9a277979d6a971fe57f7c57421b36fda7a Mon Sep 17 00:00:00 2001 From: igerber Date: Mon, 7 Sep 2026 14:13:50 -0400 Subject: [PATCH 6/6] Ignore unused categorical unit levels in DurationDiD balance checks --- changelog.d/20260906-duration-did.md | 1 + diff_diff/duration_did.py | 2 +- tests/test_duration_did.py | 38 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/changelog.d/20260906-duration-did.md b/changelog.d/20260906-duration-did.md index 16d1e84d6..33ae29fef 100644 --- a/changelog.d/20260906-duration-did.md +++ b/changelog.d/20260906-duration-did.md @@ -22,6 +22,7 @@ inference family is either fully available or fully withheld with a named `inference_status` (invalid imputed counterfactual curve, failed bootstrap draws, zero SE); failed draws are never retried or silently dropped. + Unused categorical unit levels are excluded from panel balance checks. `results.aggregate("event_study")` returns the unified `EventStudyResults` container (event time 0 = first post-treatment date, reference -1). Covariates, staggered adoption, censoring, survey and cluster inference are diff --git a/diff_diff/duration_did.py b/diff_diff/duration_did.py index a17cd2fa7..cfdcc6d70 100644 --- a/diff_diff/duration_did.py +++ b/diff_diff/duration_did.py @@ -214,7 +214,7 @@ def _validate_and_arrange( f"DurationDiD requires exactly one row per (unit, period); found duplicate " f"({unit!r}, {time!r}) combinations" ) - counts = data.groupby(unit, sort=True)[time].size() + counts = data.groupby(unit, sort=True, observed=True)[time].size() n_periods = len(grid) incomplete = counts[counts != n_periods] if len(incomplete) > 0: diff --git a/tests/test_duration_did.py b/tests/test_duration_did.py index 93a7d61d9..d5f3e196f 100644 --- a/tests/test_duration_did.py +++ b/tests/test_duration_did.py @@ -236,6 +236,44 @@ def test_unbalanced_panel(self): with pytest.raises(ValueError, match="Unbalanced panel"): fit_quiet(DurationDiD(n_bootstrap=0), df, last_pre_period=3) + @pytest.mark.parametrize("method", ["cd", "ph"]) + @pytest.mark.parametrize("ordered", [False, True]) + @pytest.mark.parametrize("string_units", [False, True]) + def test_unused_categorical_units_match_observed_panel(self, method, ordered, string_units): + # Filtering a categorical identifier need not remove its unused levels. + # Category order and unobserved identifiers must not affect the sample + # or its seeded whole-individual bootstrap. + df = simulate_panel() + if string_units: + df["unit"] = df["unit"].map(lambda value: f"unit-{value:04d}") + reference = fit_quiet( + DurationDiD(method=method, n_bootstrap=60, seed=3), df, last_pre_period=4 + ) + unused = "unused-unit" if string_units else -1 + categories = [unused, *reversed(df["unit"].unique().tolist())] + df["unit"] = pd.Categorical(df["unit"], categories=categories, ordered=ordered) + original = df.copy(deep=True) + result = fit_quiet( + DurationDiD(method=method, n_bootstrap=60, seed=3), df, last_pre_period=4 + ) + pd.testing.assert_frame_equal(df, original) + assert result.n_units == reference.n_units == 800 + assert result.n_treated == reference.n_treated == 400 + assert result.n_control == reference.n_control == 400 + assert result.inference_status == reference.inference_status == "ok" + assert result.att == reference.att and result.se == reference.se + np.testing.assert_array_equal(result.att_by_period, reference.att_by_period) + np.testing.assert_array_equal(result.vcov, reference.vcov) + np.testing.assert_array_equal(result.bootstrap_effects, reference.bootstrap_effects) + np.testing.assert_array_equal(result.pretest.contrast, reference.pretest.contrast) + np.testing.assert_array_equal(result.pretest.se, reference.pretest.se) + + def test_unused_categories_do_not_hide_missing_cells(self): + df = micro_panel() + df["unit"] = pd.Categorical(df["unit"], categories=[-1, *df["unit"].unique()]) + with pytest.raises(ValueError, match=r"Unbalanced panel:.*1 unit\(s\)"): + fit_quiet(DurationDiD(n_bootstrap=0), df.iloc[1:], last_pre_period=3) + def test_unequal_spacing(self): df = micro_panel(times=[1, 2, 3, 5, 6]) with pytest.raises(ValueError, match="equally spaced"):