Skip to content

Add TROP non-absorbing (on/off) treatment support (opt-in, local method)#592

Merged
igerber merged 1 commit into
mainfrom
trop-non-absorbing
Jun 30, 2026
Merged

Add TROP non-absorbing (on/off) treatment support (opt-in, local method)#592
igerber merged 1 commit into
mainfrom
trop-non-absorbing

Conversation

@igerber

@igerber igerber commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds opt-in non-absorbing (on/off) treatment support to TROP via TROP(non_absorbing=True) (method='local' only), matching the source paper's general-assignment scope (Athey, Imbens, Qu & Viviano 2025, §2.1 / Eq. 12 / Algorithm 2). This removes a prior implementation over-restriction rather than adding a deviation; the default non_absorbing=False is unchanged and still rejects a non-monotonic indicator (now pointing to the opt-in) to guard the common event-style-D mis-encoding.
  • method='global' rejects non_absorbing=True (its post-hoc weighting + bootstrap assume a contiguous block). A one-time UserWarning flags the no-dynamic-effects requirement and that Theorem 5.1's triple-robustness guarantee is proven under block assignment only.
  • Adds a general per-cell estimability guard to every local fit: a treated cell is estimable iff alpha_i + beta_t is identified by the two-way-FE control fit (target unit and period in the same connected component of the observed-control graph). Non-estimable cells (always-treated unit, fully-treated period, disconnected/missing control support) are materialized as NaN and excluded from the ATT — matching the library-wide non-estimable→NaN convention. On balanced / support-complete panels this is a no-op (no behavior change), confirmed by the unchanged absorbing test suite.
  • Records non_absorbing on TROPResults / to_dict() / summary(). Rust local LOOCV / point paths are unchanged (parity-tested); the bootstrap routes to the guarded Python path for non-absorbing / unbalanced / trimmed fits (the Rust per-cell path lacks the guard), and the survey-weighted average is guarded against a zero total weight.
  • Corrects the now-stale "only library estimator that handles non-absorbing treatment" claim across docs, AI-agent guides, and dCDH docstrings (dCDH is the most general; LPDiD and TROP also support it under stronger assumptions).

Methodology references (required if estimator / math changes)

  • Method name(s): TROP (Triply Robust Panel) — non-absorbing / general-assignment extension.
  • Paper / source link(s): Athey, Imbens, Qu & Viviano (2025), Triply Robust Panel Estimators, arXiv:2508.21536 (v3 consulted for the assignment-pattern sections). See docs/methodology/REGISTRY.md ## TROP and docs/methodology/papers/athey-2025-review.md.
  • Any intentional deviations from the source (and why): The point estimator follows Eq. 12 / Algorithm 2. Documented defensive choices (REGISTRY **Note:** labels): (1) default rejects non-absorbing D (footgun guard); (2) non-absorbing is local-method only; (3) inference SEs use the bootstrap with a documented caveat that Theorem 5.1 is proven under block assignment only; (4) non-estimable cells (a degeneracy the paper does not cover, which assumes overlap-rich panels) are NaN-trimmed per the library non-estimable→NaN convention.

Validation

  • Tests added/updated: tests/test_trop.py (TestDMatrixValidation: opt-in acceptance, global-combo rejection, params round-trip, results persistence, Rust/Python parity, unbalanced-panel Rust-bypass routing; TestTROPBootstrapFailureRateGuard: survey Rao-Wu zero-weight guard) and tests/test_methodology_trop.py (TestTROPDeviations: known-ATT recovery on a no-dynamic-effects toggling DGP, no-caveat-in-default, unbalanced × non-absorbing, always-treated unit at lambda_unit 0 and >0, fully-treated period, disconnected support, unbalanced-absorbing trimming). Existing default-mode rejection tests retained.
  • Backtest / simulation / notebook evidence (if applicable): N/A (no tutorial changes; recovery is locked by the synthetic-DGP methodology tests above).

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

Generated with Claude Code

@github-actions

Copy link
Copy Markdown

Overall Assessment

✅ Looks good. No unmitigated P0/P1 findings.

Executive Summary

  • Affected method: TROP, specifically local point estimation, treatment validation, bootstrap routing, and TROPResults.
  • The non-absorbing support is documented in docs/methodology/REGISTRY.md with explicit **Note:** entries, so the default absorbing guard, local-only scope, inference caveat, and estimable-cell trimming are not defects.
  • The implementation uses safe_inference() and does not introduce inline t-stat/p-value/CI anti-patterns.
  • The main issue I found is documentation-only: the changed dCDH tutorial misstates treated/stable-treated control usage.

