Repository navigation
fix(staggered): carry the bootstrap event-study covariance instead of clearing it - #839
Merged
Merged
Conversation
… clearing it
With n_bootstrap > 0, CallawaySantAnna cleared event_study_vcov at fit time
and in the post-fit aggregate("event_study") replay, so HonestDiD fell back
to diag(se**2) and its Delta^SD intervals under-covered (igerber#836). The clearing
kept the analytical VCV away from percentile-bootstrap SEs, but the
multiplier bootstrap already holds a consistent covariance: the event-study
SEs are np.std(draws, ddof=1) of es_boot_matrix, so the covariance of the
same draws has se**2 on its diagonal by construction.
CSBootstrapResults now carries that covariance over the horizons with a
finite bootstrap SE, and fit() and the replay (CallawaySantAnna and DMLDiD)
hand it on. It is not stored when n_bootstrap does not exceed the number of
horizons (B draws give rank <= B - 1), when a draw is non-finite, or with
fewer than two PSUs; consumers then keep the diagonal fallback. PreTrendsPower
and the DiagnosticReport parallel-trends check now read the same matrix.
With fewer PSUs than event-study horizons the bootstrap covariance is singular, as the analytical one is, so HonestDiD's container route now raises its singular-covariance ValueError where it used to fall back to the diagonal. State it under Behavioral Changes and in the CallawaySantAnna REGISTRY note.
…e the container route
HonestDiD.fit(results) on a CallawaySantAnnaResults object assigned the
stored event_study_vcov unvalidated, while the aggregate('event_study')
container route ran _validate_vcov_subblock(..., allow_singular=False).
With fewer PSUs than horizons the stored matrix (analytical or bootstrap)
is singular, so the native route returned finite bounds where the
container route raised. Run the same check on the native route.
- tests: singular few-PSU regression on both routes (analytical and
bootstrap); the survey-df test moves from 4 PSUs (df=2, rank-2 VCV
over 6 horizons - singular) to 10 nested PSUs (df=8).
- docs: REGISTRY CS/HonestDiD notes, changelog fragment, and the
practitioner guide line that still described the diagonal fallback on
bootstrapped CS containers.
…ce, not vcov=None
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #836.
Problem
With
n_bootstrap > 0,CallawaySantAnnaclearsevent_study_vcov, both at fit time and in the post-fitaggregate("event_study")replay. HonestDiD then warns and usesdiag(se**2), which drops the covariance between horizons. For the smoothness restriction this is anti-conservative in an ordinary staggered design: in the #836 simulation (parallel trends hold, known effect path), the Δ^SD(M = 0) interval for the average post-period effect covers the truth in about 68% of panels at a nominal 95%. PreTrendsPower and the DiagnosticReport parallel-trends check fall back the same way on bootstrapped CS fits.Reproducer
main
6874b5d, Python 3.14.7, numpy 2.5.3, pandas 3.0.6, scipy 1.18.1, pure-Python backend (DIFF_DIFF_BACKEND=python). The script is the one in #836.Script
On main:
On this branch:
Cause
diff_diff/staggered.py:2956-2958(fit time) anddiff_diff/staggered_results.py:494-495(post-fit replay) setevent_study_vcov/event_study_vcov_indextoNonewhenever the bootstrap ran. The clearing has a sound reason: the analytical influence-function VCV must not sit beside percentile-bootstrap SEs, because its diagonal would disagree with the reported SEs (_validate_vcov_subblockrejects such a container,results_base.py:842). But the bootstrap already holds a covariance that agrees with its SEs._run_multiplier_bootstrapbuildses_boot_matrix(draws × event times,staggered_bootstrap.py:679), and the event-study SEs arenp.std(draws, axis=0, ddof=1)of exactly those columns (bootstrap_utils.py:466; no weighting, no IQR rescaling). Sonp.cov(es_boot_matrix, rowvar=False, ddof=1)hasse**2on its diagonal by construction. The clearing discarded it.Fix
CSBootstrapResultsgainsevent_study_vcovandevent_study_vcov_index, appended last withNonedefaults (repr=Falseon the matrix)._run_multiplier_bootstrapstoresnp.cov(es_boot_matrix[:, kept], rowvar=False, ddof=1), wherekeptare the horizons with a finite bootstrap SE. A universal base period's reference horizon has all-zero draws and a NaN SE, so it stays out, as it stays out of the analytical index. Nothing is stored, and consumers keep the existing diagonal fallback and its warning, in three cases:bootstrap_utils.py:505), so a full-column covariance would not match it;n_bootstrapdoes not exceed the number of kept horizons. The covariance of B draws has rank at most B - 1, so the matrix would be singular by construction. The HonestDiD container route rejects a singular matrix (allow_singular=False), and the native route would use it silently.fit()and the post-fit replay hand this matrix on instead of clearing it. Under bootstrap the stored event-study VCV is always the bootstrap one, never the analytical one, on both routes.DMLDiDResults.aggregate("event_study")uses the same replay, so it is fixed too.EventStudyResults.vcovdocstrings, the SunAbraham comment that pointed at the CS clearing, REGISTRY (a new CallawaySantAnna**Note:**, the HonestDiD**Note (deviation from R):**, the PreTrendsPower Σ_22 note, the post-fitaggregate()note), REPORTING.md andguides/llms.txt.Compatibility
event_study_vcov/event_study_vcov_indexand the container'svcov/vcov_indexare now populated. As a result:compute_pretrends_powerreportscovariance_source="full_pre_period_vcov"instead of"diag_fallback", so power and MDV move.joint_wald_event_study, or_surveyon survey designs) instead of the Bonferroni fallback. This is a visible change to report output; it is listed under Behavioral Changes in the fragment.results.aggregate("event_study")then raises the singular-covarianceValueErrorit already raises for analytical fits, where main used the diagonal with a warning, and the DiagnosticReport sensitivity check reports that error. HonestDiD on the results object uses the matrix, as for analytical fits. Example: 80 units in 7 PSUs, 9 horizons,base_period="universal",n_bootstrap=199. Both matrices have rank 6. The native relative-magnitude (M = 1) interval is (1.712, 2.443), against (1.706, 2.449) analytically and (1.737, 2.417) with main's diagonal. I kept the analytical path's behavior so that the two paths agree. If you prefer a diagonal fallback for a singular bootstrap matrix, it is a small gate in_run_multiplier_bootstrap. This is also under Behavioral Changes.CSBootstrapResultsis public. The new fields come last with defaults, so keyword and positional construction still work.event_study_vcov=None(the diagonal fallback, as before). Theiraggregate("event_study")replays the bootstrap, so the replayed container now carries the matrix.CSBootstrapResultsnow also holds the matrix, but its results expose no event-study covariance, so nothing visible changes.Methodology references (required if estimator / math changes)
honest_did.AGGTEobjbuilds Σ from the influence-function matrix even whendidreports bootstrap SEs. Here, Σ on a bootstrapped fit is the covariance of the bootstrap draws, so its diagonal is the reported SEs squared. It is the multivariate counterpart of those SEs, so it relates to the analytical Σ exactly as the bootstrap SEs relate to the analytical SEs. Documented in the REGISTRY HonestDiD**Note (deviation from R):**and the new CallawaySantAnna**Note:**.Validation
Tests added/updated:
6874b5dand pass on this branch (18 failed / 1 passed before, 19 passed after):tests/test_staggered.py::TestCallawaySantAnnaBootstrap::test_bootstrap_event_study_vcov_is_draw_covariance(varying base; universal base; universal base withanticipation=1). The VCV is finite, symmetric and positive definite, ordered as its index, with diagonal equal to the bootstrap SEs squared (rtol=1e-12) and non-zero cross-horizon covariance. Reference-only horizons are excluded.tests/test_staggered.py::TestCallawaySantAnnaBootstrap::test_bootstrap_event_study_vcov_needs_more_draws_than_horizons. Withn_bootstrapequal to the number of horizons nothing is stored; with one more draw the matrix is stored.tests/test_honest_did.py::TestSurveyVariance::test_bootstrap_vcov_same_on_native_and_container_routes. HonestDiD's Σ equals the stored bootstrap matrix on the native route. The container route gives the same Σ and the same bounds.tests/test_honest_did.py::TestSurveyVariance::test_bootstrap_fit_without_vcov_falls_back_to_diagonal. Below the draw limit both routes keep the diagonal fallback and its warning.tests/test_dml_did.py::TestAggregationBootstrap::test_event_study_replay_carries_bootstrap_covariance. The DMLDiD replay container carries the matrix, with diagonal equal tose**2.tests/test_aggregate_contract.py::TestBootstrapReplayDesigns::test_single_psu_nan_surfaces_and_warninggainsassert es.vcov is None. This behavior does not change, so it passes before and after.tests/test_aggregate_contract.py::_assert_es_replay_parity:assert es.vcov is Nonebecomes parity of the replayedvcov/vcov_indexwith the fit-timeevent_study_vcov(rtol=atol=1e-13, the helper's existing tolerance). This covers the 7 callers inTestBootstrapReplay/TestBootstrapReplayDesigns(event study,balance_e, bare cluster, stratified survey, FPC, repeated cross-sections, unbalanced panel).tests/test_aggregate_contract.py::TestBootstrapReplayConsumers::test_honest_did_warns_then_uses_diagonalbecomestest_honest_did_uses_bootstrap_covariance: no "no full covariance" warning, and a finiteoriginal_se.tests/test_aggregate_contract.py::TestBootstrapReplayConsumers::test_pretrends_power_rides_diag_fallbackbecomestest_pretrends_power_uses_bootstrap_covariance: it now assertscovariance_source == "full_pre_period_vcov"(it previously asserted onlypower is not None).tests/test_honest_did.py::TestSurveyVariance::test_bootstrap_fit_clears_analytical_vcovbecomestest_bootstrap_fit_carries_bootstrap_vcov: the matrix is present, its diagonal equalsse**2, and the diagonal warning no longer fires.tests/test_diagnostic_report.py::TestDerivedEventStudySurface::test_bootstrap_plain_cs_derives_and_runs: the method changes frombonferronitojoint_wald_event_study, andcovariance_sourceisfull_pre_period_vcov.DIFF_DIFF_R=skip:ruff check diff_diff tests(0.16.7) andblack --check diff_diff tests(26.5.1): clean.mypy diff_diff(2.3.1 with numpy 2.4.5, pandas 3.0.3, scipy 1.17.1, as inlint.yml): no issues in 112 source files.python .claude/scripts/changelog_compile.py check: OK.tests/test_changelog_fragments.pyandtests/test_doc_deps_integrity.py: 328 passed..claude/scripts/premerge_scan.py: 0 pattern findings.event_study_vcov, HonestDiD, PreTrendsPower or DiagnosticReport (48 files): 5336 passed, 31 skipped, 0 failed.premerge_scan.pyresolves, plus the TripleDifference suites (10 files: SunAbraham, StaggeredTripleDifference, TripleDifference,test_staggered_rc.py,test_pretrends_event_study.pyand others): 408 passed, 119 skipped (all R-dependent), 0 failed.tests/test_changelog_fragments.py,test_doc_deps_integrity.py,test_docs_ia.py,test_doc_snippets.py,test_guides.py,test_naming_guard.py: 596 passed, 7 skipped.DIFF_DIFF_R=skip); notebooks (none changed).Backtest / simulation / notebook evidence (if applicable): the [Bug]: HonestDiD on a bootstrapped CallawaySantAnna event study drops the cross-horizon covariance; Δ^SD(0) covers 68% at 95% #836 coverage script, unchanged (
honest_coverage.py <n_units> <n_bootstrap> <panels>). Seeds 0 to N - 1 drive both the panel and the bootstrap, and every arm uses the same seeds. Coverage of the Δ^SD(M = 0) interval for the average post-period effect, nominal 95%:n_bootstrap=0, main = this branchn_bootstrap=999, main6874b5dn_bootstrap=999, this branch1000 panels per cell, seeds 0 to 999. The Monte Carlo SE of a coverage rate is about 1.5 points near 68% and 0.8 to 0.9 points near 92-94%. The analytical arm gives identical numbers on main and on this branch. The bootstrapped arm now matches it within Monte Carlo error on the same panels; the shortfall from 95% at 100 units is in the analytical arm too.
Security / privacy
Changelog
changelog.d/20261005-cs-bootstrap-event-study-vcov.md, with### Fixed(bootstrapped CS fits carry the bootstrap event-study covariance, so HonestDiD bounds change) and### Behavioral Changes(PreTrendsPower and DiagnosticReport read it; with few clusters, HonestDiD on the container raises as on analytical fits).