diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2161da..3a117fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed +- **`HeterogeneousAdoptionDiD.fit()` no longer accepts the deprecated `survey=` and + `weights=` kwargs** (the pre-scheduled removal for the 3.7.0 minor bump; the + `DeprecationWarning` shipped in a prior release with "will be removed in the next minor + release"). Weighting is now expressed solely through the canonical `survey_design=`, + matching `CallawaySantAnna` / `EfficientDiD` / `ImputationDiD` / `TwoStageDiD` (which all + take `survey_design=` only and compute design-based Binder (1983) Taylor-linearization + variance via the shared `compute_survey_if_variance`). + - **`survey=SurveyDesign(...)`** -> **`survey_design=SurveyDesign(...)`** (pure rename; same + Binder-TSL path, byte-identical output). + - **`fit(weights=)`** -> add the weights as a column on `data` and pass + **`survey_design=SurveyDesign(weights='col')`**. This is a variance-family change: the old + per-row `weights=` array produced a CCT-2014 pweight-robust / 2SLS-sandwich SE with Normal + inference (`variance_formula="pweight"` / `"pweight_2sls"`); the canonical path produces + Binder-TSL with `t(df_survey)` inference (`variance_formula="survey_binder_tsl"` / + `"survey_binder_tsl_2sls"`). Point estimates are unchanged; SEs move ~0.1%+. + - **`fit(weights=, cluster=)`** (the weighted-CR1 2SLS sandwich) has no drop-in + equivalent - migrate to **`survey_design=SurveyDesign(weights='w', psu='cluster_col')`** + (Binder-TSL clustered through the PSU). + - **`cband` is now keyword-only** on `fit()` (it previously followed the removed positional + `survey`/`weights` slots; keyword-only prevents a silent positional misbind). + - Note: this public-kwarg removal in a *minor* bump is the pre-scheduled HAD exception + documented since the deprecation shipped; other pending removals (e.g. `SyntheticDiD` + `lambda_reg`/`zeta`) remain gated on the next major (v4.0.0). The equivalent + `survey=`/`weights=` kwargs on the HAD pretest helpers (`stute_test`, `qug_test`, + `did_had_pretest_workflow`, ...) are unchanged in this release and removed separately. + ### Testing - **CI-locked standard-error parity for flagship and previously-unasserted paths (SE-audit coverage batch).** These surfaces computed SEs matching R but had no CI assertion pinning them diff --git a/TODO.md b/TODO.md index 80ed68d4..99c55d87 100644 --- a/TODO.md +++ b/TODO.md @@ -134,7 +134,7 @@ Doable in principle, but no current caller and/or explicitly out of paper scope. | `solve_ols` residual memory floor is inherent to SVD-based lstsq after the PR-E marshalling slim (rust-side allocator high-water 15.32 -> 7.81 GB on the 2.4M x 130 clustered solve; remaining = one fused equilibrated input copy + thin-SVD U transient + vcov scores block + LAPACK gelsd internals on the python path). Further reduction requires the tall-skinny-QR algorithm change - same trade-off as the parked QR-reuse row below (library-wide last-digit perturbation + loses the second independent collinearity detector); the two land together if ever reopened. Diagnosis + measurements in docs/performance-plan.md. | `rust/src/linalg.rs::solve_ols`, `diff_diff/linalg.py` | PR-E | Low | | SunAbraham-family fits are SOLVER-bound after the v3.6.x demeaning speedups: on the county-class shape (3.1k units x 60 months, ~100 interaction columns) `solve_ols` = ~60% of fit time (faer SVD 1.24s + pivoted-QR rank detection 0.70s of 3.27s), and the share grows further under the Rust demean kernel; the pivoted QR alone is ~20% of the whole fit (measured attribution in docs/performance-plan.md). Candidate fix is QR-reuse (one factorization for rank detection + solve). **Parked per the 2026-07 correctness-first decision**: the change perturbs every coefficient in the library at the last digits (golden/parity/bit-identity recapture) and removes the SVD's independent singular-value truncation - a second, different near-collinearity detector that is deliberate defense-in-depth for the no-silent-failures contract (the FE-span guard episode showed borderline cases are real). Re-open only on practitioner demand for wide event studies; any implementation gates on fixest/R end-to-end parity, not just internal consistency. | `linalg.py::solve_ols`, `linalg.py::_detect_rank_deficiency` | PR-C attribution | Medium | | dCDH parity-test SE/CI assertions only cover pure-direction scenarios; mixed-direction SE comparison is structurally apples-to-oranges (cell-count vs obs-count weighting). | `test_chaisemartin_dhaultfoeuille_parity.py` | #294 | Low | -| `HeterogeneousAdoptionDiD` survey-design API consolidation (**scheduled: next minor bump**): drop the deprecated `survey=` / `weights=` kwargs on all 8 HAD surfaces; only `survey_design=` remains. Also fold the legacy back-end `weights=` routing into the unified `_resolve_survey_for_fit` path. DeprecationWarning has shipped; the removal is ~50 LoC gated on the semver bump. | `had.py`, `had_pretests.py` | next minor | Medium | +| `HeterogeneousAdoptionDiD` survey-design API consolidation — **pretest helpers** (`HeterogeneousAdoptionDiD.fit` shipped in 3.7.0): drop the deprecated `survey=` / `weights=` kwargs on the 7 remaining HAD pretest surfaces (`did_had_pretest_workflow`, `qug_test`, `stute_test`, `yatchew_hr_test`, `stute_joint_pretest`, `joint_pretrends_test`, `joint_homogeneity_test`); only `survey_design=` remains. Each helper has its OWN weight back-end (array-in helpers read raw `survey`/`weights` for computation at `stute_test:1750`; data-in helpers route `weights=` through a downstream `make_pweight_design(weights_unit)`), so this needs per-helper tracing, not the mechanical deletion the fit() surface allowed. Deferred from the fit() removal PR to keep that change bounded. | `had_pretests.py` | next minor (fit() done 3.7.0) | Medium | | `HeterogeneousAdoptionDiD` joint cross-horizon covariance / sup-t bands: per-horizon SEs use independent sandwiches (paper-faithful pointwise CIs per Pierce-Schott Fig 2). Follow-ups (low demand): IF-based stacking for joint cross-horizon inference; analytical H×H covariance on the weighted ES path. (A sup-t band on the unweighted ES path shipped via the clustered band — `cluster=` fires the simultaneous band on the unweighted path.) | `had.py::_fit_event_study` | Phase 2b / 4.5 B | Low | | `HeterogeneousAdoptionDiD` event-study staggered timing beyond the last cohort: Phase 2b auto-filters to the last cohort (paper App B.2); earlier-cohort effects aren't HAD-identified (redirect to dCDH). Full staggered HAD needs a different identification path (out of paper scope). | `had.py::_validate_had_panel_event_study` | Phase 2b | Low | | `HeterogeneousAdoptionDiD` survey-aware support-endpoint test (**research, waits on literature**): needs a calibrated support-infimum test under complex sampling (endpoint EVT × survey-aware functional CLT × tail-empirical-process theory). Permanent `NotImplementedError` on `qug_test(survey=...)`; rationale in REGISTRY § "QUG Null Test" Note (Phase 4.5 C0). | `had_pretests.py::qug_test` | Phase 4.5 C0 | Low | diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 1f042836..a4fd5b71 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -779,12 +779,10 @@ had.fit( unit_col: str, first_treat_col: str | None = None, # Required on staggered panels (last-cohort auto-filter trigger) aggregate: str = "overall", # "overall" (single scalar WAS) or "event_study" (per-horizon WAS) - survey: SurveyDesign | None = None, # DEPRECATED alias of survey_design= - weights: np.ndarray | None = None, # DEPRECATED pweight shortcut alias - cband: bool = True, # Simultaneous (sup-t) confidence bands on event-study fits that are weighted/survey OR clustered *, - survey_design: SurveyDesign | None = None, # Canonical survey-design kwarg (weights, strata, PSU, FPC) - trends_lin: bool = False, # Eq 17 linear-trend detrending. Requires aggregate="event_study"; needs F>=3 (pre-period depth) for the regression; rejects ALL weighting entry paths (survey_design= / survey= / weights= all raise NotImplementedError under trends_lin). + cband: bool = True, # Simultaneous (sup-t) confidence bands on event-study fits that are survey-weighted OR clustered (keyword-only) + survey_design: SurveyDesign | None = None, # Survey-design kwarg (weights, strata, PSU, FPC) — the sole weighting entry + trends_lin: bool = False, # Eq 17 linear-trend detrending. Requires aggregate="event_study"; needs F>=3 (pre-period depth) for the regression; rejects survey_design= (raises NotImplementedError under trends_lin). covariates: Any | None = None, # NOT IMPLEMENTED — non-None raises NotImplementedError (deferred Appendix B.1 / Theorem 6 covariate-adjusted extension; pre-residualize the outcome on covariates as a workaround) ) -> HeterogeneousAdoptionDiDResults | HeterogeneousAdoptionDiDEventStudyResults ``` @@ -821,7 +819,7 @@ es = est.fit(data_mp, outcome_col='y', unit_col='unit', **Staggered panels.** On multi-cohort panels with `aggregate="event_study"`, `fit()` auto-filters to the last treatment cohort plus never-treated units (paper Appendix B.2) and emits a `UserWarning` naming kept/dropped counts. The estimand is then a **last-cohort-only WAS**, not a multi-cohort average. For full multi-cohort staggered support, see `ChaisemartinDHaultfoeuille`. -**Mass-point + survey constraint.** When fitting `design="mass_point"` with `survey_design=` (or the deprecated `survey=` alias), `vcov_type="hc1"` (or `robust=True`) is required: the survey path composes the standard error via Binder-TSL on the HC1-scale influence function, so the default classical sandwich path raises `NotImplementedError`. The same HC1 requirement also fires on the `weights=` shortcut when `aggregate="event_study"` AND `cband=True` — UNLESS `cluster=` is set (a clustered mass-point fit resolves to the CR1 sandwich regardless of `vcov_type` and uses the clustered sup-t band, so there is no classical-vs-HC1 mismatch): the per-horizon IF matrix is HC1-scale and the sup-t bootstrap normalizes by it, so mixing in a classical analytical SE would produce inconsistent variance families. Classical vcov is allowed on `weights=` + `aggregate="overall"` and on `weights=` + `aggregate="event_study"` + `cband=False`. Passing `vcov_type="hc1"` is a safe default on weighted survey + sup-t examples since `vcov_type` is unused on the continuous designs (CCT-2014 robust SE is the only formula there). +**Mass-point + survey constraint.** When fitting `design="mass_point"` with `survey_design=SurveyDesign(...)`, `vcov_type="hc1"` (or `robust=True`) is required: the survey path composes the standard error via Binder-TSL on the HC1-scale influence function, so the default classical sandwich path raises `NotImplementedError` — on both the static path and the event-study path (the event-study rejection fires regardless of `cband`, since the Binder-TSL analytical SE consumes the HC1-scaled IF either way). The one exception is `cluster=`: a clustered mass-point fit resolves to the CR1 sandwich regardless of `vcov_type` (and uses the clustered sup-t band), so there is no classical-vs-HC1 mismatch. Passing `vcov_type="hc1"` is a safe default on weighted survey + sup-t examples since `vcov_type` is unused on the continuous designs (CCT-2014 robust SE is the only formula there). ### StackedDiD @@ -1554,10 +1552,10 @@ Single-period results container for `HeterogeneousAdoptionDiD`. The table below | `inference_method` | `str` | `"analytical_nonparametric"` or `"analytical_2sls"` | | `vcov_type` | `str | None` | Mass-point only: `"classical"`, `"hc1"`, or `"cr1"` | | `cluster_name` | `str | None` | Cluster column name when CR1 cluster-robust SE is requested; `None` otherwise | -| `survey_metadata` | `SurveyMetadata | None` | Repo-standard survey metadata when `survey_design=` / `weights=` is supplied | +| `survey_metadata` | `SurveyMetadata | None` | Repo-standard survey metadata when `survey_design=` is supplied | | `bandwidth_diagnostics` | `BandwidthResult | None` | MSE-DPI selector output (continuous designs); `None` on `mass_point` | | `bias_corrected_fit` | `BiasCorrectedFit | None` | Phase 1c bias-corrected local-linear fit object (continuous designs); `None` on `mass_point` | -| `variance_formula` | `str | None` | HAD-specific SE label on weighted fits, populated on BOTH continuous and mass-point designs: `"pweight"` (continuous, CCT 2014 weighted-robust on the `weights=` shortcut), `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path), `"pweight_2sls"` (mass-point + `weights=`; label is applied uniformly across vcov families — classical / HC1 / CR1 — on the weighted 2SLS path, with the actual sandwich resolved via `vcov_type`), or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path). `None` on unweighted fits | +| `variance_formula` | `str | None` | HAD-specific SE label on weighted fits, populated on BOTH continuous and mass-point designs: `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path) or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path; requires `vcov_type="hc1"` / `robust=True` — the mass-point survey path rejects `vcov_type="classical"`, and `cluster=` + `survey_design=` is rejected, so PSU clustering is expressed via `SurveyDesign(weights='', psu='')`). `None` on unweighted fits | | `effective_dose_mean` | `float | None` | Weighted denominator used by the β̂-scale rescaling, populated on weighted fits across all designs: weighted `mean(d)` (`continuous_at_zero`), weighted `mean(d − d_lower)` (`continuous_near_d_lower`), or weighted Wald-IV dose gap `mean(d | Z=1, w) − mean(d | Z=0, w)` (`mass_point`). `None` on unweighted fits | **Methods:** `summary()`, `print_summary()`, `to_dict()`, `to_dataframe()` @@ -1590,7 +1588,7 @@ Per-horizon event-study results container for `HeterogeneousAdoptionDiD` with `a | `bandwidth_diagnostics` | `list[BandwidthResult | None] | None` | Per-horizon MSE-DPI selector output (continuous designs); `None` on `mass_point`; entries can be `None` on degenerate horizons | | `bias_corrected_fit` | `list[BiasCorrectedFit | None] | None` | Per-horizon Phase 1c bias-corrected local-linear fit objects; `None` on `mass_point`; entries can be `None` on degenerate horizons | | `filter_info` | `dict | None` | Staggered last-cohort auto-filter metadata (`F_last`, `n_kept`, `n_dropped`, `dropped_cohorts`); `None` when no filter applied | -| `variance_formula` | `str | None` | HAD-specific SE label applied UNIFORMLY across all horizons, populated on BOTH continuous and mass-point designs: `"pweight"` (continuous, CCT 2014 weighted-robust on the `weights=` shortcut), `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path), `"pweight_2sls"` (mass-point + `weights=`; label applied uniformly across vcov families — classical / HC1 / CR1 — on the weighted 2SLS path, with the actual sandwich resolved via `vcov_type`), or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path). `None` on unweighted fits | +| `variance_formula` | `str | None` | HAD-specific SE label applied UNIFORMLY across all horizons, populated on BOTH continuous and mass-point designs: `"survey_binder_tsl"` (continuous, Binder 1983 TSL on the `survey_design=` path) or `"survey_binder_tsl_2sls"` (mass-point, Binder 1983 TSL on the `survey_design=` path; requires `vcov_type="hc1"` / `robust=True` — the mass-point survey path rejects `vcov_type="classical"`, and `cluster=` + `survey_design=` is rejected, so PSU clustering is expressed via `SurveyDesign(weights='', psu='')`). `None` on unweighted fits | | `effective_dose_mean` | `float | None` | Weighted denominator used by the β̂-scale rescaling, populated on weighted fits across all designs: weighted `sum(w·d)/sum(w)` (`continuous_at_zero`), weighted `sum(w·(d − d_lower))/sum(w)` (`continuous_near_d_lower`), or weighted Wald-IV dose gap (`mass_point`). Scalar (not per-horizon) because the β̂-scale denominator is computed once on the fit sample. `None` on unweighted fits | | `cband_low` | `np.ndarray | None` | Simultaneous (sup-t) band lower bounds; `None` when `cband=False` or on unweighted, unclustered fits (a clustered fit produces the band even when unweighted) | | `cband_high` | `np.ndarray | None` | Simultaneous (sup-t) band upper bounds | diff --git a/diff_diff/had.py b/diff_diff/had.py index ad5e8042..4f173e74 100644 --- a/diff_diff/had.py +++ b/diff_diff/had.py @@ -82,9 +82,6 @@ bias_corrected_local_linear, ) from diff_diff.survey import ( - HAD_DEPRECATION_MSG_SURVEY_KWARG, - HAD_DEPRECATION_MSG_WEIGHTS_KWARG_HAD_FIT, - HAD_DUAL_KNOB_MUTEX_MSG_DATA_IN, SurveyMetadata, compute_survey_metadata, ) @@ -250,10 +247,9 @@ class HeterogeneousAdoptionDiDResults: se : float Standard error on the beta-scale. For continuous designs: - - Unweighted or ``weights=``: CCT-2014 weighted-robust SE - from Phase 1c divided by ``|den|`` (``den`` = raw or weighted - denominator depending on fit path). - - ``survey=SurveyDesign(...)``: Binder (1983) Taylor-series + - Unweighted: CCT-2014 robust SE from Phase 1c divided by ``|den|`` + (``den`` = raw denominator). + - ``survey_design=SurveyDesign(...)``: Binder (1983) Taylor-series linearization of the per-unit IF (bias-corrected scale, aligned with ``tau_bc``) routed through :func:`compute_survey_if_variance` for PSU-aggregated, @@ -316,15 +312,15 @@ class HeterogeneousAdoptionDiDResults: survey_metadata : SurveyMetadata or None Repo-standard survey metadata dataclass from :class:`diff_diff.survey.SurveyMetadata`. ``None`` when ``fit()`` - was called without ``survey=`` or ``weights=``; populated on the - continuous-dose weighted paths via + was called without ``survey_design=``; populated on the weighted + paths via :func:`diff_diff.survey.compute_survey_metadata`. Exposes ``weight_type``, ``effective_n``, ``design_effect``, ``sum_weights``, ``n_strata``, ``n_psu``, ``weight_range``, and ``df_survey`` for downstream reporting consumers (BusinessReport, DiagnosticReport) that read these fields via attribute access. - HAD-specific inference-method info (pweight vs Binder-TSL) is - carried on ``inference_method`` and ``variance_formula``. + HAD-specific inference-method info (Binder-TSL) is carried on + ``inference_method`` and ``variance_formula``. bandwidth_diagnostics : BandwidthResult or None Full Phase 1b MSE-DPI selector output on the continuous paths (when bandwidths were auto-selected). ``None`` on the mass-point @@ -369,16 +365,17 @@ class HeterogeneousAdoptionDiDResults: variance_formula: Optional[str] = None """HAD-specific label for the SE formula on weighted fits, populated on BOTH continuous and mass-point designs (Phase 4.5 A / B): - ``"pweight"`` (continuous, weighted-robust CCT 2014 under the - ``weights=`` shortcut), ``"survey_binder_tsl"`` (continuous, Binder - 1983 TSL with PSU/strata/FPC under ``survey_design=SurveyDesign(...)``), - ``"pweight_2sls"`` (mass-point + ``weights=``; label applied - uniformly across vcov families — classical / HC1 / CR1 — on the - weighted 2SLS path, with the actual sandwich resolved via - ``vcov_type``), or ``"survey_binder_tsl_2sls"`` (mass-point, Binder - 1983 TSL under ``survey_design=``). ``None`` on unweighted fits. Orthogonal to ``survey_metadata`` which is the - repo-standard :class:`diff_diff.survey.SurveyMetadata` shared with - downstream report/diagnostic consumers (no HAD-specific leakage).""" + ``"survey_binder_tsl"`` (continuous, Binder 1983 TSL with PSU/strata/FPC + under ``survey_design=SurveyDesign(...)``) or ``"survey_binder_tsl_2sls"`` + (mass-point, Binder 1983 TSL under ``survey_design=``; requires + ``vcov_type="hc1"`` / ``robust=True`` — the mass-point survey path rejects + ``vcov_type="classical"``, and ``cluster=`` + ``survey_design=`` is + rejected, so PSU clustering is expressed via + ``SurveyDesign(weights='', psu='')``). + ``None`` on unweighted fits. + Orthogonal to ``survey_metadata`` which is the repo-standard + :class:`diff_diff.survey.SurveyMetadata` shared with downstream + report/diagnostic consumers (no HAD-specific leakage).""" effective_dose_mean: Optional[float] = None """Weighted denominator used by the beta-scale rescaling, populated on weighted fits across all designs: ``sum(w_g · D_g) / sum(w_g)`` @@ -388,8 +385,8 @@ class HeterogeneousAdoptionDiDResults: ``Z = 1{D > d_lower}``). On the continuous designs reduces bit-exactly to ``dose_mean`` / ``mean(D - d_lower)`` when weights are uniform or absent. ``None`` when ``fit()`` was called without - ``survey_design=`` / ``survey=`` / ``weights=`` (use ``dose_mean`` - there). Exists because ``dose_mean`` is the raw sample mean of the + ``survey_design=`` (use ``dose_mean`` there). Exists because + ``dose_mean`` is the raw sample mean of the dose column; under weighted fits the estimator's actual denominator is the weighted form above, and users reconstructing the β-scale value by hand need the weighted one.""" @@ -504,18 +501,16 @@ def to_dict(self) -> Dict[str, Any]: :class:`diff_diff.survey.SurveyMetadata` dataclass (object, not dict) carrying ``weight_type`` / ``effective_n`` / ``design_effect`` / ``sum_weights`` / ``weight_range`` + - ``n_strata`` / ``n_psu`` / ``df_survey`` (latter three - ``None`` on the ``weights=`` shortcut). + ``n_strata`` / ``n_psu`` / ``df_survey`` (populated from the + resolved ``survey_design=``). - ``variance_formula``: HAD-specific SE label, populated on BOTH continuous and mass-point designs (Phase 4.5 A / B): - ``"pweight"`` (continuous, weighted-robust CCT 2014 under - ``weights=``), ``"survey_binder_tsl"`` (continuous, Binder - 1983 TSL under ``survey_design=``), ``"pweight_2sls"`` - (mass-point + ``weights=``; label applied uniformly across - vcov families — classical / HC1 / CR1 — with the sandwich - resolved via ``vcov_type``), or ``"survey_binder_tsl_2sls"`` - (mass-point, Binder 1983 TSL under ``survey_design=``). See - the field docstring above for the full contract. + ``"survey_binder_tsl"`` (continuous, Binder 1983 TSL under + ``survey_design=``) or ``"survey_binder_tsl_2sls"`` (mass-point, + Binder 1983 TSL under ``survey_design=``; requires + ``vcov_type="hc1"`` / ``robust=True`` — the mass-point survey path + rejects ``vcov_type="classical"``). See the field docstring above + for the full contract. - ``effective_dose_mean``: weighted denominator used by the beta-scale rescaling - weighted ``mean(D)`` on ``continuous_at_zero``, weighted ``mean(D - d_lower)`` on @@ -587,16 +582,10 @@ class HeterogeneousAdoptionDiDEventStudyResults: Per-horizon standard error on the beta-scale. Three regimes: - **Unweighted**: per-horizon INDEPENDENT analytical sandwich - (continuous: CCT-2014 weighted-robust divided by ``|den|``; + (continuous: CCT-2014 robust divided by ``|den|``; mass-point: structural-residual 2SLS sandwich via ``_fit_mass_point_2sls``). No cross-horizon covariance. - - **``weights=`` shortcut**: continuous paths still use the - CCT-2014 weighted-robust SE from lprobust (``bc_fit.se_robust - / |den|``); mass-point uses the analytical weighted 2SLS - pweight sandwich (HC1 / classical / CR1 depending on - ``vcov_type`` + ``cluster=``). No Binder-TSL composition - on this path — inference is Normal (``df=None``). - - **``survey=``**: each horizon composes Binder (1983) + - **``survey_design=``**: each horizon composes Binder (1983) Taylor-series linearization via :func:`compute_survey_if_variance` on the per-unit β̂-scale IF (continuous + mass-point both route through the same @@ -656,15 +645,14 @@ class HeterogeneousAdoptionDiDEventStudyResults: survey_metadata : SurveyMetadata or None Repo-standard survey metadata dataclass from :class:`diff_diff.survey.SurveyMetadata`. ``None`` when - ``fit()`` was called without ``survey=`` or ``weights=``; + ``fit()`` was called without ``survey_design=``; populated on the weighted event-study path (Phase 4.5 B). See :class:`HeterogeneousAdoptionDiDResults.survey_metadata` for the attribute contract. variance_formula : str or None Per-horizon variance family (applied uniformly across horizons). - ``"pweight"`` / ``"pweight_2sls"`` on the ``weights=`` shortcut, ``"survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"`` on the - ``survey=`` path. ``None`` on unweighted fits. + ``survey_design=`` path. ``None`` on unweighted fits. effective_dose_mean : float or None Weighted denominator used by the β̂-scale rescaling (continuous paths: weighted sample mean of ``d`` or ``d - d_lower``; @@ -748,13 +736,10 @@ class HeterogeneousAdoptionDiDEventStudyResults: # on unweighted CLUSTERED fits (Phase 2b clustered band). variance_formula: Optional[str] = None """Per-horizon variance family label (applied uniformly across all - horizons in the fit). One of ``"pweight"`` / ``"pweight_2sls"`` (when - a per-row weight array was supplied, including via the deprecated - ``weights=`` alias; continuous / mass-point), ``"survey_binder_tsl"`` - / ``"survey_binder_tsl_2sls"`` (when a SurveyDesign was supplied via - ``survey_design=`` or the deprecated ``survey=`` alias), or ``None`` - on unweighted fits. Mirrors the static-path ``variance_formula`` - field.""" + horizons in the fit). One of ``"survey_binder_tsl"`` / + ``"survey_binder_tsl_2sls"`` (when a SurveyDesign was supplied via + ``survey_design=``; continuous / mass-point) or ``None`` on unweighted + fits. Mirrors the static-path ``variance_formula`` field.""" effective_dose_mean: Optional[float] = None """Weighted denominator used by the β̂-scale rescaling. For continuous designs: weighted ``sum(w · d)/sum(w)`` (continuous_at_zero) or @@ -2062,8 +2047,9 @@ def _sup_t_multiplier_bootstrap( """Compute sup-t simultaneous CI via PSU-level multiplier bootstrap. Reuses :func:`diff_diff.bootstrap_utils.generate_survey_multiplier_weights_batch` - (survey path) / :func:`generate_bootstrap_weights_batch` (weights= - shortcut) to draw ``n_bootstrap`` replicates of multiplier weights + (survey path, ``resolved_survey`` not None) / :func:`generate_bootstrap_weights_batch` + (unit- or cluster-level Rademacher when ``resolved_survey`` is None) to draw + ``n_bootstrap`` replicates of multiplier weights shaped ``(n_bootstrap, n_units)``; the helpers handle stratum centering, lonely-PSU, and FPC scaling so this function only composes them with the per-unit influence function. @@ -2093,8 +2079,8 @@ def _sup_t_multiplier_bootstrap( Per-horizon point estimates (used to assemble the simultaneous band: ``att ± q · se``). se_per_horizon : np.ndarray, shape (n_horizons,) - Per-horizon analytical SE (Binder-TSL on survey path, HC1 - sandwich on weights= shortcut). + Per-horizon analytical SE (Binder-TSL on the survey path, HC1 + sandwich on the unweighted path). resolved_survey : Optional[ResolvedSurveyDesign] ``None`` → unit-level Rademacher draw via ``generate_bootstrap_weights_batch``. Otherwise → @@ -2118,7 +2104,7 @@ def _sup_t_multiplier_bootstrap( cluster sandwich matching the analytical cluster-robust SE (no stratum-centering / FPC / Bessel; the continuous CCT cluster meat carries no ``g/(g-1)`` correction). ``resolved_survey`` must be - ``None`` when this is set (``cluster= + survey=`` is rejected + ``None`` when this is set (``cluster= + survey_design=`` is rejected up-front). ``None`` restores the survey / unit-level dispatch. cluster_if_scale : float Path-specific finite-sample scalar applied to the cluster-aggregated @@ -2154,7 +2140,7 @@ def _sup_t_multiplier_bootstrap( # also use PSU-aggregation + stratum-demeaning + sqrt(n/(n-1)) # scaling — not raw unit-level Rademacher draws (which skip the # centering and small-sample factor). The unit-level branch below is - # reserved for the ``weights=`` shortcut (no survey object at all). + # reserved for the unweighted path (no survey object at all). use_survey_bootstrap = resolved_survey is not None if cluster_ids is not None: @@ -2741,11 +2727,12 @@ class HeterogeneousAdoptionDiD: ``continuous_near_d_lower``) paths (Phase 2a) it threads the cluster IDs into ``bias_corrected_local_linear`` so ``se_robust`` is the cluster-robust CCT-2014 nonparametric SE (``β̂``-scale - ``se = se_robust / |den|``). Composes with the ``weights=`` shortcut - (weighted cluster-robust). The cluster + ``survey_design=`` + ``se = se_robust / |den|``). A bare ``cluster=`` gives unweighted + cluster-robust inference; the cluster + ``survey_design=`` composition raises ``NotImplementedError`` (the Binder-TSL survey - variance would override the cluster-robust SE — route clustering - through ``survey_design=SurveyDesign(psu=)`` instead). + variance would override the cluster-robust SE — for weighted + clustering route through + ``survey_design=SurveyDesign(weights='', psu='')`` instead). Cluster must be constant within unit. On the event-study path (``aggregate="event_study"``, Phase 2b) ``cluster=`` provides cluster-robust per-horizon pointwise CIs (both designs) AND a @@ -2861,8 +2848,8 @@ def __init__( # number of multiplier-bootstrap replicates for the sup-t band; # ``seed`` = reproducibility seed for the multiplier draws. Both are # consulted when ``aggregate="event_study"`` + ``cband=True`` - # (default) AND either ``survey=`` / ``weights=`` (weighted/survey - # band, Phase 4.5 B) OR ``cluster=`` (cluster-robust band, Phase 2b + # (default) AND either ``survey_design=`` (weighted/survey band, + # Phase 4.5 B) OR ``cluster=`` (cluster-robust band, Phase 2b # — fires even unweighted). An unweighted, unclustered event-study # skips the bootstrap entirely (pre-Phase 4.5 B output preserved). self.n_bootstrap = n_bootstrap @@ -2992,16 +2979,8 @@ def fit( unit_col: str, first_treat_col: Optional[str] = None, aggregate: str = "overall", - # PR #376 R4 P1: preserve pre-PR positional-or-keyword status of - # `survey`, `weights`, `cband` for back-compat with positional - # callers. `survey_design=` is the only new addition and is - # keyword-only. PR #389 (Phase 4 R-parity): `trends_lin=` is - # likewise keyword-only (additive; no positional callers can - # exist for it pre-PR). - survey: Any = None, - weights: Optional[np.ndarray] = None, - cband: bool = True, *, + cband: bool = True, survey_design: Any = None, trends_lin: bool = False, covariates: Any = None, @@ -3085,31 +3064,15 @@ def fit( Survey design columns (strata / PSU / FPC) must be constant within unit (sampling-unit-level assignment); within-unit variance raises ``ValueError``. Replicate-weight designs raise - ``NotImplementedError``. Mutually exclusive with the deprecated - ``survey=`` and ``weights=`` aliases. See + ``NotImplementedError``. See ``docs/methodology/REGISTRY.md`` § HeterogeneousAdoptionDiD — "Note (HAD survey-design API consolidation)" for the full dispatch matrix. - survey : SurveyDesign or None - DEPRECATED alias of ``survey_design=``. Remains positional-or- - keyword for one minor cycle to preserve pre-PR call shapes; - will be removed in the next minor release. Prefer - ``survey_design=``. - weights : np.ndarray or None - DEPRECATED alias for the per-row pweight shortcut. Remains - positional-or-keyword for one minor cycle. Prefer adding the - weights as a column on ``data`` and passing - ``survey_design=SurveyDesign(weights='col_name')`` instead. - Will be removed in the next minor release. Currently - preserved as the analytical-HC1-sandwich shortcut (continuous: - CCT-2014 weighted-robust; mass-point: pweight 2SLS sandwich) - with the per-row → per-unit aggregation invariant intact. - Mutually exclusive with ``survey_design=`` and ``survey=``. cband : bool, default True Controls the multiplier-bootstrap simultaneous confidence band on the event-study path. When ``True`` (default) and - ``aggregate="event_study"`` AND either (a) ``survey_design=`` / - ``survey=`` / ``weights=`` is supplied (weighted/survey band, + ``aggregate="event_study"`` AND either (a) ``survey_design=`` + is supplied (weighted/survey band, Phase 4.5 B) OR (b) ``cluster=`` is supplied (cluster-robust band — fires even on an unweighted fit, Phase 2b), the fit populates ``cband_low`` / ``cband_high`` / ``cband_crit_value`` @@ -3132,9 +3095,9 @@ def fit( ``ValueError`` on ``F < 3``. The "consumed" placebo at event time ``e=-2`` is auto-dropped (R reduces max placebo lag by 1 with the same effect). Mutually - exclusive with survey weighting (``survey_design`` / - ``survey`` / ``weights``); raises ``NotImplementedError`` - if combined. Default ``False`` preserves bit-exact + exclusive with survey weighting (``survey_design``); + raises ``NotImplementedError`` if combined. Default + ``False`` preserves bit-exact backcompat with all pre-PR fits. covariates : array-like or None, default None, keyword-only NOT YET IMPLEMENTED. Reserved for the covariate-adjusted HAD @@ -3165,15 +3128,11 @@ def fit( et al. 2026, Section 2). The event-study path does not warn: it *requires* never-treated units per Appendix B.2. """ - # ---- aggregate / survey_design / survey / weights validation ---- + # ---- aggregate / survey_design validation ---- if aggregate not in _VALID_AGGREGATES: raise ValueError( f"Invalid aggregate={aggregate!r}. Must be one of " f"{_VALID_AGGREGATES}." ) - # Three-way mutex on survey_design / survey / weights (data-in pattern). - n_set = sum(x is not None for x in (survey_design, survey, weights)) - if n_set > 1: - raise ValueError(HAD_DUAL_KNOB_MUTEX_MSG_DATA_IN) # ---- covariates= future-work trap (TODO L73) ---- # Covariate-adjusted HAD identification (de Chaisemartin et al. 2026, @@ -3214,45 +3173,20 @@ def fit( "the same single-effect / per-effect estimates as " "the overall path." ) - if survey_design is not None or survey is not None or weights is not None: + if survey_design is not None: raise NotImplementedError( "HAD.fit(trends_lin=True) is not yet supported with " - "survey weighting (`survey_design=` / `survey=` / " - "`weights=`). The per-group slope estimator's " - "weighted variant (weighted-OLS slope? per-PSU slope?) " - "is not derived from the paper. Use trends_lin=True " - "WITHOUT survey weights, or use survey weights " - "WITHOUT trends_lin. Tracked in TODO.md as a follow-" - "up if user demand emerges." + "survey weighting (`survey_design=`). The per-group slope " + "estimator's weighted variant (weighted-OLS slope? per-PSU " + "slope?) is not derived from the paper. Use trends_lin=True " + "WITHOUT survey weights, or use survey weights WITHOUT " + "trends_lin. Tracked in TODO.md as a follow-up if user " + "demand emerges." ) - # Soft deprecation: route legacy survey=/weights= aliases to - # survey_design=. The internal back-end paths (legacy weights= and - # survey= routing below) are unchanged; only the entry signature - # wraps them. The bit-exact back-compat invariant is preserved - # because we only rebind names, not values, and the legacy `survey` - # / `weights` variables are re-derived from `survey_design` for - # downstream consumption. - if survey is not None: - warnings.warn(HAD_DEPRECATION_MSG_SURVEY_KWARG, DeprecationWarning, stacklevel=2) - survey_design = survey - elif weights is not None: - warnings.warn( - HAD_DEPRECATION_MSG_WEIGHTS_KWARG_HAD_FIT, - DeprecationWarning, - stacklevel=2, - ) - # weights= shortcut preserved as-is on the back end (the - # downstream `if weights is not None:` branch consumes the - # raw array directly via _aggregate_unit_weights). Don't - # rebind survey_design here — the array is not a - # SurveyDesign and survey_design= cannot accept arrays. - else: - # Canonical path: survey_design= may be None or a SurveyDesign - # instance. Map back to the internal `survey` variable name - # so downstream code (legacy `if survey is not None:` branch) - # consumes the input transparently. - survey = survey_design + # `survey_design=` is the sole public weighting entry. The fit body + # reads the internal `survey` name for the (possibly-None) SurveyDesign. + survey = survey_design # Type guard on the data-in surface (PR #376 R8 P1): HAD.fit() # accepts a SurveyDesign that gets resolved against `data` at fit @@ -3294,7 +3228,6 @@ def fit( unit_col=unit_col, first_treat_col=first_treat_col, survey=survey, - weights=weights, cband=cband, trends_lin=trends_lin, ) @@ -3359,29 +3292,23 @@ def fit( # Resolve survey/weights into per-unit weights + optional # ResolvedSurveyDesign (for PSU/strata/FPC composition). - # - `weights=` → per-row array, no PSU/strata composition. - # - `survey=SurveyDesign(weights="col", ...)` → resolve the design + # - `survey_design=SurveyDesign(weights="col", ...)` → resolve the design # and produce a unit-level analogue for per-unit IF composition. # - neither → unweighted path. weights_unit: Optional[np.ndarray] = None raw_weights_unit: Optional[np.ndarray] = None - resolved_survey_unit: Any = None # ResolvedSurveyDesign (G,) when survey= - if weights is not None: - weights_unit = _aggregate_unit_weights(data, weights, unit_col) - # On the ``weights=`` shortcut, the passed array IS the raw - # pre-normalization weights — no SurveyDesign.resolve() scaling. - raw_weights_unit = weights_unit - elif survey is not None: + resolved_survey_unit: Any = None # ResolvedSurveyDesign (G,) when weighted + if survey is not None: if not hasattr(survey, "weights"): raise TypeError( - f"survey= must be a SurveyDesign-like object with a " + f"survey_design= must be a SurveyDesign-like object with a " f".weights attribute; got {type(survey).__name__}. " f"Construct a SurveyDesign via diff_diff.survey." ) if getattr(survey, "weights", None) is None: raise NotImplementedError( - "survey= without weights is not yet supported. Pass " - "survey=SurveyDesign(weights='', ...) with a " + "survey_design= without weights is not yet supported. Pass " + "survey_design=SurveyDesign(weights='', ...) with a " "per-row weight column." ) # HAD's weighted local-linear treats ``weights`` as sampling @@ -3395,7 +3322,7 @@ def fit( weight_type = getattr(survey, "weight_type", "pweight") if weight_type != "pweight": raise NotImplementedError( - f"survey=SurveyDesign(weight_type={weight_type!r}) is " + f"survey_design=SurveyDesign(weight_type={weight_type!r}) is " f"not supported on HeterogeneousAdoptionDiD's " f"continuous path. Only ``weight_type='pweight'`` " f"(sampling / inverse-probability weights) is " @@ -3408,8 +3335,8 @@ def fit( # below so ``compute_survey_metadata`` receives raw weights # (per its contract — ``sum_weights`` / ``weight_range`` are # raw-scale quantities; passing normalized weights would make - # the metadata disagree with the ``weights=`` shortcut and - # drift from the docstring/test contract in ``survey.py``). + # the metadata disagree with ``compute_survey_metadata``'s + # raw-scale contract in ``survey.py``). weights_col_name = survey.weights # known non-None from guard above if weights_col_name not in data.columns: raise ValueError(f"survey.weights column {weights_col_name!r} not found in data.") @@ -3498,9 +3425,8 @@ def fit( f"'{resolved_design}' path is not yet supported: the survey path " f"composes Binder-TSL variance via compute_survey_if_variance and " f"would silently override the cluster-robust local-linear SE. Pass " - f"cluster= alone (unweighted cluster-robust), weights= + cluster= " - f"(weighted cluster-robust), or " - f"survey_design=SurveyDesign(psu=) to cluster through " + f"cluster= alone (unweighted cluster-robust), or " + f"survey_design=SurveyDesign(weights='', psu='') to cluster through " f"the survey (Binder-TSL) path." ) # Re-run the aggregation with cluster_col so validation (missing column / @@ -3745,29 +3671,26 @@ def fit( vcov_label: Optional[str] = "cr1" if cluster_arg is not None else None cluster_label: Optional[str] = cluster_arg if cluster_arg is not None else None elif resolved_design == "mass_point": - # Review R4 P1: narrow the cluster+weighted rejection. Only - # survey= + cluster= is a silent-mismatch case (the + # survey_design= + cluster= is a silent-mismatch case (the # Binder-TSL override would overwrite the CR1 sandwich while - # result metadata still advertises vcov_type='cr1'). The - # weights= shortcut + cluster= path just returns the - # weighted-CR1 sandwich from _fit_mass_point_2sls directly - # (no survey composition) and matches estimatr::iv_robust - # (se_type="stata") bit-exactly — see + # result metadata still advertises vcov_type='cr1'), so it is + # rejected below. The unweighted cluster= path returns the CR1 + # sandwich from _fit_mass_point_2sls directly and matches + # estimatr::iv_robust (se_type="stata") bit-exactly — see # tests/test_estimatr_iv_robust_parity.py::TestEstimatrIVRobustCR1Parity. if cluster_arg is not None and resolved_survey_unit_full is not None: raise NotImplementedError( - f"cluster={cluster_arg!r} + survey= on " + f"cluster={cluster_arg!r} + survey_design= on " f"design='mass_point' is not yet supported: the " f"survey path composes Binder-TSL variance via " f"compute_survey_if_variance and would silently " f"override the CR1 cluster-robust sandwich while " f"result metadata still advertises " f"vcov_type='cr1'. Pass cluster= alone " - f"(unweighted CR1), or weights= + cluster= " - f"(weighted-CR1 pweight sandwich; parity-tested vs " - f"estimatr::iv_robust se_type='stata'), or " - f"survey= alone (Binder-TSL). Combined cluster-" - f"robust + survey inference is deferred to a " + f"(unweighted CR1), or " + f"survey_design=SurveyDesign(weights='', psu='') " + f"to cluster through the survey (Binder-TSL) path. Combined " + f"cluster-robust + survey inference is deferred to a " f"follow-up PR." ) # Resolve the EFFECTIVE vcov family first (vcov_type_arg, @@ -3786,15 +3709,15 @@ def fit( # an HC1-scaled influence function (IF scale convention # locks compute_survey_if_variance(psi, trivial) ≈ V_HC1[1,1] # via the sqrt((n-1)/(n-k)) factor in _fit_mass_point_2sls). - # On the survey= path the analytical SE is ALWAYS overwritten + # On the survey path the analytical SE is ALWAYS overwritten # with that HC1-scale Binder-TSL composition, so effective - # classical + survey= would silently report an HC1-target + # classical + survey_design= would silently report an HC1-target # SE under a classical label. Reject until a # classical-aligned IF is derived. if vcov_requested == "classical" and resolved_survey_unit_full is not None: raise NotImplementedError( "vcov_type='classical' (resolved — either explicit or " - "from the default robust=False mapping) + survey= on " + "from the default robust=False mapping) + survey_design= on " "design='mass_point' is not yet supported: the " "survey path composes Binder-TSL variance via the " "HC1-scale influence function, which targets V_HC1 " @@ -3805,10 +3728,9 @@ def fit( "aligned IF derivation is queued for a follow-up PR." ) # Phase 4.5 B: accept weights_unit (None on unweighted fits). - # return_influence=True only on the survey= path because - # Binder-TSL composition consumes the IF; the weights= - # shortcut and unweighted paths use the analytical sandwich - # SE directly. ``psi_mp`` is per-unit IF on β̂-scale or None. + # return_influence=True only on the survey path because + # Binder-TSL composition consumes the IF; the unweighted path + # uses the analytical sandwich SE directly. ``psi_mp`` is per-unit IF on β̂-scale or None. # Fit on FULL (unfiltered) arrays so the IF aligns with the # full survey design (subpopulation convention: zero-weight # units contribute 0 to all sums; IF zero-padded back to full @@ -3853,8 +3775,8 @@ def fit( # Survey path: use t-distribution with ``df_survey = n_psu - # n_strata`` (or replicate-QR rank − 1) so small-PSU designs # don't get Normal-theory inference that overstates precision. - # Non-survey path (``weights=`` shortcut or unweighted): use - # the existing Normal-theory default. + # Non-survey path (unweighted): use the existing + # Normal-theory default. df_infer: Optional[int] = None if resolved_survey_unit is not None: df_infer = resolved_survey_unit.df_survey @@ -3871,7 +3793,7 @@ def fit( effective_dose_mean_value: Optional[float] = None if weights_unit_full is not None: if resolved_survey_unit_full is not None: - # survey= path: build metadata from the FULL + # survey path: build metadata from the FULL # ResolvedSurveyDesign (pre-zero-weight-filter), so # ``n_strata`` / ``n_psu`` / ``df_survey`` / weight sums # reflect the sampling frame, not the in-domain subset. @@ -3879,9 +3801,8 @@ def fit( # (captured before survey.resolve() rescaled pweights/ # aweights to mean=1) so ``sum_weights`` / ``weight_range`` # reflect the user-supplied scale — matching both the - # ``weights=`` shortcut and ``compute_survey_metadata``'s - # contract. - assert raw_weights_unit_full is not None # set in survey= branch + # ``compute_survey_metadata``'s contract. + assert raw_weights_unit_full is not None # set in survey branch survey_metadata = compute_survey_metadata( resolved_survey_unit_full, raw_weights_unit_full ) @@ -3892,46 +3813,6 @@ def fit( if resolved_design == "mass_point" else "survey_binder_tsl" ) - else: - # weights= shortcut: construct a minimal resolved - # SurveyDesign with the FULL user-supplied weights - # (including zero-weight units) so SurveyMetadata - # summarizes the full sample. No strata / PSU / FPC - # structure — the shortcut is pweight-only by contract. - from diff_diff.survey import ResolvedSurveyDesign - - minimal_resolved = ResolvedSurveyDesign( - weights=weights_unit_full, - weight_type="pweight", - strata=None, - psu=None, - fpc=None, - n_strata=1, - n_psu=int(weights_unit_full.shape[0]), - lonely_psu="remove", - combined_weights=True, - mse=False, - ) - # weights_unit_full is already the raw user-supplied - # array (no SurveyDesign.resolve() normalization here). - survey_metadata = compute_survey_metadata(minimal_resolved, weights_unit_full) - # On the ``weights=`` shortcut, inference stays Normal - # (df=None in safe_inference) — no PSU / strata / FPC - # composition. Clear the survey-only fields that - # ``compute_survey_metadata`` derives from the synthetic - # minimal design (``n_psu = G``, ``n_strata = 1``, - # ``df_survey = G − 1``) so ``summary()`` / BusinessReport - # do not misdescribe the fit as a finite-df survey - # result. ``weight_type``, ``effective_n``, - # ``design_effect``, ``sum_weights``, and - # ``weight_range`` stay populated — they describe the - # weighted sample regardless of inference family. - survey_metadata.n_strata = None - survey_metadata.n_psu = None - survey_metadata.df_survey = None - variance_formula_label = ( - "pweight_2sls" if resolved_design == "mass_point" else "pweight" - ) # Expose the effective weighted denominator used by the # beta-scale rescaling. Continuous paths use a weighted sample # mean of d (or d − d_lower). Mass-point uses the weighted @@ -4095,8 +3976,8 @@ def _fit_continuous( # ``se_robust`` is the cluster-robust nonparametric SE. It also # filters ``cluster_arr`` by the same positive-weight mask it # uses to drop zero-weight rows, so a full-length array aligns. - # ``cluster=`` composes with ``weights=`` (weighted cluster- - # robust); the ``survey_design=`` composition is rejected + # A bare ``cluster=`` gives unweighted cluster-robust + # inference; the ``cluster=`` + ``survey_design=`` composition is rejected # up-front in ``fit()`` (Binder-TSL would override se_robust). cluster=cluster_arr, ) @@ -4159,7 +4040,6 @@ def _fit_event_study( unit_col: str, first_treat_col: Optional[str], survey: Any = None, - weights: Optional[np.ndarray] = None, cband: bool = True, trends_lin: bool = False, ) -> HeterogeneousAdoptionDiDEventStudyResults: @@ -4173,30 +4053,22 @@ def _fit_event_study( Per-horizon variance regimes (matches the static-path contract): - - **``survey=``**: Binder (1983) Taylor-series linearization + - **``survey_design=``**: Binder (1983) Taylor-series linearization via :func:`compute_survey_if_variance` on the per-unit β̂-scale influence function (continuous + mass-point both route through the same helper). Inference is t-distribution with ``df_survey``. - - **``weights=`` shortcut**: analytical SE — CCT-2014 - weighted-robust for continuous paths (``bc_fit.se_robust / - |den|``) and weighted 2SLS pweight sandwich for mass-point - (``_fit_mass_point_2sls`` HC1 / classical / CR1). Inference - is Normal (``df=None``). + - **Unweighted**: per-horizon independent analytical sandwich + (continuous: CCT-2014 robust ``bc_fit.se_robust / |den|``; + mass-point: 2SLS sandwich via ``_fit_mass_point_2sls``). + Inference is Normal (``df=None``). The simultaneous confidence band (when ``cband=True``) is constructed by a multiplier bootstrap over the stacked per-horizon β̂-scale IF matrix via :func:`_sup_t_multiplier_bootstrap`. On the weighted/survey path it draws PSU-level multipliers; when ``cluster=`` is set it takes the clustered branch (cluster-level - multipliers, raw cluster sandwich; fires even unweighted). On the - ``weights=`` shortcut, sup-t calibration is routed through a - synthetic trivial ``ResolvedSurveyDesign`` so the centered + - sqrt(n/(n-1))-corrected survey-aware branch fires uniformly — - matches the analytical HC1 variance family at the - compute_survey_if_variance(IF, trivial) ≈ V_HC1 invariant. When - ``cluster=`` is set the clustered branch fires instead (cluster- - level multipliers, raw cluster sandwich), even unweighted. An + multipliers, raw cluster sandwich; fires even unweighted). An unweighted, unclustered event-study skips the bootstrap (pre-Phase 4.5 B numerical output preserved). """ @@ -4210,36 +4082,27 @@ def _fit_event_study( n_bootstrap_eff = int(self.n_bootstrap) seed_eff = None if self.seed is None else int(self.seed) - # ---- Survey/weights mutex + contract validation (front-door). - # Mirrors the static-path gates at fit(): exactly one knob - # (survey= xor weights=); survey= without a weights column is - # unsupported; non-pweight SurveyDesigns are rejected with an - # NotImplementedError pointing at Phase 4.5 C. ---- - if survey is not None and weights is not None: - raise ValueError( - "Pass exactly one of survey= or weights=, not both. " - "For SurveyDesign-composed inference (PSU, strata, FPC, " - "replicate weights), use survey=. For a simple pweight-" - "only shortcut, use weights=; it is internally equivalent " - "to survey=SurveyDesign(weights=w)." - ) + # ---- survey_design contract validation (front-door). + # Mirrors the static-path gates at fit(): survey_design= without a + # weights column is unsupported; non-pweight SurveyDesigns are + # rejected with a NotImplementedError pointing at Phase 4.5 C. ---- if survey is not None: if not hasattr(survey, "weights"): raise TypeError( - f"survey= must be a SurveyDesign-like object with a " + f"survey_design= must be a SurveyDesign-like object with a " f".weights attribute; got {type(survey).__name__}. " f"Construct a SurveyDesign via diff_diff.survey." ) if getattr(survey, "weights", None) is None: raise NotImplementedError( - "survey= without weights is not yet supported. Pass " - "survey=SurveyDesign(weights='', ...) with a " + "survey_design= without weights is not yet supported. Pass " + "survey_design=SurveyDesign(weights='', ...) with a " "per-row weight column." ) weight_type = getattr(survey, "weight_type", "pweight") if weight_type != "pweight": raise NotImplementedError( - f"survey=SurveyDesign(weight_type={weight_type!r}) is " + f"survey_design=SurveyDesign(weight_type={weight_type!r}) is " f"not yet supported on HeterogeneousAdoptionDiD. " f"HAD's weighted local-linear treats weights as " f"sampling (probability) weights only. Replicate " @@ -4261,35 +4124,7 @@ def _fit_event_study( weights_unit: Optional[np.ndarray] = None raw_weights_unit: Optional[np.ndarray] = None resolved_survey_unit: Any = None # ResolvedSurveyDesign (G,) or None - if weights is not None: - w_full = np.asarray(weights, dtype=np.float64).ravel() - if w_full.shape[0] != int(data.shape[0]): - raise ValueError( - f"weights length ({w_full.shape[0]}) does not match " - f"data rows ({int(data.shape[0])})." - ) - # Public ``weights`` contract is ROW-ORDER aligned with - # ``data``, NOT index-label aligned, so we must translate - # ``data_filtered``'s surviving index LABELS back to - # POSITIONAL offsets via ``data.index.get_indexer`` (handles - # custom int, string, or MultiIndex inputs uniformly; raises - # on duplicate labels that would make the mapping ambiguous). - # Review R1 P1: using ``data_filtered.index.to_numpy()`` as - # positions was a silent-failure vector on non-RangeIndex - # inputs. - positional_idx = data.index.get_indexer(data_filtered.index) - if np.any(positional_idx < 0): - raise ValueError( - "Cannot align weights to filtered panel: some " - "data_filtered rows could not be located in the " - "original data.index (possible duplicate / malformed " - "index labels). Pass a DataFrame with a unique index " - "or reset the index before calling fit()." - ) - w_filtered = w_full[positional_idx] - weights_unit = _aggregate_unit_weights(data_filtered, w_filtered, unit_col) - raw_weights_unit = weights_unit - elif survey is not None: + if survey is not None: from diff_diff.survey import ResolvedSurveyDesign # noqa: F401 resolved_survey_row = survey.resolve(data_filtered) @@ -4487,25 +4322,23 @@ def _fit_event_study( # the cluster-robust CCT SE (continuous, Phase 2a parity), AND into a # cluster-robust sup-t simultaneous band (both designs) via the # clustered branch of ``_sup_t_multiplier_bootstrap``. The one - # incompatible composition is cluster= + survey= (either design): the + # incompatible composition is cluster= + survey_design= (either design): the # survey path composes Binder-TSL variance and would silently override # the cluster-robust sandwich while metadata still reports the # cluster-robust vcov. Reject it BEFORE extracting the column (mirrors # the static-path guard had.py:3361) so the error is predictable even - # for a malformed cluster column. The former weights= + cluster= + - # cband=True mass-point rejection is lifted — the clustered band now - # reconciles the variance family (raw cluster Rademacher; mass-point - # sqrt(G/(G-1)) CR1 scaling). + # for a malformed cluster column. The clustered band reconciles the + # variance family (raw cluster Rademacher; mass-point sqrt(G/(G-1)) + # CR1 scaling). cluster_arr: Optional[np.ndarray] = None if cluster_arg is not None and resolved_survey_unit_full is not None: raise NotImplementedError( - f"cluster={cluster_arg!r} + survey= on " + f"cluster={cluster_arg!r} + survey_design= on " f"design={resolved_design!r} event-study is not supported: " f"the survey path composes Binder-TSL variance per horizon " f"and would silently override the cluster-robust sandwich. " - f"Pass cluster= alone (unweighted cluster-robust), weights= " - f"+ cluster= (weighted cluster-robust, pointwise + sup-t " - f"band), or survey_design=SurveyDesign(psu=) to " + f"Pass cluster= alone (unweighted cluster-robust), or " + f"survey_design=SurveyDesign(weights='', psu='') to " f"cluster through the survey (Binder-TSL) path." ) if cluster_arg is not None: @@ -4563,36 +4396,33 @@ def _fit_event_study( else: vcov_requested = vcov_type_arg.lower() # Review R3/R5 P1 (event-study arm): reject effective - # classical when the weighted path will compute the IF - # (always on survey= path; on weights= shortcut when - # cband=True the bootstrap divides HC1-scale perturbations - # by per-horizon analytical SE, so classical SE would - # give wrong t-stats). Matches the static-path rejection — - # weighted mass-point paths use the HC1-scale IF + # classical when the survey_design= path will compute the IF + # (the survey path composes Binder-TSL on the HC1-scale IF, so + # a classical analytical SE would give wrong t-stats). Matches + # the static-path rejection — weighted mass-point paths use the + # HC1-scale IF # convention uniformly. # When cluster= is set, the mass-point path computes the CR1 # cluster-robust sandwich regardless of vcov_type (classical/hc1 # are ignored), and the clustered sup-t band normalizes by that # CR1 SE — so there is no classical-vs-HC1 variance-family # mismatch to reject. Guard on ``cluster_arg is None``. - _uses_if_matrix = cluster_arg is None and ( - resolved_survey_unit_full is not None or (weights_unit_full is not None and cband) - ) + _uses_if_matrix = cluster_arg is None and resolved_survey_unit_full is not None if vcov_requested == "classical" and _uses_if_matrix: raise NotImplementedError( "vcov_type='classical' (resolved — either explicit " "or from the default robust=False mapping) + " - "weights/survey= on design='mass_point' event-study " + "survey_design= on design='mass_point' event-study " "is not yet supported: the per-horizon IF matrix is " "HC1-scale (targets V_HC1 via " "compute_survey_if_variance) and mixing it with a " - "classical analytical SE — either through the " - "survey Binder-TSL override or the sup-t bootstrap " - "normalization — would produce an inconsistent " - "variance family. Use vcov_type='hc1' (or leave " - "vcov_type unset with robust=True) on the weighted " - "event-study path, or pass cband=False to skip the " - "bootstrap on the weights= shortcut." + "classical analytical SE via the survey Binder-TSL " + "override would produce an inconsistent variance " + "family. cband=False does not avoid this — the survey " + "path consumes the HC1-scaled IF for the analytical SE " + "regardless. Use vcov_type='hc1' (or leave vcov_type " + "unset with robust=True) on the survey_design= " + "event-study path." ) inference_method = "analytical_2sls" vcov_label: Optional[str] = "cr1" if cluster_arg is not None else vcov_requested @@ -4635,17 +4465,16 @@ def _fit_event_study( n_obs_arr = np.full(n_horizons, n_units, dtype=np.int64) # Two IF-consumption flags (review R6 P2): the PER-HORIZON IF is - # needed when the survey= path composes Binder-TSL variance (via - # compute_survey_if_variance inside _fit_continuous or the + # needed when the survey_design= path composes Binder-TSL variance + # (via compute_survey_if_variance inside _fit_continuous or the # mass-point override below); the STACKED (G, H) IF matrix is # needed only when the sup-t multiplier bootstrap runs - # (``cband=True`` on a weighted/survey OR clustered fit). Splitting - # them avoids - # allocating / filling Psi on the common opt-out path - # ``cband=False`` + weights= shortcut, where no IF consumer - # exists. + # (``cband=True`` on a survey_design= OR clustered fit). Splitting + # them avoids allocating / filling Psi on the common opt-out path + # (``cband=False`` on an unweighted, unclustered fit), where no IF + # consumer exists. # The clustered sup-t band consumes the stacked IF too, and (unlike - # the survey/weights band) fires even on the UNWEIGHTED path — so the + # the survey band) fires even on the UNWEIGHTED path — so the # stacked-IF flag also switches on when cluster= is set. needs_per_horizon_if = resolved_survey_unit_full is not None or (weighted_es and cband) needs_stacked_if_matrix = cband and (weighted_es or cluster_arg is not None) @@ -4664,7 +4493,7 @@ def _fit_event_study( [] if resolved_design in ("continuous_at_zero", "continuous_near_d_lower") else None ) - # df_survey for t-inference on survey= path (mirrors static path). + # df_survey for t-inference on survey path (mirrors static path). df_infer: Optional[int] = None if resolved_survey_unit_full is not None: df_infer = resolved_survey_unit_full.df_survey @@ -4672,12 +4501,13 @@ def _fit_event_study( # Whenever the sup-t multiplier bootstrap runs (weighted/survey OR # clustered — see ``needs_stacked_if_matrix``), it operates on the # per-horizon IF matrix, so we must force the IF computation even - # when _fit_continuous would normally skip it (``weights=`` shortcut - # or unweighted clustered fit; no survey structure). Pass through + # when _fit_continuous would normally skip it (an unweighted + # clustered fit; no survey structure). Pass through # the actual ``resolved_survey_unit_full`` (None on those paths) so # the per-horizon analytical SE still matches the static-path - # convention (bc_fit.se_robust on shortcut/clustered; Binder-TSL on - # survey=). IF return is gated on `force_return_influence=True`. + # convention (bc_fit.se_robust on unweighted/clustered; Binder-TSL on + # the survey_design= path). IF return is gated on + # `force_return_influence=True`. # Track the Binder-TSL den for continuous paths so we can # reconstruct the per-unit IF (psi / den) for the sup-t bootstrap @@ -4693,11 +4523,11 @@ def _fit_event_study( weights_arr=weights_unit_full, resolved_survey_unit=resolved_survey_unit_full, # Force IF return only when the sup-t bootstrap - # needs the stacked matrix AND the survey= gate - # won't already produce it. Under survey= path, - # _fit_continuous returns the IF automatically - # (resolved_survey_unit_full != None); under the - # weights= shortcut + cband=True, force it here; + # needs the stacked matrix AND the survey_design= gate + # won't already produce it. Under the survey_design= + # path, _fit_continuous returns the IF automatically + # (resolved_survey_unit_full != None); on an unweighted + # CLUSTERED fit with cband=True, force it here; # otherwise skip the O(G) IF work (review R6 P2). force_return_influence=( needs_stacked_if_matrix and resolved_survey_unit_full is None @@ -4736,11 +4566,11 @@ def _fit_event_study( cluster_arr, vcov_requested, weights=weights_unit_full, - # Return IF when a consumer exists: survey= path needs it - # for per-horizon Binder-TSL override; the sup-t bootstrap - # (weighted OR clustered) needs it for the stacked matrix. - # weights= shortcut + cband=False + unclustered skips IF - # computation entirely (review R6 P2). + # Return IF when a consumer exists: the survey_design= + # path needs it for the per-horizon Binder-TSL override; + # the sup-t bootstrap (survey OR clustered) needs it for + # the stacked matrix. An unweighted, unclustered fit with + # cband=False skips IF computation entirely (review R6 P2). return_influence=(needs_per_horizon_if or needs_stacked_if_matrix), ) # Survey path: override analytical sandwich SE with @@ -4788,7 +4618,7 @@ def _fit_event_study( # bootstrap branch's own n_clusters, so scale and aggregation # share one count (a wholly-zero-weight cluster contributes 0 to # both yet is counted in G by both). Fires on the UNWEIGHTED path - # too (cluster= + survey= is rejected up-front, so no survey + # too (cluster= + survey_design= is rejected up-front, so no survey # composition). See REGISTRY "Note (HAD clustered event-study # sup-t band)". _cl_G = int(len(pd.unique(np.asarray(cluster_arr)))) @@ -4826,36 +4656,14 @@ def _fit_event_study( cband_method_label = "cluster_multiplier_bootstrap" cband_n_bootstrap_eff = n_bootstrap_eff elif weighted_es and cband and n_horizons >= 1: - # Review R7 P0: the per-unit influence function returned by - # _fit_continuous / _fit_mass_point_2sls is HC1-scaled per - # the PR #359 convention — compute_survey_if_variance(psi, - # trivial_resolved) ≈ V_HC1. Routing the weights= shortcut - # through the unit-level ``resolved_survey=None`` branch of - # _sup_t_multiplier_bootstrap would normalize against raw - # sum(psi²) = ((n-1)/n) · V_HC1, producing silently too- - # narrow simultaneous bands. Construct a synthetic trivial - # ResolvedSurveyDesign on the weights= shortcut so the - # bootstrap always fires the survey-aware branch (centered - # + sqrt(n/(n-1))-corrected), matching the variance family - # of the analytical per-horizon SE. - if resolved_survey_unit_full is not None: - resolved_for_bootstrap: Any = resolved_survey_unit_full - else: - from diff_diff.survey import ResolvedSurveyDesign - - assert weights_unit_full is not None # weighted_es invariant - resolved_for_bootstrap = ResolvedSurveyDesign( - weights=weights_unit_full, - weight_type="pweight", - strata=None, - psu=None, - fpc=None, - n_strata=1, - n_psu=int(weights_unit_full.shape[0]), - lonely_psu="remove", - combined_weights=True, - mse=False, - ) + # The per-unit influence function returned by _fit_continuous + # / _fit_mass_point_2sls is HC1-scaled per the PR #359 + # convention — compute_survey_if_variance(psi, resolved) ≈ + # V_HC1. The sup-t multiplier bootstrap fires the survey-aware + # branch on the resolved survey design (centered + + # sqrt(n/(n-1))-corrected), matching the variance family of the + # analytical per-horizon Binder-TSL SE. + resolved_for_bootstrap: Any = resolved_survey_unit_full q, cband_low_arr, cband_high_arr, _n_valid = _sup_t_multiplier_bootstrap( influence_matrix=Psi, att_per_horizon=att_arr, @@ -4885,28 +4693,6 @@ def _fit_event_study( if resolved_design == "mass_point" else "survey_binder_tsl" ) - else: - from diff_diff.survey import ResolvedSurveyDesign - - minimal_resolved = ResolvedSurveyDesign( - weights=weights_unit_full, - weight_type="pweight", - strata=None, - psu=None, - fpc=None, - n_strata=1, - n_psu=int(weights_unit_full.shape[0]), - lonely_psu="remove", - combined_weights=True, - mse=False, - ) - survey_metadata = compute_survey_metadata(minimal_resolved, weights_unit_full) - survey_metadata.n_strata = None - survey_metadata.n_psu = None - survey_metadata.df_survey = None - variance_formula_label = ( - "pweight_2sls" if resolved_design == "mass_point" else "pweight" - ) if resolved_design == "continuous_at_zero": effective_dose_mean_value = float(np.average(d_arr_full, weights=weights_unit_full)) elif resolved_design == "continuous_near_d_lower": diff --git a/diff_diff/survey.py b/diff_diff/survey.py index 900cf38d..78b80ffa 100644 --- a/diff_diff/survey.py +++ b/diff_diff/survey.py @@ -805,9 +805,11 @@ def make_pweight_design(weights: np.ndarray) -> "ResolvedSurveyDesign": # Three-way mutex error messages for `survey_design=` / `survey=` / `weights=` -# kwargs across the 8 HAD surfaces (HeterogeneousAdoptionDiD.fit + -# did_had_pretest_workflow + 5 pretest helpers + qug_test). The migration -# target text differs between data-in surfaces (which can resolve +# kwargs across the 7 HAD pretest helper surfaces (did_had_pretest_workflow + +# 5 pretest helpers + qug_test). `HeterogeneousAdoptionDiD.fit` no longer uses +# these — its `survey=`/`weights=` kwargs were removed in 3.7.0 (passing them +# raises `TypeError`); `survey_design=` is its sole weighting entry. The +# migration target text differs between data-in surfaces (which can resolve # ``SurveyDesign(weights="col_name")`` against ``data``) and array-in # surfaces (which take pre-resolved ``ResolvedSurveyDesign`` and use # ``make_pweight_design(arr)`` for the pweight-only convenience). Defined @@ -835,28 +837,10 @@ def make_pweight_design(weights: np.ndarray) -> "ResolvedSurveyDesign": "`data` and pass `survey_design=SurveyDesign(weights='col_name')` " "instead. Will be removed in the next minor release." ) -# PR #376 R11 P3: HAD.fit-specific weights= deprecation message — the -# generic data-in suggestion above (use `survey_design=SurveyDesign(...)`) -# is the long-term API target, but on `HeterogeneousAdoptionDiD.fit` the -# two paths currently produce different SE families: the deprecated -# `weights=np.ndarray` shortcut yields `variance_formula="pweight"` / -# `"pweight_2sls"` (CCT-2014 weighted-robust / 2SLS pweight-sandwich) -# while `survey_design=SurveyDesign(...)` yields `"survey_binder_tsl"` / -# `"survey_binder_tsl_2sls"`. The next-minor cleanup (TODO row 102) will -# unify the two; until then, document the SE-family caveat explicitly so -# users know what changes when they migrate. -HAD_DEPRECATION_MSG_WEIGHTS_KWARG_HAD_FIT = ( - "`weights=np.ndarray` is deprecated on HeterogeneousAdoptionDiD.fit; " - "the long-term API is to add the weights as a column on `data` and " - "pass `survey_design=SurveyDesign(weights='col_name')`. Will be " - "removed in the next minor release. NOTE: in the current release the " - "two paths produce different SE families on this surface — the " - "`weights=` shortcut keeps the analytical CCT-2014 / 2SLS pweight-" - "sandwich (`variance_formula='pweight'` or `'pweight_2sls'`), while " - "`survey_design=SurveyDesign(...)` composes Binder-TSL " - "(`'survey_binder_tsl'` or `'survey_binder_tsl_2sls'`). The " - "long-term unification is tracked for the next minor release." -) +# NOTE: the HAD.fit-specific `weights=` deprecation message was removed in +# 3.7.0 when `HeterogeneousAdoptionDiD.fit(weights=)` was dropped (weighting +# consolidated onto `survey_design=` / Binder-TSL). The array-in and data-in +# pretest-helper deprecation messages above remain until their own removal. HAD_DEPRECATION_MSG_WEIGHTS_KWARG_ARRAY_IN = ( "`weights=np.ndarray` is deprecated on array-in pretest helpers; use " "`survey_design=make_pweight_design(weights)` instead " diff --git a/docs/api/had.rst b/docs/api/had.rst index 4070aa0c..d1939935 100644 --- a/docs/api/had.rst +++ b/docs/api/had.rst @@ -41,56 +41,50 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: .. note:: **Inference contract.** Per-horizon CIs are always pointwise. There are - three SE regimes selected by call site: + two SE regimes selected by call site: - - **Unweighted** - continuous paths use the CCT-2014 weighted-robust SE + - **Unweighted** - continuous paths use the CCT-2014 robust SE from the in-house ``lprobust`` port; the mass-point path uses a 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``; CR1 when - ``cluster=`` is supplied - including ``aggregate="event_study"`` + - ``cband=True``, which adds a cluster-robust simultaneous band; + - **``survey_design=SurveyDesign(weights="col", ...)``** (the sole + weighting entry; 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"``. A bare ``cluster=`` (unweighted) gives + the CR1 2SLS sandwich on the mass-point path; for weighted clustering + use ``survey_design=SurveyDesign(weights='', + psu='')`` (which composes Binder-TSL through the PSU). ``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``. + 2SLS-specific leverage derivation. + + On ``HeterogeneousAdoptionDiD.fit`` the deprecated ``weights=`` and + ``survey=`` aliases were removed in 3.7.0; ``survey_design=`` is the sole + weighting entry (Binder-TSL). On the HAD pretest helpers the aliases + remain deprecated pending a follow-up removal: array-in helpers + (``stute_test``, ``yatchew_hr_test``, ``stute_joint_pretest``) take the + pweight-only shortcut ``survey_design=make_pweight_design(weights)``; + data-in surfaces use ``survey_design=SurveyDesign(weights="col_name", + ...)`` against ``data``. ``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 + ``survey_design=``. The composite workflow ``did_had_pretest_workflow`` + handles this by skipping QUG under survey dispatch and emitting a + ``UserWarning``. A simultaneous confidence band (sup-t) is available on the event-study - path via ``cband=True`` whenever ``weights=``/``survey_design=`` **or** - ``cluster=`` is supplied. With ``cluster=`` the band is cluster-robust - (pointwise CIs are cluster-robust too), on both designs and unweighted - or weighted; ``cluster=`` + ``survey_design=`` is rejected (route - clustering through ``survey_design=SurveyDesign(psu=)``). + path via ``cband=True`` whenever ``survey_design=`` **or** + ``cluster=`` is supplied. With a bare ``cluster=`` the band is + cluster-robust (pointwise CIs are cluster-robust too) on an unweighted + fit, on both designs; ``cluster=`` + ``survey_design=`` is rejected - + for weighted clustering pass + ``survey_design=SurveyDesign(weights='', psu='')`` + (which takes the survey Binder-TSL branch, not the bare-cluster branch). 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_design=SurveyDesign(...)`` paths (static and event-study) and - the deprecated ``weights=`` + ``aggregate="event_study"`` + - ``cband=True`` path reject ``vcov_type="classical"`` with + ``survey_design=SurveyDesign(...)`` paths (static and event-study) + reject ``vcov_type="classical"`` with ``NotImplementedError`` **when ``cluster=`` is not set** (a ``cluster=`` fit computes the CR1 sandwich regardless of ``vcov_type``, so no classical/HC1 mismatch arises). The per-unit 2SLS influence function returned @@ -108,15 +102,13 @@ Unit Remains Untreated" (arXiv:2405.04465v6), which: event-study) is rejected outright regardless of ``vcov_type``: the survey path composes Binder-TSL variance, which would silently override the cluster-robust sandwich. Workarounds: ``cluster=`` alone - (unweighted cluster-robust), ``weights=`` + ``cluster=`` (weighted - cluster-robust, pointwise + simultaneous band), or route clustering - through ``survey_design=SurveyDesign(psu=)``. All other + (unweighted cluster-robust), or route clustering through + ``survey_design=SurveyDesign(weights='', psu='')``. All other ``cluster=`` compositions are supported end-to-end, including the - ``weights=`` + ``cluster=`` + ``aggregate="event_study"`` + - ``cband=True`` mass-point path (formerly rejected): the clustered - sup-t band draws cluster-level multipliers on the per-unit influence - function and normalizes by the CR1 analytical SE (variance families - reconciled via the ``√(G/(G-1))`` CR1 scalar). + ``cluster=`` + ``aggregate="event_study"`` + ``cband=True`` mass-point + path: the clustered sup-t band draws cluster-level multipliers on the + per-unit influence function and normalizes by the CR1 analytical SE + (variance families reconciled via the ``√(G/(G-1))`` CR1 scalar). .. tip:: diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index b9abcd04..045913a8 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -694,7 +694,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 + ``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``. + - Two SE regimes per :doc:`api/had`. **Unweighted**: continuous-dose paths use the CCT-2014 robust SE from the in-house ``lprobust`` port; mass-point uses a 2SLS sandwich. **``survey_design=SurveyDesign(weights="col", ...)``** (the sole weighting entry as of the 3.7.0 ``survey=`` / ``weights=`` removal): both paths compose Binder (1983) Taylor-series linearization (``variance_formula="survey_binder_tsl"`` / ``"survey_binder_tsl_2sls"``); the mass-point survey path rejects ``vcov_type="classical"`` (requires ``hc1`` / ``robust=True``), and ``survey_design=`` + ``cluster=`` is rejected outright (route weighted clustering via ``SurveyDesign(weights=, psu=)``; a bare ``cluster=`` gives unweighted CR1). Per-horizon CIs are pointwise; sup-t bands available on the event-study path via ``cband=True`` whenever ``survey_design=`` or ``cluster=`` is supplied. * - ``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/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 84461972..0f877db7 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -3173,9 +3173,9 @@ so `δ_0` is recovered by OLS of `ΔY` on `X` and `D_2 * X`; Average Slope is `( The procedure ports the Calonico et al. `nprobust` machinery in-house (Phase 1a/1b/1c of the implementation plan): estimate optimal bandwidth `ĥ*_G`, compute `μ̂_{ĥ*_G}`, the first-order bias estimator `M̂_{ĥ*_G}`, and the variance estimator `V̂_{ĥ*_G}`. -*Weighted extension (Phase 4.5 survey support):* `HeterogeneousAdoptionDiD.fit()` accepts `weights=` (pweight shortcut) and `survey=SurveyDesign(...)` (design-based inference) on the two continuous-dose paths (`continuous_at_zero`, `continuous_near_d_lower`). Weights thread through `_nprobust_port.lprobust` via pointwise multiplication into the kernel weights: `W_combined = k((D − d̲)/h) · w_g`. Design matrices, Q.q bias-correction matrix, and variance matrices inherit the combined weights automatically. The β-scale rescaling uses weighted population moments: β̂_weighted = (Σ w_g ΔY_g / Σ w_g − μ̂_weighted(h)) / (Σ w_g (D_g − d̲) / Σ w_g). +*Weighted extension (Phase 4.5 survey support):* `HeterogeneousAdoptionDiD.fit()` accepts `survey_design=SurveyDesign(...)` (design-based inference) on the two continuous-dose paths (`continuous_at_zero`, `continuous_near_d_lower`). Weights thread through `_nprobust_port.lprobust` via pointwise multiplication into the kernel weights: `W_combined = k((D − d̲)/h) · w_g`. Design matrices, Q.q bias-correction matrix, and variance matrices inherit the combined weights automatically. The β-scale rescaling uses weighted population moments: β̂_weighted = (Σ w_g ΔY_g / Σ w_g − μ̂_weighted(h)) / (Σ w_g (D_g − d̲) / Σ w_g). -Under `survey=SurveyDesign(weights, strata, psu, fpc)`, the variance composes via Binder (1983) Taylor-series linearization — the per-unit influence function of μ̂ is aggregated by PSU within strata with FPC correction, using the shared `compute_survey_if_variance` helper (`diff_diff/survey.py:1802`) consumed by dCDH / EfficientDiD / CallawaySantAnna. Survey design columns (strata / PSU / FPC) are required to be constant within unit (sampling-unit assignment convention); within-unit variance raises `ValueError` front-door per `feedback_no_silent_failures`. +Under `survey_design=SurveyDesign(weights, strata, psu, fpc)`, the variance composes via Binder (1983) Taylor-series linearization — the per-unit influence function of μ̂ is aggregated by PSU within strata with FPC correction, using the shared `compute_survey_if_variance` helper (`diff_diff/survey.py:1802`) consumed by dCDH / EfficientDiD / CallawaySantAnna. Survey design columns (strata / PSU / FPC) are required to be constant within unit (sampling-unit assignment convention); within-unit variance raises `ValueError` front-door per `feedback_no_silent_failures`. - **Note (parity gap):** no public weighted-CCF bias-corrected local-linear reference exists in any language. `nprobust::lprobust` has no weight argument; Calonico-Cattaneo-Farrell's companion `rdrobust` is RD-shaped (not HAD-shaped); `np::npreg`'s local-linear algorithm does not reduce to a straightforward weighted-OLS at the intercept. The Phase 1c `atol=1e-12` R bit-parity bar is therefore NOT reachable on the weighted bias-corrected CI. Methodology confidence under informative weights comes from the stack documented below. - **Note:** Uniform-weights bit-parity — `lprobust(..., weights=np.ones(N))` ≡ unweighted at `atol=1e-14, rtol=1e-14` across the full output struct (`tau_cl`, `tau_bc`, `se_cl`, `se_rb`, `V_Y_cl`, `V_Y_bc`). Regression tests in `tests/test_nprobust_port.py::TestWeightedLprobust` and `tests/test_bias_corrected_lprobust.py::TestWeightedBiasCorrectedLocalLinear`. @@ -3183,8 +3183,8 @@ Under `survey=SurveyDesign(weights, strata, psu, fpc)`, the variance composes vi - **Note:** Monte Carlo oracle consistency — `tests/test_had_mc.py` validates that the weighted estimator recovers the oracle τ under informative sampling, with coverage near nominal and visible bias reduction vs unweighted. Slow-gated; 4 tests. - **Note:** Auto-bandwidth selection (Phase 1b MSE-DPI via `lpbwselect_mse_dpi`) remains UNWEIGHTED in this phase; users who want a weight-aware bandwidth should pass `h`/`b` explicitly. The auto path with uniform weights reduces to the existing unweighted bandwidth selector, so the uniform-weights bit-parity chain is preserved. - **Note:** Replicate-weight SurveyDesigns (BRR / Fay / JK1 / JKn / SDR) on the HAD continuous path raise `NotImplementedError` in this PR; Rao-Wu-style rescaled bootstrap is deferred to Phase 4.5 C (survey-under-pretests). -- **Note:** `HeterogeneousAdoptionDiD.fit()` dispatch matrix after Phase 4.5 B + 4.5 C — survey/weights are supported on ALL design × aggregate combinations (continuous × {overall, event-study}, mass-point × {overall, event-study}). The HAD pretests (`qug_test`, `stute_test`, `yatchew_hr_test`, joint Stute variants, `did_had_pretest_workflow`) ship survey support in Phase 4.5 C (PR #370) and the Phase 4.5 C strata extension (this PR) — `qug_test` permanently rejects (Phase 4.5 C0 deferral; see "QUG Null Test" §); the linearity family supports pweight + PSU + FPC + strata via PSU-level Mammen multipliers with within-stratum demean + Bessel rescale (Stute, see "Note (Stute stratified survey-bootstrap calibration)" below) + closed-form weighted variance components (Yatchew). Replicate-weight designs raise `NotImplementedError` (parallel follow-up); `lonely_psu='adjust'` + singleton-strata raises `NotImplementedError` on the Stute family (same pseudo-stratum centering gap as the HAD sup-t deviation, parallel follow-up). The canonical kwarg on all 8 HAD surfaces is `survey_design=` (see "Note (HAD survey-design API consolidation)" below); `survey=` / `weights=` remain accepted as deprecated aliases for one minor cycle. -- **Note (HAD survey-design API consolidation):** All 8 HAD surfaces — `HeterogeneousAdoptionDiD.fit`, `did_had_pretest_workflow`, `qug_test`, `stute_test`, `yatchew_hr_test`, `stute_joint_pretest`, `joint_pretrends_test`, `joint_homogeneity_test` — accept the canonical kwarg `survey_design=` (matching `ContinuousDiD`, `EfficientDiD`, `ChaisemartinDHaultfoeuille`). The pre-existing dual `survey=` and `weights=` kwargs become deprecated aliases (`DeprecationWarning`); both will be removed in the next minor release. Internal back-end behavior is UNCHANGED (the legacy paths for `weights=np.ndarray` and `survey=SurveyDesign(...)` still execute the same code; only the entry signature wraps them). Mutex semantics extend from 2-way (`survey + weights`) to 3-way (`survey_design + survey + weights`) — at most one may be non-None per call. Distinct mutex error messages by surface group: data-in surfaces (HAD.fit + workflow + joint data-in wrappers) point users to `survey_design=SurveyDesign(weights='col_name', ...)`; the three array-in linearity helpers (`stute_test` / `yatchew_hr_test` / `stute_joint_pretest`) point to `survey_design=make_pweight_design(arr)` (for pweight-only) or `survey_design=` (for full PSU/strata/FPC). The 8th surface — `qug_test` — has its own qug-specific mutex message that does NOT advertise `make_pweight_design(arr)` as a migration target; QUG-under-survey is permanently rejected (Phase 4.5 C0 deferral, see "QUG Null Test" §) regardless of which kwarg variant the caller uses, so the migration path doesn't apply. Array-in helpers reject `survey_design=SurveyDesign(...)` with `TypeError` since they have no `data` to resolve column names against. The `make_pweight_design(weights: np.ndarray) -> ResolvedSurveyDesign` factory is exported from the `diff_diff` top level (formerly `survey._make_trivial_resolved`, kept as a permanent private alias for back-compat); `weights` must be 1-D (scalar / 0-D / column-vector inputs raise `ValueError` at the front door). +- **Note:** `HeterogeneousAdoptionDiD.fit()` dispatch matrix after Phase 4.5 B + 4.5 C — survey/weights are supported on ALL design × aggregate combinations (continuous × {overall, event-study}, mass-point × {overall, event-study}). The HAD pretests (`qug_test`, `stute_test`, `yatchew_hr_test`, joint Stute variants, `did_had_pretest_workflow`) ship survey support in Phase 4.5 C (PR #370) and the Phase 4.5 C strata extension (this PR) — `qug_test` permanently rejects (Phase 4.5 C0 deferral; see "QUG Null Test" §); the linearity family supports pweight + PSU + FPC + strata via PSU-level Mammen multipliers with within-stratum demean + Bessel rescale (Stute, see "Note (Stute stratified survey-bootstrap calibration)" below) + closed-form weighted variance components (Yatchew). Replicate-weight designs raise `NotImplementedError` (parallel follow-up); `lonely_psu='adjust'` + singleton-strata raises `NotImplementedError` on the Stute family (same pseudo-stratum centering gap as the HAD sup-t deviation, parallel follow-up). The canonical kwarg on all 8 HAD surfaces is `survey_design=` (see "Note (HAD survey-design API consolidation)" below); on `HeterogeneousAdoptionDiD.fit` the `survey=` / `weights=` aliases were removed in 3.7.0, while on the 7 pretest helpers they remain accepted as deprecated aliases pending a follow-up removal. +- **Note (HAD survey-design API consolidation):** All 8 HAD surfaces — `HeterogeneousAdoptionDiD.fit`, `did_had_pretest_workflow`, `qug_test`, `stute_test`, `yatchew_hr_test`, `stute_joint_pretest`, `joint_pretrends_test`, `joint_homogeneity_test` — accept the canonical kwarg `survey_design=` (matching `ContinuousDiD`, `EfficientDiD`, `ChaisemartinDHaultfoeuille`). On `HeterogeneousAdoptionDiD.fit`, the deprecated `survey=` / `weights=` kwargs were **removed in 3.7.0** — `survey_design=` is the sole weighting entry (the pweight/CCT-2014 dispatch branches are gone, so `variance_formula` is now one of `None` / `"survey_binder_tsl"` / `"survey_binder_tsl_2sls"`), and `cband` is now keyword-only. On the 7 pretest helpers (`did_had_pretest_workflow`, `qug_test`, `stute_test`, `yatchew_hr_test`, `stute_joint_pretest`, `joint_pretrends_test`, `joint_homogeneity_test`) the `survey=` / `weights=` aliases remain deprecated (`DeprecationWarning`) pending a follow-up removal. Internal back-end behavior for the surviving `survey_design=` / unweighted paths is UNCHANGED (byte-identical output). On the 7 pretest helpers the mutex is 3-way (`survey_design + survey + weights`) — at most one may be non-None per call (`HeterogeneousAdoptionDiD.fit` no longer accepts `survey=`/`weights=`, so passing either raises `TypeError`). Distinct mutex error messages by surface group: the data-in helper surfaces (workflow + joint data-in wrappers) point users to `survey_design=SurveyDesign(weights='col_name', ...)`; the three array-in linearity helpers (`stute_test` / `yatchew_hr_test` / `stute_joint_pretest`) point to `survey_design=make_pweight_design(arr)` (for pweight-only) or `survey_design=` (for full PSU/strata/FPC). The 8th surface — `qug_test` — has its own qug-specific mutex message that does NOT advertise `make_pweight_design(arr)` as a migration target; QUG-under-survey is permanently rejected (Phase 4.5 C0 deferral, see "QUG Null Test" §) regardless of which kwarg variant the caller uses, so the migration path doesn't apply. Array-in helpers reject `survey_design=SurveyDesign(...)` with `TypeError` since they have no `data` to resolve column names against. The `make_pweight_design(weights: np.ndarray) -> ResolvedSurveyDesign` factory is exported from the `diff_diff` top level (formerly `survey._make_trivial_resolved`, kept as a permanent private alias for back-compat); `weights` must be 1-D (scalar / 0-D / column-vector inputs raise `ValueError` at the front door). *Weighted 2SLS (Phase 4.5 B):* `_fit_mass_point_2sls(..., weights=, return_influence=)` extends the Wald-IV / 2SLS sandwich with pweight semantics: - **Weighted bread**: `Z'WX = Z'·diag(w)·X` (`w¹`, matches `estimatr::iv_robust(..., weights=)` weighted-bread convention). @@ -3194,11 +3194,11 @@ Under `survey=SurveyDesign(weights, strata, psu, fpc)`, the variance composes vi - **Per-unit IF on β̂-scale** (for Binder-TSL survey composition): `psi_g = [(Z'WX)^{-1} · z_g · w_g · u_g][1] · sqrt((n-1)/(n-k))`. The scaling factor absorbs DOF / small-sample differences so `compute_survey_if_variance(psi, trivial_resolved) ≈ V_HC1[1,1]` at `atol=1e-10` (mirrors PR #359 convention; asserted by `TestIFScaleInvariant` and bit-exact against estimatr HC1 on 4 DGPs). Fixture: `benchmarks/R/generate_estimatr_iv_robust_golden.R` → `benchmarks/data/estimatr_iv_robust_golden.json`. - **Intercept SE (`return_intercept_se=True`)**: the 2×2 sandwich `V` already carries the intercept variance `V[0,0]`; the opt-in hook surfaces `sqrt(V[0,0])` (default off — the production 3-tuple return is byte-unchanged, so no public-API change). HC1 and CR1 intercept SEs are bit-exact with `estimatr` `se_intercept` at `atol=1e-10` (`tests/test_estimatr_iv_robust_parity.py`); the **classical** intercept carries the same `O(1/n)` projection/DOF deviation as the slope (bullet above) and is likewise excluded from the parity lock. -*Event-study survey composition (Phase 4.5 B):* The per-horizon loop in `_fit_event_study` threads `weights_unit_full` + `resolved_survey_unit_full` through to both `_fit_continuous` and `_fit_mass_point_2sls` (the latter with `return_influence=True` under weighted fits). The returned IF matrix `Psi ∈ R^{G × H}` has a shared construction contract across paths — each column on the β̂-scale, such that `compute_survey_if_variance(Psi[:, e], resolved) ≈ V_β[e]`. Per-horizon analytical variance uses Binder-TSL via `compute_survey_if_variance` (survey= path) or the weighted-robust HC1 sandwich (weights= shortcut). `survey_metadata`, `variance_formula` (`"survey_binder_tsl"` / `"survey_binder_tsl_2sls"` / `"pweight"` / `"pweight_2sls"`), and `effective_dose_mean` populate identically to the static path. Pre-PR numerical output is preserved bit-exactly on the unweighted path when `cband=False` (stability invariant; Phase 2b convention unchanged for unweighted fits). +*Event-study survey composition (Phase 4.5 B):* The per-horizon loop in `_fit_event_study` threads `weights_unit_full` + `resolved_survey_unit_full` through to both `_fit_continuous` and `_fit_mass_point_2sls` (the latter with `return_influence=True` under weighted fits). The returned IF matrix `Psi ∈ R^{G × H}` has a shared construction contract across paths — each column on the β̂-scale, such that `compute_survey_if_variance(Psi[:, e], resolved) ≈ V_β[e]`. Per-horizon analytical variance uses Binder-TSL via `compute_survey_if_variance` (the `survey_design=` path). `survey_metadata`, `variance_formula` (`"survey_binder_tsl"` / `"survey_binder_tsl_2sls"`), and `effective_dose_mean` populate identically to the static path. Pre-PR numerical output is preserved bit-exactly on the unweighted path when `cband=False` (stability invariant; Phase 2b convention unchanged for unweighted fits). *Sup-t multiplier bootstrap (Phase 4.5 B):* Simultaneous confidence band on the weighted/survey OR clustered event-study path via `_sup_t_multiplier_bootstrap` (the clustered branch is documented in "Note (HAD clustered event-study sup-t band)"): -1. **Multiplier draws**: reuse `diff_diff.bootstrap_utils.generate_survey_multiplier_weights_batch` (survey= path: PSU-level draws with stratum centering, FPC scaling, lonely-PSU handling) or `generate_bootstrap_weights_batch` (weights= shortcut: unit-level Rademacher). Default `n_bootstrap=999` (CS parity); `seed` exposed on `HeterogeneousAdoptionDiD.__init__` for reproducibility. +1. **Multiplier draws**: reuse `diff_diff.bootstrap_utils.generate_survey_multiplier_weights_batch` (survey_design= path: PSU-level draws with stratum centering, FPC scaling, lonely-PSU handling) or `generate_bootstrap_weights_batch` (clustered band: cluster-level Rademacher). Default `n_bootstrap=999` (CS parity); `seed` exposed on `HeterogeneousAdoptionDiD.__init__` for reproducibility. 2. **Perturbations**: `delta = xi @ Psi` — shape `(B, H)` matrix-matrix product, NO `(1/n)` prefactor (matches `staggered_bootstrap.py:373` idiom; `Psi` is already on the β̂-scale). 3. **t-statistics**: `t[b, e] = delta[b, e] / se[e]` where `se[e]` is the per-horizon analytical Binder-TSL / HC1 SE from the loop above. 4. **Sup-t distribution**: `sup_t[b] = max_e |t[b, e]|` with finite-mask filtering of degenerate horizons. @@ -3206,17 +3206,15 @@ Under `survey=SurveyDesign(weights, strata, psu, fpc)`, the variance composes vi **Reduction invariant**: at `H=1`, the sup collapses to the marginal and `q → Φ⁻¹(1 - alpha/2) ≈ 1.96` at `alpha=0.05` up to MC noise. Locked by `TestSupTReducesToNormalAtH1` (G=500, B=5000, seed=42, `atol=0.15` on the quantile), its clustered variant `test_clustered_sup_t_h1_reduces_to_normal` (both the continuous scale-1.0 and mass-point `√(G/(G-1))` scalars), and `TestEventStudySurveyCband::test_trivial_survey_h1_sup_t_matches_analytical` / `test_stratified_h1_sup_t_matches_analytical` for the trivial-survey and stratified cases respectively. -**Scope**: sup-t bootstrap runs when `aggregate="event_study"` AND `cband=True` (default) AND either (a) `weights=`/`survey=` is supplied (the survey/weights band) OR (b) `cluster=` is supplied (the clustered band — see "Note (HAD clustered event-study sup-t band)"). An unweighted, unclustered event-study skips the bootstrap entirely — pre-Phase 4.5 B numerical output bit-exactly preserved. Setting `cband=False` disables the bootstrap on any path. - -**`weights=` shortcut ↔ bootstrap routing**: on the `weights=` shortcut (no user-supplied `SurveyDesign`), per-horizon SE stays analytical (CCT-2014 robust for continuous, HC1/classical/CR1 sandwich for mass-point — NOT Binder-TSL), but sup-t calibration is routed through a synthetic trivial `ResolvedSurveyDesign` (pweight, no strata / PSU / FPC, `lonely_psu="remove"`) so the centered + sqrt(n/(n-1))-corrected bootstrap branch fires uniformly. This matches the analytical HC1 variance family — the `compute_survey_if_variance(IF, trivial) ≈ V_HC1` invariant from the IF scale convention — so the bootstrap variance target agrees with the per-horizon SE normalizer. Without this routing, the unit-level bootstrap branch would normalize against raw `sum(ψ²) = ((n-1)/n) · V_HC1` on the HC1-scaled IF and produce silently too-narrow simultaneous bands. Regression-locked by the `weights=`-shortcut / `survey=`-trivial equivalence test in `TestEventStudySurveyCband`. +**Scope**: sup-t bootstrap runs when `aggregate="event_study"` AND `cband=True` (default) AND either (a) `survey_design=` is supplied (the survey band) OR (b) `cluster=` is supplied (the clustered band — see "Note (HAD clustered event-study sup-t band)"). An unweighted, unclustered event-study skips the bootstrap entirely — pre-Phase 4.5 B numerical output bit-exactly preserved. Setting `cband=False` disables the bootstrap on any path. - **Deviation from shared survey-bootstrap contract:** `_sup_t_multiplier_bootstrap` raises `NotImplementedError` on `SurveyDesign(lonely_psu="adjust")` with singleton strata. The shared `generate_survey_multiplier_weights_batch` helper pools singleton PSUs into a pseudo-stratum with NONZERO multipliers, but `compute_survey_if_variance` centers singleton PSU scores at the GLOBAL mean of PSU scores (rather than the pseudo-stratum mean). Matching the two would require a pooled-singleton pseudo-stratum centering transform in the HAD sup-t path that has not been derived. The HAD-specific limitation is scoped to: weighted event-study + `cband=True` + `lonely_psu="adjust"` + at least one singleton stratum. Practitioners can use `lonely_psu="remove"` or `"certainty"` (matches the analytical target bit-exactly on the HAD sup-t path), or pass `cband=False` to skip the simultaneous band. All other survey-bootstrap consumers (CallawaySantAnna, dCDH, SDID) retain full `lonely_psu="adjust"` support through the shared helper. -- **Deviation: weighted mass-point `vcov_type="classical"` on survey/sup-t paths:** `vcov_type="classical"` raises `NotImplementedError` whenever the mass-point IF matrix is consumed downstream — specifically on `design="mass_point"` + `survey=` (static + event-study) and `design="mass_point"` + `weights=` + `aggregate="event_study"` + `cband=True` — **only when `cluster=` is NOT set** (with `cluster=` the mass-point path computes the CR1 sandwich regardless of `vcov_type`, so no classical-vs-HC1 mismatch exists and the classical-rejection is guarded by `cluster_arg is None`). The per-unit 2SLS IF returned by `_fit_mass_point_2sls` is scaled (`sqrt((n-1)/(n-k))`) to match V_HC1 via `compute_survey_if_variance`; mixing it with a classical analytical SE would silently return a V_HC1-targeted variance under a classical label. A classical-aligned IF derivation is queued for a follow-up PR. The allowed weighted-mass-point combinations are: `vcov_type="hc1"` on every path; `vcov_type="classical"` on `weights=` + `aggregate="overall"`, and `weights=` + `aggregate="event_study"` + `cband=False` (no IF consumption); and any `cluster=` composition (resolves to CR1). +- **Deviation: weighted mass-point `vcov_type="classical"` on survey/sup-t paths:** `vcov_type="classical"` raises `NotImplementedError` whenever the mass-point IF matrix is consumed downstream — specifically on `design="mass_point"` + `survey_design=` (static and event-study, regardless of `cband`, since the Binder-TSL analytical SE consumes the HC1-scaled IF either way) — **only when `cluster=` is NOT set** (with `cluster=` the mass-point path computes the CR1 sandwich regardless of `vcov_type`, so no classical-vs-HC1 mismatch exists and the classical-rejection is guarded by `cluster_arg is None`). The per-unit 2SLS IF returned by `_fit_mass_point_2sls` is scaled (`sqrt((n-1)/(n-k))`) to match V_HC1 via `compute_survey_if_variance`; mixing it with a classical analytical SE would silently return a V_HC1-targeted variance under a classical label. A classical-aligned IF derivation is queued for a follow-up PR. The allowed weighted-mass-point combinations are: `vcov_type="hc1"` on every `survey_design=` path; and any `cluster=` composition (resolves to CR1). -- **Deviation: `cluster=` + `survey=` rejected (both designs):** the `survey=` path composes Binder-TSL variance via `compute_survey_if_variance`, which would silently overwrite the cluster-robust sandwich while result metadata still reports `vcov_type="cr1"`. Rejected up-front on `cluster=` + `survey=` for BOTH designs (`continuous_*` and `mass_point`), static and event-study — route clustering through `survey_design=SurveyDesign(psu=)` instead. All other `cluster=` compositions now WORK end-to-end (pointwise CIs AND the simultaneous band): unweighted `cluster=`, and `weights=` + `cluster=` with `cband` either `False` (pointwise cluster-robust only) or `True` (adds the clustered sup-t band — see "Note (HAD clustered event-study sup-t band)" below). The `weights=` shortcut + `cluster=` pweight sandwich remains bit-exact with `estimatr::iv_robust(..., weights=, clusters=, se_type="stata")`. +- **Deviation: `cluster=` + `survey_design=` rejected (both designs):** the `survey_design=` path composes Binder-TSL variance via `compute_survey_if_variance`, which would silently overwrite the cluster-robust sandwich while result metadata still reports `vcov_type="cr1"`. Rejected up-front on `cluster=` + `survey_design=` for BOTH designs (`continuous_*` and `mass_point`), static and event-study — for weighted clustering route through `survey_design=SurveyDesign(weights='', psu='')` instead. All other `cluster=` compositions WORK end-to-end (pointwise CIs AND the simultaneous band): a bare unweighted `cluster=` with `cband` either `False` (pointwise cluster-robust only) or `True` (adds the clustered sup-t band — see "Note (HAD clustered event-study sup-t band)" below). -- **Note (HAD clustered event-study sup-t band):** when `cluster=` is set, `_fit_event_study` produces cluster-robust per-horizon pointwise CIs AND a cluster-robust simultaneous band on BOTH designs (continuous CCT and mass-point 2SLS), unweighted or weighted (`weights=`; `survey=` is rejected — see the deviation above). Pointwise: `cluster_arr` threads into `bias_corrected_local_linear` (continuous, static-path parity) or `_fit_mass_point_2sls` (mass-point CR1). Band: `_sup_t_multiplier_bootstrap` takes a dedicated **clustered branch** — it aggregates the per-unit β̂-scale influence function to cluster level (`s_c = Σ_{i∈c} ψ_i`) and draws cluster-level iid Rademacher multipliers, so the perturbation variance is the RAW cluster sandwich `Σ_c s_c²` (no stratum-centering / FPC / Bessel — distinct from the survey branch). This matches each path's analytical cluster-robust SE via a path scalar: **1.0** for continuous (the `lprobust_vce` cluster meat carries no `g/(g-1)` correction, so `Σ_c s_c² == se_rb²` exactly) and **`√(G/(G-1))`** for mass-point (the returned IF carries `√((n-1)/(n-k))` but not the CR1 `G/(G-1)` factor; `G` is the full-array cluster count, identical to the bootstrap branch's own `n_clusters`, so a wholly-zero-weight cluster contributes `s_c=0` to both the analytical Ω and the bootstrap yet is counted in `G` by both). The variance-family reconciliation is validated bootstrap-free: `sqrt(Σ_c (scale·s_c)²) == se` to `atol=1e-10` on the real IF for both paths (`TestEventStudyClusterBand::test_{continuous,masspoint}_if_reconciliation_deterministic`), plus the `H=1 → 1.96` reduction on the clustered branch (`TestSupTReducesToNormalAtH1::test_clustered_sup_t_h1_reduces_to_normal`). Single cluster (`G<2`) → NaN band + `RuntimeWarning` (CR undefined). `cband_method="cluster_multiplier_bootstrap"` on this path. No R anchor (no reference package computes an HAD clustered sup-t band); the reconciliation identity is the validation. +- **Note (HAD clustered event-study sup-t band):** when a bare `cluster=` is set, `_fit_event_study` produces cluster-robust per-horizon pointwise CIs AND a cluster-robust simultaneous band on BOTH designs (continuous CCT and mass-point 2SLS) on an unweighted fit (a `cluster=` + `survey_design=` composition is rejected — see the deviation above; weighted clustering routes through `survey_design=SurveyDesign(weights=..., psu=...)`, which takes the survey branch, not this clustered branch). Pointwise: `cluster_arr` threads into `bias_corrected_local_linear` (continuous, static-path parity) or `_fit_mass_point_2sls` (mass-point CR1). Band: `_sup_t_multiplier_bootstrap` takes a dedicated **clustered branch** — it aggregates the per-unit β̂-scale influence function to cluster level (`s_c = Σ_{i∈c} ψ_i`) and draws cluster-level iid Rademacher multipliers, so the perturbation variance is the RAW cluster sandwich `Σ_c s_c²` (no stratum-centering / FPC / Bessel — distinct from the survey branch). This matches each path's analytical cluster-robust SE via a path scalar: **1.0** for continuous (the `lprobust_vce` cluster meat carries no `g/(g-1)` correction, so `Σ_c s_c² == se_rb²` exactly) and **`√(G/(G-1))`** for mass-point (the returned IF carries `√((n-1)/(n-k))` but not the CR1 `G/(G-1)` factor; `G` is the full-array cluster count, identical to the bootstrap branch's own `n_clusters`, so a wholly-zero-weight cluster contributes `s_c=0` to both the analytical Ω and the bootstrap yet is counted in `G` by both). The variance-family reconciliation is validated bootstrap-free: `sqrt(Σ_c (scale·s_c)²) == se` to `atol=1e-10` on the real IF for both paths (`TestEventStudyClusterBand::test_{continuous,masspoint}_if_reconciliation_deterministic`), plus the `H=1 → 1.96` reduction on the clustered branch (`TestSupTReducesToNormalAtH1::test_clustered_sup_t_h1_reduces_to_normal`). Single cluster (`G<2`) → NaN band + `RuntimeWarning` (CR undefined). `cband_method="cluster_multiplier_bootstrap"` on this path. No R anchor (no reference package computes an HAD clustered sup-t band); the reconciliation identity is the validation. - 2SLS (Design 1 mass-point case): standard 2SLS inference (details not elaborated in the paper). - TWFE with small `G`: HC2 standard errors with Bell-McCaffrey (2002) degrees-of-freedom correction, following Imbens and Kolesar (2016). Used in the Pierce and Schott (2016) application with `G=103`. Added library-wide to `diff_diff/linalg.py` as a new `vcov_type` dispatch (Phase 1a), exposed on `DifferenceInDifferences` and `TwoWayFixedEffects`. - Bootstrap: wild bootstrap with Mammen (1993) two-point weights is used for the Stute test (see Diagnostics below), NOT for the main WAS estimator. Reuses the existing `diff_diff.bootstrap_utils.generate_bootstrap_weights(..., weight_type="mammen")` helper. @@ -3369,8 +3367,8 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in *Notes #1-#2 lock implementation choices (paper-permitted choices the library codified); Notes #3-#4 document validation-harness work waived in this PR with documented rationale; #5 is a Library extension where the library departs from the paper's prescription toward stricter safety.* -- **Note:** Equal-weighting on the continuous path. Paper does not prescribe a unit-weighting scheme on the continuous local-linear paths. Library uses per-unit equal weighting (`w_g = 1` default, matching `diff_diff/_nprobust_port.lprobust`'s default), NOT dose-cell-size weights. Practical consequence: WAS is the population-mean slope from Eq. 3 — `[E(ΔY) − lim_{d↓d̲} E(ΔY | D ≤ d)] / E(D)` (computed as `att = (mean(ΔY) − τ_bc) / mean(D)`), not a cell-size-weighted average; with cell-size weighting, units in less-densely-populated regions of the dose distribution would contribute disproportionately to the boundary slope. User-supplied `weights=` (pweight) overrides the equal-weight default and threads through as `W_combined = k((D − d̲)/h) · w_g`. Lock in `tests/test_methodology_had.py::TestHADDeviations::test_equal_weighting_is_per_row_not_per_dose_cell`. -- **Note:** Sup-t bootstrap gating. Simultaneous-band sup-t multiplier bootstrap runs when `aggregate="event_study"` AND `cband=True` (default) AND either `(weights= or survey_design= supplied)` (weighted/survey band) OR `cluster=` (cluster-robust band — fires even on an unweighted fit, Phase 2b). The unweighted, unclustered event-study path bit-exactly preserves pre-Phase 4.5 B numerical output (stability invariant). Setting `cband=False` disables the bootstrap on any path. See the algorithmic contract above at `_sup_t_multiplier_bootstrap`. +- **Note:** Equal-weighting on the continuous path. Paper does not prescribe a unit-weighting scheme on the continuous local-linear paths. Library uses per-unit equal weighting (`w_g = 1` default, matching `diff_diff/_nprobust_port.lprobust`'s default), NOT dose-cell-size weights. Practical consequence: WAS is the population-mean slope from Eq. 3 — `[E(ΔY) − lim_{d↓d̲} E(ΔY | D ≤ d)] / E(D)` (computed as `att = (mean(ΔY) − τ_bc) / mean(D)`), not a cell-size-weighted average; with cell-size weighting, units in less-densely-populated regions of the dose distribution would contribute disproportionately to the boundary slope. User-supplied weights (via `survey_design=SurveyDesign(weights=...)`, pweight) override the equal-weight default and thread through as `W_combined = k((D − d̲)/h) · w_g`. Lock in `tests/test_methodology_had.py::TestHADDeviations::test_equal_weighting_is_per_row_not_per_dose_cell`. +- **Note:** Sup-t bootstrap gating. Simultaneous-band sup-t multiplier bootstrap runs when `aggregate="event_study"` AND `cband=True` (default) AND either `survey_design=` is supplied (survey band) OR `cluster=` (cluster-robust band — fires even on an unweighted fit, Phase 2b). The unweighted, unclustered event-study path bit-exactly preserves pre-Phase 4.5 B numerical output (stability invariant). Setting `cband=False` disables the bootstrap on any path. See the algorithmic contract above at `_sup_t_multiplier_bootstrap`. - **Note:** Pierce-Schott (2016) Figure 2 replication harness deferred. The paper's empirical application self-acknowledges (Section 5.2; mirrored in `dechaisemartin-2026-review.md:321`) that "NP estimators are too noisy to be informative" on the LBD-restricted PNTR panel. R parity at `atol=1e-8` on 3 DGPs × 5 method combos via `tests/test_did_had_parity.py` (bit-exact, `rtol=0`) is a stronger correctness anchor than reproducing pointwise CIs on LBD-restricted data. **Scope caveat:** R parity locks point estimate, SE, and CI bounds bit-exactly to R's bounds — it does NOT independently verify the asymptotic-coverage properties of the bias-corrected CI in small samples. Paper Table 1 documents under-coverage at small G (89% at G=100 on DGP 1, 93% at G=500, 95% at G=2500); this is inherited from the CCF asymptotic theory itself, and Python is exact-parity with R at the limit-law machinery. - **Note:** Table 1 coverage-rate reproduction deferred. Paper Section 3.1.5 reports 2,000-iter Monte Carlo coverage rates at `G ∈ {100, 500, 2500}` on DGPs 1/2/3. The existing `tests/test_did_had_parity.py` R parity at `atol=1e-8` on the same 3 DGPs reproduces the exact point estimate and SE algorithm to bit-exact tolerance; coverage-rate MC would re-verify the CCF asymptotic coverage already pinned by R parity (Python ≡ R ≡ paper) at the sample-mean level. **Scope caveat (mirrors above):** R parity does NOT re-prove asymptotic-coverage at small G; paper Table 1's 89% / 93% / 95% under-coverage band is valid for both R and Python. - **Library extension:** Staggered-timing fail-closed. Paper Appendix B.2 prescribes "Warn" when staggered treatment timing is detected; library raises `ValueError` at `diff_diff/had.py:1511` when multiple first-treat cohorts are detected without `first_treat_col`. Library extension toward stricter safety: `UserWarning` would let the silent-misuse bug class through (HAD's Appendix B.2 only identifies the LAST cohort under staggered timing); fail-closed forces the user to either supply `first_treat_col` (which activates auto-filter to last-cohort + never-treated per Appendix B.2) or redirect to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Lock in `tests/test_methodology_had.py::TestHADDeviations`. @@ -3397,13 +3395,13 @@ Shipped in `diff_diff/had_pretests.py` as `stute_joint_pretest()` (residuals-in - [x] Phase 2a: Panel validator (`diff_diff.had._validate_had_panel`) verifies `D_{g,1} = 0` for all units, rejects negative post-period doses (`D_{g,2} < 0`) front-door on the original (unshifted) scale, rejects `>2` time periods on the `aggregate="overall"` path (multi-period panels must use `aggregate="event_study"`, Phase 2b), and rejects unbalanced panels and NaN in outcome/dose/unit columns. Both Design 1 paths (`continuous_near_d_lower` and `mass_point`) additionally require `d_lower == float(d.min())` within float tolerance; mismatched overrides raise with a pointer to the unsupported (LATE-like / off-support) estimand. - [x] Phase 2a: NaN-propagation tests covering constant-y, degenerate-mass-point, and single-cluster-CR1 inputs. The guaranteed NaN coupling is on the DOWNSTREAM triple (`t_stat`, `p_value`, `conf_int`) via the `safe_inference()` gate, which returns NaN on all three whenever `se` is non-finite, zero, or negative. `att` and `se` themselves are raw estimator outputs: on constant-y / no-dose-variation / divide-by-zero the fit paths return `(att=nan, se=nan)` so all five fields move to NaN together; on the degenerate single-cluster CR1 configuration on the mass-point path, `_fit_mass_point_2sls` returns `(att=beta_hat, se=nan)` - `att` is finite (Wald-IV is well defined) while `se` is NaN, so the downstream triple is NaN while `att` remains the raw 2SLS coefficient. The `assert_nan_inference` fixture in `tests/conftest.py` checks the downstream triple against this contract without requiring `att` to be NaN. - **Note (mass-point SE):** Standard errors on the mass-point path use the structural-residual 2SLS sandwich `[Z'X]^{-1} · Ω · [Z'X]^{-T}` with `Ω` built from the structural residuals `u = ΔY - α̂ - β̂·D` (not the reduced-form residuals from an OLS-on-indicator shortcut). Supported: `classical`, `hc1`, and CR1 (cluster-robust) when `cluster=` is supplied. `hc2` and `hc2_bm` raise `NotImplementedError` pending a 2SLS-specific leverage derivation (the OLS leverage `x_i' (X'X)^{-1} x_i` is wrong for 2SLS; the correct finite-sample correction depends on `(Z'X)^{-1}` rather than `(X'X)^{-1}`) plus a dedicated R parity anchor. Queued for the follow-up PR. - - **Note (continuous cluster-robust SE, Phase 2a):** On the continuous designs (`continuous_at_zero` / `continuous_near_d_lower`), `cluster=` threads the per-unit cluster IDs into `bias_corrected_local_linear`, whose CCT-2014 robust variance then becomes the cluster-robust nonparametric SE; the β̂-scale SE is `se_robust / |den|`. Because the β-scale rescale is a deterministic linear transform, the estimator-level clustered SE equals the direct `bias_corrected_local_linear(cluster=...).se_robust / |den|` to machine precision — this is the in-library validation anchor, and the clustered CCT SE is itself golden-tested against `nprobust` on DGP 4 (see the Phase 1c note above: `atol=1e-14` in first-appearance cluster order). Cluster IDs must be unit-constant (validated up front; a nonexistent column, NaN, or within-unit-varying cluster now raises rather than being silently ignored, mirroring the mass-point path). Cluster-robust inference is **unidentified with fewer than two clusters in the active kernel window** (`eC = cluster[ind]`, the in-bandwidth subset the CCT variance is actually computed on — a stricter condition than the global cluster count, since clusters can be separated from the boundary by the bandwidth): the guard lives in `_nprobust_port.lprobust` and NaNs `se_rb`/`se_cl`, so `bias_corrected_local_linear.se_robust` is NaN and HAD's `safe_inference` NaNs the t-stat / p-value / CI while the point estimate stays finite — the same single-cluster NaN contract as the mass-point CR1 path (`_fit_mass_point_2sls`), applied at the variance-computation site so it also covers the direct `bias_corrected_local_linear` API. A window with ≥2 distinct clusters is bit-identical (DGP-4 golden parity preserved). `cluster=` composes with the `weights=` shortcut (weighted cluster-robust; validated against the direct weighted+clustered local-linear call). The `cluster=` + `survey_design=` composition raises `NotImplementedError`: the Binder (1983) TSL survey variance is composed from the per-unit influence function via `compute_survey_if_variance` and would silently override the cluster-robust SE — route clustering through `survey_design=SurveyDesign(psu=)` instead. Result metadata reports `vcov_type="cr1"` + `cluster_name=` with `inference_method="analytical_nonparametric"` (distinguishing the clustered CCT variance from the mass-point 2SLS CR1 sandwich). Estimator-level cluster threading also extends to the **Phase 2b event-study** path (cluster-robust per-horizon pointwise CIs on both designs plus a cluster-robust simultaneous sup-t band; the per-horizon variance-family reconciliation is documented in "Note (HAD clustered event-study sup-t band)" above) — the former "cluster ignored" `UserWarning` is removed. + - **Note (continuous cluster-robust SE, Phase 2a):** On the continuous designs (`continuous_at_zero` / `continuous_near_d_lower`), `cluster=` threads the per-unit cluster IDs into `bias_corrected_local_linear`, whose CCT-2014 robust variance then becomes the cluster-robust nonparametric SE; the β̂-scale SE is `se_robust / |den|`. Because the β-scale rescale is a deterministic linear transform, the estimator-level clustered SE equals the direct `bias_corrected_local_linear(cluster=...).se_robust / |den|` to machine precision — this is the in-library validation anchor, and the clustered CCT SE is itself golden-tested against `nprobust` on DGP 4 (see the Phase 1c note above: `atol=1e-14` in first-appearance cluster order). Cluster IDs must be unit-constant (validated up front; a nonexistent column, NaN, or within-unit-varying cluster now raises rather than being silently ignored, mirroring the mass-point path). Cluster-robust inference is **unidentified with fewer than two clusters in the active kernel window** (`eC = cluster[ind]`, the in-bandwidth subset the CCT variance is actually computed on — a stricter condition than the global cluster count, since clusters can be separated from the boundary by the bandwidth): the guard lives in `_nprobust_port.lprobust` and NaNs `se_rb`/`se_cl`, so `bias_corrected_local_linear.se_robust` is NaN and HAD's `safe_inference` NaNs the t-stat / p-value / CI while the point estimate stays finite — the same single-cluster NaN contract as the mass-point CR1 path (`_fit_mass_point_2sls`), applied at the variance-computation site so it also covers the direct `bias_corrected_local_linear` API. A window with ≥2 distinct clusters is bit-identical (DGP-4 golden parity preserved). A bare `cluster=` gives unweighted cluster-robust inference; the `cluster=` + `survey_design=` composition raises `NotImplementedError`: the Binder (1983) TSL survey variance is composed from the per-unit influence function via `compute_survey_if_variance` and would silently override the cluster-robust SE — for weighted clustering route through `survey_design=SurveyDesign(weights='', psu='')` instead. Result metadata reports `vcov_type="cr1"` + `cluster_name=` with `inference_method="analytical_nonparametric"` (distinguishing the clustered CCT variance from the mass-point 2SLS CR1 sandwich). Estimator-level cluster threading also extends to the **Phase 2b event-study** path (cluster-robust per-horizon pointwise CIs on both designs plus a cluster-robust simultaneous sup-t band; the per-horizon variance-family reconciliation is documented in "Note (HAD clustered event-study sup-t band)" above) — the former "cluster ignored" `UserWarning` is removed. - **Note (Design 1 identification):** `continuous_near_d_lower` and `mass_point` fits emit a `UserWarning` surfacing that `WAS_{d̲}` identification requires Assumption 6 (or Assumption 5 for sign identification only) beyond parallel trends, and that neither is testable via pre-trends. `continuous_at_zero` (Design 1', Assumption 3 only) does not emit this warning. - **Note (CI endpoints):** Because the continuous-path `att` is `(mean(ΔY) - τ̂_bc) / den`, the beta-scale CI endpoints reverse relative to the Phase 1c boundary-limit CI: `CI_lower(β̂) = (mean(ΔY) - CI_upper(τ̂_bc)) / den` and `CI_upper(β̂) = (mean(ΔY) - CI_lower(τ̂_bc)) / den`. The `HeterogeneousAdoptionDiD.fit()` implementation computes `att ± z · se` directly via `safe_inference`, which handles the reversal naturally from the transformed point estimate. - - **Note (Phase 2a/2b scope, superseded by Phase 4.5):** Phase 2a ships the single-period `aggregate="overall"` path; Phase 2b lifts `aggregate="event_study"` (Appendix B.2 multi-period extension) which returns a `HeterogeneousAdoptionDiDEventStudyResults` with per-event-time WAS estimates and pointwise CIs. The original Phase 2a/2b release raised `NotImplementedError` on `survey=` and `weights=`, but Phase 4.5 (A/B/C0) lifted both gates with the per-design vcov contract documented above (see L2340-L2379, including the mass-point `vcov_type="classical"` deviation and `cband=True` sup-t restriction); the `survey=` path composes Binder (1983) TSL via `compute_survey_if_variance` on both continuous and mass-point IFs. + - **Note (Phase 2a/2b scope, superseded by Phase 4.5):** Phase 2a ships the single-period `aggregate="overall"` path; Phase 2b lifts `aggregate="event_study"` (Appendix B.2 multi-period extension) which returns a `HeterogeneousAdoptionDiDEventStudyResults` with per-event-time WAS estimates and pointwise CIs. The original Phase 2a/2b release raised `NotImplementedError` on all weighting; Phase 4.5 (A/B/C0) lifted that with the per-design vcov contract documented above (see L2340-L2379, including the mass-point `vcov_type="classical"` deviation and `cband=True` sup-t restriction); the `survey_design=` path composes Binder (1983) TSL via `compute_survey_if_variance` on both continuous and mass-point IFs. - **Note (panel-only):** The paper (Section 2) defines HAD on *panel or repeated cross-section* data, but both the overall and event-study paths ship a panel-only implementation: `HeterogeneousAdoptionDiD.fit()` requires a balanced panel with a unit identifier so that unit-level first differences `ΔY_{g,t} = Y_{g,t} - Y_{g,t_anchor}` can be formed. Repeated-cross-section inputs (disjoint unit IDs between periods) are rejected by the balanced-panel validator. RCS support is queued for a follow-up PR (tracked in `TODO.md`); it will need a separate identification path based on pre/post cell means rather than unit-level differences. - [x] Phase 2b: Multi-period event-study extension (Appendix B.2). `aggregate="event_study"` produces per-event-time WAS estimates using a uniform `F-1` baseline (`ΔY_{g,t} = Y_{g,t} - Y_{g,F-1}` for every horizon), reusing the three Phase 2a design paths on per-horizon first differences. Pre-period placebos included for `e <= -2` (the anchor `e = -1` is skipped since `ΔY = 0` trivially). Post-period estimates for `e >= 0`. The joint Stute test (Equation 18) across pre-periods is a SEPARATE diagnostic deferred to a **Phase 3 follow-up patch** (Phase 3 ships the single-horizon Stute test; the joint stacked-residual variant is tracked in `TODO.md`). -- [x] Phase 2b: event-study `cluster=` threading — cluster-robust per-horizon pointwise CIs (both designs) AND a cluster-robust simultaneous band via the clustered branch of `_sup_t_multiplier_bootstrap` (continuous scale 1.0; mass-point `√(G/(G-1))`). Closes the former "cluster ignored on the nonparametric path" deferral (`TODO.md`). See "Note (HAD clustered event-study sup-t band)". `cluster=` + `survey=` remains rejected (route through `survey_design=SurveyDesign(psu=...)`). +- [x] Phase 2b: event-study `cluster=` threading — cluster-robust per-horizon pointwise CIs (both designs) AND a cluster-robust simultaneous band via the clustered branch of `_sup_t_multiplier_bootstrap` (continuous scale 1.0; mass-point `√(G/(G-1))`). Closes the former "cluster ignored on the nonparametric path" deferral (`TODO.md`). See "Note (HAD clustered event-study sup-t band)". `cluster=` + `survey_design=` remains rejected (for weighted clustering route through `survey_design=SurveyDesign(weights=..., psu=...)`). - **Note (Phase 2b last-cohort filter):** When `first_treat_col` indicates more than one nonzero cohort, the panel is auto-filtered to the last-treatment cohort (`F_last = max(cohorts)`) **plus never-treated units** (`first_treat = 0`), with a `UserWarning` naming kept/dropped unit counts and dropped cohort labels. Paper Appendix B.2 is explicit that HAD "may be used only for the LAST treatment cohort in a staggered design"; the auto-filter implements this prescription, retaining never-treated units per the paper's "there must be an untreated group, at least till the period where the last cohort gets treated" requirement. Only earlier-cohort units (with `first_treat > 0` and `< F_last`) are dropped — never-treated units satisfy the dose invariant at every period (`D = 0` throughout) and preserve Design 1' identifiability (boundary at `0`) when last-cohort doses are uniformly positive. When `first_treat_col` is omitted on a >2-period panel, the validator infers each unit's first-positive-dose period from the dose path; if multiple distinct first-positive-dose cohorts are detected, the estimator raises a front-door `ValueError` directing users to pass `first_treat_col` (which activates the auto-filter) or use `ChaisemartinDHaultfoeuille` for full staggered support — there is no silent acceptance of staggered panels without cohort metadata. Common-adoption panels (single first-positive-dose cohort, or only never-treated + one cohort) pass through unchanged with `F` inferred from the dose invariant, and require dose contiguity (pre-periods < post-periods in natural ordering). Non-contiguous dose sequences (e.g., reverse treatment) raise with a pointer to `ChaisemartinDHaultfoeuille`. - **Note (Phase 2b constant-dose requirement):** The event-study aggregation uses `D_{g, F}` (first-treatment-period dose) as the single regressor for every event-time horizon, per paper Appendix B.2's "once treated, stay treated with the same dose" convention. The validator REJECTS panels where a unit has time-varying dose across post-treatment periods (`D_{g, t} != D_{g, F}` for any `t >= F` within-unit, beyond float tolerance) with a front-door `ValueError`, directing users with genuinely time-varying post-treatment doses to `ChaisemartinDHaultfoeuille` (`did_multiplegt_dyn`). Silent acceptance would misattribute later-horizon treatment-effect heterogeneity to the period-F dose. A follow-up PR could implement a time-varying-dose estimator; tracked in `TODO.md`. - **Note (Phase 2b per-horizon SE):** Each event-time horizon uses an INDEPENDENT sandwich computed on that horizon's first differences: continuous paths use the CCT-2014 robust SE from Phase 1c divided by `|den|`; mass-point path uses the structural-residual 2SLS sandwich from Phase 2a. This produces pointwise CIs per horizon, matching the paper's Pierce-Schott application (Section 5.2, Figure 2: "nonparametric pointwise CIs"). Joint cross-horizon covariance (IF-based stacking or block bootstrap) is NOT computed — the paper does not derive it and all reported CIs are pointwise. Follow-up PRs may add joint covariance for cross-horizon hypothesis tests; current tracking in `TODO.md`. diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 99f97ba8..03c839a1 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -607,10 +607,10 @@ SE path is not used here). ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Problem:** Calling ``HeterogeneousAdoptionDiD.fit(..., vcov_type="classical")`` -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``. +under ``survey_design=SurveyDesign(...)`` raises ``NotImplementedError`` on the +mass-point path (both the static and the event-study survey paths; the event-study +rejection fires regardless of ``cband`` because the Binder-TSL analytical SE +consumes the HC1-scaled influence function either way). **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 diff --git a/docs/tutorials/20_had_brand_campaign.ipynb b/docs/tutorials/20_had_brand_campaign.ipynb index cc6a6e4f..262992f3 100644 --- a/docs/tutorials/20_had_brand_campaign.ipynb +++ b/docs/tutorials/20_had_brand_campaign.ipynb @@ -388,7 +388,7 @@ "\n", "This tutorial covered HAD's headline workflow: the overall WAS_d_lower fit and the multi-week event study. The library also supports several extensions we did not demonstrate here.\n", "\n", - "- **Population-weighted (survey-aware) inference**: when some markets or regions carry more weight than others - e.g., DMAs weighted by population - HAD accepts a `SurveyDesign` object on the same `fit()` interface (the deprecated `weights=` and `survey=` kwarg aliases will be removed in the next minor release; use `survey_design=` going forward). [Tutorial 22](22_had_survey_design.ipynb) walks the BRFSS-shape survey-design path end-to-end including the pretest workflow.\n", + "- **Population-weighted (survey-aware) inference**: when some markets or regions carry more weight than others - e.g., DMAs weighted by population - HAD accepts a `SurveyDesign` object via the `survey_design=` kwarg on the same `fit()` interface. [Tutorial 22](22_had_survey_design.ipynb) walks the BRFSS-shape survey-design path end-to-end including the pretest workflow.\n", "- **Composite pretest workflow**: HAD ships a `did_had_pretest_workflow` that combines the QUG support-infimum test (`H0: d_lower = 0`, which adjudicates between the `continuous_at_zero` and `continuous_near_d_lower` design paths) with linearity tests (Stute and Yatchew-HR). On the two-period (`aggregate='overall'`) path this workflow checks QUG and linearity only; the parallel-trends step is closed by the multi-period (`aggregate='event_study'`) joint variants (`stute_joint_pretest`, `joint_pretrends_test`, `joint_homogeneity_test`). The visual placebo check we used in Section 4 is a parallel-trends sanity check, not a substitute for the formal joint pretests; see [Tutorial 21](21_had_pretest_workflow.ipynb) for an end-to-end pretest walkthrough.\n", "- **`continuous_at_zero` design path**: if the lightest-touch DMA had no regional add-on (spend exactly $0), HAD switches to the Design 1' identification path with target `WAS` instead of `WAS_d_lower`. The auto-detection picks it up.\n", "- **Mass-point design path**: if a meaningful chunk of DMAs sit at exactly the same minimum spend (rather than spread continuously near the boundary), HAD switches to a 2SLS estimator with matching identification logic. Auto-detected as well.\n", diff --git a/tests/test_guides.py b/tests/test_guides.py index 63891aaf..556253c7 100644 --- a/tests/test_guides.py +++ b/tests/test_guides.py @@ -403,6 +403,16 @@ def test_llms_full_had_fit_signature_matches_real_api(self): f"document the actual HeterogeneousAdoptionDiD.fit " f"signature." ) + # 3.7.0: survey= / weights= were removed from HAD.fit(); the guide + # signature must not re-introduce them (they are no longer real + # parameters, so an agent copying them would hit a TypeError). The + # `survey:` / `weights:` forms do not collide with `survey_design:`. + for removed in ("survey:", "weights:"): + assert removed not in fit_block, ( + f"HAD fit() guide block must not document the removed " + f"{removed!r} parameter (dropped in 3.7.0; survey_design= " + f"is the sole weighting entry)." + ) def test_llms_full_paper_citation(self): # Lead-author "D'Haultfœuille" appears in the HAD section. @@ -475,13 +485,20 @@ def test_llms_full_had_section_documents_mass_point_survey_vcov_requirement(self "(per had.py:3495-3507). Without this caveat the documented " "weighted fit example can raise NotImplementedError." ) + # 3.7.0: the mass-point guidance must not reference the removed + # weights= shortcut (fit(weights=) no longer exists). + assert "weights=` shortcut" not in had_text, ( + "HAD section must not describe the removed `weights=` shortcut " + "(dropped in 3.7.0); the sole weighting entry is survey_design=." + ) def test_llms_full_had_variance_formula_describes_all_designs(self): - # Per diff_diff/had.py:3585-3629, weighted mass-point fits populate - # variance_formula in {"pweight_2sls", "survey_binder_tsl_2sls"} and - # weighted continuous fits in {"pweight", "survey_binder_tsl"}. The - # documented description must cover ALL four labels (not just the - # two continuous ones) so agents reading the guide on a weighted + # After the 3.7.0 survey_design= consolidation, HAD.fit() emits only + # the Binder-TSL labels: weighted continuous fits populate + # "survey_binder_tsl" and weighted mass-point fits + # "survey_binder_tsl_2sls" (the pweight / pweight_2sls labels were + # removed with the weights= kwarg). The documented description must + # cover BOTH survey labels so agents reading the guide on a weighted # mass-point fit do not misread the available inference metadata. text = get_llm_guide("full") sp_start = text.index("### HeterogeneousAdoptionDiDResults") @@ -491,17 +508,21 @@ def test_llms_full_had_variance_formula_describes_all_designs(self): for line in sp_block.splitlines(): if line.startswith("| `variance_formula`"): for label in ( - "pweight", "survey_binder_tsl", - "pweight_2sls", "survey_binder_tsl_2sls", ): assert label in line, ( f"variance_formula row must enumerate the {label!r} " f"label - weighted mass-point fits populate " - f"pweight_2sls / survey_binder_tsl_2sls per " - f"had.py:3585-3629. Line: {line!r}" + f"survey_binder_tsl_2sls per had.py. Line: {line!r}" ) + # The pweight / pweight_2sls labels were removed with the + # weights= kwarg in 3.7.0 and must not reappear. + assert "pweight" not in line, ( + f"variance_formula row must not mention the removed " + f"pweight labels after the 3.7.0 consolidation. " + f"Line: {line!r}" + ) break else: pytest.fail("variance_formula row not found in HAD results table") @@ -521,13 +542,14 @@ def test_llms_full_had_variance_formula_describes_all_designs(self): def test_llms_full_had_event_study_mirrors_weighted_metadata_semantics(self): # R9 P1 (Documentation/Tests): the event-study results table at # ### HeterogeneousAdoptionDiDEventStudyResults must enumerate the - # SAME four variance_formula labels and the SAME mass-point / - # Wald-IV semantics for effective_dose_mean as the single-period - # table; the event-study fit path populates these fields with the - # same labels (had.py:639-648 for variance_formula, - # had.py:721-734 for effective_dose_mean). Without parallel - # coverage, agents reading the guide on an event-study fit - # cannot identify the inference family. + # SAME variance_formula labels and the SAME mass-point / Wald-IV + # semantics for effective_dose_mean as the single-period table; the + # event-study fit path populates these fields with the same labels. + # After the 3.7.0 survey_design= consolidation those are the two + # Binder-TSL labels (the pweight / pweight_2sls labels were removed + # with the weights= kwarg). Without parallel coverage, agents + # reading the guide on an event-study fit cannot identify the + # inference family. text = get_llm_guide("full") es_start = text.index("### HeterogeneousAdoptionDiDEventStudyResults") # End at the next top-level result section (### TROPResults is the @@ -537,17 +559,21 @@ def test_llms_full_had_event_study_mirrors_weighted_metadata_semantics(self): for line in es_block.splitlines(): if line.startswith("| `variance_formula`"): for label in ( - "pweight", "survey_binder_tsl", - "pweight_2sls", "survey_binder_tsl_2sls", ): assert label in line, ( f"event-study variance_formula row must enumerate " f"{label!r} (event-study path applies the same " - f"label uniformly across horizons per had.py:639-648). " - f"Line: {line!r}" + f"label uniformly across horizons). Line: {line!r}" ) + # The pweight / pweight_2sls labels were removed with the + # weights= kwarg in 3.7.0 and must not reappear. + assert "pweight" not in line, ( + f"event-study variance_formula row must not mention the " + f"removed pweight labels after the 3.7.0 consolidation. " + f"Line: {line!r}" + ) break else: pytest.fail( diff --git a/tests/test_had.py b/tests/test_had.py index d81ab0c7..5e983f72 100644 --- a/tests/test_had.py +++ b/tests/test_had.py @@ -971,12 +971,12 @@ def test_aggregate_invalid_raises(self): aggregate="garbage", ) - def test_survey_bad_type_raises(self): - """survey= must be a SurveyDesign-like object with a `.resolve()` - method; a bare string (or any object lacking `.resolve()`) raises - TypeError front-door. Updated PR #376 R8 P1: the data-in type - guard now runs at the canonical entry and rejects on the - `hasattr(survey, "resolve")` check (which catches both bare + def test_survey_design_bad_type_raises(self): + """survey_design= must be a SurveyDesign-like object with a + `.resolve()` method; a bare string (or any object lacking + `.resolve()`) raises TypeError front-door. The data-in type guard + runs at the canonical entry and rejects on the + `hasattr(survey_design, "resolve")` check (which catches both bare strings and ResolvedSurveyDesign / make_pweight_design output).""" d, dy = _dgp_continuous_at_zero(200, seed=0) panel = _make_panel(d, dy) @@ -988,7 +988,7 @@ def test_survey_bad_type_raises(self): "dose", "period", "unit", - survey="anything", + survey_design="anything", ) @@ -1621,23 +1621,6 @@ def test_cluster_threaded_on_continuous_path(self): assert r_cl.vcov_type == "cr1" assert r_cl.cluster_name == "state" - def test_cluster_weighted_on_continuous_path(self): - # cluster= composes with the weights= shortcut: weighted cluster-robust - # SE equals the direct weighted+clustered local-linear se_robust / |den_w|. - d, dy, cl = self._clustered_continuous(seed=1) - rng = np.random.default_rng(2) - w = rng.uniform(0.5, 2.0, size=len(d)) - panel = _make_panel(d, dy, extra_cols={"state": cl}) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - r = HeterogeneousAdoptionDiD(design="continuous_at_zero", cluster="state").fit( - panel, "outcome", "dose", "period", "unit", weights=np.repeat(w, 2) - ) - den_w = float(np.average(d, weights=w)) - bc = bias_corrected_local_linear(d=d, y=dy, boundary=0.0, weights=w, cluster=cl) - np.testing.assert_allclose(r.se, float(bc.se_robust) / abs(den_w), rtol=0.0, atol=1e-12) - assert r.cluster_name == "state" - def test_cluster_survey_design_raises_on_continuous(self): # cluster= + survey_design= is rejected: the Binder-TSL survey path # would override the cluster-robust SE (route via psu= instead). @@ -1717,26 +1700,6 @@ def test_single_cluster_continuous_near_d_lower_nan_inference(self): assert np.isfinite(r.att) assert np.isnan(r.se) - def test_single_positive_weight_cluster_continuous_nan_inference(self): - # Two global clusters, but zero weights leave only one with positive - # weight -> effective clusters < 2 -> NaN inference (the effective-cluster - # count is taken on the positive-weight support). - rng = np.random.default_rng(2) - G = 300 - d = rng.uniform(0.0, 1.0, G) - d[0] = 0.0 - dy = 0.3 * d + 0.1 * rng.standard_normal(G) - state = np.arange(G) % 2 # two clusters - w = np.where(state == 0, rng.uniform(0.5, 2.0, G), 0.0) # cluster 1 all zero-weight - panel = _make_panel(d, dy, extra_cols={"state": state}) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - r = HeterogeneousAdoptionDiD(design="continuous_at_zero", cluster="state").fit( - panel, "outcome", "dose", "period", "unit", weights=np.repeat(w, 2) - ) - assert np.isfinite(r.att) - assert np.isnan(r.se) - def test_cluster_name_populated_mass_point(self): d, dy = _dgp_mass_point(200, seed=0) cluster_unit = np.repeat(np.arange(50), 4) # 50 clusters, 4 units each @@ -3414,9 +3377,9 @@ def test_dose_regressor_uses_period_F(self): class TestHADSurvey: """Phase 4.5 (continuous-design survey support) validation suite. - Scope: ``weights=`` array + ``survey=SurveyDesign(weights=...)`` on - ``continuous_at_zero`` and ``continuous_near_d_lower``. Mass-point + - event-study under survey remain NotImplementedError (Phase 4.5 B). + Scope: ``survey_design=SurveyDesign(weights=...)`` on + ``continuous_at_zero`` and ``continuous_near_d_lower`` (the sole weighting + entry as of the 3.7.0 ``survey=``/``weights=`` removal). """ def _panel_with_unit_weights(self, G=200, seed=42, design="continuous_at_zero"): @@ -3439,80 +3402,84 @@ def _panel_with_unit_weights(self, G=200, seed=42, design="continuous_at_zero"): # ---------- Uniform-weights bit-parity ---------- def test_uniform_weights_continuous_at_zero_bit_parity(self): + from diff_diff.survey import SurveyDesign + panel, _, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=np.ones(panel.shape[0])) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") base = est.fit(panel, "outcome", "dose", "period", "unit") - w1 = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - weights=np.ones(panel.shape[0]), - ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + w1 = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) + # Uniform weights are a no-op on the point estimate (same weighted + # lprobust fit), so the ATT is bit-identical to unweighted. np.testing.assert_allclose(w1.att, base.att, atol=1e-12, rtol=1e-12) - np.testing.assert_allclose(w1.se, base.se, atol=1e-12, rtol=1e-12) - np.testing.assert_allclose(w1.t_stat, base.t_stat, atol=1e-12, rtol=1e-12) - np.testing.assert_allclose(w1.p_value, base.p_value, atol=1e-12, rtol=1e-12) - np.testing.assert_allclose(w1.conf_int[0], base.conf_int[0], atol=1e-12, rtol=1e-12) - np.testing.assert_allclose(w1.conf_int[1], base.conf_int[1], atol=1e-12, rtol=1e-12) + # The survey path uses the Binder-TSL variance family (not the + # analytical robust SE), so its SE differs from the unweighted + # SE even under uniform weights; assert only that inference is + # well-formed rather than bit-parity. + assert np.isfinite(w1.se) and w1.se > 0 + assert np.isfinite(w1.t_stat) + assert np.isfinite(w1.p_value) + assert np.isfinite(w1.conf_int[0]) and np.isfinite(w1.conf_int[1]) def test_uniform_weights_continuous_near_d_lower_bit_parity(self): + from diff_diff.survey import SurveyDesign + with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) panel, _, _, _, _, _ = self._panel_with_unit_weights( G=200, design="continuous_near_d_lower" ) + panel_w = panel.assign(w=np.ones(panel.shape[0])) est = HeterogeneousAdoptionDiD(design="continuous_near_d_lower") base = est.fit(panel, "outcome", "dose", "period", "unit") w1 = est.fit( - panel, + panel_w, "outcome", "dose", "period", "unit", - weights=np.ones(panel.shape[0]), + survey_design=SurveyDesign(weights="w"), ) + # ATT is bit-identical (uniform weights no-op); the survey SE is a + # different variance family so only assert it is finite/positive. np.testing.assert_allclose(w1.att, base.att, atol=1e-12, rtol=1e-12) - np.testing.assert_allclose(w1.se, base.se, atol=1e-12, rtol=1e-12) + assert np.isfinite(w1.se) and w1.se > 0 # ---------- Non-trivial weights: mechanism has teeth ---------- def test_nontrivial_weights_change_estimate(self): - panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - base = est.fit(panel, "outcome", "dose", "period", "unit") - w = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) - assert not np.isclose(w.att, base.att, atol=1e-6) - - # ---------- survey=SurveyDesign(weights=...) shorthand equivalence ---------- - - def test_survey_and_weights_produce_same_point_estimate(self): - """SurveyDesign(weights='col') and weights= route through - the same weighted lprobust fit, so the ATT is identical. The SE - diverges because survey= triggers Binder-TSL variance - (PSU-aggregated, FPC/strata-aware) while weights= uses the - weighted-robust SE from lprobust. This is the intended divergence - (one knob for design-based inference, one for pweight-only).""" from diff_diff.survey import SurveyDesign panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) - panel_with_w = panel.assign(w=row_w) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r_w = est.fit(panel_with_w, "outcome", "dose", "period", "unit", weights=row_w) + base = est.fit(panel, "outcome", "dose", "period", "unit") with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - sd = SurveyDesign(weights="w") - r_sd = est.fit(panel_with_w, "outcome", "dose", "period", "unit", survey=sd) - # ATTs match (same weighted lprobust estimate). - np.testing.assert_allclose(r_w.att, r_sd.att, atol=1e-12, rtol=1e-12) - # SEs differ (different inference paths). - assert r_w.variance_formula == "pweight" - assert r_sd.variance_formula == "survey_binder_tsl" + w = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) + assert not np.isclose(w.att, base.att, atol=1e-6) # ---------- Validator contract ---------- def test_weights_vary_within_unit_raises(self): + from diff_diff.survey import SurveyDesign + panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) # Corrupt one unit's pre-period weight so it differs from its # post-period weight. @@ -3520,87 +3487,80 @@ def test_weights_vary_within_unit_raises(self): first_unit_mask = panel["unit"].to_numpy() == 0 first_unit_idx = np.where(first_unit_mask)[0][0] row_w_bad[first_unit_idx] = row_w_bad[first_unit_idx] + 5.0 + panel_bad = panel.assign(w=row_w_bad) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with pytest.raises(ValueError, match="weights vary within"): - est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w_bad) + est.fit( + panel_bad, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) def test_negative_weights_raise(self): + from diff_diff.survey import SurveyDesign + panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) row_w_bad = row_w.copy() row_w_bad[0] = -1.0 # Also corrupt the paired row so the within-unit check doesn't fire first. first_unit_mask = panel["unit"].to_numpy() == panel["unit"].iloc[0] row_w_bad[first_unit_mask] = -1.0 + panel_bad = panel.assign(w=row_w_bad) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with pytest.raises(ValueError, match="non-negative"): - est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w_bad) - - def test_zero_sum_weights_raise(self): - panel, _, _, _, _, _ = self._panel_with_unit_weights(G=200) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with pytest.raises(ValueError, match="sum to zero"): - est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - weights=np.zeros(panel.shape[0]), - ) - - def test_weights_length_mismatch_raises(self): - panel, _, _, _, _, _ = self._panel_with_unit_weights(G=200) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with pytest.raises(ValueError, match="length"): est.fit( - panel, + panel_bad, "outcome", "dose", "period", "unit", - weights=np.ones(panel.shape[0] + 1), + survey_design=SurveyDesign(weights="w"), ) - def test_survey_and_weights_mutex(self): + def test_zero_sum_weights_raise(self): from diff_diff.survey import SurveyDesign - panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) - panel_with_w = panel.assign(w=row_w) - sd = SurveyDesign(weights="w") + panel, _, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_bad = panel.assign(w=np.zeros(panel.shape[0])) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with pytest.raises(ValueError, match="at most one of"): + with pytest.raises(ValueError, match="sum to zero"): est.fit( - panel_with_w, + panel_bad, "outcome", "dose", "period", "unit", - survey=sd, - weights=row_w, + survey_design=SurveyDesign(weights="w"), ) # ---------- Previously deferred paths (Phase 4.5 B supported) ---------- def test_mass_point_weights_smoke(self): - """Mass-point + uniform weights fits and is bit-parity with + """Mass-point + uniform survey weights fits and is bit-parity with unweighted (Phase 4.5 B).""" + from diff_diff.survey import SurveyDesign + d, dy = _dgp_mass_point(500, seed=42) panel = _make_panel(d, dy) + panel_w = panel.assign(w=np.ones(panel.shape[0])) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="hc1") r_unw = est.fit(panel, "outcome", "dose", "period", "unit") r_uniform = est.fit( - panel, + panel_w, "outcome", "dose", "period", "unit", - weights=np.ones(panel.shape[0]), + survey_design=SurveyDesign(weights="w"), ) assert np.isclose(r_unw.att, r_uniform.att, atol=1e-10) assert np.isclose(r_unw.se, r_uniform.se, atol=1e-10) - assert r_uniform.variance_formula == "pweight_2sls" + assert r_uniform.variance_formula == "survey_binder_tsl_2sls" def test_event_study_weights_smoke(self): """Multi-period + event-study + uniform weights fits and @@ -3618,6 +3578,7 @@ def test_event_study_weights_smoke(self): y = 0.1 * t + (dose * 2.0 if t == 2 else 0.0) + rng.normal(0, 0.2) rows.append((g, t, dose, y)) panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]) + panel_w = panel.assign(w=np.ones(panel.shape[0])) est = HeterogeneousAdoptionDiD() r_unw = est.fit( panel, @@ -3627,36 +3588,54 @@ def test_event_study_weights_smoke(self): "unit", aggregate="event_study", ) - r_w = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=np.ones(panel.shape[0]), - cband=False, # skip bootstrap to allow att/se bit-parity check - ) - # Uniform-weights + cband=False recovers the unweighted output at - # atol=1e-10 (composition through np.average introduces O(ULP) - # reductions differing from raw mean()). + from diff_diff.survey import SurveyDesign + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r_w = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + aggregate="event_study", + survey_design=SurveyDesign(weights="w"), + cband=False, # skip bootstrap + ) + # Uniform weights are a no-op on the point estimate, so att recovers + # the unweighted output at atol=1e-10 (composition through + # np.average introduces O(ULP) reductions differing from raw mean()). np.testing.assert_allclose(r_unw.att, r_w.att, atol=1e-10, rtol=1e-10) - np.testing.assert_allclose(r_unw.se, r_w.se, atol=1e-10, rtol=1e-10) - assert r_w.variance_formula == "pweight" + # The survey path uses the Binder-TSL variance family, so its SE is + # not bit-parity with the unweighted analytical SE; assert finite. + assert np.all(np.isfinite(r_w.se)) + assert r_w.variance_formula == "survey_binder_tsl" assert r_w.cband_crit_value is None # cband=False # ---------- Result-object contract ---------- def test_survey_metadata_populated_under_weights(self): + from diff_diff.survey import SurveyDesign + panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) assert r.survey_metadata is not None # Repo-standard SurveyMetadata with attribute access. assert r.survey_metadata.weight_type == "pweight" assert r.survey_metadata.sum_weights > 0 assert r.survey_metadata.effective_n > 0 - assert r.variance_formula == "pweight" + assert r.variance_formula == "survey_binder_tsl" def test_survey_metadata_none_when_unweighted(self): panel, _, _, _, _, _ = self._panel_with_unit_weights(G=200) @@ -3665,9 +3644,21 @@ def test_survey_metadata_none_when_unweighted(self): assert r.survey_metadata is None def test_to_dict_includes_survey_metadata(self): + from diff_diff.survey import SurveyDesign + panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) d = r.to_dict() assert "survey_metadata" in d # survey_metadata is now a SurveyMetadata dataclass; consumers @@ -3685,9 +3676,21 @@ def test_to_dict_survey_metadata_none_key_present(self): assert d["survey_metadata"] is None def test_summary_renders_under_weights(self): + from diff_diff.survey import SurveyDesign + panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) s = r.summary() assert "Variance formula" in s assert "Effective sample size" in s @@ -3730,7 +3733,7 @@ def test_survey_with_strata_produces_different_se(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w"), + survey_design=SurveyDesign(weights="w"), ) r_strat = est.fit( panel, @@ -3738,7 +3741,7 @@ def test_survey_with_strata_produces_different_se(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata"), + survey_design=SurveyDesign(weights="w", strata="strata"), ) np.testing.assert_allclose(r_basic.att, r_strat.att, atol=1e-14, rtol=1e-14) assert r_basic.se != r_strat.se @@ -3758,7 +3761,7 @@ def test_survey_with_psu_clustering(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata"), + survey_design=SurveyDesign(weights="w", strata="strata"), ) r_psu = est.fit( panel, @@ -3766,7 +3769,7 @@ def test_survey_with_psu_clustering(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata", psu="psu"), + survey_design=SurveyDesign(weights="w", strata="strata", psu="psu"), ) np.testing.assert_allclose(r_strat.att, r_psu.att, atol=1e-14, rtol=1e-14) assert r_psu.se != r_strat.se @@ -3786,7 +3789,7 @@ def test_survey_metadata_records_binder_tsl_method(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata", psu="psu"), + survey_design=SurveyDesign(weights="w", strata="strata", psu="psu"), ) sm = r.survey_metadata assert sm is not None @@ -3817,7 +3820,7 @@ def test_survey_design_column_varies_within_unit_raises(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata"), + survey_design=SurveyDesign(weights="w", strata="strata"), ) def test_replicate_weights_not_yet_supported(self): @@ -3843,7 +3846,7 @@ def test_replicate_weights_not_yet_supported(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) with pytest.raises(NotImplementedError, match="Replicate-weight"): - est.fit(panel2, "outcome", "dose", "period", "unit", survey=sd) + est.fit(panel2, "outcome", "dose", "period", "unit", survey_design=sd) # ---------- P0 fix: bias-corrected IF alignment (round 1 review) ---------- @@ -3898,17 +3901,17 @@ def test_survey_df_widens_ci_vs_normal(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata", psu="psu"), + survey_design=SurveyDesign(weights="w", strata="strata", psu="psu"), ) - # Same fit under pweight-only (no df_survey threading — uses - # Normal inference). ATT matches; CI width differs. + # Same fit under a weights-only SurveyDesign (no PSU/strata). + # ATT matches; the inference path differs. r_w = est.fit( panel, "outcome", "dose", "period", "unit", - weights=panel["w"].to_numpy(), + survey_design=SurveyDesign(weights="w"), ) # df_survey surfaced in metadata. assert r_sd.survey_metadata is not None @@ -3940,7 +3943,7 @@ def test_survey_df_threaded_into_inference_via_t_distribution(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata", psu="psu"), + survey_design=SurveyDesign(weights="w", strata="strata", psu="psu"), ) assert r_sd.survey_metadata is not None df = r_sd.survey_metadata.df_survey @@ -3970,7 +3973,7 @@ def test_survey_aweight_raises_not_implemented(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) with pytest.raises(NotImplementedError, match="aweight"): - est.fit(panel_with_w, "outcome", "dose", "period", "unit", survey=sd) + est.fit(panel_with_w, "outcome", "dose", "period", "unit", survey_design=sd) def test_survey_fweight_raises_not_implemented(self): """``SurveyDesign(weight_type='fweight')`` raises — frequency @@ -3993,9 +3996,9 @@ def test_survey_fweight_raises_not_implemented(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) with pytest.raises(NotImplementedError, match="fweight"): - est.fit(df, "outcome", "dose", "period", "unit", survey=sd) + est.fit(df, "outcome", "dose", "period", "unit", survey_design=sd) - # ---------- P2 fix: SRS equivalence with weights= shortcut ---------- + # ---------- P2 fix: SRS equivalence under survey_design= (uniform weights) ---------- # ---------- P2 fix: weighted denominator contract ---------- @@ -4005,11 +4008,21 @@ def test_effective_dose_mean_matches_weighted_mean_continuous_at_zero(self): uses, vs ``dose_mean`` which is the raw-sample mean (preserved for backward compatibility). Regression test for P2 from round 2 CI review.""" + from diff_diff.survey import SurveyDesign + panel, row_w, w_unit, d, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + r = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) assert r.effective_dose_mean is not None expected = float(np.average(d, weights=w_unit)) np.testing.assert_allclose(r.effective_dose_mean, expected, atol=1e-12, rtol=1e-12) @@ -4022,13 +4035,23 @@ def test_effective_dose_mean_matches_weighted_mean_near_d_lower(self): ``d_lower = d.min()`` (not the theoretical lower bound of the DGP), so the expected weighted denominator uses ``d - r.d_lower``, not the DGP's ``d_lower``.""" + from diff_diff.survey import SurveyDesign + with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) panel, row_w, w_unit, d, _, _ = self._panel_with_unit_weights( G=200, design="continuous_near_d_lower" ) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_near_d_lower") - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + r = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) assert r.effective_dose_mean is not None # Use the estimator's auto-resolved d_lower (== d.min()), not the # DGP's theoretical lower bound. @@ -4065,21 +4088,30 @@ def test_zero_weight_unit_at_d_min_does_not_flip_design(self): row_w = np.zeros(panel.shape[0]) for g in range(G_pop): row_w[panel["unit"].to_numpy() == g] = w_unit[g] + panel_w = panel.assign(w=row_w) + from diff_diff.survey import SurveyDesign + with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) # Full panel with zero-weight unit at d=0: auto-detect. est = HeterogeneousAdoptionDiD(design="auto") - r_full = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + r_full = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) # Physically drop the zero-weight unit and refit. - panel_dropped = panel[panel["unit"] != 0].reset_index(drop=True) - w_dropped = row_w[panel["unit"].to_numpy() != 0] + panel_dropped = panel_w[panel_w["unit"] != 0].reset_index(drop=True) r_dropped = est.fit( panel_dropped, "outcome", "dose", "period", "unit", - weights=w_dropped, + survey_design=SurveyDesign(weights="w"), ) # Both paths resolve to the SAME design (the positive-weight # support, not the contaminated d=0 boundary). @@ -4088,7 +4120,12 @@ def test_zero_weight_unit_at_d_min_does_not_flip_design(self): # zero-weight unit's kernel contribution; filtering earlier # doesn't change the fit numerically). np.testing.assert_allclose(r_full.att, r_dropped.att, atol=1e-10, rtol=1e-10) - np.testing.assert_allclose(r_full.se, r_dropped.se, atol=1e-10, rtol=1e-10) + # The survey path RETAINS the zero-weight unit in the sampling + # frame for variance (n_psu / df reflect the full frame), so the + # Binder-TSL SE is not bit-identical to physically dropping the + # unit; assert only that inference is well-formed. (The design / + # d_lower non-flip below is the actual point of this test.) + assert np.isfinite(r_full.se) and r_full.se > 0 # d_lower set by the positive-weight subpopulation (d.min() of # the kept units), NOT the contaminated full d.min()=0. assert r_full.d_lower > 0.0 @@ -4108,12 +4145,22 @@ def test_zero_weight_filter_warns_user(self): row_w = np.zeros(panel.shape[0]) for g in range(G): row_w[panel["unit"].to_numpy() == g] = w_unit[g] + panel_w = panel.assign(w=row_w) + from diff_diff.survey import SurveyDesign + est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with pytest.warns(UserWarning, match="weight == 0"): - est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) def test_zero_weight_survey_metadata_preserves_full_design(self): - """Round 6 P1a: on the ``survey=`` path, zero-weight units + """Round 6 P1a: on the ``survey_design=`` path, zero-weight units (subpopulation convention) stay in the ResolvedSurveyDesign for variance + SurveyMetadata. ``n_psu`` / ``n_strata`` / ``df_survey`` / ``sum_weights`` reflect the FULL sampling frame, @@ -4151,7 +4198,7 @@ def test_zero_weight_survey_metadata_preserves_full_design(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata", psu="psu"), + survey_design=SurveyDesign(weights="w", strata="strata", psu="psu"), ) # Reference fit: physically drop the zero-weight units and # refit on the positive-weight subsample. SurveyMetadata @@ -4165,7 +4212,7 @@ def test_zero_weight_survey_metadata_preserves_full_design(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata", psu="psu"), + survey_design=SurveyDesign(weights="w", strata="strata", psu="psu"), ) # Point estimate IDENTICAL — zero-weight units contribute 0 to # fit either way. @@ -4281,65 +4328,45 @@ def test_zero_weight_counts_reflect_positive_subset(self): row_w = np.zeros(panel.shape[0]) for g in range(G): row_w[panel["unit"].to_numpy() == g] = w_unit[g] - est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) - # 100 positive-weight units, not 120. - assert r.n_obs == 100 - - def test_survey_metadata_raw_weights_match_shortcut(self): - """Round 4 P2: on the ``survey=SurveyDesign(weights="col")`` - path, ``SurveyMetadata.sum_weights`` and ``weight_range`` must - reflect the RAW pre-normalization weights (per the - ``compute_survey_metadata`` contract), so they agree with the - ``weights=`` shortcut on the same data. Previously the - survey path passed resolved (post-normalization, mean=1) weights - into the helper, producing ``sum_weights ≈ G`` and normalized - ``weight_range`` that drifted from the shortcut.""" + panel_w = panel.assign(w=row_w) from diff_diff.survey import SurveyDesign - panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) - panel_with_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - r_w = est.fit(panel_with_w, "outcome", "dose", "period", "unit", weights=row_w) - r_sd = est.fit( - panel_with_w, + r = est.fit( + panel_w, "outcome", "dose", "period", "unit", - survey=SurveyDesign(weights="w"), + survey_design=SurveyDesign(weights="w"), ) - sm_w = r_w.survey_metadata - sm_sd = r_sd.survey_metadata - assert sm_w is not None and sm_sd is not None - # sum_weights at unit-level: G unit-constant weights aggregated - # per-unit via .first() give identical arrays on both paths. - np.testing.assert_allclose(sm_sd.sum_weights, sm_w.sum_weights, atol=1e-12, rtol=1e-12) - np.testing.assert_allclose( - sm_sd.weight_range[0], sm_w.weight_range[0], atol=1e-12, rtol=1e-12 - ) - np.testing.assert_allclose( - sm_sd.weight_range[1], sm_w.weight_range[1], atol=1e-12, rtol=1e-12 - ) - # design_effect and effective_n are scale-invariant so they also - # agree (secondary lock). - np.testing.assert_allclose(sm_sd.design_effect, sm_w.design_effect, atol=1e-12, rtol=1e-12) - np.testing.assert_allclose(sm_sd.effective_n, sm_w.effective_n, atol=1e-12, rtol=1e-12) + # 100 positive-weight units, not 120. + assert r.n_obs == 100 def test_repr_surfaces_weighted_fields_when_present(self): """Round 4 P3: ``__repr__`` must name ``variance_formula`` and ``effective_dose_mean`` when the fit was weighted so ad-hoc log output / interactive notebooks show which inference path and denominator were used.""" + from diff_diff.survey import SurveyDesign + panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r_w = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r_w = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) rep = repr(r_w) - assert "variance_formula='pweight'" in rep + assert "variance_formula='survey_binder_tsl'" in rep assert "effective_dose_mean=" in rep # Unweighted fit: ``__repr__`` keeps the original compact form. r_unw = est.fit(panel, "outcome", "dose", "period", "unit") @@ -4347,29 +4374,8 @@ def test_repr_surfaces_weighted_fields_when_present(self): assert "variance_formula" not in rep_unw assert "effective_dose_mean" not in rep_unw - def test_weights_shortcut_clears_survey_only_fields(self): - """Round 3 P2a: on the ``weights=`` shortcut, inference stays - Normal (df=None in safe_inference). Survey-only fields of - ``SurveyMetadata`` (``df_survey``, ``n_psu``, ``n_strata``) - must be ``None`` there — otherwise ``summary()`` / - BusinessReport render a finite-df survey result that does not - match the actual (Normal) inference family.""" - panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) - sm = r.survey_metadata - assert sm is not None - # Descriptive weighted-sample fields stay populated. - assert sm.weight_type == "pweight" - assert sm.effective_n > 0 - assert sm.sum_weights > 0 - # Survey-only fields cleared (Normal inference; no PSU/strata). - assert sm.n_strata is None - assert sm.n_psu is None - assert sm.df_survey is None - def test_survey_path_populates_df_survey(self): - """Counter-test to the above: under ``survey=SurveyDesign(...)`` + """Counter-test to the above: under ``survey_design=SurveyDesign(...)`` with real PSU/strata, ``df_survey`` IS populated and threaded into t-inference.""" panel, SurveyDesign = self._panel_with_survey_cols(G=200) @@ -4382,7 +4388,7 @@ def test_survey_path_populates_df_survey(self): "dose", "period", "unit", - survey=SurveyDesign(weights="w", strata="strata", psu="psu"), + survey_design=SurveyDesign(weights="w", strata="strata", psu="psu"), ) sm = r.survey_metadata assert sm is not None @@ -4395,12 +4401,24 @@ def test_to_dict_includes_variance_formula_and_effective_dose_mean(self): and ``effective_dose_mean`` so downstream machine consumers can recover the weighted denominator + SE family without inspecting the result object directly.""" + from diff_diff.survey import SurveyDesign + panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) d = r.to_dict() assert "variance_formula" in d - assert d["variance_formula"] == "pweight" + assert d["variance_formula"] == "survey_binder_tsl" assert "effective_dose_mean" in d assert d["effective_dose_mean"] is not None assert np.isfinite(d["effective_dose_mean"]) @@ -4416,25 +4434,42 @@ def test_to_dict_variance_formula_none_when_unweighted(self): def test_summary_renders_effective_dose_mean_under_weights(self): """``summary()`` must display the weighted denominator explicitly when the fit used weights (Round 3 P2b).""" + from diff_diff.survey import SurveyDesign + panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=row_w) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=row_w) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) s = r.summary() assert "Weighted D" in s # "Weighted D̄ (denominator):" header def test_effective_dose_mean_equals_dose_mean_under_uniform_weights(self): """Uniform weights → effective_dose_mean ≡ dose_mean at 1e-14.""" + from diff_diff.survey import SurveyDesign + panel, _, _, _, _, _ = self._panel_with_unit_weights(G=200) + panel_w = panel.assign(w=np.ones(panel.shape[0])) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - r = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - weights=np.ones(panel.shape[0]), - ) - assert r.effective_dose_mean is not None + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r = est.fit( + panel_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) + assert r.effective_dose_mean is not None np.testing.assert_allclose(r.effective_dose_mean, r.dose_mean, atol=1e-14, rtol=1e-14) # ---------- P1 fix: SurveyMetadata contract for downstream consumers ---------- @@ -4453,15 +4488,23 @@ def test_survey_metadata_is_surveymetadata_instance(self): est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) - # Both entry paths produce SurveyMetadata (not dict). - r_w = est.fit(panel_with_w, "outcome", "dose", "period", "unit", weights=row_w) + # A weights-only SurveyDesign and a fuller one both produce a + # SurveyMetadata (not a dict). + r_w = est.fit( + panel_with_w, + "outcome", + "dose", + "period", + "unit", + survey_design=SurveyDesign(weights="w"), + ) r_sd = est.fit( panel_with_w, "outcome", "dose", "period", "unit", - survey=SurveyDesign(weights="w"), + survey_design=SurveyDesign(weights="w"), ) assert isinstance(r_w.survey_metadata, SurveyMetadata) assert isinstance(r_sd.survey_metadata, SurveyMetadata) @@ -4476,48 +4519,6 @@ def test_survey_metadata_is_surveymetadata_instance(self): _ = r.survey_metadata.df_survey _ = r.survey_metadata.weight_range - def test_survey_no_psu_no_strata_se_matches_weights_hc1(self): - """Under ``SurveyDesign(weights='col')`` with no strata / PSU / - FPC, the Binder-TSL variance should be (n/(n-1))-consistent with - the lprobust HC1-style weighted-robust SE from the ``weights=`` - shortcut. Regression lock that the survey path targets the right - estimator scale (bias-corrected, matching ``tau_bc``).""" - from diff_diff.survey import SurveyDesign - - panel, row_w, _, _, _, _ = self._panel_with_unit_weights(G=200) - panel_with_w = panel.assign(w=row_w) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - r_w = est.fit( - panel_with_w, - "outcome", - "dose", - "period", - "unit", - weights=row_w, - ) - r_sd = est.fit( - panel_with_w, - "outcome", - "dose", - "period", - "unit", - survey=SurveyDesign(weights="w"), - ) - # ATT matches (same weighted lprobust fit). - np.testing.assert_allclose(r_w.att, r_sd.att, atol=1e-12, rtol=1e-12) - # SEs should be in the same ballpark (sqrt((n/(n-1))) ratio — - # roughly 1.0025 at G=200). Reject >5% discrepancy which would - # indicate the survey path targets the wrong estimator scale. - ratio = r_sd.se / r_w.se - assert 0.9 < ratio < 1.15, ( - f"Survey SE / weights SE = {ratio:.4f} at G=200 is outside " - f"the n/(n-1) tolerance band [0.9, 1.15]. If the survey IF " - f"still uses the classical scale (V_Y_cl), the SE will be " - f"materially smaller than the bias-corrected SE here." - ) - # ============================================================================= # Phase 4.5 B: mass-point weighted + event-study survey + sup-t bootstrap @@ -4615,7 +4616,7 @@ def test_weights_length_mismatch_rejected(self): _fit_mass_point_2sls(d, dy, 0.3, None, "hc1", weights=w) def test_fit_mass_point_survey_variance_formula(self): - """`fit(design='mass_point', survey=...)` sets + """`fit(design='mass_point', survey_design=...)` sets variance_formula='survey_binder_tsl_2sls' and populates survey_metadata with the full SurveyMetadata dataclass.""" from diff_diff.survey import SurveyDesign @@ -4629,39 +4630,12 @@ def test_fit_mass_point_survey_variance_formula(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="hc1") - r = est.fit(panel, "outcome", "dose", "period", "unit", survey=sd) + r = est.fit(panel, "outcome", "dose", "period", "unit", survey_design=sd) assert r.variance_formula == "survey_binder_tsl_2sls" assert r.survey_metadata is not None assert r.survey_metadata.weight_type == "pweight" assert r.effective_dose_mean is not None - def test_fit_mass_point_weights_shortcut_variance_formula(self): - """`fit(design='mass_point', weights=...)` shortcut sets - variance_formula='pweight_2sls' and clears survey-only metadata.""" - d, dy = self._dgp_mp(300, seed=7) - panel = self._make_panel(d, dy) - # Per-unit weights (constant within unit, as HAD requires). - rng = np.random.default_rng(0) - w_per_unit = rng.uniform(0.5, 2.0, d.shape[0]) - w = panel["unit"].map(lambda g: w_per_unit[g]).to_numpy() - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - est = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="hc1") - r = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - weights=w, - ) - assert r.variance_formula == "pweight_2sls" - assert r.survey_metadata is not None - # Weights= shortcut clears survey-only fields (inference is Normal). - assert r.survey_metadata.n_psu is None - assert r.survey_metadata.n_strata is None - assert r.survey_metadata.df_survey is None - def test_mass_point_non_pweight_rejected(self): """Non-pweight SurveyDesigns rejected at fit() with a clear NotImplementedError — mirrors static continuous path.""" @@ -4675,7 +4649,7 @@ def test_mass_point_non_pweight_rejected(self): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD(design="mass_point") with pytest.raises(NotImplementedError, match="aweight"): - est.fit(panel, "outcome", "dose", "period", "unit", survey=sd) + est.fit(panel, "outcome", "dose", "period", "unit", survey_design=sd) class TestSupTReducesToNormalAtH1: @@ -4892,61 +4866,6 @@ def test_continuous_clustered_band_end_to_end(self): assert np.all(r.cband_low <= r.conf_int_low + 1e-9) assert np.all(r.cband_high >= r.conf_int_high - 1e-9) - def test_masspoint_weighted_cluster_cband_no_longer_raises(self): - """Symmetry: weighted mass-point + cluster= + cband=True used to raise - NotImplementedError; the clustered band now reconciles it.""" - rng = np.random.default_rng(4) - G, d_lower = 240, 0.5 - mass_n = G // 3 - d = np.concatenate([np.full(mass_n, d_lower), rng.uniform(d_lower, 1.0, G - mass_n)]) - rng.shuffle(d) - state = np.arange(G) % 24 - panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=5, extra_cols={"state": state}) - w_unit = rng.uniform(0.5, 2.0, G) - w_row = w_unit[panel["unit"].to_numpy()] - r = HeterogeneousAdoptionDiD( - design="mass_point", cluster="state", d_lower=d_lower, n_bootstrap=1500, seed=9 - ).fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=w_row, - cband=True, - ) - assert r.vcov_type == "cr1" and r.cluster_name == "state" - assert r.cband_low is not None and np.all(np.isfinite(r.cband_low)) - assert r.cband_method == "cluster_multiplier_bootstrap" - - def test_weighted_continuous_clustered_band_end_to_end(self): - """Weighted continuous event-study + cluster= + cband: finite - cluster-robust band (the weighted arm of the continuous path).""" - rng = np.random.default_rng(6) - G = 240 - d = np.where(rng.random(G) < 0.15, 0.0, rng.uniform(0.2, 1.2, size=G)) - d[0] = 0.0 - state = np.repeat(np.arange(24), G // 24) - panel = _make_multi_period_panel(d, n_periods=5, F=3, seed=6, extra_cols={"state": state}) - w_unit = rng.uniform(0.5, 2.0, G) - w_row = w_unit[panel["unit"].to_numpy()] - r = HeterogeneousAdoptionDiD( - design="continuous_at_zero", cluster="state", n_bootstrap=1500, seed=13 - ).fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=w_row, - cband=True, - ) - assert r.vcov_type == "cr1" and r.cluster_name == "state" - assert r.cband_low is not None and np.all(np.isfinite(r.cband_low)) - assert r.cband_method == "cluster_multiplier_bootstrap" - def test_unweighted_masspoint_clustered_band_end_to_end(self): """Unweighted mass-point event-study + cluster= + cband: finite cluster-robust band (the unweighted arm of the mass-point path).""" @@ -4969,7 +4888,7 @@ def test_cluster_survey_event_study_raises(self): panel = self._clustered_panel(seed=3) panel["w"] = 1.0 - with pytest.raises(NotImplementedError, match=r"cluster.*\+ survey="): + with pytest.raises(NotImplementedError, match=r"cluster.*\+ survey_design="): HeterogeneousAdoptionDiD(design="continuous_at_zero", cluster="state").fit( panel, "outcome", @@ -5071,43 +4990,51 @@ def test_weighted_es_cband_false_skips_bootstrap(self): """`cband=False` under weighted event-study: no bootstrap, cband_* fields are None; att/se bit-exact to unweighted at uniform weights.""" - panel = self._multi_period_panel(G=200, seed=3) + from diff_diff.survey import SurveyDesign + + panel = self._multi_period_panel(G=200, seed=3).assign(w=1.0) est = HeterogeneousAdoptionDiD(design="continuous_at_zero", seed=0) - r = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=np.ones(panel.shape[0]), - cband=False, - ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r = est.fit( + panel, + "outcome", + "dose", + "period", + "unit", + aggregate="event_study", + survey_design=SurveyDesign(weights="w"), + cband=False, + ) assert r.cband_low is None assert r.cband_high is None assert r.cband_crit_value is None - # variance_formula IS set (pweight shortcut active). - assert r.variance_formula == "pweight" + # variance_formula IS set (survey Binder-TSL path active). + assert r.variance_formula == "survey_binder_tsl" def test_weighted_es_cband_true_populates_band(self): """Weighted event-study + cband=True populates cband_* fields, with cband_crit_value in a plausible range.""" - panel = self._multi_period_panel(G=200, seed=5) + from diff_diff.survey import SurveyDesign + + panel = self._multi_period_panel(G=200, seed=5).assign(w=1.0) est = HeterogeneousAdoptionDiD( design="continuous_at_zero", seed=42, n_bootstrap=500, ) - r = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=np.ones(panel.shape[0]), - cband=True, - ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + r = est.fit( + panel, + "outcome", + "dose", + "period", + "unit", + aggregate="event_study", + survey_design=SurveyDesign(weights="w"), + cband=True, + ) assert r.cband_low is not None and r.cband_high is not None assert r.cband_crit_value is not None and np.isfinite(r.cband_crit_value) assert r.cband_method == "multiplier_bootstrap" @@ -5140,6 +5067,8 @@ def test_event_study_filter_info_stable_across_weight_patterns(self): rows.append((g, t, dose, y, ft)) panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "first_treat"]) + from diff_diff.survey import SurveyDesign + est = HeterogeneousAdoptionDiD(design="continuous_at_zero") with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) @@ -5153,37 +5082,39 @@ def test_event_study_filter_info_stable_across_weight_patterns(self): first_treat_col="first_treat", ) r_uni = est.fit( - panel, + panel.assign(w=1.0), "outcome", "dose", "period", "unit", aggregate="event_study", first_treat_col="first_treat", - weights=np.ones(panel.shape[0]), + survey_design=SurveyDesign(weights="w"), cband=False, ) - # Informative per-row weights (constant within unit). + # Informative per-unit weights (constant within unit). w_unit = 1.0 + 0.5 * rng.standard_normal(G) w_unit = np.clip(w_unit, 0.1, None) w_row = panel["unit"].map(lambda g: w_unit[g]).to_numpy() r_inf = est.fit( - panel, + panel.assign(w=w_row), "outcome", "dose", "period", "unit", aggregate="event_study", first_treat_col="first_treat", - weights=w_row, + survey_design=SurveyDesign(weights="w"), cband=False, ) # filter_info must agree across all three fits (same dropped cohorts). assert r_unw.filter_info == r_uni.filter_info == r_inf.filter_info def test_event_study_mass_point_weighted_smoke(self): - """Mass-point + weighted event-study smoke: variance_formula = - 'pweight_2sls' and cband populated.""" + """Mass-point + weighted (survey) event-study smoke: + variance_formula = 'survey_binder_tsl_2sls' and cband populated.""" + from diff_diff.survey import SurveyDesign + rng = np.random.default_rng(10) G = 200 T = 4 @@ -5195,7 +5126,7 @@ def test_event_study_mass_point_weighted_smoke(self): dose = d_mp[g] if t == T - 1 else 0.0 y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() rows.append((g, t, dose, y)) - panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]) + panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]).assign(w=1.0) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD( @@ -5211,10 +5142,10 @@ def test_event_study_mass_point_weighted_smoke(self): "period", "unit", aggregate="event_study", - weights=np.ones(panel.shape[0]), + survey_design=SurveyDesign(weights="w"), ) assert r.design == "mass_point" - assert r.variance_formula == "pweight_2sls" + assert r.variance_formula == "survey_binder_tsl_2sls" assert r.cband_crit_value is not None and np.isfinite(r.cband_crit_value) def test_zero_se_horizon_nan_gates_cband(self): @@ -5245,65 +5176,14 @@ def test_zero_se_horizon_nan_gates_cband(self): assert np.isnan(low[1]) and np.isnan(high[1]) assert np.isnan(low[2]) and np.isnan(high[2]) - def test_weights_nonrange_index_aligned_positionally(self): - """Review R1 P1: ``weights=`` is row-order aligned, not - index-label aligned. A DataFrame with a custom non-RangeIndex - (here shifted int labels) must produce the same fit as the - same data with a RangeIndex + the same row-order weights.""" - rng = np.random.default_rng(3) - G, T = 150, 3 - d_post = rng.uniform(0.0, 1.0, G) - rows = [] - for t in range(T): - for g in range(G): - dose = d_post[g] if t == T - 1 else 0.0 - y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() - rows.append((g, t, dose, y)) - panel_range = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]) - # Row-order-aligned unit-constant weights. - w_unit = 1.0 + 0.3 * rng.standard_normal(G) - w_row = panel_range["unit"].map(lambda g: w_unit[g]).to_numpy() - - # Same DataFrame but with a non-positional index (offset labels - # starting at 1000; same row order). - panel_shifted = panel_range.copy() - panel_shifted.index = panel_shifted.index + 1000 - - est = HeterogeneousAdoptionDiD(design="continuous_at_zero", seed=0) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - r_range = est.fit( - panel_range, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=w_row, - cband=False, - ) - r_shifted = est.fit( - panel_shifted, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=w_row, - cband=False, - ) - np.testing.assert_allclose(r_range.att, r_shifted.att, atol=1e-12, rtol=1e-12) - np.testing.assert_allclose(r_range.se, r_shifted.se, atol=1e-12, rtol=1e-12) - def test_mass_point_survey_plus_cluster_rejected_static(self): - """Review R2 P1: mass-point + (weights= or survey=) + cluster= - must raise NotImplementedError on the static path. Previously - the weighted path silently overrode the CR1 SE with Binder-TSL - while the result still reported vcov_type='cr1'. Narrowed in - R4: only survey= + cluster= is rejected (weights= shortcut + - cluster= is the weighted-CR1 pweight sandwich, which is valid - and parity-tested). This test therefore uses survey= to - trigger the narrowed guard.""" + """Review R2 P1: mass-point + survey_design= + cluster= must raise + NotImplementedError on the static path. The survey path would + silently override the CR1 SE with Binder-TSL while the result still + reported vcov_type='cr1'; a bare cluster= (unweighted CR1) is the + supported clustering entry, and weighted clustering routes through + survey_design=SurveyDesign(weights=, psu=). This test uses + survey_design= + cluster= to trigger the guard.""" from diff_diff.survey import SurveyDesign rng = np.random.default_rng(0) @@ -5326,40 +5206,7 @@ def test_mass_point_survey_plus_cluster_rejected_static(self): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="hc1", cluster="state") with pytest.raises(NotImplementedError, match="cluster"): - est.fit(panel, "outcome", "dose", "period", "unit", survey=sd) - - def test_mass_point_weights_plus_cluster_event_study_supported(self): - """Phase 2b: mass-point + weights= + cluster= + cband (default True) - is now SUPPORTED (was rejected) — the clustered sup-t band reconciles - the CR1 variance family via the sqrt(G/(G-1)) scalar.""" - rng = np.random.default_rng(1) - G, T = 150, 4 - d_mp = np.concatenate([np.full(30, 0.3), rng.uniform(0.3, 1.0, G - 30)]) - rng.shuffle(d_mp) - rows = [] - for t in range(T): - for g in range(G): - dose = d_mp[g] if t == T - 1 else 0.0 - y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() - rows.append((g, t, dose, y, g // 25)) - panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "state"]) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - est = HeterogeneousAdoptionDiD( - design="mass_point", vcov_type="hc1", cluster="state", seed=0, n_bootstrap=500 - ) - r = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=np.ones(panel.shape[0]), - ) - assert r.vcov_type == "cr1" and r.cluster_name == "state" - assert r.cband_low is not None and np.all(np.isfinite(r.cband_low)) - assert r.cband_method == "cluster_multiplier_bootstrap" + est.fit(panel, "outcome", "dose", "period", "unit", survey_design=sd) def test_lonely_psu_adjust_with_singletons_rejected_on_cband(self): """Review R2 P1: sup-t bootstrap rejects lonely_psu='adjust' @@ -5495,7 +5342,7 @@ def test_trivial_survey_h1_sup_t_matches_analytical(self): ) def test_mass_point_classical_survey_rejected_static(self): - """Review R3 P1: vcov_type='classical' + survey= on + """Review R3 P1: vcov_type='classical' + survey_design= on design='mass_point' rejects with a clear pointer to HC1. Previously the survey path silently overrode classical SE with Binder-TSL composed from the HC1-scale IF.""" @@ -5520,15 +5367,15 @@ def test_mass_point_classical_survey_rejected_static(self): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="classical") with pytest.raises(NotImplementedError, match="classical.*survey"): - est.fit(panel, "outcome", "dose", "period", "unit", survey=sd) + est.fit(panel, "outcome", "dose", "period", "unit", survey_design=sd) def test_mass_point_classical_event_study_with_cband_rejected(self): """Review R3 P1 (event-study arm): vcov_type='classical' is - rejected on the event-study path whenever the IF matrix gets - used (survey= composition OR weights= shortcut + cband=True). - With cband=False on the weights= shortcut the classical SE is - returned as-is — no IF consumption — so that combination is - allowed and covered by the complementary test below.""" + rejected on the mass-point event-study survey path — the survey + Binder-TSL variance is built from the IF matrix, which is + incompatible with a classical SE.""" + from diff_diff.survey import SurveyDesign + rng = np.random.default_rng(30) G, T = 150, 4 d_mp = np.concatenate([np.full(30, 0.3), rng.uniform(0.3, 1.0, G - 30)]) @@ -5539,7 +5386,7 @@ def test_mass_point_classical_event_study_with_cband_rejected(self): dose = d_mp[g] if t == T - 1 else 0.0 y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() rows.append((g, t, dose, y)) - panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]) + panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]).assign(w=1.0) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD( @@ -5553,160 +5400,10 @@ def test_mass_point_classical_event_study_with_cband_rejected(self): "period", "unit", aggregate="event_study", - weights=np.ones(panel.shape[0]), + survey_design=SurveyDesign(weights="w"), cband=True, ) - def test_mass_point_classical_event_study_cband_false_accepts(self): - """Complement to the above: cband=False with classical weighted - mass-point event-study is accepted — no IF consumption, the - per-horizon classical analytical SE is returned as-is.""" - rng = np.random.default_rng(31) - G, T = 100, 4 - d_mp = np.concatenate([np.full(20, 0.3), rng.uniform(0.3, 1.0, G - 20)]) - rng.shuffle(d_mp) - rows = [] - for t in range(T): - for g in range(G): - dose = d_mp[g] if t == T - 1 else 0.0 - y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() - rows.append((g, t, dose, y)) - panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - est = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="classical", seed=0) - r = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=np.ones(panel.shape[0]), - cband=False, - ) - assert r.variance_formula == "pweight_2sls" - assert r.cband_crit_value is None - - def test_mass_point_weights_plus_cluster_shortcut_allowed(self): - """Review R4 P1: weights= shortcut + cluster= is the weighted-CR1 - pweight sandwich (parity-tested vs estimatr::iv_robust - se_type='stata') and must NOT be rejected. Narrowed guard only - rejects survey= + cluster=, not weights= + cluster=.""" - rng = np.random.default_rng(40) - G = 300 - d = np.concatenate([np.full(60, 0.3), rng.uniform(0.3, 1.0, G - 60)]) - rng.shuffle(d) - dy = 2.0 * d + 0.3 * rng.standard_normal(G) - cluster = np.repeat(np.arange(G // 20), 20) - rng.shuffle(cluster) - panel = pd.DataFrame( - { - "unit": np.repeat(np.arange(G), 2), - "period": np.tile([1, 2], G), - "dose": np.column_stack([np.zeros(G), d]).ravel(), - "outcome": np.column_stack([np.zeros(G), dy]).ravel(), - "state": np.repeat(cluster, 2), - } - ) - w_unit = 1.0 + 0.3 * rng.standard_normal(G) - w_unit = np.clip(w_unit, 0.1, None) - w_row = panel["unit"].map(lambda g: w_unit[g]).to_numpy() - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - est = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="hc1", cluster="state") - r = est.fit(panel, "outcome", "dose", "period", "unit", weights=w_row) - assert r.vcov_type == "cr1" - assert r.cluster_name == "state" - assert r.variance_formula == "pweight_2sls" - assert np.isfinite(r.se) and r.se > 0 - - def test_mass_point_weights_plus_cluster_event_study_cband_false_allowed(self): - """Review R4 P1: event-study + weights= + cluster= + cband=False - is valid (no IF consumption; per-horizon CR1 sandwich).""" - rng = np.random.default_rng(41) - G, T = 180, 4 - d_mp = np.concatenate([np.full(36, 0.3), rng.uniform(0.3, 1.0, G - 36)]) - rng.shuffle(d_mp) - cluster_per_unit = np.repeat(np.arange(G // 15), 15) - rng.shuffle(cluster_per_unit) - rows = [] - for t in range(T): - for g in range(G): - dose = d_mp[g] if t == T - 1 else 0.0 - y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() - rows.append((g, t, dose, y, cluster_per_unit[g])) - panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "state"]) - w_unit = 1.0 + 0.3 * rng.standard_normal(G) - w_unit = np.clip(w_unit, 0.1, None) - w_row = panel["unit"].map(lambda g: w_unit[g]).to_numpy() - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - est = HeterogeneousAdoptionDiD( - design="mass_point", - vcov_type="hc1", - cluster="state", - seed=0, - ) - r = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=w_row, - cband=False, - ) - assert r.vcov_type == "cr1" - assert r.cluster_name == "state" - assert r.variance_formula == "pweight_2sls" - assert r.cband_crit_value is None - - def test_mass_point_weights_plus_cluster_event_study_cband_true_supported(self): - """Phase 2b: event-study + weights= + cluster= + cband=True is now - SUPPORTED (previously rejected). The clustered bootstrap draws - cluster-level multipliers on the per-unit IF and normalizes by the - CR1 analytical SE (variance families reconciled via sqrt(G/(G-1))).""" - rng = np.random.default_rng(42) - G, T = 180, 4 - d_mp = np.concatenate([np.full(36, 0.3), rng.uniform(0.3, 1.0, G - 36)]) - rng.shuffle(d_mp) - cluster_per_unit = np.repeat(np.arange(G // 15), 15) - rng.shuffle(cluster_per_unit) - rows = [] - for t in range(T): - for g in range(G): - dose = d_mp[g] if t == T - 1 else 0.0 - y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() - rows.append((g, t, dose, y, cluster_per_unit[g])) - panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome", "state"]) - w_unit = 1.0 + 0.3 * rng.standard_normal(G) - w_unit = np.clip(w_unit, 0.1, None) - w_row = panel["unit"].map(lambda g: w_unit[g]).to_numpy() - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - est = HeterogeneousAdoptionDiD( - design="mass_point", - vcov_type="hc1", - cluster="state", - seed=0, - n_bootstrap=500, - ) - r = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=w_row, - cband=True, - ) - assert r.vcov_type == "cr1" and r.cluster_name == "state" - assert r.cband_low is not None and np.all(np.isfinite(r.cband_low)) - assert r.cband_method == "cluster_multiplier_bootstrap" - def test_event_study_zero_weight_units_excluded_from_n_units(self): """Review R4 P2: weighted event-study reports the POSITIVE-WEIGHT contributing sample size in n_units / n_obs_per_horizon (matches @@ -5721,10 +5418,13 @@ def test_event_study_zero_weight_units_excluded_from_n_units(self): dose = d_post[g] if t == T - 1 else 0.0 y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() rows.append((g, t, dose, y)) + from diff_diff.survey import SurveyDesign + panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]) w_unit = np.ones(G) w_unit[:30] = 0.0 # 30 zero-weight units; 170 contribute. w_row = panel["unit"].map(lambda g: w_unit[g]).to_numpy() + panel = panel.assign(w=w_row) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD(design="continuous_at_zero", seed=0) @@ -5735,7 +5435,7 @@ def test_event_study_zero_weight_units_excluded_from_n_units(self): "period", "unit", aggregate="event_study", - weights=w_row, + survey_design=SurveyDesign(weights="w"), cband=False, ) assert r.n_units == 170, ( @@ -5749,7 +5449,7 @@ def test_mass_point_default_vcov_survey_rejected_static(self): even when the user does NOT pass vcov_type explicitly — the default mapping (vcov_type=None, robust=False) resolves to 'classical', and that default must NOT silently slip through - on the survey= mass-point path.""" + on the survey_design= mass-point path.""" from diff_diff.survey import SurveyDesign rng = np.random.default_rng(60) @@ -5772,13 +5472,15 @@ def test_mass_point_default_vcov_survey_rejected_static(self): # Default vcov_type=None, robust=False → resolves to classical. est = HeterogeneousAdoptionDiD(design="mass_point") with pytest.raises(NotImplementedError, match="classical"): - est.fit(panel, "outcome", "dose", "period", "unit", survey=sd) + est.fit(panel, "outcome", "dose", "period", "unit", survey_design=sd) def test_mass_point_default_vcov_event_study_cband_rejected(self): """Review R5 P1 (event-study arm): default vcov_type=None + - weights= + cband=True must hit the effective-classical + survey_design= + cband=True must hit the effective-classical rejection. Previous guard only checked explicit vcov_type='classical'.""" + from diff_diff.survey import SurveyDesign + rng = np.random.default_rng(61) G, T = 150, 4 d_mp = np.concatenate([np.full(30, 0.3), rng.uniform(0.3, 1.0, G - 30)]) @@ -5789,7 +5491,7 @@ def test_mass_point_default_vcov_event_study_cband_rejected(self): dose = d_mp[g] if t == T - 1 else 0.0 y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() rows.append((g, t, dose, y)) - panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]) + panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]).assign(w=1.0) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) # Default vcov_type=None, robust=False. @@ -5802,13 +5504,13 @@ def test_mass_point_default_vcov_event_study_cband_rejected(self): "period", "unit", aggregate="event_study", - weights=np.ones(panel.shape[0]), + survey_design=SurveyDesign(weights="w"), cband=True, ) def test_survey_event_study_continuous_end_to_end(self): """Review R6 P3: estimator-level - ``fit(aggregate='event_study', survey=SurveyDesign(...))`` + ``fit(aggregate='event_study', survey_design=SurveyDesign(...))`` integration lock for the continuous path. Verifies variance_formula, survey_metadata.df_survey (t-inference path), cband_* population, and stratified PSU dispatch through @@ -5843,7 +5545,7 @@ def test_survey_event_study_continuous_end_to_end(self): "period", "unit", aggregate="event_study", - survey=sd, + survey_design=sd, ) assert r.variance_formula == "survey_binder_tsl" assert r.survey_metadata is not None @@ -5859,7 +5561,7 @@ def test_survey_event_study_continuous_end_to_end(self): def test_survey_event_study_mass_point_end_to_end(self): """Review R6 P3: estimator-level ``fit(design='mass_point', aggregate='event_study', - survey=...)`` integration lock. Verifies + survey_design=...)`` integration lock. Verifies variance_formula='survey_binder_tsl_2sls' and that the weighted 2SLS IF flows correctly through per-horizon Binder-TSL + sup-t bootstrap.""" @@ -5899,7 +5601,7 @@ def test_survey_event_study_mass_point_end_to_end(self): "period", "unit", aggregate="event_study", - survey=sd, + survey_design=sd, ) assert r.variance_formula == "survey_binder_tsl_2sls" assert r.survey_metadata is not None @@ -5908,135 +5610,9 @@ def test_survey_event_study_mass_point_end_to_end(self): assert r.cband_method == "multiplier_bootstrap" assert np.all(np.isfinite(r.se)) - def test_weights_shortcut_mass_point_h1_cband_matches_normal(self): - """Review R7 P0 (helper-level lock): at H=1 with the mass-point - HC1-scaled IF + synthetic trivial ResolvedSurveyDesign (which - matches what the weights= shortcut now routes through in - _fit_event_study), the sup-t critical value must reduce to the - Normal quantile. Previously the shortcut used the unit-level - branch of _sup_t_multiplier_bootstrap (resolved_survey=None) - which normalized against raw sum(psi²) = ((n-1)/n) · V_HC1 on - the HC1-scaled IF, producing silently too-narrow bands.""" - import scipy.stats - - from diff_diff.had import ( - _fit_mass_point_2sls, - _sup_t_multiplier_bootstrap, - ) - from diff_diff.survey import ResolvedSurveyDesign, compute_survey_if_variance - - rng = np.random.default_rng(72) - G = 500 - d = np.concatenate([np.full(100, 0.3), rng.uniform(0.3, 1.0, G - 100)]) - rng.shuffle(d) - dy = 2.0 * d + 0.3 * rng.standard_normal(G) - w = np.ones(G) - # Fit weighted 2SLS; get the HC1-scale per-unit IF. - _beta, se_analytical, psi = _fit_mass_point_2sls( - d, dy, 0.3, None, "hc1", weights=w, return_influence=True - ) - # Synthetic trivial resolved matching what _fit_event_study - # now constructs for the weights= shortcut. - trivial = ResolvedSurveyDesign( - weights=w, - weight_type="pweight", - strata=None, - psu=None, - fpc=None, - n_strata=1, - n_psu=G, - lonely_psu="remove", - combined_weights=True, - mse=False, - ) - # Sanity: bootstrap target variance matches analytical HC1. - V_analytical = compute_survey_if_variance(psi, trivial) - np.testing.assert_allclose(V_analytical, se_analytical**2, atol=1e-10, rtol=1e-10) - # H=1 sup-t with the trivial routing → Normal quantile. - q, _, _, _ = _sup_t_multiplier_bootstrap( - influence_matrix=psi.reshape(-1, 1), - att_per_horizon=np.zeros(1), - se_per_horizon=np.array([se_analytical]), - resolved_survey=trivial, - n_bootstrap=5000, - alpha=0.05, - seed=42, - ) - expected = float(scipy.stats.norm.ppf(0.975)) - # B=5000 MC noise on the tail quantile ~ 0.03-0.05; atol=0.15 - # tolerates that noise but would reject the sqrt((n-1)/n) - # under-scaling that the old unit-level branch produced - # (systematic drift toward smaller q). - assert abs(q - expected) < 0.15, ( - f"weights= shortcut-equivalent H=1 sup-t should match " - f"Phi^-1(0.975)={expected:.4f}; got q={q:.4f}. Likely " - f"sqrt(n/(n-1)) correction missing." - ) - - def test_weights_shortcut_cband_matches_trivial_survey(self): - """Review R7 P0 complement: ``weights=w`` shortcut and - ``survey=SurveyDesign(weights='w')`` must target the same - variance family, so their sup-t critical values should agree - up to small per-horizon SE convergence (bc_fit.se_robust on - the shortcut vs sqrt(compute_survey_if_variance) on survey=, - which match at atol=1e-10 per PR #359 but propagate into the - t-statistic ratio in the bootstrap sup).""" - from diff_diff.survey import SurveyDesign - - rng = np.random.default_rng(73) - G, T = 150, 4 - d_post = rng.uniform(0.0, 1.0, G) - rows = [] - for t in range(T): - for g in range(G): - dose = d_post[g] if t == T - 1 else 0.0 - y = 0.2 * t + (2.0 * dose if t == T - 1 else 0.0) + 0.5 * rng.standard_normal() - rows.append((g, t, dose, y)) - panel = pd.DataFrame(rows, columns=["unit", "period", "dose", "outcome"]) - w_unit = 1.0 + 0.3 * np.abs(rng.standard_normal(G)) - panel["w"] = panel["unit"].map(lambda g: w_unit[g]) - w_row = panel["w"].to_numpy() - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero", seed=42, n_bootstrap=2000) - r_weights = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - weights=w_row, - ) - r_survey = est.fit( - panel, - "outcome", - "dose", - "period", - "unit", - aggregate="event_study", - survey=SurveyDesign(weights="w"), - ) - # Under the R7 P0 fix, both paths use the same bootstrap - # target variance; the remaining quantile gap comes from the - # analytical per-horizon SE formula (bc_fit.se_robust on - # shortcut vs Binder-TSL on survey=) which propagates into - # t-stat normalization. The PR #359 IF scale invariant bounds - # that gap at ~0.1-1%, so the quantiles should agree within - # absolute tolerance ~0.05 (the old under-scaled path - # produced ~6-10% systematic drift, well outside this bound). - assert abs(r_weights.cband_crit_value - r_survey.cband_crit_value) < 0.05, ( - f"weights= shortcut q={r_weights.cband_crit_value:.4f} vs " - f"survey= q={r_survey.cband_crit_value:.4f} should agree " - f"within the Binder-TSL vs se_robust convergence tolerance " - f"(~atol=0.05). Larger drift signals the R7 P0 under-" - f"scaling regressed." - ) - def test_mass_point_default_vcov_robust_true_survey_allowed(self): """Complement: robust=True on the default path resolves to - hc1, so the survey= mass-point fit is allowed with no explicit + hc1, so the survey_design= mass-point fit is allowed with no explicit vcov_type.""" from diff_diff.survey import SurveyDesign @@ -6058,7 +5634,7 @@ def test_mass_point_default_vcov_robust_true_survey_allowed(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) est = HeterogeneousAdoptionDiD(design="mass_point", robust=True) - r = est.fit(panel, "outcome", "dose", "period", "unit", survey=sd) + r = est.fit(panel, "outcome", "dose", "period", "unit", survey_design=sd) assert r.vcov_type == "hc1" assert r.variance_formula == "survey_binder_tsl_2sls" diff --git a/tests/test_had_dual_knob_deprecation.py b/tests/test_had_dual_knob_deprecation.py index d66e65fb..4b105d8d 100644 --- a/tests/test_had_dual_knob_deprecation.py +++ b/tests/test_had_dual_knob_deprecation.py @@ -1,8 +1,12 @@ -"""Tests for HAD survey_design= consolidation + soft deprecation cycle. +"""Tests for HAD survey_design= consolidation + deprecation cycle. -Covers all 8 HAD surfaces (HAD.fit + did_had_pretest_workflow + 4 array-in -pretests + 2 data-in joint wrappers) per the consolidation plan -(`whimsical-brewing-liskov.md`). Each surface gets: +As of 3.7.0, ``HeterogeneousAdoptionDiD.fit`` no longer accepts ``survey=`` / +``weights=`` (they raise ``TypeError``; ``survey_design=`` is the sole weighting +entry and ``cband`` is keyword-only). The 7 pretest helpers (did_had_pretest_workflow ++ 4 array-in pretests + 2 data-in joint wrappers) still accept the deprecated +``survey=`` / ``weights=`` aliases pending a follow-up removal. + +Each pretest-helper surface gets: 1. survey_design= positive smoke (new kwarg accepted, finite output). 2. weights= deprecation warning (DeprecationWarning emitted; back-compat @@ -13,6 +17,9 @@ raises NotImplementedError on all paths). 5. Three-way mutex ValueError (any 2-of-3 combo). +The ``TestHADFitDeprecation`` class instead pins the fit()-surface removal +(``survey=`` / ``weights=`` → ``TypeError``; ``cband`` keyword-only). + Plus surface-spanning tests: - make_pweight_design importable from diff_diff top-level. - make_pweight_design ≡ _make_trivial_resolved (private alias). @@ -803,33 +810,32 @@ def test_survey_design_kwarg_smoke(self, two_period_panel): r = est.fit(df, "y", "d", "time", "unit", survey_design=SurveyDesign(weights="w")) assert np.isfinite(r.att) - def test_weights_emits_deprecation_warning(self, two_period_panel): + def test_weights_kwarg_removed(self, two_period_panel): + """`weights=` was removed on HAD.fit() in 3.7.0 (survey_design= only).""" df = two_period_panel n = len(df) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with pytest.warns(DeprecationWarning, match="weights=.*deprecated"): + with pytest.raises(TypeError, match="unexpected keyword argument 'weights'"): est.fit(df, "y", "d", "time", "unit", weights=np.ones(n)) - def test_survey_emits_deprecation_warning(self, two_period_panel): + def test_survey_kwarg_removed(self, two_period_panel): + """`survey=` was removed on HAD.fit() in 3.7.0 (use survey_design=).""" df = two_period_panel est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with pytest.warns(DeprecationWarning, match="survey=.*deprecated"): + with pytest.raises(TypeError, match="unexpected keyword argument 'survey'"): est.fit(df, "y", "d", "time", "unit", survey=SurveyDesign(weights="w")) - def test_three_way_mutex_design_plus_weights(self, two_period_panel): + def test_cband_is_keyword_only(self, two_period_panel): + """`cband` became keyword-only in 3.7.0 (it followed the removed + positional survey/weights slots).""" df = two_period_panel - n = len(df) est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with pytest.raises(ValueError, match="at most one of"): - est.fit( - df, - "y", - "d", - "time", - "unit", - survey_design=SurveyDesign(weights="w"), - weights=np.ones(n), - ) + with pytest.raises(TypeError): + # positional through aggregate, then a positional cband -- now + # keyword-only, so the 8th positional argument is rejected. + est.fit(df, "y", "d", "time", "unit", None, "overall", True) + r = est.fit(df, "y", "d", "time", "unit", cband=True) + assert np.isfinite(r.att) def test_fit_rejects_pre_resolved_design_overall(self, two_period_panel): """PR #376 R8 P1: HAD.fit() data-in surface must reject a @@ -866,70 +872,6 @@ def test_fit_rejects_pre_resolved_design_event_study(self, event_study_continuou survey_design=make_pweight_design(np.ones(200)), ) - def test_fit_rejects_pre_resolved_design_via_legacy_alias_overall(self, two_period_panel): - """PR #376 R8 P1: deprecated `survey=ResolvedSurveyDesign` (alias) - also raises TypeError after the alias rebinding.""" - df = two_period_panel - n = len(df) - est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - with pytest.raises(TypeError, match=r"`survey_design=` accepts a SurveyDesign"): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - est.fit( - df, - "y", - "d", - "time", - "unit", - survey=make_pweight_design(np.ones(n // 2)), - ) - - def test_fit_rejects_pre_resolved_design_via_legacy_alias_event_study( - self, event_study_continuous_panel - ): - """PR #376 R8 P1: deprecated `survey=ResolvedSurveyDesign` (alias) - on event-study path also raises TypeError.""" - df = event_study_continuous_panel - est = HeterogeneousAdoptionDiD(design="continuous_at_zero", n_bootstrap=99, seed=0) - with pytest.raises(TypeError, match=r"`survey_design=` accepts a SurveyDesign"): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - est.fit( - df, - "y", - "d", - "time", - "unit", - aggregate="event_study", - survey=make_pweight_design(np.ones(200)), - ) - - def test_legacy_positional_call_back_compat(self, two_period_panel): - """PR #376 R4 P1: pre-PR positional call shape for `survey`, - `weights`, `cband` MUST still work (the consolidation is additive, - not breaking). Tests the full positional sequence: - `fit(data, outcome, dose, time, unit, first_treat, aggregate, - survey, weights, cband)`.""" - df = two_period_panel - est = HeterogeneousAdoptionDiD(design="continuous_at_zero") - # Pre-PR positional order: ..., first_treat_col, aggregate, survey, - # weights, cband. None of these should be flagged as keyword-only. - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - r = est.fit( - df, - "y", - "d", - "time", - "unit", - None, # first_treat_col - "overall", # aggregate - SurveyDesign(weights="w"), # survey (positional) - None, # weights (positional) - True, # cband (positional) - ) - assert np.isfinite(r.att) - class TestDidHadPretestWorkflowDeprecation: def test_survey_design_kwarg_smoke(self, two_period_panel): @@ -1149,23 +1091,6 @@ def test_survey_design_kwarg_smoke(self, mass_point_panel): assert np.isfinite(r.att) assert np.isfinite(r.se) - def test_legacy_alias_parity_weights(self, mass_point_panel): - """weights=arr (deprecated) ≡ survey_design=SurveyDesign(weights='w') - produce identical point estimate on mass_point overall path. SE differs - by variance family (weights= → HC1 sandwich; survey_design= → - Binder-TSL on HC1-scale IF), so we assert att-only parity.""" - df = mass_point_panel - n = len(df) - est = HeterogeneousAdoptionDiD(design="mass_point", vcov_type="hc1") - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - warnings.simplefilter("ignore", UserWarning) - r_legacy = est.fit(df, "y", "d", "time", "unit", weights=np.ones(n)) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - r_new = est.fit(df, "y", "d", "time", "unit", survey_design=SurveyDesign(weights="w")) - np.testing.assert_allclose(r_legacy.att, r_new.att, atol=1e-10, rtol=1e-10) - class TestHADFitEventStudySurveyDesign: """PR #376 R2 P1: cover aggregate='event_study' + cband=True + survey_design=.""" @@ -1189,22 +1114,6 @@ def test_survey_design_kwarg_smoke(self, event_study_continuous_panel): assert r.cband_low is not None assert r.cband_high is not None - def test_legacy_alias_parity_survey(self, event_study_continuous_panel): - """survey=SurveyDesign(...) (deprecated) ≡ survey_design=SurveyDesign(...) - on event-study path.""" - df = event_study_continuous_panel - sd = SurveyDesign(weights="w") - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - r_legacy = HeterogeneousAdoptionDiD( - design="continuous_at_zero", n_bootstrap=99, seed=0 - ).fit(df, "y", "d", "time", "unit", aggregate="event_study", survey=sd, cband=True) - r_new = HeterogeneousAdoptionDiD(design="continuous_at_zero", n_bootstrap=99, seed=0).fit( - df, "y", "d", "time", "unit", aggregate="event_study", survey_design=sd, cband=True - ) - np.testing.assert_array_equal(r_legacy.att, r_new.att) - np.testing.assert_array_equal(r_legacy.se, r_new.se) - class TestDidHadPretestWorkflowEventStudySurveyDesign: """PR #376 R2 P1: cover did_had_pretest_workflow(aggregate='event_study', diff --git a/tests/test_had_mc.py b/tests/test_had_mc.py index 9c00a088..43febec7 100644 --- a/tests/test_had_mc.py +++ b/tests/test_had_mc.py @@ -95,6 +95,7 @@ def test_uniform_weights_recover_truth(self, ci_params): This is the baseline regression lock — if this fails, the estimator itself is broken before any weighting question.""" from diff_diff.had import HeterogeneousAdoptionDiD + from diff_diff.survey import SurveyDesign G = 500 n_reps = ci_params.bootstrap(200, min_n=25) @@ -112,7 +113,7 @@ def test_uniform_weights_recover_truth(self, ci_params): "dose", "period", "unit", - weights=panel["w"].to_numpy(), + survey_design=SurveyDesign(weights="w"), ) if np.isfinite(r_fit.att): estimates[r] = r_fit.att @@ -132,6 +133,7 @@ def test_informative_weights_recover_truth(self, ci_params): under survey weights — the estimator must be weight-aware in a statistically meaningful sense, not just plumbing-level.""" from diff_diff.had import HeterogeneousAdoptionDiD + from diff_diff.survey import SurveyDesign G = 500 n_reps = ci_params.bootstrap(200, min_n=25) @@ -149,7 +151,7 @@ def test_informative_weights_recover_truth(self, ci_params): "dose", "period", "unit", - weights=panel["w"].to_numpy(), + survey_design=SurveyDesign(weights="w"), ) if np.isfinite(r_fit.att): estimates[r] = r_fit.att @@ -167,6 +169,7 @@ def test_ci_coverage_near_nominal(self, ci_params): wide Monte Carlo error; use a loose bar (>80%) to avoid false-positive CI flakiness.""" from diff_diff.had import HeterogeneousAdoptionDiD + from diff_diff.survey import SurveyDesign G = 500 n_reps = ci_params.bootstrap(200, min_n=25) @@ -185,7 +188,7 @@ def test_ci_coverage_near_nominal(self, ci_params): "dose", "period", "unit", - weights=panel["w"].to_numpy(), + survey_design=SurveyDesign(weights="w"), ) if np.isfinite(r_fit.conf_int[0]) and np.isfinite(r_fit.conf_int[1]): n_conclusive += 1 @@ -213,6 +216,7 @@ def test_unweighted_informative_sampling_is_biased(self, ci_params): DGP is too weak to distinguish weighted from unweighted and the other tests above lack teeth too.""" from diff_diff.had import HeterogeneousAdoptionDiD + from diff_diff.survey import SurveyDesign G = 500 n_reps = ci_params.bootstrap(200, min_n=25) @@ -250,7 +254,7 @@ def test_unweighted_informative_sampling_is_biased(self, ci_params): "dose", "period", "unit", - weights=panel_w["w"].to_numpy(), + survey_design=SurveyDesign(weights="w"), ) if np.isfinite(r_unw.att): est_unweighted[i] = r_unw.att diff --git a/tests/test_had_pretests.py b/tests/test_had_pretests.py index 04379256..4a56a81c 100644 --- a/tests/test_had_pretests.py +++ b/tests/test_had_pretests.py @@ -5421,24 +5421,6 @@ def test_fit_with_survey_design_and_trends_lin_raises(self): survey_design=SurveyDesign(weights="w"), ) - def test_fit_with_weights_alias_and_trends_lin_raises(self): - from diff_diff import HeterogeneousAdoptionDiD - - df = self._panel(rng_seed=26) - with pytest.raises(NotImplementedError, match="trends_lin=True.*survey"): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - HeterogeneousAdoptionDiD().fit( - df, - outcome_col="y", - dose_col="d", - time_col="time", - unit_col="unit", - aggregate="event_study", - trends_lin=True, - weights=np.ones(len(df)), - ) - def test_fit_idempotence_with_trends_lin(self): """Repeat-fit with the same estimator + trends_lin=True yields identical numbers (per feedback_fit_does_not_mutate_config).""" diff --git a/tests/test_methodology_had.py b/tests/test_methodology_had.py index baa4ba02..42451c54 100644 --- a/tests/test_methodology_had.py +++ b/tests/test_methodology_had.py @@ -67,6 +67,7 @@ HeterogeneousAdoptionDiD, HeterogeneousAdoptionDiDEventStudyResults, HeterogeneousAdoptionDiDResults, + SurveyDesign, joint_homogeneity_test, joint_pretrends_test, qug_test, @@ -1041,7 +1042,7 @@ def test_sup_t_bootstrap_skipped_when_cband_false(self) -> None: """ rng = np.random.default_rng(_BASE_SEED_DEVIATIONS + 1) panel = self._make_event_study_panel(rng, G=200) - weights = np.ones(len(panel)) # uniform pweight (equivalent to unweighted) + panel = panel.assign(w=np.ones(len(panel))) # uniform pweight (equivalent to unweighted) est = HeterogeneousAdoptionDiD(design="auto", n_bootstrap=99, seed=42) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=UserWarning) @@ -1053,7 +1054,7 @@ def test_sup_t_bootstrap_skipped_when_cband_false(self) -> None: time_col="period", unit_col="unit", aggregate="event_study", - weights=weights, + survey_design=SurveyDesign(weights="w"), cband=False, ) assert isinstance(result, HeterogeneousAdoptionDiDEventStudyResults) @@ -1066,7 +1067,7 @@ def test_sup_t_bootstrap_skipped_when_overall_aggregate(self) -> None: """``aggregate="overall"`` never invokes sup-t bootstrap.""" rng = np.random.default_rng(_BASE_SEED_DEVIATIONS + 2) panel = _make_two_period_panel(rng, G=300, dose_dist="uniform_0_1", was_true=0.3, sigma=0.1) - weights = np.ones(len(panel)) + panel = panel.assign(w=np.ones(len(panel))) # Patch the bootstrap helper; should NOT be called on overall path. with patch("diff_diff.had._sup_t_multiplier_bootstrap") as mock_boot: est = HeterogeneousAdoptionDiD(design="auto") @@ -1080,7 +1081,7 @@ def test_sup_t_bootstrap_skipped_when_overall_aggregate(self) -> None: time_col="period", unit_col="unit", aggregate="overall", - weights=weights, + survey_design=SurveyDesign(weights="w"), cband=True, # request cband on overall — should be ignored ) assert mock_boot.call_count == 0 diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py index 7cb7d734..cf855492 100644 --- a/tests/test_practitioner.py +++ b/tests/test_practitioner.py @@ -905,20 +905,23 @@ def test_had_results_to_dict_docstring_matches_weighted_mass_point_contract(self doc = HeterogeneousAdoptionDiDResults.to_dict.__doc__ or "" for label in ( - "pweight", "survey_binder_tsl", - "pweight_2sls", "survey_binder_tsl_2sls", ): assert label in doc, ( f"HeterogeneousAdoptionDiDResults.to_dict() docstring " f"must enumerate the {label!r} variance_formula label - " - f"weighted mass-point fits populate pweight_2sls / " - f"survey_binder_tsl_2sls per had.py:3585-3629. The " - f"to_dict() docstring is a public source-of-truth " - f"surface and must match the field docstrings + " - f"llms-full.txt HAD section." + f"weighted mass-point fits populate survey_binder_tsl_2sls " + f"per had.py. The to_dict() docstring is a public " + f"source-of-truth surface and must match the field " + f"docstrings + llms-full.txt HAD section." ) + # The pweight / pweight_2sls labels were removed with the weights= + # kwarg in 3.7.0 and must not reappear. + assert "pweight" not in doc, ( + "HeterogeneousAdoptionDiDResults.to_dict() docstring must not " + "mention the removed pweight labels after the 3.7.0 consolidation." + ) # effective_dose_mean: must mention mass-point Wald-IV semantics. assert "mass_point" in doc or "mass-point" in doc, ( "HeterogeneousAdoptionDiDResults.to_dict() docstring must " @@ -954,19 +957,19 @@ def test_had_results_dataclass_docstrings_match_weighted_mass_point_contract(sel # Easier: read the class source via inspect.getsource() and check # the field-docstring blocks we care about. src = inspect.getsource(HeterogeneousAdoptionDiDResults) - # variance_formula docstring must enumerate all 4 labels. - assert "pweight_2sls" in src, ( - "HeterogeneousAdoptionDiDResults.variance_formula docstring " - "must mention `pweight_2sls` (weighted mass-point HC1/CR1 " - "label per had.py:3585-3629). Otherwise the dataclass " - "docstring contradicts llms-full.txt and the actual " - "implementation." - ) + # variance_formula docstring must enumerate the 2 Binder-TSL labels + # (the pweight / pweight_2sls labels were removed with the weights= + # kwarg in the 3.7.0 consolidation). assert "survey_binder_tsl_2sls" in src, ( "HeterogeneousAdoptionDiDResults.variance_formula docstring " "must mention `survey_binder_tsl_2sls` (weighted mass-point " "Binder-TSL label)." ) + assert "survey_binder_tsl" in src, ( + "HeterogeneousAdoptionDiDResults.variance_formula docstring " + "must mention `survey_binder_tsl` (weighted continuous " + "Binder-TSL label)." + ) # effective_dose_mean docstring must mention mass-point Wald-IV. assert "mass_point" in src or "mass-point" in src, ( "HeterogeneousAdoptionDiDResults.effective_dose_mean "