Skip to content

Commit 5fbcb02

Browse files
igerberclaude
andcommitted
Add inference-field aliases on staggered result classes
Adds read-only @Property aliases (att / se / conf_int / p_value / t_stat) on every result class that previously only carried prefixed canonical fields, so external adapters that read getattr(res, "se") get populated values transparently. Coverage: - Pattern B (overall_*): CallawaySantAnna, Stacked, EfficientDiD, dCDH, StaggeredTripleDiff, Wooldridge, SunAbraham, ImputationDiD, TwoStage - Pattern C (overall_att_*, ATT-side headline): ContinuousDiD — adds both flat aliases and overall_* aliases for naming consistency - Pattern D (avg_*): MultiPeriodDiD Aliases are pure read-throughs over canonical fields — no recomputation, no behavior change. NaN-canonical → NaN-alias inheritance is regression- locked at tests/test_result_aliases.py::test_pattern_b_aliases_propagate_nan. The native overall_* / overall_att_* / avg_* names remain canonical for documentation and computation. Motivated by an external adapter that reads getattr(res, "se", None) without a fallback to overall_se / overall_att_se. Pre-alias every staggered result class returned None on those keys; aliases fix the adapter's diagnostic surface transparently with no consumer-side change. 23 alias-mechanic + adapter-pattern regression tests at tests/test_result_aliases.py. Documented in CHANGELOG (Unreleased) and REGISTRY.md preamble. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ec1de01 commit 5fbcb02

14 files changed

Lines changed: 561 additions & 11 deletions

CHANGELOG.md

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

