From 6119fa91c55879b1d88bd5540693b5b55afaf6fe Mon Sep 17 00:00:00 2001 From: igerber Date: Sat, 25 Apr 2026 17:09:44 -0400 Subject: [PATCH 1/9] docs: HAD ecosystem completion (RTD audit Batch A) Closes the gaps left after PR #372 added HeterogeneousAdoptionDiD to the canonical surfaces. The narrative pages did not yet mention HAD, and the 12-symbol HAD pretest suite shipped in `had_pretests.py` was absent from the API page. Also refreshes the inference-contract block to use the `survey_design=` canonical kwarg consolidated in PR #376. - `docs/api/had.rst`: new HAD Pretests section covering all 12 public symbols (4 single-period tests + 4 result classes + 3 joint tests + 1 joint result), split into `aggregate="overall"` and `aggregate="event_study"` subsections matching the workflow's dispatch. Refreshes the existing inference-contract block to reference `survey_design=make_pweight_design(weights)` (pweight shortcut) and `survey_design=SurveyDesign(...)` (full TSL); notes `survey=` / `weights=` are deprecated aliases. - `docs/choosing_estimator.rst`: HAD entries in all 3 tables (Quick Reference, Standard Error Methods, Survey Design Support) plus a new "Universal Rollout / No Untreated Control" subsection in Detailed Guidance. SE Methods row uses `survey_design=` canonical naming. - `docs/r_comparison.rst`: HAD row in Feature Comparison Table, new "No-Untreated Designs (no R parallel)" subsection, Migration Tips bullet. - `docs/troubleshooting.rst`: new HAD Issues section with 4 subsections (estimand resolution / mass-point fallback / classical SE under survey_design / panel-only event-study). - `docs/practitioner_decision_tree.rst`: Start Here option 7, At a Glance row, new "Universal Rollout" section with `_section-no-untreated` anchor. - `docs/doc-deps.yaml`: extend had_pretests.py entry with llms.txt user-guide dep; add new top-level local_linear.py entry. Verification: all 12 HAD pretest symbols importable; `make_pweight_design` + `SurveyDesign` importable; sphinx build succeeds with 0 new warnings (71 pre-existing unaffected); HTML render contains expected HAD content (276 hits in had.html, 4-8 in narrative pages); 0 em dashes; `_section-no-untreated` anchor resolves. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/had.rst | 77 +++++++++++++++++-- docs/choosing_estimator.rst | 55 ++++++++++++++ docs/doc-deps.yaml | 13 ++++ docs/practitioner_decision_tree.rst | 53 +++++++++++++ docs/r_comparison.rst | 38 +++++++++- docs/references.rst | 2 +- docs/troubleshooting.rst | 114 ++++++++++++++++++++++++++++ 7 files changed, 343 insertions(+), 9 deletions(-) diff --git a/docs/api/had.rst b/docs/api/had.rst index f63722d7..ac1b9227 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -46,12 +46,18 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: - **Unweighted** - continuous paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; the mass-point path uses a structural-residual 2SLS sandwich. No cross-horizon covariance. - - **``weights=`` shortcut** - continuous paths reuse the CCT-2014 SE; - the mass-point path uses an analytical weighted 2SLS sandwich - (``classical`` / ``hc1`` only - ``hc2`` / ``hc2_bm`` raise - ``NotImplementedError`` pending a 2SLS-specific leverage derivation). - - **``survey=``** - both paths compose Binder (1983) Taylor-series - linearization with ``df_survey`` threaded into ``safe_inference``. + - **``survey_design=make_pweight_design(weights)``** (pweight-only + shortcut) - continuous paths reuse the CCT-2014 SE; the mass-point + path uses an analytical weighted 2SLS sandwich (``classical`` / + ``hc1`` only - ``hc2`` / ``hc2_bm`` raise ``NotImplementedError`` + pending a 2SLS-specific leverage derivation). + - **``survey_design=SurveyDesign(...)``** (full TSL with strata / PSU + / FPC) - both paths compose Binder (1983) Taylor-series linearization + with ``df_survey`` threaded into ``safe_inference``. + + The deprecated ``survey=`` and ``weights=`` aliases still resolve to + the same paths with a ``DeprecationWarning`` (removal queued for the + next minor release). A simultaneous confidence band (sup-t) is available only on the **weighted event-study path** via ``cband=True``. Joint cross-horizon @@ -59,7 +65,8 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: ``TODO.md``. **Mass-point ``vcov_type="classical"`` deviation.** The mass-point - ``survey=`` paths (static and event-study) and the ``weights=`` + + ``survey_design=SurveyDesign(...)`` paths (static and event-study) and + the ``survey_design=make_pweight_design(weights)`` + ``aggregate="event_study"`` + ``cband=True`` path reject ``vcov_type="classical"`` with ``NotImplementedError``. The per-unit 2SLS influence function returned by the mass-point fit is HC1-scaled @@ -97,3 +104,59 @@ Multi-period event-study results container for the Appendix B.2 extension. :members: :undoc-members: :show-inheritance: + +HAD Pretests +------------ + +Diagnostic pretests for the HAD identification assumptions from de Chaisemartin +et al. (2026). The composite orchestrator +:func:`~diff_diff.did_had_pretest_workflow` dispatches to two shapes based on +panel structure: the **overall** path (two-period first-differenced sample) +runs single-period tests; the **event-study** path (three or more periods) +runs joint multi-period tests. Both paths return a unified +:class:`~diff_diff.HADPretestReport`. + +.. autofunction:: diff_diff.did_had_pretest_workflow + +.. autoclass:: diff_diff.HADPretestReport + :members: + :undoc-members: + :show-inheritance: + +Single-period tests (``aggregate="overall"``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: diff_diff.qug_test + +.. autofunction:: diff_diff.stute_test + +.. autofunction:: diff_diff.yatchew_hr_test + +.. autoclass:: diff_diff.QUGTestResults + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: diff_diff.StuteTestResults + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: diff_diff.YatchewTestResults + :members: + :undoc-members: + :show-inheritance: + +Joint multi-period tests (``aggregate="event_study"``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: diff_diff.stute_joint_pretest + +.. autofunction:: diff_diff.joint_pretrends_test + +.. autofunction:: diff_diff.joint_homogeneity_test + +.. autoclass:: diff_diff.StuteJointResult + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 155a6bf0..e198e14b 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -93,6 +93,10 @@ Quick Reference - Continuous dose / treatment intensity - Strong Parallel Trends (SPT) for dose-response; PT for binarized ATT - ATT\ :sup:`loc` (PT); ATT(d), ACRT(d) (SPT) + * - ``HeterogeneousAdoptionDiD`` + - Universal rollout, dose varies, no untreated unit + - dCDH 2026 Assumptions (Design 1' QUG case or Design 1 with A6/A5) + - WAS or WAS\ :sub:`d_lower` per resolved estimand; event-study Appendix B.2 * - ``SunAbraham`` - Staggered adoption, interaction-weighted - Conditional parallel trends @@ -357,6 +361,49 @@ Use :class:`~diff_diff.ContinuousDiD` when: print(f"Overall ATT: {results.overall_att:.3f}") att_curve = results.dose_response_att.to_dataframe() +Universal Rollout / No Untreated Control +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use :class:`~diff_diff.HeterogeneousAdoptionDiD` when: + +- **Every unit is treated at the post period** (universal-rollout policy, + industry-wide tariff change, simultaneous launch into all markets) +- Treatment **intensity (dose) varies across units**, but no genuinely + untreated control group exists to anchor a standard DiD contrast +- :class:`~diff_diff.ContinuousDiD` is unavailable because its untreated-group + requirement (``D = 0``) is violated + +The estimator implements de Chaisemartin, Ciccia, D'Haultfoeuille and Knau +(2026, arXiv:2405.04465v6) and resolves to one of two estimands depending on +the dose support: + +- **Design 1' (QUG case, ``d_lower = 0``)** identifies the **Weighted Average + Slope (WAS)** under the Quasi-Untreated-Group assumption (units with the + smallest dose serve as the comparison anchor). The shipped result class + exposes ``target_parameter == "WAS"``. +- **Design 1 (no QUG, ``d_lower > 0``)** identifies ``WAS_{d_lower}`` under + Assumption 6, or sign identification only under Assumption 5; neither + additional assumption is testable via pre-trends. Result class exposes + ``target_parameter == "WAS_d_lower"``. + +The dose-distribution path is auto-detected. Run +:func:`~diff_diff.did_had_pretest_workflow` to vet the identifying assumptions +before estimation; see :doc:`api/had` for the full API and SE-regime contract. + +.. code-block:: python + + from diff_diff import HeterogeneousAdoptionDiD, did_had_pretest_workflow + + pretests = did_had_pretest_workflow(data, outcome='y', unit='unit', + time='period', dose='dose') + + est = HeterogeneousAdoptionDiD() + results = est.fit(data, outcome='y', unit='unit', + time='period', dose='dose') + + print(f"Resolved estimand: {results.target_parameter}") + print(f"Estimate: {results.coef:.3f}") + Efficient DiD ~~~~~~~~~~~~~ @@ -615,6 +662,9 @@ differences helps interpret results and choose appropriate inference. * - ``ContinuousDiD`` - Analytical (influence function) - Uses influence-function-based SEs by default. Use ``n_bootstrap=199`` (or higher) for multiplier bootstrap inference with proper CIs. + * - ``HeterogeneousAdoptionDiD`` + - Path-dependent (CCT-2014 / 2SLS / Binder TSL) + - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **``survey_design=make_pweight_design(weights)``** (pweight shortcut): continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1`` only). **``survey_design=SurveyDesign(...)``** (full TSL): both paths compose Binder (1983) Taylor-series linearization. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. The deprecated ``survey=`` / ``weights=`` aliases still resolve with a DeprecationWarning. * - ``SunAbraham`` - Cluster-robust (unit level) - Clusters at unit level by default. Specify ``cluster`` to override. Use ``n_bootstrap`` for pairs bootstrap inference. @@ -777,6 +827,11 @@ estimation. The depth of support varies by estimator: - Full - Full (analytical) - Multiplier at PSU + * - ``HeterogeneousAdoptionDiD`` + - pweight only + - Full (Binder TSL) + - -- + - Multiplier (event-study, ``cband=True`` only) * - ``EfficientDiD`` - Full - Full diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index 2a5e6f4f..1e5a34dc 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -385,6 +385,19 @@ sources: diff_diff/had_pretests.py: drift_risk: medium + docs: + - path: docs/methodology/REGISTRY.md + section: "HeterogeneousAdoptionDiD" + type: methodology + - path: docs/api/had.rst + section: "HAD Pretests" + type: api_reference + - path: diff_diff/guides/llms.txt + section: "Estimators" + type: user_guide + + diff_diff/local_linear.py: + drift_risk: low docs: - path: docs/methodology/REGISTRY.md section: "HeterogeneousAdoptionDiD" diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index 6b0f4bfd..805ae324 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -44,6 +44,11 @@ Which of these best describes your situation? Your outcome comes from a survey with complex sampling. Go to :ref:`section-survey`. +7. **All my markets received the campaign at the same time, but spend levels varied** (no untreated control market exists) + + Universal rollout with dose-only variation. Go to + :ref:`section-no-untreated`. + .. tip:: In academic literature, "rolling out in waves" is called *staggered adoption*, @@ -258,6 +263,51 @@ appropriate identification assumptions in place. require Strong Parallel Trends (see warning above). +.. _section-no-untreated: + +Universal Rollout (No Untreated Markets) +---------------------------------------- + +**Your situation:** Every market got the campaign at the same time - there is no +holdout group - but spending levels varied across markets. ``ContinuousDiD`` cannot +help here because it requires an untreated comparison group; standard DiD has no +control to anchor the contrast. + +**Recommended method:** :class:`~diff_diff.HeterogeneousAdoptionDiD` + +This estimator implements de Chaisemartin, Ciccia, D'Haultfoeuille and Knau (2026) +and resolves to one of two estimands depending on whether the smallest-dose +markets can serve as a quasi-untreated anchor (Design 1') or whether the +identification rests on stronger structural assumptions (Design 1). + +.. code-block:: python + + from diff_diff import HeterogeneousAdoptionDiD, did_had_pretest_workflow + + # Run the pretest workflow first - it adjudicates which design path + # your data supports and surfaces assumption violations + pretests = did_had_pretest_workflow( + data, outcome="y", unit="unit", time="period", dose="dose", + ) + print(pretests) + + est = HeterogeneousAdoptionDiD() + results = est.fit( + data, outcome="y", unit="unit", time="period", dose="dose", + ) + print(f"Resolved estimand: {results.target_parameter}") + print(f"Average lift per unit of dose: {results.coef:.2f}") + +.. note:: + + **Academic term:** The estimator targets the *Weighted Average Slope (WAS)* under + the QUG / Design 1' case, or *WAS_{d_lower}* under Design 1. Neither identifying + assumption is testable via pre-trends alone - run + :func:`~diff_diff.did_had_pretest_workflow` for the recommended battery. See + :doc:`api/had` for the inference contract (three SE regimes; pointwise CIs; + sup-t bands only on the weighted event-study path). + + .. _section-few-markets: Few Test Markets @@ -377,6 +427,9 @@ At a Glance * - Varied spending levels - ``ContinuousDiD`` - Dose-response curve + * - Universal rollout, no untreated markets + - ``HeterogeneousAdoptionDiD`` + - Targets WAS / WAS_{d_lower} when no holdout exists * - Only a few test markets - ``SyntheticDiD`` - Optimal with few treated units diff --git a/docs/r_comparison.rst b/docs/r_comparison.rst index f8507d2b..d899f4f1 100644 --- a/docs/r_comparison.rst +++ b/docs/r_comparison.rst @@ -213,6 +213,30 @@ The synthdid package implements Arkhangelsky et al. (2021): post_periods=post_periods ) +No-Untreated Designs (no R parallel) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When every unit is treated at the post period (universal-rollout policies, +industry-wide regime changes) but treatment intensity varies across units, +the standard R DiD ecosystem has no direct entry point - ``did``, ``fixest``, +``synthdid``, and ``DIDmultiplegtDYN`` all assume an untreated comparison +group exists. ``diff-diff`` ships +:class:`~diff_diff.HeterogeneousAdoptionDiD`, which implements +de Chaisemartin, Ciccia, D'Haultfoeuille and Knau (2026, arXiv:2405.04465v6). +The estimator targets the Weighted Average Slope (WAS) when the smallest +dose serves as a quasi-untreated anchor (Design 1') or ``WAS_{d_lower}`` +otherwise (Design 1, requiring Assumption 6 or sign-only Assumption 5). +The dCDH 2026 paper has not yet been packaged in R, so this is a +methodology niche covered in Python first. + +.. code-block:: python + + from diff_diff import HeterogeneousAdoptionDiD + + est = HeterogeneousAdoptionDiD() + results = est.fit(data, outcome='y', unit='unit', + time='period', dose='dose') + Key Differences --------------- @@ -372,6 +396,11 @@ Feature Comparison Table - ❌ - ❌ - ❌ + * - Heterogeneous adoption / no-untreated designs + - ✅ + - ❌ + - ❌ + - ❌ .. note:: @@ -382,7 +411,8 @@ Feature Comparison Table Stacked DiD requires manual implementation or the ``stackedev`` package; Continuous DiD is available via the ``did`` package continuous extension; Triple Difference requires manual implementation in R. - TROP and Efficient DiD have no direct R equivalents. + TROP, Efficient DiD, and HeterogeneousAdoptionDiD (dCDH 2026, the + no-untreated-control design) have no direct R equivalents. Migration Tips -------------- @@ -399,3 +429,9 @@ Migration Tips 5. **Missing data**: diff-diff requires complete data; use ``balance_panel()`` or ``dropna()`` first + +6. **No-untreated designs**: If your R workflow stalls because every unit was + treated at the post period (universal rollout, dose-only variation), reach + for :class:`~diff_diff.HeterogeneousAdoptionDiD`. See the + `No-Untreated Designs (no R parallel)`_ section above for the migration + pattern. diff --git a/docs/references.rst b/docs/references.rst index 7a5cda6d..33a6f2f8 100644 --- a/docs/references.rst +++ b/docs/references.rst @@ -66,7 +66,7 @@ Survey-Design Inference (Taylor-Series Linearization) - **Binder, D. A. (1983).** "On the Variances of Asymptotically Normal Estimators from Complex Surveys." *International Statistical Review*, 51(3), 279-292. https://doi.org/10.2307/1402588 - Foundational TSL (Taylor-Series Linearization) variance derivation used across diff-diff's survey-aware estimators (``compute_survey_if_variance`` and the per-estimator influence-function compositions, including the dCDH and HeterogeneousAdoptionDiD ``survey=`` paths). + Foundational TSL (Taylor-Series Linearization) variance derivation used across diff-diff's survey-aware estimators (``compute_survey_if_variance`` and the per-estimator influence-function compositions, including the dCDH and HeterogeneousAdoptionDiD ``survey_design=`` paths). Placebo Tests and DiD Diagnostics --------------------------------- diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index cdb57f12..213b5b23 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -474,6 +474,120 @@ cannot produce an ATT estimate. post_obs = group[group['period'] >= g] print(f"Cohort {g}: {len(post_obs)} post-treatment observations") +HeterogeneousAdoptionDiD (HAD) Issues +------------------------------------- + +"Resolved estimand is not what I expected (WAS vs WAS_d_lower)" +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Problem:** ``HeterogeneousAdoptionDiD`` resolves ``target_parameter`` to +``"WAS_d_lower"`` when you expected ``"WAS"`` (or vice versa). + +**Cause:** HAD auto-detects the design path from the dose distribution. Design +1' (QUG case, ``d_lower = 0``) targets WAS by treating the smallest-dose +units as a quasi-untreated anchor; Design 1 (no QUG, ``d_lower > 0``) targets +``WAS_{d_lower}``. If your data has no observations at ``dose = 0`` the +estimator routes to Design 1 even when you intend a WAS interpretation. + +**Solutions:** + +.. code-block:: python + + # Inspect the dose support before fitting + print(data['dose'].describe()) + print((data['dose'] == 0).sum(), "observations at dose=0") + + # Check the resolved estimand after fitting + results = est.fit(data, outcome='y', unit='unit', + time='period', dose='dose') + print(f"Resolved: {results.target_parameter}") + + # If you genuinely have a Design 1' panel but lack dose=0 rows, verify + # the dose variable encoding (e.g. log-transformed doses where 0 was + # mapped to a small positive value) + +"Mass-point fit fallback" +~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Problem:** HAD reports that the ``mass_point`` fit path was used instead of +the expected ``continuous_at_zero`` or ``continuous_near_d_lower`` path. + +**Cause:** Local-linear regression at the dose-support boundary requires a +sufficiently dense neighborhood. When the dose distribution has a heavy +mass-point at the boundary (e.g. many units at ``dose = d_lower``), HAD falls +back to the structural-residual 2SLS sandwich derived in dCDH 2026 Appendix. +This is a correct fallback, not a failure - it just changes the SE regime. + +**Solutions:** + +.. code-block:: python + + # Inspect the fit path used + print(f"Fit path: {results.fit_path}") # 'mass_point' indicates fallback + + # The 2SLS sandwich is the correct inference for mass-point designs; + # accept the fallback unless you can re-bin the dose variable to a + # smoother distribution. + +"NotImplementedError on survey + mass-point + vcov_type='classical'" +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Problem:** Calling ``HeterogeneousAdoptionDiD.fit(..., vcov_type="classical")`` +under ``survey_design=`` (or under the deprecated ``survey=`` / ``weights=`` +aliases) raises ``NotImplementedError`` on the mass-point path. The same +``NotImplementedError`` fires on +``survey_design=make_pweight_design(weights)`` + ``aggregate="event_study"`` + +``cband=True``. + +**Cause:** The per-unit 2SLS influence function returned by the mass-point fit +is HC1-scaled so that ``compute_survey_if_variance`` and the sup-t bootstrap +target ``V_HC1`` consistently. Mixing it with a classical analytical SE would +silently report a ``V_HC1``-targeted variance under a ``classical`` label. + +**Solutions:** + +.. code-block:: python + + # Use the default robust=True (which maps to vcov_type='hc1') + est = HeterogeneousAdoptionDiD() # robust=True by default + + # Or explicitly request HC1 + est = HeterogeneousAdoptionDiD(vcov_type='hc1') + +A classical-aligned IF derivation is queued for a follow-up release; until +then, ``vcov_type='hc1'`` is the recommended path for survey + mass-point +fits. See :doc:`api/had` for the full SE-regime contract. + +"Panel-only event-study restriction" +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Problem:** ``HeterogeneousAdoptionDiD.fit(..., aggregate="event_study")`` +raises because the panel is not balanced or the staggered-timing structure +violates the last-treatment-cohort restriction. + +**Cause:** The Appendix B.2 event-study extension requires staggered-timing +panels restricted to the last-treatment cohort (which retains never-treated +units as comparisons). Unbalanced panels or earlier-treated cohorts are not +supported in the current release. + +**Solutions:** + +.. code-block:: python + + # Verify panel balance + periods_per_unit = data.groupby('unit')['period'].nunique() + assert periods_per_unit.nunique() == 1, "Panel is unbalanced" + + # Restrict to the last-treatment cohort manually if needed + last_cohort = data['first_treat'].max() + subset = data[(data['first_treat'] == last_cohort) | + (data['first_treat'] == 0)] + + est = HeterogeneousAdoptionDiD() + results = est.fit(subset, outcome='y', unit='unit', + time='period', dose='dose', + aggregate='event_study') + Imputation / Two-Stage DiD Issues ---------------------------------- From 9a62e922816c659dc795b08517642b4d4b3a7426 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 08:25:51 -0400 Subject: [PATCH 2/9] Address PR #389 R1 (2 P1, 1 P2): HAD docs accuracy P1 (methodology contract): the inference-contract block, choosing-estimator SE Methods row, and troubleshooting "classical SE under survey" subsection incorrectly described `survey_design=make_pweight_design(weights)` as the estimator-side pweight shortcut. Per `survey.py:681-697` and `had.py:2853-2891` that helper is reserved for ARRAY-IN HAD pretest helpers (`stute_test`, `yatchew_hr_test`, `stute_joint_pretest`, `qug_test`); on the data-in `HeterogeneousAdoptionDiD.fit` surface the deprecated `weights=np.ndarray` shortcut is the actual pweight route, and it currently yields a different SE family than `survey_design=SurveyDesign(...)`: `weights=` -> `variance_formula="pweight"` / `"pweight_2sls"` (CCT-2014 / 2SLS pweight-sandwich); `survey_design=SurveyDesign(...)` -> `"survey_binder_tsl"` / `"survey_binder_tsl_2sls"` (Binder-TSL). The unification onto a single SE contract is queued for the next minor release. - `docs/api/had.rst` inference-contract block: restore `weights=` shortcut (deprecated) and `survey_design=SurveyDesign(weights="col", ...)` as the two distinct weighted regimes; spell out the SE-family difference and the next-minor unification; add a separate paragraph that documents `make_pweight_design()` correctly as the pweight-only convenience for the array-in pretest helpers. - `docs/api/had.rst` mass-point classical deviation: cband+event_study `NotImplementedError` fires on the deprecated `weights=` shortcut, not on `survey_design=make_pweight_design(...)`. - `docs/choosing_estimator.rst` SE Methods row: same restoration; spells out the variance_formula values and notes the next-minor unification. - `docs/troubleshooting.rst` "classical SE under survey": subsection `NotImplementedError` description corrected to `survey_design=SurveyDesign(...)` + deprecated `weights=` shortcut. P1 (snippet correctness): the new HAD code snippets used `outcome=`, `unit=`, `time=`, `dose=`, `results.coef`, `results.fit_path` - the actual HAD signature uses `outcome_col`, `unit_col`, `time_col`, `dose_col` (`had.py:2786-2804`, `had_pretests.py:3741+`), and the result objects expose `att` and `design` (`had.py:316, 324, 669, 679`). Fixed across: - `docs/practitioner_decision_tree.rst` Universal Rollout snippet (workflow + fit + result-attr). - `docs/choosing_estimator.rst` Universal Rollout snippet (workflow + fit + result-attr). - `docs/r_comparison.rst` Heterogeneous Adoption snippet (fit kwargs). - `docs/troubleshooting.rst` HAD Issues snippets (fit kwargs + `results.fit_path` -> `results.design`). P2 (R-comparison overstatement): the dedicated R package `DIDHAD` (de Chaisemartin et al., August 2025) covers the QUG case (Design 1') of the same arXiv paper, so claiming "no R parallel" was too strong. Reframed to acknowledge `DIDHAD` and position diff-diff's broader surface (Design 1 no-QUG, multi-period event-study extension Appendix B.2, survey-design integration via Binder TSL): - `docs/r_comparison.rst`: section heading "No-Untreated Designs (no R parallel)" -> "Heterogeneous Adoption (HAD)"; body acknowledges `DIDHAD` and frames diff-diff's broader surface; Feature Comparison Table row label simplified; note about R equivalents updated; Migration Tips bullet narrowed and cross-reference target updated. Sphinx build clean (0 warnings in edited files); also fixes a title-underline-too-short warning on the "Resolved estimand..." subsection that was below threshold by one character. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/had.rst | 46 +++++++++++++++--------- docs/choosing_estimator.rst | 12 +++---- docs/practitioner_decision_tree.rst | 8 +++-- docs/r_comparison.rst | 54 +++++++++++++++++------------ docs/troubleshooting.rst | 21 ++++++----- 5 files changed, 82 insertions(+), 59 deletions(-) diff --git a/docs/api/had.rst b/docs/api/had.rst index ac1b9227..8ce4701a 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -46,18 +46,30 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: - **Unweighted** - continuous paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; the mass-point path uses a structural-residual 2SLS sandwich. No cross-horizon covariance. - - **``survey_design=make_pweight_design(weights)``** (pweight-only - shortcut) - continuous paths reuse the CCT-2014 SE; the mass-point - path uses an analytical weighted 2SLS sandwich (``classical`` / - ``hc1`` only - ``hc2`` / ``hc2_bm`` raise ``NotImplementedError`` - pending a 2SLS-specific leverage derivation). - - **``survey_design=SurveyDesign(...)``** (full TSL with strata / PSU - / FPC) - both paths compose Binder (1983) Taylor-series linearization - with ``df_survey`` threaded into ``safe_inference``. - - The deprecated ``survey=`` and ``weights=`` aliases still resolve to - the same paths with a ``DeprecationWarning`` (removal queued for the - next minor release). + - **``weights=np.ndarray`` shortcut (deprecated)** - continuous paths + reuse the CCT-2014 SE; the mass-point path uses an analytical + weighted 2SLS sandwich (``classical`` / ``hc1`` only - ``hc2`` / + ``hc2_bm`` raise ``NotImplementedError`` pending a 2SLS-specific + leverage derivation). Yields ``variance_formula="pweight"`` / + ``"pweight_2sls"``. + - **``survey_design=SurveyDesign(weights="col", ...)``** (canonical; + accepts strata / PSU / FPC) - both paths compose Binder (1983) + Taylor-series linearization with ``df_survey`` threaded into + ``safe_inference``. Yields ``variance_formula="survey_binder_tsl"`` + / ``"survey_binder_tsl_2sls"``. + + The two weighted paths currently produce different SE families on this + estimator (CCT-2014 / 2SLS pweight-sandwich vs Binder-TSL); the + deprecated ``weights=`` and ``survey=`` aliases will be removed in the + next minor release, at which point the long-term unification onto a + single SE contract under ``survey_design=`` lands. (Tracked in + ``TODO.md``; the deprecation warning emitted by ``HeterogeneousAdoptionDiD.fit`` + spells the migration out per call site.) On array-in HAD pretest + helpers (``stute_test``, ``yatchew_hr_test``, ``stute_joint_pretest``, + ``qug_test``) the pweight-only shortcut is + ``survey_design=make_pweight_design(weights)``; data-in surfaces use + ``survey_design=SurveyDesign(weights="col_name", ...)`` against + ``data`` instead. A simultaneous confidence band (sup-t) is available only on the **weighted event-study path** via ``cband=True``. Joint cross-horizon @@ -66,11 +78,11 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: **Mass-point ``vcov_type="classical"`` deviation.** The mass-point ``survey_design=SurveyDesign(...)`` paths (static and event-study) and - the ``survey_design=make_pweight_design(weights)`` + - ``aggregate="event_study"`` + ``cband=True`` path reject - ``vcov_type="classical"`` with ``NotImplementedError``. The per-unit - 2SLS influence function returned by the mass-point fit is HC1-scaled - so that ``compute_survey_if_variance`` and the sup-t bootstrap target + the deprecated ``weights=`` + ``aggregate="event_study"`` + + ``cband=True`` path reject ``vcov_type="classical"`` with + ``NotImplementedError``. The per-unit 2SLS influence function returned + by the mass-point fit is HC1-scaled so that + ``compute_survey_if_variance`` and the sup-t bootstrap target ``V_HC1`` consistently; mixing it with a classical analytical SE would silently report a ``V_HC1``-targeted variance under a ``classical`` label. Use ``vcov_type="hc1"`` (or leave it unset with diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index e198e14b..512ed8fb 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -394,15 +394,15 @@ before estimation; see :doc:`api/had` for the full API and SE-regime contract. from diff_diff import HeterogeneousAdoptionDiD, did_had_pretest_workflow - pretests = did_had_pretest_workflow(data, outcome='y', unit='unit', - time='period', dose='dose') + pretests = did_had_pretest_workflow(data, outcome_col='y', unit_col='unit', + time_col='period', dose_col='dose') est = HeterogeneousAdoptionDiD() - results = est.fit(data, outcome='y', unit='unit', - time='period', dose='dose') + results = est.fit(data, outcome_col='y', unit_col='unit', + time_col='period', dose_col='dose') print(f"Resolved estimand: {results.target_parameter}") - print(f"Estimate: {results.coef:.3f}") + print(f"Estimate: {results.att:.3f}") Efficient DiD ~~~~~~~~~~~~~ @@ -664,7 +664,7 @@ differences helps interpret results and choose appropriate inference. - Uses influence-function-based SEs by default. Use ``n_bootstrap=199`` (or higher) for multiplier bootstrap inference with proper CIs. * - ``HeterogeneousAdoptionDiD`` - Path-dependent (CCT-2014 / 2SLS / Binder TSL) - - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **``survey_design=make_pweight_design(weights)``** (pweight shortcut): continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1`` only). **``survey_design=SurveyDesign(...)``** (full TSL): both paths compose Binder (1983) Taylor-series linearization. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. The deprecated ``survey=`` / ``weights=`` aliases still resolve with a DeprecationWarning. + - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **Deprecated ``weights=`` shortcut**: continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1`` only); yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. **``survey_design=SurveyDesign(weights="col", ...)``**: both paths compose Binder (1983) Taylor-series linearization (``"survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``). The two weighted families differ on this estimator until the next-minor unification lands. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. * - ``SunAbraham`` - Cluster-robust (unit level) - Clusters at unit level by default. Specify ``cluster`` to override. Use ``n_bootstrap`` for pairs bootstrap inference. diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index 805ae324..0ccb3c7f 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -287,16 +287,18 @@ identification rests on stronger structural assumptions (Design 1). # Run the pretest workflow first - it adjudicates which design path # your data supports and surfaces assumption violations pretests = did_had_pretest_workflow( - data, outcome="y", unit="unit", time="period", dose="dose", + data, outcome_col="y", unit_col="unit", + time_col="period", dose_col="dose", ) print(pretests) est = HeterogeneousAdoptionDiD() results = est.fit( - data, outcome="y", unit="unit", time="period", dose="dose", + data, outcome_col="y", unit_col="unit", + time_col="period", dose_col="dose", ) print(f"Resolved estimand: {results.target_parameter}") - print(f"Average lift per unit of dose: {results.coef:.2f}") + print(f"Average lift per unit of dose: {results.att:.2f}") .. note:: diff --git a/docs/r_comparison.rst b/docs/r_comparison.rst index d899f4f1..11ddbb7f 100644 --- a/docs/r_comparison.rst +++ b/docs/r_comparison.rst @@ -213,29 +213,34 @@ The synthdid package implements Arkhangelsky et al. (2021): post_periods=post_periods ) -No-Untreated Designs (no R parallel) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Heterogeneous Adoption (HAD) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When every unit is treated at the post period (universal-rollout policies, industry-wide regime changes) but treatment intensity varies across units, -the standard R DiD ecosystem has no direct entry point - ``did``, ``fixest``, -``synthdid``, and ``DIDmultiplegtDYN`` all assume an untreated comparison -group exists. ``diff-diff`` ships -:class:`~diff_diff.HeterogeneousAdoptionDiD`, which implements -de Chaisemartin, Ciccia, D'Haultfoeuille and Knau (2026, arXiv:2405.04465v6). -The estimator targets the Weighted Average Slope (WAS) when the smallest -dose serves as a quasi-untreated anchor (Design 1') or ``WAS_{d_lower}`` -otherwise (Design 1, requiring Assumption 6 or sign-only Assumption 5). -The dCDH 2026 paper has not yet been packaged in R, so this is a -methodology niche covered in Python first. +the standard R workhorses (``did``, ``fixest``, ``synthdid``, +``DIDmultiplegtDYN``) assume an untreated comparison group exists and do +not apply. The dedicated R package ``DIDHAD`` (de Chaisemartin et al., +August 2025) covers the QUG case (Design 1', ``d_lower = 0``) from the +same arXiv paper. + +``diff-diff`` ships :class:`~diff_diff.HeterogeneousAdoptionDiD`, which +implements the broader surface of de Chaisemartin, Ciccia, D'Haultfoeuille +and Knau (2026, arXiv:2405.04465v6): both Design 1' (QUG case, targets +**WAS**) **and** Design 1 (no QUG, ``d_lower > 0``, targets +``WAS_{d_lower}`` under Assumption 6 or sign-only under Assumption 5), the +multi-period event-study extension (paper Appendix B.2), and survey-design +integration via Binder (1983) Taylor-series linearization. The pretest +battery :func:`~diff_diff.did_had_pretest_workflow` adjudicates the design +path and surfaces assumption violations. .. code-block:: python from diff_diff import HeterogeneousAdoptionDiD est = HeterogeneousAdoptionDiD() - results = est.fit(data, outcome='y', unit='unit', - time='period', dose='dose') + results = est.fit(data, outcome_col='y', unit_col='unit', + time_col='period', dose_col='dose') Key Differences --------------- @@ -396,7 +401,7 @@ Feature Comparison Table - ❌ - ❌ - ❌ - * - Heterogeneous adoption / no-untreated designs + * - Heterogeneous adoption (HAD) - ✅ - ❌ - ❌ @@ -411,8 +416,12 @@ Feature Comparison Table Stacked DiD requires manual implementation or the ``stackedev`` package; Continuous DiD is available via the ``did`` package continuous extension; Triple Difference requires manual implementation in R. - TROP, Efficient DiD, and HeterogeneousAdoptionDiD (dCDH 2026, the - no-untreated-control design) have no direct R equivalents. + TROP and Efficient DiD have no direct R equivalents. + HeterogeneousAdoptionDiD (dCDH 2026) overlaps with the dedicated R + package ``DIDHAD`` (de Chaisemartin et al., 2025), which covers the + QUG case (Design 1'); diff-diff additionally covers Design 1 (no QUG, + ``WAS_{d_lower}``), the multi-period event-study extension (paper + Appendix B.2), and survey-design integration via Binder TSL. Migration Tips -------------- @@ -430,8 +439,9 @@ Migration Tips 5. **Missing data**: diff-diff requires complete data; use ``balance_panel()`` or ``dropna()`` first -6. **No-untreated designs**: If your R workflow stalls because every unit was - treated at the post period (universal rollout, dose-only variation), reach - for :class:`~diff_diff.HeterogeneousAdoptionDiD`. See the - `No-Untreated Designs (no R parallel)`_ section above for the migration - pattern. +6. **Heterogeneous Adoption (HAD)**: If you need the broader HAD surface + beyond the QUG case that the R ``DIDHAD`` package covers - Design 1 + (no QUG, ``WAS_{d_lower}``), the multi-period event-study extension, or + survey-design integration - reach for + :class:`~diff_diff.HeterogeneousAdoptionDiD`. See the + `Heterogeneous Adoption (HAD)`_ section above for the migration pattern. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 213b5b23..665ab940 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -478,7 +478,7 @@ HeterogeneousAdoptionDiD (HAD) Issues ------------------------------------- "Resolved estimand is not what I expected (WAS vs WAS_d_lower)" -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Problem:** ``HeterogeneousAdoptionDiD`` resolves ``target_parameter`` to ``"WAS_d_lower"`` when you expected ``"WAS"`` (or vice versa). @@ -498,8 +498,8 @@ estimator routes to Design 1 even when you intend a WAS interpretation. print((data['dose'] == 0).sum(), "observations at dose=0") # Check the resolved estimand after fitting - results = est.fit(data, outcome='y', unit='unit', - time='period', dose='dose') + results = est.fit(data, outcome_col='y', unit_col='unit', + time_col='period', dose_col='dose') print(f"Resolved: {results.target_parameter}") # If you genuinely have a Design 1' panel but lack dose=0 rows, verify @@ -523,7 +523,7 @@ This is a correct fallback, not a failure - it just changes the SE regime. .. code-block:: python # Inspect the fit path used - print(f"Fit path: {results.fit_path}") # 'mass_point' indicates fallback + print(f"Design: {results.design}") # 'mass_point' indicates fallback # The 2SLS sandwich is the correct inference for mass-point designs; # accept the fallback unless you can re-bin the dose variable to a @@ -533,11 +533,10 @@ This is a correct fallback, not a failure - it just changes the SE regime. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Problem:** Calling ``HeterogeneousAdoptionDiD.fit(..., vcov_type="classical")`` -under ``survey_design=`` (or under the deprecated ``survey=`` / ``weights=`` -aliases) raises ``NotImplementedError`` on the mass-point path. The same -``NotImplementedError`` fires on -``survey_design=make_pweight_design(weights)`` + ``aggregate="event_study"`` + -``cband=True``. +under ``survey_design=SurveyDesign(...)`` (or under the deprecated ``survey=`` +alias) raises ``NotImplementedError`` on the mass-point path. The same +``NotImplementedError`` fires on the deprecated ``weights=`` shortcut + +``aggregate="event_study"`` + ``cband=True``. **Cause:** The per-unit 2SLS influence function returned by the mass-point fit is HC1-scaled so that ``compute_survey_if_variance`` and the sup-t bootstrap @@ -584,8 +583,8 @@ supported in the current release. (data['first_treat'] == 0)] est = HeterogeneousAdoptionDiD() - results = est.fit(subset, outcome='y', unit='unit', - time='period', dose='dose', + results = est.fit(subset, outcome_col='y', unit_col='unit', + time_col='period', dose_col='dose', aggregate='event_study') Imputation / Two-Stage DiD Issues From f63f40be6c80e458cc7398c3b83effb988ae7fd4 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 08:44:17 -0400 Subject: [PATCH 3/9] Address PR #389 R2 (2 P1 + 1 P2): qug_test scope, design-detection rule, DIDHAD claim P1 (qug_test in array-in pretest helper list): `docs/api/had.rst:67-72` listed `qug_test` alongside `stute_test` / `yatchew_hr_test` / `stute_joint_pretest` as accepting `survey_design=make_pweight_design(weights)`. Per `had_pretests.py:1236-1255` and the methodology REGISTRY (Phase 4.5 C0 decision gate), `qug_test` permanently raises `NotImplementedError` on any of `survey_design=` / `survey=` / `weights=` - there is no migration target for survey-aware QUG, and `make_pweight_design()` is explicitly NOT a valid QUG migration target. The composite workflow `did_had_pretest_workflow` handles weighted dispatch by skipping QUG with a `UserWarning`. Removed `qug_test` from the array-in helper list and added an explicit permanent-rejection note pointing to the workflow's skip behavior. P1 (estimand-resolution rule misstatement): `docs/troubleshooting.rst` "Resolved estimand" subsection said "no exact `dose == 0` => Design 1". Per `had.py:1932-1987` `_detect_design()` resolves to Design 1' when EITHER `d.min() == 0` OR `d.min() < 0.01 * median(|d|)` (small-share-of-treated escape clause). Rewrote the cause to spell out both sub-cases and clarify that Design 1 only fires when `d.min()` is meaningfully positive relative to the dose scale. Updated the inspection snippet to compute and print the `0.01 * median(|d|)` threshold instead of just counting `dose == 0` rows. P2 (DIDHAD event-study overstatement): `docs/r_comparison.rst` Heterogeneous Adoption section, R-equivalents note, and Migration Tips bullet claimed diff-diff additionally covers "the multi-period event-study extension (paper Appendix B.2)" beyond `DIDHAD`. The `DIDHAD` package already exposes dynamic effects / placebo / event-study output in the QUG case, so this overstates the gap. Narrowed all three locations to the documented differences: Design 1 (no QUG, `WAS_{d_lower}`) and survey-design integration via Binder TSL. Sphinx build clean (0 warnings in edited files; the unrelated `tutorials/18_geo_experiments.ipynb:61` "File not found: practitioner_decision_tree.html#few-test-markets" warning is pre-existing on origin/main and not introduced here). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/had.rst | 11 ++++++++--- docs/r_comparison.rst | 24 +++++++++++------------- docs/troubleshooting.rst | 27 ++++++++++++++++++--------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/docs/api/had.rst b/docs/api/had.rst index 8ce4701a..4380ace8 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -65,11 +65,16 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: single SE contract under ``survey_design=`` lands. (Tracked in ``TODO.md``; the deprecation warning emitted by ``HeterogeneousAdoptionDiD.fit`` spells the migration out per call site.) On array-in HAD pretest - helpers (``stute_test``, ``yatchew_hr_test``, ``stute_joint_pretest``, - ``qug_test``) the pweight-only shortcut is + helpers (``stute_test``, ``yatchew_hr_test``, ``stute_joint_pretest``) + the pweight-only shortcut is ``survey_design=make_pweight_design(weights)``; data-in surfaces use ``survey_design=SurveyDesign(weights="col_name", ...)`` against - ``data`` instead. + ``data`` instead. ``qug_test`` is the exception: the QUG step has no + survey-aware migration target (Phase 4.5 C0 decision; see methodology + REGISTRY) and permanently raises ``NotImplementedError`` on any of + ``survey_design=`` / ``survey=`` / ``weights=``. The composite + workflow ``did_had_pretest_workflow`` handles this by skipping QUG + under survey/weighted dispatch and emitting a ``UserWarning``. A simultaneous confidence band (sup-t) is available only on the **weighted event-study path** via ``cband=True``. Joint cross-horizon diff --git a/docs/r_comparison.rst b/docs/r_comparison.rst index 11ddbb7f..0444e977 100644 --- a/docs/r_comparison.rst +++ b/docs/r_comparison.rst @@ -225,14 +225,14 @@ August 2025) covers the QUG case (Design 1', ``d_lower = 0``) from the same arXiv paper. ``diff-diff`` ships :class:`~diff_diff.HeterogeneousAdoptionDiD`, which -implements the broader surface of de Chaisemartin, Ciccia, D'Haultfoeuille -and Knau (2026, arXiv:2405.04465v6): both Design 1' (QUG case, targets -**WAS**) **and** Design 1 (no QUG, ``d_lower > 0``, targets -``WAS_{d_lower}`` under Assumption 6 or sign-only under Assumption 5), the -multi-period event-study extension (paper Appendix B.2), and survey-design -integration via Binder (1983) Taylor-series linearization. The pretest -battery :func:`~diff_diff.did_had_pretest_workflow` adjudicates the design -path and surfaces assumption violations. +implements de Chaisemartin, Ciccia, D'Haultfoeuille and Knau (2026, +arXiv:2405.04465v6) and adds two surfaces beyond the QUG-focused R +package: Design 1 (no QUG, ``d_lower > 0``, targets ``WAS_{d_lower}`` under +Assumption 6 or sign-only under Assumption 5), and survey-design +integration via Binder (1983) Taylor-series linearization (sampling weights ++ optional strata / PSU / FPC). The pretest battery +:func:`~diff_diff.did_had_pretest_workflow` adjudicates the design path +and surfaces assumption violations. .. code-block:: python @@ -420,8 +420,7 @@ Feature Comparison Table HeterogeneousAdoptionDiD (dCDH 2026) overlaps with the dedicated R package ``DIDHAD`` (de Chaisemartin et al., 2025), which covers the QUG case (Design 1'); diff-diff additionally covers Design 1 (no QUG, - ``WAS_{d_lower}``), the multi-period event-study extension (paper - Appendix B.2), and survey-design integration via Binder TSL. + ``WAS_{d_lower}``) and survey-design integration via Binder TSL. Migration Tips -------------- @@ -439,9 +438,8 @@ Migration Tips 5. **Missing data**: diff-diff requires complete data; use ``balance_panel()`` or ``dropna()`` first -6. **Heterogeneous Adoption (HAD)**: If you need the broader HAD surface - beyond the QUG case that the R ``DIDHAD`` package covers - Design 1 - (no QUG, ``WAS_{d_lower}``), the multi-period event-study extension, or +6. **Heterogeneous Adoption (HAD)**: If you need surfaces the R ``DIDHAD`` + package does not cover - Design 1 (no QUG, ``WAS_{d_lower}``) or survey-design integration - reach for :class:`~diff_diff.HeterogeneousAdoptionDiD`. See the `Heterogeneous Adoption (HAD)`_ section above for the migration pattern. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 665ab940..fd039916 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -483,28 +483,37 @@ HeterogeneousAdoptionDiD (HAD) Issues **Problem:** ``HeterogeneousAdoptionDiD`` resolves ``target_parameter`` to ``"WAS_d_lower"`` when you expected ``"WAS"`` (or vice versa). -**Cause:** HAD auto-detects the design path from the dose distribution. Design -1' (QUG case, ``d_lower = 0``) targets WAS by treating the smallest-dose -units as a quasi-untreated anchor; Design 1 (no QUG, ``d_lower > 0``) targets -``WAS_{d_lower}``. If your data has no observations at ``dose = 0`` the -estimator routes to Design 1 even when you intend a WAS interpretation. +**Cause:** HAD auto-detects the design path from the dose distribution. The +``_detect_design`` rule resolves to Design 1' (``continuous_at_zero``, +targets WAS) when EITHER ``d.min() == 0`` exactly OR ``d.min()`` is a small +positive value below ``0.01 * median(|d|)`` (the small-share-of-treated +escape clause). Otherwise (``d.min()`` larger than that threshold) the +estimator routes to Design 1, with a further check for mass-point structure +(modal fraction at ``d.min()`` exceeding 2% routes to ``mass_point``; +otherwise ``continuous_near_d_lower``); both Design 1 paths target +``WAS_{d_lower}``. So a Design 1 resolution only fires when ``d.min()`` +is meaningfully positive relative to the dose scale. **Solutions:** .. code-block:: python # Inspect the dose support before fitting + import numpy as np + d = data['dose'].to_numpy() print(data['dose'].describe()) - print((data['dose'] == 0).sum(), "observations at dose=0") + print(f"d.min() = {d.min():.6g}; " + f"0.01 * median(|d|) = {0.01 * np.median(np.abs(d)):.6g}; " + f"d.min() < threshold => Design 1' (WAS)") # Check the resolved estimand after fitting results = est.fit(data, outcome_col='y', unit_col='unit', time_col='period', dose_col='dose') print(f"Resolved: {results.target_parameter}") - # If you genuinely have a Design 1' panel but lack dose=0 rows, verify - # the dose variable encoding (e.g. log-transformed doses where 0 was - # mapped to a small positive value) + # If you intend Design 1' but `d.min()` exceeds the threshold, verify + # the dose-variable encoding (e.g. log-transformed doses where 0 was + # mapped to a small positive value larger than 1% of the median). "Mass-point fit fallback" ~~~~~~~~~~~~~~~~~~~~~~~~~ From 987bdcd42118bef761c202796cf0089671d706f3 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 09:00:54 -0400 Subject: [PATCH 4/9] Address PR #389 R3 P2: split staggered-event-study failure modes The HAD "Panel-only event-study restriction" subsection in `docs/troubleshooting.rst` overstated when staggered multi-cohort event-study inputs raise. Per `had.py:1230-1366` and `had.py:1470-1499` (also documented in `docs/methodology/REGISTRY.md:2408, 2533`): - Common-adoption panel (single first-treat period): `first_treat_col` optional; the period is auto-inferred from the dose invariant. - Staggered panel WITH `first_treat_col`: estimator auto-filters to the last-treatment cohort + never-treated and emits a UserWarning. - Staggered panel WITHOUT `first_treat_col`: estimator raises (the only actual failure mode for this restriction). Rewrote the cause to spell out the dispatch and made `first_treat_col` the primary remedy; kept manual cohort subsetting as an equivalent that skips the UserWarning. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/troubleshooting.rst | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index fd039916..d061cfdc 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -570,28 +570,33 @@ fits. See :doc:`api/had` for the full SE-regime contract. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Problem:** ``HeterogeneousAdoptionDiD.fit(..., aggregate="event_study")`` -raises because the panel is not balanced or the staggered-timing structure -violates the last-treatment-cohort restriction. +raises on a staggered panel. -**Cause:** The Appendix B.2 event-study extension requires staggered-timing -panels restricted to the last-treatment cohort (which retains never-treated -units as comparisons). Unbalanced panels or earlier-treated cohorts are not -supported in the current release. +**Cause:** The Appendix B.2 event-study extension requires either a +common-adoption panel (single first-treat period; ``first_treat_col`` is +then optional and the period is inferred from the dose invariant) or a +staggered panel with ``first_treat_col`` provided so the estimator can +auto-filter to the last-treatment cohort plus never-treated units (with +a ``UserWarning``). The fit raises only when the panel is staggered +**and** ``first_treat_col`` is missing. **Solutions:** .. code-block:: python - # Verify panel balance - periods_per_unit = data.groupby('unit')['period'].nunique() - assert periods_per_unit.nunique() == 1, "Panel is unbalanced" + # Primary remedy: pass `first_treat_col` so the estimator auto-filters + # to the last-treatment cohort + never-treated and emits a UserWarning. + est = HeterogeneousAdoptionDiD() + results = est.fit(data, outcome_col='y', unit_col='unit', + time_col='period', dose_col='dose', + first_treat_col='first_treat', + aggregate='event_study') - # Restrict to the last-treatment cohort manually if needed + # Equivalent: subset to the last-treatment cohort + never-treated + # before fitting (skips the UserWarning). last_cohort = data['first_treat'].max() subset = data[(data['first_treat'] == last_cohort) | (data['first_treat'] == 0)] - - est = HeterogeneousAdoptionDiD() results = est.fit(subset, outcome_col='y', unit_col='unit', time_col='period', dose_col='dose', aggregate='event_study') From ff6974104d2a25e43f0ca15445df440bca453d08 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 09:12:40 -0400 Subject: [PATCH 5/9] Address PR #389 R4 (1 P1 + 1 P3): default-robust correction + CR1 inclusion P1 (default robust value): the troubleshooting "classical SE under survey" solution and the api/had.rst inference-contract block both said the default `robust=True` maps to `vcov_type='hc1'`. Per `had.py:2643-2649` the constructor default is `robust=False`, which the survey + mass-point guard at `had.py:3418-3447` treats as classical and raises - so the suggested workaround `HeterogeneousAdoptionDiD()` reproduces the same NotImplementedError. Replaced both with explicit working overrides (`HeterogeneousAdoptionDiD(vcov_type='hc1')` or `HeterogeneousAdoptionDiD(robust=True)`) and called out the wrong-default trap explicitly. P3 (CR1 omitted from weighted mass-point inference summary): both `docs/api/had.rst:L49-L54` and `docs/choosing_estimator.rst:L665-L667` described the deprecated `weights=` shortcut mass-point path as "`classical` / `hc1` only". Per `had.py:2276-2284, 2433-2448` and `docs/methodology/REGISTRY.md:2356-2358, 2527`, CR1 (Liang-Zeger) is also supported when `cluster=` is supplied on the weighted mass-point path. Added "CR1 when `cluster=` is supplied" to both summaries. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/had.rst | 14 ++++++++------ docs/choosing_estimator.rst | 2 +- docs/troubleshooting.rst | 14 ++++++++------ 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/api/had.rst b/docs/api/had.rst index 4380ace8..f6c33a03 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -48,9 +48,10 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: structural-residual 2SLS sandwich. No cross-horizon covariance. - **``weights=np.ndarray`` shortcut (deprecated)** - continuous paths reuse the CCT-2014 SE; the mass-point path uses an analytical - weighted 2SLS sandwich (``classical`` / ``hc1`` only - ``hc2`` / - ``hc2_bm`` raise ``NotImplementedError`` pending a 2SLS-specific - leverage derivation). Yields ``variance_formula="pweight"`` / + weighted 2SLS sandwich (``classical`` / ``hc1``; CR1 when + ``cluster=`` is supplied; ``hc2`` / ``hc2_bm`` raise + ``NotImplementedError`` pending a 2SLS-specific leverage + derivation). Yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. - **``survey_design=SurveyDesign(weights="col", ...)``** (canonical; accepts strata / PSU / FPC) - both paths compose Binder (1983) @@ -90,9 +91,10 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: ``compute_survey_if_variance`` and the sup-t bootstrap target ``V_HC1`` consistently; mixing it with a classical analytical SE would silently report a ``V_HC1``-targeted variance under a - ``classical`` label. Use ``vcov_type="hc1"`` (or leave it unset with - the default ``robust=True`` mapping); a classical-aligned IF - derivation is queued for a follow-up PR. + ``classical`` label. Use ``vcov_type="hc1"`` or set ``robust=True`` + explicitly (the constructor default ``robust=False`` maps to + ``vcov_type="classical"``, which triggers the guard); a + classical-aligned IF derivation is queued for a follow-up PR. HeterogeneousAdoptionDiD ------------------------ diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 512ed8fb..26b32e6b 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -664,7 +664,7 @@ differences helps interpret results and choose appropriate inference. - Uses influence-function-based SEs by default. Use ``n_bootstrap=199`` (or higher) for multiplier bootstrap inference with proper CIs. * - ``HeterogeneousAdoptionDiD`` - Path-dependent (CCT-2014 / 2SLS / Binder TSL) - - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **Deprecated ``weights=`` shortcut**: continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1`` only); yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. **``survey_design=SurveyDesign(weights="col", ...)``**: both paths compose Binder (1983) Taylor-series linearization (``"survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``). The two weighted families differ on this estimator until the next-minor unification lands. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. + - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **Deprecated ``weights=`` shortcut**: continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1``; CR1 when ``cluster=`` is supplied); yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. **``survey_design=SurveyDesign(weights="col", ...)``**: both paths compose Binder (1983) Taylor-series linearization (``"survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``). The two weighted families differ on this estimator until the next-minor unification lands. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. * - ``SunAbraham`` - Cluster-robust (unit level) - Clusters at unit level by default. Specify ``cluster`` to override. Use ``n_bootstrap`` for pairs bootstrap inference. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index d061cfdc..cd34fc71 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -556,15 +556,17 @@ silently report a ``V_HC1``-targeted variance under a ``classical`` label. .. code-block:: python - # Use the default robust=True (which maps to vcov_type='hc1') - est = HeterogeneousAdoptionDiD() # robust=True by default - - # Or explicitly request HC1 + # The constructor default `robust=False` maps to `vcov_type='classical'` + # and triggers the guard on the mass-point survey path - so plain + # `HeterogeneousAdoptionDiD()` is NOT a workaround. Pick one of: est = HeterogeneousAdoptionDiD(vcov_type='hc1') + # Or equivalently: + est = HeterogeneousAdoptionDiD(robust=True) # maps to vcov_type='hc1' A classical-aligned IF derivation is queued for a follow-up release; until -then, ``vcov_type='hc1'`` is the recommended path for survey + mass-point -fits. See :doc:`api/had` for the full SE-regime contract. +then, ``vcov_type='hc1'`` (or the equivalent ``robust=True``) is the +recommended path for survey + mass-point fits. See :doc:`api/had` for the +full SE-regime contract. "Panel-only event-study restriction" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From db00262f7189f941f346c77021b534bb5f0c0f47 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 09:23:39 -0400 Subject: [PATCH 6/9] Address PR #389 R5 P2: clarify did_had_pretest_workflow scope (battery, not adjudicator) The new HAD prose overstated `did_had_pretest_workflow` as adjudicating the HAD design path or auto-dispatching by panel structure. Per `had_pretests.py:4033-4045` the workflow has two explicit modes via the `aggregate=` kwarg ("overall" vs "event_study") that the caller picks; the HAD design (`continuous_at_zero` / `continuous_near_d_lower` / `mass_point`) is resolved separately inside `HeterogeneousAdoptionDiD.fit` by `_detect_design()` from the dose support (`had.py:1932-1987`). - `docs/api/had.rst` HAD Pretests intro: rephrased as a diagnostic battery; spells out the two `aggregate=` modes selected by the caller and notes the design path is auto-detected inside the estimator. - `docs/practitioner_decision_tree.rst` Universal Rollout snippet: comment no longer claims the workflow "adjudicates which design path"; clarifies that the estimator picks the design from the dose support. - `docs/r_comparison.rst` Heterogeneous Adoption section: dropped "adjudicates the design path" claim; describes the workflow as a diagnostic battery and points the design-path resolution at the estimator. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/had.rst | 14 +++++++++----- docs/practitioner_decision_tree.rst | 5 +++-- docs/r_comparison.rst | 7 ++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/api/had.rst b/docs/api/had.rst index f6c33a03..693acf3a 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -129,11 +129,15 @@ HAD Pretests Diagnostic pretests for the HAD identification assumptions from de Chaisemartin et al. (2026). The composite orchestrator -:func:`~diff_diff.did_had_pretest_workflow` dispatches to two shapes based on -panel structure: the **overall** path (two-period first-differenced sample) -runs single-period tests; the **event-study** path (three or more periods) -runs joint multi-period tests. Both paths return a unified -:class:`~diff_diff.HADPretestReport`. +:func:`~diff_diff.did_had_pretest_workflow` is a diagnostic battery only - it +does NOT pick the HAD design path (``continuous_at_zero`` / +``continuous_near_d_lower`` / ``mass_point``); that is auto-detected inside +:meth:`HeterogeneousAdoptionDiD.fit` from the dose support. The workflow has +two explicit modes selected by the caller via the ``aggregate=`` kwarg: +``aggregate="overall"`` (default, two-period first-differenced sample) runs +single-period tests; ``aggregate="event_study"`` (multi-period panel with +three or more periods) runs joint multi-period tests. Both modes return a +unified :class:`~diff_diff.HADPretestReport`. .. autofunction:: diff_diff.did_had_pretest_workflow diff --git a/docs/practitioner_decision_tree.rst b/docs/practitioner_decision_tree.rst index 0ccb3c7f..14bf4883 100644 --- a/docs/practitioner_decision_tree.rst +++ b/docs/practitioner_decision_tree.rst @@ -284,8 +284,9 @@ identification rests on stronger structural assumptions (Design 1). from diff_diff import HeterogeneousAdoptionDiD, did_had_pretest_workflow - # Run the pretest workflow first - it adjudicates which design path - # your data supports and surfaces assumption violations + # Run the pretest battery first - it surfaces violations of the HAD + # identification assumptions (it does NOT pick the design path; the + # estimator does that internally from the dose support). pretests = did_had_pretest_workflow( data, outcome_col="y", unit_col="unit", time_col="period", dose_col="dose", diff --git a/docs/r_comparison.rst b/docs/r_comparison.rst index 0444e977..9a641691 100644 --- a/docs/r_comparison.rst +++ b/docs/r_comparison.rst @@ -230,9 +230,10 @@ arXiv:2405.04465v6) and adds two surfaces beyond the QUG-focused R package: Design 1 (no QUG, ``d_lower > 0``, targets ``WAS_{d_lower}`` under Assumption 6 or sign-only under Assumption 5), and survey-design integration via Binder (1983) Taylor-series linearization (sampling weights -+ optional strata / PSU / FPC). The pretest battery -:func:`~diff_diff.did_had_pretest_workflow` adjudicates the design path -and surfaces assumption violations. ++ optional strata / PSU / FPC). The diagnostic battery +:func:`~diff_diff.did_had_pretest_workflow` surfaces violations of the HAD +identification assumptions (the design path is auto-detected separately by +:meth:`HeterogeneousAdoptionDiD.fit` from the dose support). .. code-block:: python From f43d737cb44dd090b2e6a1553b2cc26ba3ed4cdb Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 09:39:09 -0400 Subject: [PATCH 7/9] Address PR #389 R6 P3: qualify CR1 mass-point + cband event-study exception The R4 CR1 addition wrote "CR1 when `cluster=` is supplied" without noting the documented mass-point + `aggregate="event_study"` + `cband=True` carve-out: that sub-path rejects an effective `classical` vcov (per `had.py:4147-4181` and REGISTRY:2380-2382), so plain `cluster=` with the default `robust=False` hits the classical-default trap. Both `docs/api/had.rst` and `docs/choosing_estimator.rst` now spell out the carve-out and point to the existing classical-default deviation note for the working override (`vcov_type="hc1"` or `robust=True`). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/had.rst | 8 ++++++-- docs/choosing_estimator.rst | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/api/had.rst b/docs/api/had.rst index 693acf3a..2bdbaa74 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -51,8 +51,12 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: weighted 2SLS sandwich (``classical`` / ``hc1``; CR1 when ``cluster=`` is supplied; ``hc2`` / ``hc2_bm`` raise ``NotImplementedError`` pending a 2SLS-specific leverage - derivation). Yields ``variance_formula="pweight"`` / - ``"pweight_2sls"``. + derivation). The mass-point + ``aggregate="event_study"`` + + ``cband=True`` sub-path additionally rejects an effective + ``classical`` vcov (so plain ``cluster=`` with the default + ``robust=False`` triggers the classical-default trap below; + pass ``vcov_type="hc1"`` or ``robust=True`` to use CR1 there). + Yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. - **``survey_design=SurveyDesign(weights="col", ...)``** (canonical; accepts strata / PSU / FPC) - both paths compose Binder (1983) Taylor-series linearization with ``df_survey`` threaded into diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 26b32e6b..0e330e66 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -664,7 +664,7 @@ differences helps interpret results and choose appropriate inference. - Uses influence-function-based SEs by default. Use ``n_bootstrap=199`` (or higher) for multiplier bootstrap inference with proper CIs. * - ``HeterogeneousAdoptionDiD`` - Path-dependent (CCT-2014 / 2SLS / Binder TSL) - - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **Deprecated ``weights=`` shortcut**: continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1``; CR1 when ``cluster=`` is supplied); yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. **``survey_design=SurveyDesign(weights="col", ...)``**: both paths compose Binder (1983) Taylor-series linearization (``"survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``). The two weighted families differ on this estimator until the next-minor unification lands. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. + - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **Deprecated ``weights=`` shortcut**: continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1``; CR1 when ``cluster=`` is supplied, except mass-point + ``aggregate="event_study"`` + ``cband=True`` requires explicit ``vcov_type="hc1"`` or ``robust=True`` per the classical-default exception in :doc:`api/had`); yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. **``survey_design=SurveyDesign(weights="col", ...)``**: both paths compose Binder (1983) Taylor-series linearization (``"survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``). The two weighted families differ on this estimator until the next-minor unification lands. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. * - ``SunAbraham`` - Cluster-robust (unit level) - Clusters at unit level by default. Specify ``cluster`` to override. Use ``n_bootstrap`` for pairs bootstrap inference. From 96eb711ca6e59de4a17f596da2ece239893d88b5 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 09:49:27 -0400 Subject: [PATCH 8/9] Address PR #389 R7 P3: clustered weighted mass-point combinations rejected outright The R6 wording said `cluster=` + `aggregate="event_study"` + `cband=True` on mass-point could be made to work via `vcov_type="hc1"` / `robust=True`. Per `had.py:4059-4095` and `had.py:3399-3447` (also documented in REGISTRY:2380-2382) that path is rejected outright regardless of `vcov_type`, and `survey_design=` + `cluster=` on weighted mass-point is similarly rejected. The error is about variance-family mixing in the sup-t bootstrap / Binder-TSL composition, not about the classical-default trap. - `docs/api/had.rst` inline weights= shortcut summary: narrowed the CR1 qualifier - "rejected outright regardless of `vcov_type`" + cross-link to a new sibling deviation note. - `docs/api/had.rst` new "Mass-point cluster-combination deviation" note beside the existing classical-default note: enumerates the two rejected combinations (survey_design= + cluster= static and event-study; weights= + cluster= + cband=True event-study) with the implementation's own workaround language (cband=False / drop cluster= / cluster= alone / weights= + cluster=). - `docs/choosing_estimator.rst` SE Methods row: dropped the misleading "requires explicit hc1/robust=True" implication; says rejected outright for both the weighted shortcut + cluster= + event_study cband and the survey_design= + cluster= mass-point combination. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/had.rst | 32 ++++++++++++++++++++++++-------- docs/choosing_estimator.rst | 2 +- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/api/had.rst b/docs/api/had.rst index 2bdbaa74..8eff00b2 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -49,14 +49,12 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: - **``weights=np.ndarray`` shortcut (deprecated)** - continuous paths reuse the CCT-2014 SE; the mass-point path uses an analytical weighted 2SLS sandwich (``classical`` / ``hc1``; CR1 when - ``cluster=`` is supplied; ``hc2`` / ``hc2_bm`` raise - ``NotImplementedError`` pending a 2SLS-specific leverage - derivation). The mass-point + ``aggregate="event_study"`` + - ``cband=True`` sub-path additionally rejects an effective - ``classical`` vcov (so plain ``cluster=`` with the default - ``robust=False`` triggers the classical-default trap below; - pass ``vcov_type="hc1"`` or ``robust=True`` to use CR1 there). - Yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. + ``cluster=`` is supplied, except ``cluster=`` + + ``aggregate="event_study"`` + ``cband=True`` is rejected outright + regardless of ``vcov_type`` per the cluster-combination deviation + below; ``hc2`` / ``hc2_bm`` raise ``NotImplementedError`` pending a + 2SLS-specific leverage derivation). Yields + ``variance_formula="pweight"`` / ``"pweight_2sls"``. - **``survey_design=SurveyDesign(weights="col", ...)``** (canonical; accepts strata / PSU / FPC) - both paths compose Binder (1983) Taylor-series linearization with ``df_survey`` threaded into @@ -100,6 +98,24 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: ``vcov_type="classical"``, which triggers the guard); a classical-aligned IF derivation is queued for a follow-up PR. + **Mass-point cluster-combination deviation.** On + ``design="mass_point"``, two clustered weighted paths are rejected + outright regardless of ``vcov_type``: + + - ``survey_design=SurveyDesign(...)`` + ``cluster=`` (static and + event-study): the survey path composes Binder-TSL variance, which + would silently override the CR1 cluster-robust sandwich. + Workarounds: ``cluster=`` alone (unweighted CR1), or ``weights=`` + + ``cluster=`` (weighted-CR1 pweight sandwich), or + ``survey_design=`` alone (Binder-TSL). Combined cluster-robust + + survey inference is queued for a follow-up PR. + - Deprecated ``weights=`` shortcut + ``cluster=`` + + ``aggregate="event_study"`` + ``cband=True``: the sup-t bootstrap + normalizes HC1-scale perturbations by the CR1 analytical SE, + mixing variance families. Workarounds: pass ``cband=False`` (keeps + weighted-CR1 per-horizon), or drop ``cluster=`` (keeps + weighted-HC1 sup-t). + HeterogeneousAdoptionDiD ------------------------ diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 0e330e66..796864be 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -664,7 +664,7 @@ differences helps interpret results and choose appropriate inference. - Uses influence-function-based SEs by default. Use ``n_bootstrap=199`` (or higher) for multiplier bootstrap inference with proper CIs. * - ``HeterogeneousAdoptionDiD`` - Path-dependent (CCT-2014 / 2SLS / Binder TSL) - - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **Deprecated ``weights=`` shortcut**: continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1``; CR1 when ``cluster=`` is supplied, except mass-point + ``aggregate="event_study"`` + ``cband=True`` requires explicit ``vcov_type="hc1"`` or ``robust=True`` per the classical-default exception in :doc:`api/had`); yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. **``survey_design=SurveyDesign(weights="col", ...)``**: both paths compose Binder (1983) Taylor-series linearization (``"survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``). The two weighted families differ on this estimator until the next-minor unification lands. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. + - Three SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 weighted-robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **Deprecated ``weights=`` shortcut**: continuous reuses CCT-2014; mass-point uses analytical weighted 2SLS (``classical`` / ``hc1``; CR1 when ``cluster=`` is supplied, except mass-point + ``cluster=`` + ``aggregate="event_study"`` + ``cband=True`` is rejected outright - see :doc:`api/had` for the cluster-combination deviation note); yields ``variance_formula="pweight"`` / ``"pweight_2sls"``. **``survey_design=SurveyDesign(weights="col", ...)``**: both paths compose Binder (1983) Taylor-series linearization (``"survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``); mass-point + ``survey_design=`` + ``cluster=`` is also rejected outright (combined survey + cluster inference is deferred). The two weighted families differ on this estimator until the next-minor unification lands. Per-horizon CIs are pointwise; sup-t bands available only on the weighted event-study path via ``cband=True``. * - ``SunAbraham`` - Cluster-robust (unit level) - Clusters at unit level by default. Specify ``cluster`` to override. Use ``n_bootstrap`` for pairs bootstrap inference. From b0399ee390a45370a1b9a671bbff048758d32e19 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 26 Apr 2026 10:01:21 -0400 Subject: [PATCH 9/9] Address PR #389 R8 P3: mass-point is a distinct estimator path, not a fallback The "Mass-point fit fallback" troubleshooting subsection framed `mass_point` as a fallback that "just changes the SE regime." Per `had.py:33-39, 2260-2272` and REGISTRY:2400-2402, 2523-2527, mass-point is a distinct Design 1 estimator path from dCDH 2026 paper Section 3.2.4 (not the Appendix): both the point estimate (Wald-IV sample-average ratio with binary instrument `Z_g = 1{D_{g,2} > d_lower}`) AND the SE (structural-residual 2SLS sandwich) differ from the continuous local-linear / CCT-2014 paths. Renamed the subsection to "Mass-point design selected", rewrote the cause to spell out the 2%-modal-fraction trigger, the Wald-IV ratio formula, and the structural-residual sandwich; corrected the paper-section attribution (Section 3.2.4, not Appendix); refined the solution snippet to point at re-binning to drop modal fraction below 2% if the user prefers continuous_near_d_lower. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/troubleshooting.rst | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index cd34fc71..d438e36c 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -515,28 +515,35 @@ is meaningfully positive relative to the dose scale. # the dose-variable encoding (e.g. log-transformed doses where 0 was # mapped to a small positive value larger than 1% of the median). -"Mass-point fit fallback" -~~~~~~~~~~~~~~~~~~~~~~~~~ +"Mass-point design selected" +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -**Problem:** HAD reports that the ``mass_point`` fit path was used instead of -the expected ``continuous_at_zero`` or ``continuous_near_d_lower`` path. +**Problem:** HAD reports that the ``mass_point`` design was selected +instead of ``continuous_at_zero`` or ``continuous_near_d_lower``. -**Cause:** Local-linear regression at the dose-support boundary requires a -sufficiently dense neighborhood. When the dose distribution has a heavy -mass-point at the boundary (e.g. many units at ``dose = d_lower``), HAD falls -back to the structural-residual 2SLS sandwich derived in dCDH 2026 Appendix. -This is a correct fallback, not a failure - it just changes the SE regime. +**Cause:** ``mass_point`` is a distinct Design 1 estimator path from the +dCDH 2026 paper (Section 3.2.4), not a fallback from the continuous +local-linear fits. ``_detect_design()`` resolves to ``mass_point`` when the +modal fraction at ``d.min()`` exceeds 2%, signalling a heavy point mass at +the dose-support boundary. On this path both the point estimate and the SE +differ from the continuous paths: the estimator uses the Wald-IV +sample-average ratio with binary instrument ``Z_g = 1{D_{g,2} > d_lower}`` +- ``(Ybar_{Z=1} - Ybar_{Z=0}) / (Dbar_{Z=1} - Dbar_{Z=0})`` - and inference +uses the structural-residual 2SLS sandwich (the local-linear / CCT-2014 +SE path is not used here). **Solutions:** .. code-block:: python - # Inspect the fit path used - print(f"Design: {results.design}") # 'mass_point' indicates fallback + # Inspect the resolved design + print(f"Design: {results.design}") # 'mass_point' here - # The 2SLS sandwich is the correct inference for mass-point designs; - # accept the fallback unless you can re-bin the dose variable to a - # smoother distribution. + # The mass-point Wald-IV estimator + structural-residual 2SLS + # sandwich is the canonical Section 3.2.4 path for designs with a + # heavy boundary point mass; accept the resolution unless you can + # re-bin the dose variable so the modal fraction at d.min() drops + # below 2% (then the detector picks continuous_near_d_lower). "NotImplementedError on survey + mass-point + vcov_type='classical'" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~