diffindiff 2.5.7__tar.gz → 2.5.9__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.
- {diffindiff-2.5.7 → diffindiff-2.5.9}/PKG-INFO +7 -7
- {diffindiff-2.5.7 → diffindiff-2.5.9}/README.md +6 -6
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/config.py +3 -3
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/didanalysis.py +115 -59
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/didanalysis_helper.py +6 -6
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/diddata.py +32 -21
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/didtools.py +55 -31
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/tests_diffindiff.py +2 -3
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/PKG-INFO +7 -7
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/SOURCES.txt +2 -1
- {diffindiff-2.5.7 → diffindiff-2.5.9}/setup.py +1 -1
- diffindiff-2.5.9/tests/test_diffindiff.py +257 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/MANIFEST.in +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/__init__.py +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/__init__.py +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/data/Corona_Hesse.xlsx +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/data/counties_DE.csv +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/data/curfew_DE.csv +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/dependency_links.txt +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/requires.txt +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/top_level.txt +0 -0
- {diffindiff-2.5.7 → diffindiff-2.5.9}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diffindiff
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.9
|
|
4
4
|
Summary: diffindiff: Python library for convenient Difference-in-Differences analyses
|
|
5
5
|
Author: Thomas Wieland
|
|
6
6
|
Author-email: geowieland@googlemail.com
|
|
@@ -40,14 +40,14 @@ Thomas Wieland [ORCID](https://orcid.org/0000-0001-5168-9846) [EMail](mailto:geo
|
|
|
40
40
|
- 💻 GitHub Repository: [diffindiff_official](https://github.com/geowieland/diffindiff_official)
|
|
41
41
|
- 📄 DOI (Zenodo): [10.5281/zenodo.18656820](https://doi.org/10.5281/zenodo.18656820)
|
|
42
42
|
|
|
43
|
-
A case study that utilizes the diffindiff library is available on [arXiv](https://arxiv.org/abs/2605.21464).
|
|
43
|
+
A case study that utilizes the diffindiff library (preprint) is available on [arXiv](https://arxiv.org/abs/2605.21464).
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
## Citation
|
|
47
47
|
|
|
48
48
|
If you use this software, please cite:
|
|
49
49
|
|
|
50
|
-
Wieland, T. (2026). diffindiff: A Python library for convenient difference-in-differences analyses (Version 2.5.
|
|
50
|
+
Wieland, T. (2026). diffindiff: A Python library for convenient difference-in-differences analyses (Version 2.5.9) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.18656820
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
## Installation
|
|
@@ -198,9 +198,9 @@ See the /tests directory for usage examples of most of the included functions.
|
|
|
198
198
|
This software was developed without the use of AI-generated code. The GitHub Copilot Chat in Microsoft Visual Studio Code using the GPT-5 mini model (by OpenAI) was used solely to assist in drafting and refining docstrings for documentation. The corresponding guidelines and constraints defined by the author are documented in `AGENTS-docstrings.md` in the [public GitHub repository](https://github.com/geowieland/diffindiff_official).
|
|
199
199
|
|
|
200
200
|
|
|
201
|
-
## What's new (v2.5.
|
|
201
|
+
## What's new (v2.5.9)
|
|
202
202
|
|
|
203
203
|
- Bugfixes
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
204
|
+
- Adjusting column naming to be compatible with newer pandas versions in `didanalysis.DiffModel.plot()`
|
|
205
|
+
- Check in `didtools.fit_metrics()` whether total sum of squares is positive before calculating $$R^2$$
|
|
206
|
+
- Incorrect access to counterfactual results in the event of model fit errors or model unavailability has been fixed in `diddata.create_counterfactual()`
|
|
@@ -14,14 +14,14 @@ Thomas Wieland [ORCID](https://orcid.org/0000-0001-5168-9846) [EMail](mailto:geo
|
|
|
14
14
|
- 💻 GitHub Repository: [diffindiff_official](https://github.com/geowieland/diffindiff_official)
|
|
15
15
|
- 📄 DOI (Zenodo): [10.5281/zenodo.18656820](https://doi.org/10.5281/zenodo.18656820)
|
|
16
16
|
|
|
17
|
-
A case study that utilizes the diffindiff library is available on [arXiv](https://arxiv.org/abs/2605.21464).
|
|
17
|
+
A case study that utilizes the diffindiff library (preprint) is available on [arXiv](https://arxiv.org/abs/2605.21464).
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
## Citation
|
|
21
21
|
|
|
22
22
|
If you use this software, please cite:
|
|
23
23
|
|
|
24
|
-
Wieland, T. (2026). diffindiff: A Python library for convenient difference-in-differences analyses (Version 2.5.
|
|
24
|
+
Wieland, T. (2026). diffindiff: A Python library for convenient difference-in-differences analyses (Version 2.5.9) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.18656820
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
## Installation
|
|
@@ -172,9 +172,9 @@ See the /tests directory for usage examples of most of the included functions.
|
|
|
172
172
|
This software was developed without the use of AI-generated code. The GitHub Copilot Chat in Microsoft Visual Studio Code using the GPT-5 mini model (by OpenAI) was used solely to assist in drafting and refining docstrings for documentation. The corresponding guidelines and constraints defined by the author are documented in `AGENTS-docstrings.md` in the [public GitHub repository](https://github.com/geowieland/diffindiff_official).
|
|
173
173
|
|
|
174
174
|
|
|
175
|
-
## What's new (v2.5.
|
|
175
|
+
## What's new (v2.5.9)
|
|
176
176
|
|
|
177
177
|
- Bugfixes
|
|
178
|
-
-
|
|
179
|
-
-
|
|
180
|
-
-
|
|
178
|
+
- Adjusting column naming to be compatible with newer pandas versions in `didanalysis.DiffModel.plot()`
|
|
179
|
+
- Check in `didtools.fit_metrics()` whether total sum of squares is positive before calculating $$R^2$$
|
|
180
|
+
- Incorrect access to counterfactual results in the event of model fit errors or model unavailability has been fixed in `diddata.create_counterfactual()`
|
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
# Author: Thomas Wieland
|
|
5
5
|
# ORCID: 0000-0001-5168-9846
|
|
6
6
|
# mail: geowieland@googlemail.com
|
|
7
|
-
# Version: 1.0.
|
|
8
|
-
# Last update: 2026-10-
|
|
7
|
+
# Version: 1.0.30
|
|
8
|
+
# Last update: 2026-10-24 09:29
|
|
9
9
|
# Copyright (c) 2025-2026 Thomas Wieland
|
|
10
10
|
#-----------------------------------------------------------------------
|
|
11
11
|
|
|
12
12
|
# Basic config:
|
|
13
13
|
|
|
14
14
|
PACKAGE_NAME = "diffindiff"
|
|
15
|
-
PACKAGE_VERSION = "2.5.
|
|
15
|
+
PACKAGE_VERSION = "2.5.9"
|
|
16
16
|
|
|
17
17
|
VERBOSE = False
|
|
18
18
|
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# Author: Thomas Wieland
|
|
5
5
|
# ORCID: 0000-0001-5168-9846
|
|
6
6
|
# mail: geowieland@googlemail.com
|
|
7
|
-
# Version: 2.4.
|
|
8
|
-
# Last update: 2026-
|
|
7
|
+
# Version: 2.4.9
|
|
8
|
+
# Last update: 2026-10-24 09:30
|
|
9
9
|
# Copyright (c) 2024-2026 Thomas Wieland
|
|
10
10
|
#-----------------------------------------------------------------------
|
|
11
11
|
|
|
@@ -111,7 +111,8 @@ class DiffModel:
|
|
|
111
111
|
def treatment_statistics(
|
|
112
112
|
self,
|
|
113
113
|
treatment: str = None,
|
|
114
|
-
after_treatment_col: str = None
|
|
114
|
+
after_treatment_col: str = None,
|
|
115
|
+
verbose: bool = False
|
|
115
116
|
):
|
|
116
117
|
|
|
117
118
|
"""
|
|
@@ -163,7 +164,9 @@ class DiffModel:
|
|
|
163
164
|
else:
|
|
164
165
|
|
|
165
166
|
treatment = model_config["treatment_col"][0]
|
|
166
|
-
|
|
167
|
+
|
|
168
|
+
if verbose:
|
|
169
|
+
print(f"NOTE: No treatment was stated. Choosing treatment '{treatment}' for calculating treatment statistics.")
|
|
167
170
|
|
|
168
171
|
if after_treatment_col is not None:
|
|
169
172
|
|
|
@@ -782,9 +785,9 @@ class DiffModel:
|
|
|
782
785
|
|
|
783
786
|
if len(no_control_conditions) > 0:
|
|
784
787
|
if len(no_control_conditions) == 1:
|
|
785
|
-
print(f"
|
|
788
|
+
print(f"WARNING: Treatment '{no_control_conditions[0]}' has no control conditions.")
|
|
786
789
|
else:
|
|
787
|
-
print(f"
|
|
790
|
+
print(f"WARNING: Treatments {', '.join(no_control_conditions)} have no control conditions.")
|
|
788
791
|
|
|
789
792
|
print("-" * int(total_width))
|
|
790
793
|
|
|
@@ -905,6 +908,7 @@ class DiffModel:
|
|
|
905
908
|
treatment_effects = self.treatment_effects()
|
|
906
909
|
|
|
907
910
|
try:
|
|
911
|
+
|
|
908
912
|
if model_config.get("pre_post", False) and len(model_config.get("after_treatment_col", [])) > 0:
|
|
909
913
|
|
|
910
914
|
labels = treatment_effects.iloc[:, 0].astype(str).values
|
|
@@ -943,9 +947,11 @@ class DiffModel:
|
|
|
943
947
|
break
|
|
944
948
|
if appended:
|
|
945
949
|
break
|
|
946
|
-
except Exception:
|
|
950
|
+
except Exception as e:
|
|
947
951
|
|
|
948
|
-
|
|
952
|
+
print(f"Plot of treatment effects not possible: {str(e)}")
|
|
953
|
+
|
|
954
|
+
return self
|
|
949
955
|
|
|
950
956
|
if sort_by_coef:
|
|
951
957
|
treatment_effects = treatment_effects.sort_values(
|
|
@@ -1099,7 +1105,8 @@ class DiffModel:
|
|
|
1099
1105
|
self,
|
|
1100
1106
|
treatment = None,
|
|
1101
1107
|
after_treatment_col: str = None,
|
|
1102
|
-
retransform_log_outcome: bool = False
|
|
1108
|
+
retransform_log_outcome: bool = False,
|
|
1109
|
+
verbose: bool = False
|
|
1103
1110
|
):
|
|
1104
1111
|
|
|
1105
1112
|
"""
|
|
@@ -1153,8 +1160,11 @@ class DiffModel:
|
|
|
1153
1160
|
if treatment not in model_config["treatment_col"]:
|
|
1154
1161
|
raise ValueError (f"Treatment '{treatment}' not in model object.")
|
|
1155
1162
|
else:
|
|
1163
|
+
|
|
1156
1164
|
treatment = model_config["treatment_col"][0]
|
|
1157
|
-
|
|
1165
|
+
|
|
1166
|
+
if verbose:
|
|
1167
|
+
print(f"NOTE: No treatment was stated. Choosing treatment '{treatment}' for calculating counterfactual.")
|
|
1158
1168
|
|
|
1159
1169
|
if after_treatment_col is not None:
|
|
1160
1170
|
if len(model_config["after_treatment_col"]) == 0:
|
|
@@ -1313,7 +1323,8 @@ class DiffModel:
|
|
|
1313
1323
|
TT_col: str = None,
|
|
1314
1324
|
divide: float = 0.5,
|
|
1315
1325
|
resample: float = 1.0,
|
|
1316
|
-
random_state = 71
|
|
1326
|
+
random_state = 71,
|
|
1327
|
+
verbose: bool = False
|
|
1317
1328
|
):
|
|
1318
1329
|
|
|
1319
1330
|
"""
|
|
@@ -1369,8 +1380,11 @@ class DiffModel:
|
|
|
1369
1380
|
if treatment not in model_config["treatment_col"]:
|
|
1370
1381
|
raise ValueError (f"Treatment '{treatment}' not in model object.")
|
|
1371
1382
|
else:
|
|
1383
|
+
|
|
1372
1384
|
treatment = model_config["treatment_col"][0]
|
|
1373
|
-
|
|
1385
|
+
|
|
1386
|
+
if verbose:
|
|
1387
|
+
print(f"NOTE: No treatment was stated. Choosing treatment '{treatment}' for placebo analysis.")
|
|
1374
1388
|
|
|
1375
1389
|
if after_treatment_col is not None:
|
|
1376
1390
|
if len(model_config["after_treatment_col"]) == 0:
|
|
@@ -1390,7 +1404,8 @@ class DiffModel:
|
|
|
1390
1404
|
treatment_demeaned = treatment
|
|
1391
1405
|
treatment = treatment.replace(f"{config.DELIMITER}{config.DEMEAN_SUFFIX}", "")
|
|
1392
1406
|
|
|
1393
|
-
|
|
1407
|
+
if verbose:
|
|
1408
|
+
print(f"NOTE: Data is demeaned. Treatment statistics are calculated based on original treatment '{treatment}'.")
|
|
1394
1409
|
|
|
1395
1410
|
treatment_statistics = self.treatment_statistics(treatment = treatment)
|
|
1396
1411
|
|
|
@@ -1484,7 +1499,8 @@ class DiffModel:
|
|
|
1484
1499
|
plot_symbol: str = "o",
|
|
1485
1500
|
treatment_group_only: bool = True,
|
|
1486
1501
|
save_fig: str = None,
|
|
1487
|
-
save_fig_kwargs: dict = {}
|
|
1502
|
+
save_fig_kwargs: dict = {},
|
|
1503
|
+
verbose: bool = False
|
|
1488
1504
|
):
|
|
1489
1505
|
|
|
1490
1506
|
"""
|
|
@@ -1542,8 +1558,11 @@ class DiffModel:
|
|
|
1542
1558
|
if treatment not in model_config["treatment_col"]:
|
|
1543
1559
|
raise ValueError (f"Treatment '{treatment}' not in model object.")
|
|
1544
1560
|
else:
|
|
1561
|
+
|
|
1545
1562
|
treatment = model_config["treatment_col"][0]
|
|
1546
|
-
|
|
1563
|
+
|
|
1564
|
+
if verbose:
|
|
1565
|
+
print(f"NOTE: No treatment was stated. Choosing treatment '{treatment}' for plotting timeline.")
|
|
1547
1566
|
|
|
1548
1567
|
if treatment_group_only:
|
|
1549
1568
|
if TG_col is None:
|
|
@@ -1615,7 +1634,8 @@ class DiffModel:
|
|
|
1615
1634
|
pre_post_bar_width = 0.5,
|
|
1616
1635
|
retransform_log_outcome: bool = False,
|
|
1617
1636
|
save_fig: str = None,
|
|
1618
|
-
save_fig_kwargs: dict = {}
|
|
1637
|
+
save_fig_kwargs: dict = {},
|
|
1638
|
+
verbose: bool = False
|
|
1619
1639
|
):
|
|
1620
1640
|
|
|
1621
1641
|
"""
|
|
@@ -1723,7 +1743,8 @@ class DiffModel:
|
|
|
1723
1743
|
break
|
|
1724
1744
|
else:
|
|
1725
1745
|
|
|
1726
|
-
|
|
1746
|
+
if verbose:
|
|
1747
|
+
print(f"NOTE: No treatment was stated. Choosing treatment '{treatment_diagnostics[0]['treatment']}' for plotting.")
|
|
1727
1748
|
|
|
1728
1749
|
treatment_group = treatment_diagnostics[0]["treatment_group"]
|
|
1729
1750
|
treatment = treatment_diagnostics[0]["treatment"]
|
|
@@ -1783,13 +1804,15 @@ class DiffModel:
|
|
|
1783
1804
|
model_data[config.PREDICTIONS_SUMMARY_FRAME_COLS_LIST[4]] = np.exp(model_data[config.PREDICTIONS_SUMMARY_FRAME_COLS_LIST[4]])
|
|
1784
1805
|
model_data[config.PREDICTIONS_SUMMARY_FRAME_COLS_LIST[5]] = np.exp(model_data[config.PREDICTIONS_SUMMARY_FRAME_COLS_LIST[5]])
|
|
1785
1806
|
|
|
1786
|
-
if
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1807
|
+
if verbose:
|
|
1808
|
+
if model_config["demean"]:
|
|
1809
|
+
print(f"NOTE: Outcome variable '{outcome_col.replace(f'{config.DELIMITER}{config.DEMEAN_SUFFIX}', '')}' was log-transformed and demeaned. Re-transformation was applied on '{outcome_col}'.")
|
|
1810
|
+
else:
|
|
1811
|
+
print(f"NOTE: Outcome variable '{outcome_col}' was log-transformed. Re-transformation was applied.")
|
|
1790
1812
|
|
|
1791
1813
|
else:
|
|
1792
|
-
|
|
1814
|
+
if verbose:
|
|
1815
|
+
print("NOTE: Parameter 'retransform_log_outcome' was set to True, but outcome variable was not log-transformed. No re-transformation applied.")
|
|
1793
1816
|
|
|
1794
1817
|
model_data_TG = model_data[model_data[TG_col] == 1]
|
|
1795
1818
|
model_data_CG = model_data[model_data[TG_col] == 0]
|
|
@@ -1854,19 +1877,21 @@ class DiffModel:
|
|
|
1854
1877
|
outcome_col_expected_PI_lower_CG = f"{outcome_col}{config.DELIMITER}{config.EXPECTED_SUFFIX}{config.DELIMITER}{config.PI_LOWER_SUFFIX}{config.DELIMITER}{config.CG_COL}"
|
|
1855
1878
|
outcome_col_expected_PI_upper_CG = f"{outcome_col}{config.DELIMITER}{config.EXPECTED_SUFFIX}{config.DELIMITER}{config.PI_UPPER_SUFFIX}{config.DELIMITER}{config.CG_COL}"
|
|
1856
1879
|
|
|
1857
|
-
model_data_TG_CG.columns
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1880
|
+
model_data_TG_CG.columns = [
|
|
1881
|
+
"t",
|
|
1882
|
+
outcome_col_observed_TG,
|
|
1883
|
+
outcome_col_observed_CG,
|
|
1884
|
+
outcome_col_expected_TG,
|
|
1885
|
+
outcome_col_expected_CG,
|
|
1886
|
+
outcome_col_expected_CI_lower_TG,
|
|
1887
|
+
outcome_col_expected_CI_upper_TG,
|
|
1888
|
+
outcome_col_expected_CI_lower_CG,
|
|
1889
|
+
outcome_col_expected_CI_upper_CG,
|
|
1890
|
+
outcome_col_expected_PI_lower_TG,
|
|
1891
|
+
outcome_col_expected_PI_upper_TG,
|
|
1892
|
+
outcome_col_expected_PI_lower_CG,
|
|
1893
|
+
outcome_col_expected_PI_upper_CG,
|
|
1894
|
+
]
|
|
1870
1895
|
|
|
1871
1896
|
if plot_size_auto:
|
|
1872
1897
|
if model_config["pre_post"]:
|
|
@@ -2096,7 +2121,8 @@ class DiffModel:
|
|
|
2096
2121
|
plot_size: list = [12, 6],
|
|
2097
2122
|
retransform_log_outcome: bool = False,
|
|
2098
2123
|
save_fig: str = None,
|
|
2099
|
-
save_fig_kwargs: dict = {}
|
|
2124
|
+
save_fig_kwargs: dict = {},
|
|
2125
|
+
verbose: bool = False
|
|
2100
2126
|
):
|
|
2101
2127
|
|
|
2102
2128
|
"""
|
|
@@ -2199,7 +2225,9 @@ class DiffModel:
|
|
|
2199
2225
|
break
|
|
2200
2226
|
|
|
2201
2227
|
else:
|
|
2202
|
-
|
|
2228
|
+
|
|
2229
|
+
if verbose:
|
|
2230
|
+
print(f"NOTE: No treatment was stated. Choosing treatment '{treatment_diagnostics[0]['treatment']}' for plotting counterfactual.")
|
|
2203
2231
|
|
|
2204
2232
|
treatment_group = treatment_diagnostics[0]["treatment_group"]
|
|
2205
2233
|
|
|
@@ -2527,8 +2555,11 @@ def did_analysis(
|
|
|
2527
2555
|
TG_col = []
|
|
2528
2556
|
|
|
2529
2557
|
if not FE_unit and config.AUTO_SWITCH_TO_FIXED_EFFECTS:
|
|
2558
|
+
|
|
2530
2559
|
FE_unit = True
|
|
2531
|
-
|
|
2560
|
+
|
|
2561
|
+
if verbose:
|
|
2562
|
+
print(f"NOTE: Quasi-experiment includes more than one treatment ({no_treatments}). Unit fixed effects are used instead of control group baseline and treatment group deviation.")
|
|
2532
2563
|
|
|
2533
2564
|
if unique_units < config.FIXED_EFFECTS_THRESHOLD:
|
|
2534
2565
|
|
|
@@ -2538,7 +2569,8 @@ def did_analysis(
|
|
|
2538
2569
|
ITT = False
|
|
2539
2570
|
intercept = True
|
|
2540
2571
|
|
|
2541
|
-
|
|
2572
|
+
if verbose:
|
|
2573
|
+
print(f"NOTE: Quasi-experiment includes less than {config.FIXED_EFFECTS_THRESHOLD} units. Unit or group fixed effects are skipped.")
|
|
2542
2574
|
|
|
2543
2575
|
if unique_time_points < config.FIXED_EFFECTS_THRESHOLD:
|
|
2544
2576
|
|
|
@@ -2547,7 +2579,8 @@ def did_analysis(
|
|
|
2547
2579
|
GTT = False
|
|
2548
2580
|
intercept = True
|
|
2549
2581
|
|
|
2550
|
-
|
|
2582
|
+
if verbose:
|
|
2583
|
+
print(f"NOTE: Quasi-experiment includes less than {config.FIXED_EFFECTS_THRESHOLD} time points. Time fixed effects are skipped.")
|
|
2551
2584
|
|
|
2552
2585
|
if demean and FE_unit:
|
|
2553
2586
|
|
|
@@ -2556,38 +2589,56 @@ def did_analysis(
|
|
|
2556
2589
|
ITE = False
|
|
2557
2590
|
ITT = False
|
|
2558
2591
|
|
|
2559
|
-
|
|
2592
|
+
if verbose:
|
|
2593
|
+
print(f"NOTE: Unit fixed effects are specified to be replaced by demeaning. {config.EFFECTS_TYPES['ITE']['description']} or {config.EFFECTS_TYPES['ITT']['description']} are not supported.")
|
|
2560
2594
|
|
|
2561
2595
|
if ITE:
|
|
2562
2596
|
|
|
2563
2597
|
if not FE_unit:
|
|
2598
|
+
|
|
2564
2599
|
FE_unit = True
|
|
2565
|
-
|
|
2600
|
+
|
|
2601
|
+
if verbose:
|
|
2602
|
+
print("NOTE: Model includes individual treatment effects. Unit fixed effects are included.")
|
|
2566
2603
|
|
|
2567
2604
|
if GTE:
|
|
2605
|
+
|
|
2568
2606
|
GTE = False
|
|
2569
|
-
|
|
2607
|
+
|
|
2608
|
+
if verbose:
|
|
2609
|
+
print("NOTE: Both group and individual treatment effects were stated. Switching to individual treatment effects only.")
|
|
2570
2610
|
|
|
2571
2611
|
if ITT:
|
|
2572
2612
|
|
|
2573
2613
|
TT_col = []
|
|
2574
2614
|
|
|
2575
2615
|
if not FE_unit:
|
|
2616
|
+
|
|
2576
2617
|
FE_unit = True
|
|
2577
|
-
|
|
2618
|
+
|
|
2619
|
+
if verbose:
|
|
2620
|
+
print("NOTE: Model includes individual time trends. Unit fixed effects are included. Treatment time variable is dropped.")
|
|
2578
2621
|
|
|
2579
2622
|
if FE_time:
|
|
2623
|
+
|
|
2580
2624
|
FE_time = False
|
|
2581
|
-
|
|
2625
|
+
|
|
2626
|
+
if verbose:
|
|
2627
|
+
print("NOTE: Model includes individual time trends. Time fixed effects are dropped.")
|
|
2582
2628
|
|
|
2583
2629
|
if GTT:
|
|
2630
|
+
|
|
2584
2631
|
GTT = False
|
|
2585
|
-
|
|
2632
|
+
|
|
2633
|
+
if verbose:
|
|
2634
|
+
print("NOTE: Both group and individual time trends were stated. Switching to individual time trends only.")
|
|
2586
2635
|
|
|
2587
2636
|
if staggered_adoption and config.AUTO_SWITCH_TO_FIXED_EFFECTS:
|
|
2588
2637
|
|
|
2589
2638
|
if not FE_unit or not FE_time:
|
|
2590
|
-
|
|
2639
|
+
|
|
2640
|
+
if verbose:
|
|
2641
|
+
print("NOTE: Quasi-experiment includes one or more staggered treatments. Two-way fixed effects model is used.")
|
|
2591
2642
|
|
|
2592
2643
|
FE_unit = True
|
|
2593
2644
|
FE_time = True
|
|
@@ -2604,7 +2655,9 @@ def did_analysis(
|
|
|
2604
2655
|
if FE_group:
|
|
2605
2656
|
|
|
2606
2657
|
if intercept or len(TG_col) > 0:
|
|
2607
|
-
|
|
2658
|
+
|
|
2659
|
+
if verbose:
|
|
2660
|
+
print("NOTE: Quasi-experiment includes group fixed effects. Control group baseline and treatment group deviation are dropped.")
|
|
2608
2661
|
|
|
2609
2662
|
TG_col = []
|
|
2610
2663
|
intercept = False
|
|
@@ -2716,13 +2769,14 @@ def did_analysis(
|
|
|
2716
2769
|
|
|
2717
2770
|
if config.AUTO_SKIP_CONSTANT_COLUMNS:
|
|
2718
2771
|
|
|
2719
|
-
print(f"
|
|
2772
|
+
print(f"WARNING: The following columns are constant and dropped from the analysis: {', '.join(data_diagnostics['cols_constants'])}.")
|
|
2720
2773
|
|
|
2721
2774
|
covariates = [col for col in covariates if col not in data_diagnostics["cols_constants"]]
|
|
2722
2775
|
|
|
2723
2776
|
if data_diagnostics["is_prepost"] and config.AUTO_SWITCH_TO_PREPOST:
|
|
2724
2777
|
|
|
2725
|
-
|
|
2778
|
+
if verbose:
|
|
2779
|
+
print(f"NOTE: Input is {config.PREPOST_PANELDATA_DESCRIPTION}. Data processing and model estimation will treat data as pre-post.")
|
|
2726
2780
|
|
|
2727
2781
|
pre_post = True
|
|
2728
2782
|
|
|
@@ -2730,8 +2784,8 @@ def did_analysis(
|
|
|
2730
2784
|
FE_time = False
|
|
2731
2785
|
FE_group = False
|
|
2732
2786
|
|
|
2733
|
-
if not data_diagnostics["is_prepost"] and not FE_unit and not FE_time:
|
|
2734
|
-
print(f"
|
|
2787
|
+
if not data_diagnostics["is_prepost"] and not FE_unit and not FE_time:
|
|
2788
|
+
print(f"WARNING: Input is {config.MULTIPERIOD_PANELDATA_DESCRIPTION}. Consider including {config.EFFECTS_TYPES['FE']['description']}.")
|
|
2735
2789
|
|
|
2736
2790
|
if log_outcome:
|
|
2737
2791
|
|
|
@@ -2999,9 +3053,8 @@ def did_analysis(
|
|
|
2999
3053
|
indep_vars_no = indep_vars_no-(len(FE_unit_vars)+len(FE_time_vars))
|
|
3000
3054
|
|
|
3001
3055
|
if verbose:
|
|
3002
|
-
print("OK")
|
|
3003
|
-
|
|
3004
|
-
print(f"NOTE: Outcome variable '{outcome_col}' is replaced by demeaned variable '{outcome_col_demeaned}', and {len(treatment_col)+len(covariates)+len(spillover_vars)+len(interactions)} other variables were remeaned.")
|
|
3056
|
+
print("OK")
|
|
3057
|
+
print(f"NOTE: Outcome variable '{outcome_col}' is replaced by demeaned variable '{outcome_col_demeaned}', and {len(treatment_col)+len(covariates)+len(spillover_vars)+len(interactions)} other variables were remeaned.")
|
|
3005
3058
|
|
|
3006
3059
|
outcome_col = outcome_col_demeaned
|
|
3007
3060
|
|
|
@@ -3283,13 +3336,15 @@ def ddd_analysis(
|
|
|
3283
3336
|
|
|
3284
3337
|
FE_unit = False
|
|
3285
3338
|
|
|
3286
|
-
|
|
3339
|
+
if verbose:
|
|
3340
|
+
print(f"NOTE: Quasi-experiment includes less than {config.FIXED_EFFECTS_THRESHOLD} units. Unit fixed effects are skipped.")
|
|
3287
3341
|
|
|
3288
3342
|
if unique_time_points < config.FIXED_EFFECTS_THRESHOLD:
|
|
3289
3343
|
|
|
3290
3344
|
FE_time = False
|
|
3291
3345
|
|
|
3292
|
-
|
|
3346
|
+
if verbose:
|
|
3347
|
+
print(f"NOTE: Quasi-experiment includes less than {config.FIXED_EFFECTS_THRESHOLD} time points. Time fixed effects are skipped.")
|
|
3293
3348
|
|
|
3294
3349
|
if FE_unit:
|
|
3295
3350
|
TG_col = None
|
|
@@ -3320,13 +3375,14 @@ def ddd_analysis(
|
|
|
3320
3375
|
|
|
3321
3376
|
if config.AUTO_SKIP_CONSTANT_COLUMNS:
|
|
3322
3377
|
|
|
3323
|
-
print(f"
|
|
3378
|
+
print(f"WARNING: The following columns are constant and dropped from the analysis: {', '.join(data_diagnostics['cols_constants'])}.")
|
|
3324
3379
|
|
|
3325
3380
|
covariates = [col for col in covariates if col not in data_diagnostics["cols_constants"]]
|
|
3326
3381
|
|
|
3327
3382
|
if data_diagnostics["is_prepost"] and config.AUTO_SWITCH_TO_PREPOST:
|
|
3328
3383
|
|
|
3329
|
-
|
|
3384
|
+
if verbose:
|
|
3385
|
+
print("NOTE: Panel data is pre-post. Data processing and model estimation will treat data as pre-post")
|
|
3330
3386
|
|
|
3331
3387
|
pre_post = True
|
|
3332
3388
|
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# Author: Thomas Wieland
|
|
5
5
|
# ORCID: 0000-0001-5168-9846
|
|
6
6
|
# mail: geowieland@googlemail.com
|
|
7
|
-
# Version: 1.2.
|
|
8
|
-
# Last update: 2026-10-
|
|
7
|
+
# Version: 1.2.6
|
|
8
|
+
# Last update: 2026-10-17 18:55
|
|
9
9
|
# Copyright (c) 2025-2026 Thomas Wieland
|
|
10
10
|
#-----------------------------------------------------------------------
|
|
11
11
|
|
|
@@ -231,8 +231,8 @@ def demean_variables(
|
|
|
231
231
|
suffix = f"{config.DELIMITER}{config.DEMEAN_SUFFIX}"
|
|
232
232
|
|
|
233
233
|
if not FE_unit and not FE_time:
|
|
234
|
-
|
|
235
|
-
print("
|
|
234
|
+
|
|
235
|
+
print("WARNING: Neither 'FE_unit' nor 'FE_time' is set to True. No demeaning will be performed.")
|
|
236
236
|
|
|
237
237
|
for col in cols_to_demean:
|
|
238
238
|
|
|
@@ -258,13 +258,13 @@ def demean_variables(
|
|
|
258
258
|
else:
|
|
259
259
|
|
|
260
260
|
tools.check_columns(
|
|
261
|
-
df =
|
|
261
|
+
df = data_demean,
|
|
262
262
|
columns = cols_to_demean + [unit_col] + [time_col],
|
|
263
263
|
verbose = verbose
|
|
264
264
|
)
|
|
265
265
|
|
|
266
266
|
tools.is_numeric(
|
|
267
|
-
df =
|
|
267
|
+
df = data_demean,
|
|
268
268
|
columns = cols_to_demean,
|
|
269
269
|
verbose = verbose
|
|
270
270
|
)
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# Author: Thomas Wieland
|
|
5
5
|
# ORCID: 0000-0001-5168-9846
|
|
6
6
|
# mail: geowieland@googlemail.com
|
|
7
|
-
# Version: 2.3.
|
|
8
|
-
# Last update: 2026-
|
|
7
|
+
# Version: 2.3.5
|
|
8
|
+
# Last update: 2026-10-30 21:20
|
|
9
9
|
# Copyright (c) 2024-2026 Thomas Wieland
|
|
10
10
|
#-----------------------------------------------------------------------
|
|
11
11
|
|
|
@@ -1763,7 +1763,7 @@ class DiffData:
|
|
|
1763
1763
|
TT_col = treatment_cols[0]["TT_col"]
|
|
1764
1764
|
treatment_name = treatment_cols[0]["treatment_name"]
|
|
1765
1765
|
|
|
1766
|
-
if len(treatment_cols) > 1:
|
|
1766
|
+
if len(treatment_cols) > 1 and verbose:
|
|
1767
1767
|
print(f"NOTE: Treatment data contains {len(treatment_cols)} treatments. Taking first treatment '{treatment_name}' for identifying treatment group and time.")
|
|
1768
1768
|
|
|
1769
1769
|
if verbose:
|
|
@@ -1853,9 +1853,8 @@ class DiffData:
|
|
|
1853
1853
|
self.data[1] = groups
|
|
1854
1854
|
|
|
1855
1855
|
if verbose:
|
|
1856
|
-
print("OK")
|
|
1857
|
-
|
|
1858
|
-
print("NOTE: DiffData object was changed with counterfactual.")
|
|
1856
|
+
print("OK")
|
|
1857
|
+
print("NOTE: DiffData object was changed with counterfactual.")
|
|
1859
1858
|
|
|
1860
1859
|
return self
|
|
1861
1860
|
|
|
@@ -1926,7 +1925,7 @@ class DiffData:
|
|
|
1926
1925
|
treatment_name = treatment_cols[0]["treatment_name"]
|
|
1927
1926
|
TG_col = treatment_cols[0]["TG_col"]
|
|
1928
1927
|
|
|
1929
|
-
if len(treatment_cols) > 1:
|
|
1928
|
+
if len(treatment_cols) > 1 and verbose:
|
|
1930
1929
|
print(f"NOTE: Treatment data contains {len(treatment_cols)} treatments. Taking first treatment '{treatment_name}' for identifying treatment group and time.")
|
|
1931
1930
|
|
|
1932
1931
|
if verbose:
|
|
@@ -2014,7 +2013,7 @@ class DiffData:
|
|
|
2014
2013
|
units = valid_units
|
|
2015
2014
|
|
|
2016
2015
|
if len(units_not_included) > 0:
|
|
2017
|
-
print(f"WARNING: {len(units_not_included)} analysis units were not included due to NaN
|
|
2016
|
+
print(f"WARNING: {len(units_not_included)} analysis units were not included due to NaN values.")
|
|
2018
2017
|
|
|
2019
2018
|
if verbose:
|
|
2020
2019
|
print("OK")
|
|
@@ -2863,7 +2862,7 @@ def create_counterfactual(
|
|
|
2863
2862
|
data_len = len(data)
|
|
2864
2863
|
data = data.dropna()
|
|
2865
2864
|
if len(data) < data_len:
|
|
2866
|
-
print(f"
|
|
2865
|
+
print(f"WARNING: Because of NaN values, {data_len-len(data)} observations were skipped.")
|
|
2867
2866
|
|
|
2868
2867
|
isnotreatment = tools.is_notreatment(
|
|
2869
2868
|
data = data,
|
|
@@ -2871,7 +2870,7 @@ def create_counterfactual(
|
|
|
2871
2870
|
treatment_col = treatment_col,
|
|
2872
2871
|
verbose = verbose
|
|
2873
2872
|
)
|
|
2874
|
-
if not isnotreatment[0]:
|
|
2873
|
+
if not isnotreatment[0] and verbose:
|
|
2875
2874
|
print(f"NOTE: No {config.NO_TREATMENT_CG_DESCRIPTION}. Counterfactual will not cover full treatment time.")
|
|
2876
2875
|
|
|
2877
2876
|
data_unit = data.loc[data[unit_col].astype(str) == cf_for_unit]
|
|
@@ -2897,7 +2896,7 @@ def create_counterfactual(
|
|
|
2897
2896
|
units = tools.unique(units_tt[unit_col])
|
|
2898
2897
|
|
|
2899
2898
|
if not isnotreatment[0]:
|
|
2900
|
-
print(f"
|
|
2899
|
+
print(f"WARNING: No {config.TREATMENT_GROUP_DESCRIPTION} units in model data left.")
|
|
2901
2900
|
|
|
2902
2901
|
if verbose:
|
|
2903
2902
|
print(f"Creating data for counterfactual with {len(X)} independent variables", end = " ... ")
|
|
@@ -2958,18 +2957,30 @@ def create_counterfactual(
|
|
|
2958
2957
|
random_state = random_state,
|
|
2959
2958
|
verbose = verbose
|
|
2960
2959
|
)
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2960
|
+
|
|
2961
|
+
model_estimation_error = counterfactual_pred[5]
|
|
2962
|
+
model_estimation_error_text = ""
|
|
2963
|
+
|
|
2964
|
+
if model_estimation_error:
|
|
2965
|
+
|
|
2966
|
+
model_estimation_error_text = counterfactual_pred[6]
|
|
2967
|
+
print(f"WARNING: The desired {config.MODEL_WRAPPER_AVAILABLE[model_type]} model was not estimated. The following error occurred: '{model_estimation_error_text}'.")
|
|
2968
|
+
|
|
2969
|
+
else:
|
|
2970
|
+
|
|
2971
|
+
if verbose:
|
|
2972
|
+
print(f"Counterfactual prediction for observational unit '{cf_for_unit}'", end = " ... ")
|
|
2973
|
+
|
|
2974
|
+
data_unit[treatment_col] = 0
|
|
2975
|
+
data_unit[y] = counterfactual_pred[1].predict(data_unit[X])
|
|
2976
|
+
|
|
2977
|
+
if verbose:
|
|
2978
|
+
print("OK")
|
|
2970
2979
|
|
|
2971
2980
|
return [
|
|
2972
2981
|
counterfactual_pred,
|
|
2973
2982
|
data_cf,
|
|
2974
|
-
data_unit
|
|
2983
|
+
data_unit,
|
|
2984
|
+
model_estimation_error,
|
|
2985
|
+
model_estimation_error_text
|
|
2975
2986
|
]
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# Author: Thomas Wieland
|
|
5
5
|
# ORCID: 0000-0001-5168-9846
|
|
6
6
|
# mail: geowieland@googlemail.com
|
|
7
|
-
# Version: 2.2.
|
|
8
|
-
# Last update: 2026-10-
|
|
7
|
+
# Version: 2.2.9
|
|
8
|
+
# Last update: 2026-10-30 21:20
|
|
9
9
|
# Copyright (c) 2025-2026 Thomas Wieland
|
|
10
10
|
#-----------------------------------------------------------------------
|
|
11
11
|
|
|
@@ -77,6 +77,8 @@ def check_columns(
|
|
|
77
77
|
"""
|
|
78
78
|
|
|
79
79
|
if len(columns) > 0:
|
|
80
|
+
|
|
81
|
+
key_errors = []
|
|
80
82
|
|
|
81
83
|
if verbose:
|
|
82
84
|
print(f"Checking if column(s) {', '.join(columns)} exist(s) in data frame", end=" ... ")
|
|
@@ -87,7 +89,7 @@ def check_columns(
|
|
|
87
89
|
print("OK")
|
|
88
90
|
|
|
89
91
|
if missing_columns:
|
|
90
|
-
|
|
92
|
+
key_errors.append(f"Data do not contain column(s): {', '.join(missing_columns)}")
|
|
91
93
|
|
|
92
94
|
if verbose:
|
|
93
95
|
print("Checking whether columns are duplicated in data frame", end = " ... ")
|
|
@@ -98,7 +100,10 @@ def check_columns(
|
|
|
98
100
|
print("OK")
|
|
99
101
|
|
|
100
102
|
if len(cols_duplicated) > 0:
|
|
101
|
-
|
|
103
|
+
key_errors.append(f"Data contain duplicated relevant columns: {', '.join(cols_duplicated)}")
|
|
104
|
+
|
|
105
|
+
if len(key_errors) > 0:
|
|
106
|
+
raise KeyError(f"{' '.join(key_errors)}")
|
|
102
107
|
|
|
103
108
|
def is_numeric(
|
|
104
109
|
df: pd.DataFrame,
|
|
@@ -134,6 +139,8 @@ def is_numeric(
|
|
|
134
139
|
>>> is_numeric(df, ['x'])
|
|
135
140
|
"""
|
|
136
141
|
|
|
142
|
+
df = df.copy()
|
|
143
|
+
|
|
137
144
|
if len(columns) > 0:
|
|
138
145
|
|
|
139
146
|
if verbose:
|
|
@@ -266,32 +273,41 @@ def is_balanced(
|
|
|
266
273
|
if other_cols is None:
|
|
267
274
|
other_cols = []
|
|
268
275
|
|
|
276
|
+
cols_relevant = [unit_col, time_col, outcome_col] + other_cols
|
|
277
|
+
|
|
269
278
|
if verbose:
|
|
270
|
-
print(f"Checking whether panel data is balanced with respect to {
|
|
279
|
+
print(f"Checking whether panel data is balanced with respect to {len(cols_relevant)} columns", end = " ... ")
|
|
271
280
|
|
|
272
|
-
|
|
273
|
-
time_freq = data[time_col].nunique()
|
|
274
|
-
unitxtime = unit_freq*time_freq
|
|
281
|
+
panel = data[cols_relevant].copy()
|
|
275
282
|
|
|
276
|
-
|
|
277
|
-
cols_relevant = [unit_col, time_col, outcome_col]
|
|
278
|
-
else:
|
|
279
|
-
cols_relevant = [unit_col, time_col, outcome_col] + other_cols
|
|
283
|
+
warnings = []
|
|
280
284
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
285
|
+
if panel.isnull().any().any():
|
|
286
|
+
warnings.append("Relevant columns contain missing values.")
|
|
287
|
+
|
|
288
|
+
pair_counts = panel[[unit_col, time_col]].dropna().groupby([unit_col, time_col]).size()
|
|
289
|
+
if (pair_counts != 1).any():
|
|
290
|
+
warnings.append("Unit-time pairs are duplicated or incomplete.")
|
|
291
|
+
|
|
292
|
+
units = panel[unit_col].dropna().unique()
|
|
293
|
+
times = panel[time_col].dropna().unique()
|
|
294
|
+
expected_pairs = set((u, t) for u in units for t in times)
|
|
295
|
+
observed_pairs = set(pair_counts.index.tolist())
|
|
296
|
+
if observed_pairs != expected_pairs:
|
|
297
|
+
warnings.append("Some unit-time combinations are missing.")
|
|
298
|
+
|
|
288
299
|
if verbose:
|
|
289
300
|
print("OK")
|
|
290
301
|
|
|
291
|
-
if
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
302
|
+
if len(warnings) > 0:
|
|
303
|
+
|
|
304
|
+
if verbose:
|
|
305
|
+
print(f"WARNING: {' '.join(warnings)}")
|
|
306
|
+
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
else:
|
|
310
|
+
return True
|
|
295
311
|
|
|
296
312
|
def is_dummy(
|
|
297
313
|
iterable,
|
|
@@ -562,7 +578,7 @@ def is_simultaneous(
|
|
|
562
578
|
if verbose:
|
|
563
579
|
print("OK")
|
|
564
580
|
|
|
565
|
-
if not simultaneous and data_isnotreatment[0]:
|
|
581
|
+
if not simultaneous and data_isnotreatment[0] and verbose:
|
|
566
582
|
print(f"NOTE: Treatment '{treatment_col}' is not simultaneous.")
|
|
567
583
|
|
|
568
584
|
if simultaneous and not data_isnotreatment[0]:
|
|
@@ -621,8 +637,8 @@ def is_notreatment(
|
|
|
621
637
|
if verbose:
|
|
622
638
|
print("OK")
|
|
623
639
|
|
|
624
|
-
|
|
625
|
-
|
|
640
|
+
if not no_treatment:
|
|
641
|
+
print(f"WARNING: treatment '{treatment_col}' does not include a {config.NO_TREATMENT_CG_DESCRIPTION}.")
|
|
626
642
|
|
|
627
643
|
return [
|
|
628
644
|
no_treatment,
|
|
@@ -1602,6 +1618,7 @@ def model_wrapper(
|
|
|
1602
1618
|
random_state = random_state
|
|
1603
1619
|
)
|
|
1604
1620
|
else:
|
|
1621
|
+
model = {}
|
|
1605
1622
|
model_estimation_error = True
|
|
1606
1623
|
model_estimation_error_text = "XGBRegressor is not available. Please install xgboost to use this model type."
|
|
1607
1624
|
|
|
@@ -1614,6 +1631,7 @@ def model_wrapper(
|
|
|
1614
1631
|
random_state = random_state
|
|
1615
1632
|
)
|
|
1616
1633
|
else:
|
|
1634
|
+
model = {}
|
|
1617
1635
|
model_estimation_error = True
|
|
1618
1636
|
model_estimation_error_text = "LGBMRegressor is not available. Please install lightgbm to use this model type."
|
|
1619
1637
|
|
|
@@ -1640,7 +1658,8 @@ def model_wrapper(
|
|
|
1640
1658
|
]
|
|
1641
1659
|
)
|
|
1642
1660
|
|
|
1643
|
-
model
|
|
1661
|
+
if model != {}:
|
|
1662
|
+
model.fit(X_train, y_train)
|
|
1644
1663
|
|
|
1645
1664
|
except Exception as e:
|
|
1646
1665
|
|
|
@@ -1708,7 +1727,9 @@ def model_wrapper(
|
|
|
1708
1727
|
X_test,
|
|
1709
1728
|
y_train,
|
|
1710
1729
|
y_test
|
|
1711
|
-
)
|
|
1730
|
+
),
|
|
1731
|
+
model_estimation_error,
|
|
1732
|
+
model_estimation_error_text
|
|
1712
1733
|
]
|
|
1713
1734
|
|
|
1714
1735
|
def fit_metrics(
|
|
@@ -1824,8 +1845,7 @@ def fit_metrics(
|
|
|
1824
1845
|
|
|
1825
1846
|
if any(observed == 0):
|
|
1826
1847
|
|
|
1827
|
-
|
|
1828
|
-
print ("NOTE: Vector 'observed' contains values equal to zero. No APE/MAPE calculated.")
|
|
1848
|
+
print ("WARNING: Vector 'observed' contains values equal to zero. No APE/MAPE calculated.")
|
|
1829
1849
|
|
|
1830
1850
|
APE = np.full_like(observed, np.nan)
|
|
1831
1851
|
MAPE = np.nan
|
|
@@ -1853,7 +1873,11 @@ def fit_metrics(
|
|
|
1853
1873
|
SAR = float(np.sum(residuals_abs))
|
|
1854
1874
|
observed_mean = float(np.sum(observed)/observed_no)
|
|
1855
1875
|
SQT = float(np.sum((observed-observed_mean)**2))
|
|
1856
|
-
|
|
1876
|
+
|
|
1877
|
+
RSQ = np.nan
|
|
1878
|
+
if SQT > 0:
|
|
1879
|
+
RSQ = float(1-(SSR/SQT))
|
|
1880
|
+
|
|
1857
1881
|
MSE = float(SSR/observed_no)
|
|
1858
1882
|
RMSE = float(np.sqrt(MSE))
|
|
1859
1883
|
MAE = float(SAR/observed_no)
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# Author: Thomas Wieland
|
|
5
5
|
# ORCID: 0000-0001-5168-9846
|
|
6
6
|
# mail: geowieland@googlemail.com
|
|
7
|
-
# Version: 2.2.
|
|
8
|
-
# Last update: 2026-
|
|
7
|
+
# Version: 2.2.1
|
|
8
|
+
# Last update: 2026-10-17 14:50
|
|
9
9
|
# Copyright (c) 2025-2026 Thomas Wieland
|
|
10
10
|
#-----------------------------------------------------------------------
|
|
11
11
|
|
|
@@ -305,7 +305,6 @@ curfew_model_FE.plot(
|
|
|
305
305
|
|
|
306
306
|
fixed_effects = curfew_model_FE.fixed_effects()
|
|
307
307
|
# Fixed effects of CHBW_data_model_FE
|
|
308
|
-
#print(fixed_effects)
|
|
309
308
|
|
|
310
309
|
|
|
311
310
|
# Model with demeaned variables:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diffindiff
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.9
|
|
4
4
|
Summary: diffindiff: Python library for convenient Difference-in-Differences analyses
|
|
5
5
|
Author: Thomas Wieland
|
|
6
6
|
Author-email: geowieland@googlemail.com
|
|
@@ -40,14 +40,14 @@ Thomas Wieland [ORCID](https://orcid.org/0000-0001-5168-9846) [EMail](mailto:geo
|
|
|
40
40
|
- 💻 GitHub Repository: [diffindiff_official](https://github.com/geowieland/diffindiff_official)
|
|
41
41
|
- 📄 DOI (Zenodo): [10.5281/zenodo.18656820](https://doi.org/10.5281/zenodo.18656820)
|
|
42
42
|
|
|
43
|
-
A case study that utilizes the diffindiff library is available on [arXiv](https://arxiv.org/abs/2605.21464).
|
|
43
|
+
A case study that utilizes the diffindiff library (preprint) is available on [arXiv](https://arxiv.org/abs/2605.21464).
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
## Citation
|
|
47
47
|
|
|
48
48
|
If you use this software, please cite:
|
|
49
49
|
|
|
50
|
-
Wieland, T. (2026). diffindiff: A Python library for convenient difference-in-differences analyses (Version 2.5.
|
|
50
|
+
Wieland, T. (2026). diffindiff: A Python library for convenient difference-in-differences analyses (Version 2.5.9) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.18656820
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
## Installation
|
|
@@ -198,9 +198,9 @@ See the /tests directory for usage examples of most of the included functions.
|
|
|
198
198
|
This software was developed without the use of AI-generated code. The GitHub Copilot Chat in Microsoft Visual Studio Code using the GPT-5 mini model (by OpenAI) was used solely to assist in drafting and refining docstrings for documentation. The corresponding guidelines and constraints defined by the author are documented in `AGENTS-docstrings.md` in the [public GitHub repository](https://github.com/geowieland/diffindiff_official).
|
|
199
199
|
|
|
200
200
|
|
|
201
|
-
## What's new (v2.5.
|
|
201
|
+
## What's new (v2.5.9)
|
|
202
202
|
|
|
203
203
|
- Bugfixes
|
|
204
|
-
-
|
|
205
|
-
-
|
|
206
|
-
-
|
|
204
|
+
- Adjusting column naming to be compatible with newer pandas versions in `didanalysis.DiffModel.plot()`
|
|
205
|
+
- Check in `didtools.fit_metrics()` whether total sum of squares is positive before calculating $$R^2$$
|
|
206
|
+
- Incorrect access to counterfactual results in the event of model fit errors or model unavailability has been fixed in `diddata.create_counterfactual()`
|
|
@@ -7,7 +7,7 @@ def read_README():
|
|
|
7
7
|
|
|
8
8
|
setup(
|
|
9
9
|
name='diffindiff',
|
|
10
|
-
version='2.5.
|
|
10
|
+
version='2.5.9',
|
|
11
11
|
description='diffindiff: Python library for convenient Difference-in-Differences analyses',
|
|
12
12
|
packages=find_packages(include=["diffindiff", "diffindiff.tests"]),
|
|
13
13
|
include_package_data=True,
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------
|
|
2
|
+
# Name: tests (diffindiff package)
|
|
3
|
+
# Purpose: Tests for diffindiff package functions
|
|
4
|
+
# Author: Thomas Wieland
|
|
5
|
+
# ORCID: 0000-0001-5168-9846
|
|
6
|
+
# mail: geowieland@googlemail.com
|
|
7
|
+
# Version: 1.0.0
|
|
8
|
+
# Last update: 2026-10-17 15:01
|
|
9
|
+
# Copyright (c) 2026 Thomas Wieland
|
|
10
|
+
#-----------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_import_diffindiff():
|
|
18
|
+
"""Test if diffindiff package can be imported."""
|
|
19
|
+
|
|
20
|
+
import diffindiff
|
|
21
|
+
from diffindiff.config import PACKAGE_NAME, PACKAGE_VERSION
|
|
22
|
+
|
|
23
|
+
print(f"This is the {PACKAGE_NAME} package v{PACKAGE_VERSION}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_prepost_analysis():
|
|
27
|
+
|
|
28
|
+
from diffindiff.diddata import create_groups, create_treatment, merge_data, create_data
|
|
29
|
+
|
|
30
|
+
curfew_DE = pd.read_csv(
|
|
31
|
+
Path(__file__).parent / "data" / "curfew_DE.csv",
|
|
32
|
+
sep=";",
|
|
33
|
+
decimal=","
|
|
34
|
+
)
|
|
35
|
+
# Dataset with daily and cumulative SARS-CoV-2 infections of German counties
|
|
36
|
+
# Data source: Wieland (2020) https://doi.org/10.18335/region.v7i2.324
|
|
37
|
+
|
|
38
|
+
curfew_groups=create_groups(
|
|
39
|
+
treatment_group=
|
|
40
|
+
curfew_DE.loc[curfew_DE["Bundesland"].isin([9,10,14])]["county"],
|
|
41
|
+
control_group=
|
|
42
|
+
curfew_DE.loc[~curfew_DE["Bundesland"].isin([9,10,14])]["county"]
|
|
43
|
+
)
|
|
44
|
+
# Creating treatment and control group
|
|
45
|
+
# "Bundesland" (=federal state): 9 = Bavaria, 10 = Saarland, 14 = Saxony
|
|
46
|
+
|
|
47
|
+
assert curfew_groups is not None
|
|
48
|
+
|
|
49
|
+
curfew_groups.summary()
|
|
50
|
+
# Groups summary
|
|
51
|
+
|
|
52
|
+
curfew_treatment_prepost=create_treatment(
|
|
53
|
+
study_period=["2020-03-01", "2020-05-15"],
|
|
54
|
+
treatment_period=["2020-03-21", "2020-05-05"],
|
|
55
|
+
freq = "D",
|
|
56
|
+
pre_post = True
|
|
57
|
+
)
|
|
58
|
+
# Creating treatment
|
|
59
|
+
# Curfew from March 21, 2020, to May, 5, 2020
|
|
60
|
+
|
|
61
|
+
assert curfew_treatment_prepost is not None
|
|
62
|
+
|
|
63
|
+
curfew_treatment_prepost.summary()
|
|
64
|
+
# Treatment summary
|
|
65
|
+
|
|
66
|
+
curfew_data_prepost_merge=merge_data(
|
|
67
|
+
outcome_data=curfew_DE,
|
|
68
|
+
unit_id_col="county",
|
|
69
|
+
time_col="infection_date",
|
|
70
|
+
outcome_col="infections_cum_per100000",
|
|
71
|
+
diff_groups=curfew_groups,
|
|
72
|
+
diff_treatment=curfew_treatment_prepost
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
assert curfew_data_prepost_merge is not None
|
|
76
|
+
|
|
77
|
+
curfew_data_prepost_merge.summary()
|
|
78
|
+
# Summary of created data
|
|
79
|
+
|
|
80
|
+
curfew_data_prepost=create_data(
|
|
81
|
+
outcome_data=curfew_DE,
|
|
82
|
+
unit_id_col="county",
|
|
83
|
+
time_col="infection_date",
|
|
84
|
+
outcome_col="infections_cum_per100000",
|
|
85
|
+
treatment_group=
|
|
86
|
+
curfew_DE.loc[curfew_DE["Bundesland"].isin([9,10,14])]["county"],
|
|
87
|
+
control_group=
|
|
88
|
+
curfew_DE.loc[~curfew_DE["Bundesland"].isin([9,10,14])]["county"],
|
|
89
|
+
study_period=["2020-03-01", "2020-05-15"],
|
|
90
|
+
treatment_period=["2020-03-21", "2020-05-05"],
|
|
91
|
+
freq="D",
|
|
92
|
+
pre_post=True,
|
|
93
|
+
after_treatment_period = False
|
|
94
|
+
)
|
|
95
|
+
# Creating DiD treatement dataset by defining groups and
|
|
96
|
+
# treatment time at once
|
|
97
|
+
|
|
98
|
+
assert curfew_data_prepost is not None
|
|
99
|
+
|
|
100
|
+
curfew_data_prepost.summary()
|
|
101
|
+
# Summary of created data
|
|
102
|
+
|
|
103
|
+
curfew_model_prepost=curfew_data_prepost.analysis(verbose=True)
|
|
104
|
+
# Model analysis of created data
|
|
105
|
+
|
|
106
|
+
assert curfew_model_prepost is not None
|
|
107
|
+
|
|
108
|
+
print(curfew_model_prepost.treatment_effects())
|
|
109
|
+
# Show treatment effects
|
|
110
|
+
|
|
111
|
+
print(curfew_model_prepost.fixed_effects())
|
|
112
|
+
# Show fixed effects (not included in model)
|
|
113
|
+
|
|
114
|
+
print(curfew_model_prepost.covariates())
|
|
115
|
+
# Show covariates (not included in model)
|
|
116
|
+
|
|
117
|
+
curfew_model_prepost.summary()
|
|
118
|
+
# Model summary
|
|
119
|
+
|
|
120
|
+
print(curfew_model_prepost.didmodel().summary())
|
|
121
|
+
# Access statsmodels OLS object
|
|
122
|
+
|
|
123
|
+
print(curfew_model_prepost.fit_metrics())
|
|
124
|
+
# Show model fit metrics
|
|
125
|
+
|
|
126
|
+
curfew_model_prepost.plot(
|
|
127
|
+
x_label="Timepoint",
|
|
128
|
+
y_label="Cumulative infections per 100,000",
|
|
129
|
+
plot_title="Curfew effectiveness pre-post - Groups over time",
|
|
130
|
+
plot_observed=False,
|
|
131
|
+
lines_col=[None,None,"blue","orange"],
|
|
132
|
+
lines_labels=[None,None,"Treatment group","Control group","Treatment group CI","Control group CI"],
|
|
133
|
+
lines_style=[None,None,"solid","solid"]
|
|
134
|
+
)
|
|
135
|
+
# Plot DiD pre vs. post results
|
|
136
|
+
# with user-determined style
|
|
137
|
+
|
|
138
|
+
curfew_model_prepost.plot(
|
|
139
|
+
x_label="Timepoint",
|
|
140
|
+
y_label="Cumulative infections per 100,000",
|
|
141
|
+
plot_title="Curfew effectiveness pre-post - Groups over time",
|
|
142
|
+
lines_col=[None,None,"blue","orange"],
|
|
143
|
+
lines_labels=[None,None,"Treatment group","Control group","Treatment group CI", "Control group CI"],
|
|
144
|
+
pre_post_barplot=True
|
|
145
|
+
)
|
|
146
|
+
# Plot DiD pre vs. post results
|
|
147
|
+
# with user-determined style
|
|
148
|
+
|
|
149
|
+
curfew_model_prepost.plot_treatment_effects(
|
|
150
|
+
x_label="Coefficients with 95% CI",
|
|
151
|
+
plot_title="Curfew effectiveness pre-post - DiD effects"
|
|
152
|
+
)
|
|
153
|
+
# plot effects
|
|
154
|
+
|
|
155
|
+
curfew_model_prepost.plot_treatment_effects(
|
|
156
|
+
x_label="Coefficients with 95% CI",
|
|
157
|
+
plot_title="Curfew effectiveness pre-post - DiD effects",
|
|
158
|
+
scale_plot=False
|
|
159
|
+
)
|
|
160
|
+
# plot effects
|
|
161
|
+
|
|
162
|
+
def test_multiperiod_paneldata_analysis():
|
|
163
|
+
|
|
164
|
+
from diffindiff.diddata import create_data
|
|
165
|
+
|
|
166
|
+
curfew_DE = pd.read_csv(
|
|
167
|
+
Path(__file__).parent / "data" / "curfew_DE.csv",
|
|
168
|
+
sep=";",
|
|
169
|
+
decimal=","
|
|
170
|
+
)
|
|
171
|
+
# Dataset with daily and cumulative SARS-CoV-2 infections of German counties
|
|
172
|
+
# Data source: Wieland (2020) https://doi.org/10.18335/region.v7i2.324
|
|
173
|
+
|
|
174
|
+
curfew_data=create_data(
|
|
175
|
+
outcome_data=curfew_DE,
|
|
176
|
+
unit_id_col="county",
|
|
177
|
+
time_col="infection_date",
|
|
178
|
+
outcome_col="infections_cum_per100000",
|
|
179
|
+
treatment_group=
|
|
180
|
+
curfew_DE.loc[curfew_DE["Bundesland"].isin([9,10,14])]["county"],
|
|
181
|
+
control_group=
|
|
182
|
+
curfew_DE.loc[~curfew_DE["Bundesland"].isin([9,10,14])]["county"],
|
|
183
|
+
treatment_name="Curfew",
|
|
184
|
+
study_period=["2020-03-01", "2020-05-15"],
|
|
185
|
+
treatment_period=["2020-03-21", "2020-05-05"],
|
|
186
|
+
freq="D"
|
|
187
|
+
)
|
|
188
|
+
# Creating DiD dataset by defining groups and treatment time at once
|
|
189
|
+
# Treatment curfew in three federal states: Bavaria (9), Saarland (10) and Saxony (14)
|
|
190
|
+
|
|
191
|
+
assert curfew_data is not None
|
|
192
|
+
|
|
193
|
+
curfew_data.summary()
|
|
194
|
+
# Summary of created treatment data
|
|
195
|
+
|
|
196
|
+
curfew_model=curfew_data.analysis(verbose=True)
|
|
197
|
+
# Model analysis of created data
|
|
198
|
+
|
|
199
|
+
assert curfew_model is not None
|
|
200
|
+
|
|
201
|
+
curfew_model.summary()
|
|
202
|
+
# Model summary
|
|
203
|
+
|
|
204
|
+
curfew_model.plot(
|
|
205
|
+
y_label="Cumulative infections per 100,000",
|
|
206
|
+
plot_title="Curfew effectiveness - Groups over time",
|
|
207
|
+
plot_observed=True
|
|
208
|
+
)
|
|
209
|
+
# Plot observed vs. predicted (means) separated by group (treatment and control)
|
|
210
|
+
|
|
211
|
+
curfew_model.plot_treatment_effects(
|
|
212
|
+
x_label="Coefficients with 95% CI",
|
|
213
|
+
plot_title="Curfew effectiveness - DiD effects"
|
|
214
|
+
)
|
|
215
|
+
# plot effects
|
|
216
|
+
|
|
217
|
+
curfew_placebo = curfew_model.placebo(
|
|
218
|
+
treatment="Curfew"
|
|
219
|
+
)
|
|
220
|
+
# Placebo test with default parameters
|
|
221
|
+
|
|
222
|
+
assert curfew_placebo is not None
|
|
223
|
+
|
|
224
|
+
curfew_placebo.summary()
|
|
225
|
+
# Summary of placebo test
|
|
226
|
+
|
|
227
|
+
# Two-way-fixed-effects model:
|
|
228
|
+
|
|
229
|
+
curfew_model_FE=curfew_data.analysis(
|
|
230
|
+
FE_unit=True,
|
|
231
|
+
FE_time=True,
|
|
232
|
+
demean = True,
|
|
233
|
+
verbose = True
|
|
234
|
+
)
|
|
235
|
+
# Model analysis of created data with fixed effects for
|
|
236
|
+
# units (FE_unit=True) and time (FE_time=True)
|
|
237
|
+
# data is demeaned
|
|
238
|
+
|
|
239
|
+
assert curfew_model_FE is not None
|
|
240
|
+
|
|
241
|
+
curfew_model_FE_summary = curfew_model_FE.summary()
|
|
242
|
+
# Model summary
|
|
243
|
+
|
|
244
|
+
assert curfew_model_FE_summary is not None
|
|
245
|
+
|
|
246
|
+
print(curfew_model_FE.treatment_statistics())
|
|
247
|
+
# Print treatment statistics
|
|
248
|
+
|
|
249
|
+
curfew_model_FE.plot(
|
|
250
|
+
y_label="Cumulative infections per 100,000",
|
|
251
|
+
plot_title="Curfew effectiveness (Two-way FE model) - Groups over time",
|
|
252
|
+
plot_observed=True
|
|
253
|
+
)
|
|
254
|
+
# Plot of treatment and control group
|
|
255
|
+
|
|
256
|
+
fixed_effects = curfew_model_FE.fixed_effects()
|
|
257
|
+
# Fixed effects of CHBW_data_model_FE
|
|
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
|