statgpu 0.1.0__py3-none-any.whl
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.
- statgpu/__init__.py +174 -0
- statgpu/_base.py +544 -0
- statgpu/_config.py +127 -0
- statgpu/anova/__init__.py +5 -0
- statgpu/anova/_oneway.py +194 -0
- statgpu/backends/__init__.py +83 -0
- statgpu/backends/_array_ops.py +529 -0
- statgpu/backends/_base.py +184 -0
- statgpu/backends/_cupy.py +453 -0
- statgpu/backends/_factory.py +65 -0
- statgpu/backends/_gpu_inference_cupy.py +214 -0
- statgpu/backends/_gpu_inference_torch.py +422 -0
- statgpu/backends/_numpy.py +324 -0
- statgpu/backends/_torch.py +685 -0
- statgpu/backends/_torch_safe.py +47 -0
- statgpu/backends/_utils.py +423 -0
- statgpu/core/__init__.py +10 -0
- statgpu/core/formula/__init__.py +33 -0
- statgpu/core/formula/_design.py +99 -0
- statgpu/core/formula/_parser.py +191 -0
- statgpu/core/formula/_terms.py +70 -0
- statgpu/core/formula/tests/__init__.py +0 -0
- statgpu/core/formula/tests/test_parser.py +194 -0
- statgpu/covariance/__init__.py +6 -0
- statgpu/covariance/_empirical.py +310 -0
- statgpu/covariance/_shrinkage.py +248 -0
- statgpu/cross_validation/__init__.py +31 -0
- statgpu/cross_validation/_base.py +410 -0
- statgpu/cross_validation/_engine.py +167 -0
- statgpu/diagnostics/__init__.py +7 -0
- statgpu/diagnostics/_regression_diagnostics.py +188 -0
- statgpu/feature_selection/__init__.py +24 -0
- statgpu/feature_selection/_knockoff.py +870 -0
- statgpu/feature_selection/_knockoff_utils.py +1003 -0
- statgpu/feature_selection/_stepwise.py +300 -0
- statgpu/glm_core/__init__.py +81 -0
- statgpu/glm_core/_base.py +202 -0
- statgpu/glm_core/_family.py +362 -0
- statgpu/glm_core/_fused.py +149 -0
- statgpu/glm_core/_gamma.py +111 -0
- statgpu/glm_core/_inverse_gaussian.py +62 -0
- statgpu/glm_core/_irls.py +561 -0
- statgpu/glm_core/_logistic.py +82 -0
- statgpu/glm_core/_negative_binomial.py +68 -0
- statgpu/glm_core/_poisson.py +60 -0
- statgpu/glm_core/_solver_legacy.py +100 -0
- statgpu/glm_core/_squared.py +53 -0
- statgpu/glm_core/_tweedie.py +74 -0
- statgpu/inference/__init__.py +239 -0
- statgpu/inference/_distributions_backend.py +2610 -0
- statgpu/inference/_multiple_testing.py +391 -0
- statgpu/inference/_resampling.py +1400 -0
- statgpu/inference/_results.py +265 -0
- statgpu/linear_model/__init__.py +75 -0
- statgpu/linear_model/_gaussian_inference.py +306 -0
- statgpu/linear_model/_glm_base.py +1261 -0
- statgpu/linear_model/_ordered_logit.py +52 -0
- statgpu/linear_model/_ordered_probit.py +50 -0
- statgpu/linear_model/_stats.py +170 -0
- statgpu/linear_model/cv/__init__.py +13 -0
- statgpu/linear_model/cv/_elasticnet_cv.py +892 -0
- statgpu/linear_model/cv/_lasso_cv.py +253 -0
- statgpu/linear_model/cv/_logistic_cv.py +895 -0
- statgpu/linear_model/cv/_ridge_cv.py +1160 -0
- statgpu/linear_model/legacy/__init__.py +1 -0
- statgpu/linear_model/legacy/_distributions_legacy_gpu.py +340 -0
- statgpu/linear_model/legacy/_elasticnet_legacy.py +936 -0
- statgpu/linear_model/legacy/_lasso_legacy.py +4876 -0
- statgpu/linear_model/legacy/_penalized_legacy.py +1174 -0
- statgpu/linear_model/legacy/_ridge_legacy.py +863 -0
- statgpu/linear_model/legacy/_solver_legacy.py +104 -0
- statgpu/linear_model/penalized/__init__.py +25 -0
- statgpu/linear_model/penalized/_base.py +437 -0
- statgpu/linear_model/penalized/_fit_mixin.py +1877 -0
- statgpu/linear_model/penalized/_inference_mixin.py +1179 -0
- statgpu/linear_model/penalized/_penalized_cv.py +2699 -0
- statgpu/linear_model/penalized/_penalized_gamma.py +86 -0
- statgpu/linear_model/penalized/_penalized_inverse_gaussian.py +62 -0
- statgpu/linear_model/penalized/_penalized_linear.py +236 -0
- statgpu/linear_model/penalized/_penalized_logistic.py +100 -0
- statgpu/linear_model/penalized/_penalized_negative_binomial.py +65 -0
- statgpu/linear_model/penalized/_penalized_poisson.py +62 -0
- statgpu/linear_model/penalized/_penalized_tweedie.py +65 -0
- statgpu/linear_model/penalized/_predict_mixin.py +182 -0
- statgpu/linear_model/wrappers/__init__.py +31 -0
- statgpu/linear_model/wrappers/_adaptive_lasso.py +63 -0
- statgpu/linear_model/wrappers/_elasticnet.py +75 -0
- statgpu/linear_model/wrappers/_gamma.py +67 -0
- statgpu/linear_model/wrappers/_inverse_gaussian.py +47 -0
- statgpu/linear_model/wrappers/_lasso.py +2124 -0
- statgpu/linear_model/wrappers/_linear.py +1127 -0
- statgpu/linear_model/wrappers/_logistic.py +1435 -0
- statgpu/linear_model/wrappers/_mcp.py +58 -0
- statgpu/linear_model/wrappers/_negative_binomial.py +58 -0
- statgpu/linear_model/wrappers/_poisson.py +48 -0
- statgpu/linear_model/wrappers/_ridge.py +166 -0
- statgpu/linear_model/wrappers/_scad.py +58 -0
- statgpu/linear_model/wrappers/_tweedie.py +57 -0
- statgpu/metrics/__init__.py +21 -0
- statgpu/metrics/_classification.py +591 -0
- statgpu/nonparametric/__init__.py +50 -0
- statgpu/nonparametric/kernel_methods/__init__.py +25 -0
- statgpu/nonparametric/kernel_methods/_kernels.py +246 -0
- statgpu/nonparametric/kernel_methods/_krr.py +234 -0
- statgpu/nonparametric/kernel_methods/_krr_cv.py +380 -0
- statgpu/nonparametric/kernel_smoothing/__init__.py +39 -0
- statgpu/nonparametric/kernel_smoothing/_bandwidth_selection.py +1083 -0
- statgpu/nonparametric/kernel_smoothing/_kde.py +761 -0
- statgpu/nonparametric/kernel_smoothing/_kernel_common.py +348 -0
- statgpu/nonparametric/kernel_smoothing/_kernel_regression.py +748 -0
- statgpu/nonparametric/splines/__init__.py +5 -0
- statgpu/nonparametric/splines/_bspline_basis.py +336 -0
- statgpu/nonparametric/splines/_penalized.py +349 -0
- statgpu/panel/__init__.py +19 -0
- statgpu/panel/_covariance.py +140 -0
- statgpu/panel/_fixed_effects.py +420 -0
- statgpu/panel/_random_effects.py +385 -0
- statgpu/panel/_utils.py +482 -0
- statgpu/penalties/__init__.py +139 -0
- statgpu/penalties/_adaptive_l1.py +313 -0
- statgpu/penalties/_base.py +261 -0
- statgpu/penalties/_categories.py +39 -0
- statgpu/penalties/_elasticnet.py +98 -0
- statgpu/penalties/_group_lasso.py +678 -0
- statgpu/penalties/_group_mcp.py +553 -0
- statgpu/penalties/_group_scad.py +605 -0
- statgpu/penalties/_l1.py +107 -0
- statgpu/penalties/_l2.py +77 -0
- statgpu/penalties/_mcp.py +237 -0
- statgpu/penalties/_scad.py +260 -0
- statgpu/semiparametric/__init__.py +5 -0
- statgpu/semiparametric/_gam.py +401 -0
- statgpu/solvers/__init__.py +24 -0
- statgpu/solvers/_admm.py +241 -0
- statgpu/solvers/_constants.py +15 -0
- statgpu/solvers/_convergence.py +6 -0
- statgpu/solvers/_fista.py +436 -0
- statgpu/solvers/_fista_bb.py +513 -0
- statgpu/solvers/_fista_lla.py +541 -0
- statgpu/solvers/_lbfgs.py +206 -0
- statgpu/solvers/_newton.py +149 -0
- statgpu/solvers/_utils.py +277 -0
- statgpu/survival/__init__.py +14 -0
- statgpu/survival/_cox.py +3974 -0
- statgpu/survival/_cox_breslow_triton_kernel.py +106 -0
- statgpu/survival/_cox_cv.py +1159 -0
- statgpu/survival/_cox_efron_cuda.py +1280 -0
- statgpu/survival/_cox_efron_triton.py +359 -0
- statgpu/unsupervised/__init__.py +29 -0
- statgpu/unsupervised/_agglomerative.py +307 -0
- statgpu/unsupervised/_dbscan.py +263 -0
- statgpu/unsupervised/_dbscan_cpu.pyx +125 -0
- statgpu/unsupervised/_gmm.py +332 -0
- statgpu/unsupervised/_incremental_pca.py +176 -0
- statgpu/unsupervised/_kmeans.py +261 -0
- statgpu/unsupervised/_minibatch_kmeans.py +299 -0
- statgpu/unsupervised/_minibatch_nmf.py +252 -0
- statgpu/unsupervised/_nmf.py +190 -0
- statgpu/unsupervised/_pca.py +189 -0
- statgpu/unsupervised/_truncated_svd.py +132 -0
- statgpu/unsupervised/_tsne.py +192 -0
- statgpu/unsupervised/_umap.py +224 -0
- statgpu/unsupervised/_utils.py +134 -0
- statgpu-0.1.0.dist-info/METADATA +245 -0
- statgpu-0.1.0.dist-info/RECORD +168 -0
- statgpu-0.1.0.dist-info/WHEEL +5 -0
- statgpu-0.1.0.dist-info/licenses/LICENSE +199 -0
- statgpu-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clustered covariance estimators for panel data models.
|
|
3
|
+
|
|
4
|
+
Implements one-way and two-way clustered standard errors following
|
|
5
|
+
Cameron & Miller (2015) and Cameron, Gelbach & Miller (2011).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__ = ["clustered_covariance", "two_way_clustered_covariance"]
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from statgpu.backends import (
|
|
16
|
+
_LINALG_ERRORS,
|
|
17
|
+
_get_torch_device_str,
|
|
18
|
+
_torch_dev,
|
|
19
|
+
_to_numpy,
|
|
20
|
+
xp_asarray,
|
|
21
|
+
xp_zeros,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ensure_xp(xp=None):
|
|
26
|
+
"""Return the array module, defaulting to numpy."""
|
|
27
|
+
return xp if xp is not None else np
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clustered_covariance(X, resid, clusters, xp=None):
|
|
31
|
+
"""One-way clustered robust covariance matrix.
|
|
32
|
+
|
|
33
|
+
Implements the cluster-robust sandwich estimator:
|
|
34
|
+
|
|
35
|
+
V = (X'X/n)^{-1} @ meat @ (X'X/n)^{-1}
|
|
36
|
+
|
|
37
|
+
where ``meat = sum_g (X_g' e_g)(X_g' e_g)'`` summed over clusters.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
X : array-like, shape (n, k)
|
|
42
|
+
Design matrix (including intercept if applicable).
|
|
43
|
+
resid : array-like, shape (n,)
|
|
44
|
+
OLS residuals.
|
|
45
|
+
clusters : array-like, shape (n,)
|
|
46
|
+
Cluster assignment labels (integer or categorical).
|
|
47
|
+
xp : module, optional
|
|
48
|
+
Array module (numpy / cupy / torch). Defaults to numpy.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
V : array, shape (k, k)
|
|
53
|
+
Cluster-robust covariance matrix of the coefficient estimates.
|
|
54
|
+
"""
|
|
55
|
+
xp = _ensure_xp(xp)
|
|
56
|
+
|
|
57
|
+
X = xp_asarray(X, dtype=xp.float64, xp=xp)
|
|
58
|
+
resid = xp_asarray(resid, dtype=xp.float64, xp=xp, ref_arr=X).ravel()
|
|
59
|
+
clusters = xp_asarray(clusters, xp=xp, ref_arr=X).ravel()
|
|
60
|
+
|
|
61
|
+
n, k = X.shape
|
|
62
|
+
|
|
63
|
+
# Bread: (X'X / n)^{-1}
|
|
64
|
+
XtX = X.T @ X / n
|
|
65
|
+
try:
|
|
66
|
+
bread = xp.linalg.inv(XtX)
|
|
67
|
+
except _LINALG_ERRORS:
|
|
68
|
+
bread = xp.linalg.pinv(XtX)
|
|
69
|
+
|
|
70
|
+
# Meat: sum over clusters of (X_g' e_g)(X_g' e_g)'
|
|
71
|
+
# Batch-transfer unique cluster values to CPU (single sync, not per-cluster)
|
|
72
|
+
unique_clusters_cpu = _to_numpy(xp.unique(clusters)).tolist()
|
|
73
|
+
meat = xp_zeros((k, k), xp.float64, xp, X)
|
|
74
|
+
for g_val in unique_clusters_cpu:
|
|
75
|
+
mask = clusters == g_val
|
|
76
|
+
Xg = X[mask]
|
|
77
|
+
eg = resid[mask]
|
|
78
|
+
Xe = Xg.T @ eg # shape (k,)
|
|
79
|
+
meat = meat + xp.outer(Xe, Xe)
|
|
80
|
+
|
|
81
|
+
# Sandwich: V = bread @ meat @ bread / n^2
|
|
82
|
+
V = bread @ meat @ bread / (n * n)
|
|
83
|
+
return V
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def two_way_clustered_covariance(X, resid, cluster1, cluster2, xp=None):
|
|
87
|
+
"""Two-way clustered robust covariance matrix.
|
|
88
|
+
|
|
89
|
+
Implements the Cameron, Gelbach & Miller (2011) intersection
|
|
90
|
+
correction::
|
|
91
|
+
|
|
92
|
+
V = V_cluster1 + V_cluster2 - V_intersection
|
|
93
|
+
|
|
94
|
+
where the intersection clusters are formed from all unique
|
|
95
|
+
``(cluster1, cluster2)`` pairs.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
X : array-like, shape (n, k)
|
|
100
|
+
Design matrix.
|
|
101
|
+
resid : array-like, shape (n,)
|
|
102
|
+
OLS residuals.
|
|
103
|
+
cluster1 : array-like, shape (n,)
|
|
104
|
+
First cluster dimension (e.g. entity). Accepts integer or
|
|
105
|
+
categorical labels (will be factorized to integers internally).
|
|
106
|
+
cluster2 : array-like, shape (n,)
|
|
107
|
+
Second cluster dimension (e.g. time). Same as cluster1.
|
|
108
|
+
xp : module, optional
|
|
109
|
+
Array module (numpy / cupy / torch). Defaults to numpy.
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
V : array, shape (k, k)
|
|
114
|
+
Two-way cluster-robust covariance matrix.
|
|
115
|
+
"""
|
|
116
|
+
xp = _ensure_xp(xp)
|
|
117
|
+
|
|
118
|
+
V1 = clustered_covariance(X, resid, cluster1, xp)
|
|
119
|
+
V2 = clustered_covariance(X, resid, cluster2, xp)
|
|
120
|
+
|
|
121
|
+
# Intersection clusters: unique (c1, c2) pairs via Cantor-pair hash
|
|
122
|
+
# Factorize labels to integers (supports string/categorical labels)
|
|
123
|
+
c1_raw = _to_numpy(xp_asarray(cluster1, xp=xp, ref_arr=V1).ravel())
|
|
124
|
+
c2_raw = _to_numpy(xp_asarray(cluster2, xp=xp, ref_arr=V1).ravel())
|
|
125
|
+
_, c1 = np.unique(c1_raw, return_inverse=True)
|
|
126
|
+
_, c2 = np.unique(c2_raw, return_inverse=True)
|
|
127
|
+
# Use Python int for Cantor-pair to avoid int64 overflow with
|
|
128
|
+
# large cluster counts (>~3 billion unique combinations).
|
|
129
|
+
c1_int = [int(x) for x in c1]
|
|
130
|
+
c2_int = [int(x) for x in c2]
|
|
131
|
+
combined_np = np.array(
|
|
132
|
+
[s * (s + 1) // 2 + c2i for s, c2i in zip(
|
|
133
|
+
[a + b for a, b in zip(c1_int, c2_int)], c2_int
|
|
134
|
+
)],
|
|
135
|
+
dtype=np.int64,
|
|
136
|
+
)
|
|
137
|
+
combined = xp_asarray(combined_np, dtype=xp.int64, xp=xp, ref_arr=V1)
|
|
138
|
+
|
|
139
|
+
V12 = clustered_covariance(X, resid, combined, xp)
|
|
140
|
+
return V1 + V2 - V12
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fixed effects panel data model (PanelOLS).
|
|
3
|
+
|
|
4
|
+
Implements one-way and two-way fixed effects estimation with support
|
|
5
|
+
for non-robust, HC1 robust, and clustered standard errors. GPU
|
|
6
|
+
acceleration is provided transparently via the statgpu backend system.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = ["PanelOLS"]
|
|
12
|
+
|
|
13
|
+
from typing import Optional, Union
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy import stats
|
|
17
|
+
|
|
18
|
+
from statgpu._base import BaseEstimator
|
|
19
|
+
from statgpu._config import Device
|
|
20
|
+
from statgpu.backends import _LINALG_ERRORS, _get_torch_device_str, _torch_dev, _to_float_scalar, _to_numpy, xp_astype, xp_cholesky_solve
|
|
21
|
+
|
|
22
|
+
from statgpu.panel._utils import PanelSummary, _scatter_add, demean_variables
|
|
23
|
+
from statgpu.panel._covariance import clustered_covariance, two_way_clustered_covariance
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PanelOLS(BaseEstimator):
|
|
27
|
+
"""Fixed effects estimator for panel data.
|
|
28
|
+
|
|
29
|
+
Supports entity (individual) fixed effects, time fixed effects,
|
|
30
|
+
and two-way fixed effects via the within transformation.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
entity_effects : bool, default=False
|
|
35
|
+
Include entity (individual) fixed effects.
|
|
36
|
+
time_effects : bool, default=False
|
|
37
|
+
Include time fixed effects.
|
|
38
|
+
cov_type : str, default='nonrobust'
|
|
39
|
+
Covariance estimator: ``'nonrobust'``, ``'robust'`` (HC1), or
|
|
40
|
+
``'clustered'``.
|
|
41
|
+
device : str or Device, default='auto'
|
|
42
|
+
Computation device.
|
|
43
|
+
|
|
44
|
+
Attributes
|
|
45
|
+
----------
|
|
46
|
+
coef_ : ndarray, shape (k,)
|
|
47
|
+
Estimated slope coefficients.
|
|
48
|
+
bse_ : ndarray, shape (k,)
|
|
49
|
+
Standard errors.
|
|
50
|
+
tvalues_ : ndarray, shape (k,)
|
|
51
|
+
t-statistics.
|
|
52
|
+
pvalues_ : ndarray, shape (k,)
|
|
53
|
+
Two-sided p-values.
|
|
54
|
+
conf_int_ : ndarray, shape (k, 2)
|
|
55
|
+
95 % confidence intervals.
|
|
56
|
+
rsquared_within : float
|
|
57
|
+
Within R-squared (variance explained by regressors after demeaning).
|
|
58
|
+
nobs : int
|
|
59
|
+
Number of observations used in estimation.
|
|
60
|
+
df_resid : int
|
|
61
|
+
Residual degrees of freedom.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
entity_effects: bool = False,
|
|
67
|
+
time_effects: bool = False,
|
|
68
|
+
cov_type: str = 'nonrobust',
|
|
69
|
+
alpha: float = 0.05,
|
|
70
|
+
device: Union[str, Device] = Device.AUTO,
|
|
71
|
+
n_jobs: Optional[int] = None,
|
|
72
|
+
):
|
|
73
|
+
super().__init__(device=device, n_jobs=n_jobs)
|
|
74
|
+
self.entity_effects = entity_effects
|
|
75
|
+
self.time_effects = time_effects
|
|
76
|
+
self.cov_type = cov_type.lower()
|
|
77
|
+
self.alpha = alpha
|
|
78
|
+
if self.cov_type not in ('nonrobust', 'robust', 'clustered'):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"cov_type must be 'nonrobust', 'robust', or 'clustered'"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Public attributes set by fit()
|
|
84
|
+
self.coef_ = None
|
|
85
|
+
self.bse_ = None
|
|
86
|
+
self.tvalues_ = None
|
|
87
|
+
self.pvalues_ = None
|
|
88
|
+
self.conf_int_ = None
|
|
89
|
+
self.rsquared_within = None
|
|
90
|
+
self.nobs = None
|
|
91
|
+
self.df_resid = None
|
|
92
|
+
|
|
93
|
+
# Internal storage
|
|
94
|
+
self._params = None
|
|
95
|
+
self._scale = None
|
|
96
|
+
self._entity_effects_map = {}
|
|
97
|
+
self._time_effects_map = {}
|
|
98
|
+
|
|
99
|
+
def fit(self, X, y, entity_ids=None, time_ids=None, cluster=None):
|
|
100
|
+
"""Fit the fixed effects model.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
X : array-like, shape (n, k)
|
|
105
|
+
Regressor matrix. Include a constant column if you want an
|
|
106
|
+
intercept (the model does not add one automatically).
|
|
107
|
+
y : array-like, shape (n,)
|
|
108
|
+
Outcome vector.
|
|
109
|
+
entity_ids : array-like, shape (n,), optional
|
|
110
|
+
Entity (individual) identifiers. Required when
|
|
111
|
+
``entity_effects=True``.
|
|
112
|
+
time_ids : array-like, shape (n,), optional
|
|
113
|
+
Time-period identifiers. Required when ``time_effects=True``.
|
|
114
|
+
cluster : array-like, shape (n,), optional
|
|
115
|
+
Cluster labels for clustered standard errors. Required when
|
|
116
|
+
``cov_type='clustered'``.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
self
|
|
121
|
+
"""
|
|
122
|
+
# Resolve backend
|
|
123
|
+
backend = self._get_backend(backend='auto')
|
|
124
|
+
backend_name = backend.name
|
|
125
|
+
xp = backend.xp
|
|
126
|
+
|
|
127
|
+
# Convert inputs to backend arrays
|
|
128
|
+
y_arr = xp_astype(self._to_array(y, backend=backend_name).ravel(), xp.float64, xp)
|
|
129
|
+
X_arr = xp_astype(self._to_array(X, backend=backend_name), xp.float64, xp)
|
|
130
|
+
if X_arr.ndim == 1:
|
|
131
|
+
X_arr = X_arr.reshape(-1, 1)
|
|
132
|
+
|
|
133
|
+
n, k = X_arr.shape
|
|
134
|
+
self.nobs = n
|
|
135
|
+
|
|
136
|
+
# Validate shapes
|
|
137
|
+
if y_arr.shape[0] != n:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"y has {y_arr.shape[0]} observations but X has {n} rows"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Validate
|
|
143
|
+
if self.entity_effects and entity_ids is None:
|
|
144
|
+
raise ValueError("entity_ids is required when entity_effects=True")
|
|
145
|
+
if self.time_effects and time_ids is None:
|
|
146
|
+
raise ValueError("time_ids is required when time_effects=True")
|
|
147
|
+
if self.cov_type == 'clustered' and cluster is None:
|
|
148
|
+
raise ValueError("cluster is required when cov_type='clustered'")
|
|
149
|
+
|
|
150
|
+
entity_arr = None
|
|
151
|
+
time_arr = None
|
|
152
|
+
if entity_ids is not None:
|
|
153
|
+
entity_arr = self._to_array(entity_ids, backend=backend_name).ravel()
|
|
154
|
+
if time_ids is not None:
|
|
155
|
+
time_arr = self._to_array(time_ids, backend=backend_name).ravel()
|
|
156
|
+
|
|
157
|
+
# Demean if fixed effects requested
|
|
158
|
+
if self.entity_effects or self.time_effects:
|
|
159
|
+
y_d, X_d = demean_variables(
|
|
160
|
+
y_arr, X_arr,
|
|
161
|
+
entity_ids=entity_arr if self.entity_effects else None,
|
|
162
|
+
time_ids=time_arr if self.time_effects else None,
|
|
163
|
+
xp=xp,
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
y_d = y_arr
|
|
167
|
+
X_d = X_arr
|
|
168
|
+
|
|
169
|
+
# OLS on demeaned data: beta = (X'X)^{-1} X'y
|
|
170
|
+
XtX = X_d.T @ X_d
|
|
171
|
+
Xty = X_d.T @ y_d
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
coef = xp_cholesky_solve(XtX, Xty, xp)
|
|
175
|
+
except _LINALG_ERRORS:
|
|
176
|
+
coef = xp.linalg.solve(XtX, Xty)
|
|
177
|
+
|
|
178
|
+
# Degrees of freedom
|
|
179
|
+
n_entities = len(xp.unique(entity_arr)) if entity_arr is not None else 0
|
|
180
|
+
n_times = len(xp.unique(time_arr)) if time_arr is not None else 0
|
|
181
|
+
n_effects = 0
|
|
182
|
+
if self.entity_effects:
|
|
183
|
+
n_effects += n_entities - 1
|
|
184
|
+
if self.time_effects:
|
|
185
|
+
n_effects += n_times - 1
|
|
186
|
+
self.df_resid = n - k - n_effects
|
|
187
|
+
|
|
188
|
+
if self.df_resid <= 0:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"Not enough observations: n={n}, k={k}, n_effects={n_effects}, "
|
|
191
|
+
f"df_resid={self.df_resid}. Check that N*T >> k + effects."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Residuals and scale (on the demeaned data, all on device)
|
|
195
|
+
y_pred = X_d @ coef
|
|
196
|
+
resid = y_d - y_pred
|
|
197
|
+
scale = _to_float_scalar(xp.sum(resid ** 2)) / self.df_resid
|
|
198
|
+
self._scale = scale
|
|
199
|
+
|
|
200
|
+
# Compute entity/time effects for predict()
|
|
201
|
+
# Subtract grand mean to avoid double-counting in two-way FE
|
|
202
|
+
self._entity_effects_map = {}
|
|
203
|
+
self._time_effects_map = {}
|
|
204
|
+
resid_orig = y_arr - X_arr @ coef
|
|
205
|
+
grand_mean = float(xp.mean(resid_orig))
|
|
206
|
+
resid_centered = resid_orig - grand_mean
|
|
207
|
+
self._grand_mean = grand_mean
|
|
208
|
+
|
|
209
|
+
if self.entity_effects and entity_arr is not None:
|
|
210
|
+
ent_np = _to_numpy(entity_arr).ravel()
|
|
211
|
+
unique_ent, idx_np = np.unique(ent_np, return_inverse=True)
|
|
212
|
+
idx_dev = xp.asarray(idx_np, dtype=xp.int64)
|
|
213
|
+
ent_sums = _scatter_add(xp, idx_dev, resid_centered, len(unique_ent))
|
|
214
|
+
ent_counts = _scatter_add(xp, idx_dev, xp.ones_like(resid_centered), len(unique_ent))
|
|
215
|
+
ent_effects = _to_numpy(ent_sums / xp.maximum(ent_counts, 1.0)).ravel()
|
|
216
|
+
for i, eid in enumerate(unique_ent):
|
|
217
|
+
self._entity_effects_map[eid] = float(ent_effects[i])
|
|
218
|
+
if self.time_effects and time_arr is not None:
|
|
219
|
+
time_np = _to_numpy(time_arr).ravel()
|
|
220
|
+
unique_time, idx_np = np.unique(time_np, return_inverse=True)
|
|
221
|
+
idx_dev = xp.asarray(idx_np, dtype=xp.int64)
|
|
222
|
+
time_sums = _scatter_add(xp, idx_dev, resid_centered, len(unique_time))
|
|
223
|
+
time_counts = _scatter_add(xp, idx_dev, xp.ones_like(resid_centered), len(unique_time))
|
|
224
|
+
time_effects = _to_numpy(time_sums / xp.maximum(time_counts, 1.0)).ravel()
|
|
225
|
+
for i, tid in enumerate(unique_time):
|
|
226
|
+
self._time_effects_map[tid] = float(time_effects[i])
|
|
227
|
+
|
|
228
|
+
# Keep arrays on device for inference — only transfer final results
|
|
229
|
+
self._compute_inference(xp, cluster, backend_name,
|
|
230
|
+
X_d, coef, resid, y_d)
|
|
231
|
+
|
|
232
|
+
# Single batch transfer of final results to CPU
|
|
233
|
+
self._params = _to_numpy(coef).ravel()
|
|
234
|
+
self.coef_ = self._params
|
|
235
|
+
|
|
236
|
+
self._fitted = True
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def _compute_inference(self, xp, cluster, backend_name,
|
|
240
|
+
X_d, coef, resid, y_d):
|
|
241
|
+
"""Compute SE, t-values, p-values, and CIs — all on device.
|
|
242
|
+
|
|
243
|
+
Uses statgpu's backend-agnostic inference framework for p-values,
|
|
244
|
+
so no GPU→CPU transfer is needed for the computation. Only the
|
|
245
|
+
final numpy result vectors are stored for the user API.
|
|
246
|
+
"""
|
|
247
|
+
from statgpu.inference._distributions_backend import get_distribution
|
|
248
|
+
|
|
249
|
+
n, k = X_d.shape
|
|
250
|
+
df = self.df_resid
|
|
251
|
+
alpha = self.alpha
|
|
252
|
+
|
|
253
|
+
# XtX and its inverse — on device
|
|
254
|
+
XtX = X_d.T @ X_d
|
|
255
|
+
try:
|
|
256
|
+
XtX_inv = xp.linalg.inv(XtX)
|
|
257
|
+
except _LINALG_ERRORS:
|
|
258
|
+
XtX_inv = xp.linalg.pinv(XtX)
|
|
259
|
+
|
|
260
|
+
if self.cov_type == 'nonrobust':
|
|
261
|
+
cov_params = self._scale * XtX_inv
|
|
262
|
+
bse_dev = xp.sqrt(xp.maximum(xp.diag(cov_params), 0.0))
|
|
263
|
+
|
|
264
|
+
elif self.cov_type == 'robust':
|
|
265
|
+
# HC1 sandwich — on device
|
|
266
|
+
# Use df_resid (not n-k) to account for absorbed fixed effects
|
|
267
|
+
e2 = resid ** 2
|
|
268
|
+
Xw = X_d * e2[:, None]
|
|
269
|
+
meat = X_d.T @ Xw
|
|
270
|
+
cov_params = XtX_inv @ meat @ XtX_inv
|
|
271
|
+
if self.df_resid > 0:
|
|
272
|
+
cov_params = cov_params * (n / self.df_resid)
|
|
273
|
+
bse_dev = xp.sqrt(xp.maximum(xp.diag(cov_params), 0.0))
|
|
274
|
+
|
|
275
|
+
else: # clustered
|
|
276
|
+
cluster_np = _to_numpy(cluster)
|
|
277
|
+
# Validate cluster length matches fitted data
|
|
278
|
+
if len(cluster_np) != X_d.shape[0]:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"cluster length ({len(cluster_np)}) does not match "
|
|
281
|
+
f"data length ({X_d.shape[0]})"
|
|
282
|
+
)
|
|
283
|
+
if cluster_np.ndim == 2 and cluster_np.shape[1] == 2:
|
|
284
|
+
V = two_way_clustered_covariance(
|
|
285
|
+
X_d, resid, cluster_np[:, 0], cluster_np[:, 1], xp=xp
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
V = clustered_covariance(X_d, resid, cluster_np, xp=xp)
|
|
289
|
+
bse_dev = xp.sqrt(xp.maximum(xp.diag(V), 0.0))
|
|
290
|
+
|
|
291
|
+
# t-values — on device
|
|
292
|
+
_eps = xp.finfo(xp.float64).tiny if hasattr(xp, 'finfo') else 2.2e-308
|
|
293
|
+
tvalues_dev = coef / xp.maximum(bse_dev, _eps)
|
|
294
|
+
abs_t = xp.abs(tvalues_dev)
|
|
295
|
+
|
|
296
|
+
# p-values via backend-agnostic inference framework — on device
|
|
297
|
+
if self.cov_type in ('nonrobust',):
|
|
298
|
+
t_dist = get_distribution("t", backend=backend_name)
|
|
299
|
+
pvalues_dev = 2.0 * t_dist.sf(abs_t, float(df))
|
|
300
|
+
t_crit = float(t_dist.isf(xp.asarray([alpha / 2.0]), float(df))[0])
|
|
301
|
+
else:
|
|
302
|
+
norm_dist = get_distribution("norm", backend=backend_name)
|
|
303
|
+
pvalues_dev = 2.0 * norm_dist.sf(abs_t)
|
|
304
|
+
t_crit = float(norm_dist.isf(xp.asarray([alpha / 2.0]))[0])
|
|
305
|
+
|
|
306
|
+
# Final transfer: only k-length vectors to CPU for storage
|
|
307
|
+
self.bse_ = _to_numpy(bse_dev).ravel()
|
|
308
|
+
self.tvalues_ = _to_numpy(tvalues_dev).ravel()
|
|
309
|
+
self.pvalues_ = _to_numpy(pvalues_dev).ravel()
|
|
310
|
+
|
|
311
|
+
coef_np = _to_numpy(coef).ravel()
|
|
312
|
+
self.conf_int_ = np.column_stack([
|
|
313
|
+
coef_np - t_crit * self.bse_,
|
|
314
|
+
coef_np + t_crit * self.bse_,
|
|
315
|
+
])
|
|
316
|
+
|
|
317
|
+
# Within R-squared — on device, single sync
|
|
318
|
+
ss_res = _to_float_scalar(xp.sum(resid ** 2))
|
|
319
|
+
y_d_mean = _to_float_scalar(xp.mean(y_d))
|
|
320
|
+
ss_tot = _to_float_scalar(xp.sum((y_d - y_d_mean) ** 2))
|
|
321
|
+
self.rsquared_within = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
|
|
322
|
+
|
|
323
|
+
def predict(self, X, entity_ids=None, time_ids=None):
|
|
324
|
+
"""Predict using the fitted model.
|
|
325
|
+
|
|
326
|
+
If the model was fitted with entity/time effects and the
|
|
327
|
+
corresponding identifiers are provided, the predictions include
|
|
328
|
+
the estimated fixed effects.
|
|
329
|
+
|
|
330
|
+
Parameters
|
|
331
|
+
----------
|
|
332
|
+
X : array-like, shape (n, k)
|
|
333
|
+
Regressor matrix.
|
|
334
|
+
entity_ids : array-like, shape (n,), optional
|
|
335
|
+
Entity identifiers. Required to include entity effects in
|
|
336
|
+
the prediction.
|
|
337
|
+
time_ids : array-like, shape (n,), optional
|
|
338
|
+
Time-period identifiers. Required to include time effects
|
|
339
|
+
in the prediction.
|
|
340
|
+
|
|
341
|
+
Returns
|
|
342
|
+
-------
|
|
343
|
+
y_pred : ndarray, shape (n,)
|
|
344
|
+
Predicted values.
|
|
345
|
+
"""
|
|
346
|
+
self._check_is_fitted()
|
|
347
|
+
X_arr = np.asarray(X, dtype=np.float64)
|
|
348
|
+
if X_arr.ndim == 1:
|
|
349
|
+
X_arr = X_arr.reshape(-1, 1)
|
|
350
|
+
y_pred = X_arr @ self.coef_
|
|
351
|
+
|
|
352
|
+
# Add entity effects via vectorized lookup
|
|
353
|
+
if self._entity_effects_map and entity_ids is not None:
|
|
354
|
+
ent_arr = np.asarray(entity_ids).ravel()
|
|
355
|
+
ent_effects = np.vectorize(
|
|
356
|
+
self._entity_effects_map.get, otypes=[np.float64]
|
|
357
|
+
)(ent_arr, 0.0)
|
|
358
|
+
y_pred = y_pred + ent_effects
|
|
359
|
+
|
|
360
|
+
# Add time effects via vectorized lookup
|
|
361
|
+
if self._time_effects_map and time_ids is not None:
|
|
362
|
+
time_arr = np.asarray(time_ids).ravel()
|
|
363
|
+
time_effects = np.vectorize(
|
|
364
|
+
self._time_effects_map.get, otypes=[np.float64]
|
|
365
|
+
)(time_arr, 0.0)
|
|
366
|
+
y_pred = y_pred + time_effects
|
|
367
|
+
|
|
368
|
+
return y_pred
|
|
369
|
+
|
|
370
|
+
def summary(self):
|
|
371
|
+
"""Print and return a structured coefficient summary.
|
|
372
|
+
|
|
373
|
+
Returns
|
|
374
|
+
-------
|
|
375
|
+
PanelSummary
|
|
376
|
+
Dataclass with all model results. Also prints a formatted
|
|
377
|
+
table to stdout for interactive use.
|
|
378
|
+
"""
|
|
379
|
+
self._check_is_fitted()
|
|
380
|
+
|
|
381
|
+
k = len(self._params)
|
|
382
|
+
feat_names = [f'x{i+1}' for i in range(k)]
|
|
383
|
+
|
|
384
|
+
s = PanelSummary(
|
|
385
|
+
model_type='PanelOLS',
|
|
386
|
+
nobs=self.nobs,
|
|
387
|
+
df_resid=self.df_resid,
|
|
388
|
+
coef=self._params,
|
|
389
|
+
bse=self.bse_,
|
|
390
|
+
tvalues=self.tvalues_,
|
|
391
|
+
pvalues=self.pvalues_,
|
|
392
|
+
conf_int=self.conf_int_,
|
|
393
|
+
feature_names=feat_names,
|
|
394
|
+
rsquared_within=self.rsquared_within,
|
|
395
|
+
cov_type=self.cov_type,
|
|
396
|
+
entity_effects=self.entity_effects,
|
|
397
|
+
time_effects=self.time_effects,
|
|
398
|
+
alpha=self.alpha,
|
|
399
|
+
)
|
|
400
|
+
print(s)
|
|
401
|
+
return s
|
|
402
|
+
|
|
403
|
+
def get_params(self, deep=True):
|
|
404
|
+
"""Get parameters for this estimator."""
|
|
405
|
+
params = super().get_params(deep)
|
|
406
|
+
params.update({
|
|
407
|
+
'entity_effects': self.entity_effects,
|
|
408
|
+
'time_effects': self.time_effects,
|
|
409
|
+
'cov_type': self.cov_type,
|
|
410
|
+
'alpha': self.alpha,
|
|
411
|
+
})
|
|
412
|
+
return params
|
|
413
|
+
|
|
414
|
+
def set_params(self, **params):
|
|
415
|
+
"""Set parameters for this estimator."""
|
|
416
|
+
for key in ('entity_effects', 'time_effects', 'cov_type', 'alpha'):
|
|
417
|
+
if key in params:
|
|
418
|
+
setattr(self, key, params.pop(key))
|
|
419
|
+
super().set_params(**params)
|
|
420
|
+
return self
|