Skip to content

feat(continuous-did): lowest-dose-as-control (control_group="lowest_dose", Remark 3.1)#623

Merged
igerber merged 3 commits into
mainfrom
feature/continuous-did-lowest-dose
Jul 6, 2026
Merged

feat(continuous-did): lowest-dose-as-control (control_group="lowest_dose", Remark 3.1)#623
igerber merged 3 commits into
mainfrom
feature/continuous-did-lowest-dose

Conversation

@igerber

@igerber igerber commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • Implement CGBS 2024 Remark 3.1 lowest-dose-as-control (control_group="lowest_dose") for ContinuousDiD: when there is no untreated group (P(D=0)=0), the lowest-dose group d_L becomes the comparison and the estimand is ATT(d) - ATT(d_L) (with ATT(d_L)=0 the omitted reference). Mechanically a control-group swap — the D=0 control pool is replaced by the d_L group, so the linear influence-function / bootstrap / event-study / survey machinery is reused unchanged (ee_control already carries the reference-group variance; no new SE plumbing). Both treatment_type="discrete" and the continuous B-spline path are supported; the discrete ACRT backward-difference reference shifts from 0 to d_L (ACRT(d_1)=ATT(d_1)/(d_1-d_L)) via a new base param on saturated_derivative_design_matrix. The continuous path requires a genuine mass point at the minimum dose (P(D=d_L)>0). Results gain a reference_dose field.
  • Fail-closed guards: never-treated units present, a singleton d_L, no treated dose above d_L, dvals <= d_L, or a survey subpopulation zeroing the d_L group all raise; multi-cohort and covariates= × lowest_dose raise NotImplementedError (deferred). The never_treated / not_yet_treated / continuous default paths are numerically unchanged.
  • Closes TODO Actionable (c) for the CGBS-2024 ContinuousDiD extensions; sweeps stale "requires never-treated / Remark 3.1 not implemented" capability claims across the guides, profiler, and practitioner routing.

Methodology references

  • Method name(s): ContinuousDiD lowest-dose-as-control (Remark 3.1)
  • Paper / source link(s): Callaway, Goodman-Bacon & Sant'Anna (2024), "Difference-in-Differences with a Continuous Treatment," NBER Working Paper 32117 — Remark 3.1.
  • Any intentional deviations from the source (and why): Library extension beyond R contdid v0.1.0, which does not implement Remark 3.1 (no external R anchor). Documented as docs/methodology/REGISTRY.md § ContinuousDiD Note Prepare v0.2.0 release #7. Multi-cohort lowest_dose (needs a within-cohort reference + support-aware cross-cohort aggregation) and covariates= × lowest_dose (conditional-PT-relative-to-d_L estimand) are deferred, fail-closed, and tracked in TODO.md.