1010
### Added
11+
- **Inference-field aliases on staggered result classes** for adapter / external-consumer compatibility. Read-only `@property` aliases expose the flat `att` / `se` / `conf_int` / `p_value` / `t_stat` names (matching `DiDResults` / `TROPResults` / `SyntheticDiDResults` / `HeterogeneousAdoptionDiDResults`) on every result class that previously only carried prefixed canonical fields: `CallawaySantAnnaResults`, `StackedDiDResults`, `EfficientDiDResults`, `ChaisemartinDHaultfoeuilleResults`, `StaggeredTripleDiffResults`, `WooldridgeDiDResults`, `SunAbrahamResults`, `ImputationDiDResults`, `TwoStageDiDResults` (mapping to `overall_*`); `ContinuousDiDResults` (mapping to `overall_att_*`, ATT-side as the headline, ACRT-side accessible unchanged via `overall_acrt_*`); `MultiPeriodDiDResults` (mapping to `avg_*`). `ContinuousDiDResults` additionally exposes `overall_se` / `overall_conf_int` / `overall_p_value` / `overall_t_stat` aliases for naming consistency with the rest of the staggered family. Aliases are pure read-throughs over the canonical fields — no recomputation, no behavior change — so the `safe_inference()` joint-NaN contract (per CLAUDE.md "Inference computation") is inherited automatically (NaN canonical → NaN alias, locked at `tests/test_result_aliases.py::test_pattern_b_aliases_propagate_nan`). The native `overall_*` / `overall_att_*` / `avg_*` fields remain canonical for documentation and computation. Motivated by the `balance.interop.diff_diff.as_balance_diagnostic()` adapter (`facebookresearch/balance` PR #465) which calls `getattr(res, "se", None)` / `getattr(res, "conf_int", None)` without a fallback chain — pre-alias, every staggered result class returned `None` on those keys, silently dropping `se` and `conf_int` from the adapter's diagnostic dict. 23 alias-mechanic + balance-adapter regression tests at `tests/test_result_aliases.py`. Patch-level (additive on stable surfaces).
1112
- **`ChaisemartinDHaultfoeuille.by_path` + non-binary integer treatment** — `by_path=k` now accepts integer-coded discrete treatment (D in Z, e.g. ordinal `{0, 1, 2}`); path tuples become integer-state tuples like `(0, 2, 2, 2)`. The previous `NotImplementedError` gate at `chaisemartin_dhaultfoeuille.py:1870` is replaced by a `ValueError` for continuous D (e.g. `D=1.5`) at fit-time per the no-silent-failures contract — the existing `int(round(float(v)))` cast in `_enumerate_treatment_paths` is now defensive (no-op for integer-coded D). Validated against R `did_multiplegt_dyn(..., by_path)` for D in `{0, 1, 2}` via the new `multi_path_reversible_by_path_non_binary` golden-value scenario (78 switchers, 3 paths, single-baseline custom DGP, F_g >= 4): per-path point estimates match R bit-exactly (rtol ~1e-9 on event horizons; rtol+atol envelope for placebo near-zero values), per-path SE inherits the documented cross-path cohort-sharing deviation (~5% rtol observed; SE_RTOL=0.15 envelope). **Deviation from R for D >= 10:** R's `did_multiplegt_by_path` derives the per-path baseline via `path_index$baseline_XX <- substr(path_index$path, 1, 1)`, which captures only the first character of the comma-separated path string (e.g. for `path = "12,12,..."` it captures `"1"` instead of `"12"`); this mis-allocates R's per-path control-pool subset for D >= 10. Python's tuple-key matching is correct in this regime — the per-path point estimates we compute are correct; R's per-path subset for the same path is buggy. The shipped parity scenario stays in `D in {0, 1, 2}` to avoid the R bug. R-parity test at `tests/test_chaisemartin_dhaultfoeuille_parity.py::TestDCDHDynRParityByPathNonBinary`; cross-surface invariants regression-tested at `tests/test_chaisemartin_dhaultfoeuille.py::TestByPathNonBinary`.
1213
- **New `paths_of_interest` kwarg on `ChaisemartinDHaultfoeuille`** for user-specified treatment-path subsets, alternative to `by_path=k`'s top-k automatic ranking. Mutually exclusive with `by_path`; setting both raises `ValueError` at `__init__` and `set_params` time. Each path tuple must be a list/tuple of `int` of length `L_max + 1` (uniformity validated at `__init__`; length match against `L_max + 1` validated at fit-time); `bool` and `np.bool_` are explicitly rejected, `np.integer` accepted and canonicalized to Python `int` for tuple-key consistency. Duplicates emit a `UserWarning` and are deduplicated; paths not observed in the panel emit a `UserWarning` and are omitted from `path_effects`. Paths appear in `results.path_effects` in the user-specified order, modulo deduplication and unobserved-path filtering. Composes with non-binary D and all downstream `by_path` surfaces (bootstrap, per-path placebos, per-path joint sup-t bands, `controls`, `trends_linear`, `trends_nonparam`) — mechanical filter on observed paths via the same `_enumerate_treatment_paths` call site, no methodology change. **Python-only API extension; no R equivalent** — R's `did_multiplegt_dyn(..., by_path=k)` only accepts a positive int (top-k) or `-1` (all paths). The `by_path` precondition gate at `chaisemartin_dhaultfoeuille.py:1118` (drop_larger_lower / L_max / `heterogeneity` / `design2` / `honest_did` / `survey_design` mutex) and the 11 `self.by_path is not None` activation branches in `fit()` were rerouted to fire under either selector. Validation + behavior + cross-feature regressions at `tests/test_chaisemartin_dhaultfoeuille.py::TestPathsOfInterest`.
1314
- **HAD `practitioner_next_steps()` handler + `llms-full.txt` reference section** (Phase 5). Adds `_handle_had` and `_handle_had_event_study` to `diff_diff/practitioner.py::_HANDLERS`, routing both `HeterogeneousAdoptionDiDResults` (single-period) and `HeterogeneousAdoptionDiDEventStudyResults` (event-study) through HAD-specific Baker et al. (2025) step guidance: `did_had_pretest_workflow` (step 3 — paper Section 4.2 step-2 closure on the event-study path), an estimand-difference routing nudge to `ContinuousDiD` (step 4 — fires when the user wants per-dose ATT(d) / ACRT(d) curves rather than HAD's WAS estimand and has never-treated controls; framed around estimand difference, NOT around the existence of untreated units, since HAD remains valid with a small never-treated share per REGISTRY § HeterogeneousAdoptionDiD edge cases and explicitly retains never-treated units on the staggered event-study path per paper Appendix B.2 / `had.py:1325`), `results.bandwidth_diagnostics` inspection on continuous designs and simultaneous (sup-t) `cband_*` reading on weighted event-study fits (step 6), per-horizon WAS event-study disaggregation (step 7), and the explicit design-auto-detection / last-cohort-only-WAS framing (step 8). Symmetric pair: `_handle_continuous` gains a Step-4 nudge to `HeterogeneousAdoptionDiD` for ContinuousDiD users on no-untreated panels (this direction is correct because ContinuousDiD's identification requires never-treated controls). Extends `_check_nan_att` with an ndarray branch via lazy `numpy` import for HAD's per-horizon `att` array; uses `np.all(np.isnan(arr))` semantics so partial-NaN arrays (legitimate event-study output under degenerate horizon-specific designs) do not over-fire the warning. Scalar path is bit-exact preserved across all 12 untouched handlers. Adds full HAD section + `HeterogeneousAdoptionDiDResults` / `HeterogeneousAdoptionDiDEventStudyResults` blocks + `## HAD Pretests` index covering all 7 pretest entry points + Choosing-an-Estimator row to `diff_diff/guides/llms-full.txt` (the bundled-in-wheel agent reference); the documented constructor + `fit()` signatures match the real `HeterogeneousAdoptionDiD.__init__` / `.fit` API exactly (verified by `inspect.signature`-based regression tests). Tightens the existing `Continuous treatment intensity` Choosing row to surface ATT(d) vs WAS as the estimand differentiator. `docs/doc-deps.yaml` updated to remove the `llms-full.txt` deferral note on `had.py` and add `llms-full.txt` entries to `had.py`, `had_pretests.py`, and `practitioner.py` blocks. Patch-level (additive on stable surfaces). 26 new tests (16 in `tests/test_practitioner.py::TestHADDispatch` + 9 in `tests/test_guides.py::TestLLMsFullHADCoverage` + 1 fixture-minimality regression locking the "handlers are STRING-ONLY at runtime" stability invariant). Closes the Phase 5 "agent surfaces" gap; T21 pretest tutorial and T22 weighted/survey tutorial remain queued as separate notebook PRs.

diff_diff/chaisemartin_dhaultfoeuille_results.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,27 @@ def _estimand_label(self) -> str:
695695
return f"{did_part}{suffix}_{sub_part}" if sub_part else f"{did_part}{suffix}"
696696
return base
697697

698+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
699+
@property
700+
def att(self) -> float:
701+
return self.overall_att
702+
703+
@property
704+
def se(self) -> float:
705+
return self.overall_se
706+
707+
@property
708+
def conf_int(self) -> Tuple[float, float]:
709+
return self.overall_conf_int
710+
711+
@property
712+
def p_value(self) -> float:
713+
return self.overall_p_value
714+
715+
@property
716+
def t_stat(self) -> float:
717+
return self.overall_t_stat
718+
698719
def __repr__(self) -> str:
699720
"""Concise string representation."""
700721
sig = _get_significance_stars(self.overall_p_value)

diff_diff/continuous_did_results.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,45 @@ class ContinuousDiDResults:
143143
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
144144
survey_metadata: Optional[Any] = field(default=None)
145145

146+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
147+
# ATT-side is the headline contract; ACRT remains accessible via overall_acrt_*.
148+
@property
149+
def att(self) -> float:
150+
return self.overall_att
151+
152+
@property
153+
def se(self) -> float:
154+
return self.overall_att_se
155+
156+
@property
157+
def conf_int(self) -> Tuple[float, float]:
158+
return self.overall_att_conf_int
159+
160+
@property
161+
def p_value(self) -> float:
162+
return self.overall_att_p_value
163+
164+
@property
165+
def t_stat(self) -> float:
166+
return self.overall_att_t_stat
167+
168+
# `overall_*` aliases for naming consistency with the rest of the staggered family.
169+
@property
170+
def overall_se(self) -> float:
171+
return self.overall_att_se
172+
173+
@property
174+
def overall_conf_int(self) -> Tuple[float, float]:
175+
return self.overall_att_conf_int
176+
177+
@property
178+
def overall_p_value(self) -> float:
179+
return self.overall_att_p_value
180+
181+
@property
182+
def overall_t_stat(self) -> float:
183+
return self.overall_att_t_stat
184+
146185
def __repr__(self) -> str:
147186
sig_att = _get_significance_stars(self.overall_att_p_value)
148187
sig_acrt = _get_significance_stars(self.overall_acrt_p_value)

diff_diff/efficient_did_results.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,27 @@ class EfficientDiDResults:
167167
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
168168
survey_metadata: Optional[Any] = field(default=None)
169169

170+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
171+
@property
172+
def att(self) -> float:
173+
return self.overall_att
174+
175+
@property
176+
def se(self) -> float:
177+
return self.overall_se
178+
179+
@property
180+
def conf_int(self) -> Tuple[float, float]:
181+
return self.overall_conf_int
182+
183+
@property
184+
def p_value(self) -> float:
185+
return self.overall_p_value
186+
187+
@property
188+
def t_stat(self) -> float:
189+
return self.overall_t_stat
190+
170191
def __repr__(self) -> str:
171192
sig = _get_significance_stars(self.overall_p_value)
172193
path = "DR" if self.estimation_path == "dr" else "nocov"

diff_diff/imputation_results.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,27 @@ class ImputationDiDResults:
143143
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
144144
survey_metadata: Optional[Any] = field(default=None, repr=False)
145145

146+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
147+
@property
148+
def att(self) -> float:
149+
return self.overall_att
150+
151+
@property
152+
def se(self) -> float:
153+
return self.overall_se
154+
155+
@property
156+
def conf_int(self) -> Tuple[float, float]:
157+
return self.overall_conf_int
158+
159+
@property
160+
def p_value(self) -> float:
161+
return self.overall_p_value
162+
163+
@property
164+
def t_stat(self) -> float:
165+
return self.overall_t_stat
166+
146167
def __repr__(self) -> str:
147168
"""Concise string representation."""
148169
sig = _get_significance_stars(self.overall_p_value)

diff_diff/results.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,27 @@ class MultiPeriodDiDResults:
447447
vcov_type: Optional[str] = field(default=None)
448448
cluster_name: Optional[str] = field(default=None)
449449

450+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
451+
@property
452+
def att(self) -> float:
453+
return self.avg_att
454+
455+
@property
456+
def se(self) -> float:
457+
return self.avg_se
458+
459+
@property
460+
def conf_int(self) -> Tuple[float, float]:
461+
return self.avg_conf_int
462+
463+
@property
464+
def p_value(self) -> float:
465+
return self.avg_p_value
466+
467+
@property
468+
def t_stat(self) -> float:
469+
return self.avg_t_stat
470+
450471
def __repr__(self) -> str:
451472
"""Concise string representation."""
452473
sig = _get_significance_stars(self.avg_p_value)
@@ -1180,7 +1201,7 @@ def get_loo_effects_df(self) -> pd.DataFrame:
11801201
"back to fit-time unit IDs is not well-defined. See "
11811202
"``result.placebo_effects`` for the raw PSU-level replicate "
11821203
"array and ``docs/methodology/REGISTRY.md`` §SyntheticDiD "
1183-
"\"Note (survey + jackknife composition)\" for the "
1204+
'"Note (survey + jackknife composition)" for the '
11841205
"aggregation formula."
11851206
)
11861207
if self._loo_unit_ids is None or self._loo_roles is None or self.placebo_effects is None:
@@ -1386,9 +1407,7 @@ def in_time_placebo(
13861407
lambda_fake,
13871408
)
13881409
synthetic_pre_fake_n = Y_pre_c_n @ omega_eff_fake
1389-
pre_fit_n = float(
1390-
np.sqrt(np.mean((y_pre_t_mean_n - synthetic_pre_fake_n) ** 2))
1391-
)
1410+
pre_fit_n = float(np.sqrt(np.mean((y_pre_t_mean_n - synthetic_pre_fake_n) ** 2)))
13921411
# ATT is scale-equivariant and shift-invariant in Y; RMSE is
13931412
# scale-equivariant. Rescale back to original-Y units.
13941413
row["att"] = float(att_fake_n * Y_scale)
@@ -1482,12 +1501,8 @@ def sensitivity_to_zeta_omega(
14821501
Y_post_treated_n = (snap.Y_post_treated - Y_shift) / Y_scale
14831502

14841503
if snap.w_treated is not None:
1485-
y_pre_t_mean_n = np.average(
1486-
Y_pre_treated_n, axis=1, weights=snap.w_treated
1487-
)
1488-
y_post_t_mean_n = np.average(
1489-
Y_post_treated_n, axis=1, weights=snap.w_treated
1490-
)
1504+
y_pre_t_mean_n = np.average(Y_pre_treated_n, axis=1, weights=snap.w_treated)
1505+
y_post_t_mean_n = np.average(Y_post_treated_n, axis=1, weights=snap.w_treated)
14911506
else:
14921507
y_pre_t_mean_n = np.mean(Y_pre_treated_n, axis=1)
14931508
y_post_t_mean_n = np.mean(Y_post_treated_n, axis=1)

diff_diff/stacked_did_results.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,27 @@ class StackedDiDResults:
9797
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
9898
survey_metadata: Optional[Any] = field(default=None)
9999

100+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
101+
@property
102+
def att(self) -> float:
103+
return self.overall_att
104+
105+
@property
106+
def se(self) -> float:
107+
return self.overall_se
108+
109+
@property
110+
def conf_int(self) -> Tuple[float, float]:
111+
return self.overall_conf_int
112+
113+
@property
114+
def p_value(self) -> float:
115+
return self.overall_p_value
116+
117+
@property
118+
def t_stat(self) -> float:
119+
return self.overall_t_stat
120+
100121
def __repr__(self) -> str:
101122
"""Concise string representation."""
102123
sig = _get_significance_stars(self.overall_p_value)

diff_diff/staggered_results.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,27 @@ class CallawaySantAnnaResults:
138138
epv_threshold: float = 10
139139
pscore_fallback: str = "error"
140140

141+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
142+
@property
143+
def att(self) -> float:
144+
return self.overall_att
145+
146+
@property
147+
def se(self) -> float:
148+
return self.overall_se
149+
150+
@property
151+
def conf_int(self) -> Tuple[float, float]:
152+
return self.overall_conf_int
153+
154+
@property
155+
def p_value(self) -> float:
156+
return self.overall_p_value
157+
158+
@property
159+
def t_stat(self) -> float:
160+
return self.overall_t_stat
161+
141162
def __repr__(self) -> str:
142163
"""Concise string representation."""
143164
sig = _get_significance_stars(self.overall_p_value)

diff_diff/staggered_triple_diff_results.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,27 @@ class StaggeredTripleDiffResults:
9595
epv_threshold: float = 10
9696
pscore_fallback: str = "error"
9797

98+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
99+
@property
100+
def att(self) -> float:
101+
return self.overall_att
102+
103+
@property
104+
def se(self) -> float:
105+
return self.overall_se
106+
107+
@property
108+
def conf_int(self) -> Tuple[float, float]:
109+
return self.overall_conf_int
110+
111+
@property
112+
def p_value(self) -> float:
113+
return self.overall_p_value
114+
115+
@property
116+
def t_stat(self) -> float:
117+
return self.overall_t_stat
118+
98119
def __repr__(self) -> str:
99120
"""Concise string representation."""
100121
sig = _get_significance_stars(self.overall_p_value)

diff_diff/sun_abraham.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ class SunAbrahamResults:
9292
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
9393
survey_metadata: Optional[Any] = field(default=None)
9494

95+
# --- Inference-field aliases (balance/external-adapter compatibility) ---
96+
@property
97+
def att(self) -> float:
98+
return self.overall_att
99+
100+
@property
101+
def se(self) -> float:
102+
return self.overall_se
103+
104+
@property
105+
def conf_int(self) -> Tuple[float, float]:
106+
return self.overall_conf_int
107+
108+
@property
109+
def p_value(self) -> float:
110+
return self.overall_p_value
111+
112+
@property
113+
def t_stat(self) -> float:
114+
return self.overall_t_stat
115+
95116
def __repr__(self) -> str:
96117
"""Concise string representation."""
97118
sig = _get_significance_stars(self.overall_p_value)

0 commit comments

Comments
 (0)