diff-diff 1.3.0__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {diff_diff-1.3.0 → diff_diff-1.4.0}/PKG-INFO +2 -1
  2. {diff_diff-1.3.0 → diff_diff-1.4.0}/README.md +1 -0
  3. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/__init__.py +1 -1
  4. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/estimators.py +29 -38
  5. diff_diff-1.4.0/diff_diff/linalg.py +271 -0
  6. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/results.py +14 -1
  7. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/staggered.py +330 -193
  8. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/sun_abraham.py +2 -2
  9. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/synthetic_did.py +188 -14
  10. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/triple_diff.py +14 -20
  11. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/twfe.py +10 -6
  12. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/utils.py +17 -53
  13. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/PKG-INFO +2 -1
  14. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/SOURCES.txt +2 -0
  15. {diff_diff-1.3.0 → diff_diff-1.4.0}/pyproject.toml +1 -1
  16. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_estimators.py +44 -24
  17. diff_diff-1.4.0/tests/test_linalg.py +422 -0
  18. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/bacon.py +0 -0
  19. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/diagnostics.py +0 -0
  20. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/honest_did.py +0 -0
  21. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/power.py +0 -0
  22. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/prep.py +0 -0
  23. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/pretrends.py +0 -0
  24. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/visualization.py +0 -0
  25. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/dependency_links.txt +0 -0
  26. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/requires.txt +0 -0
  27. {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/top_level.txt +0 -0
  28. {diff_diff-1.3.0 → diff_diff-1.4.0}/setup.cfg +0 -0
  29. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_bacon.py +0 -0
  30. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_diagnostics.py +0 -0
  31. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_honest_did.py +0 -0
  32. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_power.py +0 -0
  33. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_prep.py +0 -0
  34. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_pretrends.py +0 -0
  35. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_staggered.py +0 -0
  36. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_sun_abraham.py +0 -0
  37. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_triple_diff.py +0 -0
  38. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_utils.py +0 -0
  39. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_visualization.py +0 -0
  40. {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_wild_bootstrap.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: A library for Difference-in-Differences causal inference analysis
5
5
  Author: diff-diff contributors
6
6
  License-Expression: MIT
@@ -116,6 +116,7 @@ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
116
116
  - **Pre-trends power analysis**: Roth (2022) minimum detectable violation (MDV) and power curves for pre-trends tests
117
117
  - **Power analysis**: MDE, sample size, and power calculations for study design; simulation-based power for any estimator
118
118
  - **Data prep utilities**: Helper functions for common data preparation tasks
119
+ - **Validated against R**: Benchmarked against `did`, `synthdid`, and `fixest` packages (see [benchmarks](docs/benchmarks.rst))
119
120
 
120
121
  ## Tutorials
121
122
 
@@ -81,6 +81,7 @@ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
81
81
  - **Pre-trends power analysis**: Roth (2022) minimum detectable violation (MDV) and power curves for pre-trends tests
82
82
  - **Power analysis**: MDE, sample size, and power calculations for study design; simulation-based power for any estimator
83
83
  - **Data prep utilities**: Helper functions for common data preparation tasks
84
+ - **Validated against R**: Benchmarked against `did`, `synthdid`, and `fixest` packages (see [benchmarks](docs/benchmarks.rst))
84
85
 
85
86
  ## Tutorials
86
87
 
@@ -103,7 +103,7 @@ from diff_diff.visualization import (
103
103
  plot_sensitivity,
104
104
  )
105
105
 
106
- __version__ = "1.2.1"
106
+ __version__ = "1.4.0"
107
107
  __all__ = [
108
108
  # Estimators
109
109
  "DifferenceInDifferences",
@@ -17,12 +17,12 @@ from typing import Any, Dict, List, Optional, Tuple
17
17
  import numpy as np
18
18
  import pandas as pd
19
19
 
20
+ from diff_diff.linalg import compute_r_squared, compute_robust_vcov, solve_ols
20
21
  from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect
21
22
  from diff_diff.utils import (
22
23
  WildBootstrapResults,
23
24
  compute_confidence_interval,
24
25
  compute_p_value,
25
- compute_robust_se,
26
26
  validate_binary,
27
27
  wild_bootstrap_se,
28
28
  )
@@ -261,8 +261,11 @@ class DifferenceInDifferences:
261
261
  X = np.column_stack([X, dummies[col].values.astype(float)])
262
262
  var_names.append(col)
263
263
 
264
- # Fit OLS
265
- coefficients, residuals, fitted, r_squared = self._fit_ols(X, y)
264
+ # Fit OLS using unified backend
265
+ coefficients, residuals, fitted, vcov = solve_ols(
266
+ X, y, return_fitted=True, return_vcov=False
267
+ )
268
+ r_squared = compute_r_squared(y, residuals)
266
269
 
267
270
  # Extract ATT (coefficient on interaction term)
268
271
  att_idx = 3 # Index of interaction term
@@ -285,13 +288,13 @@ class DifferenceInDifferences:
285
288
  )
286
289
  elif self.cluster is not None:
287
290
  cluster_ids = data[self.cluster].values
288
- vcov = compute_robust_se(X, residuals, cluster_ids)
291
+ vcov = compute_robust_vcov(X, residuals, cluster_ids)
289
292
  se = np.sqrt(vcov[att_idx, att_idx])
290
293
  t_stat = att / se
291
294
  p_value = compute_p_value(t_stat, df=df)
292
295
  conf_int = compute_confidence_interval(att, se, self.alpha, df=df)
293
296
  elif self.robust:
294
- vcov = compute_robust_se(X, residuals)
297
+ vcov = compute_robust_vcov(X, residuals)
295
298
  se = np.sqrt(vcov[att_idx, att_idx])
296
299
  t_stat = att / se
297
300
  p_value = compute_p_value(t_stat, df=df)
@@ -300,7 +303,7 @@ class DifferenceInDifferences:
300
303
  # Classical OLS standard errors
301
304
  n = len(y)
302
305
  k = X.shape[1]
303
- mse = np.sum(residuals ** 2) / (n - k)
306
+ mse = np.sum(residuals**2) / (n - k)
304
307
  # Use solve() instead of inv() for numerical stability
305
308
  # solve(A, B) computes X where AX=B, so this yields (X'X)^{-1} * mse
306
309
  vcov = np.linalg.solve(X.T @ X, mse * np.eye(k))
@@ -352,10 +355,15 @@ class DifferenceInDifferences:
352
355
 
353
356
  return self.results_
354
357
 
355
- def _fit_ols(self, X: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]:
358
+ def _fit_ols(
359
+ self, X: np.ndarray, y: np.ndarray
360
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]:
356
361
  """
357
362
  Fit OLS regression.
358
363
 
364
+ This method is kept for backwards compatibility. Internally uses the
365
+ unified solve_ols from diff_diff.linalg for optimized computation.
366
+
359
367
  Parameters
360
368
  ----------
361
369
  X : np.ndarray
@@ -367,32 +375,12 @@ class DifferenceInDifferences:
367
375
  -------
368
376
  tuple
369
377
  (coefficients, residuals, fitted_values, r_squared)
370
-
371
- Raises
372
- ------
373
- ValueError
374
- If design matrix is rank-deficient (perfect multicollinearity).
375
378
  """
376
- # Check for rank deficiency (perfect multicollinearity)
377
- rank = np.linalg.matrix_rank(X)
378
- if rank < X.shape[1]:
379
- raise ValueError(
380
- f"Design matrix is rank-deficient (rank {rank} < {X.shape[1]} columns). "
381
- "This indicates perfect multicollinearity. Check your fixed effects "
382
- "and covariates for linear dependencies."
383
- )
384
-
385
- # Solve normal equations: β = (X'X)^(-1) X'y
386
- coefficients = np.linalg.lstsq(X, y, rcond=None)[0]
387
-
388
- # Compute fitted values and residuals
389
- fitted = X @ coefficients
390
- residuals = y - fitted
391
-
392
- # Compute R-squared
393
- ss_res = np.sum(residuals ** 2)
394
- ss_tot = np.sum((y - np.mean(y)) ** 2)
395
- r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0.0
379
+ # Use unified OLS backend
380
+ coefficients, residuals, fitted, _ = solve_ols(
381
+ X, y, return_fitted=True, return_vcov=False
382
+ )
383
+ r_squared = compute_r_squared(y, residuals)
396
384
 
397
385
  return coefficients, residuals, fitted, r_squared
398
386
 
@@ -442,7 +430,7 @@ class DifferenceInDifferences:
442
430
  t_stat = bootstrap_results.t_stat_original
443
431
 
444
432
  # Also compute vcov for storage (using cluster-robust for consistency)
445
- vcov = compute_robust_se(X, residuals, cluster_ids)
433
+ vcov = compute_robust_vcov(X, residuals, cluster_ids)
446
434
 
447
435
  return se, p_value, conf_int, t_stat, vcov, bootstrap_results
448
436
 
@@ -889,8 +877,11 @@ class MultiPeriodDiD(DifferenceInDifferences):
889
877
  X = np.column_stack([X, dummies[col].values.astype(float)])
890
878
  var_names.append(col)
891
879
 
892
- # Fit OLS
893
- coefficients, residuals, fitted, r_squared = self._fit_ols(X, y)
880
+ # Fit OLS using unified backend
881
+ coefficients, residuals, fitted, _ = solve_ols(
882
+ X, y, return_fitted=True, return_vcov=False
883
+ )
884
+ r_squared = compute_r_squared(y, residuals)
894
885
 
895
886
  # Degrees of freedom
896
887
  df = len(y) - X.shape[1] - n_absorbed_effects
@@ -900,13 +891,13 @@ class MultiPeriodDiD(DifferenceInDifferences):
900
891
  # For now, we use analytical inference even if inference="wild_bootstrap"
901
892
  if self.cluster is not None:
902
893
  cluster_ids = data[self.cluster].values
903
- vcov = compute_robust_se(X, residuals, cluster_ids)
894
+ vcov = compute_robust_vcov(X, residuals, cluster_ids)
904
895
  elif self.robust:
905
- vcov = compute_robust_se(X, residuals)
896
+ vcov = compute_robust_vcov(X, residuals)
906
897
  else:
907
898
  n = len(y)
908
899
  k = X.shape[1]
909
- mse = np.sum(residuals ** 2) / (n - k)
900
+ mse = np.sum(residuals**2) / (n - k)
910
901
  # Use solve() instead of inv() for numerical stability
911
902
  # solve(A, B) computes X where AX=B, so this yields (X'X)^{-1} * mse
912
903
  vcov = np.linalg.solve(X.T @ X, mse * np.eye(k))
@@ -0,0 +1,271 @@
1
+ """
2
+ Unified linear algebra backend for diff-diff.
3
+
4
+ This module provides optimized OLS and variance estimation that can be
5
+ swapped to a compiled backend (Rust/C++) for maximum performance.
6
+
7
+ The key optimizations are:
8
+ 1. scipy.linalg.lstsq with 'gelsy' driver (QR-based, faster than SVD)
9
+ 2. Vectorized cluster-robust SE via groupby (eliminates O(n*clusters) loop)
10
+ 3. Single interface for all estimators (reduces code duplication)
11
+
12
+ Future: This module can be extended with a Rust backend for additional speedup.
13
+ """
14
+
15
+ from typing import Optional, Tuple, Union
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from scipy.linalg import lstsq as scipy_lstsq
20
+
21
+
22
+ def solve_ols(
23
+ X: np.ndarray,
24
+ y: np.ndarray,
25
+ *,
26
+ cluster_ids: Optional[np.ndarray] = None,
27
+ return_vcov: bool = True,
28
+ return_fitted: bool = False,
29
+ check_finite: bool = True,
30
+ ) -> Union[
31
+ Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]],
32
+ Tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray]],
33
+ ]:
34
+ """
35
+ Solve OLS regression with optional clustered standard errors.
36
+
37
+ This is the unified OLS solver for all diff-diff estimators. It uses
38
+ scipy's optimized LAPACK routines and vectorized variance estimation.
39
+
40
+ Parameters
41
+ ----------
42
+ X : ndarray of shape (n, k)
43
+ Design matrix (should include intercept if desired).
44
+ y : ndarray of shape (n,)
45
+ Response vector.
46
+ cluster_ids : ndarray of shape (n,), optional
47
+ Cluster identifiers for cluster-robust standard errors.
48
+ If None, HC1 (heteroskedasticity-robust) SEs are computed.
49
+ return_vcov : bool, default True
50
+ Whether to compute and return the variance-covariance matrix.
51
+ Set to False for faster computation when SEs are not needed.
52
+ return_fitted : bool, default False
53
+ Whether to return fitted values in addition to residuals.
54
+ check_finite : bool, default True
55
+ Whether to check that X and y contain only finite values (no NaN/Inf).
56
+ Set to False for faster computation if you are certain your data is clean.
57
+
58
+ Returns
59
+ -------
60
+ coefficients : ndarray of shape (k,)
61
+ OLS coefficient estimates.
62
+ residuals : ndarray of shape (n,)
63
+ Residuals (y - X @ coefficients).
64
+ fitted : ndarray of shape (n,), optional
65
+ Fitted values (X @ coefficients). Only returned if return_fitted=True.
66
+ vcov : ndarray of shape (k, k) or None
67
+ Variance-covariance matrix (HC1 or cluster-robust).
68
+ None if return_vcov=False.
69
+
70
+ Notes
71
+ -----
72
+ This function uses scipy.linalg.lstsq with the 'gelsy' driver, which is
73
+ QR-based and typically faster than NumPy's default SVD-based solver for
74
+ well-conditioned matrices.
75
+
76
+ The cluster-robust standard errors use the sandwich estimator with the
77
+ standard small-sample adjustment: (G/(G-1)) * ((n-1)/(n-k)).
78
+
79
+ Examples
80
+ --------
81
+ >>> import numpy as np
82
+ >>> from diff_diff.linalg import solve_ols
83
+ >>> X = np.column_stack([np.ones(100), np.random.randn(100)])
84
+ >>> y = 2 + 3 * X[:, 1] + np.random.randn(100)
85
+ >>> coef, resid, vcov = solve_ols(X, y)
86
+ >>> print(f"Intercept: {coef[0]:.2f}, Slope: {coef[1]:.2f}")
87
+ """
88
+ # Validate inputs
89
+ X = np.asarray(X, dtype=np.float64)
90
+ y = np.asarray(y, dtype=np.float64)
91
+
92
+ if X.ndim != 2:
93
+ raise ValueError(f"X must be 2-dimensional, got shape {X.shape}")
94
+ if y.ndim != 1:
95
+ raise ValueError(f"y must be 1-dimensional, got shape {y.shape}")
96
+ if X.shape[0] != y.shape[0]:
97
+ raise ValueError(
98
+ f"X and y must have same number of observations: "
99
+ f"{X.shape[0]} vs {y.shape[0]}"
100
+ )
101
+
102
+ n, k = X.shape
103
+ if n < k:
104
+ raise ValueError(
105
+ f"Fewer observations ({n}) than parameters ({k}). "
106
+ "Cannot solve underdetermined system."
107
+ )
108
+
109
+ # Check for NaN/Inf values if requested
110
+ if check_finite:
111
+ if not np.isfinite(X).all():
112
+ raise ValueError(
113
+ "X contains NaN or Inf values. "
114
+ "Clean your data or set check_finite=False to skip this check."
115
+ )
116
+ if not np.isfinite(y).all():
117
+ raise ValueError(
118
+ "y contains NaN or Inf values. "
119
+ "Clean your data or set check_finite=False to skip this check."
120
+ )
121
+
122
+ # Solve OLS using scipy's optimized solver
123
+ # 'gelsy' uses QR with column pivoting, faster than default 'gelsd' (SVD)
124
+ # Note: gelsy doesn't reliably report rank, so we don't check for deficiency
125
+ coefficients = scipy_lstsq(X, y, lapack_driver="gelsy", check_finite=False)[0]
126
+
127
+ # Compute residuals and fitted values
128
+ fitted = X @ coefficients
129
+ residuals = y - fitted
130
+
131
+ # Compute variance-covariance matrix if requested
132
+ vcov = None
133
+ if return_vcov:
134
+ vcov = compute_robust_vcov(X, residuals, cluster_ids)
135
+
136
+ if return_fitted:
137
+ return coefficients, residuals, fitted, vcov
138
+ else:
139
+ return coefficients, residuals, vcov
140
+
141
+
142
+ def compute_robust_vcov(
143
+ X: np.ndarray,
144
+ residuals: np.ndarray,
145
+ cluster_ids: Optional[np.ndarray] = None,
146
+ ) -> np.ndarray:
147
+ """
148
+ Compute heteroskedasticity-robust or cluster-robust variance-covariance matrix.
149
+
150
+ Uses the sandwich estimator: (X'X)^{-1} * meat * (X'X)^{-1}
151
+
152
+ Parameters
153
+ ----------
154
+ X : ndarray of shape (n, k)
155
+ Design matrix.
156
+ residuals : ndarray of shape (n,)
157
+ OLS residuals.
158
+ cluster_ids : ndarray of shape (n,), optional
159
+ Cluster identifiers. If None, computes HC1 robust SEs.
160
+
161
+ Returns
162
+ -------
163
+ vcov : ndarray of shape (k, k)
164
+ Variance-covariance matrix.
165
+
166
+ Notes
167
+ -----
168
+ For HC1 (no clustering):
169
+ meat = X' * diag(u^2) * X
170
+ adjustment = n / (n - k)
171
+
172
+ For cluster-robust:
173
+ meat = sum_g (X_g' u_g)(X_g' u_g)'
174
+ adjustment = (G / (G-1)) * ((n-1) / (n-k))
175
+
176
+ The cluster-robust computation is vectorized using pandas groupby,
177
+ which is much faster than a Python loop over clusters.
178
+ """
179
+ n, k = X.shape
180
+ XtX = X.T @ X
181
+
182
+ if cluster_ids is None:
183
+ # HC1 (heteroskedasticity-robust) standard errors
184
+ adjustment = n / (n - k)
185
+ u_squared = residuals**2
186
+ # Vectorized meat computation: X' diag(u^2) X = (X * u^2)' X
187
+ meat = X.T @ (X * u_squared[:, np.newaxis])
188
+ else:
189
+ # Cluster-robust standard errors (vectorized via groupby)
190
+ cluster_ids = np.asarray(cluster_ids)
191
+ unique_clusters = np.unique(cluster_ids)
192
+ n_clusters = len(unique_clusters)
193
+
194
+ if n_clusters < 2:
195
+ raise ValueError(
196
+ f"Need at least 2 clusters for cluster-robust SEs, got {n_clusters}"
197
+ )
198
+
199
+ # Small-sample adjustment
200
+ adjustment = (n_clusters / (n_clusters - 1)) * ((n - 1) / (n - k))
201
+
202
+ # Compute cluster-level scores: sum of X_i * u_i within each cluster
203
+ # scores[i] = X[i] * residuals[i] for each observation
204
+ scores = X * residuals[:, np.newaxis] # (n, k)
205
+
206
+ # Sum scores within each cluster using pandas groupby (vectorized)
207
+ # This is much faster than looping over clusters
208
+ cluster_scores = pd.DataFrame(scores).groupby(cluster_ids).sum().values # (G, k)
209
+
210
+ # Meat is the outer product sum: sum_g (score_g)(score_g)'
211
+ # Equivalent to cluster_scores.T @ cluster_scores
212
+ meat = cluster_scores.T @ cluster_scores # (k, k)
213
+
214
+ # Sandwich estimator: (X'X)^{-1} meat (X'X)^{-1}
215
+ # Solve (X'X) temp = meat, then solve (X'X) vcov' = temp'
216
+ # More stable than explicit inverse
217
+ try:
218
+ temp = np.linalg.solve(XtX, meat)
219
+ vcov = adjustment * np.linalg.solve(XtX, temp.T).T
220
+ except np.linalg.LinAlgError as e:
221
+ if "Singular" in str(e):
222
+ raise ValueError(
223
+ "Design matrix is rank-deficient (singular X'X matrix). "
224
+ "This indicates perfect multicollinearity. Check your fixed effects "
225
+ "and covariates for linear dependencies."
226
+ ) from e
227
+ raise
228
+
229
+ return vcov
230
+
231
+
232
+ def compute_r_squared(
233
+ y: np.ndarray,
234
+ residuals: np.ndarray,
235
+ adjusted: bool = False,
236
+ n_params: int = 0,
237
+ ) -> float:
238
+ """
239
+ Compute R-squared or adjusted R-squared.
240
+
241
+ Parameters
242
+ ----------
243
+ y : ndarray of shape (n,)
244
+ Response vector.
245
+ residuals : ndarray of shape (n,)
246
+ OLS residuals.
247
+ adjusted : bool, default False
248
+ If True, compute adjusted R-squared.
249
+ n_params : int, default 0
250
+ Number of parameters (including intercept). Required if adjusted=True.
251
+
252
+ Returns
253
+ -------
254
+ r_squared : float
255
+ R-squared or adjusted R-squared.
256
+ """
257
+ ss_res = np.sum(residuals**2)
258
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
259
+
260
+ if ss_tot == 0:
261
+ return 0.0
262
+
263
+ r_squared = 1 - (ss_res / ss_tot)
264
+
265
+ if adjusted:
266
+ n = len(y)
267
+ if n <= n_params:
268
+ return r_squared
269
+ r_squared = 1 - (1 - r_squared) * (n - 1) / (n - n_params)
270
+
271
+ return r_squared
@@ -507,6 +507,8 @@ class SyntheticDiDResults:
507
507
  List of pre-treatment period identifiers.
