@@ -218,13 +218,18 @@ def compute_trend(group_data):
218218 mean_t = np .mean (time_norm )
219219 mean_y = np .mean (outcome_values )
220220
221- slope = np .sum ((time_norm - mean_t ) * (outcome_values - mean_y )) / np .sum ((time_norm - mean_t ) ** 2 )
221+ # Check for zero variance in time (all same time period)
222+ time_var = np .sum ((time_norm - mean_t ) ** 2 )
223+ if time_var == 0 :
224+ return np .nan , np .nan
225+
226+ slope = np .sum ((time_norm - mean_t ) * (outcome_values - mean_y )) / time_var
222227
223228 # Compute standard error of slope
224229 y_hat = mean_y + slope * (time_norm - mean_t )
225230 residuals = outcome_values - y_hat
226231 mse = np .sum (residuals ** 2 ) / (n - 2 )
227- se_slope = np .sqrt (mse / np . sum (( time_norm - mean_t ) ** 2 ) )
232+ se_slope = np .sqrt (mse / time_var )
228233
229234 return slope , se_slope
230235
@@ -258,7 +263,8 @@ def check_parallel_trends_robust(
258263 unit : str = None ,
259264 pre_periods : list = None ,
260265 n_permutations : int = 1000 ,
261- seed : int = None
266+ seed : int = None ,
267+ wasserstein_threshold : float = 0.2
262268) -> dict :
263269 """
264270 Perform robust parallel trends testing using distributional comparisons.
@@ -286,6 +292,9 @@ def check_parallel_trends_robust(
286292 Number of permutations for computing p-value.
287293 seed : int, optional
288294 Random seed for reproducibility.
295+ wasserstein_threshold : float, default=0.2
296+ Threshold for normalized Wasserstein distance. Values below this
297+ threshold (combined with p > 0.05) suggest parallel trends are plausible.
289298
290299 Returns
291300 -------
@@ -321,8 +330,8 @@ def check_parallel_trends_robust(
321330 of pre-treatment changes are similar, supporting the parallel trends
322331 assumption.
323332 """
324- if seed is not None :
325- np .random .seed (seed )
333+ # Use local RNG to avoid affecting global random state
334+ rng = np .random .default_rng (seed )
326335
327336 # Identify pre-treatment periods
328337 if pre_periods is None :
@@ -361,7 +370,7 @@ def check_parallel_trends_robust(
361370
362371 permuted_distances = np .zeros (n_permutations )
363372 for i in range (n_permutations ):
364- perm_idx = np . random .permutation (n_total )
373+ perm_idx = rng .permutation (n_total )
365374 perm_treated = all_changes [perm_idx [:n_treated ]]
366375 perm_control = all_changes [perm_idx [n_treated :]]
367376 permuted_distances [i ] = stats .wasserstein_distance (perm_treated , perm_control )
@@ -383,10 +392,10 @@ def check_parallel_trends_robust(
383392 wasserstein_normalized = wasserstein_dist / pooled_std if pooled_std > 0 else np .nan
384393
385394 # Assessment: parallel trends plausible if p-value > 0.05
386- # and normalized Wasserstein is small (< 0.2 as rule of thumb )
395+ # and normalized Wasserstein is small (below threshold )
387396 plausible = bool (
388397 wasserstein_p > 0.05 and
389- (wasserstein_normalized < 0.2 if not np .isnan (wasserstein_normalized ) else True )
398+ (wasserstein_normalized < wasserstein_threshold if not np .isnan (wasserstein_normalized ) else True )
390399 )
391400
392401 return {
@@ -523,37 +532,82 @@ def equivalence_test_trends(
523532 pre_data , outcome , time , treatment_group , unit
524533 )
525534
535+ # Need at least 2 observations per group to compute variance
536+ # and at least 3 total for meaningful df calculation
526537 if len (treated_changes ) < 2 or len (control_changes ) < 2 :
527538 return {
528539 "mean_difference" : np .nan ,
540+ "se_difference" : np .nan ,
529541 "equivalence_margin" : np .nan ,
542+ "lower_t_stat" : np .nan ,
543+ "upper_t_stat" : np .nan ,
530544 "lower_p_value" : np .nan ,
531545 "upper_p_value" : np .nan ,
532546 "tost_p_value" : np .nan ,
547+ "degrees_of_freedom" : np .nan ,
533548 "equivalent" : None ,
534- "error" : "Insufficient data" ,
549+ "error" : "Insufficient data (need at least 2 observations per group) " ,
535550 }
536551
537552 # Compute statistics
553+ var_t = np .var (treated_changes , ddof = 1 )
554+ var_c = np .var (control_changes , ddof = 1 )
555+ n_t = len (treated_changes )
556+ n_c = len (control_changes )
557+
538558 mean_diff = np .mean (treated_changes ) - np .mean (control_changes )
539- se_diff = np .sqrt (
540- np .var (treated_changes , ddof = 1 ) / len (treated_changes ) +
541- np .var (control_changes , ddof = 1 ) / len (control_changes )
542- )
559+
560+ # Handle zero variance case
561+ if var_t == 0 and var_c == 0 :
562+ return {
563+ "mean_difference" : mean_diff ,
564+ "se_difference" : 0.0 ,
565+ "equivalence_margin" : np .nan ,
566+ "lower_t_stat" : np .nan ,
567+ "upper_t_stat" : np .nan ,
568+ "lower_p_value" : np .nan ,
569+ "upper_p_value" : np .nan ,
570+ "tost_p_value" : np .nan ,
571+ "degrees_of_freedom" : np .nan ,
572+ "equivalent" : None ,
573+ "error" : "Zero variance in both groups - cannot perform t-test" ,
574+ }
575+
576+ se_diff = np .sqrt (var_t / n_t + var_c / n_c )
577+
578+ # Handle zero SE case (cannot divide by zero in t-stat calculation)
579+ if se_diff == 0 :
580+ return {
581+ "mean_difference" : mean_diff ,
582+ "se_difference" : 0.0 ,
583+ "equivalence_margin" : np .nan ,
584+ "lower_t_stat" : np .nan ,
585+ "upper_t_stat" : np .nan ,
586+ "lower_p_value" : np .nan ,
587+ "upper_p_value" : np .nan ,
588+ "tost_p_value" : np .nan ,
589+ "degrees_of_freedom" : np .nan ,
590+ "equivalent" : None ,
591+ "error" : "Zero standard error - cannot perform t-test" ,
592+ }
543593
544594 # Set equivalence margin if not provided
545595 if equivalence_margin is None :
546596 pooled_changes = np .concatenate ([treated_changes , control_changes ])
547597 equivalence_margin = 0.5 * np .std (pooled_changes , ddof = 1 )
548598
549599 # Degrees of freedom (Welch-Satterthwaite approximation)
550- var_t = np .var (treated_changes , ddof = 1 )
551- var_c = np .var (control_changes , ddof = 1 )
552- n_t = len (treated_changes )
553- n_c = len (control_changes )
554-
555- df = ((var_t / n_t + var_c / n_c )** 2 /
556- ((var_t / n_t )** 2 / (n_t - 1 ) + (var_c / n_c )** 2 / (n_c - 1 )))
600+ # Guard against division by zero when one group has zero variance
601+ numerator = (var_t / n_t + var_c / n_c )** 2
602+ denom_t = (var_t / n_t )** 2 / (n_t - 1 ) if var_t > 0 else 0
603+ denom_c = (var_c / n_c )** 2 / (n_c - 1 ) if var_c > 0 else 0
604+ denominator = denom_t + denom_c
605+
606+ if denominator == 0 :
607+ # Fall back to minimum of n_t-1 and n_c-1 when one variance is zero
608+ df = min (n_t - 1 , n_c - 1 )
609+ else :
610+ df = numerator / denominator
557611
558612 # TOST: Two one-sided tests
559613 # Test 1: H0: diff <= -margin vs H1: diff > -margin
0 commit comments