Skip to content

Commit 1140ae8

Browse files
igerberclaude
andcommitted
fix(continuous-did): paper-faithful backward-to-zero discrete ACRT (fixes CI P1)
CI review flagged that a single positive dose (J=1) returned ACRT=0, which contradicts the documented binary identity ACRT=ATT and the paper's Eq. 4.1 d_0=0 discrete setup. The prior forward-difference at the lowest dose was a deviation; switch to the paper's backward difference on the grid {0, d_1, ..., d_J} (d_0=0, ATT(0)=0), so ACRT(d_1) = ATT(d_1)/d_1 and a binary D in {0,1} gives ACRT=ATT. Only the lowest dose's row changes; the j>=2 adjacent backward differences are unchanged. Consequence (correct): reg/dr share ACRT(d_j) for j>=2 (the constant DR augmentation cancels in adjacent differences) but differ at ACRT(d_1) by eta_cont/d_1 -- d_1 references the fixed baseline ATT(0)=0, which the augmentation does not shift. The dr influence function now carries the augmentation variance at d_1 (analytical ACRT(d_1) SE matches the multiplier bootstrap); applied only on the discrete path, so the validated B-spline covariate IF is byte-identical. The all-same-dose "ACRT will be 0" warning is suppressed on the discrete path (single-dose ACRT = ATT(d_1)/d_1 != 0). Updates REGISTRY Note #5/#6 + Key Equations, continuous-did.md 5.1, CHANGELOG, llms-full.txt, docstring; adds binary-single-dose and dr-ACRT-analytical-vs- bootstrap tests; updates the ACRT-boundary / reg-dr / DGP-recovery tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHDijzf8zHXk5T8ahS2mKi
1 parent 0ac13f9 commit 1140ae8

8 files changed

Lines changed: 145 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- **`ContinuousDiD` discrete-treatment saturated regression** (`treatment_type="discrete"`) for
1212
multi-valued / discrete dose (CGBS 2024 Eq. 4.1). Each distinct dose level gets its own effect
1313
coefficient — `ATT(d_j) = mean_{D=d_j}(ΔY) − control` (a per-level 2×2 DiD) — instead of a B-spline
14-
curve; `ACRT(d_j)` is a finite difference (forward at the lowest level, backward elsewhere). The
15-
saturated fit is an exact basis swap, so analytical, multiplier-bootstrap, covariate (`reg`/`dr`),
16-
and survey inference all compose and reduce analytically to the per-level 2×2 DiD standard error.
14+
curve; `ACRT(d_j)` is the paper's backward finite difference on the grid `{0, d_1, …, d_J}`
15+
(`ACRT(d_1) = ATT(d_1)/d_1`, so a binary `D ∈ {0,1}` gives `ACRT = ATT`). The saturated fit is an
16+
exact basis swap, so analytical, multiplier-bootstrap, covariate (`reg`/`dr`), and survey inference
17+
all compose and reduce analytically to the per-level 2×2 DiD standard error.
1718
Multi-cohort fits with heterogeneous dose support across cohorts raise `NotImplementedError`
1819
(support-aware aggregation deferred); an off-support `dvals` value raises `ValueError`. The default
1920
`treatment_type="continuous"` (B-spline) path is unchanged.

