diff-diff 1.1.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
diff_diff/__init__.py CHANGED
@@ -45,6 +45,13 @@ from diff_diff.power import (
45
45
  compute_sample_size,
46
46
  simulate_power,
47
47
  )
48
+ from diff_diff.pretrends import (
49
+ PreTrendsPower,
50
+ PreTrendsPowerCurve,
51
+ PreTrendsPowerResults,
52
+ compute_mdv,
53
+ compute_pretrends_power,
54
+ )
48
55
  from diff_diff.prep import (
49
56
  aggregate_to_cohorts,
50
57
  balance_panel,
@@ -87,10 +94,11 @@ from diff_diff.visualization import (
87
94
  plot_group_effects,
88
95
  plot_honest_event_study,
89
96
  plot_power_curve,
97
+ plot_pretrends_power,
90
98
  plot_sensitivity,
91
99
  )
92
100
 
93
- __version__ = "1.1.0"
101
+ __version__ = "1.2.1"
94
102
  __all__ = [
95
103
  # Estimators
96
104
  "DifferenceInDifferences",
@@ -164,4 +172,11 @@ __all__ = [
164
172
  "compute_sample_size",
165
173
  "simulate_power",
166
174
  "plot_power_curve",
175
+ # Pre-trends power analysis
176
+ "PreTrendsPower",
177
+ "PreTrendsPowerResults",
178
+ "PreTrendsPowerCurve",
179
+ "compute_pretrends_power",
180
+ "compute_mdv",
181
+ "plot_pretrends_power",
167
182
  ]
diff_diff/diagnostics.py CHANGED
@@ -625,11 +625,30 @@ def permutation_test(
625
625
  # Handle edge cases where fitting fails
626
626
  permuted_effects[i] = np.nan
627
627
 
628
- # Remove any NaN values
628
+ # Remove any NaN values and track failure rate
629
629
  valid_effects = permuted_effects[~np.isnan(permuted_effects)]
630
+ n_failed = n_permutations - len(valid_effects)
630
631
 
631
632
  if len(valid_effects) == 0:
632
- raise RuntimeError("All permutations failed - check your data")
633
+ raise RuntimeError(
634
+ f"All {n_permutations} permutations failed. This typically occurs when:\n"
635
+ f" - Treatment/control groups are too small for valid permutation\n"
636
+ f" - Data contains collinearity or singular matrices after permutation\n"
637
+ f" - There are too few observations per time period\n"
638
+ f"Consider checking data quality with validate_did_data() from diff_diff.prep."
639
+ )
640
+
641
+ # Warn if significant number of permutations failed
642
+ if n_failed > 0:
643
+ failure_rate = n_failed / n_permutations
644
+ if failure_rate > 0.1:
645
+ import warnings
646
+ warnings.warn(
647
+ f"{n_failed}/{n_permutations} permutations failed ({failure_rate:.1%}). "
648
+ f"Results based on {len(valid_effects)} successful permutations.",
649
+ UserWarning,
650
+ stacklevel=2
651
+ )
633
652
 
634
653
  # Compute p-value: proportion of |permuted| >= |original|
635
654
  p_value = np.mean(np.abs(valid_effects) >= np.abs(original_att))
@@ -736,11 +755,30 @@ def leave_one_out_test(
736
755
  # Skip units that cause fitting issues
737
756
  loo_effects[u] = np.nan
738
757
 
739
- # Remove NaN values for statistics
758
+ # Remove NaN values for statistics and track failures
740
759
  valid_effects = [v for v in loo_effects.values() if not np.isnan(v)]
760
+ n_total = len(loo_effects)
761
+ n_failed = n_total - len(valid_effects)
741
762
 
742
763
  if len(valid_effects) == 0:
743
- raise RuntimeError("All leave-one-out estimates failed")
764
+ raise RuntimeError(
765
+ f"All {n_total} leave-one-out estimates failed. This typically occurs when:\n"
766
+ f" - Removing any single treated unit causes model fitting to fail\n"
767
+ f" - Very few treated units (need at least 2 for LOO)\n"
768
+ f" - Data has collinearity issues that manifest when units are removed\n"
769
+ f"Consider checking data quality and ensuring sufficient treated units."
770
+ )
771
+
772
+ # Warn if significant number of LOO iterations failed
773
+ if n_failed > 0:
774
+ import warnings
775
+ failed_units = [u for u, v in loo_effects.items() if np.isnan(v)]
776
+ warnings.warn(
777
+ f"{n_failed}/{n_total} leave-one-out estimates failed for units: {failed_units}. "
778
+ f"Results based on {len(valid_effects)} successful iterations.",
779
+ UserWarning,
780
+ stacklevel=2
781
+ )
744
782
 
745
783
  # Statistics of LOO distribution
746
784
  mean_effect = np.mean(valid_effects)
@@ -838,8 +876,13 @@ def run_all_placebo_tests(
838
876
  )
839
877
  results[f"fake_timing_{period}"] = test_result
840
878
  except Exception as e:
841
- # Store error info
842
- results[f"fake_timing_{period}"] = {"error": str(e)}
879
+ # Store structured error info for debugging
880
+ results[f"fake_timing_{period}"] = {
881
+ "error": str(e),
882
+ "error_type": type(e).__name__,
883
+ "test_type": "fake_timing",
884
+ "period": period
885
+ }
843
886
 
844
887
  # Permutation test
845
888
  try:
@@ -856,7 +899,11 @@ def run_all_placebo_tests(
856
899
  )
857
900
  results["permutation"] = perm_result
858
901
  except Exception as e:
859
- results["permutation"] = {"error": str(e)}
902
+ results["permutation"] = {
903
+ "error": str(e),
904
+ "error_type": type(e).__name__,
905
+ "test_type": "permutation"
906
+ }
860
907
 
861
908
  # Leave-one-out test
862
909
  try:
@@ -871,6 +918,10 @@ def run_all_placebo_tests(
871
918
  )
872
919
  results["leave_one_out"] = loo_result
873
920
  except Exception as e:
874
- results["leave_one_out"] = {"error": str(e)}
921
+ results["leave_one_out"] = {
922
+ "error": str(e),
923
+ "error_type": type(e).__name__,
924
+ "test_type": "leave_one_out"
925
+ }
875
926
 
876
927
  return results
diff_diff/estimators.py CHANGED
@@ -19,6 +19,7 @@ import pandas as pd
19
19
 
20
20
  from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect
21
21
  from diff_diff.utils import (
22
+ WildBootstrapResults,
22
23
  compute_confidence_interval,
23
24
  compute_p_value,
24
25
  compute_robust_se,
@@ -279,22 +280,9 @@ class DifferenceInDifferences:
279
280
  if self.inference == "wild_bootstrap" and self.cluster is not None:
280
281
  # Wild cluster bootstrap for few-cluster inference
281
282
  cluster_ids = data[self.cluster].values
282
- bootstrap_results = wild_bootstrap_se(
283
- X, y, residuals, cluster_ids,
284
- coefficient_index=att_idx,
285
- n_bootstrap=self.n_bootstrap,
286
- weight_type=self.bootstrap_weights,
287
- alpha=self.alpha,
288
- seed=self.seed,
289
- return_distribution=False
283
+ se, p_value, conf_int, t_stat, vcov, _ = self._run_wild_bootstrap_inference(
284
+ X, y, residuals, cluster_ids, att_idx
290
285
  )
291
- self._bootstrap_results = bootstrap_results
292
- se = bootstrap_results.se
293
- p_value = bootstrap_results.p_value
294
- conf_int = (bootstrap_results.ci_lower, bootstrap_results.ci_upper)
295
- t_stat = bootstrap_results.t_stat_original
296
- # Also compute vcov for storage (using cluster-robust for consistency)
297
- vcov = compute_robust_se(X, residuals, cluster_ids)
298
286
  elif self.cluster is not None:
299
287
  cluster_ids = data[self.cluster].values
300
288
  vcov = compute_robust_se(X, residuals, cluster_ids)
@@ -408,6 +396,56 @@ class DifferenceInDifferences:
408
396
 
409
397
  return coefficients, residuals, fitted, r_squared
410
398
 
399
+ def _run_wild_bootstrap_inference(
400
+ self,
401
+ X: np.ndarray,
402
+ y: np.ndarray,
403
+ residuals: np.ndarray,
404
+ cluster_ids: np.ndarray,
405
+ coefficient_index: int,
406
+ ) -> Tuple[float, float, Tuple[float, float], float, np.ndarray, WildBootstrapResults]:
407
+ """
408
+ Run wild cluster bootstrap inference.
409
+
410
+ Parameters
411
+ ----------
412
+ X : np.ndarray
413
+ Design matrix.
414
+ y : np.ndarray
415
+ Outcome vector.
416
+ residuals : np.ndarray
417
+ OLS residuals.
418
+ cluster_ids : np.ndarray
419
+ Cluster identifiers for each observation.
420
+ coefficient_index : int
421
+ Index of the coefficient to compute inference for.
422
+
423
+ Returns
424
+ -------
425
+ tuple
426
+ (se, p_value, conf_int, t_stat, vcov, bootstrap_results)
427
+ """
428
+ bootstrap_results = wild_bootstrap_se(
429
+ X, y, residuals, cluster_ids,
430
+ coefficient_index=coefficient_index,
431
+ n_bootstrap=self.n_bootstrap,
432
+ weight_type=self.bootstrap_weights,
433
+ alpha=self.alpha,
434
+ seed=self.seed,
435
+ return_distribution=False
436
+ )
437
+ self._bootstrap_results = bootstrap_results
438
+
439
+ se = bootstrap_results.se
440
+ p_value = bootstrap_results.p_value
441
+ conf_int = (bootstrap_results.ci_lower, bootstrap_results.ci_upper)
442
+ t_stat = bootstrap_results.t_stat_original
443
+
444
+ # Also compute vcov for storage (using cluster-robust for consistency)
445
+ vcov = compute_robust_se(X, residuals, cluster_ids)
446
+
447
+ return se, p_value, conf_int, t_stat, vcov, bootstrap_results
448
+
411
449
  def _parse_formula(
412
450
  self, formula: str, data: pd.DataFrame
413
451
  ) -> Tuple[str, str, str, Optional[List[str]]]:
diff_diff/prep.py CHANGED
@@ -601,7 +601,7 @@ def summarize_did_data(
601
601
  if len(time_vals) == 2:
602
602
  pre_val, post_val = time_vals[0], time_vals[1]
603
603
 
604
- def format_label(x):
604
+ def format_label(x: tuple) -> str:
605
605
  treatment_label = 'Treated' if x[0] == 1 else 'Control'
606
606
  time_label = 'Post' if x[1] == post_val else 'Pre'
607
607
  return f"{treatment_label} - {time_label}"