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.
- {diff_diff-1.3.0 → diff_diff-1.4.0}/PKG-INFO +2 -1
- {diff_diff-1.3.0 → diff_diff-1.4.0}/README.md +1 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/__init__.py +1 -1
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/estimators.py +29 -38
- diff_diff-1.4.0/diff_diff/linalg.py +271 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/results.py +14 -1
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/staggered.py +330 -193
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/sun_abraham.py +2 -2
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/synthetic_did.py +188 -14
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/triple_diff.py +14 -20
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/twfe.py +10 -6
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/utils.py +17 -53
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/PKG-INFO +2 -1
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/SOURCES.txt +2 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/pyproject.toml +1 -1
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_estimators.py +44 -24
- diff_diff-1.4.0/tests/test_linalg.py +422 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/bacon.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/diagnostics.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/honest_did.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/power.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/prep.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/pretrends.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff/visualization.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/dependency_links.txt +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/requires.txt +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/diff_diff.egg-info/top_level.txt +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/setup.cfg +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_bacon.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_diagnostics.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_honest_did.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_power.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_prep.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_pretrends.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_staggered.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_sun_abraham.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_triple_diff.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_utils.py +0 -0
- {diff_diff-1.3.0 → diff_diff-1.4.0}/tests/test_visualization.py +0 -0
- {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
|
+
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
|
|
|
@@ -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,
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
#
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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 =
|
|
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,
|
|
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 =
|
|
894
|
+
vcov = compute_robust_vcov(X, residuals, cluster_ids)
|
|
904
895
|
elif self.robust:
|
|
905
|
-
vcov =
|
|
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
|
|
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
|
-
|
|
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
|
"""
|