Skip to content

Commit a0a63c2

Browse files
authored
feat(continuous-did): covariate support (reg + dr) under conditional parallel trends (#616)
1 parent bcd00f6 commit a0a63c2

9 files changed

Lines changed: 1129 additions & 95 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **`ContinuousDiD` covariate support** (`covariates=`, `estimation_method ∈ {"reg", "dr"}`) for
12+
dose-response estimation under **conditional** parallel trends
13+
(`E[ΔY(0) | D=d, X] = E[ΔY(0) | D=0, X]`). `reg` uses an outcome-regression control counterfactual;
14+
`dr` (default) is doubly-robust (DRDID `drdid_panel`). The scalar `overall_att` + standard error
15+
match `DRDID::reg_did_panel` / `drdid_panel` to ~1e-8; analytical, multiplier-bootstrap, and
16+
event-study inference all compose with covariates. `reg` and `dr` share the dose-response *shape*
17+
and `ACRT(d)`, differing only in the `overall_att` / ATT(d) level and the doubly-robust SE.
18+
`estimation_method="ipw"` with covariates raises `NotImplementedError` (pure IPW's covariate
19+
adjustment is a scalar level shift and cannot adjust the curve shape); `covariates=` +
20+
`survey_design=` is deferred (`NotImplementedError`).
1121
- **`HeterogeneousAdoptionDiD` event-study `cluster=` support** (both designs). On
1222
`aggregate="event_study"`, `cluster=` now produces cluster-robust per-horizon pointwise
1323
confidence intervals AND a cluster-robust simultaneous sup-t confidence band (`cband=True`),

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The `Origin` column (Actionable tables) and the `PR` column (Deferred tables) bo
2929
| Issue | Location | Origin | Effort | Priority |
3030
|-------|----------|--------|--------|----------|
3131
| `SyntheticControl` conformal (CWZ 2021) extensions: (a) one-sided / signed-`t` variants (§7); (b) covariates in the conformal proxy (`X_jt`, eqs 4/6 — current proxy is outcomes-only); (c) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies. The joint test, pointwise CIs, and average-effect CI have landed. | `conformal.py`, `synthetic_control_results.py` | CWZ-2021 | Heavy | Low |
32-
| `ContinuousDiD` CGBS-2024 extensions (matches R `contdid` v0.1.0 deferral set): (a) `covariates=` kwarg; (b) discrete-treatment saturated regression (integer dose currently warned, not routed to per-level coefficients); (c) lowest-dose-as-control per Remark 3.1 when `P(D=0)=0`. REGISTRY `## ContinuousDiD` → Implementation Checklist marks these `[ ]`. | `continuous_did.py` | CGBS-2024 | Heavy | Low |
32+
| `ContinuousDiD` CGBS-2024 extensions. (a) `covariates=` kwarg**DONE (reg/dr)**; remaining: (b) discrete-treatment saturated regression (integer dose currently warned, not routed to per-level coefficients); (c) lowest-dose-as-control per Remark 3.1 when `P(D=0)=0`. Also deferred from the covariate work: `estimation_method="ipw"` on the dose curve (scalar-adjustment / degenerate — documented `NotImplementedError`), and `covariates=` × `survey_design=` (weighted OR + weighted nuisance IF). | `continuous_did.py` | CGBS-2024 | Heavy | Low |
3333
| `ImputationDiD` LOO conservative-variance refinement (BJS 2024 Supp. Appendix A.9) — a finite-sample improvement to the auxiliary-model residuals reducing overfit of `tau_tilde_g` to `epsilon`. Asymptotic Theorem-3 variance is implemented and matches R `didimputation` (which also omits LOO by default). | `imputation.py` | imputation-validation | Mid | Low |
3434
| `TwoWayFixedEffects(vcov_type in {hc2, hc2_bm})` with replicate-weight designs raises `NotImplementedError` (`twfe.py:~233`). The replicate path re-demeans per replicate, which doesn't compose with the full-dummy HC2/HC2-BM build — a correct impl needs per-replicate full-dummy refit. Workaround: `hc1` for replicate-weight CR1. | `twfe.py::fit` | follow-up | Heavy | Low |
3535
| TWFE's HC2/HC2-BM inline full-dummy build (`twfe.py:280-315`) duplicates the dummy-construction logic in `DifferenceInDifferences(fixed_effects=...)` (`estimators.py:478-486`). Extract a shared helper, or delegate TWFE's HC2/HC2-BM path to DiD's `fixed_effects=` branch (with TWFE-specific cluster-default threading), to reduce drift risk on FE naming / survey behavior / result-surface conventions. Substantive refactor — touches both estimators. | `twfe.py::fit`, `estimators.py::DifferenceInDifferences.fit` | follow-up | Heavy | Low |

diff_diff/continuous_did.py

Lines changed: 422 additions & 13 deletions
Large diffs are not rendered by default.

diff_diff/continuous_did_results.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ class ContinuousDiDResults:
139139
bootstrap_weights: str = "rademacher"
140140
seed: Optional[int] = None
141141
rank_deficient_action: str = "warn"
142+
# Covariate adjustment (conditional parallel trends). ``covariates`` is None
143+
# for the unconditional path; ``estimation_method`` is only meaningful when
144+
# covariates are used (``"reg"`` or ``"dr"``).
145+
covariates: Optional[List[str]] = field(default=None)
146+
estimation_method: str = "dr"
147+
pscore_trim: float = 0.01
148+
epv_threshold: float = 10.0
149+
pscore_fallback: str = "error"
142150
event_study_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None)
143151
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
144152
survey_metadata: Optional[Any] = field(default=None)
@@ -224,8 +232,11 @@ def summary(self, alpha: Optional[float] = None) -> str:
224232
f"{'Interior knots:':<30} {self.num_knots:>10}",
225233
f"{'Base period:':<30} {self.base_period:>10}",
226234
f"{'Anticipation:':<30} {self.anticipation:>10}",
227-
"",
228235
]
236+
if self.covariates:
237+
lines.append(f"{'Covariates:':<30} {', '.join(self.covariates):>10}")
238+
lines.append(f"{'Estimation method:':<30} {self.estimation_method:>10}")
239+
lines.append("")
229240