diff_diff/continuous_did.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,11 @@ class ContinuousDiD:
111111
``"discrete"`` (saturated per-dose-level regression, CGBS 2024 Eq. 4.1).
112112
On the discrete path each distinct dose level gets its own effect
113113
coefficient — ``ATT(d_j) = mean_{D=d_j}(ΔY) − control`` (a per-level 2×2
114-
DiD) — and ``ACRT(d_j)`` is a finite difference (forward at the lowest
115-
level, backward elsewhere). It composes with ``covariates`` and
116-
``survey_design`` and reduces to the per-level 2×2 DiD standard error.
114+
DiD) — and ``ACRT(d_j)`` is the paper's backward finite difference on the
115+
grid ``{0, d_1, ..., d_J}`` (``ACRT(d_1) = ATT(d_1)/d_1``, so a binary
116+
dose ``D in {0, 1}`` gives ``ACRT = ATT``). It composes with
117+
``covariates`` and ``survey_design`` and reduces to the per-level 2×2 DiD
118+
standard error.
117119
Multi-cohort fits must share the same dose support across cohorts (else
118120
``NotImplementedError``); an off-support ``dvals`` value raises
119121
``ValueError``.
@@ -1285,15 +1287,31 @@ def _covariate_cell_influence(
12851287
if_acrt_d = np.vstack([acrt_d_if_t, acrt_d_if_c])
12861288

12871289
if self.estimation_method == "dr":
1288-
# dr shares the reg curve shape (ACRT identical); att_glob / ATT(d)
1289-
# differ by the augmentation eta_cont. Ground att_glob's IF in the
1290-
# validated DRDID doubly-robust IF and shift ATT(d) uniformly by the
1291-
# augmentation IF (= reg att_glob IF - dr att_glob IF). ACRT untouched.
1290+
# The DR augmentation eta_cont shifts att_glob / ATT(d) by a constant;
1291+
# ground att_glob's IF in the validated DRDID doubly-robust IF and
1292+
# shift ATT(d) uniformly by the augmentation IF (= reg att_glob IF -
1293+
# dr att_glob IF). eta_cont perturbs beta by a constant direction
1294+
# `bread @ psi_bar` (= e_intercept for the B-spline basis, ones(J)
1295+
# for the saturated basis), so its effect on the curve is
1296+
# Psi_eval/dPsi_eval applied to that direction. For ATT that is the
1297+
# uniform shift below. For ACRT it is dPsi_eval @ (bread @ psi_bar):
1298+
# zero for the B-spline path (intercept derivative is 0) and for the
1299+
# discrete j>=2 rows (backward differences sum to 0), but NONZERO at
1300+
# the lowest discrete dose, whose backward-to-zero row references the
1301+
# fixed baseline ATT(0)=0. There, ACRT(d_1) = ATT(d_1)/d_1 genuinely
1302+
# depends on the DR level, so its IF must carry the augmentation
1303+
# variance. Applied only on the discrete path to keep the validated
1304+
# B-spline covariate IF byte-identical.
12921305
n_total = cov_adj["n_total"]
12931306
dr_att_glob_if = cov_adj["dr_inf"] / n_total # (n_total,)
12941307
if_eta = if_att_glob - dr_att_glob_if # augmentation IF, per unit
12951308
if_att_d = if_att_d - if_eta[:, np.newaxis]
12961309
if_att_glob = dr_att_glob_if
1310+
if self.treatment_type == "discrete":
1311+
const_dir = bread @ Psi.mean(axis=0) # (K,), = ones(J) here
1312+
acrt_shift = dPsi_eval @ const_dir # (n_grid,), = L @ 1
1313+
if_acrt_d = if_acrt_d - if_eta[:, np.newaxis] * acrt_shift[np.newaxis, :]
1314+
if_acrt_glob = if_acrt_glob - if_eta * float(dpsi_bar @ const_dir)
12971315

12981316
return {
12991317
"cell_indices": np.concatenate([treated_indices, control_indices]),
@@ -1481,8 +1499,12 @@ def _deriv(z: np.ndarray) -> np.ndarray:
14811499
"level from the dose grid."
14821500
)
14831501

1484-
# Check for all-same dose
1485-
if np.all(treated_doses == treated_doses[0]):
1502+
# Check for all-same dose. On the continuous (B-spline) path this
1503+
# collapses the basis so ACRT(d) = 0 everywhere. On the discrete path a
1504+
# single dose level (J=1) is a valid single-dose fit with
1505+
# ACRT(d_1) = ATT(d_1)/d_1 (backward difference to the zero-dose
1506+
# baseline), so the "ACRT will be 0" warning does not apply there.
1507+
if self.treatment_type != "discrete" and np.all(treated_doses == treated_doses[0]):
14861508
warnings.warn(
14871509
f"All treated doses identical in (g={g}, t={t}). " "ACRT(d) will be 0 everywhere.",
14881510
UserWarning,

diff_diff/continuous_did_bspline.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -325,15 +325,19 @@ def saturated_derivative_design_matrix(x, levels, tol=SATURATED_TOL):
325325
"""
326326
Finite-difference derivative rows for the saturated (discrete) basis.
327327
328-
ACRT for a discrete dose is a finite difference of the level effects
329-
(CGBS 2024): ``ACRT(d_j) = [ATT(d_j) - ATT(d_{j-1})] / (d_j - d_{j-1})``
330-
for ``j >= 2`` (backward), with a **forward** difference at the lowest
331-
level ``d_1`` (``[ATT(d_2) - ATT(d_1)] / (d_2 - d_1)``) so the ACRT curve
332-
shares the ATT grid and ``ACRT^glob`` stays well-defined. This is a linear
333-
operator ``L`` on ``beta`` (each row sums to 0, so a constant level/control
334-
shift cancels), and ``acrt = L @ beta``. Returns the ``L`` row for each
335-
``x_i`` at its dose level. With ``J = 1`` (single dose) every row is 0
336-
(``ACRT = 0``). Analogous to :func:`bspline_derivative_design_matrix`.
328+
ACRT for a discrete dose is the paper's backward difference of the level
329+
effects (CGBS 2024 §3.2 / §4.1) on the grid ``{d_0 = 0, d_1, ..., d_J}``,
330+
where ``d_0 = 0`` is the omitted (untreated) category with ``ATT(0) = 0``:
331+
``ACRT(d_j) = [ATT(d_j) - ATT(d_{j-1})] / (d_j - d_{j-1})``. At the lowest
332+
positive level this references the zero-dose baseline,
333+
``ACRT(d_1) = [ATT(d_1) - 0] / (d_1 - 0) = ATT(d_1) / d_1`` — so a single
334+
positive dose (``J = 1``, e.g. binary ``D in {0, 1}``) gives
335+
``ACRT(d_1) = ATT(d_1) / d_1`` and, for ``d_1 = 1``, the documented binary
336+
identity ``ACRT = ATT``. This is a linear operator ``L`` on ``beta``, and
337+
``acrt = L @ beta``. Only the lowest level's row references ``d_0 = 0`` (so
338+
that row does NOT sum to 0); the ``j >= 2`` rows are ordinary adjacent
339+
backward differences (rows sum to 0). Returns the ``L`` row for each ``x_i``
340+
at its dose level. Analogous to :func:`bspline_derivative_design_matrix`.
337341
338342
Parameters
339343
----------
@@ -353,14 +357,12 @@ def saturated_derivative_design_matrix(x, levels, tol=SATURATED_TOL):
353357
idx = _match_levels(x, levels, tol)
354358
J = len(levels)
355359
L = np.zeros((J, J))
356-
# Row 0: forward difference at the lowest level; rows j>=1: backward.
360+
# Row 0 (lowest positive dose d_1): backward difference to the zero-dose
361+
# baseline d_0 = 0, ATT(0) = 0 -> ACRT(d_1) = ATT(d_1) / d_1. Rows j >= 1:
362+
# ordinary adjacent backward differences between positive doses.
357363
for j in range(J):
358-
if J == 1:
359-
break # single dose level: derivative is 0 everywhere
360364
if j == 0:
361-
h = levels[1] - levels[0]
362-
L[0, 0] = -1.0 / h
363-
L[0, 1] = 1.0 / h
365+
L[0, 0] = 1.0 / levels[0]
364366
else:
365367
h = levels[j] - levels[j - 1]
366368
L[j, j - 1] = -1.0 / h

diff_diff/guides/llms-full.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -715,10 +715,10 @@ reg_did_panel/drdid_panel. `covariates=` + `survey_design=` is not yet supported
715715

716716
`treatment_type="discrete"` fits a **saturated regression** for a multi-valued dose: one indicator per
717717
distinct dose level, so `ATT(d_j) = mean_{D=d_j}(ΔY) − control` (a per-level 2×2 DiD) and `ACRT(d_j)`
718-
is a finite difference (forward at the lowest level, backward elsewhere). It reuses the full inference
719-
stack (analytical / bootstrap / covariate / survey) and reduces to the per-level 2×2 DiD SE. Multi-
720-
cohort fits need a shared dose support across cohorts (else `NotImplementedError`); an off-support
721-
`dvals` value raises `ValueError`.
718+
is the paper's backward finite difference on `{0, d_1, …, d_J}` (`ACRT(d_1) = ATT(d_1)/d_1`, so binary
719+
`D ∈ {0,1}` gives `ACRT = ATT`). It reuses the full inference stack (analytical / bootstrap /
720+
covariate / survey) and reduces to the per-level 2×2 DiD SE. Multi-cohort fits need a shared dose
721+
support across cohorts (else `NotImplementedError`); an off-support `dvals` value raises `ValueError`.
722722

723723
**Alias:** `CDiD`
724724

0 commit comments

Comments
 (0)