Skip to content

ChangesInChanges absorbs QDiD via method= - M-015/M-143 (phase 3(c)) - #758

Merged
igerber merged 2 commits into
mainfrom
feat/v4-3c-cic-method
Aug 9, 2026
Merged

ChangesInChanges absorbs QDiD via method= - M-015/M-143 (phase 3(c))#758
igerber merged 2 commits into
mainfrom
feat/v4-3c-cic-method

Conversation

@igerber

@igerber igerber commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 3(c) of the 4.0 program — the last non-terminal phase-3 row, so 3.9 is now implementation-complete and what follows is Phase 4 (migration guide, version bump, cut).

  • ChangesInChanges(method="qdid") fits the estimator the standalone QDiD class owned; method="cic" (the default, encoding Athey & Imbens' p. 447 recommendation) is unchanged. method= is keyword-only, so every existing positional slot is preserved, and lowercase-only.
  • 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. No engine relocation, no mixin, no stacklevel threading.
  • Only the CLASS spelling is deprecated, not the estimator. method="qdid" is fully supported 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 caution where a user can act on it, and a third warning would blur which of the class/method is dying.
  • QDiD warns on construction (FutureWarning) and is removed in 4.0 [M-015]; the QDiDResults alias dies with it [M-061].
  • Results field estimatormethod [M-143], the section-8 rule-9 mirror. Read-only deprecation property, __setstate__ pickle migration, and a dual-key to_dict() window through 3.9.
  • Errors raised during a method="qdid" fit now name ChangesInChanges rather than a class the caller never constructed (estimator_name derives from type(est).__name__).
  • practitioner.py no longer emits QDiD(...) in generated guidance.

Two things reviewers may want to look at directly

Validation lives in two places on purpose. __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 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.

The deprecation 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_coltakeup) and M-114 (groupsunits) — neither of which preserved a deprecated constructor keyword. The container has one library call site, and a constructor shim would add a deprecated surface carrying no ledger row. Pinned by a test so the documented contract cannot drift either way.

Methodology references

  • Method name(s): Changes-in-Changes and Quantile DiD, 2×2 design. No estimator equation, weighting, bootstrap scheme, variance/SE, inference formula, cell selection, or identification guard is changed by this PR — it is an API merge over an already-shared engine.
  • Paper / source link(s): Athey, S. & Imbens, G. W. (2006), "Identification and Inference in Nonlinear Difference-in-Differences Models", Econometrica 74(2), 431–497 — QDiD is Section 3.3 (pp. 446–447), and the CiC-over-QDiD recommendation the default encodes is p. 447. Parity target qte v1.3.1 (qte::CiC() / qte::QDiD()).
  • Intentional deviations: none introduced. Two decisions are recorded as REGISTRY - **Note:** entries: (1) method="qdid" deliberately emits no advisory warning, with the reasoning above; (2) the M-143 deprecation window covers reads and pickles but not construction, matching M-094/M-114. Mode-specific behaviour that the shared signature can hide is documented explicitly — the eq. 17 interior-range guard and q_lower/q_upper are CiC-only (NaN for every qdid fit), and the covariate branch fits QRs in two control cells for CiC but three for QDiD.

Validation

  • Tests added: tests/test_v4_merge_cic.py (63 gates across parity, committed pre-merge oracle, validation, deprecation, field shim, engine guards, consumers) and tests/_capture_v4_merge_cic_oracles.py (the capture script).
    • Parity is bit-exact (assert_array_equal) — same engine, same process, so a needed tolerance would itself be the finding.
    • The oracle literals were captured from the unmodified pre-merge tree at c2941caa under DIFF_DIFF_BACKEND=python. 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.
    • The consumer gate execs the emitted practitioner snippet rather than compiling it, since a missing import is a run-time NameError invisible to compile().
  • Tests updated: test_practitioner.py (7 mock sites → method=), test_t27_cic_distributional_effects_drift.py (migrated off the deprecated class; its simplefilter("error") block would otherwise defeat the pyproject filter), test_base_estimator.py, test_naming_guard.py, test_v4_matrix.py.
  • Suites run: new gates 63/63; naming guard 20/20; matrix + base-estimator + inference-policy + serialization 718; CiC family incl. R-golden parity + drift + carousel 289; docs gates 373.
  • Notebook evidence: docs/tutorials/27_cic_distributional_effects.ipynb migrated off the deprecated class (import in cell 2, constructions in cell 23, stored practitioner output in cell 29) and executed under the diffdiff-dev kernel — 5-line diff, no output churn.
  • Other gates: Sphinx -W build succeeded with zero warnings; ruff, black, and mypy clean at the CI-pinned toolchain.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes. The pre-push scan flagged tests/test_naming_guard.py, verified as a false positive — the pattern token[[:space:]]*[=:] matched the literal phrase "M-143's old token:" in a code comment. No credential material is involved.

