diff-diff 2.7.1__tar.gz → 2.7.2__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.1 → diff_diff-2.7.2}/PKG-INFO +35 -7
- {diff_diff-2.7.1 → diff_diff-2.7.2}/README.md +24 -3
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/__init__.py +1 -1
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/_backend.py +16 -16
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/datasets.py +3 -3
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/staggered.py +30 -13
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/staggered_results.py +2 -2
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/trop.py +70 -53
- {diff_diff-2.7.1 → diff_diff-2.7.2}/pyproject.toml +20 -3
- {diff_diff-2.7.1 → diff_diff-2.7.2}/rust/Cargo.lock +1 -1
- {diff_diff-2.7.1 → diff_diff-2.7.2}/rust/Cargo.toml +1 -1
- {diff_diff-2.7.1 → diff_diff-2.7.2}/rust/src/lib.rs +4 -4
- {diff_diff-2.7.1 → diff_diff-2.7.2}/rust/src/trop.rs +16 -12
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/bacon.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/bootstrap_utils.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/continuous_did.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/continuous_did_bspline.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/continuous_did_results.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/diagnostics.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/efficient_did.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/efficient_did_bootstrap.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/efficient_did_results.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/efficient_did_weights.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/estimators.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/honest_did.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/imputation.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/imputation_bootstrap.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/imputation_results.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/linalg.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/power.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/prep.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/prep_dgp.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/pretrends.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/results.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/stacked_did.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/stacked_did_results.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/staggered_aggregation.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/staggered_bootstrap.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/sun_abraham.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/triple_diff.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/trop_results.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/twfe.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/two_stage.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/two_stage_bootstrap.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/two_stage_results.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/utils.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/diff_diff/visualization.py +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/rust/build.rs +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/rust/src/bootstrap.rs +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/rust/src/linalg.rs +0 -0
- {diff_diff-2.7.1 → diff_diff-2.7.2}/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.
|
|
3
|
+
Version: 2.7.2
|
|
4
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
5
5
|
Classifier: Intended Audience :: Science/Research
|
|
6
6
|
Classifier: Operating System :: OS Independent
|
|
@@ -11,6 +11,8 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.13
|
|
13
13
|
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
16
|
Requires-Dist: numpy>=1.20.0
|
|
15
17
|
Requires-Dist: pandas>=1.3.0
|
|
16
18
|
Requires-Dist: scipy>=1.7.0
|
|
@@ -21,12 +23,17 @@ Requires-Dist: black>=23.0 ; extra == 'dev'
|
|
|
21
23
|
Requires-Dist: ruff>=0.1.0 ; extra == 'dev'
|
|
22
24
|
Requires-Dist: mypy>=1.0 ; extra == 'dev'
|
|
23
25
|
Requires-Dist: maturin>=1.4,<2.0 ; extra == 'dev'
|
|
26
|
+
Requires-Dist: matplotlib>=3.5 ; extra == 'dev'
|
|
24
27
|
Requires-Dist: sphinx>=6.0 ; extra == 'docs'
|
|
25
|
-
Requires-Dist: sphinx-
|
|
28
|
+
Requires-Dist: pydata-sphinx-theme>=0.15 ; extra == 'docs'
|
|
29
|
+
Requires-Dist: sphinxext-opengraph>=0.9 ; extra == 'docs'
|
|
30
|
+
Requires-Dist: sphinx-sitemap>=2.5 ; extra == 'docs'
|
|
31
|
+
Requires-Dist: nbsphinx>=0.9 ; extra == 'docs'
|
|
32
|
+
Requires-Dist: matplotlib>=3.5 ; extra == 'docs'
|
|
26
33
|
Provides-Extra: dev
|
|
27
34
|
Provides-Extra: docs
|
|
28
|
-
Summary:
|
|
29
|
-
Keywords: causal-inference,difference-in-differences,econometrics,statistics,treatment-effects
|
|
35
|
+
Summary: Difference-in-Differences causal inference with sklearn-like API. Callaway-Sant'Anna, Synthetic DiD, Honest DiD, event studies, parallel trends.
|
|
36
|
+
Keywords: causal-inference,difference-in-differences,econometrics,statistics,treatment-effects,event-study,staggered-adoption,parallel-trends,synthetic-control,panel-data,did,twfe,callaway-santanna,honest-did,sensitivity-analysis
|
|
30
37
|
Author: diff-diff contributors
|
|
31
38
|
License-Expression: MIT
|
|
32
39
|
Requires-Python: >=3.9, <3.14
|
|
@@ -38,6 +45,12 @@ Project-URL: Repository, https://github.com/igerber/diff-diff
|
|
|
38
45
|
|
|
39
46
|
# diff-diff
|
|
40
47
|
|
|
48
|
+
[](https://pypi.org/project/diff-diff/)
|
|
49
|
+
[](https://pypi.org/project/diff-diff/)
|
|
50
|
+
[](https://opensource.org/licenses/MIT)
|
|
51
|
+
[](https://pypi.org/project/diff-diff/)
|
|
52
|
+
[](https://diff-diff.readthedocs.io/en/stable/)
|
|
53
|
+
|
|
41
54
|
A Python library for Difference-in-Differences (DiD) causal inference analysis with an sklearn-like API and statsmodels-style outputs.
|
|
42
55
|
|
|
43
56
|
## Installation
|
|
@@ -1555,7 +1568,7 @@ trop = TROP(
|
|
|
1555
1568
|
|
|
1556
1569
|
```python
|
|
1557
1570
|
TROP(
|
|
1558
|
-
method='
|
|
1571
|
+
method='local', # Estimation method: 'local' (default) or 'global'
|
|
1559
1572
|
lambda_time_grid=None, # Time decay grid (default: [0, 0.1, 0.5, 1, 2, 5])
|
|
1560
1573
|
lambda_unit_grid=None, # Unit distance grid (default: [0, 0.1, 0.5, 1, 2, 5])
|
|
1561
1574
|
lambda_nn_grid=None, # Nuclear norm grid (default: [0, 0.01, 0.1, 1, 10])
|
|
@@ -1568,8 +1581,8 @@ TROP(
|
|
|
1568
1581
|
```
|
|
1569
1582
|
|
|
1570
1583
|
**Estimation methods:**
|
|
1571
|
-
- `'
|
|
1572
|
-
- `'
|
|
1584
|
+
- `'local'` (default): Per-observation model fitting following Algorithm 2 of the paper. Computes observation-specific weights and fits a model for each treated observation, then averages the individual treatment effects. More flexible but computationally intensive.
|
|
1585
|
+
- `'global'`: Global weighted least squares optimization. Fits a single model on control observations with global weights, then computes per-observation treatment effects as residuals. Faster but uses global rather than observation-specific weights.
|
|
1573
1586
|
|
|
1574
1587
|
**Convenience function:**
|
|
1575
1588
|
|
|
@@ -2947,6 +2960,21 @@ The `HonestDiD` module implements sensitivity analysis methods for relaxing the
|
|
|
2947
2960
|
|
|
2948
2961
|
- **Cunningham, S. (2021).** *Causal Inference: The Mixtape*. Yale University Press. [https://mixtape.scunning.com/](https://mixtape.scunning.com/)
|
|
2949
2962
|
|
|
2963
|
+
## Citing diff-diff
|
|
2964
|
+
|
|
2965
|
+
If you use diff-diff in your research, please cite it:
|
|
2966
|
+
|
|
2967
|
+
```bibtex
|
|
2968
|
+
@software{diff_diff,
|
|
2969
|
+
title = {diff-diff: Difference-in-Differences Causal Inference for Python},
|
|
2970
|
+
author = {{diff-diff contributors}},
|
|
2971
|
+
url = {https://github.com/igerber/diff-diff},
|
|
2972
|
+
license = {MIT},
|
|
2973
|
+
}
|
|
2974
|
+
```
|
|
2975
|
+
|
|
2976
|
+
See [`CITATION.cff`](CITATION.cff) for the full citation metadata.
|
|
2977
|
+
|
|
2950
2978
|
## License
|
|
2951
2979
|
|
|
2952
2980
|
MIT License
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# diff-diff
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/diff-diff/)
|
|
4
|
+
[](https://pypi.org/project/diff-diff/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://pypi.org/project/diff-diff/)
|
|
7
|
+
[](https://diff-diff.readthedocs.io/en/stable/)
|
|
8
|
+
|
|
3
9
|
A Python library for Difference-in-Differences (DiD) causal inference analysis with an sklearn-like API and statsmodels-style outputs.
|
|
4
10
|
|
|
5
11
|
## Installation
|
|
@@ -1517,7 +1523,7 @@ trop = TROP(
|
|
|
1517
1523
|
|
|
1518
1524
|
```python
|
|
1519
1525
|
TROP(
|
|
1520
|
-
method='
|
|
1526
|
+
method='local', # Estimation method: 'local' (default) or 'global'
|
|
1521
1527
|
lambda_time_grid=None, # Time decay grid (default: [0, 0.1, 0.5, 1, 2, 5])
|
|
1522
1528
|
lambda_unit_grid=None, # Unit distance grid (default: [0, 0.1, 0.5, 1, 2, 5])
|
|
1523
1529
|
lambda_nn_grid=None, # Nuclear norm grid (default: [0, 0.01, 0.1, 1, 10])
|
|
@@ -1530,8 +1536,8 @@ TROP(
|
|
|
1530
1536
|
```
|
|
1531
1537
|
|
|
1532
1538
|
**Estimation methods:**
|
|
1533
|
-
- `'
|
|
1534
|
-
- `'
|
|
1539
|
+
- `'local'` (default): Per-observation model fitting following Algorithm 2 of the paper. Computes observation-specific weights and fits a model for each treated observation, then averages the individual treatment effects. More flexible but computationally intensive.
|
|
1540
|
+
- `'global'`: Global weighted least squares optimization. Fits a single model on control observations with global weights, then computes per-observation treatment effects as residuals. Faster but uses global rather than observation-specific weights.
|
|
1535
1541
|
|
|
1536
1542
|
**Convenience function:**
|
|
1537
1543
|
|
|
@@ -2909,6 +2915,21 @@ The `HonestDiD` module implements sensitivity analysis methods for relaxing the
|
|
|
2909
2915
|
|
|
2910
2916
|
- **Cunningham, S. (2021).** *Causal Inference: The Mixtape*. Yale University Press. [https://mixtape.scunning.com/](https://mixtape.scunning.com/)
|
|
2911
2917
|
|
|
2918
|
+
## Citing diff-diff
|
|
2919
|
+
|
|
2920
|
+
If you use diff-diff in your research, please cite it:
|
|
2921
|
+
|
|
2922
|
+
```bibtex
|
|
2923
|
+
@software{diff_diff,
|
|
2924
|
+
title = {diff-diff: Difference-in-Differences Causal Inference for Python},
|
|
2925
|
+
author = {{diff-diff contributors}},
|
|
2926
|
+
url = {https://github.com/igerber/diff-diff},
|
|
2927
|
+
license = {MIT},
|
|
2928
|
+
}
|
|
2929
|
+
```
|
|
2930
|
+
|
|
2931
|
+
See [`CITATION.cff`](CITATION.cff) for the full citation metadata.
|
|
2932
|
+
|
|
2912
2933
|
## License
|
|
2913
2934
|
|
|
2914
2935
|
MIT License
|
|
@@ -23,13 +23,13 @@ try:
|
|
|
23
23
|
project_simplex as _rust_project_simplex,
|
|
24
24
|
solve_ols as _rust_solve_ols,
|
|
25
25
|
compute_robust_vcov as _rust_compute_robust_vcov,
|
|
26
|
-
# TROP estimator acceleration (
|
|
26
|
+
# TROP estimator acceleration (local method)
|
|
27
27
|
compute_unit_distance_matrix as _rust_unit_distance_matrix,
|
|
28
28
|
loocv_grid_search as _rust_loocv_grid_search,
|
|
29
29
|
bootstrap_trop_variance as _rust_bootstrap_trop_variance,
|
|
30
|
-
# TROP estimator acceleration (
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
# TROP estimator acceleration (global method)
|
|
31
|
+
loocv_grid_search_global as _rust_loocv_grid_search_global,
|
|
32
|
+
bootstrap_trop_variance_global as _rust_bootstrap_trop_variance_global,
|
|
33
33
|
# SDID weights (Frank-Wolfe matching R's synthdid)
|
|
34
34
|
compute_sdid_unit_weights as _rust_sdid_unit_weights,
|
|
35
35
|
compute_time_weights as _rust_compute_time_weights,
|
|
@@ -46,13 +46,13 @@ except ImportError:
|
|
|
46
46
|
_rust_project_simplex = None
|
|
47
47
|
_rust_solve_ols = None
|
|
48
48
|
_rust_compute_robust_vcov = None
|
|
49
|
-
# TROP estimator acceleration (
|
|
49
|
+
# TROP estimator acceleration (local method)
|
|
50
50
|
_rust_unit_distance_matrix = None
|
|
51
51
|
_rust_loocv_grid_search = None
|
|
52
52
|
_rust_bootstrap_trop_variance = None
|
|
53
|
-
# TROP estimator acceleration (
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
# TROP estimator acceleration (global method)
|
|
54
|
+
_rust_loocv_grid_search_global = None
|
|
55
|
+
_rust_bootstrap_trop_variance_global = None
|
|
56
56
|
# SDID weights (Frank-Wolfe matching R's synthdid)
|
|
57
57
|
_rust_sdid_unit_weights = None
|
|
58
58
|
_rust_compute_time_weights = None
|
|
@@ -69,13 +69,13 @@ if _backend_env == 'python':
|
|
|
69
69
|
_rust_project_simplex = None
|
|
70
70
|
_rust_solve_ols = None
|
|
71
71
|
_rust_compute_robust_vcov = None
|
|
72
|
-
# TROP estimator acceleration (
|
|
72
|
+
# TROP estimator acceleration (local method)
|
|
73
73
|
_rust_unit_distance_matrix = None
|
|
74
74
|
_rust_loocv_grid_search = None
|
|
75
75
|
_rust_bootstrap_trop_variance = None
|
|
76
|
-
# TROP estimator acceleration (
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
# TROP estimator acceleration (global method)
|
|
77
|
+
_rust_loocv_grid_search_global = None
|
|
78
|
+
_rust_bootstrap_trop_variance_global = None
|
|
79
79
|
# SDID weights (Frank-Wolfe matching R's synthdid)
|
|
80
80
|
_rust_sdid_unit_weights = None
|
|
81
81
|
_rust_compute_time_weights = None
|
|
@@ -118,13 +118,13 @@ __all__ = [
|
|
|
118
118
|
'_rust_project_simplex',
|
|
119
119
|
'_rust_solve_ols',
|
|
120
120
|
'_rust_compute_robust_vcov',
|
|
121
|
-
# TROP estimator acceleration (
|
|
121
|
+
# TROP estimator acceleration (local method)
|
|
122
122
|
'_rust_unit_distance_matrix',
|
|
123
123
|
'_rust_loocv_grid_search',
|
|
124
124
|
'_rust_bootstrap_trop_variance',
|
|
125
|
-
# TROP estimator acceleration (
|
|
126
|
-
'
|
|
127
|
-
'
|
|
125
|
+
# TROP estimator acceleration (global method)
|
|
126
|
+
'_rust_loocv_grid_search_global',
|
|
127
|
+
'_rust_bootstrap_trop_variance_global',
|
|
128
128
|
# SDID weights (Frank-Wolfe matching R's synthdid)
|
|
129
129
|
'_rust_sdid_unit_weights',
|
|
130
130
|
'_rust_compute_time_weights',
|
|
@@ -266,7 +266,7 @@ def load_castle_doctrine(force_download: bool = False) -> pd.DataFrame:
|
|
|
266
266
|
... outcome="homicide_rate",
|
|
267
267
|
... unit="state",
|
|
268
268
|
... time="year",
|
|
269
|
-
...
|
|
269
|
+
... first_treat="first_treat"
|
|
270
270
|
... )
|
|
271
271
|
"""
|
|
272
272
|
url = "https://raw.githubusercontent.com/causaldata/causal_datasets/main/castle/castle.csv"
|
|
@@ -412,7 +412,7 @@ def load_divorce_laws(force_download: bool = False) -> pd.DataFrame:
|
|
|
412
412
|
... outcome="divorce_rate",
|
|
413
413
|
... unit="state",
|
|
414
414
|
... time="year",
|
|
415
|
-
...
|
|
415
|
+
... first_treat="first_treat"
|
|
416
416
|
... )
|
|
417
417
|
"""
|
|
418
418
|
# Try to load from causaldata repository
|
|
@@ -571,7 +571,7 @@ def load_mpdta(force_download: bool = False) -> pd.DataFrame:
|
|
|
571
571
|
... outcome="lemp",
|
|
572
572
|
... unit="countyreal",
|
|
573
573
|
... time="year",
|
|
574
|
-
...
|
|
574
|
+
... first_treat="first_treat"
|
|
575
575
|
... )
|
|
576
576
|
"""
|
|
577
577
|
# mpdta is available from the did package documentation
|
|
@@ -450,10 +450,12 @@ class CallawaySantAnna(
|
|
|
450
450
|
if self.control_group == "never_treated":
|
|
451
451
|
control_mask = never_treated_mask
|
|
452
452
|
else: # not_yet_treated
|
|
453
|
-
# Not yet treated at time t
|
|
454
|
-
#
|
|
453
|
+
# Not yet treated at BOTH time t and the base period:
|
|
454
|
+
# Controls must be untreated at whichever is later, otherwise
|
|
455
|
+
# their outcome at the base period is contaminated by treatment.
|
|
456
|
+
nyt_threshold = max(t, base_period_val) + self.anticipation
|
|
455
457
|
control_mask = never_treated_mask | (
|
|
456
|
-
(unit_cohorts >
|
|
458
|
+
(unit_cohorts > nyt_threshold) & (unit_cohorts != g)
|
|
457
459
|
)
|
|
458
460
|
|
|
459
461
|
# Extract outcomes for base and post periods
|
|
@@ -616,21 +618,23 @@ class CallawaySantAnna(
|
|
|
616
618
|
if base_period_val not in period_to_col or t not in period_to_col:
|
|
617
619
|
continue
|
|
618
620
|
|
|
619
|
-
tasks.append((g, t, period_to_col[base_period_val], period_to_col[t]))
|
|
621
|
+
tasks.append((g, t, period_to_col[base_period_val], period_to_col[t], base_period_val))
|
|
620
622
|
|
|
621
623
|
# Process all tasks
|
|
622
624
|
atts = []
|
|
623
625
|
ses = []
|
|
624
626
|
task_keys = []
|
|
625
627
|
|
|
626
|
-
for g, t, base_col, post_col in tasks:
|
|
628
|
+
for g, t, base_col, post_col, base_period_val in tasks:
|
|
627
629
|
treated_mask = cohort_masks[g]
|
|
628
630
|
|
|
629
631
|
if self.control_group == "never_treated":
|
|
630
632
|
control_mask = never_treated_mask
|
|
631
633
|
else:
|
|
634
|
+
# Controls must be untreated at both t and base_period_val
|
|
635
|
+
nyt_threshold = max(t, base_period_val) + self.anticipation
|
|
632
636
|
control_mask = never_treated_mask | (
|
|
633
|
-
(unit_cohorts >
|
|
637
|
+
(unit_cohorts > nyt_threshold) & (unit_cohorts != g)
|
|
634
638
|
)
|
|
635
639
|
|
|
636
640
|
y_base = outcome_matrix[:, base_col]
|
|
@@ -786,10 +790,10 @@ class CallawaySantAnna(
|
|
|
786
790
|
if self.control_group == "never_treated":
|
|
787
791
|
control_mask = never_treated_mask
|
|
788
792
|
else:
|
|
789
|
-
#
|
|
790
|
-
|
|
793
|
+
# Controls must be untreated at both t and base_period_val
|
|
794
|
+
nyt_threshold = max(first_t, base_period_val) + self.anticipation
|
|
791
795
|
control_mask = never_treated_mask | (
|
|
792
|
-
(unit_cohorts >
|
|
796
|
+
(unit_cohorts > nyt_threshold) & (unit_cohorts != first_g)
|
|
793
797
|
)
|
|
794
798
|
|
|
795
799
|
# For balanced panels, valid_mask is all True so control_valid = control_mask
|
|
@@ -846,13 +850,15 @@ class CallawaySantAnna(
|
|
|
846
850
|
cho = None
|
|
847
851
|
|
|
848
852
|
# Process each (g, t) pair in this group
|
|
849
|
-
for g, t,
|
|
853
|
+
for g, t, bp_val, base_col, post_col in tasks:
|
|
850
854
|
treated_mask = cohort_masks[g]
|
|
851
855
|
|
|
852
856
|
# Recompute control mask for not_yet_treated (varies by g, t)
|
|
853
857
|
if self.control_group == "not_yet_treated":
|
|
858
|
+
# Controls must be untreated at both t and base period
|
|
859
|
+
nyt_threshold = max(t, bp_val) + self.anticipation
|
|
854
860
|
control_mask = never_treated_mask | (
|
|
855
|
-
(unit_cohorts >
|
|
861
|
+
(unit_cohorts > nyt_threshold) & (unit_cohorts != g)
|
|
856
862
|
)
|
|
857
863
|
|
|
858
864
|
y_base = outcome_matrix[:, base_col]
|
|
@@ -1127,8 +1133,19 @@ class CallawaySantAnna(
|
|
|
1127
1133
|
n_treated_units = (unit_info[first_treat] > 0).sum()
|
|
1128
1134
|
n_control_units = (unit_info["_never_treated"]).sum()
|
|
1129
1135
|
|
|
1130
|
-
if n_control_units == 0:
|
|
1131
|
-
raise ValueError(
|
|
1136
|
+
if n_control_units == 0 and self.control_group == "never_treated":
|
|
1137
|
+
raise ValueError(
|
|
1138
|
+
"No never-treated units found. Check 'first_treat' column. "
|
|
1139
|
+
"Use control_group='not_yet_treated' if all units are eventually treated."
|
|
1140
|
+
)
|
|
1141
|
+
if n_control_units == 0 and self.control_group == "not_yet_treated":
|
|
1142
|
+
# With not_yet_treated, controls are units not yet treated at each
|
|
1143
|
+
# (g, t) pair — never-treated units are not required.
|
|
1144
|
+
if len(treatment_groups) < 2:
|
|
1145
|
+
raise ValueError(
|
|
1146
|
+
"not_yet_treated control group requires at least 2 treatment "
|
|
1147
|
+
"cohorts when there are no never-treated units."
|
|
1148
|
+
)
|
|
1132
1149
|
|
|
1133
1150
|
# Pre-compute data structures for efficient ATT(g,t) computation
|
|
1134
1151
|
precomputed = self._precompute_structures(
|
|
@@ -88,7 +88,7 @@ class CallawaySantAnnaResults:
|
|
|
88
88
|
n_treated_units : int
|
|
89
89
|
Number of ever-treated units.
|
|
90
90
|
n_control_units : int
|
|
91
|
-
Number of never-treated units.
|
|
91
|
+
Number of never-treated units (excludes not-yet-treated dynamic controls).
|
|
92
92
|
event_study_effects : dict, optional
|
|
93
93
|
Effects aggregated by relative time (event study).
|
|
94
94
|
group_effects : dict, optional
|
|
@@ -152,7 +152,7 @@ class CallawaySantAnnaResults:
|
|
|
152
152
|
"",
|
|
153
153
|
f"{'Total observations:':<30} {self.n_obs:>10}",
|
|
154
154
|
f"{'Treated units:':<30} {self.n_treated_units:>10}",
|
|
155
|
-
f"{'
|
|
155
|
+
f"{'Never-treated units:':<30} {self.n_control_units:>10}",
|
|
156
156
|
f"{'Treatment cohorts:':<30} {len(self.groups):>10}",
|
|
157
157
|
f"{'Time periods:':<30} {len(self.time_periods):>10}",
|
|
158
158
|
f"{'Control group:':<30} {self.control_group:>10}",
|
|
@@ -31,8 +31,8 @@ from diff_diff._backend import (
|
|
|
31
31
|
_rust_unit_distance_matrix,
|
|
32
32
|
_rust_loocv_grid_search,
|
|
33
33
|
_rust_bootstrap_trop_variance,
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
_rust_loocv_grid_search_global,
|
|
35
|
+
_rust_bootstrap_trop_variance_global,
|
|
36
36
|
)
|
|
37
37
|
from diff_diff.trop_results import (
|
|
38
38
|
_LAMBDA_INF,
|
|
@@ -63,10 +63,10 @@ class TROP:
|
|
|
63
63
|
|
|
64
64
|
Parameters
|
|
65
65
|
----------
|
|
66
|
-
method : str, default='
|
|
66
|
+
method : str, default='local'
|
|
67
67
|
Estimation method to use:
|
|
68
68
|
|
|
69
|
-
- '
|
|
69
|
+
- 'local': Per-observation model fitting following Algorithm 2 of
|
|
70
70
|
Athey et al. (2025). Computes observation-specific weights and fits
|
|
71
71
|
a model for each treated observation, averaging the individual
|
|
72
72
|
treatment effects. More flexible but computationally intensive.
|
|
@@ -77,10 +77,11 @@ class TROP:
|
|
|
77
77
|
treatment effects as residuals:
|
|
78
78
|
tau_it = Y_it - mu - alpha_i - beta_t - L_it for treated cells.
|
|
79
79
|
ATT is the mean of these effects. For the paper's full
|
|
80
|
-
per-treated-cell estimator, use ``method='
|
|
80
|
+
per-treated-cell estimator, use ``method='local'``.
|
|
81
81
|
|
|
82
|
-
- '
|
|
83
|
-
|
|
82
|
+
- 'twostep': Deprecated alias for 'local'. Will be removed in v3.0.
|
|
83
|
+
|
|
84
|
+
- 'joint': Deprecated alias for 'global'. Will be removed in v3.0.
|
|
84
85
|
|
|
85
86
|
lambda_time_grid : list, optional
|
|
86
87
|
Grid of time weight decay parameters. 0.0 = uniform weights (disabled).
|
|
@@ -138,7 +139,7 @@ class TROP:
|
|
|
138
139
|
|
|
139
140
|
def __init__(
|
|
140
141
|
self,
|
|
141
|
-
method: str = "
|
|
142
|
+
method: str = "local",
|
|
142
143
|
lambda_time_grid: Optional[List[float]] = None,
|
|
143
144
|
lambda_unit_grid: Optional[List[float]] = None,
|
|
144
145
|
lambda_nn_grid: Optional[List[float]] = None,
|
|
@@ -149,16 +150,24 @@ class TROP:
|
|
|
149
150
|
seed: Optional[int] = None,
|
|
150
151
|
):
|
|
151
152
|
# Validate method parameter
|
|
152
|
-
# 'global'
|
|
153
|
-
valid_methods = ("twostep", "joint", "global")
|
|
153
|
+
# 'local'/'global' are preferred; 'twostep'/'joint' are deprecated aliases
|
|
154
|
+
valid_methods = ("local", "twostep", "joint", "global")
|
|
154
155
|
if method not in valid_methods:
|
|
155
156
|
raise ValueError(
|
|
156
157
|
f"method must be one of {valid_methods}, got '{method}'"
|
|
157
158
|
)
|
|
159
|
+
if method == "twostep":
|
|
160
|
+
warnings.warn(
|
|
161
|
+
"method='twostep' is deprecated and will be removed in v3.0. "
|
|
162
|
+
"Use method='local' instead.",
|
|
163
|
+
FutureWarning,
|
|
164
|
+
stacklevel=2,
|
|
165
|
+
)
|
|
166
|
+
method = "local"
|
|
158
167
|
if method == "joint":
|
|
159
168
|
warnings.warn(
|
|
160
|
-
"method='joint' is deprecated and will be removed in
|
|
161
|
-
"
|
|
169
|
+
"method='joint' is deprecated and will be removed in v3.0. "
|
|
170
|
+
"Use method='global' instead.",
|
|
162
171
|
FutureWarning,
|
|
163
172
|
stacklevel=2,
|
|
164
173
|
)
|
|
@@ -556,7 +565,7 @@ class TROP:
|
|
|
556
565
|
# Joint estimation method
|
|
557
566
|
# =========================================================================
|
|
558
567
|
|
|
559
|
-
def
|
|
568
|
+
def _compute_global_weights(
|
|
560
569
|
self,
|
|
561
570
|
Y: np.ndarray,
|
|
562
571
|
D: np.ndarray,
|
|
@@ -567,7 +576,7 @@ class TROP:
|
|
|
567
576
|
n_periods: int,
|
|
568
577
|
) -> np.ndarray:
|
|
569
578
|
"""
|
|
570
|
-
Compute distance-based weights for
|
|
579
|
+
Compute distance-based weights for global estimation.
|
|
571
580
|
|
|
572
581
|
Following the reference implementation, weights are computed based on:
|
|
573
582
|
- Time distance: distance to center of treated block
|
|
@@ -655,7 +664,7 @@ class TROP:
|
|
|
655
664
|
|
|
656
665
|
return delta
|
|
657
666
|
|
|
658
|
-
def
|
|
667
|
+
def _solve_global_model(
|
|
659
668
|
self,
|
|
660
669
|
Y: np.ndarray,
|
|
661
670
|
delta: np.ndarray,
|
|
@@ -668,10 +677,10 @@ class TROP:
|
|
|
668
677
|
"""
|
|
669
678
|
n_periods, n_units = Y.shape
|
|
670
679
|
if lambda_nn >= 1e10:
|
|
671
|
-
mu, alpha, beta = self.
|
|
680
|
+
mu, alpha, beta = self._solve_global_no_lowrank(Y, delta)
|
|
672
681
|
L = np.zeros((n_periods, n_units))
|
|
673
682
|
else:
|
|
674
|
-
mu, alpha, beta, L = self.
|
|
683
|
+
mu, alpha, beta, L = self._solve_global_with_lowrank(
|
|
675
684
|
Y, delta, lambda_nn, self.max_iter, self.tol
|
|
676
685
|
)
|
|
677
686
|
return mu, alpha, beta, L
|
|
@@ -718,7 +727,7 @@ class TROP:
|
|
|
718
727
|
|
|
719
728
|
return att, treatment_effects, tau_values
|
|
720
729
|
|
|
721
|
-
def
|
|
730
|
+
def _loocv_score_global(
|
|
722
731
|
self,
|
|
723
732
|
Y: np.ndarray,
|
|
724
733
|
D: np.ndarray,
|
|
@@ -731,12 +740,12 @@ class TROP:
|
|
|
731
740
|
n_periods: int,
|
|
732
741
|
) -> float:
|
|
733
742
|
"""
|
|
734
|
-
Compute LOOCV score for
|
|
743
|
+
Compute LOOCV score for global method with specific parameter combination.
|
|
735
744
|
|
|
736
745
|
Following paper's Equation 5:
|
|
737
746
|
Q(λ) = Σ_{j,s: D_js=0} [τ̂_js^loocv(λ)]²
|
|
738
747
|
|
|
739
|
-
For
|
|
748
|
+
For global method, we exclude each control observation, fit the global model
|
|
740
749
|
on remaining data, and compute the pseudo-treatment effect at the excluded obs.
|
|
741
750
|
|
|
742
751
|
Parameters
|
|
@@ -766,7 +775,7 @@ class TROP:
|
|
|
766
775
|
LOOCV score (sum of squared pseudo-treatment effects).
|
|
767
776
|
"""
|
|
768
777
|
# Compute global weights (same for all LOOCV iterations)
|
|
769
|
-
delta = self.
|
|
778
|
+
delta = self._compute_global_weights(
|
|
770
779
|
Y, D, lambda_time, lambda_unit, treated_periods, n_units, n_periods
|
|
771
780
|
)
|
|
772
781
|
|
|
@@ -779,7 +788,7 @@ class TROP:
|
|
|
779
788
|
delta_ex[t_ex, i_ex] = 0.0
|
|
780
789
|
|
|
781
790
|
try:
|
|
782
|
-
mu, alpha, beta, L = self.
|
|
791
|
+
mu, alpha, beta, L = self._solve_global_model(Y, delta_ex, lambda_nn)
|
|
783
792
|
|
|
784
793
|
# Pseudo treatment effect: τ = Y - μ - α - β - L
|
|
785
794
|
if np.isfinite(Y[t_ex, i_ex]):
|
|
@@ -796,7 +805,7 @@ class TROP:
|
|
|
796
805
|
|
|
797
806
|
return tau_sq_sum
|
|
798
807
|
|
|
799
|
-
def
|
|
808
|
+
def _solve_global_no_lowrank(
|
|
800
809
|
self,
|
|
801
810
|
Y: np.ndarray,
|
|
802
811
|
delta: np.ndarray,
|
|
@@ -806,7 +815,7 @@ class TROP:
|
|
|
806
815
|
|
|
807
816
|
Solves: min Σ (1-W)*δ_{it}(Y_{it} - μ - α_i - β_t)²
|
|
808
817
|
|
|
809
|
-
The (1-W) masking is already applied to delta by
|
|
818
|
+
The (1-W) masking is already applied to delta by _compute_global_weights,
|
|
810
819
|
so treated observations have zero weight and do not affect the fit.
|
|
811
820
|
|
|
812
821
|
Parameters
|
|
@@ -880,7 +889,7 @@ class TROP:
|
|
|
880
889
|
|
|
881
890
|
return float(mu), alpha, beta
|
|
882
891
|
|
|
883
|
-
def
|
|
892
|
+
def _solve_global_with_lowrank(
|
|
884
893
|
self,
|
|
885
894
|
Y: np.ndarray,
|
|
886
895
|
delta: np.ndarray,
|
|
@@ -893,7 +902,7 @@ class TROP:
|
|
|
893
902
|
|
|
894
903
|
Solves: min Σ (1-W)*δ_{it}(Y_{it} - μ - α_i - β_t - L_{it})² + λ_nn||L||_*
|
|
895
904
|
|
|
896
|
-
The (1-W) masking is already applied to delta by
|
|
905
|
+
The (1-W) masking is already applied to delta by _compute_global_weights,
|
|
897
906
|
so treated observations have zero weight and do not affect the fit.
|
|
898
907
|
|
|
899
908
|
Parameters
|
|
@@ -942,7 +951,7 @@ class TROP:
|
|
|
942
951
|
|
|
943
952
|
# Step 1: Fix L, solve for (mu, alpha, beta)
|
|
944
953
|
Y_adj = Y_safe - L
|
|
945
|
-
mu, alpha, beta = self.
|
|
954
|
+
mu, alpha, beta = self._solve_global_no_lowrank(Y_adj, delta_masked)
|
|
946
955
|
|
|
947
956
|
# Step 2: Fix (mu, alpha, beta), update L with FISTA acceleration
|
|
948
957
|
R = Y_safe - mu - alpha[np.newaxis, :] - beta[:, np.newaxis]
|
|
@@ -981,11 +990,11 @@ class TROP:
|
|
|
981
990
|
|
|
982
991
|
# Final re-solve with converged L (match Rust behavior)
|
|
983
992
|
Y_adj = Y_safe - L
|
|
984
|
-
mu, alpha, beta = self.
|
|
993
|
+
mu, alpha, beta = self._solve_global_no_lowrank(Y_adj, delta_masked)
|
|
985
994
|
|
|
986
995
|
return mu, alpha, beta, L
|
|
987
996
|
|
|
988
|
-
def
|
|
997
|
+
def _fit_global(
|
|
989
998
|
self,
|
|
990
999
|
data: pd.DataFrame,
|
|
991
1000
|
outcome: str,
|
|
@@ -1024,10 +1033,10 @@ class TROP:
|
|
|
1024
1033
|
(fixed `treated_periods` across resamples). The treatment timing is
|
|
1025
1034
|
inferred from the data once and held constant for all bootstrap
|
|
1026
1035
|
iterations. For staggered adoption designs where treatment timing varies
|
|
1027
|
-
across units, use `method="
|
|
1036
|
+
across units, use `method="local"` which computes observation-specific
|
|
1028
1037
|
weights that naturally handle heterogeneous timing.
|
|
1029
1038
|
"""
|
|
1030
|
-
# Data setup (same as
|
|
1039
|
+
# Data setup (same as local method)
|
|
1031
1040
|
all_units = sorted(data[unit].unique())
|
|
1032
1041
|
all_periods = sorted(data[time].unique())
|
|
1033
1042
|
|
|
@@ -1097,7 +1106,7 @@ class TROP:
|
|
|
1097
1106
|
if n_pre_periods < 2:
|
|
1098
1107
|
raise ValueError("Need at least 2 pre-treatment periods")
|
|
1099
1108
|
|
|
1100
|
-
# Check for staggered adoption (
|
|
1109
|
+
# Check for staggered adoption (global method requires simultaneous treatment)
|
|
1101
1110
|
# Use only observed periods (skip missing) to avoid false positives on unbalanced panels
|
|
1102
1111
|
first_treat_by_unit = []
|
|
1103
1112
|
for i in treated_unit_idx:
|
|
@@ -1115,7 +1124,7 @@ class TROP:
|
|
|
1115
1124
|
raise ValueError(
|
|
1116
1125
|
f"method='global' requires simultaneous treatment adoption, but your data "
|
|
1117
1126
|
f"shows staggered adoption (units first treated at periods {unique_starts}). "
|
|
1118
|
-
f"Use method='
|
|
1127
|
+
f"Use method='local' which properly handles staggered adoption designs."
|
|
1119
1128
|
)
|
|
1120
1129
|
|
|
1121
1130
|
# LOOCV grid search for tuning parameters
|
|
@@ -1124,7 +1133,7 @@ class TROP:
|
|
|
1124
1133
|
best_score = np.inf
|
|
1125
1134
|
control_mask = D == 0
|
|
1126
1135
|
|
|
1127
|
-
if HAS_RUST_BACKEND and
|
|
1136
|
+
if HAS_RUST_BACKEND and _rust_loocv_grid_search_global is not None:
|
|
1128
1137
|
try:
|
|
1129
1138
|
# Prepare inputs for Rust function
|
|
1130
1139
|
control_mask_u8 = control_mask.astype(np.uint8)
|
|
@@ -1133,7 +1142,7 @@ class TROP:
|
|
|
1133
1142
|
lambda_unit_arr = np.array(self.lambda_unit_grid, dtype=np.float64)
|
|
1134
1143
|
lambda_nn_arr = np.array(self.lambda_nn_grid, dtype=np.float64)
|
|
1135
1144
|
|
|
1136
|
-
result =
|
|
1145
|
+
result = _rust_loocv_grid_search_global(
|
|
1137
1146
|
Y, D.astype(np.float64), control_mask_u8,
|
|
1138
1147
|
lambda_time_arr, lambda_unit_arr, lambda_nn_arr,
|
|
1139
1148
|
self.max_iter, self.tol,
|
|
@@ -1170,7 +1179,7 @@ class TROP:
|
|
|
1170
1179
|
except Exception as e:
|
|
1171
1180
|
# Fall back to Python implementation on error
|
|
1172
1181
|
logger.debug(
|
|
1173
|
-
"Rust LOOCV grid search (
|
|
1182
|
+
"Rust LOOCV grid search (global) failed, falling back to Python: %s", e
|
|
1174
1183
|
)
|
|
1175
1184
|
best_lambda = None
|
|
1176
1185
|
best_score = np.inf
|
|
@@ -1193,7 +1202,7 @@ class TROP:
|
|
|
1193
1202
|
ln = 1e10 if np.isinf(lambda_nn_val) else lambda_nn_val
|
|
1194
1203
|
|
|
1195
1204
|
try:
|
|
1196
|
-
score = self.
|
|
1205
|
+
score = self._loocv_score_global(
|
|
1197
1206
|
Y, D, control_obs, lt, lu, ln,
|
|
1198
1207
|
treated_periods, n_units, n_periods
|
|
1199
1208
|
)
|
|
@@ -1223,11 +1232,11 @@ class TROP:
|
|
|
1223
1232
|
lambda_nn = 1e10
|
|
1224
1233
|
|
|
1225
1234
|
# Compute final weights and fit
|
|
1226
|
-
delta = self.
|
|
1235
|
+
delta = self._compute_global_weights(
|
|
1227
1236
|
Y, D, lambda_time, lambda_unit, treated_periods, n_units, n_periods
|
|
1228
1237
|
)
|
|
1229
1238
|
|
|
1230
|
-
mu, alpha, beta, L = self.
|
|
1239
|
+
mu, alpha, beta, L = self._solve_global_model(Y, delta, lambda_nn)
|
|
1231
1240
|
|
|
1232
1241
|
# Post-hoc tau extraction (per paper Eq. 2)
|
|
1233
1242
|
att, treatment_effects, tau_values = self._extract_posthoc_tau(
|
|
@@ -1258,7 +1267,7 @@ class TROP:
|
|
|
1258
1267
|
# Bootstrap variance estimation
|
|
1259
1268
|
effective_lambda = (lambda_time, lambda_unit, lambda_nn)
|
|
1260
1269
|
|
|
1261
|
-
se, bootstrap_dist = self.
|
|
1270
|
+
se, bootstrap_dist = self._bootstrap_variance_global(
|
|
1262
1271
|
data, outcome, treatment, unit, time,
|
|
1263
1272
|
effective_lambda, treated_periods
|
|
1264
1273
|
)
|
|
@@ -1300,7 +1309,7 @@ class TROP:
|
|
|
1300
1309
|
self.is_fitted_ = True
|
|
1301
1310
|
return self.results_
|
|
1302
1311
|
|
|
1303
|
-
def
|
|
1312
|
+
def _bootstrap_variance_global(
|
|
1304
1313
|
self,
|
|
1305
1314
|
data: pd.DataFrame,
|
|
1306
1315
|
outcome: str,
|
|
@@ -1311,7 +1320,7 @@ class TROP:
|
|
|
1311
1320
|
treated_periods: int,
|
|
1312
1321
|
) -> Tuple[float, np.ndarray]:
|
|
1313
1322
|
"""
|
|
1314
|
-
Compute bootstrap standard error for
|
|
1323
|
+
Compute bootstrap standard error for global method.
|
|
1315
1324
|
|
|
1316
1325
|
Uses Rust backend when available for parallel bootstrap (5-15x speedup).
|
|
1317
1326
|
|
|
@@ -1340,7 +1349,7 @@ class TROP:
|
|
|
1340
1349
|
lambda_time, lambda_unit, lambda_nn = optimal_lambda
|
|
1341
1350
|
|
|
1342
1351
|
# Try Rust backend for parallel bootstrap (5-15x speedup)
|
|
1343
|
-
if HAS_RUST_BACKEND and
|
|
1352
|
+
if HAS_RUST_BACKEND and _rust_bootstrap_trop_variance_global is not None:
|
|
1344
1353
|
try:
|
|
1345
1354
|
# Create matrices for Rust function
|
|
1346
1355
|
all_units = sorted(data[unit].unique())
|
|
@@ -1359,7 +1368,7 @@ class TROP:
|
|
|
1359
1368
|
.values
|
|
1360
1369
|
)
|
|
1361
1370
|
|
|
1362
|
-
bootstrap_estimates, se =
|
|
1371
|
+
bootstrap_estimates, se = _rust_bootstrap_trop_variance_global(
|
|
1363
1372
|
Y, D,
|
|
1364
1373
|
lambda_time, lambda_unit, lambda_nn,
|
|
1365
1374
|
self.n_bootstrap, self.max_iter, self.tol,
|
|
@@ -1378,7 +1387,7 @@ class TROP:
|
|
|
1378
1387
|
|
|
1379
1388
|
except Exception as e:
|
|
1380
1389
|
logger.debug(
|
|
1381
|
-
"Rust bootstrap (
|
|
1390
|
+
"Rust bootstrap (global) failed, falling back to Python: %s", e
|
|
1382
1391
|
)
|
|
1383
1392
|
|
|
1384
1393
|
# Python fallback implementation
|
|
@@ -1419,7 +1428,7 @@ class TROP:
|
|
|
1419
1428
|
], ignore_index=True)
|
|
1420
1429
|
|
|
1421
1430
|
try:
|
|
1422
|
-
tau = self.
|
|
1431
|
+
tau = self._fit_global_with_fixed_lambda(
|
|
1423
1432
|
boot_data, outcome, treatment, unit, time,
|
|
1424
1433
|
optimal_lambda, treated_periods
|
|
1425
1434
|
)
|
|
@@ -1441,7 +1450,7 @@ class TROP:
|
|
|
1441
1450
|
se = np.std(bootstrap_estimates, ddof=1)
|
|
1442
1451
|
return float(se), bootstrap_estimates
|
|
1443
1452
|
|
|
1444
|
-
def
|
|
1453
|
+
def _fit_global_with_fixed_lambda(
|
|
1445
1454
|
self,
|
|
1446
1455
|
data: pd.DataFrame,
|
|
1447
1456
|
outcome: str,
|
|
@@ -1478,12 +1487,12 @@ class TROP:
|
|
|
1478
1487
|
)
|
|
1479
1488
|
|
|
1480
1489
|
# Compute weights (includes (1-W) masking)
|
|
1481
|
-
delta = self.
|
|
1490
|
+
delta = self._compute_global_weights(
|
|
1482
1491
|
Y, D, lambda_time, lambda_unit, treated_periods, n_units, n_periods
|
|
1483
1492
|
)
|
|
1484
1493
|
|
|
1485
1494
|
# Fit model on control data and extract post-hoc tau
|
|
1486
|
-
mu, alpha, beta, L = self.
|
|
1495
|
+
mu, alpha, beta, L = self._solve_global_model(Y, delta, lambda_nn)
|
|
1487
1496
|
att, _, _ = self._extract_posthoc_tau(Y, D, mu, alpha, beta, L)
|
|
1488
1497
|
return att
|
|
1489
1498
|
|
|
@@ -1541,9 +1550,9 @@ class TROP:
|
|
|
1541
1550
|
|
|
1542
1551
|
# Dispatch based on estimation method
|
|
1543
1552
|
if self.method == "global":
|
|
1544
|
-
return self.
|
|
1553
|
+
return self._fit_global(data, outcome, treatment, unit, time)
|
|
1545
1554
|
|
|
1546
|
-
# Below is the
|
|
1555
|
+
# Below is the local method (default)
|
|
1547
1556
|
# Get unique units and periods
|
|
1548
1557
|
all_units = sorted(data[unit].unique())
|
|
1549
1558
|
all_periods = sorted(data[time].unique())
|
|
@@ -2660,10 +2669,18 @@ class TROP:
|
|
|
2660
2669
|
def set_params(self, **params) -> "TROP":
|
|
2661
2670
|
"""Set estimator parameters."""
|
|
2662
2671
|
for key, value in params.items():
|
|
2672
|
+
if key == "method" and value == "twostep":
|
|
2673
|
+
warnings.warn(
|
|
2674
|
+
"method='twostep' is deprecated and will be removed in "
|
|
2675
|
+
"v3.0. Use method='local' instead.",
|
|
2676
|
+
FutureWarning,
|
|
2677
|
+
stacklevel=2,
|
|
2678
|
+
)
|
|
2679
|
+
value = "local"
|
|
2663
2680
|
if key == "method" and value == "joint":
|
|
2664
2681
|
warnings.warn(
|
|
2665
|
-
"method='joint' is deprecated and will be removed in
|
|
2666
|
-
"
|
|
2682
|
+
"method='joint' is deprecated and will be removed in "
|
|
2683
|
+
"v3.0. Use method='global' instead.",
|
|
2667
2684
|
FutureWarning,
|
|
2668
2685
|
stacklevel=2,
|
|
2669
2686
|
)
|
|
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "diff-diff"
|
|
7
|
-
version = "2.7.
|
|
8
|
-
description = "
|
|
7
|
+
version = "2.7.2"
|
|
8
|
+
description = "Difference-in-Differences causal inference with sklearn-like API. Callaway-Sant'Anna, Synthetic DiD, Honest DiD, event studies, parallel trends."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.9,<3.14"
|
|
@@ -18,6 +18,16 @@ keywords = [
|
|
|
18
18
|
"econometrics",
|
|
19
19
|
"statistics",
|
|
20
20
|
"treatment-effects",
|
|
21
|
+
"event-study",
|
|
22
|
+
"staggered-adoption",
|
|
23
|
+
"parallel-trends",
|
|
24
|
+
"synthetic-control",
|
|
25
|
+
"panel-data",
|
|
26
|
+
"did",
|
|
27
|
+
"twfe",
|
|
28
|
+
"callaway-santanna",
|
|
29
|
+
"honest-did",
|
|
30
|
+
"sensitivity-analysis",
|
|
21
31
|
]
|
|
22
32
|
classifiers = [
|
|
23
33
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -30,6 +40,8 @@ classifiers = [
|
|
|
30
40
|
"Programming Language :: Python :: 3.12",
|
|
31
41
|
"Programming Language :: Python :: 3.13",
|
|
32
42
|
"Topic :: Scientific/Engineering :: Mathematics",
|
|
43
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
44
|
+
"Topic :: Scientific/Engineering",
|
|
33
45
|
]
|
|
34
46
|
dependencies = [
|
|
35
47
|
"numpy>=1.20.0",
|
|
@@ -46,10 +58,15 @@ dev = [
|
|
|
46
58
|
"ruff>=0.1.0",
|
|
47
59
|
"mypy>=1.0",
|
|
48
60
|
"maturin>=1.4,<2.0",
|
|
61
|
+
"matplotlib>=3.5",
|
|
49
62
|
]
|
|
50
63
|
docs = [
|
|
51
64
|
"sphinx>=6.0",
|
|
52
|
-
"sphinx-
|
|
65
|
+
"pydata-sphinx-theme>=0.15",
|
|
66
|
+
"sphinxext-opengraph>=0.9",
|
|
67
|
+
"sphinx-sitemap>=2.5",
|
|
68
|
+
"nbsphinx>=0.9",
|
|
69
|
+
"matplotlib>=3.5",
|
|
53
70
|
]
|
|
54
71
|
|
|
55
72
|
[project.urls]
|
|
@@ -42,14 +42,14 @@ fn _rust_backend(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
42
42
|
m.add_function(wrap_pyfunction!(linalg::solve_ols, m)?)?;
|
|
43
43
|
m.add_function(wrap_pyfunction!(linalg::compute_robust_vcov, m)?)?;
|
|
44
44
|
|
|
45
|
-
// TROP estimator acceleration (
|
|
45
|
+
// TROP estimator acceleration (local method)
|
|
46
46
|
m.add_function(wrap_pyfunction!(trop::compute_unit_distance_matrix, m)?)?;
|
|
47
47
|
m.add_function(wrap_pyfunction!(trop::loocv_grid_search, m)?)?;
|
|
48
48
|
m.add_function(wrap_pyfunction!(trop::bootstrap_trop_variance, m)?)?;
|
|
49
49
|
|
|
50
|
-
// TROP estimator acceleration (
|
|
51
|
-
m.add_function(wrap_pyfunction!(trop::
|
|
52
|
-
m.add_function(wrap_pyfunction!(trop::
|
|
50
|
+
// TROP estimator acceleration (global method)
|
|
51
|
+
m.add_function(wrap_pyfunction!(trop::loocv_grid_search_global, m)?)?;
|
|
52
|
+
m.add_function(wrap_pyfunction!(trop::bootstrap_trop_variance_global, m)?)?;
|
|
53
53
|
|
|
54
54
|
// Diagnostics
|
|
55
55
|
m.add_function(wrap_pyfunction!(rust_backend_info, m)?)?;
|
|
@@ -1081,12 +1081,16 @@ pub fn bootstrap_trop_variance<'py>(
|
|
|
1081
1081
|
}
|
|
1082
1082
|
|
|
1083
1083
|
// ============================================================================
|
|
1084
|
-
//
|
|
1084
|
+
// Global method implementation
|
|
1085
|
+
//
|
|
1086
|
+
// Note: Only the #[pyfunction] exports were renamed (joint → global) to match
|
|
1087
|
+
// the Python public API. The private Rust helpers below retain their original
|
|
1088
|
+
// `*_joint*` names to keep the Rust-only rename scope minimal.
|
|
1085
1089
|
// ============================================================================
|
|
1086
1090
|
|
|
1087
|
-
/// Compute global weights for
|
|
1091
|
+
/// Compute global weights for global method estimation.
|
|
1088
1092
|
///
|
|
1089
|
-
/// Unlike
|
|
1093
|
+
/// Unlike local (which computes per-observation weights), global uses
|
|
1090
1094
|
/// weights based on:
|
|
1091
1095
|
/// - Time weights: distance to center of treated block
|
|
1092
1096
|
/// - Unit weights: RMSE to average treated trajectory over pre-periods
|
|
@@ -1196,7 +1200,7 @@ fn compute_joint_weights(
|
|
|
1196
1200
|
delta
|
|
1197
1201
|
}
|
|
1198
1202
|
|
|
1199
|
-
/// Solve
|
|
1203
|
+
/// Solve global TWFE via weighted least squares (no low-rank, no tau).
|
|
1200
1204
|
///
|
|
1201
1205
|
/// Minimizes: min Σ δ_{it}(Y_{it} - μ - α_i - β_t)²
|
|
1202
1206
|
///
|
|
@@ -1315,7 +1319,7 @@ fn solve_joint_no_lowrank(
|
|
|
1315
1319
|
Some((mu, alpha, beta))
|
|
1316
1320
|
}
|
|
1317
1321
|
|
|
1318
|
-
/// Solve
|
|
1322
|
+
/// Solve global TWFE + low-rank via alternating minimization (no tau).
|
|
1319
1323
|
///
|
|
1320
1324
|
/// Minimizes: min Σ δ_{it}(Y_{it} - μ - α_i - β_t - L_{it})² + λ_nn||L||_*
|
|
1321
1325
|
///
|
|
@@ -1422,12 +1426,12 @@ fn solve_joint_with_lowrank(
|
|
|
1422
1426
|
Some((mu, alpha, beta, l))
|
|
1423
1427
|
}
|
|
1424
1428
|
|
|
1425
|
-
/// Compute LOOCV score for
|
|
1429
|
+
/// Compute LOOCV score for global method with specific parameter combination.
|
|
1426
1430
|
///
|
|
1427
1431
|
/// Following paper's Equation 5:
|
|
1428
1432
|
/// Q(λ) = Σ_{j,s: D_js=0} [τ̂_js^loocv(λ)]²
|
|
1429
1433
|
///
|
|
1430
|
-
/// For
|
|
1434
|
+
/// For global method, we exclude each control observation, fit the global model
|
|
1431
1435
|
/// on remaining data, and compute the pseudo-treatment effect at the excluded obs.
|
|
1432
1436
|
///
|
|
1433
1437
|
/// # Returns
|
|
@@ -1502,7 +1506,7 @@ fn loocv_score_joint(
|
|
|
1502
1506
|
}
|
|
1503
1507
|
}
|
|
1504
1508
|
|
|
1505
|
-
/// Perform LOOCV grid search for
|
|
1509
|
+
/// Perform LOOCV grid search for global method using parallel grid search.
|
|
1506
1510
|
///
|
|
1507
1511
|
/// Evaluates all combinations of (lambda_time, lambda_unit, lambda_nn) in parallel
|
|
1508
1512
|
/// and returns the combination with lowest LOOCV score.
|
|
@@ -1522,7 +1526,7 @@ fn loocv_score_joint(
|
|
|
1522
1526
|
#[pyfunction]
|
|
1523
1527
|
#[pyo3(signature = (y, d, control_mask, lambda_time_grid, lambda_unit_grid, lambda_nn_grid, max_iter, tol))]
|
|
1524
1528
|
#[allow(clippy::too_many_arguments)]
|
|
1525
|
-
pub fn
|
|
1529
|
+
pub fn loocv_grid_search_global<'py>(
|
|
1526
1530
|
_py: Python<'py>,
|
|
1527
1531
|
y: PyReadonlyArray2<'py, f64>,
|
|
1528
1532
|
d: PyReadonlyArray2<'py, f64>,
|
|
@@ -1630,7 +1634,7 @@ pub fn loocv_grid_search_joint<'py>(
|
|
|
1630
1634
|
Ok((best_lt, best_lu, best_ln, best_score, n_valid, n_attempted, first_failed))
|
|
1631
1635
|
}
|
|
1632
1636
|
|
|
1633
|
-
/// Compute bootstrap variance estimation for TROP
|
|
1637
|
+
/// Compute bootstrap variance estimation for TROP global method in parallel.
|
|
1634
1638
|
///
|
|
1635
1639
|
/// Performs unit-level block bootstrap, parallelizing across bootstrap iterations.
|
|
1636
1640
|
/// Uses stratified sampling to preserve treated/control unit ratio.
|
|
@@ -1651,7 +1655,7 @@ pub fn loocv_grid_search_joint<'py>(
|
|
|
1651
1655
|
#[pyfunction]
|
|
1652
1656
|
#[pyo3(signature = (y, d, lambda_time, lambda_unit, lambda_nn, n_bootstrap, max_iter, tol, seed))]
|
|
1653
1657
|
#[allow(clippy::too_many_arguments)]
|
|
1654
|
-
pub fn
|
|
1658
|
+
pub fn bootstrap_trop_variance_global<'py>(
|
|
1655
1659
|
py: Python<'py>,
|
|
1656
1660
|
y: PyReadonlyArray2<'py, f64>,
|
|
1657
1661
|
d: PyReadonlyArray2<'py, f64>,
|
|
@@ -1737,7 +1741,7 @@ pub fn bootstrap_trop_variance_joint<'py>(
|
|
|
1737
1741
|
}
|
|
1738
1742
|
}
|
|
1739
1743
|
|
|
1740
|
-
// Compute weights and fit
|
|
1744
|
+
// Compute weights and fit global model
|
|
1741
1745
|
let delta = compute_joint_weights(
|
|
1742
1746
|
&y_boot.view(),
|
|
1743
1747
|
&d_boot.view(),
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|