Methodology

P3 informational, no action required
Impact: TROP(non_absorbing=True) is a methodology-affecting change, but its key deviations and caveats are documented: defensive default, local-only support, block-only triple-robustness caveat, bootstrap caveat, and non-estimable-cell trimming are all recorded under docs/methodology/REGISTRY.md:L2768-L2771. The code aligns with those contracts at diff_diff/trop.py:L511-L539, diff_diff/trop_global.py:L591-L600, and diff_diff/trop_local.py:L35-L98. The paper source supports general assignment patterns and multiple treated-unit/period estimation, while also stating no dynamic effects and bootstrap validity conditions. (arxiv.org)
Concrete fix: None.

Code Quality

No findings. The new parameter is validated, included in get_params() / set_params(), persisted on results, and routed through local/global behavior appropriately: diff_diff/trop.py:L163-L177, diff_diff/trop.py:L949-L971, diff_diff/trop_results.py:L133-L170.

Performance

No blocking findings. The connected-component estimability guard adds per-treated-cell work, but it is documented as correctness-first and only materially active on support-degenerate cases: diff_diff/trop_local.py:L65-L75.

Maintainability

No findings. The bootstrap force_python switch is explicit and localized: diff_diff/trop.py:L881-L903, diff_diff/trop_local.py:L1266-L1286.

Tech Debt

No findings. I did not see a new untracked correctness deferral introduced by this PR.

Security

No findings. No secrets or security-sensitive code paths observed in the diff.

Documentation/Tests

P2
Impact: The changed dCDH tutorial says leavers are compared to “contemporaneously treated control cells,” then concludes “no treated unit is ever used as a control.” That is misleading: stable-treated cells are treated cells and are used as controls for leavers in the described dCDH comparison. This is a prose methodology issue, not an estimator-output bug. Location: docs/tutorials/19_dcdh_marketing_pulse.ipynb:L13.
Concrete fix: Replace that sentence with: “No switching cell is used as a control; stable-untreated cells serve as controls for joiners, and stable-treated cells serve as controls for leavers.”

P3 review note
Impact: I could not execute targeted tests locally because pytest is not installed in this environment.
Concrete fix: Ensure CI runs the added tests/test_trop.py and tests/test_methodology_trop.py coverage.

TROP now accepts general (on/off) assignment via TROP(non_absorbing=True),
matching the source paper's scope (Athey, Imbens, Qu & Viviano 2025 §2.1 / Eq. 12
/ Algorithm 2: "units moving into and out of treatment"). This removes a prior
implementation over-restriction rather than adding a deviation; the default
non_absorbing=False is unchanged and still rejects a non-monotonic indicator with
a ValueError (now also pointing to the opt-in), guarding the common mistake of
encoding absorbing treatment as an event-style spike.

API / scope:
- non_absorbing: bool = False on TROP (mirrors LPDiD); threaded through
  __init__ validation, get_params/set_params, and recorded on TROPResults /
  to_dict() / summary() so a persisted result keeps the assignment-scope context.
- method='local' only; method='global' rejects non_absorbing=True (its post-hoc
  weighting + bootstrap assume a contiguous block).
- One-time caveat UserWarning: validity relies on no-dynamic-effects, and the
  Theorem 5.1 triple-robustness guarantee is proven under block assignment only.

Estimability guard (general correctness, applied to every local fit):
- A treated cell (i,t) is estimable iff alpha_i + beta_t is identified by the
  two-way-FE control fit, i.e. the target unit node and period node lie in the
  same connected component of the bipartite graph of positively-weighted observed
  control cells (_treated_cell_is_estimable). Non-estimable cells (always-treated
  unit, fully-treated period, disconnected support; or unbalanced absorbing panels
  with entirely-missing unit/period controls) are NaN and excluded from the ATT,
  which is the mean over estimable cells -- matching the library-wide
  non-estimable->NaN convention. On balanced panels (and absorbing panels with an
  observed never-treated unit) the graph is one component, so the guard is a no-op
  (no behavior change), confirmed by the unchanged absorbing test suite.
- The point fit and the bootstrap refit apply the identical predicate; the
  survey-weighted average is guarded against a zero total weight (NaN per the
  bootstrap contract, not ZeroDivisionError).

