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.
Files changed (93) hide show
  1. {diff_diff-3.5.2 → diff_diff-3.5.3}/PKG-INFO +1 -1
  2. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/__init__.py +3 -1
  3. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/estimators.py +37 -3
  4. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/llms-full.txt +7 -1
  5. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/linalg.py +41 -2
  6. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/power.py +214 -9
  7. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/prep.py +1 -0
  8. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/prep_dgp.py +217 -0
  9. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/results.py +10 -3
  10. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/twfe.py +3 -0
  11. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/two_stage.py +67 -29
  12. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/two_stage_bootstrap.py +90 -42
  13. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/two_stage_results.py +4 -3
  14. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/utils.py +308 -136
  15. {diff_diff-3.5.2 → diff_diff-3.5.3}/pyproject.toml +1 -1
  16. {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/Cargo.lock +42 -308
  17. {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/Cargo.toml +1 -1
  18. {diff_diff-3.5.2 → diff_diff-3.5.3}/LICENSE +0 -0
  19. {diff_diff-3.5.2 → diff_diff-3.5.3}/README.md +0 -0
  20. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/_backend.py +0 -0
  21. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/_guides_api.py +0 -0
  22. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/_nprobust_port.py +0 -0
  23. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/_reporting_helpers.py +0 -0
  24. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/agent_workflow.py +0 -0
  25. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/bacon.py +0 -0
  26. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/balancing.py +0 -0
  27. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/bootstrap_utils.py +0 -0
  28. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/business_report.py +0 -0
  29. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/chaisemartin_dhaultfoeuille.py +0 -0
  30. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
  31. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
  32. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/conformal.py +0 -0
  33. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/conley.py +0 -0
  34. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/continuous_did.py +0 -0
  35. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/continuous_did_bspline.py +0 -0
  36. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/continuous_did_results.py +0 -0
  37. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/datasets.py +0 -0
  38. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/diagnostic_report.py +0 -0
  39. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/diagnostics.py +0 -0
  40. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did.py +0 -0
  41. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did_bootstrap.py +0 -0
  42. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did_covariates.py +0 -0
  43. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did_results.py +0 -0
  44. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/efficient_did_weights.py +0 -0
  45. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/__init__.py +0 -0
  46. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/llms-autonomous.txt +0 -0
  47. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/llms-practitioner.txt +0 -0
  48. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/guides/llms.txt +0 -0
  49. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/had.py +0 -0
  50. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/had_pretests.py +0 -0
  51. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/honest_did.py +0 -0
  52. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/imputation.py +0 -0
  53. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/imputation_bootstrap.py +0 -0
  54. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/imputation_results.py +0 -0
  55. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/local_linear.py +0 -0
  56. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/practitioner.py +0 -0
  57. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/pretrends.py +0 -0
  58. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/profile.py +0 -0
  59. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/spillover.py +0 -0
  60. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/stacked_did.py +0 -0
  61. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/stacked_did_results.py +0 -0
  62. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered.py +0 -0
  63. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_aggregation.py +0 -0
  64. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_bootstrap.py +0 -0
  65. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_results.py +0 -0
  66. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_triple_diff.py +0 -0
  67. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/staggered_triple_diff_results.py +0 -0
  68. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/sun_abraham.py +0 -0
  69. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/survey.py +0 -0
  70. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/synthetic_control.py +0 -0
  71. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/synthetic_control_results.py +0 -0
  72. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/synthetic_did.py +0 -0
  73. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/triple_diff.py +0 -0
  74. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/trop.py +0 -0
  75. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/trop_global.py +0 -0
  76. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/trop_local.py +0 -0
  77. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/trop_results.py +0 -0
  78. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/__init__.py +0 -0
  79. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_common.py +0 -0
  80. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_continuous.py +0 -0
  81. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_diagnostic.py +0 -0
  82. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_event_study.py +0 -0
  83. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_power.py +0 -0
  84. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_staggered.py +0 -0
  85. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/visualization/_synthetic.py +0 -0
  86. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/wooldridge.py +0 -0
  87. {diff_diff-3.5.2 → diff_diff-3.5.3}/diff_diff/wooldridge_results.py +0 -0
  88. {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/build.rs +0 -0
  89. {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/bootstrap.rs +0 -0
  90. {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/lib.rs +0 -0
  91. {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/linalg.rs +0 -0
  92. {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/trop.rs +0 -0
  93. {diff_diff-3.5.2 → diff_diff-3.5.3}/rust/src/weights.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 3.5.2
3
+ Version: 3.5.3
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: OS Independent
@@ -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.2"
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 (using cluster-robust for consistency)
816
- vcov = compute_robust_vcov(X, residuals, cluster_ids)
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.2
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
- return float(np.sqrt(self.vcov_[index, index]))
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
- se = float(np.sqrt(self.vcov_[index, index]))
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 estimator_name == "TripleDifference" and not use_custom_dgp:
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
- if estimator_name == "TripleDifference" and data_generator is None:
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
- min_n = profile.min_n if profile is not None else 20
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
- # DDD grid snapping: bisection candidates must be multiples of 8
2934
- is_ddd_grid = estimator_name == "TripleDifference" and data_generator is None
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 for others
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
- abs_min = 16 if is_ddd_grid else 4
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
@@ -28,6 +28,7 @@ from diff_diff.prep_dgp import ( # noqa: F401
28
28
  generate_staggered_data,
29
29
  generate_staggered_ddd_data,
30
30
  generate_survey_did_data,
31
+ generate_synthetic_control_data,
31
32
  )
32
33
  from diff_diff.survey import (
33
34
  ResolvedSurveyDesign,