diff-diff 1.1.0__py3-none-any.whl → 1.2.0__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 +16 -1
- diff_diff/diagnostics.py +59 -8
- diff_diff/estimators.py +53 -15
- diff_diff/prep.py +1 -1
- diff_diff/pretrends.py +1067 -0
- diff_diff/staggered.py +2 -2
- diff_diff/synthetic_did.py +28 -4
- diff_diff/twfe.py +2 -15
- diff_diff/utils.py +1 -1
- diff_diff/visualization.py +236 -0
- {diff_diff-1.1.0.dist-info → diff_diff-1.2.0.dist-info}/METADATA +161 -1
- diff_diff-1.2.0.dist-info/RECORD +19 -0
- diff_diff-1.1.0.dist-info/RECORD +0 -18
- {diff_diff-1.1.0.dist-info → diff_diff-1.2.0.dist-info}/WHEEL +0 -0
- {diff_diff-1.1.0.dist-info → diff_diff-1.2.0.dist-info}/top_level.txt +0 -0
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.
|
|
101
|
+
__version__ = "1.2.0"
|
|
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(
|
|
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(
|
|
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}"] = {
|
|
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"] = {
|
|
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"] = {
|
|
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
|
-
|
|
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}"
|