@@ -7299,6 +7299,115 @@ def test_per_path_placebos_with_trends_linear_present(self):
72997299 "placebo path may have regressed."
73007300 )
73017301
7302+ @pytest .mark .slow
7303+ def test_per_path_placebos_with_trends_linear_bootstrap_inference (self ):
7304+ """Bootstrap-derived inference fields populated on negative-
7305+ horizon ``path_placebo_event_study`` rows under ``by_path +
7306+ trends_linear + placebo + n_bootstrap > 0``. Pins the placebo
7307+ bootstrap collector path that consumes the first-differenced
7308+ ``Y_mat`` AND the bootstrap propagation block at
7309+ ``chaisemartin_dhaultfoeuille.py:3097-`` for negative horizons.
7310+ Without this, a silent regression in the placebo bootstrap
7311+ propagation would surface analytical SEs on a bootstrap fit.
7312+ """
7313+ data = _by_path_data_with_trends_linear ()
7314+ with warnings .catch_warnings ():
7315+ warnings .simplefilter ("ignore" , UserWarning )
7316+ est_a = ChaisemartinDHaultfoeuille (
7317+ drop_larger_lower = False , by_path = 3 , placebo = True
7318+ )
7319+ res_a = est_a .fit (
7320+ data ,
7321+ outcome = "outcome" ,
7322+ group = "group" ,
7323+ time = "period" ,
7324+ treatment = "treatment" ,
7325+ trends_linear = True ,
7326+ L_max = 3 ,
7327+ )
7328+ est_b = ChaisemartinDHaultfoeuille (
7329+ drop_larger_lower = False ,
7330+ by_path = 3 ,
7331+ placebo = True ,
7332+ n_bootstrap = 200 ,
7333+ seed = 42 ,
7334+ )
7335+ res_b = est_b .fit (
7336+ data ,
7337+ outcome = "outcome" ,
7338+ group = "group" ,
7339+ time = "period" ,
7340+ treatment = "treatment" ,
7341+ trends_linear = True ,
7342+ L_max = 3 ,
7343+ )
7344+ # Negative-horizon placebo rows must exist and carry bootstrap-
7345+ # derived inference. Verify by comparing analytical-only fit's
7346+ # SEs to bootstrap-fit's SEs on the same negative-horizon
7347+ # entries: bootstrap should differ (non-bit-identical) since
7348+ # the propagation block overwrites SE / p_value / conf_int.
7349+ assert res_b .path_placebo_event_study is not None
7350+ any_se_diff = False
7351+ any_finite = False
7352+ for path , lag_dict in res_b .path_placebo_event_study .items ():
7353+ for lag_k , vals_b in lag_dict .items ():
7354+ if not np .isfinite (vals_b ["se" ]):
7355+ continue
7356+ any_finite = True
7357+ vals_a = res_a .path_placebo_event_study .get (path , {}).get (lag_k )
7358+ if vals_a is None or not np .isfinite (vals_a ["se" ]):
7359+ continue
7360+ if abs (vals_b ["se" ] - vals_a ["se" ]) > 1e-10 :
7361+ any_se_diff = True
7362+ break
7363+ if any_se_diff :
7364+ break
7365+ assert any_finite , "No finite negative-horizon bootstrap SEs surfaced"
7366+ assert any_se_diff , (
7367+ "Bootstrap fit produced bit-identical SEs to analytical fit on "
7368+ "every negative-horizon placebo cell; the placebo bootstrap "
7369+ "propagation block under trends_linear may not be running."
7370+ )
7371+
7372+ @pytest .mark .slow
7373+ def test_per_path_placebos_with_trends_linear_bootstrap_nan_consistent (self ):
7374+ """``n_bootstrap=1`` produces NaN-consistent inference on
7375+ negative-horizon ``path_placebo_event_study`` rows under
7376+ ``by_path + trends_linear + placebo``. Pins the library-wide
7377+ NaN-on-invalid bootstrap contract on the new placebo path.
7378+ """
7379+ data = _by_path_data_with_trends_linear ()
7380+ with warnings .catch_warnings ():
7381+ warnings .simplefilter ("ignore" , (UserWarning , RuntimeWarning ))
7382+ est = ChaisemartinDHaultfoeuille (
7383+ drop_larger_lower = False ,
7384+ by_path = 3 ,
7385+ placebo = True ,
7386+ n_bootstrap = 1 ,
7387+ seed = 42 ,
7388+ )
7389+ res = est .fit (
7390+ data ,
7391+ outcome = "outcome" ,
7392+ group = "group" ,
7393+ time = "period" ,
7394+ treatment = "treatment" ,
7395+ trends_linear = True ,
7396+ L_max = 3 ,
7397+ )
7398+ assert res .path_placebo_event_study is not None
7399+ # n_bootstrap=1 → degenerate bootstrap distribution → NaN SE /
7400+ # p_value / conf_int on every negative-horizon entry.
7401+ for path , lag_dict in res .path_placebo_event_study .items ():
7402+ for lag_k , vals in lag_dict .items ():
7403+ assert not np .isfinite (vals ["se" ]), (
7404+ f"path={ path } lag={ lag_k } : SE finite ({ vals ['se' ]} ) "
7405+ "under n_bootstrap=1; expected NaN"
7406+ )
7407+ assert not np .isfinite (vals ["p_value" ]), (
7408+ f"path={ path } lag={ lag_k } : p_value finite under n_bootstrap=1"
7409+ )
7410+
73027411 @pytest .mark .slow
73037412 def test_sup_t_bands_with_trends_linear_finite_crit (self ):
73047413 """Per-path joint sup-t bands populated under ``by_path +
@@ -7851,3 +7960,84 @@ def test_sup_t_bands_with_trends_nonparam_finite_crit(self):
78517960 "No positive-horizon cband rows populated under "
78527961 "trends_nonparam + bootstrap"
78537962 )
7963+
7964+ @pytest .mark .slow
7965+ def test_per_path_placebos_with_trends_nonparam_bootstrap_inference (self ):
7966+ """Bootstrap-derived inference fields populated on negative-
7967+ horizon ``path_placebo_event_study`` rows under ``by_path +
7968+ trends_nonparam + placebo + n_bootstrap > 0``.
7969+
7970+ Pins the ``set_ids`` threading into
7971+ ``_collect_path_placebo_bootstrap_inputs`` (line 5963 in the
7972+ diff): without that threading, the placebo bootstrap collector
7973+ would re-compute the per-group placebo IF with set_ids=None,
7974+ bypassing the set-restricted control pool. We verify by
7975+ comparing two bootstrap fits — one with trends_nonparam, one
7976+ without — and asserting at least one negative-horizon SE
7977+ differs (the set restriction must propagate through the
7978+ placebo bootstrap path) AND remains finite.
7979+ """
7980+ data = _by_path_data_with_trends_nonparam ()
7981+ with warnings .catch_warnings ():
7982+ warnings .simplefilter ("ignore" , UserWarning )
7983+ est_no_set = ChaisemartinDHaultfoeuille (
7984+ drop_larger_lower = False ,
7985+ by_path = 3 ,
7986+ placebo = True ,
7987+ n_bootstrap = 200 ,
7988+ seed = 42 ,
7989+ )
7990+ res_no = est_no_set .fit (
7991+ data ,
7992+ outcome = "outcome" ,
7993+ group = "group" ,
7994+ time = "period" ,
7995+ treatment = "treatment" ,
7996+ L_max = 3 ,
7997+ )
7998+ est_set = ChaisemartinDHaultfoeuille (
7999+ drop_larger_lower = False ,
8000+ by_path = 3 ,
8001+ placebo = True ,
8002+ n_bootstrap = 200 ,
8003+ seed = 42 ,
8004+ )
8005+ res_set = est_set .fit (
8006+ data ,
8007+ outcome = "outcome" ,
8008+ group = "group" ,
8009+ time = "period" ,
8010+ treatment = "treatment" ,
8011+ trends_nonparam = "state" ,
8012+ L_max = 3 ,
8013+ )
8014+ assert res_set .path_placebo_event_study is not None
8015+ assert res_no .path_placebo_event_study is not None
8016+ any_diff = False
8017+ any_finite = False
8018+ for path , lag_dict in res_set .path_placebo_event_study .items ():
8019+ for lag_k , vals_set in lag_dict .items ():
8020+ if not np .isfinite (vals_set ["se" ]):
8021+ continue
8022+ any_finite = True
8023+ vals_no = res_no .path_placebo_event_study .get (path , {}).get (
8024+ lag_k
8025+ )
8026+ if vals_no is None or not np .isfinite (vals_no ["se" ]):
8027+ continue
8028+ # Set restriction shrinks the control pool; with the
8029+ # same seed, the bootstrap distribution should differ.
8030+ if abs (vals_set ["se" ] - vals_no ["se" ]) > 1e-10 :
8031+ any_diff = True
8032+ break
8033+ if any_diff :
8034+ break
8035+ assert any_finite , (
8036+ "No finite negative-horizon bootstrap SEs surfaced under "
8037+ "trends_nonparam + placebo + bootstrap"
8038+ )
8039+ assert any_diff , (
8040+ "Bootstrap placebo SEs are bit-identical with vs without "
8041+ "trends_nonparam restriction; set_ids may not be reaching "
8042+ "the per-path placebo bootstrap collector."
8043+ )
0 commit comments