Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
- [TwoStageDiD](https://diff-diff.readthedocs.io/en/stable/api/two_stage.html) - Gardner (2022) two-stage estimator with GMM sandwich variance
- [SpilloverDiD](https://diff-diff.readthedocs.io/en/stable/api/spillover.html) - Butts (2021) ring-indicator spillover-aware DiD identifying direct effect on treated + per-ring spillover on near-control units; handles non-staggered and staggered timing; supports survey-design variance under `survey_design=` for HC1 / CR1 (Wave E.1 Binder TSL) and Conley (Wave E.2 panel-aware stratified-Conley sandwich on per-period PSU totals; extended in Wave E.2 follow-up to `conley_lag_cutoff > 0` via panel-block composition with within-PSU serial Bartlett HAC — `lag>0` requires an effective PSU via explicit `survey_design.psu` or injected `cluster=<col>`); `SurveyDesign.subpopulation()` preserves full-design `n_psu` / `df_survey` via zero-padded scores (Wave E.3, R `svyrecvar(subset())` form)
- [SyntheticDiD](https://diff-diff.readthedocs.io/en/stable/api/estimators.html) - Synthetic DiD combining standard DiD and synthetic control for few treated units
- [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html) - Abadie, Diamond & Hainmueller (2010) classic synthetic control for a single treated unit (donor-weight counterfactual, nested/custom V; no inference in this release — permutation/placebo planned)
- [SyntheticControl](https://diff-diff.readthedocs.io/en/stable/api/synthetic_control.html) - Abadie, Diamond & Hainmueller (2010) classic synthetic control for a single treated unit (donor-weight counterfactual, nested/custom V; in-space placebo permutation inference via `in_space_placebo()`)
- [TripleDifference](https://diff-diff.readthedocs.io/en/stable/api/triple_diff.html) - triple difference (DDD) estimator for designs requiring two criteria for treatment eligibility
- [ContinuousDiD](https://diff-diff.readthedocs.io/en/stable/api/continuous_did.html) - Callaway, Goodman-Bacon & Sant'Anna (2024) continuous treatment DiD with dose-response curves
- [HeterogeneousAdoptionDiD](https://diff-diff.readthedocs.io/en/stable/api/had.html) - de Chaisemartin, Ciccia, D'Haultfœuille & Knau (2026) for designs where **no unit remains untreated**; local-linear estimator at the dose support boundary returning Weighted Average Slope (WAS) on Design 1' (`d̲ = 0` / QUG) or `WAS_{d̲}` on Design 1 (`d̲ > 0`, continuous-near-d̲ or mass-point), with a multi-period event-study extension (last-treatment cohort, pointwise CIs). **Panel-only** in this release - repeated cross-sections rejected by the validator. Alias `HAD`.
Expand Down
3 changes: 2 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Deferred items from PR reviews that were not addressed before merge.
| ImputationDiD dense `(A0'A0).toarray()` scales O((U+T+K)^2), OOM risk on large panels | `imputation.py` | #141 | Medium (deferred — only triggers when sparse solver fails) |
| Multi-absorb weighted demeaning needs iterative alternating projections for N > 1 absorbed FE with survey weights; unweighted multi-absorb also uses single-pass (pre-existing, exact only for balanced panels) | `estimators.py` | #218 | Medium |
| Survey design resolution/collapse patterns are inconsistent across panel estimators — ContinuousDiD rebuilds unit-level design in SE code, EfficientDiD builds once in fit(), StackedDiD re-resolves on stacked data; extract shared helpers for panel-to-unit collapse, post-filter re-resolution, and metadata recomputation | `continuous_did.py`, `efficient_did.py`, `stacked_did.py` | #226 | Low |
| SyntheticControl: `SyntheticControlResults` not wired into the practitioner / DiagnosticReport / BusinessReport routing, so routing SCM results through those tools yields generic parallel-trends/HonestDiD guidance that doesn't fit SCM. Add SCM to the native-routed rejection sets (mirror SDiD/TROP) and surface SCM-native diagnostics (pre-fit / in-space placebo / in-time placebo / leave-one-out). Deferred to PR-2, where it pairs with the placebo-inference layer those reports would surface. | `practitioner.py`, `diagnostic_report.py`, `business_report.py` | SCM PR-1 → PR-2 | Medium |
| SyntheticControl: in-time placebo + leave-one-out donor-robustness diagnostics are not implemented (ADH 2015, not the ADH 2010 scope of the current estimator), so `_scm_native` surfaces only pre-fit + in-space placebo. The practitioner / DiagnosticReport / BusinessReport routing and the in-space placebo permutation layer landed in PR-2; this remaining row covers adding the two ADH-2015 diagnostics (and surfacing them under `estimator_native_diagnostics`) in a later 2015-sourced PR. | `synthetic_control.py`, `diagnostic_report.py` | ADH-2015 follow-up | Low |
| ContinuousDiD deferred CGBS 2024 extensions: (a) `covariates=` kwarg not implemented (matches R `contdid` v0.1.0); (b) discrete-treatment saturated regression deferred (integer-valued dose currently warned, not routed to per-level coefficients); (c) lowest-dose-as-control per CGBS 2024 Remark 3.1 (when `P(D=0) = 0`) not implemented — estimator requires never-treated controls. REGISTRY `## ContinuousDiD` → Implementation Checklist marks these as deferred `[ ]` items. | `diff_diff/continuous_did.py` | — | Low |
| Survey-weighted Silverman bandwidth in EfficientDiD conditional Omega* — `_silverman_bandwidth()` uses unweighted mean/std for bandwidth selection; survey-weighted statistics would better reflect the population distribution but is a second-order refinement | `efficient_did_covariates.py` | — | Low |
| TROP: extend Wave 4's `_setup_trop_data` helper to also cover the duplicated bootstrap resampling loop in `_bootstrap_variance` / `_bootstrap_variance_global` (~40 LoC dedup; mirrors the data-setup helper pattern with a `fit_callable` parameter for the per-draw refit step). | `trop_local.py`, `trop_global.py` | follow-up | Low |
Expand Down Expand Up @@ -163,6 +163,7 @@ Deferred items from PR reviews that were not addressed before merge.
| MPD cluster+hc2_bm path computes CR2 precomputes twice — once via `solve_ols` → `_compute_cr2_bm` for vcov + per-coefficient DOF, then again via `_compute_cr2_bm_contrast_dof` from `MultiPeriodDiD.fit()` for the post-period-average contrast DOF. Both rebuild `H = X bread_inv X'`, the residual-maker `M`, and the per-cluster `A_g = (I - H_gg)^{-1/2}` matrices. O(n²k) redundant work; acceptable for typical cluster-robust DiD panel sizes (n ≤ a few thousand). Fix would plumb the contrast DOF through the existing CR2 vcov path (intrusive API change) or share the precomputes via a cached helper. | `linalg.py::_compute_cr2_bm_contrast_dof`, `estimators.py::MultiPeriodDiD.fit` | follow-up | Low |
| Rust-backend HC2 implementation. Current Rust path only supports HC1; HC2 and CR2 Bell-McCaffrey fall through to the NumPy backend. For large-n fits this is noticeable. | `rust/src/linalg.rs` | Phase 1a | Low |
| CR2 Bell-McCaffrey DOF uses a naive `O(n² k)` per-coefficient loop over cluster pairs. Pustejovsky-Tipton (2018) Appendix B has a scores-based formulation that avoids the full `n × n` `M` matrix. Switch when a user hits a large-`n` cluster-robust design. | `linalg.py::_compute_cr2_bm` | Phase 1a | Low |
| `SyntheticControl` retains a full `_SyntheticControlFitSnapshot` (pivoted outcome/predictor panels) on EVERY fit to support the opt-in `in_space_placebo()`, so callers who never run the placebo still pay O(units × periods × predictor-vars) memory (same as `SyntheticDiD`'s always-on snapshot for `in_time_placebo`). Store a compact array/index representation instead of per-variable DataFrames, or build the snapshot lazily on first placebo call (would need to retain the source data, ~same cost). | `synthetic_control.py` snapshot build, `synthetic_control_results.py::_SyntheticControlFitSnapshot` | follow-up | Low |

#### Testing/Docs

Expand Down
17 changes: 17 additions & 0 deletions diff_diff/_reporting_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,23 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
"reference": "REGISTRY.md Sec. TROP",
}

if name == "SyntheticControlResults":
return {
"name": "SCM ATT (mean post-treatment gap for the single treated unit)",
"definition": (
"The average over the post-treatment periods of the gap "
"``alpha_hat_{1t} = Y_{1t} - sum_j w_j Y_{jt}`` between the single "
"treated unit and its donor-weighted synthetic control (Abadie, "
"Diamond & Hainmueller 2010). There is no population-averaging or "
"sampling estimand — it is the effect on the one treated unit; "
"significance is assessed by in-space placebo permutation inference "
"(no analytical standard error)."
),
"aggregation": "single_unit_gap",
"headline_attribute": "att",
"reference": "REGISTRY.md Sec. SyntheticControl",
}

# Default: unrecognized result class. Fall through with a neutral
# block — agents / downstream consumers can still dispatch on
# ``aggregation="unknown"`` and fall back to generic ATT narration.
Expand Down
76 changes: 62 additions & 14 deletions diff_diff/business_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ def __init__(
if honest_did_results is not None and type(results).__name__ in {
"SyntheticDiDResults",
"TROPResults",
"SyntheticControlResults",
}:
raise ValueError(
f"{type(results).__name__} routes robustness to "
Expand All @@ -213,8 +214,9 @@ def __init__(
"object's native diagnostics "
"(SDiD: ``in_time_placebo()``, ``sensitivity_to_zeta_omega()``, "
"``pre_treatment_fit``; TROP: ``effective_rank``, "
"``loocv_score``) — BusinessReport surfaces these "
"automatically under ``estimator_native_diagnostics``."
"``loocv_score``; SyntheticControl: ``in_space_placebo()``, "
"``pre_rmspe``, ``get_placebo_df()``) — BusinessReport surfaces "
"these automatically under ``estimator_native_diagnostics``."
)

# Round-44 P1 CI review on PR #318: mirror the SDiD/TROP
Expand Down Expand Up @@ -646,10 +648,13 @@ def _extract_headline(self, dr_schema: Optional[Dict[str, Any]]) -> Dict[str, An
if att is None or not np.isfinite(att):
sign = "undefined"
ci_level = int(round((1.0 - display_alpha) * 100))
is_significant = (
# bool(...) coerces away numpy bool_ — when ``p`` is a numpy NaN (e.g.
# SyntheticControl, whose analytical p_value is always NaN), ``np.isfinite``
# yields a numpy bool that is NOT JSON-serializable in the schema.
is_significant = bool(
p is not None and np.isfinite(p) and p < phrasing_alpha if p is not None else False
)
near_threshold = (
near_threshold = bool(
p is not None
and np.isfinite(p)
and (phrasing_alpha - 0.01) < p < (phrasing_alpha + 0.001)
Expand Down Expand Up @@ -1002,16 +1007,25 @@ def _lift_robustness(dr: Optional[Dict[str, Any]]) -> Dict[str, Any]:
return {"status": "skipped", "reason": "auto_diagnostics=False"}
bacon = dr.get("bacon") or {}
native = dr.get("estimator_native_diagnostics") or {}
native_block = {
"status": native.get("status"),
"estimator": native.get("estimator"),
"pre_treatment_fit": native.get("pre_treatment_fit"),
}
# Classic SCM exposes pre_rmspe + donor-weight concentration + the (opt-in)
# in-space placebo rather than SDiD's pre_treatment_fit; surface those so the
# top-level robustness block is not empty for SyntheticControl.
if native.get("estimator") == "SyntheticControl":
native_block["pre_rmspe"] = native.get("pre_rmspe")
native_block["weight_concentration"] = native.get("weight_concentration")
native_block["in_space_placebo"] = native.get("in_space_placebo")
return {
"bacon": {
"status": bacon.get("status"),
"forbidden_weight": bacon.get("forbidden_weight"),
"verdict": bacon.get("verdict"),
},
"estimator_native": {
"status": native.get("status"),
"pre_treatment_fit": native.get("pre_treatment_fit"),
},
"estimator_native": native_block,
}


Expand Down Expand Up @@ -1153,6 +1167,20 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str,
"captured through latent factor loadings."
),
}
if estimator_name in {"SyntheticControlResults"}:
return {
# Distinct from SDiD's "synthetic_fit" weighted-PT analogue: classic
# SCM is a donor-weighted level match (matches the DR "scm_fit" method).
"parallel_trends_variant": "scm_fit",
"no_anticipation": True,
"description": (
"Classic synthetic control identifies the single treated unit's "
"counterfactual via a donor-weighted match to its pre-treatment "
"trajectory (a design-enforced fit, not a parallel-trends test); "
"significance comes from in-space placebo permutation inference "
"rather than an analytical standard error."
),
}
if estimator_name == "ContinuousDiDResults":
# Callaway, Goodman-Bacon & Sant'Anna (2024), two-level PT:
# REGISTRY.md §ContinuousDiD > Identification.
Expand Down Expand Up @@ -1780,6 +1808,8 @@ def _pt_method_subject(method: Optional[str]) -> str:
return "Pre-treatment event-study coefficients"
if method == "synthetic_fit":
return "The synthetic-control pre-treatment fit"
if method == "scm_fit":
return "The synthetic-control donor-weighted pre-treatment fit"
if method == "factor":
return "The factor-model pre-treatment fit"
return "Pre-treatment data"
Expand All @@ -1806,7 +1836,9 @@ def _pt_method_stat_label(method: Optional[str]) -> Optional[str]:
return "joint p"
if method in {"slope_difference", "hausman"}:
return "p"
if method in {"synthetic_fit", "factor"}:
if method in {"synthetic_fit", "scm_fit", "factor"}:
# Design-enforced fit-based paths have no p-value label (SCM's significance
# is the in-space placebo, not a PT joint test).
return None
return "joint p"

Expand Down Expand Up @@ -1846,6 +1878,13 @@ def _references_for(estimator_name: str) -> List[Dict[str, str]]:
"& Wager, S. (2021). Synthetic Difference in Differences."
),
},
"SyntheticControlResults": {
"role": "estimator",
"citation": (
"Abadie, A., Diamond, A., & Hainmueller, J. (2010). Synthetic "
"Control Methods for Comparative Case Studies. JASA, 105(490)."
),
},
"SunAbrahamResults": {
"role": "estimator",
"citation": (
Expand Down Expand Up @@ -2181,11 +2220,20 @@ def _render_summary(schema: Dict[str, Any]) -> str:
"assumption." + sens_tail_see_reliable
)
elif verdict == "design_enforced_pt":
sentences.append(
"The synthetic control is designed to match the treated "
"group's pre-period trajectory (SDiD's weighted-parallel-"
"trends analogue)."
)
if method == "scm_fit":
sentences.append(
"The synthetic control is designed to reproduce the treated "
"unit's pre-period trajectory via donor weights (classic SCM's "
"design-enforced analogue of parallel trends); significance "
"comes from in-space placebo permutation inference, not a "
"parallel-trends test."
)
else:
sentences.append(
"The synthetic control is designed to match the treated "
"group's pre-period trajectory (SDiD's weighted-parallel-"
"trends analogue)."
)
elif verdict == "inconclusive":
# Round-35 P1 CI review on PR #318: a ``verdict=="inconclusive"``
# state means one or more pre-period coefficients had
Expand Down
Loading
Loading