…ase 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.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

Overall assessment

Looks good — no unmitigated P0/P1 findings.

Executive summary

  • CiC and QDiD methodology, weighting, bootstrap, and inference remain unchanged.
  • method propagation and result-field migration are complete.
  • NaN inference continues through safe_inference.
  • Two non-blocking documentation issues remain.

Methodology

No findings. The affected methods are Changes-in-Changes and QDiD. Dispatch remains on the shared engine and agrees with the documented behavior in REGISTRY.md:L4640-L4642 and L4703-L4717.

Code Quality

No findings.

Performance

No findings. Dispatch adds negligible overhead and does not alter estimator complexity.

Maintainability

No findings. Validation, pickle migration, and the deprecated read-only alias are centralized and tested.

Tech Debt

No findings. Existing methodology limitations remain tracked in DEFERRED.md.

Security

No findings. No apparent secrets or unsafe behavior were introduced.

Documentation/Tests

P2 — Existing addition is now under the Deprecated heading

  • Location: CHANGELOG.md:L36-L41
  • Impact: The TripleDifference serves both DDD designs addition is rendered as a deprecated change because the new ### Deprecated heading remains active.
  • Concrete fix: Add ### Added before the TripleDifference entry, or relocate the new entries into the corresponding existing sections.

P3 — Fit docstring describes covariates as CiC-only

  • Location: diff_diff/changes_in_changes.py:L1350-L1356
  • Impact: Readers may incorrectly conclude that covariates= does not apply to method="qdid".
  • Concrete fix: Describe it as the selected estimator’s conditional quantile-regression branch and mention the two-cell/three-cell distinction.

Verification was static because the review environment lacks pytest and runtime dependencies.

…ates 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)).
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 0dd489380c2de51000b76e44eaecc7dea27f844f


Overall assessment

Looks good — no unmitigated P0/P1 findings.

Executive summary

  • Both prior documentation findings are resolved.
  • CiC/QDiD equations, weighting, bootstrap, and inference remain unchanged.
  • method is fully propagated through validation, cloning, fitting, bootstrap, and results.
  • Registry notes document the intentional compatibility behavior.
  • Static parsing passed; runtime tests were unavailable because dependencies are absent.

Methodology

No findings. The affected methods are Changes-in-Changes and QDiD. Dispatch matches the documented mode-specific behavior in docs/methodology/REGISTRY.md:L4639-L4642 and L4703-L4717. Inference remains routed through safe_inference.

Code Quality

No findings.

Performance

No findings. The dispatch adds negligible overhead.

Maintainability

No findings. Validation and legacy result-field migration are centralized and tested.

Tech Debt

No findings. Existing methodological limitations remain documented and tracked.

Security

No findings. No secrets or unsafe behavior were identified.

Documentation/Tests

No findings.

  • The TripleDifference entry is again under an ### Added heading (CHANGELOG.md:L41).
  • The fit docstring now describes covariates for both methods (diff_diff/changes_in_changes.py:L1350-L1360).
  • Changed Python files passed AST parsing.
  • pytest and runtime dependencies were unavailable in the review environment.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Aug 9, 2026
@igerber
igerber merged commit baa3e90 into main Aug 9, 2026
45 of 46 checks passed
@igerber
igerber deleted the feat/v4-3c-cic-method branch August 9, 2026 14:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant