Skip to content

fix(sdid): shape-only pre-treatment fit RMSE, level-gap field, placebo-anchored poor-fit warning - #818

Merged
igerber merged 1 commit into
mainfrom
fix/sdid-shape-only-fit-rmse
Sep 5, 2026
Merged

fix(sdid): shape-only pre-treatment fit RMSE, level-gap field, placebo-anchored poor-fit warning#818
igerber merged 1 commit into
mainfrom
fix/sdid-shape-only-fit-rmse

Conversation

@igerber

@igerber igerber commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

  • Bug (user-reported): SyntheticDiD.pre_treatment_fit and the "Pre-treatment fit is poor" warning measured the raw level residual between the treated mean and the synthetic control, while the Frank-Wolfe unit weights are fit on column-centered outcomes (intercept=True, matching R synthdid) and deliberately leave a constant level gap to the DiD step. A parallel treated series at a different level therefore reported a large RMSE and a false poor-fit warning even when the ATT was recovered exactly. The old test_poor_fit_emits_warning pinned exactly that false positive (treated ~100 vs controls ~10, both flat).
  • Fix: pre_treatment_fit is now the shape-only RMSE (pre-period residual after removing its mean, i.e. the quantity the solver minimised), computed on the normalized arrays and rescaled by Y_scale (cancellation-free; equals the multiplier-1.0 sensitivity_to_zeta_omega() row exactly). The removed mean is exposed as pre_treatment_level_gap (signed, treated minus synthetic). in_time_placebo() and sensitivity_to_zeta_omega() report the same shape-only pre_fit_rmse (column names unchanged). NaN with a single pre-period.
  • Warning anchor (paper-anchored): the old 1 x std(treated pre-outcomes) rule is unreachable for a shape-only RMSE (a flat synthetic against a trending treated series gives exactly the population SD). The warning now uses the in-space placebo fit assessment of Abadie, Diamond & Hainmueller (2010) / Abadie (2021): the treated fit is placed within the distribution of the same statistic over placebo fits in which a random set of n_treated controls is treated as if treated and omega is re-estimated on the rest at the fit-time zeta (steps 1-3 of Algorithm 4, warm-started like the placebo SE loop, min(n_bootstrap, 20) draws from a private RNG stream so SE draws are untouched). It fires when pre_fit_placebo_pvalue = (1 + #{placebo >= treated}) / (1 + n_draws) <= 0.05, i.e. at the 20-draw default when the treated fit is worse than every placebo draw. New results fields pre_fit_placebo_rmse / pre_fit_placebo_pvalue; the p-value is in summary() and to_dict(). Known limitation (inherent to any control-side reference, documented in the registry with the rejected alternatives): treated units far noisier than the controls fit worse than every placebo and warn; the warning text says so.
  • Surfaces: summary() labels, to_dict(), DR (_pt_synthetic_fit skips on NaN instead of rendering "RMSE = nan"; narrative reworded) and the BR twin sentence, practitioner snippet, llms-full.txt, autosummary stub, REPORTING.md, docs/references.rst (Abadie 2021), tutorials 03 (source only, stays output-free) and 18 (re-executed; drift guards re-tightened; stakeholder markdown updated; "good fit" no longer claimed to validate the counterfactual).
  • Estimates, SEs, CIs and weights are unchanged. Classic SyntheticControl (intercept=False, level RMSPE by design) is untouched.
  • Cost: up to 20 extra Frank-Wolfe solves per fit for every variance method (the pure-Python test_methodology_sdid.py goes from ~218 s to ~330 s; a 50-draw cap doubled it, hence 20).
  • Follow-up tracked in TODO.md (Testing / docs): the geo tutorial's SDiD fits hit the Frank-Wolfe iteration cap under both backends on main already (the committed April outputs predate the aggregated non-convergence warning); surfaced by the notebook refresh, out of scope here.

Methodology references (required if estimator / math changes)

  • Method name(s): Synthetic Difference-in-Differences (diagnostic only; no estimator, weight or variance change)
  • Paper / source link(s): Arkhangelsky, Athey, Hirshberg, Imbens & Wager (2021), Synthetic Difference-in-Differences, AER 111(12), Algorithm 4 (placebo draws); Abadie, Diamond & Hainmueller (2010), JASA 105(490), in-space placebo fit assessment; Abadie (2021), Using Synthetic Controls, JEL 59(2); R synthdid (sc.weight.fw(intercept=TRUE), synthdid_rmse_plot plots the centered FW objective, synthdid_plot applies an intercept offset)
  • Any intentional deviations from the source (and why): the fit statistic is now consistent with the centered FW objective (the previous level RMSE was the deviation). The placebo-reference warning is the SDID transposition of the SCM placebo practice (no SDID paper defines a fit threshold; R exposes no user-facing fit statistic); the 20-draw cap and alpha = 0.05 are library choices recorded, with calibration, the known limitation and the rejected anchors (1x treated-SD, 2x sigma-hat, residual whiteness), as a **Note:** in docs/methodology/REGISTRY.md (SyntheticDiD, unit-weights block) and in the "Poor pre-treatment fit" edge case.

Validation

  • Tests added/updated: tests/test_methodology_sdid.py (TestPreTreatmentFitWarning rebuilt on the placebo p-value contract: trend-vs-flat misfit fires at p = 1/21; level-offset, flat-noise-level, single-pre-period NaN, single-control (no reference), noiseless-controls (placebo RMSE 0), treated-only level-shift invariance on all three surfaces, reference-field contract across variance methods incl. the n_bootstrap < 19 silence, and a 1e9 common-offset location-invariance test for the CI P1; test_pre_fit_rmse_recoverable recovers both fields), tests/test_estimators.py (new fields + to_dict keys), tests/test_diagnostic_report.py (single-pre-period fit -> PT analogue skipped, native diagnostics carry NaN).
  • Backtest / simulation / notebook evidence (if applicable): user's scenario reproduced (treated = control trend + 10, true ATT 2.0): ATT 1.96, old RMSE 9.30 with a false warning -> shape RMSE 0.29, level gap 9.29, no warning. Calibration at 20 draws (2 treated, 8 controls, T=8, noise 0.5): adequate fits p = 0.48-0.67; every misfit design p = 0.048. A 1e9 common offset leaves RMSE, gap and p-value unchanged (fit-time vs normalized-path RMSE identical through 1e9; the old original-scale path diverged only at 1e12+). Tutorials 03 and 18 pass pytest --nbmake; 18 re-executed in place (shape RMSE 33.42, level gap 4.41, placebo p-value 0.381; the placebo SE moved 7.3125 -> 7.2659 because the committed outputs came from an older environment - HEAD's code gives the same 7.2659 here).

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

Changelog

  • changelog.d/ fragment added (or N/A - no user-visible change): Yes (changelog.d/20260904-sdid-shape-only-fit-rmse.md: Fixed / Behavioral Changes / Added)

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Overall assessment

⚠️ Needs changes — one unmitigated P1 numerical-correctness issue affects the new diagnostics and warning.

Executive summary

  • The shape-only RMSE correctly reflects SDiD’s centered Frank–Wolfe fit objective.
  • ATT, weights, variance, SE, and inference are unchanged.
  • Original-scale diagnostic arithmetic reintroduces catastrophic cancellation and can disagree with normalized sensitivity results.
  • The new warning heuristic and limitations are properly documented.
  • Changelog validation and Python syntax checks pass; runtime tests could not run because dependencies are unavailable.

Methodology

P1 — Main-fit diagnostics bypass outcome normalization

Location: diff_diff/synthetic_did.py:L859-L880, compared with diff_diff/results.py:L1918-L1934

Impact: pre_treatment_fit and pre_treatment_level_gap are calculated by subtracting original-scale weighted trajectories. With a large common outcome level, floating-point cancellation can change both diagnostics—even though they are location-invariant mathematically—and can cause false or missed poor-fit warnings. The normalized sensitivity path avoids this, so its multiplier-1 result can disagree with the main fit. This contradicts the normalization contract documented in docs/methodology/REGISTRY.md:L3495.

Concrete fix: Calculate the residual using Y_pre_control_n, Y_pre_treated_mean_n, and omega_eff, then multiply its mean and centered RMSE by Y_scale. Original-scale trajectories can still be retained for plotting.

P3 — Warning threshold is an intentional library heuristic

Location: docs/methodology/REGISTRY.md:L3266, docs/methodology/REGISTRY.md:L3361

Impact: The 2 × noise_level rule can false-positive for noisy treated units or miss fit problems with dispersed control trends.

Concrete fix: None required. The rule, absence of a paper/R anchor, and known limitations are explicitly documented.

Code Quality

No additional findings.

Performance

No findings; the added diagnostic work is linear in the number of pre-periods.

Maintainability

No additional findings. Tests connect the fit, placebo, and sensitivity definitions at ordinary scales.

Tech Debt

P3 — Tutorial bootstrap comparison uses non-converged draws

Location: TODO.md:L80, docs/tutorials/18_geo_experiments.ipynb:L893-L900

Impact: The tutorial’s bootstrap robustness claim is not dependable when all 100 refits hit the iteration cap.

Concrete fix: None required for approval; this is explicitly tracked in TODO.md.

Security

No findings or apparent secrets.

Documentation / Tests

P2 — Tutorial overstates what good pre-fit establishes

Location: docs/tutorials/18_geo_experiments.ipynb:L733-L746

Impact: “Good fit means a trustworthy post-period effect” implies that pre-fit alone establishes identification, despite weighted parallel trends, no anticipation, and post-treatment shocks remaining unverified.

Concrete fix: Say that good fit supports design plausibility but does not by itself validate the causal counterfactual.

The required changelog fragment exists and passes the fragment checker.

Path to Approval

  1. Compute the main-fit RMSE and level gap from normalized residuals, rescaling only the final values.
  2. Add a large-common-offset regression test asserting that the main result matches the multiplier-1 sensitivity RMSE and that the warning decision and level gap remain location-invariant.

@igerber igerber changed the title fix(sdid): shape-only pre-treatment fit RMSE, level-gap field, noise-anchored poor-fit warning fix(sdid): shape-only pre-treatment fit RMSE, level-gap field, placebo-anchored poor-fit warning Sep 5, 2026
@igerber
igerber force-pushed the fix/sdid-shape-only-fit-rmse branch from d3ba66f to f485f66 Compare September 5, 2026 12:05
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: f485f66ec1a723124c18f98106391ee1f89c4f4e


Overall assessment

Looks good — no unmitigated P0 or P1 findings.

Executive summary

  • The prior P1 is resolved: fit diagnostics now use normalized residuals and rescale only final values.
  • Shape-only RMSE matches SDiD’s centered Frank–Wolfe objective; ATT, weights, SE, and inference remain unchanged.
  • The placebo warning is an explicitly documented library heuristic.
  • The prior tutorial overstatement was corrected.
  • Syntax parsing and changelog validation pass; runtime tests were unavailable because dependencies are not installed.

Methodology

P3 — Placebo-draw coincidence claim needs qualification

Location: diff_diff/synthetic_did.py:L1236-L1244, diff_diff/synthetic_did.py:L2088-L2117, docs/methodology/REGISTRY.md:L3266

Impact: The claim that diagnostic draws coincide with placebo-SE permutations is true for seeded unweighted/pweight fits, but not full-design surveys, whose SE loop uses stratified permutations. It is also not guaranteed when seed=None.

Concrete fix: Optionally qualify the statement to “for a fixed seed on non-full-design survey paths.” This is documentation-only; the unstratified diagnostic choice is explicitly documented and therefore not a defect.

No estimator, variance, SE, or identification mismatch found.

Code Quality

No findings.

Performance

No findings. The additional work is intentionally capped at 20 Frank–Wolfe solves and documented.

Maintainability

No findings.

Tech Debt

P3 — Geo tutorial uses non-converged bootstrap refits

Location: TODO.md:L80, docs/tutorials/18_geo_experiments.ipynb

Impact: The bootstrap robustness comparison is not dependable while all refits reach the iteration cap.

Concrete fix: None required for approval; remediation is explicitly tracked in TODO.md.

Security

No findings or apparent secrets.

Documentation / Tests

No findings. The required changelog.d/ fragment exists and passes the fragment checker. The large-offset, single-pre-period, level-shift, and placebo-reference regression coverage addresses the prior review findings.

@igerber
igerber force-pushed the fix/sdid-shape-only-fit-rmse branch from f485f66 to b512b54 Compare September 5, 2026 12:09
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: b512b54757a2683bff3c1a8b43d300a889a254c5


Overall assessment

⚠️ Needs changes — one newly identified P1 backward-compatibility issue remains.

Executive summary

  • SDiD estimator, weights, ATT, SE, and inference remain unchanged.
  • Shape RMSE correctly measures the centered residual component of the Frank–Wolfe loss.
  • The placebo warning heuristic and survey deviations are documented in the Methodology Registry.
  • Older pickled results silently retain the former level-RMSE semantics but are relabeled as shape-only.
  • The geo-tutorial convergence limitation is properly tracked.

Methodology

P3 — Placebo warning is a documented library heuristic

Location: diff_diff/synthetic_did.py:L1236-L1283, docs/methodology/REGISTRY.md:L3266

Impact: The 20-draw pre-fit rank diagnostic is an SDiD adaptation of SCM placebo-fit practice, not a threshold prescribed by Arkhangelsky et al. or Abadie. The Registry explicitly documents the transposition, cap, survey behavior, and limitations, so this is not a defect.

Concrete fix: None required.

No estimator, weighting, variance, SE, or identification mismatch found.

Code Quality

No findings.

Performance

No findings. The additional diagnostic work is capped at 20 Frank–Wolfe solves and documented.

Maintainability

P1 — [Newly identified] Legacy pickles silently preserve the obsolete RMSE meaning

Location: diff_diff/results.py:L1152-L1216, diff_diff/results.py:L1258-L1287, diff_diff/results.py:L1356-L1361

Impact: Results pickled before this PR contain a level-inclusive pre_treatment_fit. __setstate__() does not migrate it, so after loading, current summaries and diagnostics silently describe that old value as shape-only. A large constant level gap can therefore reappear as a false fit failure.

Concrete fix: In __setstate__(), detect states lacking pre_treatment_level_gap. Recompute shape RMSE and level gap from stored treated/synthetic pre-trajectories when available; otherwise clear the obsolete value rather than relabeling it. Initialize both placebo fields to None, and add a legacy-state regression test.

Tech Debt

P3 — Geo tutorial bootstrap refits do not converge

Location: TODO.md:L80, docs/tutorials/18_geo_experiments.ipynb

Impact: The displayed bootstrap CI is based on 100/100 non-converged refits.

Concrete fix: None required for approval; remediation is tracked in TODO.md.

Security

No findings or apparent secrets.

Documentation / Tests

P3 — Documentation overstates correspondence with the full solver objective

Location: changelog.d/20260904-sdid-shape-only-fit-rmse.md:L9-L11, diff_diff/guides/llms-full.txt:L1883

Impact: Shape RMSE is the centered residual component; the full Frank–Wolfe objective also includes the ridge penalty.

Concrete fix: Optionally replace “the quantity/objective the solver minimised” with “the data-fit component of the centered Frank–Wolfe objective.”

Runtime tests were unavailable because NumPy, Pandas, and Pytest are not installed. AST parsing, notebook JSON parsing, diff checks, and changelog-fragment structure checks passed.

Path to Approval

  1. Add legacy SyntheticDiDResults state migration and a regression test proving old level-RMSE values cannot be silently reported as shape-only.

@igerber
igerber force-pushed the fix/sdid-shape-only-fit-rmse branch from b512b54 to 5a9a973 Compare September 5, 2026 12:20
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 5a9a973abacf89b4b6bc3375991a8220a51fb836


Overall assessment

Looks good — no unmitigated P0 or P1 findings.

Executive summary

  • The prior legacy-pickle P1 is resolved with migration and regression coverage.
  • Shape-only RMSE matches the centered SDiD data-fit component; ATT, weights, SEs, and inference are unchanged.
  • The placebo warning heuristic and limitations are explicitly documented in the Methodology Registry.
  • The geo-tutorial convergence limitation is tracked in TODO.md.
  • Two minor documentation wordings could be clarified.

Methodology

P3 — Documented placebo-warning heuristic

Location: diff_diff/synthetic_did.py:L1236-L1283, docs/methodology/REGISTRY.md:L3266

Impact: The 20-draw rank diagnostic is a library adaptation of SCM placebo-fit practice, not a threshold prescribed for SDiD. The Registry explicitly documents this choice and its limitations, so it is not a defect.

Concrete fix: None required.

No estimator, weighting, variance, SE, or identification mismatch found.

Code Quality

No findings.

Performance

No findings. The additional diagnostic work is intentionally capped at 20 Frank–Wolfe solves.

Maintainability

The previous P1 is resolved. SyntheticDiDResults.__setstate__() recomputes legacy shape RMSE and level gap from stored trajectories, or clears the obsolete value when trajectories are unavailable (diff_diff/results.py:L1275-L1314). Regression coverage is present.

Tech Debt

P3 — Tracked tutorial convergence limitation

Location: TODO.md:L80

Impact: The geo tutorial’s bootstrap refits do not converge, making that robustness cross-check unreliable.

Concrete fix: None required for approval; remediation is tracked.

Security

No findings or apparent secrets.

Documentation / Tests

P3 — Minor formula wording ambiguity

Location: changelog.d/20260904-sdid-shape-only-fit-rmse.md:L14-L17

Impact: “Recomputed as shape RMSE plus level gap” can imply the old level RMSE equals their arithmetic sum, which is false.

Concrete fix: Say “the shape RMSE and level gap are recomputed from the stored trajectories.”

P3 — Draw-count wording is internally inconsistent

Location: docs/methodology/REGISTRY.md:L3266

Impact: The Registry calls 20 the smallest draw count at which α=0.05 is reachable, while 19 draws yield 1/(19+1)=0.05.

Concrete fix: State that 19 is the minimum and 20 is the chosen cap/default.

Runtime tests could not run because pytest, NumPy, and Pandas are unavailable. Python AST and notebook JSON parsing passed.

…o-anchored poor-fit warning

SyntheticDiD's pre_treatment_fit measured the raw level residual while the
Frank-Wolfe unit weights are fit on column-centered outcomes, so a parallel
treated series at a different level reported a large RMSE and a false
poor-fit warning. The RMSE is now taken on the pre-period residual after
removing its mean (the quantity the solver minimised), computed on the
normalized arrays and rescaled; the removed mean is exposed as
pre_treatment_level_gap. The poor-fit warning is anchored to an in-space
placebo fit reference (Abadie, Diamond & Hainmueller 2010; Abadie 2021):
up to 20 Algorithm-4 pseudo-treated draws refit at the fit-time zeta from
a private RNG stream, warning when the placebo p-value <= 0.05; new fields
pre_fit_placebo_rmse / pre_fit_placebo_pvalue. in_time_placebo and
sensitivity_to_zeta_omega report the same shape-only statistic; DR/BR
prose and the NaN (single pre-period) case are handled. Estimates, SEs and
weights are unchanged. Tutorials 03/18 updated (18 re-executed); a
pre-existing geo-tutorial Frank-Wolfe non-convergence surfaced by the
refresh is tracked in TODO.md.
@igerber
igerber force-pushed the fix/sdid-shape-only-fit-rmse branch from 5a9a973 to f423552 Compare September 5, 2026 12:27
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: f4235527cb01b678d8728231aa7336e8dac0fc19


Overall assessment

Looks good — no unmitigated P0 or P1 findings.

Executive summary

  • The prior legacy-pickle P1 is resolved through safe migration and regression coverage.
  • Shape-only RMSE correctly matches the centered Frank–Wolfe data-fit term; estimator, weights, SEs, and CIs are unchanged.
  • The placebo-warning heuristic and survey conventions are explicitly documented in the Methodology Registry.
  • Both prior documentation wording findings are resolved.
  • Static syntax, notebook JSON, and changelog checks passed. Runtime tests could not run because NumPy is unavailable.

Methodology

P3 — Documented placebo-warning heuristic

Location: diff_diff/synthetic_did.py:L1236-L1283, docs/methodology/REGISTRY.md:L3266

Impact: The capped placebo-rank warning is an explicitly documented SDiD adaptation of SCM placebo-fit practice, including its limitations and survey behavior. It is not a paper-prescribed SDiD threshold, but the documented deviation is informational only.

Concrete fix: None required.

No estimator, weighting, variance, SE, or identification mismatch found.

Code Quality

No findings.

Performance

No findings. The additional diagnostic computation is intentionally capped at 20 Frank–Wolfe solves.

Maintainability

No findings. The prior P1 is resolved: legacy results recompute both shape RMSE and level gap from stored trajectories, or clear the obsolete metric when reconstruction is impossible (diff_diff/results.py:L1275-L1314).

Tech Debt

P3 — Tracked tutorial convergence limitation

Location: TODO.md:L80

Impact: The geo tutorial’s non-convergent bootstrap cross-check remains unreliable, but the pre-existing limitation is explicitly tracked.

Concrete fix: None required for this PR.

Security

No findings or apparent secrets.

Documentation / Tests

No findings. The changelog wording now accurately describes trajectory-based reconstruction, and the Registry correctly states that 19 successful draws are the minimum for reaching α = 0.05. The required changelog fragment is present and valid.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Sep 5, 2026
@igerber
igerber merged commit 6e64e93 into main Sep 5, 2026
39 of 40 checks passed
@igerber igerber mentioned this pull request Sep 8, 2026
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