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.
Files changed (168) hide show
  1. statgpu/__init__.py +174 -0
  2. statgpu/_base.py +544 -0
  3. statgpu/_config.py +127 -0
  4. statgpu/anova/__init__.py +5 -0
  5. statgpu/anova/_oneway.py +194 -0
  6. statgpu/backends/__init__.py +83 -0
  7. statgpu/backends/_array_ops.py +529 -0
  8. statgpu/backends/_base.py +184 -0
  9. statgpu/backends/_cupy.py +453 -0
  10. statgpu/backends/_factory.py +65 -0
  11. statgpu/backends/_gpu_inference_cupy.py +214 -0
  12. statgpu/backends/_gpu_inference_torch.py +422 -0
  13. statgpu/backends/_numpy.py +324 -0
  14. statgpu/backends/_torch.py +685 -0
  15. statgpu/backends/_torch_safe.py +47 -0
  16. statgpu/backends/_utils.py +423 -0
  17. statgpu/core/__init__.py +10 -0
  18. statgpu/core/formula/__init__.py +33 -0
  19. statgpu/core/formula/_design.py +99 -0
  20. statgpu/core/formula/_parser.py +191 -0
  21. statgpu/core/formula/_terms.py +70 -0
  22. statgpu/core/formula/tests/__init__.py +0 -0
  23. statgpu/core/formula/tests/test_parser.py +194 -0
  24. statgpu/covariance/__init__.py +6 -0
  25. statgpu/covariance/_empirical.py +310 -0
  26. statgpu/covariance/_shrinkage.py +248 -0
  27. statgpu/cross_validation/__init__.py +31 -0
  28. statgpu/cross_validation/_base.py +410 -0
  29. statgpu/cross_validation/_engine.py +167 -0
  30. statgpu/diagnostics/__init__.py +7 -0
  31. statgpu/diagnostics/_regression_diagnostics.py +188 -0
  32. statgpu/feature_selection/__init__.py +24 -0
  33. statgpu/feature_selection/_knockoff.py +870 -0
  34. statgpu/feature_selection/_knockoff_utils.py +1003 -0
  35. statgpu/feature_selection/_stepwise.py +300 -0
  36. statgpu/glm_core/__init__.py +81 -0
  37. statgpu/glm_core/_base.py +202 -0
  38. statgpu/glm_core/_family.py +362 -0
  39. statgpu/glm_core/_fused.py +149 -0
  40. statgpu/glm_core/_gamma.py +111 -0
  41. statgpu/glm_core/_inverse_gaussian.py +62 -0
  42. statgpu/glm_core/_irls.py +561 -0
  43. statgpu/glm_core/_logistic.py +82 -0
  44. statgpu/glm_core/_negative_binomial.py +68 -0
  45. statgpu/glm_core/_poisson.py +60 -0
  46. statgpu/glm_core/_solver_legacy.py +100 -0
  47. statgpu/glm_core/_squared.py +53 -0
  48. statgpu/glm_core/_tweedie.py +74 -0
  49. statgpu/inference/__init__.py +239 -0
  50. statgpu/inference/_distributions_backend.py +2610 -0
  51. statgpu/inference/_multiple_testing.py +391 -0
  52. statgpu/inference/_resampling.py +1400 -0
  53. statgpu/inference/_results.py +265 -0
  54. statgpu/linear_model/__init__.py +75 -0
  55. statgpu/linear_model/_gaussian_inference.py +306 -0
  56. statgpu/linear_model/_glm_base.py +1261 -0
  57. statgpu/linear_model/_ordered_logit.py +52 -0
  58. statgpu/linear_model/_ordered_probit.py +50 -0
  59. statgpu/linear_model/_stats.py +170 -0
  60. statgpu/linear_model/cv/__init__.py +13 -0
  61. statgpu/linear_model/cv/_elasticnet_cv.py +892 -0
  62. statgpu/linear_model/cv/_lasso_cv.py +253 -0
  63. statgpu/linear_model/cv/_logistic_cv.py +895 -0
  64. statgpu/linear_model/cv/_ridge_cv.py +1160 -0
  65. statgpu/linear_model/legacy/__init__.py +1 -0
  66. statgpu/linear_model/legacy/_distributions_legacy_gpu.py +340 -0
  67. statgpu/linear_model/legacy/_elasticnet_legacy.py +936 -0
  68. statgpu/linear_model/legacy/_lasso_legacy.py +4876 -0
  69. statgpu/linear_model/legacy/_penalized_legacy.py +1174 -0
  70. statgpu/linear_model/legacy/_ridge_legacy.py +863 -0
  71. statgpu/linear_model/legacy/_solver_legacy.py +104 -0
  72. statgpu/linear_model/penalized/__init__.py +25 -0
  73. statgpu/linear_model/penalized/_base.py +437 -0
  74. statgpu/linear_model/penalized/_fit_mixin.py +1877 -0
  75. statgpu/linear_model/penalized/_inference_mixin.py +1179 -0
  76. statgpu/linear_model/penalized/_penalized_cv.py +2699 -0
  77. statgpu/linear_model/penalized/_penalized_gamma.py +86 -0
  78. statgpu/linear_model/penalized/_penalized_inverse_gaussian.py +62 -0
  79. statgpu/linear_model/penalized/_penalized_linear.py +236 -0
  80. statgpu/linear_model/penalized/_penalized_logistic.py +100 -0
  81. statgpu/linear_model/penalized/_penalized_negative_binomial.py +65 -0
  82. statgpu/linear_model/penalized/_penalized_poisson.py +62 -0
  83. statgpu/linear_model/penalized/_penalized_tweedie.py +65 -0
  84. statgpu/linear_model/penalized/_predict_mixin.py +182 -0
  85. statgpu/linear_model/wrappers/__init__.py +31 -0
  86. statgpu/linear_model/wrappers/_adaptive_lasso.py +63 -0
  87. statgpu/linear_model/wrappers/_elasticnet.py +75 -0
  88. statgpu/linear_model/wrappers/_gamma.py +67 -0
  89. statgpu/linear_model/wrappers/_inverse_gaussian.py +47 -0
  90. statgpu/linear_model/wrappers/_lasso.py +2124 -0
  91. statgpu/linear_model/wrappers/_linear.py +1127 -0
  92. statgpu/linear_model/wrappers/_logistic.py +1435 -0
  93. statgpu/linear_model/wrappers/_mcp.py +58 -0
  94. statgpu/linear_model/wrappers/_negative_binomial.py +58 -0
  95. statgpu/linear_model/wrappers/_poisson.py +48 -0
  96. statgpu/linear_model/wrappers/_ridge.py +166 -0
  97. statgpu/linear_model/wrappers/_scad.py +58 -0
  98. statgpu/linear_model/wrappers/_tweedie.py +57 -0
  99. statgpu/metrics/__init__.py +21 -0
  100. statgpu/metrics/_classification.py +591 -0
  101. statgpu/nonparametric/__init__.py +50 -0
  102. statgpu/nonparametric/kernel_methods/__init__.py +25 -0
  103. statgpu/nonparametric/kernel_methods/_kernels.py +246 -0
  104. statgpu/nonparametric/kernel_methods/_krr.py +234 -0
  105. statgpu/nonparametric/kernel_methods/_krr_cv.py +380 -0
  106. statgpu/nonparametric/kernel_smoothing/__init__.py +39 -0
  107. statgpu/nonparametric/kernel_smoothing/_bandwidth_selection.py +1083 -0
  108. statgpu/nonparametric/kernel_smoothing/_kde.py +761 -0
  109. statgpu/nonparametric/kernel_smoothing/_kernel_common.py +348 -0
  110. statgpu/nonparametric/kernel_smoothing/_kernel_regression.py +748 -0
  111. statgpu/nonparametric/splines/__init__.py +5 -0
  112. statgpu/nonparametric/splines/_bspline_basis.py +336 -0
  113. statgpu/nonparametric/splines/_penalized.py +349 -0
  114. statgpu/panel/__init__.py +19 -0
  115. statgpu/panel/_covariance.py +140 -0
  116. statgpu/panel/_fixed_effects.py +420 -0
  117. statgpu/panel/_random_effects.py +385 -0
  118. statgpu/panel/_utils.py +482 -0
  119. statgpu/penalties/__init__.py +139 -0
  120. statgpu/penalties/_adaptive_l1.py +313 -0
  121. statgpu/penalties/_base.py +261 -0
  122. statgpu/penalties/_categories.py +39 -0
  123. statgpu/penalties/_elasticnet.py +98 -0
  124. statgpu/penalties/_group_lasso.py +678 -0
  125. statgpu/penalties/_group_mcp.py +553 -0
  126. statgpu/penalties/_group_scad.py +605 -0
  127. statgpu/penalties/_l1.py +107 -0
  128. statgpu/penalties/_l2.py +77 -0
  129. statgpu/penalties/_mcp.py +237 -0
  130. statgpu/penalties/_scad.py +260 -0
  131. statgpu/semiparametric/__init__.py +5 -0
  132. statgpu/semiparametric/_gam.py +401 -0
  133. statgpu/solvers/__init__.py +24 -0
  134. statgpu/solvers/_admm.py +241 -0
  135. statgpu/solvers/_constants.py +15 -0
  136. statgpu/solvers/_convergence.py +6 -0
  137. statgpu/solvers/_fista.py +436 -0
  138. statgpu/solvers/_fista_bb.py +513 -0
  139. statgpu/solvers/_fista_lla.py +541 -0
  140. statgpu/solvers/_lbfgs.py +206 -0
  141. statgpu/solvers/_newton.py +149 -0
  142. statgpu/solvers/_utils.py +277 -0
  143. statgpu/survival/__init__.py +14 -0
  144. statgpu/survival/_cox.py +3974 -0
  145. statgpu/survival/_cox_breslow_triton_kernel.py +106 -0
  146. statgpu/survival/_cox_cv.py +1159 -0
  147. statgpu/survival/_cox_efron_cuda.py +1280 -0
  148. statgpu/survival/_cox_efron_triton.py +359 -0
  149. statgpu/unsupervised/__init__.py +29 -0
  150. statgpu/unsupervised/_agglomerative.py +307 -0
  151. statgpu/unsupervised/_dbscan.py +263 -0
  152. statgpu/unsupervised/_dbscan_cpu.pyx +125 -0
  153. statgpu/unsupervised/_gmm.py +332 -0
  154. statgpu/unsupervised/_incremental_pca.py +176 -0
  155. statgpu/unsupervised/_kmeans.py +261 -0
  156. statgpu/unsupervised/_minibatch_kmeans.py +299 -0
  157. statgpu/unsupervised/_minibatch_nmf.py +252 -0
  158. statgpu/unsupervised/_nmf.py +190 -0
  159. statgpu/unsupervised/_pca.py +189 -0
  160. statgpu/unsupervised/_truncated_svd.py +132 -0
  161. statgpu/unsupervised/_tsne.py +192 -0
  162. statgpu/unsupervised/_umap.py +224 -0
  163. statgpu/unsupervised/_utils.py +134 -0
  164. statgpu-0.1.0.dist-info/METADATA +245 -0
  165. statgpu-0.1.0.dist-info/RECORD +168 -0
  166. statgpu-0.1.0.dist-info/WHEEL +5 -0
  167. statgpu-0.1.0.dist-info/licenses/LICENSE +199 -0
  168. 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