diff-diff 3.5.2__tar.gz → 3.5.3__tar.gz
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-3.5.2 → diff_diff-3.5.3}/PKG-INFO +1 -1
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/__init__.py +3 -1
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/estimators.py +37 -3
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/llms-full.txt +7 -1
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/linalg.py +41 -2
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/power.py +214 -9
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/prep.py +1 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/prep_dgp.py +217 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/results.py +10 -3
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/twfe.py +3 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/two_stage.py +67 -29
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/two_stage_bootstrap.py +90 -42
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/two_stage_results.py +4 -3
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/utils.py +308 -136
- {diff_diff-3.5.2 → diff_diff-3.5.3}/pyproject.toml +1 -1
- {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/Cargo.lock +42 -308
- {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/Cargo.toml +1 -1
- {diff_diff-3.5.2 → diff_diff-3.5.3}/LICENSE +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/README.md +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/_backend.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/_guides_api.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/_nprobust_port.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/_reporting_helpers.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/agent_workflow.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/bacon.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/balancing.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/bootstrap_utils.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/business_report.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/chaisemartin_dhaultfoeuille.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/conformal.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/conley.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/continuous_did.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/continuous_did_bspline.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/continuous_did_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/datasets.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/diagnostic_report.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/diagnostics.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did_bootstrap.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did_covariates.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did_weights.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/__init__.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/llms-autonomous.txt +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/llms-practitioner.txt +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/llms.txt +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/had.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/had_pretests.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/honest_did.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/imputation.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/imputation_bootstrap.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/imputation_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/local_linear.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/practitioner.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/pretrends.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/profile.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/spillover.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/stacked_did.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/stacked_did_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_aggregation.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_bootstrap.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_triple_diff.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_triple_diff_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/sun_abraham.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/survey.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/synthetic_control.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/synthetic_control_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/triple_diff.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/trop.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/trop_global.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/trop_local.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/trop_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/__init__.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_common.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_continuous.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_diagnostic.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_event_study.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_power.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_staggered.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_synthetic.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/wooldridge.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/wooldridge_results.py +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/build.rs +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/bootstrap.rs +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/lib.rs +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/linalg.rs +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/trop.rs +0 -0
- {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/weights.rs +0 -0
|
@@ -133,6 +133,7 @@ from diff_diff.prep import (
|
|
|
133
133
|
generate_staggered_data,
|
|
134
134
|
generate_staggered_ddd_data,
|
|
135
135
|
generate_survey_did_data,
|
|
136
|
+
generate_synthetic_control_data,
|
|
136
137
|
make_post_indicator,
|
|
137
138
|
make_treatment_indicator,
|
|
138
139
|
rank_control_units,
|
|
@@ -298,7 +299,7 @@ ETWFE = WooldridgeDiD
|
|
|
298
299
|
DCDH = ChaisemartinDHaultfoeuille
|
|
299
300
|
HAD = HeterogeneousAdoptionDiD
|
|
300
301
|
|
|
301
|
-
__version__ = "3.5.
|
|
302
|
+
__version__ = "3.5.3"
|
|
302
303
|
__all__ = [
|
|
303
304
|
# Estimators
|
|
304
305
|
"DifferenceInDifferences",
|
|
@@ -426,6 +427,7 @@ __all__ = [
|
|
|
426
427
|
"generate_survey_did_data",
|
|
427
428
|
"generate_continuous_did_data",
|
|
428
429
|
"generate_reversible_did_data",
|
|
430
|
+
"generate_synthetic_control_data",
|
|
429
431
|
"create_event_time",
|
|
430
432
|
"aggregate_survey",
|
|
431
433
|
"aggregate_to_cohorts",
|
|
@@ -23,7 +23,6 @@ from diff_diff.linalg import (
|
|
|
23
23
|
LinearRegression,
|
|
24
24
|
_expand_vcov_with_nan,
|
|
25
25
|
compute_r_squared,
|
|
26
|
-
compute_robust_vcov,
|
|
27
26
|
solve_ols,
|
|
28
27
|
)
|
|
29
28
|
from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect
|
|
@@ -99,6 +98,12 @@ class DifferenceInDifferences:
|
|
|
99
98
|
bootstrap_weights : str, default="rademacher"
|
|
100
99
|
Type of bootstrap weights: "rademacher" (standard), "webb"
|
|
101
100
|
(recommended for <10 clusters), or "mammen" (skewness correction).
|
|
101
|
+
p_val_type : str, default="two-tailed"
|
|
102
|
+
Shape of the wild cluster bootstrap test (mirrors
|
|
103
|
+
``fwildclusterboot::boottest``): "two-tailed" (test on ``|t|``,
|
|
104
|
+
two-tailed inverted CI — which may be asymmetric) or "equal-tailed"
|
|
105
|
+
(each tail at ``alpha/2``, equal-tailed CI). Only used when
|
|
106
|
+
``inference="wild_bootstrap"``.
|
|
102
107
|
seed : int, optional
|
|
103
108
|
Random seed for reproducibility when using bootstrap inference.
|
|
104
109
|
If None (default), results will vary between runs.
|
|
@@ -181,6 +186,7 @@ class DifferenceInDifferences:
|
|
|
181
186
|
inference: str = "analytical",
|
|
182
187
|
n_bootstrap: int = 999,
|
|
183
188
|
bootstrap_weights: str = "rademacher",
|
|
189
|
+
p_val_type: str = "two-tailed",
|
|
184
190
|
seed: Optional[int] = None,
|
|
185
191
|
rank_deficient_action: str = "warn",
|
|
186
192
|
conley_coords: Optional[Tuple[str, str]] = None,
|
|
@@ -208,6 +214,9 @@ class DifferenceInDifferences:
|
|
|
208
214
|
self.inference = inference
|
|
209
215
|
self.n_bootstrap = n_bootstrap
|
|
210
216
|
self.bootstrap_weights = bootstrap_weights
|
|
217
|
+
# Test shape for wild cluster bootstrap (mirrors fwildclusterboot's
|
|
218
|
+
# p_val_type): "two-tailed" (default) or "equal-tailed".
|
|
219
|
+
self.p_val_type = p_val_type
|
|
211
220
|
self.seed = seed
|
|
212
221
|
self.rank_deficient_action = rank_deficient_action
|
|
213
222
|
# Conley spatial-HAC parameters; column names (NOT array values) for
|
|
@@ -698,10 +707,12 @@ class DifferenceInDifferences:
|
|
|
698
707
|
inference_method = "analytical"
|
|
699
708
|
n_bootstrap_used = None
|
|
700
709
|
n_clusters_used = None
|
|
710
|
+
p_val_type_used = None
|
|
701
711
|
if self._bootstrap_results is not None:
|
|
702
712
|
inference_method = "wild_bootstrap"
|
|
703
713
|
n_bootstrap_used = self._bootstrap_results.n_bootstrap
|
|
704
714
|
n_clusters_used = self._bootstrap_results.n_clusters
|
|
715
|
+
p_val_type_used = self._bootstrap_results.p_val_type
|
|
705
716
|
|
|
706
717
|
# Store results
|
|
707
718
|
self.results_ = DiDResults(
|
|
@@ -722,6 +733,7 @@ class DifferenceInDifferences:
|
|
|
722
733
|
inference_method=inference_method,
|
|
723
734
|
n_bootstrap=n_bootstrap_used,
|
|
724
735
|
n_clusters=n_clusters_used,
|
|
736
|
+
p_val_type=p_val_type_used,
|
|
725
737
|
survey_metadata=survey_metadata,
|
|
726
738
|
# Report the family that actually produced the SE, which may be
|
|
727
739
|
# the remapped "hc1" (CR1) under the legacy alias path, not the
|
|
@@ -804,6 +816,7 @@ class DifferenceInDifferences:
|
|
|
804
816
|
alpha=self.alpha,
|
|
805
817
|
seed=self.seed,
|
|
806
818
|
return_distribution=False,
|
|
819
|
+
p_val_type=self.p_val_type,
|
|
807
820
|
)
|
|
808
821
|
self._bootstrap_results = bootstrap_results
|
|
809
822
|
|
|
@@ -812,8 +825,25 @@ class DifferenceInDifferences:
|
|
|
812
825
|
conf_int = (bootstrap_results.ci_lower, bootstrap_results.ci_upper)
|
|
813
826
|
t_stat = bootstrap_results.t_stat_original
|
|
814
827
|
|
|
815
|
-
# Also compute vcov for storage
|
|
816
|
-
|
|
828
|
+
# Also compute the cluster-robust vcov for storage. Use the rank-aware
|
|
829
|
+
# solve_ols path (silently dropping collinear nuisance columns and
|
|
830
|
+
# NaN-expanding the vcov for them), matching how wild_bootstrap_se itself
|
|
831
|
+
# handles rank-deficient full-dummy designs — `compute_robust_vcov()`
|
|
832
|
+
# inverts the full X'X directly and would raise (or return garbage) on a
|
|
833
|
+
# rank-deficient design even though the ATT and bootstrap are identified.
|
|
834
|
+
# On a saturated design (degenerate bootstrap, NaN se) store a NaN vcov
|
|
835
|
+
# to keep the all-or-nothing NaN contract. (On a full-rank design this
|
|
836
|
+
# vcov is bit-identical to the prior compute_robust_vcov result.)
|
|
837
|
+
if np.isnan(se):
|
|
838
|
+
vcov = np.full((X.shape[1], X.shape[1]), np.nan)
|
|
839
|
+
else:
|
|
840
|
+
_, _, vcov = solve_ols(
|
|
841
|
+
X,
|
|
842
|
+
y,
|
|
843
|
+
cluster_ids=cluster_ids,
|
|
844
|
+
return_vcov=True,
|
|
845
|
+
rank_deficient_action="silent",
|
|
846
|
+
)
|
|
817
847
|
|
|
818
848
|
return se, p_value, conf_int, t_stat, vcov, bootstrap_results
|
|
819
849
|
|
|
@@ -994,6 +1024,7 @@ class DifferenceInDifferences:
|
|
|
994
1024
|
"inference": self.inference,
|
|
995
1025
|
"n_bootstrap": self.n_bootstrap,
|
|
996
1026
|
"bootstrap_weights": self.bootstrap_weights,
|
|
1027
|
+
"p_val_type": self.p_val_type,
|
|
997
1028
|
"seed": self.seed,
|
|
998
1029
|
"rank_deficient_action": self.rank_deficient_action,
|
|
999
1030
|
"conley_coords": self.conley_coords,
|
|
@@ -1319,6 +1350,9 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
1319
1350
|
# the analytical-fallback warning first would produce contradictory
|
|
1320
1351
|
# guidance on the same call (warn "falling back" + raise "not
|
|
1321
1352
|
# supported"). The Conley raise takes precedence. Codex CI R11 P3.
|
|
1353
|
+
# NOTE: ``p_val_type`` is inherited from DifferenceInDifferences but is
|
|
1354
|
+
# inert here — MultiPeriodDiD has no wild-bootstrap path (it falls back
|
|
1355
|
+
# to analytical inference below), so the parameter has no effect.
|
|
1322
1356
|
effective_inference = self.inference
|
|
1323
1357
|
if self.inference == "wild_bootstrap" and self.vcov_type != "conley":
|
|
1324
1358
|
warnings.warn(
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> A Python library for Difference-in-Differences causal inference analysis. Provides sklearn-like estimators with statsmodels-style output for econometric analysis.
|
|
4
4
|
|
|
5
|
-
- Version: 3.5.
|
|
5
|
+
- Version: 3.5.3
|
|
6
6
|
- Repository: https://github.com/igerber/diff-diff
|
|
7
7
|
- License: MIT
|
|
8
8
|
- Dependencies: numpy, pandas, scipy (no statsmodels dependency)
|
|
@@ -1956,6 +1956,7 @@ from diff_diff import (
|
|
|
1956
1956
|
generate_factor_data,
|
|
1957
1957
|
generate_ddd_data,
|
|
1958
1958
|
generate_continuous_did_data,
|
|
1959
|
+
generate_synthetic_control_data,
|
|
1959
1960
|
)
|
|
1960
1961
|
|
|
1961
1962
|
# Basic 2x2 DiD data
|
|
@@ -1985,6 +1986,11 @@ data = generate_ddd_data(n_per_cell=100, treatment_effect=2.0, seed=42)
|
|
|
1985
1986
|
# Continuous dose data
|
|
1986
1987
|
data = generate_continuous_did_data(n_units=500, n_periods=4,
|
|
1987
1988
|
att_function="linear", att_slope=2.0, seed=42)
|
|
1989
|
+
|
|
1990
|
+
# Single-treated-unit panel for synthetic control (noiseless treated path in the
|
|
1991
|
+
# donor convex hull; ramping or constant effect). Use with SyntheticControl.
|
|
1992
|
+
data = generate_synthetic_control_data(n_donors=20, n_pre=60, n_post=5,
|
|
1993
|
+
effect_type="ramp", seed=0)
|
|
1988
1994
|
```
|
|
1989
1995
|
|
|
1990
1996
|
## Real-World Datasets
|
|
@@ -2623,6 +2623,38 @@ def _compute_robust_vcov_numpy(
|
|
|
2623
2623
|
# ------------------------------------------------------------------
|
|
2624
2624
|
assert vcov_type == "hc1"
|
|
2625
2625
|
|
|
2626
|
+
# Cluster-robust validity check FIRST: a cluster-robust request with fewer
|
|
2627
|
+
# than 2 clusters is invalid and must raise the documented error — this has
|
|
2628
|
+
# to precede the saturated-design guard below so a 1-cluster *saturated* fit
|
|
2629
|
+
# still raises rather than being masked by the NaN return. (Mirrors the
|
|
2630
|
+
# effective-cluster count computed in the cluster branch, including the
|
|
2631
|
+
# zero-total-weight exclusion.)
|
|
2632
|
+
if cluster_ids is not None:
|
|
2633
|
+
# Normalize to an array first (as the cluster branch does) so the
|
|
2634
|
+
# weighted groupby below cannot index-align a pandas Series grouper
|
|
2635
|
+
# against the freshly-created Series(weights) and miscount clusters.
|
|
2636
|
+
cluster_ids_arr = np.asarray(cluster_ids)
|
|
2637
|
+
n_clusters_check = len(np.unique(cluster_ids_arr))
|
|
2638
|
+
if weights is not None and weight_type != "fweight" and np.any(weights == 0):
|
|
2639
|
+
cluster_weight_sums = pd.Series(weights).groupby(cluster_ids_arr).sum()
|
|
2640
|
+
n_clusters_check = int((cluster_weight_sums > 0).sum())
|
|
2641
|
+
if n_clusters_check < 2:
|
|
2642
|
+
raise ValueError(
|
|
2643
|
+
f"Need at least 2 clusters for cluster-robust SEs, got {n_clusters_check}"
|
|
2644
|
+
)
|
|
2645
|
+
|
|
2646
|
+
# Saturated design (no residual degrees of freedom): both the HC1 adjustment
|
|
2647
|
+
# n_eff/(n_eff-k) and the CR1 adjustment (n_eff-1)/(n_eff-k) divide by
|
|
2648
|
+
# (n_eff - k), which is zero when the design exactly determines y. Return a
|
|
2649
|
+
# NaN vcov so downstream inference is degenerate (NaN) rather than raising
|
|
2650
|
+
# ZeroDivisionError — consistent with the library's all-or-nothing NaN
|
|
2651
|
+
# convention for undefined inference.
|
|
2652
|
+
if n_eff - k <= 0:
|
|
2653
|
+
nan_vcov = np.full((k, k), np.nan)
|
|
2654
|
+
if return_dof:
|
|
2655
|
+
return nan_vcov, np.full(k, np.nan, dtype=np.float64)
|
|
2656
|
+
return nan_vcov
|
|
2657
|
+
|
|
2626
2658
|
# Compute weighted scores for cluster-robust meat (outer product of sums).
|
|
2627
2659
|
# pweight/fweight multiply by w; aweight and unweighted use raw residuals.
|
|
2628
2660
|
_use_weighted_scores = weights is not None and weight_type not in ("aweight",)
|
|
@@ -3865,7 +3897,13 @@ class LinearRegression:
|
|
|
3865
3897
|
"""
|
|
3866
3898
|
self._check_fitted()
|
|
3867
3899
|
assert self.vcov_ is not None
|
|
3868
|
-
|
|
3900
|
+
# Clamp a tiny-negative variance artifact at 0 before sqrt. A high-leverage
|
|
3901
|
+
# / degenerate coefficient (e.g. an absorbed-FE dummy near-collinear with the
|
|
3902
|
+
# treatment) can have a CR2/HC variance of ~0 that lands just below zero under
|
|
3903
|
+
# BLAS-dependent float rounding; without the clamp `np.sqrt` returns NaN
|
|
3904
|
+
# nondeterministically (passes single-threaded, fails under parallel test
|
|
3905
|
+
# load). The SE is then finite — 0 for a genuinely-zero variance.
|
|
3906
|
+
return float(np.sqrt(max(float(self.vcov_[index, index]), 0.0)))
|
|
3869
3907
|
|
|
3870
3908
|
def get_inference(
|
|
3871
3909
|
self,
|
|
@@ -3908,7 +3946,8 @@ class LinearRegression:
|
|
|
3908
3946
|
assert self.vcov_ is not None
|
|
3909
3947
|
|
|
3910
3948
|
coef = float(self.coefficients_[index])
|
|
3911
|
-
|
|
3949
|
+
# See get_se: clamp a tiny-negative variance artifact at 0 so SE is finite, not NaN.
|
|
3950
|
+
se = float(np.sqrt(max(float(self.vcov_[index, index]), 0.0)))
|
|
3912
3951
|
|
|
3913
3952
|
# Use instance alpha if not provided
|
|
3914
3953
|
effective_alpha = alpha if alpha is not None else self.alpha
|
|
@@ -267,6 +267,29 @@ def _ddd_dgp_kwargs(
|
|
|
267
267
|
)
|
|
268
268
|
|
|
269
269
|
|
|
270
|
+
def _ddd_panel_dgp_kwargs(
|
|
271
|
+
n_units: int,
|
|
272
|
+
n_periods: int,
|
|
273
|
+
treatment_effect: float,
|
|
274
|
+
treatment_fraction: float,
|
|
275
|
+
treatment_period: int,
|
|
276
|
+
sigma: float,
|
|
277
|
+
) -> Dict[str, Any]:
|
|
278
|
+
# Panel DDD DGP (n_periods > 2). `n_units` maps directly (no 8-cell //8
|
|
279
|
+
# rounding). `treatment_fraction` is intentionally NOT mapped — DDD is a
|
|
280
|
+
# balanced factorial, so group_frac/partition_frac are left at the DGP
|
|
281
|
+
# default 0.5. Omitting group_frac/partition_frac here also keeps them out
|
|
282
|
+
# of the _PROTECTED_DGP_KEYS collision check, so a user can still override
|
|
283
|
+
# the group/partition split via data_generator_kwargs.
|
|
284
|
+
return dict(
|
|
285
|
+
n_units=n_units,
|
|
286
|
+
n_periods=n_periods,
|
|
287
|
+
treatment_period=treatment_period,
|
|
288
|
+
treatment_effect=treatment_effect,
|
|
289
|
+
noise_sd=sigma,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
270
293
|
# -- Fit kwargs builders ------------------------------------------------------
|
|
271
294
|
|
|
272
295
|
|
|
@@ -320,6 +343,19 @@ def _ddd_fit_kwargs(
|
|
|
320
343
|
return dict(outcome="outcome", group="group", partition="partition", time="time")
|
|
321
344
|
|
|
322
345
|
|
|
346
|
+
def _ddd_panel_fit_kwargs(
|
|
347
|
+
data: pd.DataFrame,
|
|
348
|
+
n_units: int,
|
|
349
|
+
n_periods: int,
|
|
350
|
+
treatment_period: int,
|
|
351
|
+
) -> Dict[str, Any]:
|
|
352
|
+
# Panel DDD: time="post" is generate_ddd_panel_data's derived binary
|
|
353
|
+
# pre/post indicator (vs the cross-sectional "time"). Clustering is NOT a
|
|
354
|
+
# fit kwarg — it resolves from the estimator's cluster="unit" attribute
|
|
355
|
+
# against the DGP's "unit" column.
|
|
356
|
+
return dict(outcome="outcome", group="group", partition="partition", time="post")
|
|
357
|
+
|
|
358
|
+
|
|
323
359
|
def _trop_fit_kwargs(
|
|
324
360
|
data: pd.DataFrame,
|
|
325
361
|
n_units: int,
|
|
@@ -636,6 +672,47 @@ def _ddd_effective_n(
|
|
|
636
672
|
return eff if eff != n_units else None
|
|
637
673
|
|
|
638
674
|
|
|
675
|
+
def _ddd_panel_cells_populated(n: int, group_frac: float, partition_frac: float) -> bool:
|
|
676
|
+
"""Whether ``generate_ddd_panel_data`` would populate all 4 (group,partition)
|
|
677
|
+
cells at ``n`` units. Mirrors the rounded stratified allocation + non-empty
|
|
678
|
+
validation in ``prep_dgp.generate_ddd_panel_data`` (kept in lockstep)."""
|
|
679
|
+
if n < 4:
|
|
680
|
+
return False
|
|
681
|
+
n_g1 = int(round(n * group_frac))
|
|
682
|
+
n_g0 = n - n_g1
|
|
683
|
+
n_p1_g0 = int(round(n_g0 * partition_frac))
|
|
684
|
+
n_p1_g1 = int(round(n_g1 * partition_frac))
|
|
685
|
+
cells = (n_g0 - n_p1_g0, n_p1_g0, n_g1 - n_p1_g1, n_p1_g1)
|
|
686
|
+
return min(cells) >= 1
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _ddd_panel_viable_min_n(
|
|
690
|
+
group_frac: float, partition_frac: float, floor: int = 16, search_max: int = 100000
|
|
691
|
+
) -> int:
|
|
692
|
+
"""Smallest ``n_units`` for which ``generate_ddd_panel_data`` populates all
|
|
693
|
+
four (group,partition) cells under the given split, floored at ``floor``.
|
|
694
|
+
|
|
695
|
+
For the balanced default (0.5/0.5) this is 4, so the result is ``floor``;
|
|
696
|
+
skewed splits (e.g. 0.1/0.1) need more units before every cell is non-empty,
|
|
697
|
+
so the sample-size search must bracket above this value (the registry
|
|
698
|
+
documents group_frac/partition_frac as data_generator_kwargs overrides)."""
|
|
699
|
+
# Validate up front (matching generate_ddd_panel_data) so an out-of-range
|
|
700
|
+
# split raises the same clear message here — before the bracketing logic can
|
|
701
|
+
# surface a misleading "n_range below the minimum" error downstream.
|
|
702
|
+
if not (0.0 < group_frac < 1.0):
|
|
703
|
+
raise ValueError(f"group_frac must be in (0, 1); got {group_frac}.")
|
|
704
|
+
if not (0.0 < partition_frac < 1.0):
|
|
705
|
+
raise ValueError(f"partition_frac must be in (0, 1); got {partition_frac}.")
|
|
706
|
+
for n in range(4, search_max + 1):
|
|
707
|
+
if _ddd_panel_cells_populated(n, group_frac, partition_frac):
|
|
708
|
+
return max(floor, n)
|
|
709
|
+
raise ValueError(
|
|
710
|
+
f"No panel-DDD sample size <= {search_max} populates all four "
|
|
711
|
+
f"(group, partition) cells for group_frac={group_frac}, "
|
|
712
|
+
f"partition_frac={partition_frac}; move the split closer to 0.5."
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
639
716
|
def _check_ddd_dgp_compat(
|
|
640
717
|
n_units: int,
|
|
641
718
|
n_periods: int,
|
|
@@ -686,6 +763,55 @@ def _check_ddd_dgp_compat(
|
|
|
686
763
|
)
|
|
687
764
|
|
|
688
765
|
|
|
766
|
+
def _check_ddd_panel_dgp_compat(
|
|
767
|
+
estimator: Any,
|
|
768
|
+
treatment_fraction: float,
|
|
769
|
+
data_generator_kwargs: Optional[Dict[str, Any]],
|
|
770
|
+
) -> None:
|
|
771
|
+
"""Compat checks for the panel DDD power path (``n_periods > 2``).
|
|
772
|
+
|
|
773
|
+
Unlike the cross-sectional ``_check_ddd_dgp_compat``, ``n_periods`` and
|
|
774
|
+
``treatment_period`` are honored here (no warning). ``treatment_fraction``
|
|
775
|
+
is still inert (the panel DGP is a balanced 2×2×2). The key addition is the
|
|
776
|
+
clustering caveat: ``generate_ddd_panel_data`` has within-unit serial
|
|
777
|
+
correlation, so unclustered SEs overstate power.
|
|
778
|
+
"""
|
|
779
|
+
overrides = data_generator_kwargs or {}
|
|
780
|
+
if "n_per_cell" in overrides:
|
|
781
|
+
raise ValueError(
|
|
782
|
+
"data_generator_kwargs contains 'n_per_cell', a cross-sectional "
|
|
783
|
+
"generate_ddd_data parameter. The panel DDD power path "
|
|
784
|
+
"(n_periods > 2) uses generate_ddd_panel_data, which sizes the panel "
|
|
785
|
+
"by n_units directly. Control the design via n_units and the "
|
|
786
|
+
"group_frac / partition_frac data_generator_kwargs instead."
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
if treatment_fraction != 0.5:
|
|
790
|
+
warnings.warn(
|
|
791
|
+
f"treatment_fraction={treatment_fraction} is ignored for "
|
|
792
|
+
f"TripleDifference power: generate_ddd_panel_data uses a balanced "
|
|
793
|
+
f"2×2×2 design (group_frac=partition_frac=0.5). Pass group_frac / "
|
|
794
|
+
f"partition_frac via data_generator_kwargs to vary the split.",
|
|
795
|
+
UserWarning,
|
|
796
|
+
stacklevel=2,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
# The panel DGP hard-names its unit column "unit", and TripleDifference
|
|
800
|
+
# resolves clustering from `self.cluster` against a same-named data column,
|
|
801
|
+
# so on this auto-DGP path the only correct value is the literal "unit" —
|
|
802
|
+
# this is NOT a general clustering check.
|
|
803
|
+
if getattr(estimator, "cluster", None) != "unit":
|
|
804
|
+
warnings.warn(
|
|
805
|
+
"TripleDifference power on the panel DGP (n_periods > 2) has "
|
|
806
|
+
"within-unit serial correlation, so unclustered standard errors are "
|
|
807
|
+
"anti-conservative and overstate power. Construct the estimator as "
|
|
808
|
+
'TripleDifference(cluster="unit") so the reported power reflects '
|
|
809
|
+
"cluster-robust (Liang-Zeger CR1) inference.",
|
|
810
|
+
UserWarning,
|
|
811
|
+
stacklevel=2,
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
689
815
|
def _check_sdid_placebo_data(
|
|
690
816
|
data: pd.DataFrame,
|
|
691
817
|
estimator: Any,
|
|
@@ -833,6 +959,28 @@ def _get_registry() -> Dict[str, _EstimatorProfile]:
|
|
|
833
959
|
return _ESTIMATOR_REGISTRY
|
|
834
960
|
|
|
835
961
|
|
|
962
|
+
def _ddd_panel_profile() -> "_EstimatorProfile":
|
|
963
|
+
"""Profile for panel DDD power simulations (n_periods > 2).
|
|
964
|
+
|
|
965
|
+
Routes TripleDifference power analysis to ``generate_ddd_panel_data``
|
|
966
|
+
(which honors ``n_periods``/``treatment_period``) instead of the
|
|
967
|
+
cross-sectional 2x2x2 ``generate_ddd_data``. See ``simulate_power``.
|
|
968
|
+
"""
|
|
969
|
+
from diff_diff.prep import generate_ddd_panel_data
|
|
970
|
+
|
|
971
|
+
# min_n=16 is intentionally lower than the cross-sectional DDD min_n=64
|
|
972
|
+
# (which counts 8 cells x n_per_cell); the panel DGP maps n_units directly
|
|
973
|
+
# and only requires n_units >= 4, so 16 gives >=1 unit per (group,partition)
|
|
974
|
+
# cell with margin.
|
|
975
|
+
return _EstimatorProfile(
|
|
976
|
+
default_dgp=generate_ddd_panel_data,
|
|
977
|
+
dgp_kwargs_builder=_ddd_panel_dgp_kwargs,
|
|
978
|
+
fit_kwargs_builder=_ddd_panel_fit_kwargs,
|
|
979
|
+
result_extractor=_extract_simple,
|
|
980
|
+
min_n=16,
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
|
|
836
984
|
@dataclass
|
|
837
985
|
class PowerResults:
|
|
838
986
|
"""
|
|
@@ -2007,6 +2155,21 @@ def simulate_power(
|
|
|
2007
2155
|
use_custom_dgp = data_generator is not None
|
|
2008
2156
|
use_survey_dgp = survey_config is not None
|
|
2009
2157
|
|
|
2158
|
+
# Route DDD power to the panel DGP when n_periods > 2. The cross-sectional
|
|
2159
|
+
# 2x2x2 generate_ddd_data ignores n_periods; generate_ddd_panel_data honors
|
|
2160
|
+
# it. Swapping the profile here (before the collision check and the DDD
|
|
2161
|
+
# compat warnings) makes every downstream consumer (dgp_kwargs_builder,
|
|
2162
|
+
# default_dgp, fit_kwargs_builder, result_extractor, min_n) use the panel
|
|
2163
|
+
# variant automatically.
|
|
2164
|
+
use_ddd_panel = (
|
|
2165
|
+
estimator_name == "TripleDifference"
|
|
2166
|
+
and n_periods > 2
|
|
2167
|
+
and not use_custom_dgp
|
|
2168
|
+
and not use_survey_dgp
|
|
2169
|
+
)
|
|
2170
|
+
if use_ddd_panel:
|
|
2171
|
+
profile = _ddd_panel_profile()
|
|
2172
|
+
|
|
2010
2173
|
# --- Survey config validation ---
|
|
2011
2174
|
if use_survey_dgp:
|
|
2012
2175
|
assert survey_config is not None # for type narrowing
|
|
@@ -2161,7 +2324,13 @@ def simulate_power(
|
|
|
2161
2324
|
)
|
|
2162
2325
|
|
|
2163
2326
|
# Warn if DDD design inputs are silently ignored
|
|
2164
|
-
if
|
|
2327
|
+
if use_ddd_panel:
|
|
2328
|
+
# Panel path honors n_periods/treatment_period; n_units maps directly
|
|
2329
|
+
# (no //8 rounding). Different compat surface than the cross-sectional
|
|
2330
|
+
# 2x2x2 design.
|
|
2331
|
+
_check_ddd_panel_dgp_compat(estimator, treatment_fraction, data_generator_kwargs)
|
|
2332
|
+
effective_n_units = None
|
|
2333
|
+
elif estimator_name == "TripleDifference" and not use_custom_dgp:
|
|
2165
2334
|
_check_ddd_dgp_compat(
|
|
2166
2335
|
n_units,
|
|
2167
2336
|
n_periods,
|
|
@@ -2707,8 +2876,10 @@ def simulate_mde(
|
|
|
2707
2876
|
estimator_name = type(estimator).__name__
|
|
2708
2877
|
search_path: List[Dict[str, float]] = []
|
|
2709
2878
|
|
|
2710
|
-
# Compute effective N for DDD (N is fixed throughout MDE search)
|
|
2711
|
-
|
|
2879
|
+
# Compute effective N for DDD (N is fixed throughout MDE search). Only the
|
|
2880
|
+
# cross-sectional 2x2x2 DGP (n_periods <= 2) rounds n_units to a multiple of
|
|
2881
|
+
# 8; the panel DGP (n_periods > 2) maps n_units directly, so report None.
|
|
2882
|
+
if estimator_name == "TripleDifference" and data_generator is None and n_periods <= 2:
|
|
2712
2883
|
effective_n_units = _ddd_effective_n(n_units, data_generator_kwargs)
|
|
2713
2884
|
else:
|
|
2714
2885
|
effective_n_units = None
|
|
@@ -2925,15 +3096,33 @@ def simulate_sample_size(
|
|
|
2925
3096
|
estimator_name = type(estimator).__name__
|
|
2926
3097
|
search_path: List[Dict[str, float]] = []
|
|
2927
3098
|
|
|
2928
|
-
# Determine min_n from registry
|
|
3099
|
+
# Determine min_n from registry. DDD splits cross-sectional (n_periods <= 2,
|
|
3100
|
+
# 2x2x2 factorial) from panel (n_periods > 2, generate_ddd_panel_data).
|
|
2929
3101
|
registry = _get_registry()
|
|
2930
3102
|
profile = registry.get(estimator_name)
|
|
2931
|
-
|
|
3103
|
+
is_ddd = estimator_name == "TripleDifference" and data_generator is None
|
|
3104
|
+
is_ddd_panel = is_ddd and n_periods > 2
|
|
3105
|
+
if is_ddd_panel:
|
|
3106
|
+
# The panel DGP requires every (group,partition) cell non-empty, which
|
|
3107
|
+
# for a skewed group_frac/partition_frac override needs more than the
|
|
3108
|
+
# default 16-unit floor. Bracket above the viable minimum so the search
|
|
3109
|
+
# never probes an infeasible n (which would raise in the DGP).
|
|
3110
|
+
_ddd_overrides = data_generator_kwargs or {}
|
|
3111
|
+
min_n = _ddd_panel_viable_min_n(
|
|
3112
|
+
_ddd_overrides.get("group_frac", 0.5),
|
|
3113
|
+
_ddd_overrides.get("partition_frac", 0.5),
|
|
3114
|
+
floor=_ddd_panel_profile().min_n,
|
|
3115
|
+
)
|
|
3116
|
+
else:
|
|
3117
|
+
min_n = profile.min_n if profile is not None else 20
|
|
2932
3118
|
|
|
2933
|
-
#
|
|
2934
|
-
|
|
3119
|
+
# Grid snapping: the cross-sectional 2x2x2 DDD DGP rounds n_units to a
|
|
3120
|
+
# multiple of 8, so bisection candidates must snap to that grid. The panel
|
|
3121
|
+
# DGP maps n_units directly → continuous (step-1) search like every other
|
|
3122
|
+
# estimator.
|
|
3123
|
+
is_ddd_grid = is_ddd and not is_ddd_panel
|
|
2935
3124
|
grid_step = 8 if is_ddd_grid else 1
|
|
2936
|
-
convergence_threshold = grid_step + 1 # 9 for DDD, 2
|
|
3125
|
+
convergence_threshold = grid_step + 1 # 9 for cross-sectional DDD, 2 otherwise
|
|
2937
3126
|
|
|
2938
3127
|
if is_ddd_grid and data_generator_kwargs and "n_per_cell" in data_generator_kwargs:
|
|
2939
3128
|
raise ValueError(
|
|
@@ -2991,9 +3180,25 @@ def simulate_sample_size(
|
|
|
2991
3180
|
)
|
|
2992
3181
|
|
|
2993
3182
|
# --- Bracket ---
|
|
2994
|
-
|
|
3183
|
+
# Cross-sectional DDD wants a >=16 floor so the 8 G×P×T cells are populated;
|
|
3184
|
+
# the panel path uses its split-aware viable floor (min_n above, which is
|
|
3185
|
+
# >=16 and higher for skewed group_frac/partition_frac); everything else
|
|
3186
|
+
# floors at 4.
|
|
3187
|
+
if is_ddd_panel:
|
|
3188
|
+
abs_min = min_n
|
|
3189
|
+
elif is_ddd:
|
|
3190
|
+
abs_min = 16
|
|
3191
|
+
else:
|
|
3192
|
+
abs_min = 4
|
|
2995
3193
|
if survey_config is not None:
|
|
2996
3194
|
abs_min = max(abs_min, survey_config.min_viable_n)
|
|
3195
|
+
if is_ddd_panel and n_range is not None and n_range[1] < abs_min:
|
|
3196
|
+
raise ValueError(
|
|
3197
|
+
f"n_range upper bound ({n_range[1]}) is below the minimum panel-DDD "
|
|
3198
|
+
f"sample size ({abs_min}) needed to populate all (group, partition) "
|
|
3199
|
+
f"cells for group_frac/partition_frac. Raise the upper bound or move "
|
|
3200
|
+
f"the split closer to 0.5."
|
|
3201
|
+
)
|
|
2997
3202
|
if n_range is not None:
|
|
2998
3203
|
lo, hi = _snap_n(n_range[0], "up", floor=abs_min), _snap_n(
|
|
2999
3204
|
n_range[1], "down", floor=abs_min
|