Backends:
- The Rust local LOOCV / point paths were already mask-driven and are unchanged
  (Rust/Python ATT parity regression-tested). The Rust per-cell bootstrap lacks
  the estimability guard, so the bootstrap is routed to the guarded Python path
  for non_absorbing fits, any unbalanced panel, or whenever the point fit trimmed
  a cell; balanced panels keep the Rust happy path.

Docs: REGISTRY ## TROP (D-matrix semantics, validation, requirements checklist
flipped from "out of scope" to supported, no-dynamic-effects + block-only
inference + non-estimable-cell Notes, v3 consulted), athey-2025-review.md,
METHODOLOGY_REVIEW.md, api/trop.rst, llms.txt, llms-full.txt, CHANGELOG, and
docstrings. Also corrects the stale "only library estimator that handles
non-absorbing treatment" claim across docs / AI-agent guides / dCDH docstrings
(dCDH is the most general; LPDiD and TROP also support it under stronger
assumptions).

Tests: opt-in acceptance, default + event-style rejection, global-combo
rejection, params round-trip, known-ATT recovery on a no-dynamic-effects toggling
DGP, fully-toggling (no never-treated unit), unbalanced panels, always-treated
unit (lambda_unit 0 and >0), fully-treated period, disconnected support,
unbalanced-absorbing trimming, Rust/Python parity, Rust-bypass routing, and the
survey Rao-Wu zero-weight guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@igerber igerber force-pushed the trop-non-absorbing branch from 5b5b6d9 to 4087c32 Compare June 30, 2026 20:34
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 4087c3274a4d188b5a9d1a6abb7b5737a7c9e548


Overall Assessment

✅ Looks good. No unmitigated P0/P1 findings.

Executive Summary

  • Affected method: TROP, mainly local estimation, D-matrix validation, estimable-cell trimming, bootstrap routing, and TROPResults.
  • Methodology-affecting choices are documented in docs/methodology/REGISTRY.md with explicit **Note:** entries, so they are not defects.
  • The prior notebook prose issue is resolved: it now correctly says no switching cell is used as a control and distinguishes stable-untreated from stable-treated controls.
  • non_absorbing is propagated through params/results/global rejection/bootstrap paths, and inference still routes through safe_inference().
  • I could not run the test suite because this environment lacks pytest, numpy, and pandas; syntax parsing of touched Python files passed.

Methodology

P3 informational, no action required
Impact: TROP(non_absorbing=True) changes estimator scope and inference caveats. The paper supports general assignment in the setup and extends the treatment-effect construction to multiple treated units/periods, while Theorem 5.1’s formal guarantee is stated under block assignment; the PR documents this as opt-in, local-only, warning-backed behavior in the registry. citeturn3view0 Locations: docs/methodology/REGISTRY.md:L2618-L2647, docs/methodology/REGISTRY.md:L2768-L2771, diff_diff/trop.py:L511-L539, diff_diff/trop_global.py:L591-L600.
Concrete fix: None.

Code Quality

No findings. The new constructor parameter is validated and included in get_params() / set_params(), global mode rejects it, and results persist it through TROPResults / to_dict(). Locations: diff_diff/trop.py:L163-L177, diff_diff/trop.py:L949-L974, diff_diff/trop_global.py:L591-L600, diff_diff/trop_results.py:L133-L170, diff_diff/trop_results.py:L286-L314.

Performance

No blocking findings. The connected-component estimability guard adds per-treated-cell work, but it is scoped to local fits and documented as a correctness guard; Rust remains available for clean local LOOCV and absorbing bootstrap paths. Locations: diff_diff/trop_local.py:L35-L98, diff_diff/trop_local.py:L1266-L1286.

Maintainability

No findings. The support predicate is centralized in _treated_cell_is_estimable() and reused by both the main point fit and fixed-lambda bootstrap refit. Locations: diff_diff/trop.py:L757-L779, diff_diff/trop_local.py:L1621-L1632.

Tech Debt

No findings. I did not see a new untracked correctness deferral. The documented caveats are in REGISTRY.md, so they are mitigated under the review rules.

Security

No findings. The diff does not introduce secrets, network calls, or security-sensitive behavior.

Documentation/Tests

P3 informational
Impact: Tests were not executable in this environment because pytest, numpy, and pandas are not installed. I did run an AST parse over the touched Python files successfully.
Concrete fix: Ensure CI runs the added/changed tests/test_trop.py and tests/test_methodology_trop.py coverage.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Jun 30, 2026
@igerber igerber merged commit 193f0ea into main Jun 30, 2026
33 of 34 checks passed
@igerber igerber deleted the trop-non-absorbing branch June 30, 2026 22:14
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