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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 120 additions & 14 deletions docs/api/had.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,29 +46,75 @@ 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``.
- **``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, 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
``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``)
the pweight-only shortcut is
``survey_design=make_pweight_design(weights)``; data-in surfaces use
``survey_design=SurveyDesign(weights="col_name", ...)`` against
``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
analytical covariance is not computed in this release; tracked in
``TODO.md``.

**Mass-point ``vcov_type="classical"`` deviation.** The mass-point
``survey=`` paths (static and event-study) and the ``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
``survey_design=SurveyDesign(...)`` paths (static and event-study) and
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
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.

**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
------------------------
Expand Down Expand Up @@ -97,3 +143,63 @@ 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` 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

.. 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:
55 changes: 55 additions & 0 deletions docs/choosing_estimator.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_col='y', unit_col='unit',
time_col='period', dose_col='dose')

est = HeterogeneousAdoptionDiD()
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.att:.3f}")

Efficient DiD
~~~~~~~~~~~~~

Expand Down Expand Up @@ -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. **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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/doc-deps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
56 changes: 56 additions & 0 deletions docs/practitioner_decision_tree.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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*,
Expand Down Expand Up @@ -258,6 +263,54 @@ 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 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",
)
print(pretests)

est = HeterogeneousAdoptionDiD()
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"Average lift per unit of dose: {results.att:.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
Expand Down Expand Up @@ -377,6 +430,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
Expand Down
Loading
Loading