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.
Files changed (22) hide show
  1. {diffindiff-2.5.7 → diffindiff-2.5.9}/PKG-INFO +7 -7
  2. {diffindiff-2.5.7 → diffindiff-2.5.9}/README.md +6 -6
  3. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/config.py +3 -3
  4. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/didanalysis.py +115 -59
  5. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/didanalysis_helper.py +6 -6
  6. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/diddata.py +32 -21
  7. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/didtools.py +55 -31
  8. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/tests_diffindiff.py +2 -3
  9. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/PKG-INFO +7 -7
  10. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/SOURCES.txt +2 -1
  11. {diffindiff-2.5.7 → diffindiff-2.5.9}/setup.py +1 -1
  12. diffindiff-2.5.9/tests/test_diffindiff.py +257 -0
  13. {diffindiff-2.5.7 → diffindiff-2.5.9}/MANIFEST.in +0 -0
  14. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/__init__.py +0 -0
  15. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/__init__.py +0 -0
  16. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/data/Corona_Hesse.xlsx +0 -0
  17. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/data/counties_DE.csv +0 -0
  18. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff/tests/data/curfew_DE.csv +0 -0
  19. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/dependency_links.txt +0 -0
  20. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/requires.txt +0 -0
  21. {diffindiff-2.5.7 → diffindiff-2.5.9}/diffindiff.egg-info/top_level.txt +0 -0
  22. {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.7
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.7) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.18656820
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.7)
201
+ ## What's new (v2.5.9)
202
202
 
203
203
  - Bugfixes
204
- - Catching RecursionError when model formulas include too many variables
205
- - Checking input data frames for duplicated columns
206
- - Avoiding treatment group deviation in summary when treatment/control groups are identical
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.7) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.18656820
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.7)
175
+ ## What's new (v2.5.9)
176
176
 
177
177
  - Bugfixes
178
- - Catching RecursionError when model formulas include too many variables
179
- - Checking input data frames for duplicated columns
180
- - Avoiding treatment group deviation in summary when treatment/control groups are identical
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.28
8
- # Last update: 2026-10-10 09:58
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.7"
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.6
8
- # Last update: 2026-09-11 21:46
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
- print(f"NOTE: No treatment was stated. Choosing treatment '{treatment}' for calculating treatment statistics.")
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"NOTE: Treatment '{no_control_conditions[0]}' has no control conditions.")
788
+ print(f"WARNING: Treatment '{no_control_conditions[0]}' has no control conditions.")
786
789
  else:
