@@ -81,6 +81,11 @@ class ImputationDiD(ImputationDiDBootstrapMixin):
8181 - "cohort_horizon": Groups by cohort x relative time (tightest SEs)
8282 - "cohort": Groups by cohort only (more conservative)
8383 - "horizon": Groups by relative time only (more conservative)
84+ pretrends : bool, default=False
85+ If True, event study includes pre-treatment horizons for visual
86+ pre-trends assessment. Pre-period effects should be ~0 under
87+ parallel trends. Only affects event_study aggregation; overall
88+ ATT and group aggregation are unchanged.
8489
8590 Attributes
8691 ----------
@@ -134,6 +139,7 @@ def __init__(
134139 rank_deficient_action : str = "warn" ,
135140 horizon_max : Optional [int ] = None ,
136141 aux_partition : str = "cohort_horizon" ,
142+ pretrends : bool = False ,
137143 ):
138144 if rank_deficient_action not in ("warn" , "error" , "silent" ):
139145 raise ValueError (
@@ -160,6 +166,7 @@ def __init__(
160166 self .rank_deficient_action = rank_deficient_action
161167 self .horizon_max = horizon_max
162168 self .aux_partition = aux_partition
169+ self .pretrends = pretrends
163170
164171 self .is_fitted_ = False
165172 self .results_ : Optional [ImputationDiDResults ] = None
@@ -229,6 +236,14 @@ def fit(
229236 if missing :
230237 raise ValueError (f"Missing columns: { missing } " )
231238
239+ if self .pretrends and survey_design is not None and aggregate in ("event_study" , "all" ):
240+ raise NotImplementedError (
241+ "pretrends=True is not yet compatible with survey_design. "
242+ "The pre-period lead regression uses unweighted demeaning, "
243+ "which does not account for survey weights. Use pretrends=False "
244+ "with survey_design for now."
245+ )
246+
232247 # Create working copy
233248 df = data .copy ()
234249
@@ -1101,6 +1116,7 @@ def _compute_cluster_psi_sums(
11011116 # ---- Compute v_it for untreated observations ----
11021117 if covariates is None or len (covariates ) == 0 :
11031118 # FE-only case: closed-form
1119+ # Build w_by_unit, w_by_time, w_total from the target weights
11041120 treated_units = df_1 [unit ].values
11051121 treated_times = df_1 [time ].values
11061122
@@ -1116,6 +1132,9 @@ def _compute_cluster_psi_sums(
11161132
11171133 w_total = float (np .sum (weights ))
11181134
1135+ untreated_units = df_0 [unit ].values
1136+ untreated_times = df_0 [time ].values
1137+
11191138 # Use survey-weighted sums for untreated denominators when present
11201139 if survey_weights_0 is not None :
11211140 sw0_series = pd .Series (survey_weights_0 , index = df_0 .index )
@@ -1127,8 +1146,6 @@ def _compute_cluster_psi_sums(
11271146 n0_by_time = df_0 .groupby (time ).size ().to_dict ()
11281147 n0_denom = n_0
11291148
1130- untreated_units = df_0 [unit ].values
1131- untreated_times = df_0 [time ].values
11321149 v_untreated = np .zeros (n_0 )
11331150
11341151 for j in range (n_0 ):
@@ -1513,6 +1530,69 @@ def _aggregate_event_study(
15131530 "n_obs" : 0 ,
15141531 }
15151532
1533+ # Pre-period coefficients via BJS Test 1 lead regression
1534+ if self .pretrends :
1535+ df_0 = df .loc [omega_0_mask ].copy ()
1536+
1537+ # Determine which cohorts' lead indicators to include.
1538+ # balance_e restricts which cohorts contribute lead dummies,
1539+ # but the full Omega_0 sample (including never-treated controls)
1540+ # is kept for the within-transformed OLS (BJS Test 1, Equation 9).
1541+ balanced_cohorts = None
1542+ skip_preperiods = False
1543+ if balance_e is not None :
1544+ cohort_rel_times_0 = self ._build_cohort_rel_times (df , first_treat )
1545+ balanced_cohorts = set ()
1546+ if all_horizons :
1547+ max_h = max (all_horizons )
1548+ required_range = set (range (- balance_e , max_h + 1 ))
1549+ for g , horizons in cohort_rel_times_0 .items ():
1550+ if required_range .issubset (horizons ):
1551+ balanced_cohorts .add (g )
1552+ if not balanced_cohorts :
1553+ skip_preperiods = True # No cohorts qualify — skip entirely
1554+
1555+ if not skip_preperiods :
1556+ rel_time_0 = np .where (
1557+ ~ df_0 ["_never_treated" ],
1558+ df_0 [time ] - df_0 [first_treat ],
1559+ np .nan ,
1560+ )
1561+
1562+ # When balance_e is set, only include leads from balanced cohorts
1563+ if balanced_cohorts is not None :
1564+ is_balanced = df_0 [first_treat ].isin (balanced_cohorts ).values
1565+ rel_time_for_leads = np .where (is_balanced , rel_time_0 , np .nan )
1566+ else :
1567+ rel_time_for_leads = rel_time_0
1568+
1569+ pre_rel_times = sorted (
1570+ set (
1571+ int (h )
1572+ for h in rel_time_for_leads
1573+ if np .isfinite (h ) and h < - self .anticipation
1574+ )
1575+ )
1576+ pre_rel_times = [h for h in pre_rel_times if h != ref_period ]
1577+ if self .horizon_max is not None :
1578+ pre_rel_times = [
1579+ h for h in pre_rel_times if abs (h ) <= self .horizon_max
1580+ ]
1581+ if pre_rel_times :
1582+ pre_effects , _ , _ = self ._compute_lead_coefficients (
1583+ df_0 ,
1584+ outcome ,
1585+ unit ,
1586+ time ,
1587+ first_treat ,
1588+ covariates ,
1589+ cluster_var ,
1590+ pre_rel_times ,
1591+ alpha = self .alpha ,
1592+ balanced_cohorts = balanced_cohorts ,
1593+ )
1594+ event_study_effects .update (pre_effects )
1595+
15161596 # Collect horizons with Proposition 5 violations
15171597 prop5_horizons = []
15181598
@@ -1748,9 +1828,138 @@ def _aggregate_group(
17481828 return group_effects
17491829
17501830 # =========================================================================
1751- # Pre-trend test (Equation 9)
1831+ # Pre-trend test (Equation 9) & pre-period lead coefficients
17521832 # =========================================================================
17531833
1834+ def _compute_lead_coefficients (
1835+ self ,
1836+ df_0 : pd .DataFrame ,
1837+ outcome : str ,
1838+ unit : str ,
1839+ time : str ,
1840+ first_treat : str ,
1841+ covariates : Optional [List [str ]],
1842+ cluster_var : str ,
1843+ pre_rel_times : List [int ],
1844+ alpha : float = 0.05 ,
1845+ balanced_cohorts : Optional [set ] = None ,
1846+ ) -> Tuple [Dict [int , Dict [str , Any ]], np .ndarray , np .ndarray ]:
1847+ """
1848+ Compute pre-period lead coefficients via within-transformed OLS (Test 1).
1849+
1850+ Adds lead indicator dummies W_it(h) = 1[K_it = h] to the untreated
1851+ model and estimates their coefficients with cluster-robust SEs.
1852+
1853+ The full Omega_0 sample (including never-treated controls) is always
1854+ used for within-transformation. When balanced_cohorts is provided,
1855+ lead indicators are restricted to observations from those cohorts only.
1856+
1857+ Returns
1858+ -------
1859+ effects : dict
1860+ Per-horizon event_study_effects entries.
1861+ gamma : ndarray
1862+ Lead coefficient vector.
1863+ V_gamma : ndarray
1864+ Sub-VCV matrix for lead coefficients.
1865+ """
1866+ rel_time_0 = np .where (
1867+ ~ df_0 ["_never_treated" ],
1868+ df_0 [time ] - df_0 [first_treat ],
1869+ np .nan ,
1870+ )
1871+
1872+ # Build lead indicators — restrict to balanced cohorts if specified
1873+ if balanced_cohorts is not None :
1874+ is_balanced = df_0 [first_treat ].isin (balanced_cohorts ).values
1875+ else :
1876+ is_balanced = None
1877+
1878+ lead_cols = []
1879+ for h in pre_rel_times :
1880+ col_name = f"_lead_{ h } "
1881+ indicator = (rel_time_0 == h ).astype (float )
1882+ if is_balanced is not None :
1883+ indicator = indicator * is_balanced # zero out non-balanced cohorts
1884+ df_0 [col_name ] = indicator
1885+ lead_cols .append (col_name )
1886+
1887+ # Within-transform via iterative demeaning
1888+ y_dm = self ._iterative_demean (
1889+ df_0 [outcome ].values , df_0 [unit ].values , df_0 [time ].values , df_0 .index
1890+ )
1891+
1892+ all_x_cols = lead_cols [:]
1893+ if covariates :
1894+ all_x_cols .extend (covariates )
1895+
1896+ X_dm = np .column_stack (
1897+ [
1898+ self ._iterative_demean (
1899+ df_0 [col ].values , df_0 [unit ].values , df_0 [time ].values , df_0 .index
1900+ )
1901+ for col in all_x_cols
1902+ ]
1903+ )
1904+
1905+ # OLS with cluster-robust SEs
1906+ cluster_ids = df_0 [cluster_var ].values
1907+ try :
1908+ result = solve_ols (
1909+ X_dm ,
1910+ y_dm ,
1911+ cluster_ids = cluster_ids ,
1912+ return_vcov = True ,
1913+ rank_deficient_action = self .rank_deficient_action ,
1914+ column_names = all_x_cols ,
1915+ )
1916+ except (IndexError , np .linalg .LinAlgError ):
1917+ # All lead columns dropped (rank deficient after demeaning)
1918+ effects : Dict [int , Dict [str , Any ]] = {}
1919+ for h in pre_rel_times :
1920+ n_obs = int (df_0 [f"_lead_{ h } " ].sum ())
1921+ effects [h ] = {
1922+ "effect" : np .nan , "se" : np .nan , "t_stat" : np .nan ,
1923+ "p_value" : np .nan , "conf_int" : (np .nan , np .nan ),
1924+ "n_obs" : n_obs ,
1925+ }
1926+ for col in lead_cols :
1927+ df_0 .drop (columns = col , inplace = True )
1928+ return effects , np .full (len (pre_rel_times ), np .nan ), np .full (
1929+ (len (pre_rel_times ), len (pre_rel_times )), np .nan
1930+ )
1931+
1932+ coefficients = result [0 ]
1933+ vcov = result [2 ]
1934+ assert vcov is not None
1935+
1936+ n_leads = len (lead_cols )
1937+ gamma = coefficients [:n_leads ]
1938+ V_gamma = vcov [:n_leads , :n_leads ]
1939+
1940+ # Build per-horizon effects
1941+ effects = {}
1942+ for j , h in enumerate (pre_rel_times ):
1943+ effect = float (gamma [j ])
1944+ se = float (np .sqrt (max (V_gamma [j , j ], 0.0 )))
1945+ # n_obs from the lead indicator (respects balanced_cohorts restriction)
1946+ n_obs = int (df_0 [f"_lead_{ h } " ].sum ())
1947+ t_stat , p_value , conf_int = safe_inference (effect , se , alpha = alpha )
1948+ effects [h ] = {
1949+ "effect" : effect ,
1950+ "se" : se ,
1951+ "t_stat" : t_stat ,
1952+ "p_value" : p_value ,
1953+ "conf_int" : conf_int ,
1954+ "n_obs" : n_obs ,
1955+ }
1956+
1957+ # Clean up temporary columns
1958+ for col in lead_cols :
1959+ df_0 .drop (columns = col , inplace = True )
1960+
1961+ return effects , gamma , V_gamma
1962+
17541963 def _pretrend_test (self , n_leads : Optional [int ] = None ) -> Dict [str , Any ]:
17551964 """
17561965 Run pre-trend test (Equation 9).
@@ -1782,7 +1991,6 @@ def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
17821991 df_0 = df .loc [omega_0_mask ].copy ()
17831992
17841993 # Compute relative time for untreated obs
1785- # For not-yet-treated units in their pre-treatment periods
17861994 rel_time_0 = np .where (
17871995 ~ df_0 ["_never_treated" ],
17881996 df_0 [time ] - df_0 [first_treat ],
@@ -1808,7 +2016,6 @@ def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
18082016 pre_rel_times = [h for h in pre_rel_times if h != ref ]
18092017
18102018 if n_leads is not None :
1811- # Take the n_leads periods closest to treatment
18122019 pre_rel_times = sorted (pre_rel_times , reverse = True )[:n_leads ]
18132020 pre_rel_times = sorted (pre_rel_times )
18142021
@@ -1821,49 +2028,13 @@ def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
18212028 "lead_coefficients" : {},
18222029 }
18232030
1824- # Build lead indicators
1825- lead_cols = []
1826- for h in pre_rel_times :
1827- col_name = f"_lead_{ h } "
1828- df_0 [col_name ] = ((rel_time_0 == h )).astype (float )
1829- lead_cols .append (col_name )
1830-
1831- # Within-transform via iterative demeaning (exact for unbalanced panels)
1832- y_dm = self ._iterative_demean (
1833- df_0 [outcome ].values , df_0 [unit ].values , df_0 [time ].values , df_0 .index
2031+ # Use shared lead coefficient computation
2032+ effects , gamma , V_gamma = self ._compute_lead_coefficients (
2033+ df_0 , outcome , unit , time , first_treat , covariates ,
2034+ cluster_var , pre_rel_times , alpha = self .alpha ,
18342035 )
18352036
1836- all_x_cols = lead_cols [:]
1837- if covariates :
1838- all_x_cols .extend (covariates )
1839-
1840- X_dm = np .column_stack (
1841- [
1842- self ._iterative_demean (
1843- df_0 [col ].values , df_0 [unit ].values , df_0 [time ].values , df_0 .index
1844- )
1845- for col in all_x_cols
1846- ]
1847- )
1848-
1849- # OLS with cluster-robust SEs
1850- cluster_ids = df_0 [cluster_var ].values
1851- result = solve_ols (
1852- X_dm ,
1853- y_dm ,
1854- cluster_ids = cluster_ids ,
1855- return_vcov = True ,
1856- rank_deficient_action = self .rank_deficient_action ,
1857- column_names = all_x_cols ,
1858- )
1859- coefficients = result [0 ]
1860- vcov = result [2 ]
1861- assert vcov is not None
1862-
1863- # Extract lead coefficients and their sub-VCV
1864- n_leads_actual = len (lead_cols )
1865- gamma = coefficients [:n_leads_actual ]
1866- V_gamma = vcov [:n_leads_actual , :n_leads_actual ]
2037+ n_leads_actual = len (pre_rel_times )
18672038
18682039 # Wald F-test: F = (gamma' V^{-1} gamma) / n_leads
18692040 try :
@@ -1874,17 +2045,15 @@ def _pretrend_test(self, n_leads: Optional[int] = None) -> Dict[str, Any]:
18742045 f_stat = np .nan
18752046
18762047 # P-value from F distribution
2048+ cluster_ids = df_0 [cluster_var ].values
18772049 if np .isfinite (f_stat ) and f_stat >= 0 :
18782050 n_clusters = len (np .unique (cluster_ids ))
18792051 df_denom = max (n_clusters - 1 , 1 )
18802052 p_value = float (stats .f .sf (f_stat , n_leads_actual , df_denom ))
18812053 else :
18822054 p_value = np .nan
18832055
1884- # Store lead coefficients
1885- lead_coefficients = {}
1886- for j , h in enumerate (pre_rel_times ):
1887- lead_coefficients [h ] = float (gamma [j ])
2056+ lead_coefficients = {h : effects [h ]["effect" ] for h in pre_rel_times }
18882057
18892058 return {
18902059 "f_stat" : f_stat ,
@@ -1910,6 +2079,7 @@ def get_params(self) -> Dict[str, Any]:
19102079 "rank_deficient_action" : self .rank_deficient_action ,
19112080 "horizon_max" : self .horizon_max ,
19122081 "aux_partition" : self .aux_partition ,
2082+ "pretrends" : self .pretrends ,
19132083 }
19142084
19152085 def set_params (self , ** params ) -> "ImputationDiD" :
0 commit comments