Skip to content

Commit 18707f0

Browse files
authored
Merge pull request #532 from igerber/refactor/sdid-variance-effects-rename
refactor: rename SyntheticDiDResults.placebo_effects → variance_effects (deprecated alias)
2 parents ae2a25a + 0f24559 commit 18707f0

11 files changed

Lines changed: 168 additions & 54 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- **`SyntheticControl` conformal inference (Chernozhukov, Wüthrich & Zhu 2021, *JASA* 116(536)).** Three opt-in `SyntheticControlResults` methods give valid p-values for the post-period effect trajectory and pointwise confidence intervals — what the in-space placebo / Firpo-Possebom test-inversion paths cannot. Unlike the Firpo path (which re-ranks the cross-unit placebo gaps), the conformal layer fits its **own** time-permutation-invariant constrained-LS synthetic-control proxy (CWZ §2.3 eqs 3–4 — simplex weights on raw outcomes over **all** periods under the null, no `V`-matrix, no intercept) and permutes residuals **over time** for the single treated unit (CWZ's exactness theory requires a time-symmetric proxy, which the headline ADH `V`-matrix fit is not). **`conformal_test(effect, q=1, scheme="moving_block", n_iid=10000, seed=None)`** computes the joint sharp-null permutation p-value (eqs 1–2) of `S_q(û) = ((1/√T*)·Σ_{t>T0}|û_t|^q)^{1/q}` (`q ∈ {1, 2, ∞}`); the proxy is fit once and only residuals are permuted (footnote 7). **`conformal_confidence_intervals(alpha=0.1, scheme="moving_block", bounds=None, n_grid=100, seed=None)`** returns pointwise per-period CIs by test inversion (Algorithm 1 — each period `t` uses `Z = (pre-periods, t)` with the other post-periods dropped, a clean `T*=1` test). **`conformal_average_effect(alpha=0.1, scheme="moving_block", bounds=None, n_grid=200, seed=None)`** returns a CI for the average post-period effect by collapsing the panel into non-overlapping `T*`-blocks and permuting the block residuals (Appendix A.1). Permutation schemes: `"moving_block"` (`Π_→` cyclic shifts, valid under serial dependence — the default) and `"iid"` (`Π_all`, sampled, finer p-values); both include the identity so the p-value floor is `1/|Π|` (no extra `+1`). Fail-closed handling for `<1` donor / unpickled result / non-finite panel / non-converged grid points (treated as indeterminate, not rejected) / grid-limited / empty / unbounded sets; a single donor and `T*≥T0` warn. Surfaced under `conformal_inference` / `get_conformal_grid_df()` and `DiagnosticReport`'s `estimator_native_diagnostics`; the analytical `se`/`t_stat`/`p_value`/`conf_int`/`is_significant` stay NaN throughout. Core in the new `diff_diff/conformal.py` (reuses the Frank-Wolfe simplex solver). *Deferred:* one-sided variants (§7), covariates folded into the proxy, and the AR/innovation-permutation path (Lemmas 5–7).
1212

13+
### Changed
14+
- **`SyntheticDiDResults.placebo_effects` renamed to `variance_effects`.** The
15+
array's contents are method-specific — placebo treatment effects
16+
(`variance_method="placebo"`), per-draw bootstrap ATT estimates
17+
(`"bootstrap"`), or leave-one-out estimates (`"jackknife"`) — so the old name
18+
was misleading; the `variance_method` field disambiguates the contents. Read
19+
`result.variance_effects` going forward.
20+
21+
### Deprecated
22+
- **`SyntheticDiDResults.placebo_effects`** is now a read-only alias for
23+
`variance_effects` that emits a `DeprecationWarning` on access; it will be
24+
removed in v4.0.0. The alias is a property, not a dataclass field, so it is
25+
read-only (assignment raises `AttributeError`) and
26+
`dataclasses.replace(result, placebo_effects=...)` no longer works /
27+
`dataclasses.asdict(result)` now emits the `variance_effects` key — use
28+
`variance_effects`.
29+
1330
## [3.5.1] - 2026-06-02
1431

1532
### Added

TODO.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,6 @@ Deferred items from PR reviews that were not addressed before merge.
175175
| R comparison tests spawn separate `Rscript` per test (slow CI) | `tests/test_methodology_twfe.py:294` | #139 | Low |
176176
| CS R helpers hard-code `xformla = ~ 1`; no covariate-adjusted R benchmark for IRLS path | `tests/test_methodology_callaway.py` | #202 | Low |
177177
| Validating the `.txt` AI guides (`diff_diff/guides/llms-full.txt`, `llms-practitioner.txt`) as executable snippets is **not low-lift** (re-scoped 2026-06-01): of their ~112 fenced Python blocks only ~20% are standalone-runnable — the rest are API-signature references (`Foo(param: type = default)` pseudo-signatures that are `SyntaxError` by design), context fragments (e.g. `results.att` on an undefined `results`), or dataset-shape-specific blocks. The guides are reference documentation, not runnable examples; a real implementation needs signature-block detection + a context/data skip-allowlist + per-snippet fixtures (multi-round curation), unlike the curated `.rst` files the existing smoke test covers. | `tests/test_doc_snippets.py` | #239 | Low |
178-
| SyntheticDiD: rename internal `placebo_effects` variable to `variance_effects` (or `resampled_effects`). Misleading name across the placebo/bootstrap/jackknife dispatch paths — holds three different contents depending on variance method. Low-risk refactor; user-facing field rename should preserve `placebo_effects` as a deprecated alias for one release. | `synthetic_did.py`, `results.py` | follow-up | Medium |
179178
| `TestWorkflowDoesNotExecutePRHeadCode` (CodeQL #14 dismissal guard) does not model: `bash <script>` / `sh <script>` / `./<script>` / `source <script>` / `. <script>` direct shell-script execution; multi-line `python3 -c` bodies (line-by-line shlex can't reassemble across newlines — the workflow's 5 sanitizer bodies are exempt by invisibility); shell-variable-expansion indirection (`SCRIPT="$X"; python3 "$SCRIPT"`); `eval`; `find -exec`; `xargs -I {}`. Each represents a path by which PR-head bytes COULD execute without the test failing. The guard catches accidental regressions of common forms (16 tests covering pip/npm/cargo/maturin/etc. installs, python file exec, bash -c indirection with compound flags, env-var prefixes, line continuations, subshells/brace groups, single-line python -c, write-overwrites of allowlisted /tmp paths). Closing the residuals would require multi-line shell parsing with command-substitution awareness + script-execution allowlists — significant work for diminishing return given the dismissal's primary defense is the documented threat model on the alert and in `.github/workflows/ai_pr_review.yml` comment block. | `tests/test_openai_review.py`, `.github/workflows/ai_pr_review.yml` | #436 | Low |
180179
| Render `docs/methodology/REPORTING.md` and `docs/methodology/REGISTRY.md` as in-site Sphinx pages so cross-references can use `:doc:` instead of off-site GitHub `blob/main` URLs. Current state (#410 fix-audit-r2) restores navigable links via `blob/main`, but stable-docs readers can land on a different revision than the package version they are reading. Two viable paths: (a) add `myst-parser` to `docs/conf.py` extensions + docs extras and link with `:doc:`, or (b) convert both files to `.rst`. | `docs/conf.py`, `docs/api/business_report.rst`, `docs/api/diagnostic_report.rst`, `docs/tutorials/18_geo_experiments.ipynb`, `docs/tutorials/19_dcdh_marketing_pulse.ipynb` | follow-up | Low |
181180
| ImputationDiD methodology validation (PR-B): add `tests/test_methodology_imputation.py` with paper-equation-numbered Verified Components (Theorems 1-3, eqs. 5-9, Props. 5/9) and an R `didimputation` parity fixture (none on file). Flips the METHODOLOGY_REVIEW.md row to Complete. | `tests/test_methodology_imputation.py` | imputation-validation (PR-B) | Medium |
@@ -190,11 +189,8 @@ Ordered paydown view across the tables above. Tier A → D is by effort × risk,
190189

191190
_(No active items. The sole prior entry — the WooldridgeDiD method/outcome efficiency hint — has shipped; see CHANGELOG `## [Unreleased]` and REGISTRY §WooldridgeDiD "Nonlinear extensions".)_
192191

193-
(SyntheticDiD `placebo_effects``variance_effects` rename moved to Tier B — the user-facing field rename + one-release deprecation alias is too large for ≤1 day / ≤3 CI rounds.)
194-
195192
#### Tier B — Mid-size methodology (5-10 CI rounds expected, per memory cascade priors)
196193

197-
- SyntheticDiD: rename internal `placebo_effects``variance_effects` AND public `placebo_effects` field with deprecation alias retained for one release (`synthetic_did.py`, `results.py`)
198194
- StaggeredTripleDifference R parity: commit CSV fixtures + add covariate-adjusted scenarios + aggregation-SE assertions (`tests/test_methodology_staggered_triple_diff.py`, `benchmarks/R/benchmark_staggered_triplediff.R`)
199195
- StaggeredTripleDifference: per-cohort group-effect SE WIF override for exact R `triplediff` match (`staggered_triple_diff.py`)
200196
- WooldridgeDiD: QMLE Stata-parity `qmle` weight type + Stata golden values (`wooldridge.py`, `linalg.py`, `tests/test_wooldridge.py`)

diff_diff/guides/llms-full.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,7 @@ Returned by `SyntheticDiD.fit()`.
12631263
| `pre_periods` | `list` | Pre-treatment periods |
12641264
| `post_periods` | `list` | Post-treatment periods |
12651265
| `variance_method` | `str` | "bootstrap", "jackknife", or "placebo" |
1266+
| `variance_effects` | `np.ndarray` | Per-iteration draws (placebo effects, bootstrap ATT draws, or jackknife LOO estimates per `variance_method`); deprecated alias `placebo_effects` (removed v4.0.0) |
12661267
| `noise_level` | `float` | Estimated noise level |
12671268
| `zeta_omega` | `float` | Unit weight regularization |
12681269
| `zeta_lambda` | `float` | Time weight regularization |
@@ -1272,7 +1273,7 @@ Returned by `SyntheticDiD.fit()`.
12721273

12731274
**Validation diagnostics** (call after `fit()`):
12741275
- `get_weight_concentration(top_k=5)` - effective N and top-k weight share; flags fragile synthetic controls dominated by a few donor units
1275-
- `get_loo_effects_df()` - per-unit leave-one-out influence from the jackknife pass (DataFrame includes both control and treated rows). Requires `variance_method="jackknife"` with unit-level LOO granularity: available on non-survey and pweight-only jackknife fits; raises `NotImplementedError` on full-design survey jackknife (PSU-level LOO, see `result.placebo_effects` for raw PSU-level replicates) and `ValueError` when LOO is unavailable (single treated unit, only one control with nonzero effective weight, etc.)
1276+
- `get_loo_effects_df()` - per-unit leave-one-out influence from the jackknife pass (DataFrame includes both control and treated rows). Requires `variance_method="jackknife"` with unit-level LOO granularity: available on non-survey and pweight-only jackknife fits; raises `NotImplementedError` on full-design survey jackknife (PSU-level LOO, see `result.variance_effects` for raw PSU-level replicates) and `ValueError` when LOO is unavailable (single treated unit, only one control with nonzero effective weight, etc.)
12761277
- `in_time_placebo()` - re-estimate on shifted fake treatment dates in the pre-period; near-zero placebo ATTs indicate a credible design
12771278
- `sensitivity_to_zeta_omega()` - re-estimate across a grid of unit-weight regularization values; checks ATT robustness to the auto-selected zeta_omega
12781279

diff_diff/results.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Provides statsmodels-style output with a more Pythonic interface.
55
"""
66

7+
import warnings
78
from dataclasses import dataclass, field
89
from typing import Any, Dict, List, Optional, Tuple
910

@@ -1079,12 +1080,13 @@ class SyntheticDiDResults:
10791080
Arkhangelsky et al. 2021 Algorithm 2 step 2, and R's default
10801081
``synthdid::vcov(method="bootstrap")``), ``"jackknife"``, or
10811082
``"placebo"``.
1082-
placebo_effects : np.ndarray, optional
1083+
variance_effects : np.ndarray, optional
10831084
Method-specific per-iteration estimates: placebo treatment effects
10841085
(for ``"placebo"``), bootstrap ATT estimates with re-estimated
10851086
weights per draw (for ``"bootstrap"``), or leave-one-out estimates
10861087
(for ``"jackknife"``). The ``variance_method`` field disambiguates
1087-
the contents.
1088+
the contents. (The deprecated read-only alias ``placebo_effects``
1089+
returns this array and is removed in v4.0.0.)
10881090
synthetic_pre_trajectory : np.ndarray, optional
10891091
Synthetic control trajectory in pre-treatment periods, shape
10901092
``(n_pre,)``. Equal to ``Y_pre_control @ omega_eff`` where
@@ -1122,7 +1124,7 @@ class SyntheticDiDResults:
11221124
zeta_omega: Optional[float] = field(default=None)
11231125
zeta_lambda: Optional[float] = field(default=None)
11241126
pre_treatment_fit: Optional[float] = field(default=None)
1125-
placebo_effects: Optional[np.ndarray] = field(default=None)
1127+
variance_effects: Optional[np.ndarray] = field(default=None)
11261128
n_bootstrap: Optional[int] = field(default=None)
11271129
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
11281130
survey_metadata: Optional[Any] = field(default=None)
@@ -1145,7 +1147,7 @@ def __post_init__(self):
11451147
# Plain attributes rather than dataclass fields so asdict()-style
11461148
# recursion cannot serialize internal panel state.
11471149
self._loo_unit_ids: Optional[List[Any]] = None
1148-
# Granularity of the `placebo_effects` LOO array: "unit" (non-
1150+
# Granularity of the `variance_effects` LOO array: "unit" (non-
11491151
# survey + pweight-only jackknife), "psu" (full-design survey
11501152
# jackknife), or None (non-jackknife variance methods). Governs
11511153
# which accessors are well-defined. Set by `fit()` at result
@@ -1180,6 +1182,20 @@ def __getstate__(self) -> Dict[str, Any]:
11801182
state["_fit_snapshot"] = None
11811183
return state
11821184

1185+
def __setstate__(self, state: Dict[str, Any]) -> None:
1186+
"""Restore from pickle, migrating the legacy field name.
1187+
1188+
Results pickled before the ``placebo_effects`` → ``variance_effects``
1189+
rename (<= 3.5.x) carry the old key in their state; map it so the
1190+
stored variance draws survive and remain reachable through both
1191+
``variance_effects`` and the deprecated ``placebo_effects`` alias.
1192+
Remove together with the alias in v4.0.0.
1193+
"""
1194+
if "placebo_effects" in state and "variance_effects" not in state:
1195+
state = dict(state)
1196+
state["variance_effects"] = state.pop("placebo_effects")
1197+
self.__dict__.update(state)
1198+
11831199
@property
11841200
def coef_var(self) -> float:
11851201
"""Coefficient of variation: SE / abs(ATT). NaN when ATT is 0 or SE non-finite."""
@@ -1189,6 +1205,27 @@ def coef_var(self) -> float:
11891205
return np.nan
11901206
return self.se / abs(self.att)
11911207

1208+
@property
1209+
def placebo_effects(self) -> Optional[np.ndarray]:
1210+
"""Deprecated alias for :attr:`variance_effects` (removed in v4.0.0).
1211+
1212+
.. deprecated:: 3.6.0
1213+
Renamed to ``variance_effects`` because the array's contents are
1214+
method-specific (placebo effects, bootstrap ATT draws, or
1215+
leave-one-out estimates depending on ``variance_method``).
1216+
"""
1217+
# `3.6.0` is the assumed next-minor (current is 3.5.1); confirm/resolve
1218+
# at bump-version time. The v4.0.0 removal target is fixed.
1219+
warnings.warn(
1220+
"SyntheticDiDResults.placebo_effects is deprecated; use "
1221+
"variance_effects instead. The array holds placebo effects, "
1222+
"bootstrap ATT draws, or leave-one-out estimates depending on "
1223+
"variance_method. Will be removed in v4.0.0.",
1224+
DeprecationWarning,
1225+
stacklevel=2,
1226+
)
1227+
return self.variance_effects
1228+
11921229
def summary(self, alpha: Optional[float] = None) -> str:
11931230
"""
11941231
Generate a formatted summary of the estimation results.
@@ -1388,7 +1425,7 @@ def get_loo_effects_df(self) -> pd.DataFrame:
13881425
* full-design survey jackknife fits (strata / PSU / FPC set in
13891426
``SurveyDesign``) - the underlying replicates are PSU-level
13901427
``τ̂_{(h,j)}`` (Rust & Rao 1996), not unit-level. See
1391-
``result.placebo_effects`` for the raw PSU-level replicate
1428+
``result.variance_effects`` for the raw PSU-level replicate
13921429
array and REGISTRY §SyntheticDiD "Note (survey + jackknife
13931430
composition)" for the aggregation formula.
13941431
@@ -1424,7 +1461,7 @@ def get_loo_effects_df(self) -> pd.DataFrame:
14241461
)
14251462
# Survey-jackknife fits use PSU-level LOO (Rust & Rao 1996) with
14261463
# stratum aggregation rather than unit-level LOO. The returned
1427-
# ``placebo_effects`` array in that path is a flat list of
1464+
# ``variance_effects`` array in that path is a flat list of
14281465
# PSU-level τ̂_{(h,j)} replicates (variable length, ordered by
14291466
# stratum then PSU), not a length-N unit-indexed array. Mapping
14301467
# these onto the fit-time unit IDs would mislabel PSU replicates
@@ -1441,19 +1478,19 @@ def get_loo_effects_df(self) -> pd.DataFrame:
14411478
"stratum aggregation, Rust & Rao 1996); the underlying "
14421479
"replicates are PSU-level, not unit-level, so joining them "
14431480
"back to fit-time unit IDs is not well-defined. See "
1444-
"``result.placebo_effects`` for the raw PSU-level replicate "
1481+
"``result.variance_effects`` for the raw PSU-level replicate "
14451482
"array and ``docs/methodology/REGISTRY.md`` §SyntheticDiD "
14461483
'"Note (survey + jackknife composition)" for the '
14471484
"aggregation formula."
14481485
)
1449-
if self._loo_unit_ids is None or self._loo_roles is None or self.placebo_effects is None:
1486+
if self._loo_unit_ids is None or self._loo_roles is None or self.variance_effects is None:
14501487
raise ValueError(
14511488
"Leave-one-out estimates are unavailable (jackknife returned "
14521489
"NaN or an empty array). See prior warnings from fit() for the "
14531490
"cause (e.g., single treated unit, all weight on one control)."
14541491
)
14551492

1456-
att_loo = np.asarray(self.placebo_effects, dtype=float)
1493+
att_loo = np.asarray(self.variance_effects, dtype=float)
14571494
delta = att_loo - self.att
14581495
df = pd.DataFrame(
14591496
{

0 commit comments

Comments
 (0)