Validation

  • Tests added/updated: tests/test_methodology_continuous_did.py::TestLowestDose (exact d_L→0 equivalence anchor — relabelling a never_treated panel's D=0 group as a tiny common dose reproduces the never_treated ATT and SE exactly; discrete hand-calc of ATT/ACRT/overall_att/overall_acrt and the per-level 2×2 SE at ~1e-10; continuous mass-point DGP recovery; analytical-vs-bootstrap SE agreement; pre-period placebo; MC coverage) and tests/test_continuous_did.py::TestLowestDoseAPI (transactional params, all fail-closed guards incl. the survey-zeroed d_L group, threshold consistency, metadata/reference_dose, event-study, idempotent refit). Full existing ContinuousDiD suites pass unchanged (byte-stable never_treated/continuous paths).
  • Backtest / simulation / notebook evidence (if applicable): N/A — validation is R-free and runs in CI.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

🤖 Generated with Claude Code

https://claude.ai/code/session_01LHDijzf8zHXk5T8ahS2mKi

igerber and others added 2 commits July 6, 2026 09:30
…ose", Remark 3.1)

Implement CGBS 2024 Remark 3.1 for settings with no untreated group
(P(D=0)=0): the lowest-dose group d_L becomes the comparison and the
estimand is ATT(d) - ATT(d_L) (with ATT(d_L)=0 the omitted reference).

Mechanically a control-group swap -- the D=0 control pool is replaced by
the d_L group, so the linear influence-function / bootstrap / event-study
/ survey machinery is reused unchanged (ee_control already carries the
reference-group variance; no new SE plumbing). Both treatment_type=
"discrete" and the continuous B-spline path are supported; the discrete
ACRT backward-difference reference shifts from 0 to d_L via a new `base`
param on saturated_derivative_design_matrix. The continuous path requires
a genuine mass point at the minimum dose (P(D=d_L)>0). Results gain a
reference_dose field (= d_L); n_treated/n_control metadata gated on the
lowest_dose path (byte-stable elsewhere).

Fail-closed guards: never-treated units present, a singleton d_L, no
treated dose above d_L, dvals <= d_L, or a survey subpopulation zeroing
the d_L group all raise; multi-cohort and covariates x lowest_dose raise
NotImplementedError (deferred). The never_treated / not_yet_treated /
continuous default paths are numerically unchanged.

Validation (R-free, in CI): an exact d_L->0 equivalence anchor
(relabelling a never_treated panel's D=0 group as a tiny common dose
reproduces the never_treated ATT and SE exactly), discrete hand-calc of
ATT/ACRT/overall/overall_acrt and the per-level 2x2 SE (~1e-10),
continuous mass-point DGP recovery, analytical-vs-bootstrap SE agreement,
a pre-period placebo, event-study composition, and MC coverage.

Closes TODO Actionable (c) for the CGBS-2024 ContinuousDiD extensions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHDijzf8zHXk5T8ahS2mKi
…ion-tree routing

Address local review P3s: a direct regression test for the fail-closed
guard when a survey subpopulation zeroes the entire lowest-dose reference
group, and reword the "Universal Rollout" decision-tree recommendation to
make ContinuousDiD(control_group="lowest_dose") vs HeterogeneousAdoptionDiD
conditional on whether a lowest-dose mass point exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHDijzf8zHXk5T8ahS2mKi
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

PR Review: ContinuousDiD control_group="lowest_dose"

Overall Assessment

⚠️ Needs changes — one unmitigated P1 finding around survey-weighted edge-case handling for the new lowest_dose reference group.

Executive Summary

  • The core lowest_dose control swap matches REGISTRY.md Note Prepare v0.2.0 release #7: modelled treated units are D > d_L, controls are D = d_L, and discrete ACRT references d_L.
  • Multi-cohort and covariates= interactions are fail-closed and documented in TODO.md / REGISTRY.md, so they are not defects.
  • The new tests cover most advertised guards and methodology anchors.
  • P1: survey/subpopulation weights can leave only one positive-weight d_L control unit, despite the documented >=2 reference-group requirement, leading to potentially understated/undefined reference-group variance.
  • I could not run tests in this review environment because numpy is not installed.

Methodology

Finding 1 — P1: Survey domain can reduce d_L to an effective singleton

Location: diff_diff/continuous_did.py:L564-L580, diff_diff/continuous_did.py:L643-L655, diff_diff/continuous_did.py:L1553-L1568, diff_diff/continuous_did.py:L1783-L1787

Impact: REGISTRY.md documents lowest_dose as requiring a genuine lowest-dose group with >= 2 units at d_L (docs/methodology/REGISTRY.md:L1006, L1038). The raw-data guard enforces that before survey weights, but the survey guard only rejects the case where all d_L weights are zero. If a subpopulation/domain keeps exactly one positive-weight d_L unit, the fit proceeds. Then mu_0 is based on that one reference unit and ee_control = w_control * (delta_y_control - mu_0) contributes zero reference variance for the positive-weight singleton, so inference can report finite SE/CI/p-values with an effectively unidentified control variance.

Concrete fix: When lowest_dose is not None and survey weights are present, require at least two positive-weight d_L units before fitting, preferably in the per-cell path as well:
np.count_nonzero(w_control > 0) >= 2.
For clustered survey designs, consider requiring at least two positive-weight d_L PSUs as the design-effective guard. Add a regression test where the raw d_L group has multiple units but survey weights leave only one positive-weight d_L unit, and assert ValueError.

Code Quality

No additional findings. The new control_group dispatch is explicit, get_params() propagation is present, and no new inline inference anti-patterns were introduced.

Performance

No findings.

Maintainability

No findings. The implementation keeps the new behavior localized by threading lowest_dose through precomp instead of mutating estimator state.

Tech Debt

No blocker findings. The deferred multi-cohort lowest_dose and covariates= × lowest_dose work is documented in TODO.md and REGISTRY.md, so it is P3-informational only.

Security

No findings.

Documentation/Tests

Finding 2 — P3: Missing survey effective-singleton regression test

Impact: The current test suite includes the fully zeroed d_L survey case, but not the “only one positive-weight d_L unit remains” case described above.

Concrete fix: Add a test in TestLowestDoseAPI with d_L group size >= 2, set all but one d_L unit’s survey weight to zero, and assert the estimator raises.

Path to Approval

  1. Add a survey-weighted lowest_dose guard requiring at least two positive-weight d_L reference units after applying survey/subpopulation weights.
  2. Add a regression test for the effective-singleton d_L survey case.

…ey weighting (CI P1)

The raw lowest-dose mass-point guard (>= 2 units at d_L) runs before survey
weights. A subpopulation/domain that keeps exactly one positive-weight d_L
unit slipped through the survey guard (which only rejected an all-zero d_L
group): with one positive-weight reference unit, mu_0 equals that unit's dY
so its ee_control = w*(dY - mu_0) = 0 and the reference contributes zero
control-side variance, yielding a finite but effectively unidentified SE.

Enforce the same effective >= 2 positive-weight requirement in the survey
guard (np.count_nonzero(dL_w > 0) >= 2). Add a regression test for the
effective-singleton case; update the zeroed-group test's message match and
REGISTRY Note #7 / CHANGELOG wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHDijzf8zHXk5T8ahS2mKi
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 67db3fb4b429ecdf9f27add320b8d7765ddf4f1e


Overall Assessment

Looks good — no unmitigated P0/P1 findings.

Executive Summary

  • The prior P1 is addressed: lowest_dose now requires >= 2 positive-weight d_L units after survey/subpopulation weighting, and a regression test covers the effective-singleton case.
  • The implementation matches REGISTRY.md Note Prepare v0.2.0 release #7: d_L is excluded from modelled doses, used as the control/reference group, and discrete ACRT shifts its base from 0 to d_L.
  • Deferred multi-cohort lowest_dose and covariates= × lowest_dose paths fail closed and are tracked in TODO.md.
  • No new inline inference or partial NaN-guard anti-patterns were introduced.
  • I could not run the targeted tests because pytest is not installed in this environment.

Methodology

No findings.

The affected method is ContinuousDiD with control_group="lowest_dose". The new guards and estimands align with docs/methodology/REGISTRY.md:L964, L1006, and Note #7 at docs/methodology/REGISTRY.md:L1038. The implementation computes d_L, excludes it from modelled doses/grid support, rejects invalid dvals <= d_L, and uses the d_L cohort as the per-cell control group at diff_diff/continuous_did.py:L541-L664 and diff_diff/continuous_did.py:L1515-L1541.

The prior survey effective-singleton issue is fixed at diff_diff/continuous_did.py:L643-L663, with coverage at tests/test_continuous_did.py:L2125-L2140.

Code Quality

No findings.

control_group propagation is present in validation and params, and the new reference_dose result field is passed through cleanly at diff_diff/continuous_did.py:L1106-L1140 and diff_diff/continuous_did_results.py:L154-L157.

Performance

No findings.

Maintainability

No findings.

The base parameter on saturated_derivative_design_matrix() keeps the discrete ACRT reference shift localized and documented at diff_diff/continuous_did_bspline.py:L324-L374.

Tech Debt

No blocking findings.

P3 informational: multi-cohort lowest_dose and covariates= × lowest_dose are deferred, fail closed, and tracked in TODO.md:L32 plus REGISTRY.md:L1038. No action required for this PR.

Security

No findings.

Documentation/Tests

No findings.

The new tests cover API propagation, fail-closed guards, event-study composition, reference_dose metadata, hand-calculated ATT/ACRT/SE checks, bootstrap-vs-analytical SE, and the prior survey effective-singleton regression. Targeted test execution was attempted with:

PYTHONDONTWRITEBYTECODE=1 pytest -q tests/test_continuous_did.py::TestLowestDoseAPI tests/test_methodology_continuous_did.py::TestLowestDose -p no:cacheprovider

but failed because pytest is not installed.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Jul 6, 2026
@igerber igerber merged commit d4a6e93 into main Jul 6, 2026
35 of 36 checks passed
@igerber igerber deleted the feature/continuous-did-lowest-dose branch July 6, 2026 16:00
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