508
508
  post_periods : list
509
509
  List of post-treatment period identifiers.
510
+ variance_method : str
511
+ Method used for variance estimation: "bootstrap" or "placebo".
510
512
  """
511
513
 
512
514
  att: float
@@ -522,9 +524,11 @@ class SyntheticDiDResults:
522
524
  pre_periods: List[Any]
523
525
  post_periods: List[Any]
524
526
  alpha: float = 0.05
527
+ variance_method: str = field(default="bootstrap")
525
528
  lambda_reg: Optional[float] = field(default=None)
526
529
  pre_treatment_fit: Optional[float] = field(default=None)
527
530
  placebo_effects: Optional[np.ndarray] = field(default=None)
531
+ n_bootstrap: Optional[int] = field(default=None)
528
532
 
529
533
  def __repr__(self) -> str:
530
534
  """Concise string representation."""
@@ -571,6 +575,11 @@ class SyntheticDiDResults:
571
575
  if self.pre_treatment_fit is not None:
572
576
  lines.append(f"{'Pre-treatment fit (RMSE):':<25} {self.pre_treatment_fit:>10.4f}")
573
577
 
578
+ # Variance method info
579
+ lines.append(f"{'Variance method:':<25} {self.variance_method:>10}")
580
+ if self.variance_method == "bootstrap" and self.n_bootstrap is not None:
581
+ lines.append(f"{'Bootstrap replications:':<25} {self.n_bootstrap:>10}")
582
+
574
583
  lines.extend([
575
584
  "",
576
585
  "-" * 75,
@@ -624,7 +633,7 @@ class SyntheticDiDResults:
624
633
  Dict[str, Any]
625
634
  Dictionary containing all estimation results.
626
635
  """
627
- return {
636
+ result = {
628
637
  "att": self.att,
629
638
  "se": self.se,
630
639
  "t_stat": self.t_stat,
@@ -636,9 +645,13 @@ class SyntheticDiDResults:
636
645
  "n_control": self.n_control,
637
646
  "n_pre_periods": len(self.pre_periods),
638
647
  "n_post_periods": len(self.post_periods),
648
+ "variance_method": self.variance_method,
639
649
  "lambda_reg": self.lambda_reg,
640
650
  "pre_treatment_fit": self.pre_treatment_fit,
641
651
  }
652
+ if self.n_bootstrap is not None:
653
+ result["n_bootstrap"] = self.n_bootstrap
654
+ return result
642
655
 
643
656
  def to_dataframe(self) -> pd.DataFrame:
644
657
  """