diff-diff 1.4.0__tar.gz → 2.0.1__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.4.0 → diff_diff-2.0.1}/PKG-INFO +19 -18
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/__init__.py +13 -1
- diff_diff-2.0.1/diff_diff/_backend.py +64 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/bacon.py +12 -60
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/estimators.py +9 -8
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/linalg.py +151 -4
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/staggered.py +151 -26
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/sun_abraham.py +2 -22
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/twfe.py +2 -18
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/utils.py +176 -9
- {diff_diff-1.4.0 → diff_diff-2.0.1}/pyproject.toml +14 -5
- diff_diff-2.0.1/rust/Cargo.lock +2396 -0
- diff_diff-2.0.1/rust/Cargo.toml +39 -0
- diff_diff-2.0.1/rust/src/bootstrap.rs +223 -0
- diff_diff-2.0.1/rust/src/lib.rs +33 -0
- diff_diff-2.0.1/rust/src/linalg.rs +229 -0
- diff_diff-2.0.1/rust/src/weights.rs +220 -0
- diff_diff-1.4.0/diff_diff.egg-info/PKG-INFO +0 -2255
- diff_diff-1.4.0/diff_diff.egg-info/SOURCES.txt +0 -38
- diff_diff-1.4.0/diff_diff.egg-info/dependency_links.txt +0 -1
- diff_diff-1.4.0/diff_diff.egg-info/requires.txt +0 -14
- diff_diff-1.4.0/diff_diff.egg-info/top_level.txt +0 -1
- diff_diff-1.4.0/setup.cfg +0 -4
- diff_diff-1.4.0/tests/test_bacon.py +0 -679
- diff_diff-1.4.0/tests/test_diagnostics.py +0 -674
- diff_diff-1.4.0/tests/test_estimators.py +0 -2719
- diff_diff-1.4.0/tests/test_honest_did.py +0 -699
- diff_diff-1.4.0/tests/test_linalg.py +0 -422
- diff_diff-1.4.0/tests/test_power.py +0 -691
- diff_diff-1.4.0/tests/test_prep.py +0 -794
- diff_diff-1.4.0/tests/test_pretrends.py +0 -813
- diff_diff-1.4.0/tests/test_staggered.py +0 -1358
- diff_diff-1.4.0/tests/test_sun_abraham.py +0 -732
- diff_diff-1.4.0/tests/test_triple_diff.py +0 -869
- diff_diff-1.4.0/tests/test_utils.py +0 -1270
- diff_diff-1.4.0/tests/test_visualization.py +0 -284
- diff_diff-1.4.0/tests/test_wild_bootstrap.py +0 -804
- {diff_diff-1.4.0 → diff_diff-2.0.1}/README.md +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/diagnostics.py +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/honest_did.py +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/power.py +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/prep.py +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/pretrends.py +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/results.py +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/triple_diff.py +0 -0
- {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/visualization.py +0 -0
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diff-diff
|
|
3
|
-
Version:
|
|
4
|
-
Summary: A library for Difference-in-Differences causal inference analysis
|
|
5
|
-
Author: diff-diff contributors
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://github.com/igerber/diff-diff
|
|
8
|
-
Project-URL: Documentation, https://diff-diff.readthedocs.io
|
|
9
|
-
Project-URL: Repository, https://github.com/igerber/diff-diff
|
|
10
|
-
Project-URL: Issues, https://github.com/igerber/diff-diff/issues
|
|
11
|
-
Keywords: causal-inference,difference-in-differences,econometrics,statistics,treatment-effects
|
|
3
|
+
Version: 2.0.1
|
|
12
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
13
5
|
Classifier: Intended Audience :: Science/Research
|
|
14
6
|
Classifier: Operating System :: OS Independent
|
|
@@ -18,20 +10,28 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
18
10
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
11
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
12
|
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
21
|
-
Requires-Python: >=3.9
|
|
22
|
-
Description-Content-Type: text/markdown
|
|
23
13
|
Requires-Dist: numpy>=1.20.0
|
|
24
14
|
Requires-Dist: pandas>=1.3.0
|
|
25
15
|
Requires-Dist: scipy>=1.7.0
|
|
16
|
+
Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
|
|
18
|
+
Requires-Dist: black>=23.0 ; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.1.0 ; extra == 'dev'
|
|
20
|
+
Requires-Dist: mypy>=1.0 ; extra == 'dev'
|
|
21
|
+
Requires-Dist: sphinx>=6.0 ; extra == 'docs'
|
|
22
|
+
Requires-Dist: sphinx-rtd-theme>=1.0 ; extra == 'docs'
|
|
26
23
|
Provides-Extra: dev
|
|
27
|
-
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
28
|
-
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
29
|
-
Requires-Dist: black>=23.0; extra == "dev"
|
|
30
|
-
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
31
|
-
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
32
24
|
Provides-Extra: docs
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
Summary: A library for Difference-in-Differences causal inference analysis
|
|
26
|
+
Keywords: causal-inference,difference-in-differences,econometrics,statistics,treatment-effects
|
|
27
|
+
Author: diff-diff contributors
|
|
28
|
+
License-Expression: MIT
|
|
29
|
+
Requires-Python: >=3.9
|
|
30
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
31
|
+
Project-URL: Documentation, https://diff-diff.readthedocs.io
|
|
32
|
+
Project-URL: Homepage, https://github.com/igerber/diff-diff
|
|
33
|
+
Project-URL: Issues, https://github.com/igerber/diff-diff/issues
|
|
34
|
+
Project-URL: Repository, https://github.com/igerber/diff-diff
|
|
35
35
|
|
|
36
36
|
# diff-diff
|
|
37
37
|
|
|
@@ -2253,3 +2253,4 @@ The `HonestDiD` module implements sensitivity analysis methods for relaxing the
|
|
|
2253
2253
|
## License
|
|
2254
2254
|
|
|
2255
2255
|
MIT License
|
|
2256
|
+
|
|
@@ -5,6 +5,16 @@ This library provides sklearn-like estimators for causal inference
|
|
|
5
5
|
using the difference-in-differences methodology.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
# Import backend detection from dedicated module (avoids circular imports)
|
|
9
|
+
from diff_diff._backend import (
|
|
10
|
+
HAS_RUST_BACKEND,
|
|
11
|
+
_rust_bootstrap_weights,
|
|
12
|
+
_rust_compute_robust_vcov,
|
|
13
|
+
_rust_project_simplex,
|
|
14
|
+
_rust_solve_ols,
|
|
15
|
+
_rust_synthetic_weights,
|
|
16
|
+
)
|
|
17
|
+
|
|
8
18
|
from diff_diff.bacon import (
|
|
9
19
|
BaconDecomposition,
|
|
10
20
|
BaconDecompositionResults,
|
|
@@ -103,7 +113,7 @@ from diff_diff.visualization import (
|
|
|
103
113
|
plot_sensitivity,
|
|
104
114
|
)
|
|
105
115
|
|
|
106
|
-
__version__ = "
|
|
116
|
+
__version__ = "2.0.1"
|
|
107
117
|
__all__ = [
|
|
108
118
|
# Estimators
|
|
109
119
|
"DifferenceInDifferences",
|
|
@@ -187,4 +197,6 @@ __all__ = [
|
|
|
187
197
|
"compute_pretrends_power",
|
|
188
198
|
"compute_mdv",
|
|
189
199
|
"plot_pretrends_power",
|
|
200
|
+
# Rust backend
|
|
201
|
+
"HAS_RUST_BACKEND",
|
|
190
202
|
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend detection and configuration for diff-diff.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
1. Detection of optional Rust backend
|
|
6
|
+
2. Environment variable configuration (DIFF_DIFF_BACKEND)
|
|
7
|
+
3. Exports HAS_RUST_BACKEND and Rust function references
|
|
8
|
+
|
|
9
|
+
Other modules should import from here to avoid circular imports with __init__.py.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
# Check for backend override via environment variable
|
|
15
|
+
# DIFF_DIFF_BACKEND can be: 'auto' (default), 'python', or 'rust'
|
|
16
|
+
_backend_env = os.environ.get('DIFF_DIFF_BACKEND', 'auto').lower()
|
|
17
|
+
|
|
18
|
+
# Try to import Rust backend for accelerated operations
|
|
19
|
+
try:
|
|
20
|
+
from diff_diff._rust_backend import (
|
|
21
|
+
generate_bootstrap_weights_batch as _rust_bootstrap_weights,
|
|
22
|
+
compute_synthetic_weights as _rust_synthetic_weights,
|
|
23
|
+
project_simplex as _rust_project_simplex,
|
|
24
|
+
solve_ols as _rust_solve_ols,
|
|
25
|
+
compute_robust_vcov as _rust_compute_robust_vcov,
|
|
26
|
+
)
|
|
27
|
+
_rust_available = True
|
|
28
|
+
except ImportError:
|
|
29
|
+
_rust_available = False
|
|
30
|
+
_rust_bootstrap_weights = None
|
|
31
|
+
_rust_synthetic_weights = None
|
|
32
|
+
_rust_project_simplex = None
|
|
33
|
+
_rust_solve_ols = None
|
|
34
|
+
_rust_compute_robust_vcov = None
|
|
35
|
+
|
|
36
|
+
# Determine final backend based on environment variable and availability
|
|
37
|
+
if _backend_env == 'python':
|
|
38
|
+
# Force pure Python mode - disable Rust even if available
|
|
39
|
+
HAS_RUST_BACKEND = False
|
|
40
|
+
_rust_bootstrap_weights = None
|
|
41
|
+
_rust_synthetic_weights = None
|
|
42
|
+
_rust_project_simplex = None
|
|
43
|
+
_rust_solve_ols = None
|
|
44
|
+
_rust_compute_robust_vcov = None
|
|
45
|
+
elif _backend_env == 'rust':
|
|
46
|
+
# Force Rust mode - fail if not available
|
|
47
|
+
if not _rust_available:
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"DIFF_DIFF_BACKEND=rust but Rust backend is not available. "
|
|
50
|
+
"Install with: pip install diff-diff[rust]"
|
|
51
|
+
)
|
|
52
|
+
HAS_RUST_BACKEND = True
|
|
53
|
+
else:
|
|
54
|
+
# Auto mode - use Rust if available
|
|
55
|
+
HAS_RUST_BACKEND = _rust_available
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
'HAS_RUST_BACKEND',
|
|
59
|
+
'_rust_bootstrap_weights',
|
|
60
|
+
'_rust_synthetic_weights',
|
|
61
|
+
'_rust_project_simplex',
|
|
62
|
+
'_rust_solve_ols',
|
|
63
|
+
'_rust_compute_robust_vcov',
|
|
64
|
+
]
|
|
@@ -17,6 +17,8 @@ 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.utils import within_transform as _within_transform_util
|
|
21
|
+
|
|
20
22
|
|
|
21
23
|
@dataclass
|
|
22
24
|
class Comparison2x2:
|
|
@@ -573,66 +575,16 @@ class BaconDecomposition:
|
|
|
573
575
|
treat_col: str = '__bacon_treated_internal__',
|
|
574
576
|
) -> float:
|
|
575
577
|
"""Compute TWFE estimate using within-transformation."""
|
|
576
|
-
#
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
unit_idx = np.array([unit_map[u] for u in units])
|
|
587
|
-
n_units = len(unit_map)
|
|
588
|
-
|
|
589
|
-
# Time means
|
|
590
|
-
time_map = {t: i for i, t in enumerate(df[time].unique())}
|
|
591
|
-
time_idx = np.array([time_map[t] for t in times])
|
|
592
|
-
n_times = len(time_map)
|
|
593
|
-
|
|
594
|
-
# Compute means
|
|
595
|
-
y_unit_mean = np.zeros(n_units)
|
|
596
|
-
d_unit_mean = np.zeros(n_units)
|
|
597
|
-
unit_counts = np.zeros(n_units)
|
|
598
|
-
|
|
599
|
-
for i in range(len(y)):
|
|
600
|
-
u = unit_idx[i]
|
|
601
|
-
y_unit_mean[u] += y[i]
|
|
602
|
-
d_unit_mean[u] += d[i]
|
|
603
|
-
unit_counts[u] += 1
|
|
604
|
-
|
|
605
|
-
y_unit_mean /= np.maximum(unit_counts, 1)
|
|
606
|
-
d_unit_mean /= np.maximum(unit_counts, 1)
|
|
607
|
-
|
|
608
|
-
y_time_mean = np.zeros(n_times)
|
|
609
|
-
d_time_mean = np.zeros(n_times)
|
|
610
|
-
time_counts = np.zeros(n_times)
|
|
611
|
-
|
|
612
|
-
for i in range(len(y)):
|
|
613
|
-
t = time_idx[i]
|
|
614
|
-
y_time_mean[t] += y[i]
|
|
615
|
-
d_time_mean[t] += d[i]
|
|
616
|
-
time_counts[t] += 1
|
|
617
|
-
|
|
618
|
-
y_time_mean /= np.maximum(time_counts, 1)
|
|
619
|
-
d_time_mean /= np.maximum(time_counts, 1)
|
|
620
|
-
|
|
621
|
-
# Overall mean
|
|
622
|
-
y_mean = np.mean(y)
|
|
623
|
-
d_mean = np.mean(d)
|
|
624
|
-
|
|
625
|
-
# Within transformation: y_it - y_i - y_t + y
|
|
626
|
-
y_within = np.zeros(len(y))
|
|
627
|
-
d_within = np.zeros(len(d))
|
|
628
|
-
|
|
629
|
-
for i in range(len(y)):
|
|
630
|
-
u = unit_idx[i]
|
|
631
|
-
t = time_idx[i]
|
|
632
|
-
y_within[i] = y[i] - y_unit_mean[u] - y_time_mean[t] + y_mean
|
|
633
|
-
d_within[i] = d[i] - d_unit_mean[u] - d_time_mean[t] + d_mean
|
|
634
|
-
|
|
635
|
-
# OLS on demeaned data
|
|
578
|
+
# Apply two-way within transformation
|
|
579
|
+
df_dm = _within_transform_util(
|
|
580
|
+
df, [outcome, treat_col], unit, time, suffix="_within"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# Extract within-transformed values
|
|
584
|
+
y_within = df_dm[f"{outcome}_within"].values
|
|
585
|
+
d_within = df_dm[f"{treat_col}_within"].values
|
|
586
|
+
|
|
587
|
+
# OLS on demeaned data: beta = sum(d * y) / sum(d^2)
|
|
636
588
|
d_var = np.sum(d_within ** 2)
|
|
637
589
|
if d_var > 0:
|
|
638
590
|
beta = np.sum(d_within * y_within) / d_var
|
|
@@ -23,6 +23,7 @@ from diff_diff.utils import (
|
|
|
23
23
|
WildBootstrapResults,
|
|
24
24
|
compute_confidence_interval,
|
|
25
25
|
compute_p_value,
|
|
26
|
+
demean_by_group,
|
|
26
27
|
validate_binary,
|
|
27
28
|
wild_bootstrap_se,
|
|
28
29
|
)
|
|
@@ -227,10 +228,10 @@ class DifferenceInDifferences:
|
|
|
227
228
|
# unit-invariant, so demeaning them would create multicollinearity
|
|
228
229
|
vars_to_demean = [outcome] + (covariates or [])
|
|
229
230
|
for ab_var in absorb:
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
working_data, n_fe = demean_by_group(
|
|
232
|
+
working_data, vars_to_demean, ab_var, inplace=True
|
|
233
|
+
)
|
|
234
|
+
n_absorbed_effects += n_fe
|
|
234
235
|
absorbed_vars.append(ab_var)
|
|
235
236
|
|
|
236
237
|
# Extract variables (may be demeaned if absorb was used)
|
|
@@ -828,10 +829,10 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
828
829
|
if absorb:
|
|
829
830
|
vars_to_demean = [outcome] + (covariates or [])
|
|
830
831
|
for ab_var in absorb:
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
832
|
+
working_data, n_fe = demean_by_group(
|
|
833
|
+
working_data, vars_to_demean, ab_var, inplace=True
|
|
834
|
+
)
|
|
835
|
+
n_absorbed_effects += n_fe
|
|
835
836
|
|
|
836
837
|
# Extract outcome and treatment
|
|
837
838
|
y = working_data[outcome].values.astype(float)
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Unified linear algebra backend for diff-diff.
|
|
3
3
|
|
|
4
|
-
This module provides optimized OLS and variance estimation
|
|
5
|
-
|
|
4
|
+
This module provides optimized OLS and variance estimation with an optional
|
|
5
|
+
Rust backend for maximum performance.
|
|
6
6
|
|
|
7
7
|
The key optimizations are:
|
|
8
8
|
1. scipy.linalg.lstsq with 'gelsy' driver (QR-based, faster than SVD)
|
|
9
9
|
2. Vectorized cluster-robust SE via groupby (eliminates O(n*clusters) loop)
|
|
10
10
|
3. Single interface for all estimators (reduces code duplication)
|
|
11
|
+
4. Optional Rust backend for additional speedup (when available)
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
The Rust backend is automatically used when available, with transparent
|
|
14
|
+
fallback to NumPy/SciPy implementations.
|
|
13
15
|
"""
|
|
14
16
|
|
|
15
17
|
from typing import Optional, Tuple, Union
|
|
@@ -18,6 +20,13 @@ import numpy as np
|
|
|
18
20
|
import pandas as pd
|
|
19
21
|
from scipy.linalg import lstsq as scipy_lstsq
|
|
20
22
|
|
|
23
|
+
# Import Rust backend if available (from _backend to avoid circular imports)
|
|
24
|
+
from diff_diff._backend import (
|
|
25
|
+
HAS_RUST_BACKEND,
|
|
26
|
+
_rust_compute_robust_vcov,
|
|
27
|
+
_rust_solve_ols,
|
|
28
|
+
)
|
|
29
|
+
|
|
21
30
|
|
|
22
31
|
def solve_ols(
|
|
23
32
|
X: np.ndarray,
|
|
@@ -119,6 +128,87 @@ def solve_ols(
|
|
|
119
128
|
"Clean your data or set check_finite=False to skip this check."
|
|
120
129
|
)
|
|
121
130
|
|
|
131
|
+
# Use Rust backend if available
|
|
132
|
+
# Note: Fall back to NumPy if check_finite=False since Rust's LAPACK
|
|
133
|
+
# doesn't support non-finite values
|
|
134
|
+
if HAS_RUST_BACKEND and check_finite:
|
|
135
|
+
# Ensure contiguous arrays for Rust
|
|
136
|
+
X = np.ascontiguousarray(X, dtype=np.float64)
|
|
137
|
+
y = np.ascontiguousarray(y, dtype=np.float64)
|
|
138
|
+
|
|
139
|
+
# Convert cluster_ids to int64 for Rust (if provided)
|
|
140
|
+
cluster_ids_int = None
|
|
141
|
+
if cluster_ids is not None:
|
|
142
|
+
cluster_ids_int = pd.factorize(cluster_ids)[0].astype(np.int64)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
coefficients, residuals, vcov = _rust_solve_ols(
|
|
146
|
+
X, y, cluster_ids_int, return_vcov
|
|
147
|
+
)
|
|
148
|
+
except ValueError as e:
|
|
149
|
+
# Translate Rust LAPACK errors to consistent Python error messages
|
|
150
|
+
error_msg = str(e)
|
|
151
|
+
if "Matrix inversion failed" in error_msg or "Least squares failed" in error_msg:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Design matrix is rank-deficient (singular X'X matrix). "
|
|
154
|
+
"This indicates perfect multicollinearity. Check your fixed effects "
|
|
155
|
+
"and covariates for linear dependencies."
|
|
156
|
+
) from e
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
if return_fitted:
|
|
160
|
+
fitted = X @ coefficients
|
|
161
|
+
return coefficients, residuals, fitted, vcov
|
|
162
|
+
else:
|
|
163
|
+
return coefficients, residuals, vcov
|
|
164
|
+
|
|
165
|
+
# Fallback to NumPy/SciPy implementation
|
|
166
|
+
return _solve_ols_numpy(
|
|
167
|
+
X, y, cluster_ids=cluster_ids, return_vcov=return_vcov, return_fitted=return_fitted
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _solve_ols_numpy(
|
|
172
|
+
X: np.ndarray,
|
|
173
|
+
y: np.ndarray,
|
|
174
|
+
*,
|
|
175
|
+
cluster_ids: Optional[np.ndarray] = None,
|
|
176
|
+
return_vcov: bool = True,
|
|
177
|
+
return_fitted: bool = False,
|
|
178
|
+
) -> Union[
|
|
179
|
+
Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]],
|
|
180
|
+
Tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray]],
|
|
181
|
+
]:
|
|
182
|
+
"""
|
|
183
|
+
NumPy/SciPy fallback implementation of solve_ols.
|
|
184
|
+
|
|
185
|
+
Uses scipy.linalg.lstsq with 'gelsy' driver (QR with column pivoting)
|
|
186
|
+
for fast and stable least squares solving.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
X : np.ndarray
|
|
191
|
+
Design matrix of shape (n, k).
|
|
192
|
+
y : np.ndarray
|
|
193
|
+
Response vector of shape (n,).
|
|
194
|
+
cluster_ids : np.ndarray, optional
|
|
195
|
+
Cluster identifiers for cluster-robust SEs.
|
|
196
|
+
return_vcov : bool
|
|
197
|
+
Whether to compute variance-covariance matrix.
|
|
198
|
+
return_fitted : bool
|
|
199
|
+
Whether to return fitted values.
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
coefficients : np.ndarray
|
|
204
|
+
OLS coefficients of shape (k,).
|
|
205
|
+
residuals : np.ndarray
|
|
206
|
+
Residuals of shape (n,).
|
|
207
|
+
fitted : np.ndarray, optional
|
|
208
|
+
Fitted values if return_fitted=True.
|
|
209
|
+
vcov : np.ndarray, optional
|
|
210
|
+
Variance-covariance matrix if return_vcov=True.
|
|
211
|
+
"""
|
|
122
212
|
# Solve OLS using scipy's optimized solver
|
|
123
213
|
# 'gelsy' uses QR with column pivoting, faster than default 'gelsd' (SVD)
|
|
124
214
|
# Note: gelsy doesn't reliably report rank, so we don't check for deficiency
|
|
@@ -131,7 +221,7 @@ def solve_ols(
|
|
|
131
221
|
# Compute variance-covariance matrix if requested
|
|
132
222
|
vcov = None
|
|
133
223
|
if return_vcov:
|
|
134
|
-
vcov =
|
|
224
|
+
vcov = _compute_robust_vcov_numpy(X, residuals, cluster_ids)
|
|
135
225
|
|
|
136
226
|
if return_fitted:
|
|
137
227
|
return coefficients, residuals, fitted, vcov
|
|
@@ -176,6 +266,63 @@ def compute_robust_vcov(
|
|
|
176
266
|
The cluster-robust computation is vectorized using pandas groupby,
|
|
177
267
|
which is much faster than a Python loop over clusters.
|
|
178
268
|
"""
|
|
269
|
+
# Use Rust backend if available
|
|
270
|
+
if HAS_RUST_BACKEND:
|
|
271
|
+
X = np.ascontiguousarray(X, dtype=np.float64)
|
|
272
|
+
residuals = np.ascontiguousarray(residuals, dtype=np.float64)
|
|
273
|
+
|
|
274
|
+
cluster_ids_int = None
|
|
275
|
+
if cluster_ids is not None:
|
|
276
|
+
cluster_ids_int = pd.factorize(cluster_ids)[0].astype(np.int64)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
return _rust_compute_robust_vcov(X, residuals, cluster_ids_int)
|
|
280
|
+
except ValueError as e:
|
|
281
|
+
# Translate Rust LAPACK errors to consistent Python error messages
|
|
282
|
+
error_msg = str(e)
|
|
283
|
+
if "Matrix inversion failed" in error_msg:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
"Design matrix is rank-deficient (singular X'X matrix). "
|
|
286
|
+
"This indicates perfect multicollinearity. Check your fixed effects "
|
|
287
|
+
"and covariates for linear dependencies."
|
|
288
|
+
) from e
|
|
289
|
+
raise
|
|
290
|
+
|
|
291
|
+
# Fallback to NumPy implementation
|
|
292
|
+
return _compute_robust_vcov_numpy(X, residuals, cluster_ids)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _compute_robust_vcov_numpy(
|
|
296
|
+
X: np.ndarray,
|
|
297
|
+
residuals: np.ndarray,
|
|
298
|
+
cluster_ids: Optional[np.ndarray] = None,
|
|
299
|
+
) -> np.ndarray:
|
|
300
|
+
"""
|
|
301
|
+
NumPy fallback implementation of compute_robust_vcov.
|
|
302
|
+
|
|
303
|
+
Computes HC1 (heteroskedasticity-robust) or cluster-robust variance-covariance
|
|
304
|
+
matrix using the sandwich estimator.
|
|
305
|
+
|
|
306
|
+
Parameters
|
|
307
|
+
----------
|
|
308
|
+
X : np.ndarray
|
|
309
|
+
Design matrix of shape (n, k).
|
|
310
|
+
residuals : np.ndarray
|
|
311
|
+
OLS residuals of shape (n,).
|
|
312
|
+
cluster_ids : np.ndarray, optional
|
|
313
|
+
Cluster identifiers. If None, uses HC1. If provided, uses
|
|
314
|
+
cluster-robust with G/(G-1) small-sample adjustment.
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
vcov : np.ndarray
|
|
319
|
+
Variance-covariance matrix of shape (k, k).
|
|
320
|
+
|
|
321
|
+
Notes
|
|
322
|
+
-----
|
|
323
|
+
Uses vectorized groupby aggregation for cluster-robust SEs to avoid
|
|
324
|
+
the O(n * G) loop that would be required with explicit iteration.
|
|
325
|
+
"""
|
|
179
326
|
n, k = X.shape
|
|
180
327
|
XtX = X.T @ X
|
|
181
328
|
|