diff-diff 2.7.6__tar.gz → 2.8.0__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 (66) hide show
  1. {diff_diff-2.7.6 → diff_diff-2.8.0}/PKG-INFO +10 -1
  2. {diff_diff-2.7.6 → diff_diff-2.8.0}/README.md +8 -0
  3. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/__init__.py +20 -1
  4. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/honest_did.py +224 -24
  5. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/prep.py +1 -0
  6. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/prep_dgp.py +188 -0
  7. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/staggered.py +1416 -83
  8. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/staggered_aggregation.py +181 -53
  9. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/staggered_bootstrap.py +30 -17
  10. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/staggered_results.py +6 -2
  11. diff_diff-2.8.0/diff_diff/staggered_triple_diff.py +1215 -0
  12. diff_diff-2.8.0/diff_diff/staggered_triple_diff_results.py +348 -0
  13. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_common.py +36 -0
  14. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_diagnostic.py +23 -4
  15. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_event_study.py +24 -4
  16. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_power.py +14 -4
  17. {diff_diff-2.7.6 → diff_diff-2.8.0}/pyproject.toml +2 -1
  18. {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/Cargo.lock +1 -1
  19. {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/Cargo.toml +1 -1
  20. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/_backend.py +0 -0
  21. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/bacon.py +0 -0
  22. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/bootstrap_utils.py +0 -0
  23. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/continuous_did.py +0 -0
  24. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/continuous_did_bspline.py +0 -0
  25. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/continuous_did_results.py +0 -0
  26. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/datasets.py +0 -0
  27. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/diagnostics.py +0 -0
  28. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did.py +0 -0
  29. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did_bootstrap.py +0 -0
  30. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did_covariates.py +0 -0
  31. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did_results.py +0 -0
  32. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did_weights.py +0 -0
  33. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/estimators.py +0 -0
  34. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/imputation.py +0 -0
  35. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/imputation_bootstrap.py +0 -0
  36. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/imputation_results.py +0 -0
  37. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/linalg.py +0 -0
  38. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/power.py +0 -0
  39. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/practitioner.py +0 -0
  40. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/pretrends.py +0 -0
  41. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/results.py +0 -0
  42. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/stacked_did.py +0 -0
  43. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/stacked_did_results.py +0 -0
  44. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/sun_abraham.py +0 -0
  45. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/survey.py +0 -0
  46. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/synthetic_did.py +0 -0
  47. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/triple_diff.py +0 -0
  48. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/trop.py +0 -0
  49. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/trop_global.py +0 -0
  50. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/trop_local.py +0 -0
  51. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/trop_results.py +0 -0
  52. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/twfe.py +0 -0
  53. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/two_stage.py +0 -0
  54. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/two_stage_bootstrap.py +0 -0
  55. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/two_stage_results.py +0 -0
  56. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/utils.py +0 -0
  57. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/__init__.py +0 -0
  58. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_continuous.py +0 -0
  59. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_staggered.py +0 -0
  60. {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_synthetic.py +0 -0
  61. {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/build.rs +0 -0
  62. {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/bootstrap.rs +0 -0
  63. {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/lib.rs +0 -0
  64. {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/linalg.rs +0 -0
  65. {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/trop.rs +0 -0
  66. {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/weights.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 2.7.6
3
+ Version: 2.8.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: OS Independent
@@ -45,6 +45,7 @@ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
45
45
  Project-URL: Documentation, https://diff-diff.readthedocs.io
46
46
  Project-URL: Homepage, https://github.com/igerber/diff-diff
47
47
  Project-URL: Issues, https://github.com/igerber/diff-diff/issues
48
+ Project-URL: Practitioner Guide, https://github.com/igerber/diff-diff/blob/main/docs/llms-practitioner.txt
48
49
  Project-URL: Repository, https://github.com/igerber/diff-diff
49
50
 
50
51
  # diff-diff
@@ -116,6 +117,14 @@ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
116
117
  ======================================================================
117
118
  ```
118
119
 
120
+ ## For AI Agents
121
+
122
+ If you are an AI agent or LLM using this library, read [`docs/llms.txt`](docs/llms.txt) for a concise API reference with an 8-step practitioner workflow (based on Baker et al. 2025). The workflow ensures rigorous DiD analysis — not just calling `fit()`, but testing assumptions, running sensitivity analysis, and checking robustness.
123
+
124
+ After estimation, call `practitioner_next_steps(results)` for context-aware guidance on remaining diagnostic steps.
125
+
126
+ Detailed guide: [`docs/llms-practitioner.txt`](docs/llms-practitioner.txt)
127
+
119
128
  ## Features
120
129
 
121
130
  - **sklearn-like API**: Familiar `fit()` interface with `get_params()` and `set_params()`
@@ -67,6 +67,14 @@ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
67
67
  ======================================================================
68
68
  ```
69
69
 
70
+ ## For AI Agents
71
+
72
+ If you are an AI agent or LLM using this library, read [`docs/llms.txt`](docs/llms.txt) for a concise API reference with an 8-step practitioner workflow (based on Baker et al. 2025). The workflow ensures rigorous DiD analysis — not just calling `fit()`, but testing assumptions, running sensitivity analysis, and checking robustness.
73
+
74
+ After estimation, call `practitioner_next_steps(results)` for context-aware guidance on remaining diagnostic steps.
75
+
76
+ Detailed guide: [`docs/llms-practitioner.txt`](docs/llms-practitioner.txt)
77
+
70
78
  ## Features
71
79
 
72
80
  - **sklearn-like API**: Familiar `fit()` interface with `get_params()` and `set_params()`
@@ -3,6 +3,13 @@ diff-diff: A library for Difference-in-Differences analysis.
3
3
 
4
4
  This library provides sklearn-like estimators for causal inference
5
5
  using the difference-in-differences methodology.
6
+
7
+ For rigorous analysis, follow the 8-step practitioner workflow in
8
+ docs/llms-practitioner.txt (based on Baker et al. 2025). After
9
+ estimation, call ``practitioner_next_steps(results)`` for context-aware
10
+ guidance on remaining diagnostic steps.
11
+
12
+ AI agent reference: docs/llms.txt
6
13
  """
7
14
 
8
15
  # Import backend detection from dedicated module (avoids circular imports)
@@ -81,6 +88,7 @@ from diff_diff.prep import (
81
88
  generate_factor_data,
82
89
  generate_panel_data,
83
90
  generate_staggered_data,
91
+ generate_staggered_ddd_data,
84
92
  make_post_indicator,
85
93
  make_treatment_indicator,
86
94
  rank_control_units,
@@ -133,6 +141,12 @@ from diff_diff.triple_diff import (
133
141
  TripleDifferenceResults,
134
142
  triple_difference,
135
143
  )
144
+ from diff_diff.staggered_triple_diff import (
145
+ StaggeredTripleDifference,
146
+ )
147
+ from diff_diff.staggered_triple_diff_results import (
148
+ StaggeredTripleDiffResults,
149
+ )
136
150
  from diff_diff.continuous_did import (
137
151
  ContinuousDiD,
138
152
  ContinuousDiDResults,
@@ -190,11 +204,12 @@ SA = SunAbraham
190
204
  BJS = ImputationDiD
191
205
  Gardner = TwoStageDiD
192
206
  DDD = TripleDifference
207
+ SDDD = StaggeredTripleDifference
193
208
  Stacked = StackedDiD
194
209
  Bacon = BaconDecomposition
195
210
  EDiD = EfficientDiD
196
211
 
197
- __version__ = "2.7.6"
212
+ __version__ = "2.8.0"
198
213
  __all__ = [
199
214
  # Estimators
200
215
  "DifferenceInDifferences",
@@ -220,6 +235,7 @@ __all__ = [
220
235
  "BJS",
221
236
  "Gardner",
222
237
  "DDD",
238
+ "SDDD",
223
239
  "Stacked",
224
240
  "Bacon",
225
241
  # Bacon Decomposition
@@ -247,6 +263,8 @@ __all__ = [
247
263
  "two_stage_did",
248
264
  "TripleDifferenceResults",
249
265
  "triple_difference",
266
+ "StaggeredTripleDifference",
267
+ "StaggeredTripleDiffResults",
250
268
  "TROPResults",
251
269
  "trop",
252
270
  "StackedDiDResults",
@@ -296,6 +314,7 @@ __all__ = [
296
314
  "generate_ddd_data",
297
315
  "generate_panel_data",
298
316
  "generate_event_study_data",
317
+ "generate_staggered_ddd_data",
299
318
  "generate_continuous_did_data",
300
319
  "create_event_time",
301
320
  "aggregate_to_cohorts",
@@ -22,11 +22,12 @@ from typing import Any, Dict, List, Literal, Optional, Tuple, Union
22
22
 
23
23
  import numpy as np
24
24
  import pandas as pd
25
- from scipy import optimize, stats
25
+ from scipy import optimize
26
26
 
27
27
  from diff_diff.results import (
28
28
  MultiPeriodDiDResults,
29
29
  )
30
+ from diff_diff.utils import _get_critical_value
30
31
 
31
32
  # =============================================================================
32
33
  # Delta Restriction Classes
@@ -193,6 +194,9 @@ class HonestDiDResults:
193
194
  original_results: Optional[Any] = field(default=None, repr=False)
194
195
  # Event study bounds (optional)
195
196
  event_study_bounds: Optional[Dict[Any, Dict[str, float]]] = field(default=None, repr=False)
197
+ # Survey design metadata (Phase 7d)
198
+ survey_metadata: Optional[Any] = field(default=None, repr=False)
199
+ df_survey: Optional[int] = field(default=None, repr=False)
196
200
 
197
201
  def __repr__(self) -> str:
198
202
  sig = "" if self.ci_lb <= 0 <= self.ci_ub else "*"
@@ -534,7 +538,7 @@ class SensitivityResults:
534
538
 
535
539
  def _extract_event_study_params(
536
540
  results: Union[MultiPeriodDiDResults, Any],
537
- ) -> Tuple[np.ndarray, np.ndarray, int, int, List[Any], List[Any]]:
541
+ ) -> Tuple[np.ndarray, np.ndarray, int, int, List[Any], List[Any], Optional[int]]:
538
542
  """
539
543
  Extract event study parameters from results objects.
540
544
 
@@ -557,6 +561,8 @@ def _extract_event_study_params(
557
561
  Pre-period identifiers.
558
562
  post_periods : list
559
563
  Post-period identifiers.
564
+ df_survey : int or None
565
+ Survey degrees of freedom for t-distribution inference.
560
566
  """
561
567
  if isinstance(results, MultiPeriodDiDResults):
562
568
  # Extract from MultiPeriodDiD
@@ -606,7 +612,23 @@ def _extract_event_study_params(
606
612
  # Fallback: diagonal from SEs
607
613
  sigma = np.diag(np.array(ses) ** 2)
608
614
 
609
- return beta_hat, sigma, num_pre_periods, num_post_periods, pre_periods, post_periods
615
+ # Extract survey df. Replicate designs with undefined df → sentinel 0.
616
+ df_survey = None
617
+ if hasattr(results, "survey_metadata") and results.survey_metadata is not None:
618
+ sm = results.survey_metadata
619
+ df_survey = getattr(sm, "df_survey", None)
620
+ if df_survey is None and getattr(sm, "replicate_method", None) is not None:
621
+ df_survey = 0
622
+
623
+ return (
624
+ beta_hat,
625
+ sigma,
626
+ num_pre_periods,
627
+ num_post_periods,
628
+ pre_periods,
629
+ post_periods,
630
+ df_survey,
631
+ )
610
632
 
611
633
  else:
612
634
  # Try CallawaySantAnnaResults
@@ -621,6 +643,22 @@ def _extract_event_study_params(
621
643
  "event study effects."
622
644
  )
623
645
 
646
+ # Warn if not using universal base period (R's HonestDiD requires it)
647
+ if getattr(results, "base_period", "universal") != "universal":
648
+ import warnings
649
+
650
+ warnings.warn(
651
+ "HonestDiD sensitivity analysis on CallawaySantAnna results "
652
+ "requires base_period='universal' for valid interpretation. "
653
+ "With base_period='varying', pre-treatment coefficients use "
654
+ "consecutive comparisons (not a common reference period), "
655
+ "which changes the meaning of the parallel trends restriction. "
656
+ "Re-run with CallawaySantAnna(base_period='universal') for "
657
+ "methodologically valid HonestDiD bounds.",
658
+ UserWarning,
659
+ stacklevel=3,
660
+ )
661
+
624
662
  # Extract event study effects by relative time
625
663
  # Filter out normalization constraints (n_groups=0) and non-finite SEs
626
664
  event_effects = {
@@ -630,9 +668,35 @@ def _extract_event_study_params(
630
668
  }
631
669
  rel_times = sorted(event_effects.keys())
632
670
 
633
- # Split into pre and post
634
- pre_times = [t for t in rel_times if t < 0]
635
- post_times = [t for t in rel_times if t >= 0]
671
+ # Infer the omitted reference period from the normalization
672
+ # marker injected by _aggregate_event_study for universal base.
673
+ # The reference has the exact signature: effect=0.0, se=NaN, n_groups=0.
674
+ # Other empty bins may also have n_groups=0 but with NaN effect.
675
+ ref_period = None
676
+ for t, data in results.event_study_effects.items():
677
+ if (
678
+ data.get("n_groups", 1) == 0
679
+ and data.get("effect", None) == 0.0
680
+ and not np.isfinite(data.get("se", 0.0))
681
+ ):
682
+ ref_period = t
683
+ break
684
+
685
+ if ref_period is not None:
686
+ # Universal base: split relative to the reference period
687
+ pre_times = [t for t in rel_times if t < ref_period]
688
+ post_times = [t for t in rel_times if t > ref_period]
689
+ else:
690
+ # Varying base or no reference marker: split at t < 0 / t >= 0
691
+ pre_times = [t for t in rel_times if t < 0]
692
+ post_times = [t for t in rel_times if t >= 0]
693
+
694
+ if len(pre_times) == 0:
695
+ raise ValueError(
696
+ "No pre-period effects with finite estimates found in "
697
+ "CallawaySantAnna event study. HonestDiD requires at "
698
+ "least one identified pre-period coefficient."
699
+ )
636
700
 
637
701
  effects = []
638
702
  ses = []
@@ -641,9 +705,100 @@ def _extract_event_study_params(
641
705
  ses.append(event_effects[t]["se"])
642
706
 
643
707
  beta_hat = np.array(effects)
644
- sigma = np.diag(np.array(ses) ** 2)
645
708
 
646
- return (beta_hat, sigma, len(pre_times), len(post_times), pre_times, post_times)
709
+ # Use full event-study VCV if available (Phase 7d),
710
+ # otherwise fall back to diagonal from SEs
711
+ if hasattr(results, "event_study_vcov") and results.event_study_vcov is not None:
712
+ vcov = results.event_study_vcov
713
+ # VCV is indexed by the aggregated event times (stored in
714
+ # event_study_vcov_index), NOT by event_study_effects keys
715
+ # (which may include an injected reference period).
716
+ # Subset to match the surviving rel_times.
717
+ vcov_index = getattr(results, "event_study_vcov_index", None)
718
+ if vcov_index is not None and len(rel_times) < len(vcov_index):
719
+ idx = [vcov_index.index(t) for t in rel_times if t in vcov_index]
720
+ if len(idx) == len(rel_times):
721
+ sigma = vcov[np.ix_(idx, idx)]
722
+ else:
723
+ sigma = np.diag(np.array(ses) ** 2)
724
+ elif vcov.shape[0] == len(rel_times):
725
+ sigma = vcov
726
+ else:
727
+ sigma = np.diag(np.array(ses) ** 2)
728
+ else:
729
+ # No full VCV available. Check if this is a bootstrap fit
730
+ # (VCV was cleared to prevent mixing analytical/bootstrap).
731
+ if (
732
+ hasattr(results, "bootstrap_results")
733
+ and results.bootstrap_results is not None
734
+ ):
735
+ import warnings
736
+
737
+ warnings.warn(
738
+ "HonestDiD on bootstrap-fitted CallawaySantAnna results "
739
+ "uses a diagonal covariance matrix (cross-event-time "
740
+ "covariance is not available from bootstrap). For full "
741
+ "covariance structure, use analytical SEs (n_bootstrap=0).",
742
+ UserWarning,
743
+ stacklevel=4,
744
+ )
745
+ sigma = np.diag(np.array(ses) ** 2)
746
+
747
+ # Validate the full event-time grid is consecutive.
748
+ # For universal base: exactly one gap for the omitted reference.
749
+ # For varying base: no gap expected (pre ends at -1, post starts at 0).
750
+ if pre_times and post_times:
751
+ if ref_period is not None:
752
+ # Universal: pre[-1]+1 = ref, ref+1 = post[0] → gap of 2
753
+ ref_gap = post_times[0] - pre_times[-1]
754
+ has_gap = ref_gap != 2
755
+ else:
756
+ # Varying: pre ends at -1, post starts at 0 → gap of 1
757
+ ref_gap = post_times[0] - pre_times[-1]
758
+ has_gap = ref_gap != 1
759
+ elif pre_times:
760
+ has_gap = False # only pre, no ref gap to check
761
+ elif post_times:
762
+ has_gap = False # only post, no ref gap to check
763
+ else:
764
+ has_gap = False
765
+ # Also check within-block consecutiveness
766
+ for block in [pre_times, post_times]:
767
+ if len(block) >= 2:
768
+ for i in range(len(block) - 1):
769
+ if block[i + 1] - block[i] != 1:
770
+ has_gap = True
771
+ break
772
+ if has_gap:
773
+ raise ValueError(
774
+ "HonestDiD requires a consecutive event-time grid "
775
+ "around the omitted reference period. Retained "
776
+ f"pre-periods {pre_times} and post-periods "
777
+ f"{post_times} have gaps. This can happen when "
778
+ "some event-study horizons have non-finite SEs. "
779
+ "Ensure all event-study periods have valid estimates, "
780
+ "or use balance_e to restrict to a balanced subset."
781
+ )
782
+
783
+ # Extract survey df. For replicate designs with undefined df
784
+ # (rank <= 1), use sentinel df=0 so _get_critical_value returns
785
+ # NaN, matching the safe_inference contract.
786
+ df_survey = None
787
+ if hasattr(results, "survey_metadata") and results.survey_metadata is not None:
788
+ sm = results.survey_metadata
789
+ df_survey = getattr(sm, "df_survey", None)
790
+ if df_survey is None and getattr(sm, "replicate_method", None) is not None:
791
+ df_survey = 0 # undefined replicate df → NaN inference
792
+
793
+ return (
794
+ beta_hat,
795
+ sigma,
796
+ len(pre_times),
797
+ len(post_times),
798
+ pre_times,
799
+ post_times,
800
+ df_survey,
801
+ )
647
802
  except ImportError:
648
803
  pass
649
804
 
@@ -860,7 +1015,13 @@ def _solve_bounds_lp(
860
1015
  return lb, ub
861
1016
 
862
1017
 
863
- def _compute_flci(lb: float, ub: float, se: float, alpha: float = 0.05) -> Tuple[float, float]:
1018
+ def _compute_flci(
1019
+ lb: float,
1020
+ ub: float,
1021
+ se: float,
1022
+ alpha: float = 0.05,
1023
+ df: Optional[int] = None,
1024
+ ) -> Tuple[float, float]:
864
1025
  """
865
1026
  Compute Fixed Length Confidence Interval (FLCI).
866
1027
 
@@ -877,6 +1038,9 @@ def _compute_flci(lb: float, ub: float, se: float, alpha: float = 0.05) -> Tuple
877
1038
  Standard error of the estimator.
878
1039
  alpha : float
879
1040
  Significance level.
1041
+ df : int, optional
1042
+ Degrees of freedom. If provided, uses t-distribution critical value
1043
+ instead of normal (for survey designs with df = n_PSU - n_strata).
880
1044
 
881
1045
  Returns
882
1046
  -------
@@ -895,7 +1059,7 @@ def _compute_flci(lb: float, ub: float, se: float, alpha: float = 0.05) -> Tuple
895
1059
  if not (0 < alpha < 1):
896
1060
  raise ValueError(f"alpha must be between 0 and 1, got alpha={alpha}")
897
1061
 
898
- z = stats.norm.ppf(1 - alpha / 2)
1062
+ z = _get_critical_value(alpha, df)
899
1063
  ci_lb = lb - z * se
900
1064
  ci_ub = ub + z * se
901
1065
  return ci_lb, ci_ub
@@ -909,6 +1073,7 @@ def _compute_clf_ci(
909
1073
  max_pre_violation: float,
910
1074
  alpha: float = 0.05,
911
1075
  n_draws: int = 1000,
1076
+ df: Optional[int] = None,
912
1077
  ) -> Tuple[float, float, float, float]:
913
1078
  """
914
1079
  Compute Conditional Least Favorable (C-LF) confidence interval.
@@ -931,6 +1096,8 @@ def _compute_clf_ci(
931
1096
  Significance level.
932
1097
  n_draws : int
933
1098
  Number of Monte Carlo draws for conditional CI.
1099
+ df : int, optional
1100
+ Degrees of freedom for t-distribution critical value.
934
1101
 
935
1102
  Returns
936
1103
  -------
@@ -956,7 +1123,7 @@ def _compute_clf_ci(
956
1123
  ub = theta + bound
957
1124
 
958
1125
  # CI with estimation uncertainty
959
- z = stats.norm.ppf(1 - alpha / 2)
1126
+ z = _get_critical_value(alpha, df)
960
1127
  ci_lb = lb - z * se
961
1128
  ci_ub = ub + z * se
962
1129
 
@@ -1086,7 +1253,7 @@ class HonestDiD:
1086
1253
  M = M if M is not None else self.M
1087
1254
 
1088
1255
  # Extract event study parameters
1089
- (beta_hat, sigma, num_pre, num_post, pre_periods, post_periods) = (
1256
+ (beta_hat, sigma, num_pre, num_post, pre_periods, post_periods, df_survey) = (
1090
1257
  _extract_event_study_params(results)
1091
1258
  )
1092
1259
 
@@ -1137,22 +1304,41 @@ class HonestDiD:
1137
1304
  # Compute bounds based on method
1138
1305
  if self.method == "smoothness":
1139
1306
  lb, ub, ci_lb, ci_ub = self._compute_smoothness_bounds(
1140
- beta_post, sigma_post, l_vec, num_pre, num_post, M
1307
+ beta_post, sigma_post, l_vec, num_pre, num_post, M, df=df_survey
1141
1308
  )
1142
1309
  ci_method = "FLCI"
1143
1310
 
1144
1311
  elif self.method == "relative_magnitude":
1145
1312
  lb, ub, ci_lb, ci_ub = self._compute_rm_bounds(
1146
- beta_post, sigma_post, l_vec, num_pre, num_post, M, pre_periods, results
1313
+ beta_post,
1314
+ sigma_post,
1315
+ l_vec,
1316
+ num_pre,
1317
+ num_post,
1318
+ M,
1319
+ pre_periods,
1320
+ results,
1321
+ df=df_survey,
1147
1322
  )
1148
1323
  ci_method = "C-LF"
1149
1324
 
1150
1325
  else: # combined
1151
1326
  lb, ub, ci_lb, ci_ub = self._compute_combined_bounds(
1152
- beta_post, sigma_post, l_vec, num_pre, num_post, M, pre_periods, results
1327
+ beta_post,
1328
+ sigma_post,
1329
+ l_vec,
1330
+ num_pre,
1331
+ num_post,
1332
+ M,
1333
+ pre_periods,
1334
+ results,
1335
+ df=df_survey,
1153
1336
  )
1154
1337
  ci_method = "FLCI"
1155
1338
 
1339
+ # Extract survey_metadata for storage on results
1340
+ survey_metadata = getattr(results, "survey_metadata", None)
1341
+
1156
1342
  return HonestDiDResults(
1157
1343
  lb=lb,
1158
1344
  ub=ub,
@@ -1165,6 +1351,8 @@ class HonestDiD:
1165
1351
  alpha=self.alpha,
1166
1352
  ci_method=ci_method,
1167
1353
  original_results=results,
1354
+ survey_metadata=survey_metadata,
1355
+ df_survey=df_survey,
1168
1356
  )
1169
1357
 
1170
1358
  def _compute_smoothness_bounds(
@@ -1175,6 +1363,7 @@ class HonestDiD:
1175
1363
  num_pre: int,
1176
1364
  num_post: int,
1177
1365
  M: float,
1366
+ df: Optional[int] = None,
1178
1367
  ) -> Tuple[float, float, float, float]:
1179
1368
  """Compute bounds under smoothness restriction."""
1180
1369
  # Construct constraints
@@ -1185,7 +1374,7 @@ class HonestDiD:
1185
1374
 
1186
1375
  # Compute FLCI
1187
1376
  se = np.sqrt(l_vec @ sigma_post @ l_vec)
1188
- ci_lb, ci_ub = _compute_flci(lb, ub, se, self.alpha)
1377
+ ci_lb, ci_ub = _compute_flci(lb, ub, se, self.alpha, df=df)
1189
1378
 
1190
1379
  return lb, ub, ci_lb, ci_ub
1191
1380
 
@@ -1199,6 +1388,7 @@ class HonestDiD:
1199
1388
  Mbar: float,
1200
1389
  pre_periods: List,
1201
1390
  results: Any,
1391
+ df: Optional[int] = None,
1202
1392
  ) -> Tuple[float, float, float, float]:
1203
1393
  """Compute bounds under relative magnitudes restriction."""
1204
1394
  # Estimate max pre-period violation from pre-trends
@@ -1209,12 +1399,18 @@ class HonestDiD:
1209
1399
  # No pre-period violations detected - use point estimate
1210
1400
  theta = np.dot(l_vec, beta_post)
1211
1401
  se = np.sqrt(l_vec @ sigma_post @ l_vec)
1212
- z = stats.norm.ppf(1 - self.alpha / 2)
1402
+ z = _get_critical_value(self.alpha, df)
1213
1403
  return theta, theta, theta - z * se, theta + z * se
1214
1404
 
1215
1405
  # Compute bounds
1216
1406
  lb, ub, ci_lb, ci_ub = _compute_clf_ci(
1217
- beta_post, sigma_post, l_vec, Mbar, max_pre_violation, self.alpha
1407
+ beta_post,
1408
+ sigma_post,
1409
+ l_vec,
1410
+ Mbar,
1411
+ max_pre_violation,
1412
+ self.alpha,
1413
+ df=df,
1218
1414
  )
1219
1415
 
1220
1416
  return lb, ub, ci_lb, ci_ub
@@ -1229,16 +1425,17 @@ class HonestDiD:
1229
1425
  M: float,
1230
1426
  pre_periods: List,
1231
1427
  results: Any,
1428
+ df: Optional[int] = None,
1232
1429
  ) -> Tuple[float, float, float, float]:
1233
1430
  """Compute bounds under combined smoothness + RM restriction."""
1234
1431
  # Get smoothness bounds
1235
1432
  lb_sd, ub_sd, _, _ = self._compute_smoothness_bounds(
1236
- beta_post, sigma_post, l_vec, num_pre, num_post, M
1433
+ beta_post, sigma_post, l_vec, num_pre, num_post, M, df=df
1237
1434
  )
1238
1435
 
1239
1436
  # Get RM bounds (use M as Mbar for combined)
1240
1437
  lb_rm, ub_rm, _, _ = self._compute_rm_bounds(
1241
- beta_post, sigma_post, l_vec, num_pre, num_post, M, pre_periods, results
1438
+ beta_post, sigma_post, l_vec, num_pre, num_post, M, pre_periods, results, df=df
1242
1439
  )
1243
1440
 
1244
1441
  # Combined bounds are intersection
@@ -1252,7 +1449,7 @@ class HonestDiD:
1252
1449
 
1253
1450
  # Compute FLCI on combined bounds
1254
1451
  se = np.sqrt(l_vec @ sigma_post @ l_vec)
1255
- ci_lb, ci_ub = _compute_flci(lb, ub, se, self.alpha)
1452
+ ci_lb, ci_ub = _compute_flci(lb, ub, se, self.alpha, df=df)
1256
1453
 
1257
1454
  return lb, ub, ci_lb, ci_ub
1258
1455
 
@@ -1283,15 +1480,18 @@ class HonestDiD:
1283
1480
 
1284
1481
  if isinstance(results, CallawaySantAnnaResults):
1285
1482
  if results.event_study_effects:
1286
- # Filter out normalization constraints (n_groups=0, e.g. reference period)
1483
+ # Use the reference-aware pre_periods from _extract_event_study_params
1484
+ pre_set = set(pre_periods) if pre_periods else set()
1287
1485
  pre_effects = [
1288
1486
  abs(results.event_study_effects[t]["effect"])
1289
1487
  for t in results.event_study_effects
1290
- if t < 0 and results.event_study_effects[t].get("n_groups", 1) > 0
1488
+ if t in pre_set and results.event_study_effects[t].get("n_groups", 1) > 0
1291
1489
  ]
1292
1490
  if pre_effects:
1293
1491
  return max(pre_effects)
1294
- return results.overall_se
1492
+ # No valid pre-effects — should have been caught by
1493
+ # _extract_event_study_params pre-period validation
1494
+ return 0.0
1295
1495
  except ImportError:
1296
1496
  pass
1297
1497
 
@@ -25,6 +25,7 @@ from diff_diff.prep_dgp import (
25
25
  generate_ddd_data,
26
26
  generate_panel_data,
27
27
  generate_event_study_data,
28
+ generate_staggered_ddd_data,
28
29
  )
29
30
 
30
31
  # Constants for rank_control_units