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.
- {diff_diff-2.7.6 → diff_diff-2.8.0}/PKG-INFO +10 -1
- {diff_diff-2.7.6 → diff_diff-2.8.0}/README.md +8 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/__init__.py +20 -1
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/honest_did.py +224 -24
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/prep.py +1 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/prep_dgp.py +188 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/staggered.py +1416 -83
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/staggered_aggregation.py +181 -53
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/staggered_bootstrap.py +30 -17
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/staggered_results.py +6 -2
- diff_diff-2.8.0/diff_diff/staggered_triple_diff.py +1215 -0
- diff_diff-2.8.0/diff_diff/staggered_triple_diff_results.py +348 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_common.py +36 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_diagnostic.py +23 -4
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_event_study.py +24 -4
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_power.py +14 -4
- {diff_diff-2.7.6 → diff_diff-2.8.0}/pyproject.toml +2 -1
- {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/Cargo.lock +1 -1
- {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/Cargo.toml +1 -1
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/_backend.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/bacon.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/bootstrap_utils.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/continuous_did.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/continuous_did_bspline.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/continuous_did_results.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/datasets.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/diagnostics.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did_bootstrap.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did_covariates.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did_results.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/efficient_did_weights.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/estimators.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/imputation.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/imputation_bootstrap.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/imputation_results.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/linalg.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/power.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/practitioner.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/pretrends.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/results.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/stacked_did.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/stacked_did_results.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/sun_abraham.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/survey.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/triple_diff.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/trop.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/trop_global.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/trop_local.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/trop_results.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/twfe.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/two_stage.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/two_stage_bootstrap.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/two_stage_results.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/utils.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/__init__.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_continuous.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_staggered.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/diff_diff/visualization/_synthetic.py +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/build.rs +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/bootstrap.rs +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/lib.rs +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/linalg.rs +0 -0
- {diff_diff-2.7.6 → diff_diff-2.8.0}/rust/src/trop.rs +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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,
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|