787
- print(f"NOTE: Treatments {', '.join(no_control_conditions)} have no control conditions.")
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
- pass
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
- print(f"NOTE: No treatment was stated. Choosing treatment '{treatment}' for calculating counterfactual.")
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
- print(f"NOTE: No treatment was stated. Choosing treatment '{treatment}' for placebo analysis.")
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
- print(f"NOTE: Data is demeaned. Treatment statistics are calculated based on original treatment '{treatment}'.")
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
- print(f"NOTE: No treatment was stated. Choosing treatment '{treatment}' for plotting timeline.")
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
- print(f"NOTE: No treatment was stated. Choosing treatment '{treatment_diagnostics[0]['treatment']}' for plotting.")
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 model_config["demean"]:
1787
- 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}'.")
1788
- else:
1789
- print(f"NOTE: Outcome variable '{outcome_col}' was log-transformed. Re-transformation was applied.")
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
- print("NOTE: Parameter 'retransform_log_outcome' was set to True, but outcome variable was not log-transformed. No re-transformation applied.")
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.values[0] = "t"
1858
- model_data_TG_CG.columns.values[1] = outcome_col_observed_TG
1859
- model_data_TG_CG.columns.values[2] = outcome_col_observed_CG
1860
- model_data_TG_CG.columns.values[3] = outcome_col_expected_TG
1861
- model_data_TG_CG.columns.values[4] = outcome_col_expected_CG
1862
- model_data_TG_CG.columns.values[5] = outcome_col_expected_CI_lower_TG
1863
- model_data_TG_CG.columns.values[6] = outcome_col_expected_CI_upper_TG
1864
- model_data_TG_CG.columns.values[7] = outcome_col_expected_CI_lower_CG
1865
- model_data_TG_CG.columns.values[8] = outcome_col_expected_CI_upper_CG
1866
- model_data_TG_CG.columns.values[9] = outcome_col_expected_PI_lower_TG
1867
- model_data_TG_CG.columns.values[10] = outcome_col_expected_PI_upper_TG
1868
- model_data_TG_CG.columns.values[11] = outcome_col_expected_PI_lower_CG
1869
- model_data_TG_CG.columns.values[12] = outcome_col_expected_PI_upper_CG
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
- print(f"NOTE: No treatment was stated. Choosing treatment '{treatment_diagnostics[0]['treatment']}' for plotting counterfactual.")
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
- 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.")
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
- print(f"NOTE: Quasi-experiment includes less than {config.FIXED_EFFECTS_THRESHOLD} units. Unit or group fixed effects are skipped.")
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
- print(f"NOTE: Quasi-experiment includes less than {config.FIXED_EFFECTS_THRESHOLD} time points. Time fixed effects are skipped.")
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
- 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.")
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
- print("NOTE: Model includes individual treatment effects. Unit fixed effects are included.")
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
- print("NOTE: Both group and individual treatment effects were stated. Switching to individual treatment effects only.")
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
- print("NOTE: Model includes individual time trends. Unit fixed effects are included. Treatment time variable is dropped.")
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
- print("NOTE: Model includes individual time trends. Time fixed effects are dropped.")
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
- print("NOTE: Both group and individual time trends were stated. Switching to individual time trends only.")
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
- print("NOTE: Quasi-experiment includes one or more staggered treatments. Two-way fixed effects model is used.")
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
- print("NOTE: Quasi-experiment includes group fixed effects. Control group baseline and treatment group deviation are dropped.")
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"NOTE: The following columns are constant and dropped from the analysis: {', '.join(data_diagnostics['cols_constants'])}.")
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
- print(f"NOTE: Input is {config.PREPOST_PANELDATA_DESCRIPTION}. Data processing and model estimation will treat data as pre-post.")
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"NOTE: Input is {config.MULTIPERIOD_PANELDATA_DESCRIPTION}. Consider including {config.EFFECTS_TYPES['FE']['description']}.")
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
- print(f"NOTE: Quasi-experiment includes less than {config.FIXED_EFFECTS_THRESHOLD} units. Unit fixed effects are skipped.")
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
- print(f"NOTE: Quasi-experiment includes less than {config.FIXED_EFFECTS_THRESHOLD} time points. Time fixed effects are skipped.")
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"NOTE: The following columns are constant and dropped from the analysis: {', '.join(data_diagnostics['cols_constants'])}.")
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
- print("NOTE: Panel data is pre-post. Data processing and model estimation will treat data as pre-post")
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.5
8
- # Last update: 2026-10-09 23:04
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("NOTE: Neither 'FE_unit' nor 'FE_time' is set to True. No demeaning will be performed.")
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 = data,
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 = data,
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.3
8
- # Last update: 2026-08-04 17:51
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 data.")
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"NOTE: Because of NaN values, {data_len-len(data)} observations were skipped.")
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"NOTE: No {config.TREATMENT_GROUP_DESCRIPTION} units in model data left.")
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
- if verbose:
2963
- print(f"Counterfactual prediction for observational unit '{cf_for_unit}'", end = " ... ")
2964
-
2965
- data_unit[treatment_col] = 0
2966
- data_unit[y] = counterfactual_pred[1].predict(data_unit[X])
2967
-
2968
- if verbose:
2969
- print("OK")
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.7
8
- # Last update: 2026-10-10 09:57
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
- raise KeyError(f"Data do not contain column(s): {', '.join(missing_columns)}")
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
- raise KeyError(f"Data contain duplicated relevant columns: {', '.join(cols_duplicated)}")
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 {3+len(other_cols)} columns", end = " ... ")
279
+ print(f"Checking whether panel data is balanced with respect to {len(cols_relevant)} columns", end = " ... ")
271
280
 
272
- unit_freq = data[unit_col].nunique()
273
- time_freq = data[time_col].nunique()
274
- unitxtime = unit_freq*time_freq
281
+ panel = data[cols_relevant].copy()
275
282
 
276
- if other_cols is None:
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
- data_relevant = data[cols_relevant]
282
-
283
- if unitxtime != len(data_relevant.notna()):
284
- balanced = False
285
- else:
286
- balanced = True
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 not balanced:
292
- print("WARNING: Panel data is not balanced.")
293
-
294
- return balanced
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
- if not no_treatment:
625
- print(f"NOTE: treatment '{treatment_col}' does not include a {config.NO_TREATMENT_CG_DESCRIPTION}.")
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.fit(X_train, y_train)
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
- if verbose:
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
- RSQ = float(1-(SSR/SQT))
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.0
8
- # Last update: 2026-08-04 18:42
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.7
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.7) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.18656820
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.7)
201
+ ## What's new (v2.5.9)
202
202
 
203
203
  - Bugfixes
204
- - Catching RecursionError when model formulas include too many variables
205
- - Checking input data frames for duplicated columns
206
- - Avoiding treatment group deviation in summary when treatment/control groups are identical
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()`
@@ -16,4 +16,5 @@ diffindiff/tests/__init__.py
16
16
  diffindiff/tests/tests_diffindiff.py
17
17
  diffindiff/tests/data/Corona_Hesse.xlsx
18
18
  diffindiff/tests/data/counties_DE.csv
19
- diffindiff/tests/data/curfew_DE.csv
19
+ diffindiff/tests/data/curfew_DE.csv
20
+ tests/test_diffindiff.py
@@ -7,7 +7,7 @@ def read_README():
7
7
 
8
8
  setup(
9
9
  name='diffindiff',
10
- version='2.5.7',
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