@@ -48,13 +48,14 @@ class DCDHBootstrapResults:
4848 in the underlying data (e.g., no leavers), the matching fields are
4949 ``None``.
5050
51- **Phase 1 placebo bootstrap is intentionally NOT computed.** The
52- dynamic companion paper Section 3.7.3 derives the cohort-recentered
53- analytical variance for ``DID_l`` only, not for the placebo
51+ **Phase 1 per-period placebo (L_max=None) bootstrap is NOT computed.**
52+ The dynamic companion paper Section 3.7.3 derives the cohort-recentered
53+ analytical variance for ``DID_l`` only, not for the per-period
5454 ``DID_M^pl``. The ``placebo_se`` / ``placebo_ci`` / ``placebo_p_value``
55- fields below ALWAYS remain ``None`` in Phase 1, even when
56- ``n_bootstrap > 0``. Phase 2 will add multiplier-bootstrap support
57- for the placebo via the dynamic paper's machinery.
55+ fields below remain ``None`` for Phase 1. Multi-horizon placebos
56+ (``L_max >= 1``) have valid SE via ``placebo_horizon_ses`` - this is
57+ a library extension applying the same IF/variance structure to the
58+ placebo estimand (see REGISTRY.md dynamic placebo SE Note).
5859
5960 Attributes
6061 ----------
@@ -84,12 +85,14 @@ class DCDHBootstrapResults:
8485 leavers_p_value : float, optional
8586 Bootstrap p-value for leavers-only ``DID_-``.
8687 placebo_se : float, optional
87- **Always ``None`` in Phase 1** — placebo bootstrap is deferred
88- to Phase 2 (see class docstring above).
88+ ``None`` for the Phase 1 single-period placebo (``L_max=None``).
89+ Multi-horizon placebo bootstrap SE is on
90+ ``placebo_horizon_ses``.
8991 placebo_ci : tuple of float, optional
90- **Always ``None`` in Phase 1** (see class docstring above) .
92+ ``None`` for single-period placebo. See ``placebo_horizon_cis`` .
9193 placebo_p_value : float, optional
92- **Always ``None`` in Phase 1** (see class docstring above).
94+ ``None`` for single-period placebo. See
95+ ``placebo_horizon_p_values``.
9396 bootstrap_distribution : np.ndarray, optional
9497 Full bootstrap distribution of the overall ``DID_M`` estimator
9598 (shape: ``(n_bootstrap,)``). Stored for advanced diagnostics;
@@ -160,9 +163,10 @@ class ChaisemartinDHaultfoeuilleResults:
160163 ``summary()``, ``to_dataframe()``, ``is_significant``, and
161164 ``significance_stars`` all read from these top-level fields and
162165 therefore reflect the bootstrap inference automatically. The
163- placebo path is unchanged: placebo bootstrap is deferred to Phase
164- 2, so ``placebo_p_value`` and ``placebo_conf_int`` stay NaN even
165- when ``n_bootstrap > 0``. See the methodology registry
166+ single-period placebo (``L_max=None``) still has NaN bootstrap
167+ fields; multi-horizon placebos (``L_max >= 1``) have valid
168+ bootstrap SE/CI/p via ``placebo_horizon_ses/cis/p_values``.
169+ See the methodology registry
166170 ``Note (bootstrap inference surface)`` for the full contract and
167171 library precedent.
168172
@@ -273,8 +277,12 @@ class ChaisemartinDHaultfoeuilleResults:
273277 n_treated_obs : int
274278 Treated observations in the post-filter sample.
275279 n_switcher_cells : int
276- Number of switching ``(g, t)`` cells across periods. Equals
277- ``sum_t (n_10_t + n_01_t)`` where each transition cell counts
280+ When ``L_max=None``: number of switching ``(g, t)`` cells
281+ (``N_S = sum_t (n_10_t + n_01_t)``). When ``L_max >= 1``:
282+ number of eligible switcher groups at horizon 1 (``N_1``).
283+ Previously this field always held the cell count; for
284+ ``L_max >= 1`` it was repurposed to hold the per-group count
285+ that matches the ``DID_1`` estimand. Originally equals
278286 once regardless of how many original observations fed into it.
279287 This is the ``N_S`` denominator of ``DID_M`` per AER 2020
280288 Theorem 3 — cell counts, not within-cell observation counts.
@@ -301,14 +309,14 @@ class ChaisemartinDHaultfoeuilleResults:
301309 alpha : float
302310 Significance level used for confidence intervals.
303311 event_study_effects : dict, optional
304- In Phase 1 this is populated with a single entry for horizon
305- ``1``, mirroring ``overall_att``. Keeping the field shape stable
306- avoids API churn when Phase 2 adds entries for ``l = 2, ..., L``.
312+ Populated with horizon ``1`` when ``L_max=None``, or horizons
313+ ``1..L_max`` when ``L_max >= 1``. When ``L_max >= 1``, uses the
314+ per-group ``DID_{g,l}`` path; when ``L_max=None``, uses the
315+ per-period ``DID_M`` path.
307316 normalized_effects : dict, optional
308- Phase 2 placeholder ( ``DID^n_l``). Always ``None`` in Phase 1 .
317+ Normalized estimator ``DID^n_l``. Populated when ``L_max >= 1`` .
309318 cost_benefit_delta : dict, optional
310- Phase 2 placeholder (cost-benefit aggregate ``delta``). Always
311- ``None`` in Phase 1.
319+ Cost-benefit aggregate ``delta``. Populated when ``L_max >= 2``.
312320 sup_t_bands : dict, optional
313321 Phase 2 placeholder (sup-t simultaneous confidence bands).
314322 covariate_residuals : pd.DataFrame, optional
@@ -411,7 +419,12 @@ class ChaisemartinDHaultfoeuilleResults:
411419 def __repr__ (self ) -> str :
412420 """Concise string representation."""
413421 sig = _get_significance_stars (self .overall_p_value )
414- label = "delta" if self .L_max is not None and self .L_max >= 2 else "DID_M"
422+ if self .L_max is not None and self .L_max >= 2 :
423+ label = "delta"
424+ elif self .L_max is not None and self .L_max == 1 :
425+ label = "DID_1"
426+ else :
427+ label = "DID_M"
415428 return (
416429 f"ChaisemartinDHaultfoeuilleResults("
417430 f"{ label } ={ self .overall_att :.4f} { sig } , "
@@ -477,7 +490,9 @@ def summary(self, alpha: Optional[float] = None) -> str:
477490 "" ,
478491 f"{ 'Total observations:' :<35} { self .n_obs :>10} " ,
479492 f"{ 'Treated observations:' :<35} { self .n_treated_obs :>10} " ,
480- f"{ 'Switcher cells (N_S):' :<35} { self .n_switcher_cells :>10} " ,
493+ f"{ 'Eligible switchers (N_1):' :<35} { self .n_switcher_cells :>10} "
494+ if self .L_max is not None and self .L_max >= 1
495+ else f"{ 'Switcher cells (N_S):' :<35} { self .n_switcher_cells :>10} " ,
481496 f"{ 'Groups (post-filter):' :<35} { len (self .groups ):>10} " ,
482497 f"{ 'Cohorts:' :<35} { self .n_cohorts :>10} " ,
483498 f"{ 'Time periods:' :<35} { len (self .time_periods ):>10} " ,
@@ -507,12 +522,15 @@ def summary(self, alpha: Optional[float] = None) -> str:
507522 )
508523
509524 # --- Overall ---
510- overall_label = (
511- "Cost-Benefit Delta"
512- if self .L_max is not None and self .L_max >= 2
513- else "DID_M (Contemporaneous-Switch ATT)"
514- )
515- overall_row_label = "delta" if self .L_max is not None and self .L_max >= 2 else "DID_M"
525+ if self .L_max is not None and self .L_max >= 2 :
526+ overall_label = "Cost-Benefit Delta"
527+ overall_row_label = "delta"
528+ elif self .L_max is not None and self .L_max == 1 :
529+ overall_label = "DID_1 (Per-Group ATT at Horizon 1)"
530+ overall_row_label = "DID_1"
531+ else :
532+ overall_label = "DID_M (Contemporaneous-Switch ATT)"
533+ overall_row_label = "DID_M"
516534 lines .extend (
517535 [
518536 thin ,
@@ -537,7 +555,8 @@ def summary(self, alpha: Optional[float] = None) -> str:
537555
538556 cv = self .coef_var
539557 if np .isfinite (cv ):
540- lines .append (f"{ 'CV (SE/|DID_M|):' :<25} { cv :>10.4f} " )
558+ cv_label = f"CV (SE/|{ overall_row_label } |):"
559+ lines .append (f"{ cv_label :<25} { cv :>10.4f} " )
541560
542561 lines .append ("" )
543562 is_delta = (
@@ -647,8 +666,8 @@ def summary(self, alpha: Optional[float] = None) -> str:
647666 ]
648667 )
649668
650- # --- Phase 2: Event study table ---
651- if self .L_max is not None and self .L_max >= 2 and self .event_study_effects :
669+ # --- Event study table (L_max >= 1) ---
670+ if self .L_max is not None and self .L_max >= 1 and self .event_study_effects :
652671 lines .extend (
653672 [
654673 thin ,
@@ -768,18 +787,19 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
768787 level : str, default="overall"
769788 One of:
770789
771- - ``"overall"``: single-row table with the overall ``DID_M``
772- point estimate, SE, t-stat, p-value, CI bounds.
773- - ``"joiners_leavers"``: three rows for ``DID_M``, ``DID_+``,
774- and ``DID_-``.
790+ - ``"overall"``: single-row table with the overall estimand
791+ (``DID_M`` when ``L_max=None``, ``DID_1`` when ``L_max=1``,
792+ ``delta`` when ``L_max >= 2``).
793+ - ``"joiners_leavers"``: up to three rows for the overall,
794+ ``DID_+``, and ``DID_-`` (binary panels only).
775795 - ``"per_period"``: one row per time period with
776796 ``did_plus_t``, ``did_minus_t``, switching cell counts, and
777797 the A11-zeroed flags.
778798 - ``"event_study"``: one row per horizon (positive and
779799 negative/placebo), including a reference period at
780- horizon 0. Available when ``L_max >= 2 ``.
800+ horizon 0. Available when ``L_max >= 1 ``.
781801 - ``"normalized"``: one row per horizon for the normalized
782- effects ``DID^n_l``. Available when ``L_max >= 2 ``.
802+ effects ``DID^n_l``. Available when ``L_max >= 1 ``.
783803 - ``"twfe_weights"``: per-(group, time) TWFE decomposition
784804 weights table. Only available when ``twfe_diagnostic=True``
785805 was passed to ``fit()``.
@@ -793,7 +813,11 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
793813 [
794814 {
795815 "estimand" : (
796- "delta" if self .L_max is not None and self .L_max >= 2 else "DID_M"
816+ "delta"
817+ if self .L_max is not None and self .L_max >= 2
818+ else "DID_1"
819+ if self .L_max is not None and self .L_max == 1
820+ else "DID_M"
797821 ),
798822 "effect" : self .overall_att ,
799823 "se" : self .overall_se ,
@@ -816,7 +840,12 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
816840 # For the DID_M row, both quantities use the overall switching
817841 # cell set: n_cells = sum of joiner + leaver cells, and n_obs
818842 # is the same sum of raw observation counts.
819- overall_est_label = "delta" if self .L_max is not None and self .L_max >= 2 else "DID_M"
843+ if self .L_max is not None and self .L_max >= 2 :
844+ overall_est_label = "delta"
845+ elif self .L_max is not None and self .L_max == 1 :
846+ overall_est_label = "DID_1"
847+ else :
848+ overall_est_label = "DID_M"
820849 rows = [
821850 {
822851 "estimand" : overall_est_label ,
@@ -827,7 +856,11 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
827856 "conf_int_lower" : self .overall_conf_int [0 ],
828857 "conf_int_upper" : self .overall_conf_int [1 ],
829858 "n_cells" : self .n_switcher_cells ,
830- "n_obs" : self .n_joiner_obs + self .n_leaver_obs ,
859+ "n_obs" : (
860+ self .n_treated_obs
861+ if not self .joiners_available and not self .leavers_available
862+ else self .n_joiner_obs + self .n_leaver_obs
863+ ),
831864 "available" : True ,
832865 },
833866 {
@@ -942,7 +975,7 @@ def to_dataframe(self, level: str = "overall") -> pd.DataFrame:
942975
943976 elif level == "normalized" :
944977 if not self .normalized_effects :
945- raise ValueError ("Normalized effects not computed. Pass L_max >= 2 to fit()." )
978+ raise ValueError ("Normalized effects not computed. Pass L_max >= 1 to fit()." )
946979 rows = []
947980 for h in sorted (self .normalized_effects .keys ()):
948981 entry = self .normalized_effects [h ]
0 commit comments