230241
# Add survey design info
231242
if self.survey_metadata is not None:

diff_diff/guides/llms-full.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,9 +696,21 @@ ContinuousDiD(
696696
bootstrap_weights: str = "rademacher",
697697
seed: int | None = None,
698698
rank_deficient_action: str = "warn",
699+
covariates: list[str] | None = None, # Conditional parallel trends (X). None = unconditional
700+
estimation_method: str = "dr", # "reg" or "dr" (used only with covariates); "ipw" not
701+
# supported on the dose curve (raises NotImplementedError)
702+
pscore_trim: float = 0.01, # dr propensity trimming
703+
epv_threshold: float = 10.0, # dr propensity events-per-variable
704+
pscore_fallback: str = "error", # dr propensity-failure action: "error" (fail-closed
705+
# default) or "unconditional" (opt-in reg-like fallback)
699706
)
700707
```
701708

709+
Covariates give conditional parallel trends: each (g,t) cell's control counterfactual becomes a
710+
covariate-adjusted prediction. `reg`/`dr` share the ATT(d) *shape* and ACRT(d); `dr` (doubly-robust,
711+
default) differs only in the overall_att/ATT(d) level and SE. `overall_att`+SE match DRDID
712+
reg_did_panel/drdid_panel. `covariates=` + `survey_design=` is not yet supported.
713+
702714
**Alias:** `CDiD`
703715

704716
**fit() parameters:**

docs/methodology/REGISTRY.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,12 @@ No selection into dose groups on the basis of treatment effects.
889889
Implies `ATT(d|d) = ATT(d)` for all d.
890890
Additionally identifies: `ATT(d)`, `ACRT(d)`, `ACRT^{glob}`, and cross-dose comparisons.
891891

892+
**Conditional Parallel Trends (with covariates).** When `covariates=` is passed the PT/SPT
893+
assumptions are conditional on covariates `X`:
894+
`E[Y_t(0) - Y_{t-1}(0) | D = d, X] = E[Y_t(0) - Y_{t-1}(0) | D = 0, X]`. The per-`(g,t)` cell's
895+
control counterfactual becomes a covariate-adjusted prediction instead of the unconditional control
896+
mean (see Key Equations and the covariate Note below).
897+
892898
See `docs/methodology/continuous-did.md` Section 4 for full details.
893899

894900
### Key Equations
@@ -907,6 +913,17 @@ See `docs/methodology/continuous-did.md` Section 4 for full details.
907913
3. OLS: `beta = (Psi'Psi)^{-1} Psi' Delta_tilde_Y`
908914
4. `ATT(d) = Psi(d)' beta`, `ACRT(d) = dPsi(d)/dd' beta`
909915

916+
**Covariate-adjusted `Delta_tilde_Y` (conditional PT).** With `covariates=`, step 1's scalar control
917+
mean is replaced by a per-treated-unit covariate-adjusted counterfactual (`X_i` includes an intercept):
918+
- `reg` (outcome regression): fit `gamma_hat = (X_C'X_C)^{-1} X_C' Delta_Y_C` on controls;
919+
`Delta_tilde_Y_i = Delta_Y_i - X_i' gamma_hat`.
920+
- `dr` (doubly-robust, DRDID `drdid_panel`): same OLS `gamma_hat`, plus a propensity model and a scalar
921+
augmentation `eta_cont = odds_weighted_mean_C(Delta_Y - X' gamma_hat)`;
922+
`Delta_tilde_Y_i = Delta_Y_i - X_i' gamma_hat - eta_cont`.
923+
Steps 2-4 are unchanged. Because the augmentation is a constant, `reg` and `dr` share the same
924+
`ACRT(d)` (a constant only shifts the B-spline intercept, which `dPsi` annihilates); they differ only
925+
in the `ATT(d)` / `ATT^{glob}` level (by `-eta_cont`) and in the doubly-robust SE.
926+
910927
### Edge Cases
911928

912929
- **No untreated group**: Remark 3.1 (lowest-dose-as-control) not implemented; requires P(D=0) > 0.
@@ -939,6 +956,7 @@ labels.*
939956
2. **Note:** `bspline_derivative_design_matrix` derivative-failure `UserWarning` — Phase 2 axis-C #12 silent-failures audit fix. No R correspondence; `contdid` v0.1.0 does not implement an equivalent warning. Cross-references the § Edge Cases `**Note:**` bullet above (`bspline_derivative_design_matrix` entry) and `METHODOLOGY_REVIEW.md` § ContinuousDiD Deviations #2. Locked in `tests/test_continuous_did.py::TestBSplineDerivativeDegenerateBasis` (3 tests); source-level aggregate-warning block at `diff_diff/continuous_did_bspline.py:150-187`.
940957
3. **Note:** `+inf` → `0` never-treated recoding emits `UserWarning` reporting the affected row count; negative `first_treat` (including `-inf`) raises `ValueError`. Axis-E silent-coercion fix per Phase 2 audit. No R correspondence; `contdid` v0.1.0 silently absorbs `+inf` without a signal. Cross-references the § Implementation Checklist `**Note:**` below and `METHODOLOGY_REVIEW.md` § ContinuousDiD Deviations #3.
941958
4. **Note:** Zero-`first_treat` rows with nonzero `dose` are force-zeroed with `UserWarning` reporting the affected row count (axis-E silent-coercion). No R correspondence; `contdid` v0.1.0 has the same `first_treat = 0` → `D = 0` invariant but silently coerces without a warning. Cross-references the § Implementation Checklist `**Note:**` below and `METHODOLOGY_REVIEW.md` § ContinuousDiD Deviations #4.
959+
5. **Note (covariate support — library extension beyond `contdid` v0.1.0):** `covariates=` with `estimation_method ∈ {reg, dr}` adds conditional-parallel-trends adjustment. This is a **library extension**: `contdid` v0.1.0 hard-stops on any covariate (`stop("covariates not currently supported…")`), so there is **no external R anchor for the covariate-adjusted dose *curve***. Validation instead: (a) the **scalar `overall_att` + SE** map *exactly* onto `DRDID::reg_did_panel` (reg) / `DRDID::drdid_panel` (dr) — a tight (~1e-8) component anchor, skip-guarded since DRDID is not in CI (`tests/test_methodology_continuous_did.py::TestCovariateReg`); (b) an **R-free NumPy reconstruction** of the reg/dr `att`+SE runs *in CI* at p≥2 (`test_dr_reg_numpy_crosscheck_p2`) — the guard the p=1 reduction cannot provide (at p=1 the intercept-only propensity is constant, so `eta_cont ≡ 0` and dr collapses to reg); (c) DGP recovery + MC coverage (reg 96%, dr 95%). **`ipw` restricted:** `estimation_method="ipw"` with covariates raises `NotImplementedError` — pure IPW's covariate adjustment is a single scalar (a propensity-reweighted control mean) that shifts only the ATT(d) level and leaves `ACRT(d)` identical to the unconditional fit, so it cannot adjust the dose-response *shape*. **Deviations from DRDID:** unit weights are 1 (unweighted; `covariates=` + `survey_design=` raises `NotImplementedError`, deferred); propensity trimming uses clip semantics (`pscore_trim`) rather than DRDID's drop-trimming — identical on moderate-overlap data (the anchor regime), diverging only at extreme propensities. **Fail-closed policies (no-silent-failures):** (i) missing/non-finite covariate values raise `ValueError` up front — a per-cell fallback to unconditional estimation would silently mix conditional-PT and unconditional-PT cells in the aggregate; (ii) `dr` propensity-estimation failure raises by default (`pscore_fallback="error"`) so a `dr` fit never silently degrades to a non-DR estimate — `pscore_fallback="unconditional"` opts into the graceful (warned, reg-like) fallback. Cross-references `docs/methodology/continuous-did.md` § Covariates.
942960

943961
### Implementation Checklist
944962

@@ -948,7 +966,7 @@ labels.*
948966
- [x] Multiplier bootstrap for inference
949967
- [x] Analytical SEs via influence functions
950968
- [x] Equation verification tests (linear, quadratic, multi-period)
951-
- [ ] Covariate support (deferred, matching R v0.1.0)
969+
- [x] Covariate support (reg / dr) — conditional parallel trends. **ipw restricted** (see Note below); survey × covariate deferred; discrete-saturated & Remark 3.1 still deferred.
952970
- [ ] Discrete treatment saturated regression
953971
- [ ] Lowest-dose-as-control (Remark 3.1)
954972
- [x] Survey design support (Phase 3): weighted B-spline OLS, TSL on influence functions; bootstrap+survey supported (Phase 6)

docs/methodology/continuous-did.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,14 @@ can be meaningfully aggregated.
434434
### Phase 3 (Advanced)
435435
8. CCK nonparametric estimation
436436
9. Uniform confidence bands
437-
10. Covariates support (DR/IPW/OR)
437+
10. Covariates support — **implemented** for outcome-regression (`reg`) and doubly-robust (`dr`)
438+
under conditional parallel trends (`covariates=`, `estimation_method=`). Each `(g,t)` cell replaces
439+
the unconditional control mean with a covariate-adjusted counterfactual (`reg`:
440+
`ΔY_i − X_i'γ̂`; `dr`: additionally minus the DRDID augmentation `η̄_cont`); the B-spline dose
441+
layer is unchanged. Scalar `overall_att` + SE match `DRDID::reg_did_panel` / `drdid_panel`. `ipw`
442+
is not offered on the dose curve (its covariate adjustment is a scalar level shift → `ACRT(d)`
443+
unchanged), and `covariates=` + `survey_design=` is deferred. See REGISTRY § ContinuousDiD
444+
Note #5.
438445

439446
### Defer
440447
- Discrete treatment (saturated regression — simpler, add later)

0 commit comments

Comments
 (0)