From 36de44db1964f42dd8f2a0fb02686cd9fb1c6189 Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 9 Aug 2026 09:01:55 -0400 Subject: [PATCH 1/2] feat(v4): ChangesInChanges absorbs QDiD via method= - M-015/M-143 (phase 3(c)) Last phase-3 row in the 4.0 ledger; 3.9 is now implementation-complete. `ChangesInChanges(method="qdid")` fits the estimator the standalone `QDiD` class owned; `method="cic"` (the default, encoding Athey-Imbens' p. 447 recommendation) is unchanged. Nothing moves. Unlike 3(a) and 3(b), CiC and QDiD were already one implementation: byte-identical constructors, fit bodies differing only in the literal handed to the shared module-level `_fit_distributional`, one results container, and `QDiDResults` already an alias. So this is an API merge with no engine relocation, no mixin and no stacklevel threading. ONLY THE CLASS SPELLING IS DEPRECATED, NOT THE ESTIMATOR. `method="qdid"` is a supported mode and emits no advisory warning: the fit-time footnote-21 non-monotonicity UserWarning and practitioner.py's "Prefer ChangesInChanges over QDiD" step already carry the paper's caution where a user can act on it, and a third warning would blur which of the class/method is dying. Validation needs BOTH halves, which is easy to get wrong. `__init__` builds its validation dict literally rather than forwarding `get_params()`, so `method` is added there AND read as `params.get("method", "cic")` inside `_validate_all_params`. The `.get` default is not defensive padding: QDiD keeps its frozen five-param `__init__`, so its `get_params()` has no "method" key and `_fit_distributional` re-validates through that same function on every QDiD fit - a bare `params["method"]` would KeyError there. With only the `.get` default and no dict entry, `ChangesInChanges(method="bogus")` and the set_params probe re-init would both succeed silently. `_validate_method` checks isinstance BEFORE membership, matching the module's sibling validators: a bare membership test lets `np.array(["cic"])` through (elementwise compare, 1-element bool) and stores the ARRAY as the method tag, which is unhashable and breaks the `_ESTIMATOR_TITLES` lookup in `summary()`. `_fit_distributional`'s `estimator_name` now derives from `type(est).__name__` instead of the `kind` literal, so a `method="qdid"` fit no longer raises covariate errors naming a class the caller never constructed. Byte-identical on both 3.x surfaces. Results field renamed `estimator` -> `method` [M-143], the section-8 rule-9 mirror: the field already held exactly the tag the new param sets, and `WooldridgeDiDResults.method` is the in-repo template. It also resolves a standing collision - `AggregationResult.estimator` holds a CLASS NAME while this one holds a method tag. Shim is `deprecated_field_property` (read-only by design, so a stale `setattr` fails loudly rather than writing a shadow attribute the renamed field cannot see) plus a `__setstate__` key migration; `to_dict()` emits BOTH keys through 3.9 per the M-094 twin, so `test_to_dict_keys` stays green untouched and the old key drops at 4.0. The window's SCOPE is a recorded decision (REGISTRY note, cross-linked from M-143): it covers the READ path and pickle migration, not construction, so `ChangesInChangesResults(..., estimator=...)` raises `TypeError` from 3.9 rather than warning until 4.0. That matches both prior results-field renames - M-094 (`treatment_col` -> `takeup`) and M-114 (`groups` -> `units`) - neither of which preserved a deprecated constructor keyword; the container has one library call site, and a constructor shim would add a deprecated surface with no ledger row. Pinned by a test so the documented contract cannot drift either way. Adding that row arms two guard duties, the first non-obvious: `test_predicate_binds_to_ledger_tokens` forces every live field-rename token into `_PATTERN_TOKENS`, which turns on Duty A repo-wide. Hence four SURFACE_ALLOWLIST entries for independent same-named surfaces (AggregationResult.estimator; the estimator= INSTANCE param on the three power entry points) and eight CONSUMER_ALLOWLIST entries, both reconciled against live sweeps rather than a written table. `estimator` also joins _AMBIGUOUS_TOKENS so Duty C matches through the attr/quoted lanes only. practitioner.py stops handing readers deprecated code: `_cic_fit_snippet` drops `est_name` for a keyword-only `method=` and always emits `ChangesInChanges`. Import lines are per-site, not a blanket rewrite - Step-8's imports TWO names because `_did_anchor_snippet` appends a `DifferenceInDifferences()` call with no import of its own, so collapsing it to one name would emit a snippet that NameErrors. The field reads use a `__dict__` fallback rather than `getattr` for the old name: on a real results object that name is now a deprecation property, so reading it warns even when it returns the right value, which collides with the warning-as-error gates. Tests: tests/test_v4_merge_cic.py, seven gates (parity, committed pre-merge oracle, validation, deprecation, field shim, engine guards, consumers). Parity is bit-exact - same engine, same process, so a needed tolerance IS the finding. The oracle is captured from the UNMODIFIED tree by tests/_capture_v4_merge_cic_oracles.py; it covers the unconditional arm only (the covariate QR path is tie-selection-bounded with BLAS-dependent tie flips and CI spans four platforms) and deliberately omits quantile_effects, which benchmarks/data/qte_golden.json already pins against R qte 1.3.1 at atol=1e-10 - a stronger cross-tree pin, and the reason 3(b)'s oracle doctrine does not transfer wholesale. The consumer gate EXECS the emitted snippet rather than compiling it, since a missing import is invisible to compile(). Also records, in docs/dev-status.md, that local `mypy` can fail for an environment reason unrelated to any code defect: lint.yml pins numpy==2.4.5 but that pin lives only in the workflow, so a drifted local numpy (2.5.1) makes mypy abort inside numpy's own stubs and stop checking entirely. Verified: 63/63 new gates; naming guard 20/20; matrix/base-estimator/ inference-policy/serialization 718; CiC family + drift + carousel 289; docs gates 373; notebook executes under the diffdiff-dev kernel with a 5-line diff; Sphinx -W build succeeded with zero warnings; ruff, black, and mypy clean at the CI-pinned toolchain. The regenerated ChangesInChangesResults autosummary stub is committed because this change genuinely altered the API shape (`estimator` is now a property, not a dataclass field). The two unrelated stubs the build also rewrote were reverted - that staleness predates this branch. --- CHANGELOG.md | 30 + README.md | 2 +- diff_diff/changes_in_changes.py | 123 +++- diff_diff/changes_in_changes_results.py | 38 +- diff_diff/guides/llms-full.txt | 9 +- diff_diff/guides/llms.txt | 2 +- diff_diff/practitioner.py | 71 +- ...hanges_results.ChangesInChangesResults.rst | 3 +- docs/api/changes_in_changes.rst | 18 +- docs/choosing_estimator.rst | 4 +- docs/dev-status.md | 27 + docs/doc-deps.yaml | 2 +- docs/index.rst | 2 +- docs/methodology/REGISTRY.md | 6 + .../papers/athey-imbens-2006-review.md | 4 +- .../27_cic_distributional_effects.ipynb | 10 +- docs/v4-deprecations.yaml | 25 +- docs/v4-design.md | 9 +- pyproject.toml | 5 + tests/_capture_v4_merge_cic_oracles.py | 128 ++++ tests/test_base_estimator.py | 4 +- tests/test_naming_guard.py | 61 ++ tests/test_practitioner.py | 16 +- ...st_t27_cic_distributional_effects_drift.py | 16 +- tests/test_v4_matrix.py | 5 +- tests/test_v4_merge_cic.py | 649 ++++++++++++++++++ 26 files changed, 1175 insertions(+), 94 deletions(-) create mode 100644 tests/_capture_v4_merge_cic_oracles.py create mode 100644 tests/test_v4_merge_cic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b162041c..7fa947cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **ChangesInChanges serves both 2x2 distributional estimators** (v4 program + Phase 3(c); ledger rows [M-015] shimmed, [M-143]): + `ChangesInChanges(method="qdid")` fits the quantile-DiD comparison estimator + that the standalone `QDiD` class used to own; `method="cic"` (the default, + encoding Athey & Imbens' p. 447 recommendation) is unchanged. `method=` is + keyword-only, so every existing positional slot is preserved, and accepts + lowercase `"cic"`/`"qdid"` only. + - The estimation core is UNCHANGED - both classes always shared one + dispatcher, one bootstrap path and one results container, so the numbers + are identical by construction (pinned bit-exactly, plus literals captured + from the pre-merge tree). + - **Only the CLASS spelling is deprecated, not the estimator.** + `method="qdid"` is fully supported and emits no advisory warning: the + existing footnote-21 non-monotonicity warning and the practitioner + guidance already carry the paper's recommendation. + - Errors raised during a `method="qdid"` fit now name `ChangesInChanges` + rather than a class the caller never constructed. + +### Changed +- `ChangesInChangesResults.estimator` is renamed to `.method` (ledger row + [M-143]), matching the new constructor parameter it echoes. The old name + still reads through a deprecation property, `to_dict()` emits BOTH keys for + the 3.9 window, and old pickles migrate on load; the deprecated name and the + duplicate key are removed in 4.0. `repr()` now prints `method=`. + +### Deprecated +- **`QDiD` is deprecated** and will be removed in 4.0 (ledger row [M-015]); + use `ChangesInChanges(method="qdid")`. The `QDiDResults` alias is deprecated + with it ([M-061]). Constructing `QDiD` warns; the numbers are unchanged. + - **TripleDifference serves both DDD designs** (v4 program Phase 3(b); ledger rows [M-013] shimmed, [M-064]): `TripleDifference().fit(..., unit=, time=, first_treat=, partition=)` estimates the staggered-adoption DDD design that diff --git a/README.md b/README.md index 6af027de..e3c61df5 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`. - [StaggeredTripleDifference](https://diff-diff.readthedocs.io/en/stable/api/staggered.html#staggeredtripledifference) - Ortiz-Villavicencio & Sant'Anna (2025) staggered DDD with group-time ATT (deprecated 3.9 - use `TripleDifference` with `first_treat=`) - [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html) - Wooldridge (2023, 2025) ETWFE: saturated OLS, logit/Poisson QMLE (ASF-based ATT). Alias `ETWFE`. - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment -- [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator; bootstrap inference; R qte parity. Alias `CiC` +- [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator via `method="qdid"`; bootstrap inference; R qte parity. Alias `CiC` - [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings ## Diagnostics & Sensitivity diff --git a/diff_diff/changes_in_changes.py b/diff_diff/changes_in_changes.py index 6345c2c8..1fc9a98f 100644 --- a/diff_diff/changes_in_changes.py +++ b/diff_diff/changes_in_changes.py @@ -93,6 +93,25 @@ ] ) +# The two estimators ChangesInChanges dispatches over (row M-015). Lowercase only - +# no "CiC"/"QDiD" casing - matching the library-wide rule that a uniform name never +# carries an altered spelling. The value is the ``kind`` handed to _fit_distributional +# and is echoed back on the results container as ``method``. +_VALID_METHODS = ("cic", "qdid") + +# Row M-015: the QDiD CLASS is deprecated, the QDiD METHOD is not. Emitted once per +# construction from QDiD.__init__ (set_params re-emits via BaseEstimator's transactional +# probe re-init - the documented side effect MultiPeriodDiD's and SDDD's shims also have). +# Pinned verbatim by tests/test_v4_merge_cic.py and the targeted pytest filter in +# pyproject.toml. REMOVE WITH THE CLASS at 4.0. +_QDID_DEPRECATION_MSG = ( + "QDiD is deprecated and will be removed in 4.0; use " + "ChangesInChanges(method='qdid') instead - the same engine, so the numbers " + "are unchanged. Only the class spelling is deprecated: method='qdid' is a " + "fully supported comparison mode and emits no warning of its own. The " + "QDiDResults alias is deprecated with it." +) + # Covariate-path quantile-regression tau grid: qte hardcodes ``seq(0.01, 0.99, 0.01)`` # inside compute.CiC/compute.QDiD (99 taus, not user-configurable). Pinned to R's EXACT # seq() doubles for the same reason as _DEFAULT_QUANTILES: natural numpy constructions @@ -827,7 +846,12 @@ def _fit_distributional( quantiles = np.sort( np.asarray(_DEFAULT_QUANTILES if est.quantiles is None else est.quantiles, dtype=float) ) - estimator_name = "ChangesInChanges" if kind == "cic" else "QDiD" + # Derived from the INSTANCE, not from ``kind``: with method= on the merged class, + # a kind-based map would name "QDiD" in errors raised by a fit the user made + # through ChangesInChanges(method="qdid") - a class they never constructed. This + # is byte-identical to the old mapping on both 3.x surfaces (QDiD -> "QDiD", + # ChangesInChanges -> "ChangesInChanges"). + estimator_name = type(est).__name__ # ---- column resolution ------------------------------------------------- if formula is not None: @@ -1108,7 +1132,7 @@ def _fit_distributional( n_bootstrap=est.n_bootstrap, n_bootstrap_valid=n_valid, panel=est.panel, - estimator=kind, + method=kind, quantiles=quantiles, alpha=est.alpha, covariates=list(covariates) if covariates is not None else None, @@ -1152,6 +1176,18 @@ def _validate_seed(seed: Any) -> None: raise ValueError(f"seed must be None or a non-negative integer, got '{seed}'") +def _validate_method(method: Any) -> None: + # isinstance FIRST, matching _validate_panel/_validate_seed. A bare + # membership test is not enough: `np.array(["cic"]) in _VALID_METHODS` + # compares elementwise and bool()s a 1-element result to True, so the array + # would be stored as self.method - an unhashable tag that breaks the + # _ESTIMATOR_TITLES lookup in summary() and serializes as an array. (A + # multi-element array raises the ambiguous-truth ValueError instead, which + # is a confusing message for a simple type error.) + if not isinstance(method, str) or method not in _VALID_METHODS: + raise ValueError(f"method must be 'cic' or 'qdid', got '{method}'") + + def _validate_all_params(params: Dict[str, Any]) -> None: """Validate the full hyperparameter dict (used by __init__, set_params, and fit).""" _validate_quantiles(params["quantiles"]) @@ -1159,6 +1195,11 @@ def _validate_all_params(params: Dict[str, Any]) -> None: _validate_alpha(params["alpha"]) _validate_panel(params["panel"]) _validate_seed(params["seed"]) + # ``.get``, not ``[...]``: QDiD keeps its frozen five-param __init__ (row + # M-015 deprecates the class, not the method), so its get_params() carries + # no "method" key and _fit_distributional re-validates through this same + # function on every QDiD fit. A bare params["method"] would KeyError there. + _validate_method(params.get("method", "cic")) class ChangesInChanges(BaseEstimator): @@ -1193,26 +1234,42 @@ class ChangesInChanges(BaseEstimator): marginal cell distributions either way. seed : int, optional Seed for the bootstrap RNG (``numpy.random.default_rng``). + method : str, default="cic" + Which 2x2 distributional estimator to fit. ``"cic"`` is + Changes-in-Changes (Athey & Imbens 2006); ``"qdid"`` is the + quantile-DiD comparison estimator, matching ``qte::QDiD()``. The + default encodes Athey & Imbens' recommendation of CiC over QDiD + (2006, p. 447), but ``"qdid"`` is a fully supported mode and selecting + it emits no warning. Every other constructor parameter, the ``fit`` + signature, the bootstrap machinery and the results container are + shared; the value is echoed back as ``results.method``. Notes ----- - Quantile effects are point-identified only on the eq. (17) interior range - ``(q_lower, q_upper)``; effects outside it keep their point estimates (qte - parity) but report NaN inference with a warning. This guard applies to - unconditional (no-covariate) fits only: with covariates the eq. (17) - bounds are not the relevant objects (``q_lower``/``q_upper`` are NaN) and - a conditional support diagnostic replaces the unconditional one. + **CiC only (``method="cic"``).** Quantile effects are point-identified + only on the eq. (17) interior range ``(q_lower, q_upper)``; effects + outside it keep their point estimates (qte parity) but report NaN + inference with a warning. This guard applies to unconditional + (no-covariate) fits only: with covariates the eq. (17) bounds are not the + relevant objects (``q_lower``/``q_upper`` are NaN) and a conditional + support diagnostic replaces the unconditional one. QDiD has no eq. (17) + analogue, so ``q_lower``/``q_upper`` are NaN for every ``method="qdid"`` + fit; its own diagnostic is a non-monotonicity warning on the implied + counterfactual quantile function (footnote 21), unconditional fits only. Covariates (``covariates=`` at fit time, or trailing formula terms) port qte's ``xformla`` branch exactly: linear quantile regressions of the - outcome on the covariates within the control pre- and post-period cells on - qte's fixed internal 0.01-0.99 tau grid (99 points, not user-configurable), - conditional-rank imputation per treated pre-period observation, and the - same bootstrap schemes with every quantile regression refit inside each - replicate. Covariates must be numeric (dummy-encode categoricals). Runtime - note: a covariate fit solves roughly ``2 x 99 x (1 + n_bootstrap)`` small - linear programs (~40k at the default ``n_bootstrap=200``) - typically tens - of seconds at moderate cell sizes, the same cost profile as ``qte::CiC``. + outcome on the covariates, on qte's fixed internal 0.01-0.99 tau grid (99 + points, not user-configurable), conditional-rank imputation per treated + pre-period observation, and the same bootstrap schemes with every quantile + regression refit inside each replicate. The cells regressed differ by + method: CiC fits the two CONTROL cells, QDiD fits three (both control + cells plus treated-pre, with the type-7/type-1 quantile asymmetry qte + itself carries). Covariates must be numeric (dummy-encode categoricals). + Runtime note: a covariate fit solves roughly ``k x 99 x (1 + n_bootstrap)`` + small linear programs for ``k`` in {2, 3} (~40-60k at the default + ``n_bootstrap=200``) - typically tens of seconds at moderate cell sizes, + the same cost profile as ``qte::CiC`` / ``qte::QDiD``. Additive random group-time shocks (random effects at the group x period level) BIAS the CiC estimator - unlike linear DiD, where they only @@ -1233,9 +1290,16 @@ def __init__( alpha: float = 0.05, panel: bool = False, seed: Optional[int] = None, + *, + method: str = "cic", ): # Stored verbatim (sklearn-clone contract): quantiles=None resolves to the # default grid at fit time, the raw None round-trips get_params(). + # + # ``method`` is in this hand-built dict, not just in _validate_all_params' + # ``.get`` default: __init__ does NOT forward get_params(), so omitting it + # here would let ChangesInChanges(method="bogus") - and the set_params probe + # re-init that reconstructs through it - succeed silently. _validate_all_params( { "quantiles": quantiles, @@ -1243,6 +1307,7 @@ def __init__( "alpha": alpha, "panel": panel, "seed": seed, + "method": method, } ) self.quantiles = quantiles @@ -1250,6 +1315,7 @@ def __init__( self.alpha = alpha self.panel = panel self.seed = seed + self.method = method self.is_fitted_ = False self.results_: Optional[ChangesInChangesResults] = None @@ -1265,7 +1331,7 @@ def fit( covariates: Optional[List[str]] = None, unit: Optional[str] = None, ) -> ChangesInChangesResults: - """Fit the CiC estimator on a 2x2 dataset. + """Fit the selected estimator (``method=``) on a 2x2 dataset. Parameters ---------- @@ -1293,13 +1359,19 @@ def fit( (documented) when ``panel=False``, matching qte's ``idname``. """ return _fit_distributional( - self, data, outcome, treatment, time, formula, covariates, unit, "cic" + self, data, outcome, treatment, time, formula, covariates, unit, self.method ) class QDiD(BaseEstimator): """Quantile Difference-in-Differences comparison estimator (2x2 design). + .. deprecated:: 3.9 + Use ``ChangesInChanges(method="qdid")`` instead; this class is removed + in 4.0 (ledger row M-015). The ESTIMATOR is not deprecated - only this + class spelling. The merged surface runs the same engine, so the numbers + are unchanged, and ``method="qdid"`` emits no warning of its own. + Applies DiD quantile-by-quantile: ``qte(tau) = Q(y11, tau) - [Q(y10, tau) + Q(y01, tau) - Q(y00, tau)]`` with R type-7 (linear-interpolation) quantiles, matching ``qte::QDiD()`` (v1.3.1) exactly - including its ATT @@ -1326,9 +1398,12 @@ class QDiD(BaseEstimator): for the treated post-period quantiles, type-1 for the imputed counterfactual - and that asymmetry is ported verbatim (REGISTRY Note). - Constructor parameters, fit signature, bootstrap behavior, and the results - container are identical to :class:`ChangesInChanges` (no interior-range - guard: eq. 17 has no QDiD analogue). + Fit signature, bootstrap behavior, and the results container are identical + to :class:`ChangesInChanges` (no interior-range guard: eq. 17 has no QDiD + analogue). The constructor parameters are identical EXCEPT that the merged + class additionally carries ``method=``, which is what selects this + estimator there; this class deliberately keeps its frozen five-parameter + signature through removal, so ``QDiD(method=...)`` is not accepted. """ def __init__( @@ -1339,8 +1414,14 @@ def __init__( panel: bool = False, seed: Optional[int] = None, ): + # Row M-015. Per construction, so a caller sees it at the site that names the + # dying class. NOT added to _DEPRECATED_ALIASES in __init__.py - QDiD is a real + # class there, not an alias, and routing it through both would double-warn. + warnings.warn(_QDID_DEPRECATION_MSG, FutureWarning, stacklevel=2) # Stored verbatim (sklearn-clone contract): quantiles=None resolves to the # default grid at fit time, the raw None round-trips get_params(). + # No "method" key: this class's five-param contract is frozen through removal, + # which is why _validate_all_params reads it with .get rather than [...]. _validate_all_params( { "quantiles": quantiles, diff --git a/diff_diff/changes_in_changes_results.py b/diff_diff/changes_in_changes_results.py index 72fd7e37..300537d1 100644 --- a/diff_diff/changes_in_changes_results.py +++ b/diff_diff/changes_in_changes_results.py @@ -6,6 +6,7 @@ import numpy as np import pandas as pd +from diff_diff._deprecation import deprecated_field_property from diff_diff.results_base import BaseResults _ESTIMATOR_TITLES = { @@ -51,7 +52,7 @@ class ChangesInChangesResults(BaseResults): n_bootstrap: int n_bootstrap_valid: int panel: bool - estimator: str + method: str quantiles: np.ndarray = field(repr=False) alpha: float = 0.05 covariates: Optional[List[str]] = None @@ -114,7 +115,11 @@ def to_dict(self) -> Dict[str, Any]: "n_bootstrap": self.n_bootstrap, "n_bootstrap_valid": self.n_bootstrap_valid, "panel": self.panel, - "estimator": self.estimator, + "method": self.method, + # Dual-key 3.9 window (row M-143, the M-094/rdd.py twin): the old key + # stays until 4.0 so serialized consumers keep working. Both read the + # SAME attribute - they can never disagree. + "estimator": self.method, "alpha": self.alpha, "covariates": list(self.covariates) if self.covariates else None, "inference_method": "bootstrap" if self.n_bootstrap > 0 else "none", @@ -170,7 +175,7 @@ def _fmt(x: Any, nd: int = 4) -> str: cs = self.cell_sizes lines = [ bar, - _ESTIMATOR_TITLES.get(self.estimator, "Distributional DiD Results").center(width), + _ESTIMATOR_TITLES.get(self.method, "Distributional DiD Results").center(width), bar, f"Observations: {self.n_obs} Mode: {mode}", ( @@ -192,7 +197,7 @@ def _fmt(x: Any, nd: int = 4) -> str: ) else: lines.append("Inference: disabled (n_bootstrap=0); all inference fields are NaN") - if self.estimator == "cic" and np.isfinite(self.q_lower) and np.isfinite(self.q_upper): + if self.method == "cic" and np.isfinite(self.q_lower) and np.isfinite(self.q_upper): lines.append( f"Point-identified interior quantile range (eq. 17): " f"({self.q_lower:.4f}, {self.q_upper:.4f})" @@ -229,16 +234,37 @@ def print_summary(self) -> None: """Print :meth:`summary` to stdout.""" print(self.summary()) + # Row M-143: read-only alias for the pre-3.9 ``estimator`` field name. No + # annotation, so it stays a descriptor and never becomes a + # __dataclass_fields__ entry. Being setter-less is deliberate and load-bearing: + # ``setattr(res, "estimator", ...)`` must fail rather than silently write a + # shadow attribute the renamed field would not see. + estimator = deprecated_field_property("ChangesInChangesResults", "estimator", "method") + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Migrate pickles created before the ``estimator`` -> ``method`` rename. + + Results pickled before row M-143 stored the tag under ``estimator``; + rewriting the key on load keeps both ``method`` and the deprecated + ``estimator`` alias working on old pickles. Dataclass unpickling routes + through here for CURRENT objects too, so this must stay a pass-through + in the common case. + """ + if "estimator" in state and "method" not in state: + state = dict(state) + state["method"] = state.pop("estimator") + self.__dict__.update(state) + def __repr__(self) -> str: att_s = "nan" if np.isnan(self.att) else f"{self.att:.4f}" se_s = "nan" if np.isnan(self.se) else f"{self.se:.4f}" return ( "ChangesInChangesResults(" - f"estimator={self.estimator!r}, ATT={att_s}, SE={se_s}, " + f"method={self.method!r}, ATT={att_s}, SE={se_s}, " f"n_quantiles={len(self.quantile_effects)}, " f"panel={self.panel}, n_bootstrap={self.n_bootstrap})" ) -# QDiD shares the container; the ``estimator`` field distinguishes the two. +# QDiD shares the container; the ``method`` field distinguishes the two. QDiDResults = ChangesInChangesResults diff --git a/diff_diff/guides/llms-full.txt b/diff_diff/guides/llms-full.txt index 66321805..02789c3a 100644 --- a/diff_diff/guides/llms-full.txt +++ b/diff_diff/guides/llms-full.txt @@ -1202,6 +1202,7 @@ ChangesInChanges( alpha: float = 0.05, # Pointwise CI level (uniform bands stay fixed at 95%) panel: bool = False, # True: same units both periods (requires unit=); affects resampling only seed: int | None = None, # Bootstrap RNG seed (numpy default_rng) + method: str = 'cic', # KEYWORD-ONLY. 'cic' | 'qdid' - which 2x2 distributional estimator to fit ) ``` @@ -1235,7 +1236,7 @@ print(results.uniform_bands()) # sup-t simultaneous bands (fixed 95%) ### QDiD -Quantile Difference-in-Differences comparison estimator (Athey & Imbens 2006, Section 3.3) for the 2x2 design: `QTE(tau) = Q(y11,tau) - [Q(y10,tau) + Q(y01,tau) - Q(y00,tau)]` with R type-7 linear-interpolation quantiles, matching `qte::QDiD()` (v1.3.1) exactly - including its ATT formula (control-group quantile functions evaluated at treated pre-period own-sample ranks; population-equivalent to the paper's k^QDID transformation but a different finite-sample estimator, see the REGISTRY.md Note). The paper recommends ChangesInChanges over QDiD: QDiD's justifying model is not scale-invariant, forces identical unobservable distributions in all four cells, and places testable restrictions on the data (a warning fires when the implied counterfactual quantile function is non-monotone; unconditional fits only - the covariate-path counterfactual quantile curve is monotone by construction). QDiD's mean effect equals standard DiD's ATT in population. Constructor, fit signature (including `covariates=`), bootstrap machinery, and results container are identical to ChangesInChanges (no interior-range guard; `q_lower`/`q_upper` are NaN). Covariate fits use quantile regressions in THREE cells with own-cell conditional ranks and qte's verbatim-ported asymmetric quantile types (type-7 treated-post, type-1 imputed counterfactual). +Quantile Difference-in-Differences comparison estimator (Athey & Imbens 2006, Section 3.3) for the 2x2 design: `QTE(tau) = Q(y11,tau) - [Q(y10,tau) + Q(y01,tau) - Q(y00,tau)]` with R type-7 linear-interpolation quantiles, matching `qte::QDiD()` (v1.3.1) exactly - including its ATT formula (control-group quantile functions evaluated at treated pre-period own-sample ranks; population-equivalent to the paper's k^QDID transformation but a different finite-sample estimator, see the REGISTRY.md Note). The paper recommends ChangesInChanges over QDiD: QDiD's justifying model is not scale-invariant, forces identical unobservable distributions in all four cells, and places testable restrictions on the data (a warning fires when the implied counterfactual quantile function is non-monotone; unconditional fits only - the covariate-path counterfactual quantile curve is monotone by construction). QDiD's mean effect equals standard DiD's ATT in population. **Deprecated in 3.9, removed in 4.0 - use `ChangesInChanges(method="qdid")` instead** (same engine, identical numbers; only the class spelling is deprecated, the estimator is not). Fit signature (including `covariates=`), bootstrap machinery, and results container are identical to ChangesInChanges (no interior-range guard; `q_lower`/`q_upper` are NaN); the constructor is identical EXCEPT that the merged class additionally carries `method=`, which is what selects this estimator there. Covariate fits use quantile regressions in THREE cells with own-cell conditional ranks and qte's verbatim-ported asymmetric quantile types (type-7 treated-post, type-1 imputed counterfactual). ```python QDiD( @@ -1250,9 +1251,9 @@ QDiD( **Usage:** ```python -from diff_diff import QDiD +from diff_diff import ChangesInChanges -qdid = QDiD(n_bootstrap=200, seed=42) +qdid = ChangesInChanges(method='qdid', n_bootstrap=200, seed=42) results = qdid.fit(data, outcome='y', treatment='treated', time='post') ``` @@ -1943,7 +1944,7 @@ Per-horizon event-study results container for `HeterogeneousAdoptionDiD`'s event ### ChangesInChangesResults -Results container shared by `ChangesInChanges` and `QDiD` (the `estimator` field is `"cic"` or `"qdid"`; `QDiDResults` is an alias of this class). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int`; `quantile_effects` is a DataFrame with columns `quantile`, `qte`, `se`, `t_stat`, `p_value`, `conf_low`, `conf_high`. `q_lower`/`q_upper` bound the point-identified interior quantile range for unconditional CiC fits (NaN for QDiD and for covariate fits); `sup_t_crit` is the qte sup-t critical value backing `uniform_bands()` (fixed 95% level). Also carries `n_obs`, `cell_sizes`, `n_bootstrap`, `n_bootstrap_valid`, `panel`, `quantiles`, `alpha`, and `covariates` (the covariate columns of a conditional fit, else None). Methods: `summary()`, `print_summary()`, `to_dict()`, `to_dataframe(level="quantiles"|"att")`, `uniform_bands()`. All inference flows through `safe_inference`/`safe_inference_batch` (joint-NaN contract; `n_bootstrap=0` yields NaN inference everywhere). +Results container shared by `ChangesInChanges` and `QDiD` (the `method` field is `"cic"` or `"qdid"`; the pre-3.9 name `estimator` still reads it with a FutureWarning until 4.0; `QDiDResults` is an alias of this class). Flat-native headline fields `att`, `se`, `t_stat`, `p_value`, `conf_int`; `quantile_effects` is a DataFrame with columns `quantile`, `qte`, `se`, `t_stat`, `p_value`, `conf_low`, `conf_high`. `q_lower`/`q_upper` bound the point-identified interior quantile range for unconditional CiC fits (NaN for QDiD and for covariate fits); `sup_t_crit` is the qte sup-t critical value backing `uniform_bands()` (fixed 95% level). Also carries `n_obs`, `cell_sizes`, `n_bootstrap`, `n_bootstrap_valid`, `panel`, `quantiles`, `alpha`, and `covariates` (the covariate columns of a conditional fit, else None). Methods: `summary()`, `print_summary()`, `to_dict()`, `to_dataframe(level="quantiles"|"att")`, `uniform_bands()`. All inference flows through `safe_inference`/`safe_inference_batch` (joint-NaN contract; `n_bootstrap=0` yields NaN inference everywhere). ### TROPResults diff --git a/diff_diff/guides/llms.txt b/diff_diff/guides/llms.txt index e2d8be19..24339e0d 100644 --- a/diff_diff/guides/llms.txt +++ b/diff_diff/guides/llms.txt @@ -79,7 +79,7 @@ The site is organized into 5 sections, each with a landing page: - [WooldridgeDiD](https://diff-diff.readthedocs.io/en/stable/api/wooldridge_etwfe.html): Wooldridge (2023, 2025) ETWFE — saturated OLS, logit/Poisson QMLE (ASF-based ATT). Alias: ETWFE - [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html): Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting); variance- or equally-weighted ATT, premean differencing, pooled pre/post, fast. Absorbing by default; non-absorbing (reversible) treatment via `non_absorbing="first_entry"` (Eq. 12) or `"effect_stabilization"` (Eq. 13, window `L`). Complex-survey designs (pweight + stratified-PSU TSL SEs) on the default path via `fit(survey_design=...)`. - [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: recovers the treated group's full counterfactual outcome distribution and quantile treatment effects (ATT + QTE grid) via the CDF transformation `F_10(F_00^{-1}(F_01(y)))`; invariant to monotone outcome transformations (unconditional fits; the covariate QR branch is not); bootstrap inference (panel or repeated cross-section resampling); point parity with R `qte::CiC()`, including its covariate branch (`covariates=` -> per-cell linear quantile regression, Melly-Santangelo-style conditional CiC). Continuous outcomes, numeric covariates. Alias `CiC`. -- [QDiD](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): Athey & Imbens (2006) quantile DiD comparison estimator (additive quantile-by-quantile DiD, matching R `qte::QDiD()` including its covariate branch via `covariates=`); same bootstrap machinery as ChangesInChanges. The paper recommends CiC over QDiD (scale-dependent model with testable restrictions; a non-monotonicity warning fires when violated - unconditional fits only, the covariate-path counterfactual quantile curve is monotone by construction). +- [QDiD](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html): **Deprecated 3.9, removed 4.0 - use `ChangesInChanges(method="qdid")`.** Athey & Imbens (2006) quantile DiD comparison estimator (additive quantile-by-quantile DiD, matching R `qte::QDiD()` including its covariate branch via `covariates=`); same bootstrap machinery as ChangesInChanges. The paper recommends CiC over QDiD (scale-dependent model with testable restrictions; a non-monotonicity warning fires when violated - unconditional fits only, the covariate-path counterfactual quantile curve is monotone by construction). - [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html): Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings ## Diagnostics and Sensitivity Analysis diff --git a/diff_diff/practitioner.py b/diff_diff/practitioner.py index c55250a3..4c97a2c8 100644 --- a/diff_diff/practitioner.py +++ b/diff_diff/practitioner.py @@ -51,16 +51,34 @@ } +def _distributional_kind(results: Any) -> Any: + """Read a ChangesInChangesResults' method tag, old field name included. + + Row M-143 renamed the field ``estimator`` -> ``method``. The fallback is + NOT ``getattr(results, "estimator", None)``: on a real results object that + name is now a deprecation property, so reading it emits a FutureWarning + even when it returns the right value - which turns into an error under the + warning-as-error assertions this module's own tests use. Reading the + instance ``__dict__`` finds a duck-typed hand-built attribute without ever + touching the descriptor. Real results resolve ``method`` first, so the + fallback only ever fires for mocks built before the rename. + """ + kind = getattr(results, "method", None) + if kind is not None: + return kind + return getattr(results, "__dict__", {}).get("estimator") + + def _estimator_display(type_name: str, results: Any) -> str: """Per-instance display name. ``ChangesInChangesResults`` is shared by CiC and QDiD (``QDiDResults`` is an alias), so the static per-type map cannot distinguish them; the - ``estimator`` field ("cic"/"qdid") does. Defensive: mock results may + ``method`` field ("cic"/"qdid") does. Defensive: mock results may lack the field, in which case the static entry is the fallback. """ if type_name == "ChangesInChangesResults": - kind = getattr(results, "estimator", None) + kind = _distributional_kind(results) if kind == "cic": return "ChangesInChanges (CiC)" if kind == "qdid": @@ -1569,7 +1587,7 @@ def _cic_assumptions_step(results: Any) -> Dict[str, Any]: distributional, not mean parallel trends. Same baker_step/step_name, so ``completed_steps`` filtering is unchanged. """ - if getattr(results, "estimator", None) == "qdid": + if _distributional_kind(results) == "qdid": why = ( "Name the distributional assumptions you are invoking - not a " "mean parallel-trends variant. QDiD's justifying model " @@ -1600,13 +1618,20 @@ def _cic_assumptions_step(results: Any) -> Dict[str, Any]: def _cic_fit_snippet( - est_name: str, results: Any, var: str, data_var: str = "data", covariates: str = "same", + *, + method: Optional[str] = None, ) -> str: - """Render a CiC/QDiD constructor+fit snippet preserving the fit's design. + """Render a ChangesInChanges constructor+fit snippet preserving the fit's design. + + Always emits ``ChangesInChanges(...)``: row M-015 deprecates the QDiD + CLASS, so guidance must never hand the reader a ``QDiD(...)`` call. Pass + ``method="qdid"`` to select that estimator on the merged surface; any other + value (including the default ``None``) emits no ``method=`` argument at + all, keeping CiC snippets byte-identical to the pre-merge text. Refit snippets must mirror the original specification: ``panel=True`` changes the bootstrap resampling scheme (unit-block vs pooled rows) @@ -1627,6 +1652,9 @@ def _cic_fit_snippet( panel = bool(getattr(results, "panel", False)) covs = list(getattr(results, "covariates", None) or []) if covariates == "same" else [] ctor_args = "n_bootstrap=200, seed=42" + if method == "qdid": + # First: it is the identification choice, not an inference knob. + ctor_args = "method='qdid', " + ctor_args if panel: ctor_args += ", panel=True" extras = [] @@ -1641,7 +1669,7 @@ def _cic_fit_snippet( body += ",\n " + ", ".join(extras) + ")" else: body += ")" - snippet = f"{var} = {est_name}({ctor_args}).fit(\n" + body + snippet = f"{var} = ChangesInChanges({ctor_args}).fit(\n" + body if panel: snippet += ( "\n# panel=True + unit= mirror the original unit-block bootstrap (use your unit column)" @@ -1672,7 +1700,7 @@ def _handle_cic(results: Any): """ChangesInChanges / QDiD guidance (shared results class). CiC and QDiD share ``ChangesInChangesResults`` (``QDiDResults`` is an - alias), so this single handler branches on the ``estimator`` field + alias), so this single handler branches on the ``method`` field ("cic"/"qdid"; unknown or missing kinds fall to the CiC branch - the paper-primary, safe-voiced default) and on covariate status (truthiness, not ``is not None``: fit() normalizes ``covariates=[]`` @@ -1682,9 +1710,14 @@ def _handle_cic(results: Any): CiC-covariate-only: the QDiD covariate path has no support diagnostic (``_check_conditional_support`` is invoked on the CiC path only). """ - is_qdid = getattr(results, "estimator", None) == "qdid" + is_qdid = _distributional_kind(results) == "qdid" has_cov = bool(getattr(results, "covariates", None)) + # Display name for step LABELS (still the estimator's own name - the METHOD + # "QDiD" is not deprecated, only the class spelling is). est_name = "QDiD" if is_qdid else "ChangesInChanges" + # Constructor argument for emitted SNIPPETS: every snippet now builds + # ChangesInChanges, selecting the estimator via method= (row M-015). + _snippet_method = "qdid" if is_qdid else None if is_qdid: s3_why = ( @@ -1778,7 +1811,7 @@ def _handle_cic(results: Any): ), code=( "from diff_diff import ChangesInChanges\n" - + _cic_fit_snippet("ChangesInChanges", results, "cic_results") + + _cic_fit_snippet(results, "cic_results") + "\nprint(cic_results.summary())" ), step_name="estimator_selection", @@ -1895,10 +1928,10 @@ def _handle_cic(results: Any): ), code=( "# Requires >= 2 pre-periods in the SOURCE panel:\n" - f"from diff_diff import {est_name}\n" + "from diff_diff import ChangesInChanges\n" "pre = source_panel[source_panel['period'].isin([p0, p1])].copy()\n" "pre['post'] = (pre['period'] == p1).astype(int)\n" - + _cic_fit_snippet(est_name, results, "placebo", data_var="pre") + + _cic_fit_snippet(results, "placebo", data_var="pre", method=_snippet_method) + "\nprint(placebo.summary()) # QTE/ATT should be ~ 0" ), priority="medium", @@ -1956,9 +1989,11 @@ def _handle_cic(results: Any): label="Report with and without covariates", why=s8b_why, code=( - f"from diff_diff import {est_name}\n" + "from diff_diff import ChangesInChanges\n" "# Explicitly UNCONDITIONAL refit (covariates dropped by design):\n" - + _cic_fit_snippet(est_name, results, "results_nocov", covariates="none") + + _cic_fit_snippet( + results, "results_nocov", covariates="none", method=_snippet_method + ) + "\nprint(results.att, results_nocov.att)" ), priority="medium", @@ -1987,8 +2022,10 @@ def _handle_cic(results: Any): "at moderate cell sizes." ), code=( - f"from diff_diff import {est_name}\n" - + _cic_fit_snippet(est_name, results, "results_cov", covariates="add") + "from diff_diff import ChangesInChanges\n" + + _cic_fit_snippet( + results, "results_cov", covariates="add", method=_snippet_method + ) + "\nprint(results.att, results_cov.att) # compare ATT + QTE profiles" ), priority="medium", @@ -2076,8 +2113,8 @@ def _handle_cic(results: Any): "primary (p. 447). " + s8c_why_tail ), code=( - "from diff_diff import QDiD, DifferenceInDifferences\n" - + _cic_fit_snippet("QDiD", results, "qdid_results") + "from diff_diff import ChangesInChanges, DifferenceInDifferences\n" + + _cic_fit_snippet(results, "qdid_results", method="qdid") + "\n" + _did_anchor_snippet(results) + "\nprint(results.att, qdid_results.att, did_results.att)" diff --git a/docs/api/_autosummary/diff_diff.changes_in_changes_results.ChangesInChangesResults.rst b/docs/api/_autosummary/diff_diff.changes_in_changes_results.ChangesInChangesResults.rst index 18b4fb7c..81f1e7d0 100644 --- a/docs/api/_autosummary/diff_diff.changes_in_changes_results.ChangesInChangesResults.rst +++ b/docs/api/_autosummary/diff_diff.changes_in_changes_results.ChangesInChangesResults.rst @@ -27,6 +27,7 @@ ~ChangesInChangesResults.alpha ~ChangesInChangesResults.covariates + ~ChangesInChangesResults.estimator ~ChangesInChangesResults.is_significant ~ChangesInChangesResults.significance_stars ~ChangesInChangesResults.att @@ -43,6 +44,6 @@ ~ChangesInChangesResults.n_bootstrap ~ChangesInChangesResults.n_bootstrap_valid ~ChangesInChangesResults.panel - ~ChangesInChangesResults.estimator + ~ChangesInChangesResults.method ~ChangesInChangesResults.quantiles diff --git a/docs/api/changes_in_changes.rst b/docs/api/changes_in_changes.rst index b2070103..1d8132bd 100644 --- a/docs/api/changes_in_changes.rst +++ b/docs/api/changes_in_changes.rst @@ -92,6 +92,10 @@ QDiD Quantile difference-in-differences comparison estimator. +*Deprecated in 3.9, removed in 4.0* - use ``ChangesInChanges(method="qdid")`` +instead. The same engine runs either way, so the numbers are unchanged; only +the class spelling is deprecated, not the estimator. + .. autoclass:: diff_diff.QDiD :no-index: :members: @@ -110,8 +114,9 @@ Quantile difference-in-differences comparison estimator. ChangesInChangesResults ----------------------- -Results container shared by both estimators (the ``estimator`` field records -which produced it; ``QDiDResults`` is an alias of this class). +Results container shared by both estimators (the ``method`` field records +which produced it - the pre-3.9 name ``estimator`` still reads it with a +``FutureWarning`` until 4.0; ``QDiDResults`` is an alias of this class). .. autoclass:: diff_diff.changes_in_changes_results.ChangesInChangesResults :no-index: @@ -183,11 +188,12 @@ Panel mode (same units in both periods) changes only the bootstrap:: data, outcome="y", treatment="treated", time="post", unit="unit" ) -QDiD as a comparison estimator:: +QDiD as a comparison estimator (selected with ``method=`` on the merged +class):: - from diff_diff import QDiD + from diff_diff import ChangesInChanges - qdid = QDiD(n_bootstrap=200, seed=42) + qdid = ChangesInChanges(method="qdid", n_bootstrap=200, seed=42) results_qdid = qdid.fit(data, outcome="y", treatment="treated", time="post") Covariates (conditional CiC via per-cell quantile regression, matching qte's @@ -236,7 +242,7 @@ Comparison with related estimators - 2x2 - ATT + quantile effects - ``h(u, t)`` monotone in scalar unobservable; ``U`` time-invariant within groups - * - ``QDiD`` + * - ``QDiD`` (``ChangesInChanges(method="qdid")``) - 2x2 - ATT + quantile effects - Additive quantile model (scale-dependent, testable restrictions) diff --git a/docs/choosing_estimator.rst b/docs/choosing_estimator.rst index 56519d96..6bdf40f6 100644 --- a/docs/choosing_estimator.rst +++ b/docs/choosing_estimator.rst @@ -39,7 +39,7 @@ Start here and follow the questions: 4. **Do you have panel data?** (Multiple observations per unit over time) - **No** → Use :class:`~diff_diff.DifferenceInDifferences` (basic 2x2) - - **No, and you care about effect heterogeneity across the outcome distribution** → Use :class:`~diff_diff.ChangesInChanges` (2x2 quantile treatment effects, invariant to monotone outcome rescaling in unconditional fits; optional numeric covariates via quantile-regression conditioning - the covariate branch's linear quantile regressions are not equivariant to nonlinear monotone transforms; works with panel data too - ``panel=True`` changes only the bootstrap). :class:`~diff_diff.QDiD` is the quantile-DiD comparison estimator; Athey & Imbens (2006) recommend CiC over it + - **No, and you care about effect heterogeneity across the outcome distribution** → Use :class:`~diff_diff.ChangesInChanges` (2x2 quantile treatment effects, invariant to monotone outcome rescaling in unconditional fits; optional numeric covariates via quantile-regression conditioning - the covariate branch's linear quantile regressions are not equivariant to nonlinear monotone transforms; works with panel data too - ``panel=True`` changes only the bootstrap). :class:`~diff_diff.ChangesInChanges` with ``method="qdid"`` is the quantile-DiD comparison estimator (the standalone ``QDiD`` class is deprecated in 3.9); Athey & Imbens (2006) recommend CiC over it - **Yes** → Go to question 5 5. **Do you need period-specific effects?** (Event study design) @@ -140,7 +140,7 @@ Quick Reference - 2x2 distributional effects (which quantiles moved, not just the mean) - h(u, t) monotone in a scalar unobservable; U time-invariant within groups - ATT + quantile treatment effects (bootstrap inference) - * - ``QDiD`` + * - ``QDiD`` (deprecated 3.9; use ``ChangesInChanges(method="qdid")``) - 2x2 quantile-DiD comparison alongside ChangesInChanges - Additive quantile model (scale-dependent, testable restrictions) - ATT + quantile treatment effects (bootstrap inference) diff --git a/docs/dev-status.md b/docs/dev-status.md index 8f31340f..337b8860 100644 --- a/docs/dev-status.md +++ b/docs/dev-status.md @@ -103,6 +103,33 @@ Mixin cross-class attribute access uses `TYPE_CHECKING`-guarded attribute/method the bootstrap mixin classes; keep stubs in sync with implementations (stub drift shows up as `[misc]` unpack-arity errors). +**Local `mypy` can fail for an environment reason that is NOT a code defect** (observed +2026-08). `lint.yml` installs the runtime deps at exact pins (`numpy==2.4.5 pandas==3.0.3 +scipy==1.17.1`) precisely to freeze stub drift, but those pins live only in the workflow — +they are not in the `dev` extra, so `pip install -e ".[dev]"` does not reproduce them and a +local numpy drifts freely. With a newer numpy (2.5.1 seen locally), mypy targeting +`python_version = "3.10"` aborts inside numpy's own `__init__.pyi`: + +``` +numpy/__init__.pyi:737: error: Type statement is only supported in Python 3.12 and greater [syntax] +Found 1 error in 1 file (errors prevented further checking) +``` + +That is numpy 2.5's stubs using PEP 695 `type` statements. Note the trailing line: checking +**stops**, so this masquerades as "one error" while actually verifying nothing. CI is +unaffected (it installs the pins on a clean runner). To reproduce the real gate locally, +build a throwaway venv at the workflow's pins rather than debugging the error: + +```bash +python3 -m venv /tmp/mypyenv +/tmp/mypyenv/bin/pip install mypy==2.3.0 numpy==2.4.5 pandas==3.0.3 scipy==1.17.1 +/tmp/mypyenv/bin/mypy diff_diff +``` + +Folding the runtime pins into the `dev` extra would remove the divergence, but it would also +pin every contributor's numpy for ordinary test runs — deliberately not done; re-evaluate if +the drift starts costing more than the workaround. + ## Test Coverage Visualization tests skip when matplotlib / plotly are not installed (see diff --git a/docs/doc-deps.yaml b/docs/doc-deps.yaml index e1a9268b..e3b92457 100644 --- a/docs/doc-deps.yaml +++ b/docs/doc-deps.yaml @@ -1207,7 +1207,7 @@ sources: - path: diff_diff/guides/llms-full.txt section: "Practitioner Workflow" type: user_guide - note: "HAD handlers (_handle_had / _handle_had_event_study) emit did_had_pretest_workflow + bandwidth_diagnostics references; symmetric Step-4 routing in _handle_continuous; _handle_cic branches on ChangesInChangesResults.estimator/covariates and restates the CiC/QDiD fit-time diagnostics (interior range, envelope, footnote-21, bootstrap health)" + note: "HAD handlers (_handle_had / _handle_had_event_study) emit did_had_pretest_workflow + bandwidth_diagnostics references; symmetric Step-4 routing in _handle_continuous; _handle_cic branches on ChangesInChangesResults.method/covariates and restates the CiC/QDiD fit-time diagnostics (interior range, envelope, footnote-21, bootstrap health)" # ── Visualization (visualization group) ──────────────────────────── diff --git a/docs/index.rst b/docs/index.rst index 9294a49b..82ffa26f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -174,7 +174,7 @@ Supported Estimators * - :class:`~diff_diff.ChangesInChanges` - Athey & Imbens (2006) distributional DiD with quantile treatment effects * - :class:`~diff_diff.QDiD` - - Quantile DiD comparison estimator applying DiD quantile-by-quantile + - Quantile DiD comparison estimator applying DiD quantile-by-quantile (deprecated 3.9 - use :class:`~diff_diff.ChangesInChanges` with ``method="qdid"``) * - :class:`~diff_diff.RegressionDiscontinuity` - Calonico, Cattaneo & Titiunik (2014) sharp/fuzzy RD with robust bias-corrected inference * - :class:`~diff_diff.TROP` diff --git a/docs/methodology/REGISTRY.md b/docs/methodology/REGISTRY.md index 2dabcdbd..8475f54b 100644 --- a/docs/methodology/REGISTRY.md +++ b/docs/methodology/REGISTRY.md @@ -4637,6 +4637,10 @@ contracts, and the matplotlib ImportError path). ## ChangesInChanges (CiC) +- **Note:** The class dispatches over two 2x2 distributional estimators via the keyword-only `method=` parameter (`"cic"` default | `"qdid"`, lowercase only - `"CiC"`/`"QDiD"` casing raises). The default encodes Athey-Imbens' recommendation of CiC over QDiD (p. 447). Mode-specific behaviour, easy to misread from the shared signature: the eq. 17 interior-range guard and `q_lower`/`q_upper` are CiC-ONLY (NaN for every `method="qdid"` fit, which has no eq. 17 analogue), and the covariate branch fits quantile regressions in TWO control cells for CiC but THREE for QDiD (both control cells plus treated-pre, with qte's verbatim-ported type-7 / type-1 asymmetry). +- **Note:** The results field carrying that tag is `ChangesInChangesResults.method` (ledger row M-143). It was named `estimator` before 3.9; the old name still reads through a deprecation property until 4.0, and `to_dict()` emits BOTH keys during the 3.9 window so serialized consumers keep working. The rename follows the rule that a parameter and the results field echoing it share a name, and resolves a collision with `AggregationResult.estimator`, which holds a CLASS NAME rather than a method tag. +- **Note:** The deprecation window for that rename covers the READ path, not the CONSTRUCT path: `results.estimator` keeps working (with a `FutureWarning`) and old pickles migrate on load, but `ChangesInChangesResults(..., estimator=...)` raises `TypeError` from 3.9 rather than warning until 4.0. This is deliberate and matches both prior results-field renames - `RegressionDiscontinuityResults.treatment_col` -> `takeup` (M-094) and `ChaisemartinDHaultfoeuilleResults.groups` -> `units` (M-114) - neither of which preserved a deprecated constructor keyword. Results containers are constructed by the library (one call site) and consumed by users, so the read path is what a deprecation window protects; adding a constructor shim would introduce a deprecated surface with no ledger row and diverge from two shipped precedents. Recorded because an automated reviewer reads the immediate `TypeError` as an early removal of a 4.0-scheduled surface. + **Primary source:** Athey, S., & Imbens, G. W. (2006). Identification and Inference in Nonlinear Difference-in-Differences Models. *Econometrica*, 74(2), 431-497. https://doi.org/10.1111/j.1468-0262.2006.00668.x Full equation-level review (all equation/theorem/page pins below refer to the published version): `docs/methodology/papers/athey-imbens-2006-review.md`. Companion reviews: `melly-santangelo-2015-review.md` (covariates - the qte-style QR branch below is its simplified form; the full MS estimator remains deferred), `callaway-li-oka-2018-review.md` (panel QTT / bootstrap validity machinery, deferred), `ciaccio-2024-review.md` (staggered, deferred). @@ -4698,6 +4702,8 @@ where `Q1` is the R type-1 quantile - the paper's eq. (35)/(A.1) inf-based ceili ### QDiD (quantile DiD comparison estimator) +- **Note:** As of 3.9 this estimator is selected with `ChangesInChanges(method="qdid")`; the standalone `QDiD` class is deprecated (ledger row M-015) and removed in 4.0. Only the CLASS SPELLING is deprecated - the estimator is fully supported and selecting it emits no advisory warning. The existing footnote-21 non-monotonicity `UserWarning` and `practitioner.py`'s "Prefer ChangesInChanges over QDiD" step already carry Athey-Imbens' p. 447 recommendation at the moments a user can act on it; a third warning on every `method="qdid"` fit would blur which of the class/method is dying. Both engines and every number are unchanged by the merge. + **Primary source:** Athey & Imbens (2006), Section 3.3 (pp. 446-447) - the paper formalizes QDiD as the natural comparison estimator and recommends CiC over it: QDiD's justifying model (eq. 22) is not invariant to monotone rescaling of the outcome, forces `U ⊥ (G, T)` (identical unobservable distributions in all four cells), and places testable restrictions on the data (footnote 21 - the implementation warns when the implied counterfactual quantile function is non-monotone on the requested grid; unconditional fits only, see the covariates block above). *Estimator equations (as implemented, matching `qte::QDiD()` v1.3.1 exactly):* diff --git a/docs/methodology/papers/athey-imbens-2006-review.md b/docs/methodology/papers/athey-imbens-2006-review.md index 007e8c64..522d9263 100644 --- a/docs/methodology/papers/athey-imbens-2006-review.md +++ b/docs/methodology/papers/athey-imbens-2006-review.md @@ -216,7 +216,7 @@ C^pq = E[p(Y_00)*q(Y_01)], C^rs = E[r(Y_10)*s(Y_11)] = Cov( k(Y_10), Y_11 ) ### QDiD variant (Section 3.3, journal pp. 446-447) -The paper formalizes quantile DiD as the natural comparison estimator; the library ships it alongside CiC (planned class `QDiD`). Applying DiD quantile-by-quantile (coefficients `alpha_q, beta_q, gamma_q` indexed by quantile) gives the transformation (unnumbered display, p. 447): +The paper formalizes quantile DiD as the natural comparison estimator; the library ships it alongside CiC, selected with `ChangesInChanges(method="qdid")` (the standalone `QDiD` class is deprecated in 3.9 and removed in 4.0). Applying DiD quantile-by-quantile (coefficients `alpha_q, beta_q, gamma_q` indexed by quantile) gives the transformation (unnumbered display, p. 447): ``` k^QDID(y) = y + F_Y,01^{-1}( F_Y,10(y) ) - F_Y,00^{-1}( F_Y,10(y) ) @@ -370,7 +370,7 @@ Aggregation `tau_Lambda = Lambda' tau^CIC_I` (columns of Lambda sum to 1): sampl | `quantiles` (quantile effects grid) | array of floats in (0,1) | deciles (library choice; paper suggests quartiles or deciles for joint tests, p. 465) | must lie strictly inside `(q_lower, q_upper)` from eq. (17); estimator NaNs/warns outside | | `n_bootstrap` | int | library convention (paper: none - inference is analytical; qte parity target uses bootstrap) | SE stability; align with qte for parity tests | | `mode` | {"panel", "rc"} | inferred from unit id presence | Assumption 5.3 vs 5.1 sampling; affects resampling only | -| `variant` | CiC vs QDiD (separate classes) | CiC | authors recommend CiC (p. 447); QDiD kept as comparison estimator | +| `method` | CiC vs QDiD (one class, `method="cic"`/`"qdid"`) | `"cic"` | authors recommend CiC (p. 447); QDiD kept as comparison estimator | | density bandwidth (deferred analytical SEs) | float | `N^{-1/3}` (footnote 31) | paper's one-sided EDF difference quotient; any boundary-uniform-consistent estimator admissible | ### Relation to Existing diff-diff Estimators diff --git a/docs/tutorials/27_cic_distributional_effects.ipynb b/docs/tutorials/27_cic_distributional_effects.ipynb index 6c0d70a5..a12de029 100644 --- a/docs/tutorials/27_cic_distributional_effects.ipynb +++ b/docs/tutorials/27_cic_distributional_effects.ipynb @@ -74,7 +74,7 @@ "import pandas as pd\n", "from scipy import stats\n", "\n", - "from diff_diff import ChangesInChanges, DifferenceInDifferences, QDiD, practitioner_next_steps\n" + "from diff_diff import ChangesInChanges, DifferenceInDifferences, practitioner_next_steps\n" ] }, { @@ -779,8 +779,8 @@ } ], "source": [ - "qdid = QDiD(n_bootstrap=0).fit(df, outcome=\"spend\", treatment=\"treated\", time=\"post\")\n", - "qdid_log = QDiD(n_bootstrap=0).fit(\n", + "qdid = ChangesInChanges(method=\"qdid\", n_bootstrap=0).fit(df, outcome=\"spend\", treatment=\"treated\", time=\"post\")\n", + "qdid_log = ChangesInChanges(method=\"qdid\", n_bootstrap=0).fit(\n", " df_log, outcome=\"log_spend\", treatment=\"treated\", time=\"post\"\n", ")\n", "\n", @@ -1028,8 +1028,8 @@ "\n", " - [MEDIUM] Step 8: Compare with QDiD and mean DiD\n", " Why: QDiD is the natural comparison estimator (same 2x2 cells, different justifying model); broadly agreeing QTE profiles strengthen the distributional conclusions, with CiC remaining the recommended primary (p. 447). The linear-DiD ATT is a useful anchor: CiC's ATT can differ from it when the outcome model is nonlinear, so a gap is informative about nonlinearity rather than a red flag on its own - report both.\n", - " >>> from diff_diff import QDiD, DifferenceInDifferences\n", - " >>> qdid_results = QDiD(n_bootstrap=200, seed=42).fit(\n", + " >>> from diff_diff import ChangesInChanges, DifferenceInDifferences\n", + " >>> qdid_results = ChangesInChanges(method='qdid', n_bootstrap=200, seed=42).fit(\n", " >>> data, outcome='y', treatment='treated', time='post')\n", " >>> # n_bootstrap=200 is the default; seed=42 is illustrative (default seed=None)\n", " >>> # carry over quantiles=/alpha= if you customized them\n", diff --git a/docs/v4-deprecations.yaml b/docs/v4-deprecations.yaml index 63ffc393..e329246f 100644 --- a/docs/v4-deprecations.yaml +++ b/docs/v4-deprecations.yaml @@ -196,11 +196,12 @@ rows: introduced_in: "3.9" deprecated_in: "3.9" removed_in: "4.0" - status: planned - phase: 3 + status: shimmed + phase: 5 warning: FutureWarning - code_refs: [diff_diff/changes_in_changes.py, diff_diff/__init__.py] - notes: "method='cic' (default) | 'qdid'; default encodes Athey-Imbens' recommendation of CiC over QDiD. Already one dispatcher/results class internally." + test_ref: tests/test_v4_merge_cic.py + code_refs: [diff_diff/changes_in_changes.py, diff_diff/changes_in_changes_results.py, diff_diff/practitioner.py, diff_diff/__init__.py] + notes: "method='cic' (default) | 'qdid'; default encodes Athey-Imbens' recommendation of CiC over QDiD. Already one dispatcher/results class internally. SHIPPED 3.9 (Phase 3(c)). method= is KEYWORD-ONLY (every pre-existing positional slot is preserved byte-for-byte; BaseEstimator._param_names accepts KEYWORD_ONLY so get_params/set_params pick it up with no new hooks) and lowercase-only - 'CiC'/'QDiD' casing raises, matching the underscored-only control_group ruling in [M-013]. The METHOD is not deprecated, only the CLASS spelling: method='qdid' emits no advisory warning, because the fit-time footnote-21 non-monotonicity UserWarning and practitioner.py's 'Prefer ChangesInChanges over QDiD' step already carry Athey-Imbens' caution, and a third warning would blur which of class/method is dying. VALIDATION is in BOTH places by necessity: __init__ passes a hand-built dict (it does not forward get_params()), so 'method' is added there AND read as params.get('method', 'cic') inside _validate_all_params - the .get default exists because QDiD keeps its frozen five-param __init__, so its get_params() carries no 'method' key and _fit_distributional re-validates through the same function on every QDiD fit. _fit_distributional's estimator_name now derives from type(est).__name__ instead of the kind literal, so a ChangesInChanges(method='qdid') fit no longer raises errors naming a class the user never constructed (byte-identical on both 3.x surfaces). QDiD stays a STANDALONE sibling, not a subclass: inheriting the merged __init__ would put method into its get_params() and make QDiD(method='cic') constructible. Results-field mirror: [M-143]." - id: M-016 kind: field group: merge-mpd @@ -758,7 +759,7 @@ rows: status: planned phase: 5 code_refs: [diff_diff/__init__.py] - notes: "Already a pure export alias of ChangesInChangesResults; dies with QDiD [M-015]." + notes: "Already a pure export alias of ChangesInChangesResults; dies with QDiD [M-015]. Status stays 'planned' through 3.9 (the M-060/M-064 precedent): the alias is a plain module global, deliberately NOT routed through _DEPRECATED_ALIASES, so it emits no warning of its own - constructing QDiD is what warns, and the alias identity QDiDResults is ChangesInChangesResults is pinned by tests/test_v4_merge_cic.py." - id: M-064 kind: alias group: alias-table @@ -1718,3 +1719,17 @@ rows: test_ref: tests/test_v4_merge_ddd.py code_refs: [diff_diff/triple_diff.py] notes: "Input-validation tightening shipped with the M-013 merge: TripleDifference's pscore_trim was previously UNVALIDATED, and the merged constructor adopts the staggered engine's 0 < x < 0.5 rule. Not cosmetic - the value feeds np.clip(pscore, trim, 1 - trim) in both engines, so trim=0 disables the overlap guard that keeps the 1/(1-p) IPW/DR weights finite and trim >= 0.5 inverts the clip bounds. TripleDifference(pscore_trim=0) therefore changes from accepted to a loud ValueError; no in-repo caller passed it. Same shape as [M-096] (a 3.9 validation tightening on a previously-unvalidated/silently-degrading param, rowed in the PR that shipped it), and status 'done' is terminal so the row is exempt from both phase-table directions. SIBLING DIVERGENCE, deliberate and recorded in REGISTRY: ContinuousDiD still validates 0.0 <= pscore_trim < 0.5, i.e. it admits 0 - aligning it is out of scope for a DDD merge and carries a TODO.md row instead of silent drift." + - id: M-143 + kind: field + group: merge-qdid + old: "diff_diff:ChangesInChangesResults.estimator" + new: "diff_diff:ChangesInChangesResults.method" + introduced_in: "3.9" + deprecated_in: "3.9" + removed_in: "4.0" + status: shimmed + phase: 5 + warning: FutureWarning + test_ref: tests/test_v4_merge_cic.py + code_refs: [diff_diff/changes_in_changes_results.py, diff_diff/changes_in_changes.py, diff_diff/practitioner.py, docs/methodology/REGISTRY.md] + notes: "Results-side mirror of [M-015] under section 8 rule 9: the field already held exactly the tag the new method= param sets ('cic'/'qdid'), so leaving it named 'estimator' would ship a param and its echoing field under different names and keep emitting 'estimator' from to_dict() forever. WooldridgeDiDResults.method is the in-repo template (a lowercase method tag selected by a method= constructor param, mirrored into summary()/to_dict()/__repr__). It also resolves a pre-existing cross-class collision: AggregationResult.estimator holds a CLASS NAME while this field holds a method tag. Shim is deprecated_field_property (read-only BY DESIGN - setattr must fail rather than write a shadow attribute the renamed field cannot see) plus a __setstate__ key migration; to_dict() emits BOTH keys through 3.9 per the [M-094] twin and spec section 5, so test_to_dict_keys stays green untouched and the old key drops at 4.0. __repr__'s user-visible label flips to method=. ENFORCEMENT NOTE: adding this row forces 'estimator' into test_naming_guard's _PATTERN_TOKENS (test_predicate_binds_to_ledger_tokens requires every live param/field rename token to satisfy _pattern_hit), which arms Duty A repo-wide - hence the four SURFACE_ALLOWLIST entries for the independent same-named surfaces (AggregationResult.estimator and the estimator= instance param on the three power entry points). 'estimator' is also added to _AMBIGUOUS_TOKENS so Duty C matches through the attr/quoted lanes only: the kwarg lane's remaining hits are the construction site (which becomes method= here) and validate_covariate_names(..., estimator=...), an unrelated parameter. REGISTRY.md is in code_refs because this PR's own REGISTRY subsection names the renamed field and would otherwise be an uncovered Duty C hit in the same diff that writes it. SCOPE OF THE WINDOW (decision, recorded as a REGISTRY Note): the shim covers the READ path, not the CONSTRUCT path - ChangesInChangesResults(..., estimator=...) raises TypeError from 3.9 rather than warning until 4.0, matching [M-094] and [M-114], neither of which preserved a deprecated constructor keyword. The container has one library call site; a constructor shim would add a deprecated surface with no row of its own." diff --git a/docs/v4-design.md b/docs/v4-design.md index a414a732..9373f1d7 100644 --- a/docs/v4-design.md +++ b/docs/v4-design.md @@ -739,7 +739,8 @@ missed.** shim schedule - a results field is public surface, is emitted by `to_dict()`, and section 5 forbids deprecated names in serialized output from 4.0. Mirrors added by amendment: [M-094] (mirrors [M-042]), [M-095] - (mirrors [M-043]), [M-114] (mirrors [M-033]). When adding a rename row, + (mirrors [M-043]), [M-114] (mirrors [M-033]), [M-143] (mirrors [M-015] - + the field already held the tag the new `method=` param sets). When adding a rename row, grep the matching `*_results.py` for the old name before assuming the param row is the whole job. 10. **These rules bind module-level PUBLIC FUNCTIONS, not just classes.** The @@ -823,9 +824,9 @@ above; anything only one PR cares about stays in that PR's plan.** |---|---|---| | 1 (this PR) | - | Spec + matrix + enforcement test + support edits | | 2: contract foundations | 3.9 | (a) results base + unified event-study representation [M-092] + to_dict completion + the Diagnostic marker base on the diagnostic result roster [M-091] (section 3.5); (b) `aggregate()` + fit(aggregate=) shims [M-020..M-027] [M-139] (M-020's shim already shipped; M-139 is the HAD workflow twin, a pre-cut amendment); (c) param renames [M-030..M-047] [M-084] [M-086..M-089] + their results-field mirrors [M-094] [M-095] (section 8 rule 9) + the public-function completeness sweep [M-097..M-113] (section 8 rule 10) + the dCDH results mirror [M-114] + the fourth `robust` site [M-115] + the 2(c)-ii missed-rename amendments [M-136..M-138] (LPDiD `level` value; the two post-dummy diagnostics params) + BaseEstimator mixin + ContinuousDiD covariates move; (d) alias introduction [M-062] (the Spillover introduction is cancelled [M-063]) + the alias-diet `__getattr__` warning shim [M-135] + wrapper deprecations [M-070..M-077] + the two inference-surface policies: `n_bootstrap` semantic unification [M-081] and the wild-cluster-bootstrap roster guard [M-096]; shipped insertions (all done): the aggregate contract [M-122], the ETWFE reference-period family [M-123] [M-124] [M-125], and the variance-consolidation program [M-126] [M-127] | -| 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] + the fit `time`->`post` rename [M-082] (gates: section 4.1's equivalence/divergence/pooled-parity test triple) (shipped: tests/test_v4_merge_mpd.py; consumer ports incl. HonestDiD/PreTrendsPower calendar routes); (b) TripleDifference facade [M-013] + the SDDD alias [M-064] (shipped: tests/test_v4_merge_ddd.py; the engine relocation into the private shared mixin, the keyword-only staggered fit params, and the pscore_trim tightening [M-142]. The fit-time aggregate=/balance_e= carve-out rows are scheduled REMOVALS and so are cited in the phase-5 cell, not here - their only lifecycle version is removed_in 4.0); (c) CiC method= [M-015] | +| 3: merges | 3.9 | (a) TWFE event-study mode [M-010] + EventStudy warn [M-060] + the fit `time`->`post` rename [M-082] (gates: section 4.1's equivalence/divergence/pooled-parity test triple) (shipped: tests/test_v4_merge_mpd.py; consumer ports incl. HonestDiD/PreTrendsPower calendar routes); (b) TripleDifference facade [M-013] + the SDDD alias [M-064] (shipped: tests/test_v4_merge_ddd.py; the engine relocation into the private shared mixin, the keyword-only staggered fit params, and the pscore_trim tightening [M-142]. The fit-time aggregate=/balance_e= carve-out rows are scheduled REMOVALS and so are cited in the phase-5 cell, not here - their only lifecycle version is removed_in 4.0); (c) CiC method= [M-015] + its results-field mirror [M-143] (shipped: tests/test_v4_merge_cic.py; method= is keyword-only and lowercase-only, the QDiD CLASS is deprecated while the METHOD is not, and ChangesInChangesResults.estimator -> .method carries a dual-key to_dict() window through 3.9) | | 4: release + soak | 3.9 cut | Migration guide written (skeleton: section 10); maintainer cuts 3.9; maint/3.8 rule active | -| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-139, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120, M-140, M-141] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; docs/llms.txt/README refresh | +| 5: enforcement | 4.0 | Removals [M-010..M-015, M-020..M-027, M-139, M-030, M-032..M-047 old names, M-060, M-061, M-064, M-070..M-077, M-084, M-086..M-089, M-001..M-003, M-117, M-118, M-119, M-120, M-140, M-141, M-143] + the alias diet [M-132]..[M-134] + the amendment's old names [M-094] [M-095] [M-097..M-115] [M-136..M-138] (incl. their consumer migrations and the `clean_control` serialized reporting key); M-031's old `time` name persists as the merged class's calendar column, so it is deliberately absent from the removal roster (its 4.0 enforcement is the M-085 behavior entry below); property window: [M-016] property-flips at 4.0 (removal at 5.0); storage flips [M-050..M-058]; default policies [M-004..M-006, M-128..M-131, M-080]; merged-class behavior enforcements [M-083] [M-085]; warning retirement [M-007]; fastpath go/no-go [M-008]; diagnostic-family docs/roster reorganization [M-090]; sentinel retirement [M-093]; docs/llms.txt/README refresh | | 6: front door | 4.1 | `event_study(data, outcome, unit, time, first_treat, estimator=...)` comparison entry point over the staggered family (sketch only; specified in its own plan) | Citation semantic for the table: a cell may cite a row whose current `phase` @@ -903,7 +904,7 @@ Everything else queued for 3.9 is row-gated, by one of two mechanisms. Symbol rows that declare a `warning` gate on `deprecated_in` - the shim must have shipped ([M-010] [M-013] [M-015], [M-020]..[M-027], [M-139], [M-030]..[M-047], [M-070]..[M-077], [M-082], -[M-084], [M-086]..[M-089], [M-094] [M-095], [M-097]..[M-115]). Rows with no +[M-084], [M-086]..[M-089], [M-094] [M-095], [M-097]..[M-115], [M-143]). Rows with no shim to assert gate on `introduced_in` instead - the new surface must have shipped: the introduce-only alias [M-062] and the `behavior`-kind policies diff --git a/pyproject.toml b/pyproject.toml index 852650e7..e78ef454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,11 @@ markers = [ filterwarnings = [ "ignore:MultiPeriodDiD is deprecated:FutureWarning", "ignore:StaggeredTripleDifference is deprecated:FutureWarning", + "ignore:QDiD is deprecated:FutureWarning", + # Row M-143's results-FIELD alias, not a class: the CiC family reads + # res.estimator on every BOTH-parametrized run (test_changes_in_changes.py's + # test_results_types_and_fields). REMOVE WITH THE FIELD at 4.0. + "ignore:ChangesInChangesResults.estimator is deprecated:FutureWarning", ] [tool.black] diff --git a/tests/_capture_v4_merge_cic_oracles.py b/tests/_capture_v4_merge_cic_oracles.py new file mode 100644 index 00000000..ac21c697 --- /dev/null +++ b/tests/_capture_v4_merge_cic_oracles.py @@ -0,0 +1,128 @@ +"""Step 0: capture the pre-merge oracles for tests/test_v4_merge_cic.py. + +MUST run on the UNMODIFIED tree (before ``method=`` lands on ChangesInChanges), +under DIFF_DIFF_BACKEND=python: + + DIFF_DIFF_BACKEND=python python3 tests/_capture_v4_merge_cic_oracles.py + +Unlike the 3(b) DDD capture, this one does NOT guard a relocation - nothing +moves in 3(c). It guards the two things a same-process parity gate cannot see: +a regression shared by both callers of ``_fit_distributional`` (e.g. a broken +``_validate_all_params`` or a changed default), and a drift in the values the +``kind`` dispatch selects. + +Scope decisions, both deliberate: + + * UNCONDITIONAL arm only. The covariate quantile-regression path is + tie-selection-bounded with BLAS-dependent tie flips (see + tests/test_changes_in_changes_parity.py: COV_ATT_ATOL = 0.04, + COV_QTE_ATOL = 0.25) and CI runs ubuntu/macos/windows/arm, so committed + literals at 1e-9 would be platform-fragile. Covariate coverage comes from + the in-process bit-exact parity gate, which cannot be platform-dependent. + + * ``quantile_effects`` is NOT captured. benchmarks/data/qte_golden.json is + git-tracked, so test_changes_in_changes_parity.py::test_point_parity never + skips and already pins quantile_effects["qte"] against the R qte 1.3.1 + golden at atol=1e-10, rtol=0 for cic/qdid x panel/rcs. That is a stronger + absolute cross-tree pin than anything this file would add. + +The DGP lives HERE, not in the test module: this script runs at step 1 while +the test module is written at step 7, so the dependency must point this way. +tests/test_v4_merge_cic.py imports ``make_2x2`` from this module. + +PROVENANCE of the literals committed in tests/test_v4_merge_cic.py: + commit c2941caa2a7865c9458c6092359e75238fdbabb1 (origin/main, pre-3(c)) + command DIFF_DIFF_BACKEND=python python3 tests/_capture_v4_merge_cic_oracles.py + tree clean apart from this file (no source edits had landed) + +Re-running this on the merged tree must reproduce the same literals - that is +the gate. Note the qdid arms legitimately emit the Athey-Imbens footnote-21 +non-monotonicity UserWarning on this DGP (that is the restriction QDiD places +on the data), so the capture is not warning-free and callers must not treat a +warning here as a failure. +""" + +import json + +import numpy as np +import pandas as pd + +from diff_diff import ChangesInChanges, QDiD + + +def make_2x2(n_treated=60, n_control=80, seed=0, effect=1.0): + """Full-overlap continuous 2x2 panel (long format, one row per unit-period). + + Mirrors tests/test_changes_in_changes.py's helper of the same name. The + ``id`` column repeats across periods, so the same frame serves both + ``panel=False`` (pooled row resample) and ``panel=True`` (unit-block + resample, ``unit="id"``). + """ + rng = np.random.default_rng(seed) + n = n_treated + n_control + treat = np.repeat([1, 0], [n_treated, n_control]) + u = rng.normal(0, 1, n) + y_pre = u + rng.normal(0, 0.3, n) + y_post = u + 0.5 + rng.normal(0, 0.3, n) + treat * effect + return pd.DataFrame( + { + "id": np.tile(np.arange(n), 2), + "post": np.repeat([0, 1], n), + "treated": np.tile(treat, 2), + "y": np.concatenate([y_pre, y_post]), + } + ) + + +# Every knob is pinned explicitly - they ARE the oracle's meaning. +N_TREATED = 60 +N_CONTROL = 80 +DGP_SEED = 0 +EFFECT = 1.0 +QUANTILES = None # the default 0.05-0.95 grid +ALPHA = 0.05 +BOOT_N = 49 +BOOT_SEED = 7 + + +def _record(res): + lo, hi = res.conf_int + return { + "att": float(res.att), + "se": float(res.se), + "t_stat": float(res.t_stat), + "p_value": float(res.p_value), + "conf_int_lower": float(lo), + "conf_int_upper": float(hi), + "q_lower": float(res.q_lower), + "q_upper": float(res.q_upper), + "sup_t_crit": float(res.sup_t_crit), + "n_obs": int(res.n_obs), + "n_bootstrap_valid": int(res.n_bootstrap_valid), + "cell_sizes": {k: int(v) for k, v in res.cell_sizes.items()}, + } + + +def main(): + df = make_2x2(n_treated=N_TREATED, n_control=N_CONTROL, seed=DGP_SEED, effect=EFFECT) + out = {} + + for label, cls in (("cic", ChangesInChanges), ("qdid", QDiD)): + for panel in (False, True): + est = cls(quantiles=QUANTILES, n_bootstrap=0, alpha=ALPHA, panel=panel) + if panel: + res = est.fit(df, outcome="y", treatment="treated", time="post", unit="id") + else: + res = est.fit(df, outcome="y", treatment="treated", time="post") + out[f"{label}_panel{int(panel)}_nb0"] = _record(res) + + est = cls(quantiles=QUANTILES, n_bootstrap=BOOT_N, alpha=ALPHA, panel=False, seed=BOOT_SEED) + out[f"{label}_panel0_nb{BOOT_N}"] = _record( + est.fit(df, outcome="y", treatment="treated", time="post") + ) + + print(json.dumps(out, indent=4, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_base_estimator.py b/tests/test_base_estimator.py index 4d04a2c2..ae000860 100644 --- a/tests/test_base_estimator.py +++ b/tests/test_base_estimator.py @@ -176,10 +176,12 @@ def test_init_signature_matches_get_params(cls): # (3.9 class merges, v4-design section 4.1). The round-trip test's # warnings-as-errors filter exists to catch UNEXPECTED warnings from a # re-init; these messages are expected by contract and are ignored inside -# the error filter. Forward home for the remaining phase-3 sibling (QDiD). +# the error filter. Complete as of phase 3(c): all three merges have shipped, +# so any future entry belongs to a NEW deprecation, not a pending sibling. DEPRECATED_CLASS_WARNINGS = { "MultiPeriodDiD": r"MultiPeriodDiD is deprecated", "StaggeredTripleDifference": r"StaggeredTripleDifference is deprecated", + "QDiD": r"QDiD is deprecated", } diff --git a/tests/test_naming_guard.py b/tests/test_naming_guard.py index d5689597..08b991ac 100644 --- a/tests/test_naming_guard.py +++ b/tests/test_naming_guard.py @@ -124,6 +124,10 @@ "zeta", "placebo_effects", "period_effects", + # M-143 (ChangesInChangesResults.estimator -> .method). Not optional: + # test_predicate_binds_to_ledger_tokens requires every live param/field + # rename token to satisfy _pattern_hit, so adding the row arms Duty A. + "estimator", } # Tokens that are LEGAL canonical vocabulary on other surfaces (rule 1 `time`, @@ -142,6 +146,14 @@ # bare grep (docs/guides-lane hits on canonical successor vocabulary # are covered by CONSUMER_ALLOWLIST entries below). "aggregate", + # M-143's old token: "estimator" is canonical vocabulary on independent + # surfaces (AggregationResult.estimator holds a CLASS NAME; power.py's + # estimator= takes an estimator INSTANCE; utils.validate_covariate_names + # has an estimator= label param). The precise lanes still sweep the dying + # field - a results field is read as `.estimator` or "estimator", and the + # only kwarg-lane hits left in changes_in_changes.py are the construction + # site (now method=) and validate_covariate_names', neither a stale reader. + "estimator", } @@ -502,6 +514,22 @@ def _build_rowed_index(): ) SURFACE_ALLOWLIST = { + # M-143 renames a RESULTS field named `estimator`; these four public + # surfaces share only the word. AggregationResult.estimator holds a CLASS + # NAME ("StackedDiD"), not a method tag - the very collision M-143 exists + # to stop propagating - and the power entry points take an estimator + # INSTANCE. None of them is a reader of the dying field. + "AggregationResult.estimator": ( + "independent same-named field holding a CLASS NAME, not CiC's " + "method tag (M-143); survives 4.0" + ), + **{ + f"{fn}[estimator]": ( + "power API's estimator INSTANCE param, unrelated to CiC's " + "results method tag (M-143); survives 4.0" + ) + for fn in ("simulate_power", "simulate_mde", "simulate_sample_size") + }, **{f"{cls}.groups": _CS_COHORT for cls in _CS_GROUPS_CLASSES}, "GroupTimeEffect.group": _CS_COHORT, "HADPretestReport.aggregate": ( @@ -932,6 +960,39 @@ def _token_family_code_refs(tok): ) CONSUMER_ALLOWLIST = { + # M-143's `estimator` token is ordinary vocabulary across the library: + # report schema keys, an AggregationResult field holding a class name, and + # a label parameter. None of these reads ChangesInChangesResults' renamed + # field. The one file that DID name it - diff_diff/guides/llms-full.txt - + # was migrated in this same diff (migrate-first rule) and remains a lane + # hit only through its unrelated backticked schema key. + ("estimator", "diff_diff/aggregation.py"): ( + "AggregationResult.estimator - independent field holding a CLASS NAME" + ), + ("estimator", "diff_diff/business_report.py"): ( + 'report-schema "estimator" keys holding class names / native tags' + ), + ("estimator", "diff_diff/diagnostic_report.py"): ( + 'report-schema "estimator" keys holding type(results).__name__' + ), + ("estimator", "diff_diff/had.py"): ( + "prose/schema use of the word, not a read of CiC's results field" + ), + ("estimator", "diff_diff/lpdid.py"): ( + "prose/schema use of the word, not a read of CiC's results field" + ), + ("estimator", "diff_diff/utils.py"): ( + "validate_covariate_names' estimator= LABEL parameter (default " + '"estimator"), unrelated to the renamed field' + ), + ("estimator", "diff_diff/guides/llms-full.txt"): ( + "remaining hit is the backticked report-schema key; the one sentence " + "that named ChangesInChangesResults.estimator was migrated to " + "`method` in this diff" + ), + ("estimator", "docs/methodology/papers/calonico-cattaneo-farrell-titiunik-2017-review.md"): ( + "RDD paper review's own use of the word - no CiC surface involved" + ), ("lambda_reg", "diff_diff/prep.py"): ( "rank_control_units' own independent regularization param - not the " "removed SyntheticDiD kwarg (M-001)" diff --git a/tests/test_practitioner.py b/tests/test_practitioner.py index 73ea2901..ae604171 100644 --- a/tests/test_practitioner.py +++ b/tests/test_practitioner.py @@ -1534,7 +1534,7 @@ def test_n_bootstrap_zero_warning(self, cic_cov_fit_results): def test_failed_replicates_warning(self): r = _mock_cic( - att=0.5, estimator="cic", covariates=None, n_bootstrap=200, n_bootstrap_valid=100 + att=0.5, method="cic", covariates=None, n_bootstrap=200, n_bootstrap_valid=100 ) output = practitioner_next_steps(r, verbose=False) joined = " ".join(output["warnings"]) @@ -1545,7 +1545,7 @@ def test_minor_replicate_failures_below_threshold_no_warning(self): # 4/200 = 2% failed, below the 5% fit-time materiality threshold # (warn_bootstrap_failure_rate) that this surface mirrors. r = _mock_cic( - att=0.5, estimator="cic", covariates=None, n_bootstrap=200, n_bootstrap_valid=196 + att=0.5, method="cic", covariates=None, n_bootstrap=200, n_bootstrap_valid=196 ) output = practitioner_next_steps(r, verbose=False) assert output["warnings"] == [] @@ -1553,7 +1553,7 @@ def test_minor_replicate_failures_below_threshold_no_warning(self): def test_nan_att_warning(self): r = _mock_cic( att=float("nan"), - estimator="cic", + method="cic", covariates=None, n_bootstrap=200, n_bootstrap_valid=200, @@ -1572,9 +1572,7 @@ def test_completed_placebo_filters_placebo_step(self, cic_fit_results): def test_empty_list_covariates_mock_takes_unconditional_branch(self): # fit() normalizes covariates=[] to None, but hand-built results # may carry the empty list - the branch predicate is truthiness. - r = _mock_cic( - att=0.5, estimator="cic", covariates=[], n_bootstrap=200, n_bootstrap_valid=200 - ) + r = _mock_cic(att=0.5, method="cic", covariates=[], n_bootstrap=200, n_bootstrap_valid=200) output = practitioner_next_steps(r, verbose=False) labels = [s["label"] for s in output["next_steps"]] assert any("interior point-identification range" in lbl for lbl in labels) @@ -1711,21 +1709,21 @@ def test_qdid_step3_keeps_meaningful_means_screen(self, qdid_fit_results): def test_n_bootstrap_one_warning(self): # n_bootstrap=1 passes the disabled-inference check but cannot # clear the >= 2 valid-replicate SE gate: all inference is NaN. - r = _mock_cic(att=0.5, estimator="cic", covariates=None, n_bootstrap=1, n_bootstrap_valid=1) + r = _mock_cic(att=0.5, method="cic", covariates=None, n_bootstrap=1, n_bootstrap_valid=1) output = practitioner_next_steps(r, verbose=False) assert any("n_bootstrap=1 cannot produce inference" in w for w in output["warnings"]) def test_bootstrap_warnings_accept_numpy_scalars(self): r = _mock_cic( att=0.5, - estimator="cic", + method="cic", covariates=None, n_bootstrap=np.int64(200), n_bootstrap_valid=np.int64(100), ) output = practitioner_next_steps(r, verbose=False) assert any("100 of 200" in w for w in output["warnings"]) - r0 = _mock_cic(att=0.5, estimator="cic", covariates=None, n_bootstrap=np.int64(0)) + r0 = _mock_cic(att=0.5, method="cic", covariates=None, n_bootstrap=np.int64(0)) output0 = practitioner_next_steps(r0, verbose=False) assert any("n_bootstrap=0" in w for w in output0["warnings"]) diff --git a/tests/test_t27_cic_distributional_effects_drift.py b/tests/test_t27_cic_distributional_effects_drift.py index 68117a3f..999038a6 100644 --- a/tests/test_t27_cic_distributional_effects_drift.py +++ b/tests/test_t27_cic_distributional_effects_drift.py @@ -54,7 +54,7 @@ import pytest from scipy import stats -from diff_diff import ChangesInChanges, DifferenceInDifferences, QDiD, practitioner_next_steps +from diff_diff import ChangesInChanges, DifferenceInDifferences, practitioner_next_steps from tests._tutorial_drift import assert_quotes_in_rendered, notebook_markdown NB = "docs/tutorials/27_cic_distributional_effects.ipynb" @@ -191,8 +191,12 @@ def test_main_fits_warning_free_and_support_clean(self, df, df_log, cic): ChangesInChanges(n_bootstrap=0).fit( df_log, outcome="log_spend", treatment="treated", time="post" ) - QDiD(n_bootstrap=0).fit(df, outcome="spend", treatment="treated", time="post") - QDiD(n_bootstrap=0).fit(df_log, outcome="log_spend", treatment="treated", time="post") + ChangesInChanges(method="qdid", n_bootstrap=0).fit( + df, outcome="spend", treatment="treated", time="post" + ) + ChangesInChanges(method="qdid", n_bootstrap=0).fit( + df_log, outcome="log_spend", treatment="treated", time="post" + ) def test_mean_did_verdict(self, did): # "$0.22, p = 0.90" - insignificant AND biased (truth is ~$3.01). @@ -270,8 +274,10 @@ def test_did_flips_verdict_across_scales(self, df_log, did): assert abs(100 * (np.exp(did_log.att) - 1) - 14.0) < 0.1 def test_qdid_scale_gap_grows_toward_top(self, df, df_log): - qdid = QDiD(n_bootstrap=0).fit(df, outcome="spend", treatment="treated", time="post") - qdid_log = QDiD(n_bootstrap=0).fit( + qdid = ChangesInChanges(method="qdid", n_bootstrap=0).fit( + df, outcome="spend", treatment="treated", time="post" + ) + qdid_log = ChangesInChanges(method="qdid", n_bootstrap=0).fit( df_log, outcome="log_spend", treatment="treated", time="post" ) y11 = df.query("treated == 1 and post == 1")["spend"].to_numpy() diff --git a/tests/test_v4_matrix.py b/tests/test_v4_matrix.py index d52bb691..8ecb50fd 100644 --- a/tests/test_v4_matrix.py +++ b/tests/test_v4_matrix.py @@ -132,7 +132,7 @@ # Ids are never reused and terminal rows are never deleted, so the ledger # only grows - raise the floor when rows are added; a lower parse count # means scanner/format drift or an illegal row deletion. -ROW_COUNT_FLOOR = 124 +ROW_COUNT_FLOOR = 125 # Committed snapshot of the shipped id set ("ids are never deleted or reused" # contract - a delete-one-add-one edit keeps the count above the floor but trips @@ -185,6 +185,7 @@ (118, 119), (139, 139), (140, 142), + (143, 143), ] EXPECTED_INITIAL_IDS = frozenset( f"M-{n:03d}" for lo, hi in _INITIAL_ID_RANGES for n in range(lo, hi + 1) @@ -590,7 +591,7 @@ def test_initial_ids_never_deleted(): M-136..M-138 + M-139 + M-140..M-142).""" missing = sorted(EXPECTED_INITIAL_IDS - set(_ROW_IDS)) assert not missing, f"ledger rows deleted (ids are permanent): {missing}" - assert len(EXPECTED_INITIAL_IDS) == 124 + assert len(EXPECTED_INITIAL_IDS) == 125 def test_version_tuple_pads_to_three_components(): diff --git a/tests/test_v4_merge_cic.py b/tests/test_v4_merge_cic.py new file mode 100644 index 00000000..247a4ebe --- /dev/null +++ b/tests/test_v4_merge_cic.py @@ -0,0 +1,649 @@ +"""Phase 3(c): ChangesInChanges absorbs QDiD - rows M-015 / M-143. + +TOLERANCE DOCTRINE. Every merge-parity gate is BIT-EXACT +(``assert_array_equal``): both surfaces call the same untouched +``_fit_distributional`` in the same process, so a needed tolerance IS the +finding. The single exception is Gate B's committed oracle, captured in a +different process (and possibly a different backend), asserted with +``assert_allclose(rtol=1e-9, atol=1e-12)``. + +ORACLE PROVENANCE (Gate B). Literals captured by +``tests/_capture_v4_merge_cic_oracles.py`` on the UNMODIFIED pre-merge tree: + + commit c2941caa2a7865c9458c6092359e75238fdbabb1 (origin/main, pre-3(c)) + command DIFF_DIFF_BACKEND=python python3 tests/_capture_v4_merge_cic_oracles.py + +The oracle covers the UNCONDITIONAL arm only, and omits ``quantile_effects``. +Both are deliberate: the covariate quantile-regression path is +tie-selection-bounded with BLAS-dependent tie flips (see +``tests/test_changes_in_changes_parity.py``: COV_ATT_ATOL = 0.04, +COV_QTE_ATOL = 0.25) and CI runs ubuntu/macos/windows/arm, so tight committed +covariate literals would be platform-fragile; and ``benchmarks/data/qte_golden.json`` +is git-tracked, so ``test_point_parity`` never skips and already pins +``quantile_effects["qte"]`` against the R ``qte`` 1.3.1 golden at +``atol=1e-10, rtol=0`` for cic/qdid x panel/rcs - a stronger absolute pin than +anything this file would add. Covariate coverage here comes from Gate A's +in-process bit-exact parity, which cannot be platform-dependent. +""" + +import pickle +import re +import warnings + +import numpy as np +import pytest +from numpy.testing import assert_allclose, assert_array_equal + +from diff_diff import ( + ChangesInChanges, + ChangesInChangesResults, + QDiD, + QDiDResults, + practitioner_next_steps, +) +from diff_diff.changes_in_changes import _validate_all_params + +from ._capture_v4_merge_cic_oracles import make_2x2 + +# -------------------------------------------------------------------------- +# Pinned messages +# -------------------------------------------------------------------------- +QDID_DEPRECATION_RE = re.escape("QDiD is deprecated and will be removed in 4.0") +METHOD_VALUE_RE = re.escape("method must be 'cic' or 'qdid', got ") +FIELD_DEPRECATION_RE = re.escape("ChangesInChangesResults.estimator is deprecated") + +FIT_KW = dict(outcome="y", treatment="treated", time="post") + + +def _qdid(**kw): + """Construct the dying class without its (expected) FutureWarning.""" + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message=QDID_DEPRECATION_RE, category=FutureWarning) + return QDiD(**kw) + + +def _fit_quiet(est, df, **kw): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return est.fit(df, **FIT_KW, **kw) + + +@pytest.fixture(scope="module") +def df(): + return make_2x2() + + +@pytest.fixture(scope="module") +def cov_df(): + """Small covariate frame - the conditional QR path, exercised in-process only.""" + rng = np.random.default_rng(11) + base = make_2x2(n_treated=25, n_control=25, seed=3) + base = base.copy() + base["x1"] = rng.normal(0, 1, len(base)) + return base + + +# ========================================================================== +# Gate A - merged mode is bit-exact against the dying class +# ========================================================================== +class TestGateAParity: + _FIELDS = ("att", "se", "t_stat", "p_value", "q_lower", "q_upper", "sup_t_crit") + + def _assert_same(self, merged, dying): + for f in self._FIELDS: + assert_array_equal( + np.asarray(getattr(merged, f), dtype=float), + np.asarray(getattr(dying, f), dtype=float), + err_msg=f"field {f!r} diverged between the merged and dying surfaces", + ) + assert_array_equal(np.asarray(merged.conf_int, float), np.asarray(dying.conf_int, float)) + assert_array_equal( + merged.quantile_effects.to_numpy(dtype=float), + dying.quantile_effects.to_numpy(dtype=float), + ) + assert list(merged.quantile_effects.columns) == list(dying.quantile_effects.columns) + assert merged.cell_sizes == dying.cell_sizes + assert merged.n_obs == dying.n_obs + assert merged.n_bootstrap_valid == dying.n_bootstrap_valid + assert merged.method == dying.method == "qdid" + assert type(merged).__name__ == type(dying).__name__ + + @pytest.mark.parametrize("panel", [False, True], ids=["rcs", "panel"]) + def test_point_parity(self, df, panel): + kw = {"unit": "id"} if panel else {} + merged = _fit_quiet(ChangesInChanges(n_bootstrap=0, panel=panel, method="qdid"), df, **kw) + dying = _fit_quiet(_qdid(n_bootstrap=0, panel=panel), df, **kw) + self._assert_same(merged, dying) + + def test_seeded_bootstrap_parity(self, df): + merged = _fit_quiet(ChangesInChanges(n_bootstrap=49, seed=7, method="qdid"), df) + dying = _fit_quiet(_qdid(n_bootstrap=49, seed=7), df) + self._assert_same(merged, dying) + + def test_covariate_parity(self, cov_df): + """The conditional QR path - in-process, so bit-exact is legitimate here.""" + merged = _fit_quiet( + ChangesInChanges(n_bootstrap=0, method="qdid"), cov_df, covariates=["x1"] + ) + dying = _fit_quiet(_qdid(n_bootstrap=0), cov_df, covariates=["x1"]) + self._assert_same(merged, dying) + + def test_cic_default_unchanged(self, df): + """method='cic' is the default, and must match a bare ChangesInChanges.""" + a = _fit_quiet(ChangesInChanges(n_bootstrap=49, seed=7), df) + b = _fit_quiet(ChangesInChanges(n_bootstrap=49, seed=7, method="cic"), df) + assert_array_equal(np.asarray(a.att), np.asarray(b.att)) + assert_array_equal(np.asarray(a.se), np.asarray(b.se)) + assert a.method == b.method == "cic" + + def test_fit_time_userwarning_sets_match(self, df): + """Scoped to fit-time UserWarnings ONLY. + + The whole warning sets differ BY CONSTRUCTION - Gate D requires QDiD() + to emit a FutureWarning the merged surface must not - so comparing them + wholesale would contradict this file's own deprecation gate. + """ + + def _user_warnings(make): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + make() + return sorted(str(x.message) for x in w if issubclass(x.category, UserWarning)) + + merged = _user_warnings( + lambda: ChangesInChanges(n_bootstrap=0, method="qdid").fit(df, **FIT_KW) + ) + dying = _user_warnings(lambda: _qdid(n_bootstrap=0).fit(df, **FIT_KW)) + assert merged == dying + assert any("non-monotone" in m for m in merged), ( + "the DGP is expected to trip QDiD's footnote-21 non-monotonicity warning; " + "without it this parity assertion would be vacuous" + ) + + +# ========================================================================== +# Gate B - committed pre-merge oracle +# ========================================================================== +_NAN = float("nan") +ORACLES = { + "cic_panel0_nb0": { + "att": 0.9704717894944577, + "se": _NAN, + "t_stat": _NAN, + "p_value": _NAN, + "conf_int_lower": _NAN, + "conf_int_upper": _NAN, + "q_lower": 0.0, + "q_upper": 1.0, + "sup_t_crit": _NAN, + "n_obs": 280, + "n_bootstrap_valid": 0, + }, + "cic_panel1_nb0": { + "att": 0.9704717894944577, + "se": _NAN, + "t_stat": _NAN, + "p_value": _NAN, + "conf_int_lower": _NAN, + "conf_int_upper": _NAN, + "q_lower": 0.0, + "q_upper": 1.0, + "sup_t_crit": _NAN, + "n_obs": 280, + "n_bootstrap_valid": 0, + }, + "cic_panel0_nb49": { + "att": 0.9704717894944577, + "se": 0.19313483048560523, + "t_stat": 5.02484086922265, + "p_value": 5.03850140083979e-07, + "conf_int_lower": 0.5919344775824229, + "conf_int_upper": 1.3490091014064924, + "q_lower": 0.0, + "q_upper": 1.0, + "sup_t_crit": 3.4366340739481993, + "n_obs": 280, + "n_bootstrap_valid": 49, + }, + "qdid_panel0_nb0": { + "att": 0.9562069949948577, + "se": _NAN, + "t_stat": _NAN, + "p_value": _NAN, + "conf_int_lower": _NAN, + "conf_int_upper": _NAN, + "q_lower": _NAN, + "q_upper": _NAN, + "sup_t_crit": _NAN, + "n_obs": 280, + "n_bootstrap_valid": 0, + }, + "qdid_panel1_nb0": { + "att": 0.9562069949948577, + "se": _NAN, + "t_stat": _NAN, + "p_value": _NAN, + "conf_int_lower": _NAN, + "conf_int_upper": _NAN, + "q_lower": _NAN, + "q_upper": _NAN, + "sup_t_crit": _NAN, + "n_obs": 280, + "n_bootstrap_valid": 0, + }, + "qdid_panel0_nb49": { + "att": 0.9562069949948577, + "se": 0.1922323026627172, + "t_stat": 4.9742264008176535, + "p_value": 6.55087129855928e-07, + "conf_int_lower": 0.5794386051107289, + "conf_int_upper": 1.3329753848789867, + "q_lower": _NAN, + "q_upper": _NAN, + "sup_t_crit": 3.6662009990828244, + "n_obs": 280, + "n_bootstrap_valid": 49, + }, +} +_CELLS = {"control_post": 80, "control_pre": 80, "treated_post": 60, "treated_pre": 60} + + +class TestGateBOracle: + @pytest.mark.parametrize("key", sorted(ORACLES)) + def test_matches_pre_merge_capture(self, df, key): + method, panel_tag, boot_tag = key.split("_") + panel = panel_tag == "panel1" + n_boot = int(boot_tag[2:]) + est = ChangesInChanges( + n_bootstrap=n_boot, panel=panel, seed=7 if n_boot else None, method=method + ) + res = _fit_quiet(est, df, **({"unit": "id"} if panel else {})) + + exp = ORACLES[key] + lo, hi = res.conf_int + got = { + "att": res.att, + "se": res.se, + "t_stat": res.t_stat, + "p_value": res.p_value, + "conf_int_lower": lo, + "conf_int_upper": hi, + "q_lower": res.q_lower, + "q_upper": res.q_upper, + "sup_t_crit": res.sup_t_crit, + } + for field, want in got.items(): + assert_allclose( + want, + exp[field], + rtol=1e-9, + atol=1e-12, + err_msg=f"{key}.{field} drifted from the pre-merge capture", + ) + assert res.n_obs == exp["n_obs"] + assert res.n_bootstrap_valid == exp["n_bootstrap_valid"] + assert res.cell_sizes == _CELLS + + def test_methods_are_not_interchangeable(self): + """Guards the oracle itself: if cic and qdid agreed, it could not discriminate.""" + assert ORACLES["cic_panel0_nb0"]["att"] != ORACLES["qdid_panel0_nb0"]["att"] + + +# ========================================================================== +# Gate C - the new parameter's contract +# ========================================================================== +class TestGateCValidation: + @pytest.mark.parametrize("bad", ["bogus", None, "CiC", "QDiD", "", 1, 0.5]) + def test_rejects_at_construction(self, bad): + """Construction-time, not just fit-time: the regression test for + ``method`` being in __init__'s hand-built validation dict. With only + _validate_all_params' ``.get`` default this would construct silently.""" + with pytest.raises(ValueError, match=METHOD_VALUE_RE): + ChangesInChanges(method=bad) + + @pytest.mark.parametrize( + "arr", + [np.array(["cic"]), np.array(["cic", "qdid"]), np.array([["cic"]])], + ids=["one-element", "multi-element", "2d"], + ) + def test_rejects_ndarray_lookalikes(self, arr): + """A bare membership test would accept the 1-element case. + + ``np.array(["cic"]) in ("cic", "qdid")`` compares elementwise and + bool()s a 1-element result to True, so the ARRAY would be stored as + ``self.method`` - an unhashable tag that breaks the _ESTIMATOR_TITLES + lookup in summary() and serializes as an array rather than the + documented string. The multi-element cases must raise the same clean + message, not numpy's ambiguous-truth error. + """ + with pytest.raises(ValueError, match=METHOD_VALUE_RE): + ChangesInChanges(method=arr) + + @pytest.mark.parametrize("good", ["cic", "qdid"]) + def test_accepts_vocabulary(self, good): + assert ChangesInChanges(method=good).method == good + + def test_keyword_only(self): + import inspect + + kind = inspect.signature(ChangesInChanges).parameters["method"].kind + assert kind is inspect.Parameter.KEYWORD_ONLY + with pytest.raises(TypeError): + ChangesInChanges(None, 200, 0.05, False, None, "qdid") # 6th positional + + def test_set_params_success_flips_behaviour(self, df): + est = ChangesInChanges(n_bootstrap=0) + est.set_params(method="qdid") + assert est.method == "qdid" + assert_array_equal( + np.asarray(_fit_quiet(est, df).att), + np.asarray(ORACLES["qdid_panel0_nb0"]["att"]), + ) + + def test_set_params_failure_is_transactional(self): + est = ChangesInChanges(n_bootstrap=0, method="cic") + with pytest.raises(ValueError, match=METHOD_VALUE_RE): + est.set_params(method="bogus") + assert est.method == "cic" + + def test_get_params_key_sets(self): + merged = set(ChangesInChanges().get_params()) + assert merged == {"quantiles", "n_bootstrap", "alpha", "panel", "seed", "method"} + # The dying class's five-param contract is FROZEN through removal. + assert set(_qdid().get_params()) == merged - {"method"} + + @pytest.mark.parametrize("method", ["cic", "qdid"]) + def test_reinstantiation_round_trip(self, method): + est = ChangesInChanges(n_bootstrap=3, alpha=0.1, method=method) + with warnings.catch_warnings(): + warnings.simplefilter("error") + clone = ChangesInChanges(**est.get_params()) + assert clone.get_params() == est.get_params() + + def test_dying_class_round_trip_needs_targeted_ignore(self): + """QDiD construction MUST warn, so the error filter needs the per-class + ignore (the tests/test_base_estimator.py:188-193 idiom).""" + est = _qdid(n_bootstrap=3) + with warnings.catch_warnings(): + warnings.simplefilter("error") + warnings.filterwarnings("ignore", message=QDID_DEPRECATION_RE, category=FutureWarning) + clone = QDiD(**est.get_params()) + assert clone.get_params() == est.get_params() + + +# ========================================================================== +# Gate D - deprecation choreography +# ========================================================================== +class TestGateDDeprecation: + def test_construction_warns_once(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + QDiD(n_bootstrap=0) + fw = [x for x in w if issubclass(x.category, FutureWarning)] + assert len(fw) == 1 + assert re.search(QDID_DEPRECATION_RE, str(fw[0].message)) + assert "ChangesInChanges(method='qdid')" in str(fw[0].message) + + def test_warns_but_still_works(self, df): + res = _fit_quiet(_qdid(n_bootstrap=0), df) + assert np.isfinite(res.att) + + def test_stacklevel_attributes_to_caller(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + QDiD(n_bootstrap=0) + assert w[0].filename == __file__, "stacklevel=2 must blame the user frame" + + def test_merged_surface_is_silent(self): + """The METHOD is not deprecated - only the class spelling.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + ChangesInChanges(n_bootstrap=0, method="qdid") + ChangesInChanges(n_bootstrap=0, method="cic") + + def test_set_params_re_emits(self): + """Documented side effect of BaseEstimator's transactional probe re-init.""" + est = _qdid(n_bootstrap=0) + with pytest.warns(FutureWarning, match=QDID_DEPRECATION_RE): + est.set_params(n_bootstrap=5) + + def test_results_alias_identity(self): + assert QDiDResults is ChangesInChangesResults + + +# ========================================================================== +# Gate E - the renamed results field (M-143) +# ========================================================================== +class TestGateEFieldShim: + @pytest.mark.parametrize("method", ["cic", "qdid"]) + def test_method_field(self, df, method): + assert _fit_quiet(ChangesInChanges(n_bootstrap=0, method=method), df).method == method + + def test_old_name_reads_and_warns(self, df): + res = _fit_quiet(ChangesInChanges(n_bootstrap=0), df) + with pytest.warns(FutureWarning, match=FIELD_DEPRECATION_RE): + assert res.estimator == "cic" + + def test_old_name_is_not_a_constructor_keyword(self, df): + """Pins the DOCUMENTED scope of M-143's deprecation window. + + The window covers the READ path (the property below) and pickle + migration - not construction. `ChangesInChangesResults(estimator=...)` + raises immediately rather than warning until 4.0, matching M-094 + (`treatment_col` -> `takeup`) and M-114 (`groups` -> `units`), neither + of which kept a deprecated constructor keyword. Asserted so the + REGISTRY note is an enforced contract rather than prose: a later + "helpful" constructor shim would have to change this test deliberately. + """ + good = _fit_quiet(ChangesInChanges(n_bootstrap=0), df).to_dict() + with pytest.raises(TypeError, match="estimator"): + ChangesInChangesResults( # type: ignore[call-arg] + att=good["att"], + se=good["se"], + t_stat=good["t_stat"], + p_value=good["p_value"], + conf_int=(good["conf_int_lower"], good["conf_int_upper"]), + quantile_effects=None, + q_lower=good["q_lower"], + q_upper=good["q_upper"], + sup_t_crit=good["sup_t_crit"], + n_obs=good["n_obs"], + cell_sizes=good["cell_sizes"], + n_bootstrap=good["n_bootstrap"], + n_bootstrap_valid=good["n_bootstrap_valid"], + panel=good["panel"], + estimator="cic", + quantiles=None, + ) + + def test_old_name_is_read_only(self, df): + res = _fit_quiet(ChangesInChanges(n_bootstrap=0), df) + with pytest.raises(AttributeError): + res.estimator = "qdid" + + def test_to_dict_carries_both_keys(self, df): + """3.9 dual-key window (the M-094 twin). Both read the SAME attribute.""" + d = _fit_quiet(ChangesInChanges(n_bootstrap=0, method="qdid"), df).to_dict() + assert d["method"] == "qdid" + assert d["estimator"] == d["method"] + + def test_repr_label_flipped(self, df): + r = repr(_fit_quiet(ChangesInChanges(n_bootstrap=0), df)) + assert "method='cic'" in r + assert "estimator=" not in r + + def test_live_pickle_round_trip(self, df): + """__setstate__ runs for CURRENT objects too - a defective one breaks them.""" + res = _fit_quiet(ChangesInChanges(n_bootstrap=0, method="qdid"), df) + clone = pickle.loads(pickle.dumps(res)) + assert clone.method == "qdid" + assert_array_equal(np.asarray(clone.att), np.asarray(res.att)) + + def test_legacy_state_migration(self, df): + res = _fit_quiet(ChangesInChanges(n_bootstrap=0, method="qdid"), df) + legacy = dict(res.__dict__) + legacy["estimator"] = legacy.pop("method") + revived = ChangesInChangesResults.__new__(ChangesInChangesResults) + revived.__setstate__(legacy) + assert revived.method == "qdid" + with pytest.warns(FutureWarning, match=FIELD_DEPRECATION_RE): + assert revived.estimator == "qdid" + + def test_summary_interior_range_branch(self, df): + """Inspects summary() TEXT, not the field. + + ``q_lower``/``q_upper`` are computed by the engine either way, so + asserting on their values would pass even with a broken ``self.method`` + read. Only the rendered line proves the branch saw the right value. + """ + cic = _fit_quiet(ChangesInChanges(n_bootstrap=0), df).summary() + qdid = _fit_quiet(ChangesInChanges(n_bootstrap=0, method="qdid"), df).summary() + assert "interior quantile range" in cic + assert "interior quantile range" not in qdid + assert "Changes-in-Changes" in cic + assert "Quantile Difference-in-Differences" in qdid + + @pytest.mark.parametrize("method", ["cic", "qdid"]) + def test_canonical_surface_emits_no_deprecation(self, df, method): + """The ONLY thing that catches an unmigrated internal ``self.estimator``. + + The shim returns the right value, so behaviour looks correct and every + value assertion still passes - while every user gets a FutureWarning on + each summary()/repr() call, which the blanket pyproject filter hides. + """ + res = _fit_quiet(ChangesInChanges(n_bootstrap=0, method=method), df) + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + res.summary() + res.to_dict() + repr(res) + res.to_dataframe() + + +# ========================================================================== +# Gate F - dispatch guards inside the shared engine +# ========================================================================== +class TestGateFEngineGuards: + def test_qdid_fit_survives_revalidation(self, df): + """QDiD's get_params() has no 'method' key and _fit_distributional + re-validates through _validate_all_params on every fit.""" + assert np.isfinite(_fit_quiet(_qdid(n_bootstrap=0), df).att) + + def test_validate_all_params_tolerates_missing_method(self): + """The behavioural pin for ``params.get("method", "cic")``. + + A ``params["method"]`` read would KeyError here - which is exactly what + every QDiD fit would hit. Asserted directly rather than by mutating + source, so nothing dirties the worktree. + """ + _validate_all_params( + {"quantiles": None, "n_bootstrap": 0, "alpha": 0.05, "panel": False, "seed": None} + ) + + def test_direct_attribute_mutation_revalidated_at_fit(self, df): + """The one route that bypasses BOTH __init__ and the set_params probe.""" + est = ChangesInChanges(n_bootstrap=0) + est.method = "bogus" + with pytest.raises(ValueError, match=METHOD_VALUE_RE): + est.fit(df, **FIT_KW) + + @pytest.mark.parametrize( + "factory,expected", + [ + (lambda: ChangesInChanges(n_bootstrap=0, method="qdid"), "ChangesInChanges"), + (lambda: _qdid(n_bootstrap=0), "QDiD"), + ], + ids=["merged", "dying"], + ) + def test_errors_name_the_class_the_user_built(self, cov_df, factory, expected): + """Derived from type(est).__name__, so a method='qdid' fit never names + a class the caller never constructed.""" + bad = cov_df.copy() + bad["x_str"] = "a" + with pytest.raises(ValueError) as exc: + factory().fit(bad, **FIT_KW, covariates=["x_str"]) + assert expected in str(exc.value) + + +# ========================================================================== +# Gate G - consumers +# ========================================================================== +def _steps(res): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return practitioner_next_steps(res)["next_steps"] + + +class TestGateGConsumers: + @pytest.mark.parametrize("method", ["cic", "qdid"]) + def test_no_snippet_teaches_the_dying_class(self, df, method): + res = _fit_quiet(ChangesInChanges(n_bootstrap=0, method=method), df) + blob = "\n".join(s.get("code", "") for s in _steps(res)) + assert not re.search(r"\bQDiD\s*\(", blob), "emitted a deprecated QDiD(...) constructor" + assert not re.search(r"import[^\n]*\bQDiD\b", blob), "emitted a deprecated QDiD import" + + def test_qdid_snippet_uses_the_merged_surface(self, df): + res = _fit_quiet(ChangesInChanges(n_bootstrap=0), df) + blob = "\n".join(s.get("code", "") for s in _steps(res)) + assert "ChangesInChanges(method='qdid'" in blob + + def test_cic_snippets_carry_no_method_argument(self, df): + """method= must not leak into snippets for a CiC refit.""" + res = _fit_quiet(ChangesInChanges(n_bootstrap=0), df) + for s in _steps(res): + code = s.get("code", "") + if "cic_results =" in code or "results_nocov =" in code: + assert "method=" not in code + + def test_step8_snippet_executes(self, df): + """EXEC, not compile: a missing import is a run-time NameError that + compile() cannot see - exactly how the Step-8 two-name import + (ChangesInChanges + DifferenceInDifferences) would slip through. + + The fixture is deliberately unconditional and panel=False: the helper + emits a placeholder ``unit='unit_id'`` for panel fits, and + ``covariates="same"`` would copy a covariate list into this block and + drag the slow QR path into the test. + """ + res = _fit_quiet(ChangesInChanges(n_bootstrap=0), df) + blocks = [s["code"] for s in _steps(res) if "qdid_results" in s.get("code", "")] + assert blocks, "the CiC branch must emit the Step-8 QDiD comparison snippet" + ns = {"data": df, "results": res} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + exec(blocks[0], ns) # noqa: S102 - executing our own emitted guidance is the point + assert "qdid_results" in ns and "did_results" in ns + + @pytest.mark.parametrize("method", ["cic", "qdid"]) + def test_guidance_emits_no_deprecation_warning(self, df, method): + """Gate E's doctrine applied to the consumer surface: an old-name-first + or unmigrated read would keep the output identical while warning users, + and the blanket pyproject filter would hide it.""" + res = _fit_quiet(ChangesInChanges(n_bootstrap=0, method=method), df) + with warnings.catch_warnings(): + warnings.simplefilter("error", FutureWarning) + practitioner_next_steps(res) + + def test_duck_typed_legacy_results_still_route(self): + """Exercises the __dict__ fallback in _distributional_kind. + + Cannot use the _mock_cic idiom: ``estimator`` is now a setter-less + property, so ``setattr`` on a real ChangesInChangesResults raises. + Dispatch keys on type(...).__name__, so a separate class sharing the + name is the supported way to build a pre-rename duck type. + """ + + class ChangesInChangesResults: # noqa: F811 - deliberate name shadow + def __init__(self): + self.estimator = "qdid" + self.att = 0.5 + self.covariates = None + self.n_bootstrap = 200 + self.n_bootstrap_valid = 200 + self.panel = False + + out = _steps(ChangesInChangesResults()) + assert any("QDiD" in s.get("label", "") + s.get("why", "") for s in out), ( + "a legacy duck-typed result carrying only `estimator` must still reach the " + "QDiD branch - otherwise the __dict__ fallback is dead code" + ) From 0dd489380c2de51000b76e44eaecc7dea27f844f Mon Sep 17 00:00:00 2001 From: igerber Date: Sun, 9 Aug 2026 09:17:09 -0400 Subject: [PATCH 2/2] fix: restore the CHANGELOG Added heading and drop the CiC-only covariates wording CI review, both non-blocking. P2: the new '### Deprecated' heading swallowed the pre-existing Phase 3(b) 'TripleDifference serves both DDD designs' entry, rendering an Added item as a deprecation. Restores an '### Added' before it - the Unreleased block already repeats headings per wave, so this matches the file's own convention. P3: fit()'s covariates entry described the branch as 'conditional (quantile-regression) CiC', implying covariates do not apply to method='qdid'. They do; only the cells regressed differ (CiC two control cells, QDiD three). The class docstring was already made method-aware - this is its fit-level twin, which the original sweep missed. Sphinx -W still builds with zero warnings (the edit stays inside the numpydoc Parameters block's indentation, the exact shape that broke in 3(b)). --- CHANGELOG.md | 1 + diff_diff/changes_in_changes.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa947cb..310a0550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 use `ChangesInChanges(method="qdid")`. The `QDiDResults` alias is deprecated with it ([M-061]). Constructing `QDiD` warns; the numbers are unchanged. +### Added - **TripleDifference serves both DDD designs** (v4 program Phase 3(b); ledger rows [M-013] shimmed, [M-064]): `TripleDifference().fit(..., unit=, time=, first_treat=, partition=)` estimates the staggered-adoption DDD design that diff --git a/diff_diff/changes_in_changes.py b/diff_diff/changes_in_changes.py index 1fc9a98f..91d5a30a 100644 --- a/diff_diff/changes_in_changes.py +++ b/diff_diff/changes_in_changes.py @@ -1348,12 +1348,16 @@ def fit( raises - deliberately stricter than ``DifferenceInDifferences``, which silently lets the formula win. covariates : list of str, optional - Numeric covariate columns for the conditional (quantile- - regression) CiC - qte's ``xformla``. Fit-time argument only (not a - hyperparameter; absent from ``get_params()``, like ``unit``). - Dummy-encode categorical covariates first. In panel mode, - covariates may be time-varying: each (group, period) cell uses its - own rows' covariate values, exactly like qte. + Numeric covariate columns selecting the conditional + (quantile-regression) branch of whichever estimator ``method`` + names - qte's ``xformla``, supported for BOTH ``"cic"`` and + ``"qdid"``. The cells regressed differ: CiC fits the two control + cells, QDiD fits three (both control cells plus treated-pre). + Fit-time argument only (not a hyperparameter; absent from + ``get_params()``, like ``unit``). Dummy-encode categorical + covariates first. In panel mode, covariates may be time-varying: + each (group, period) cell uses its own rows' covariate values, + exactly like qte. unit : str, optional Unit identifier column. Required when ``panel=True``; ignored (documented) when ``panel=False``, matching qte's ``idname``.