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,1127 @@
1
+ """
2
+ Linear regression with full statistical inference and GPU support.
3
+ """
4
+
5
+ __all__ = ["LinearRegression"]
6
+
7
+ from typing import Optional, Union
8
+ import numpy as np
9
+ from scipy import stats
10
+ from time import perf_counter
11
+
12
+ from statgpu._base import BaseEstimator
13
+ from statgpu._config import Device
14
+ from statgpu.backends import _get_torch_device_str
15
+ from statgpu.inference._results import GaussianInferenceResult
16
+ from statgpu.linear_model._gaussian_inference import (
17
+ compute_gaussian_inference,
18
+ validate_cov_type,
19
+ validate_hac_maxlags,
20
+ )
21
+
22
+
23
+ def _parse_formula_if_provided(formula, data, X, y):
24
+ """Parse formula+data or fall back to raw arrays. Returns (y, X, info)."""
25
+ if formula is not None:
26
+ from statgpu.core.formula import parse_formula
27
+ return parse_formula(formula, data)
28
+ y = np.asarray(y)
29
+ if y.ndim == 2 and y.shape[1] == 1:
30
+ y = y.ravel()
31
+ return y, np.asarray(X), None
32
+
33
+
34
+ class LinearRegression(BaseEstimator):
35
+ """
36
+ Ordinary least squares linear regression with GPU acceleration
37
+ and full statistical inference (R/statsmodels style).
38
+
39
+ Parameters
40
+ ----------
41
+ fit_intercept : bool, default=True
42
+ Whether to calculate the intercept.
43
+ device : str or Device, default='auto'
44
+ Computation device: 'cpu', 'cuda', or 'auto'.
45
+
46
+ Attributes
47
+ ----------
48
+ coef_ : ndarray of shape (n_features,)
49
+ Estimated coefficients.
50
+ intercept_ : float
51
+ Independent term.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ fit_intercept: bool = True,
57
+ device: Union[str, Device] = Device.AUTO,
58
+ n_jobs: Optional[int] = None,
59
+ compute_inference: bool = True,
60
+ gpu_memory_cleanup: bool = False,
61
+ cov_type: str = "nonrobust",
62
+ hac_maxlags: Optional[int] = None,
63
+ ):
64
+ super().__init__(device=device, n_jobs=n_jobs)
65
+ self.fit_intercept = fit_intercept
66
+ self.compute_inference = compute_inference
67
+ self.gpu_memory_cleanup = bool(gpu_memory_cleanup)
68
+ self.cov_type = validate_cov_type(cov_type)
69
+ self.hac_maxlags = validate_hac_maxlags(hac_maxlags)
70
+ self.coef_ = None
71
+ self.intercept_ = None
72
+
73
+ # Internal storage for inference
74
+ self._X_design = None
75
+ self._y = None
76
+ self._resid = None
77
+ self._scale = None
78
+ self._nobs = None
79
+ self._df_resid = None
80
+ self._params = None
81
+ self._bse = None
82
+ self._tvalues = None
83
+ self._pvalues = None
84
+ self._conf_int = None
85
+ self._inference_result = None
86
+ self._is_multi_output = False
87
+ self._hac_mixed_precision_preference = {}
88
+ self._feature_names = None
89
+ self._design_info = None
90
+ self._formula_has_intercept = None
91
+
92
+ def _clear_inference_result(self):
93
+ self._bse = None
94
+ self._tvalues = None
95
+ self._pvalues = None
96
+ self._conf_int = None
97
+ self._inference_result = None
98
+
99
+ def _cleanup_cuda_memory(self):
100
+ """Best-effort CuPy memory pool cleanup."""
101
+ if not self.gpu_memory_cleanup:
102
+ return
103
+ try:
104
+ import cupy as cp
105
+ cp.get_default_memory_pool().free_all_blocks()
106
+ cp.get_default_pinned_memory_pool().free_all_blocks()
107
+ except Exception:
108
+ pass
109
+
110
+ def _resolve_hac_maxlags(self, n_obs: int) -> int:
111
+ """Resolve HAC lag count with a Newey-West style default rule."""
112
+ if n_obs <= 1:
113
+ return 0
114
+ if self.hac_maxlags is None:
115
+ maxlags = int(np.floor(4.0 * (n_obs / 100.0) ** (2.0 / 9.0)))
116
+ else:
117
+ maxlags = int(self.hac_maxlags)
118
+ return max(0, min(maxlags, n_obs - 1))
119
+
120
+ def _benchmark_hac_numpy_kernel(
121
+ self,
122
+ scores: np.ndarray,
123
+ maxlags: int,
124
+ use_mixed_precision: bool,
125
+ ) -> float:
126
+ """Benchmark a tiny HAC kernel to choose the faster precision path."""
127
+ probe_maxlags = min(maxlags, 2)
128
+ if use_mixed_precision:
129
+ scores32 = scores.astype(np.float32, copy=False)
130
+ t0 = perf_counter()
131
+ meat = (scores32.T @ scores32).astype(np.float64)
132
+ for lag in range(1, probe_maxlags + 1):
133
+ weight = 1.0 - (lag / (maxlags + 1.0))
134
+ gamma = scores32[lag:].T @ scores32[:-lag]
135
+ meat = meat + float(weight) * (gamma + gamma.T).astype(np.float64)
136
+ _ = float(meat[0, 0])
137
+ return perf_counter() - t0
138
+
139
+ t0 = perf_counter()
140
+ meat = scores.T @ scores
141
+ for lag in range(1, probe_maxlags + 1):
142
+ weight = 1.0 - (lag / (maxlags + 1.0))
143
+ gamma = scores[lag:].T @ scores[:-lag]
144
+ meat = meat + weight * (gamma + gamma.T)
145
+ _ = float(meat[0, 0])
146
+ return perf_counter() - t0
147
+
148
+ def _should_use_mixed_precision_hac_numpy(self, scores: np.ndarray, maxlags: int) -> bool:
149
+ """Choose HAC precision path adaptively and cache by problem shape."""
150
+ n_obs = int(scores.shape[0])
151
+ n_features = int(scores.shape[1])
152
+ if not (scores.dtype == np.float64 and n_obs >= 4096 and n_features <= 64):
153
+ return False
154
+
155
+ if n_obs < 32768:
156
+ n_bucket = "small"
157
+ elif n_obs < 65536:
158
+ n_bucket = "medium"
159
+ else:
160
+ n_bucket = "large"
161
+
162
+ key = (n_features, int(min(maxlags, 8)), n_bucket)
163
+ cached = self._hac_mixed_precision_preference.get(key)
164
+ if cached is not None:
165
+ return bool(cached)
166
+
167
+ probe_cap = 12288 if n_bucket != "large" else 24576
168
+ probe_n = min(n_obs, probe_cap)
169
+ if probe_n <= maxlags + 16:
170
+ self._hac_mixed_precision_preference[key] = True
171
+ return True
172
+
173
+ probe_scores = np.asarray(scores[:probe_n], dtype=np.float64, order="C")
174
+ try:
175
+ # Warmup to reduce one-time BLAS startup noise.
176
+ self._benchmark_hac_numpy_kernel(probe_scores, maxlags, use_mixed_precision=True)
177
+ self._benchmark_hac_numpy_kernel(probe_scores, maxlags, use_mixed_precision=False)
178
+ mixed_time = self._benchmark_hac_numpy_kernel(
179
+ probe_scores, maxlags, use_mixed_precision=True
180
+ )
181
+ float64_time = self._benchmark_hac_numpy_kernel(
182
+ probe_scores, maxlags, use_mixed_precision=False
183
+ )
184
+ # Keep mixed path only if it clears a small speed margin.
185
+ use_mixed = mixed_time <= 0.95 * float64_time
186
+ except Exception:
187
+ use_mixed = True
188
+
189
+ self._hac_mixed_precision_preference[key] = use_mixed
190
+ return use_mixed
191
+
192
+ def _hac_meat_numpy(self, scores: np.ndarray) -> np.ndarray:
193
+ """Bartlett-kernel HAC meat from per-observation score matrix."""
194
+ n_obs = int(scores.shape[0])
195
+ maxlags = self._resolve_hac_maxlags(n_obs)
196
+ weights = 1.0 - (np.arange(1, maxlags + 1, dtype=float) / (maxlags + 1.0))
197
+
198
+ # Adaptive mixed precision: select per-shape path by quick local probe,
199
+ # then cache the decision to avoid recurring benchmark overhead.
200
+ use_mixed_precision = self._should_use_mixed_precision_hac_numpy(scores, maxlags)
201
+
202
+ if use_mixed_precision:
203
+ scores32 = scores.astype(np.float32, copy=False)
204
+ meat = (scores32.T @ scores32).astype(np.float64)
205
+ if maxlags == 0:
206
+ return meat
207
+ for lag, weight in enumerate(weights, start=1):
208
+ gamma = scores32[lag:].T @ scores32[:-lag]
209
+ meat = meat + float(weight) * (gamma + gamma.T).astype(np.float64)
210
+ return meat
211
+
212
+ meat = scores.T @ scores
213
+ if maxlags == 0:
214
+ return meat
215
+ for lag, weight in enumerate(weights, start=1):
216
+ gamma = scores[lag:].T @ scores[:-lag]
217
+ meat = meat + weight * (gamma + gamma.T)
218
+ return meat
219
+
220
+ def _hac_meat_cupy(self, scores):
221
+ """CuPy Bartlett-kernel HAC meat from per-observation score matrix."""
222
+ import cupy as cp
223
+
224
+ n_obs = int(scores.shape[0])
225
+ meat = scores.T @ scores
226
+ maxlags = self._resolve_hac_maxlags(n_obs)
227
+ if maxlags == 0:
228
+ return meat
229
+ for lag in range(1, maxlags + 1):
230
+ weight = 1.0 - (lag / (maxlags + 1.0))
231
+ gamma = scores[lag:].T @ scores[:-lag]
232
+ meat = meat + weight * (gamma + gamma.T)
233
+ return meat
234
+
235
+ def _robust_covariance_numpy(self, X: np.ndarray, resid: np.ndarray, XtX_inv: np.ndarray) -> np.ndarray:
236
+ """Compute robust/HAC covariance matrix for OLS-like score equations."""
237
+ n, k = X.shape
238
+ e = np.asarray(resid, dtype=float).reshape(-1)
239
+
240
+ if self.cov_type == "hac":
241
+ scores = X * e[:, np.newaxis]
242
+ meat = self._hac_meat_numpy(scores)
243
+ return XtX_inv @ meat @ XtX_inv
244
+
245
+ if self.cov_type in ("hc2", "hc3"):
246
+ leverage = np.einsum("ij,jk,ik->i", X, XtX_inv, X)
247
+ leverage = np.clip(leverage, 0.0, 1.0 - 1e-12)
248
+ if self.cov_type == "hc2":
249
+ e2 = (e ** 2) / (1.0 - leverage)
250
+ else:
251
+ e2 = (e ** 2) / ((1.0 - leverage) ** 2)
252
+ else:
253
+ e2 = e ** 2
254
+
255
+ Xw = X * e2[:, np.newaxis]
256
+ meat = X.T @ Xw
257
+ cov_params = XtX_inv @ meat @ XtX_inv
258
+ if self.cov_type == "hc1" and n > k:
259
+ cov_params *= (n / (n - k))
260
+ return cov_params
261
+
262
+ def _robust_covariance_cupy(self, X, resid, XtX_inv):
263
+ """Compute robust/HAC covariance matrix for OLS-like score equations on GPU."""
264
+ import cupy as cp
265
+
266
+ n, k = X.shape
267
+ e = resid.reshape(-1)
268
+
269
+ if self.cov_type == "hac":
270
+ scores = X * e[:, cp.newaxis]
271
+ meat = self._hac_meat_cupy(scores)
272
+ return XtX_inv @ meat @ XtX_inv
273
+
274
+ if self.cov_type in ("hc2", "hc3"):
275
+ leverage = cp.einsum("ij,jk,ik->i", X, XtX_inv, X)
276
+ leverage = cp.clip(leverage, 0.0, 1.0 - 1e-12)
277
+ if self.cov_type == "hc2":
278
+ e2 = cp.square(e) / (1.0 - leverage)
279
+ else:
280
+ e2 = cp.square(e) / cp.square(1.0 - leverage)
281
+ else:
282
+ e2 = cp.square(e)
283
+
284
+ Xw = X * e2[:, cp.newaxis]
285
+ meat = X.T @ Xw
286
+ cov_params = XtX_inv @ meat @ XtX_inv
287
+ if self.cov_type == "hc1" and n > k:
288
+ cov_params = cov_params * (n / (n - k))
289
+ return cov_params
290
+
291
+ def fit(self, X=None, y=None, sample_weight=None, formula=None, data=None):
292
+ """Fit linear model.
293
+
294
+ Parameters
295
+ ----------
296
+ X : array-like or None
297
+ Predictor matrix. Required if ``formula`` is None.
298
+ y : array-like or None
299
+ Response vector. Required if ``formula`` is None.
300
+ sample_weight : array-like or None
301
+ Sample weights.
302
+ formula : str or None
303
+ R-style formula string (e.g. ``"y ~ x1 + x2"``). Mutually
304
+ exclusive with ``X``/``y``.
305
+ data : pd.DataFrame or None
306
+ DataFrame used with ``formula`` for column lookup.
307
+ """
308
+ self._clear_inference_result()
309
+
310
+ # Handle formula interface
311
+ _orig_fit_intercept = self.fit_intercept
312
+ if formula is not None:
313
+ if data is None:
314
+ raise ValueError(
315
+ "formula was provided but data is None. "
316
+ "Pass data=your_dataframe when using formula."
317
+ )
318
+ y_arr, X_arr, design_info = _parse_formula_if_provided(
319
+ formula, data, None, None
320
+ )
321
+ self._design_info = design_info
322
+ formula_column_names = list(design_info.column_names)
323
+ self._formula_has_intercept = "Intercept" in formula_column_names
324
+ self._feature_names = [name for name in formula_column_names if name != "Intercept"]
325
+ if self._formula_has_intercept:
326
+ intercept_idx = formula_column_names.index("Intercept")
327
+ # Drop the intercept column — let the fitting methods handle it
328
+ X_arr = np.delete(X_arr, intercept_idx, axis=1)
329
+ self.fit_intercept = True
330
+ else:
331
+ # Formula syntax owns intercept semantics, matching statsmodels/R.
332
+ self.fit_intercept = False
333
+ else:
334
+ if X is None or y is None:
335
+ raise ValueError(
336
+ "Either formula+data or X+y must be provided."
337
+ )
338
+ self._feature_names = None
339
+ self._design_info = None
340
+ self._formula_has_intercept = None
341
+ y_arr = np.asarray(y)
342
+ if y_arr.ndim == 2 and y_arr.shape[1] == 1:
343
+ y_arr = y_arr.ravel()
344
+ X_arr = np.asarray(X)
345
+
346
+ self.fit_intercept = _orig_fit_intercept
347
+ # Store y (may be CuPy/Torch array, convert later for CPU)
348
+ self._y = y_arr
349
+
350
+ # Get backend - support explicit torch backend selection
351
+ backend = self._get_backend(backend="auto")
352
+ backend_name = backend.name
353
+
354
+ X_arr = self._to_array(X_arr, backend=backend_name)
355
+ y_arr = self._to_array(y_arr, backend=backend_name)
356
+ self._is_multi_output = y_arr.ndim > 1 and y_arr.shape[1] > 1
357
+
358
+ device = self._get_compute_device()
359
+
360
+ # Route to appropriate backend
361
+ if backend_name == "torch":
362
+ self._fit_torch(X_arr, y_arr, sample_weight)
363
+ elif backend_name == "cupy":
364
+ self._fit_gpu(X_arr, y_arr, sample_weight)
365
+ else:
366
+ self._fit_cpu(X_arr, y_arr, sample_weight)
367
+
368
+ # Convert y to numpy for diagnostics if needed
369
+ if hasattr(self._y, 'get'): # CuPy
370
+ self._y = self._y.get()
371
+ elif hasattr(self._y, 'cpu'): # Torch
372
+ self._y = self._y.cpu().numpy()
373
+ else:
374
+ self._y = np.asarray(self._y)
375
+
376
+ # GPU single-output inference is computed in _fit_gpu/_fit_torch().
377
+ # Multi-output GPU inference is not implemented yet; do not fall back to
378
+ # the NumPy inference path when the user selected a GPU backend.
379
+ if self.compute_inference and self._is_multi_output and device in (Device.CUDA, Device.TORCH):
380
+ raise NotImplementedError(
381
+ "Multi-output LinearRegression inference is not implemented for "
382
+ f"device='{device.value}'. Set compute_inference=False or use device='cpu'."
383
+ )
384
+ if self.compute_inference and device == Device.CPU:
385
+ self._compute_inference()
386
+ self._fitted = True
387
+ return self
388
+
389
+ def _fit_cpu(self, X, y, sample_weight=None):
390
+ """Fit using CPU."""
391
+ X = np.asarray(X)
392
+ y = np.asarray(y)
393
+
394
+ n_samples, n_features = X.shape
395
+ self._nobs = n_samples
396
+
397
+ if sample_weight is not None:
398
+ sample_weight = np.asarray(sample_weight)
399
+ sqrt_sw = np.sqrt(sample_weight)
400
+ X = X * sqrt_sw[:, np.newaxis]
401
+ y = y * sqrt_sw
402
+
403
+ if self.fit_intercept:
404
+ self._X_design = np.column_stack([np.ones(n_samples, dtype=X.dtype), X])
405
+ else:
406
+ self._X_design = X.copy()
407
+
408
+ if y.ndim == 1:
409
+ y = y.reshape(-1, 1)
410
+
411
+ coef, _, _, _ = np.linalg.lstsq(self._X_design, y, rcond=None)
412
+
413
+ if self.fit_intercept:
414
+ if coef.shape[1] > 1:
415
+ self.intercept_ = coef[0, :].copy()
416
+ self.coef_ = coef[1:, :].T
417
+ self._params = coef.copy()
418
+ else:
419
+ coef_1d = coef[:, 0]
420
+ self.intercept_ = float(coef_1d[0])
421
+ self.coef_ = coef_1d[1:]
422
+ self._params = coef_1d.copy()
423
+ else:
424
+ if coef.shape[1] > 1:
425
+ self.intercept_ = np.zeros(coef.shape[1], dtype=coef.dtype)
426
+ self.coef_ = coef.T
427
+ self._params = coef.copy()
428
+ else:
429
+ self.intercept_ = 0.0
430
+ self.coef_ = coef[:, 0].copy()
431
+ self._params = self.coef_.copy()
432
+
433
+ y_pred = self._X_design @ coef
434
+ self._resid = y - y_pred
435
+ if self._resid.shape[1] == 1:
436
+ self._resid = self._resid[:, 0]
437
+ self._df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0))
438
+
439
+ if self._df_resid > 0:
440
+ if np.asarray(self._resid).ndim == 1:
441
+ self._scale = np.sum(self._resid ** 2) / self._df_resid
442
+ else:
443
+ self._scale = np.sum(self._resid ** 2, axis=0) / self._df_resid
444
+ else:
445
+ self._scale = np.nan
446
+
447
+ def _fit_gpu(self, X, y, sample_weight=None):
448
+ """Fit using GPU with FULL GPU computation (including inference)."""
449
+ import cupy as cp
450
+ from statgpu.backends._gpu_inference_cupy import (
451
+ compute_inference_gpu,
452
+ compute_r2_gpu,
453
+ compute_aic_bic_gpu,
454
+ compute_f_stat_gpu,
455
+ )
456
+ from statgpu.inference._distributions_backend import norm
457
+
458
+ n_samples, n_features = X.shape
459
+ self._nobs = n_samples
460
+
461
+ # Ensure CuPy arrays
462
+ X = cp.asarray(X)
463
+ y = cp.asarray(y)
464
+
465
+ if sample_weight is not None:
466
+ sample_weight = cp.asarray(sample_weight)
467
+ sqrt_sw = cp.sqrt(sample_weight)
468
+ X = X * sqrt_sw[:, cp.newaxis]
469
+ y = y * sqrt_sw
470
+
471
+ if self.fit_intercept:
472
+ X_design = cp.column_stack([cp.ones(n_samples, dtype=X.dtype), X])
473
+ else:
474
+ X_design = X
475
+
476
+ if y.ndim == 1:
477
+ y = y.reshape(-1, 1)
478
+
479
+ # Use normal equations: (X'X)^-1 X'y
480
+ XtX = X_design.T @ X_design
481
+ Xty = X_design.T @ y
482
+
483
+ try:
484
+ # Cholesky decomposition
485
+ L = cp.linalg.cholesky(XtX)
486
+ tmp = cp.linalg.solve_triangular(L, Xty, lower=True)
487
+ coef = cp.linalg.solve_triangular(L.T, tmp, lower=False)
488
+ except Exception:
489
+ coef = cp.linalg.solve(XtX, Xty)
490
+
491
+ # Compute predictions and residuals on GPU
492
+ y_pred = X_design @ coef
493
+ resid = y - y_pred
494
+
495
+ # Compute scale on GPU
496
+ df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0))
497
+ if df_resid > 0:
498
+ if y.shape[1] > 1:
499
+ scale = cp.sum(resid ** 2, axis=0) / df_resid
500
+ else:
501
+ scale = cp.sum(resid ** 2) / df_resid
502
+ else:
503
+ if y.shape[1] > 1:
504
+ scale = cp.full((y.shape[1],), cp.nan, dtype=y.dtype)
505
+ else:
506
+ scale = cp.nan
507
+
508
+ # Compute inference-related statistics only when requested.
509
+ if self.compute_inference and not self._is_multi_output:
510
+ coef_flat = coef.flatten()
511
+ if self.cov_type == "nonrobust":
512
+ self._bse_gpu, self._tvalues_gpu, self._pvalues_gpu, self._conf_int_gpu = \
513
+ compute_inference_gpu(X_design, resid, scale, df_resid, coef_flat)
514
+ else:
515
+ XtX_cov = X_design.T @ X_design
516
+ try:
517
+ XtX_inv = cp.linalg.inv(XtX_cov)
518
+ except Exception:
519
+ XtX_inv = cp.linalg.pinv(XtX_cov)
520
+ cov_params = self._robust_covariance_cupy(X_design, resid, XtX_inv)
521
+ self._bse_gpu = cp.sqrt(cp.maximum(cp.diag(cov_params), 0.0))
522
+ self._tvalues_gpu = coef_flat / (self._bse_gpu + 1e-30)
523
+ self._pvalues_gpu = cp.minimum(1.0, 2.0 * norm.sf(cp.abs(self._tvalues_gpu)))
524
+ z_crit = norm.ppf(0.975)
525
+ self._conf_int_gpu = cp.stack([
526
+ coef_flat - z_crit * self._bse_gpu,
527
+ coef_flat + z_crit * self._bse_gpu,
528
+ ], axis=1)
529
+
530
+ # R-squared on GPU
531
+ self._rsquared_gpu = compute_r2_gpu(y, resid)
532
+
533
+ # AIC/BIC on GPU
534
+ k = n_features + (1 if self.fit_intercept else 0)
535
+ scale_mle = cp.sum(resid ** 2) / n_samples
536
+ self._aic_gpu, self._bic_gpu = compute_aic_bic_gpu(n_samples, k, scale_mle)
537
+
538
+ # F-statistic on GPU
539
+ self._fvalue_gpu, self._f_pvalue = compute_f_stat_gpu(y, resid, X_design, df_resid)
540
+
541
+ # Single transfer to CPU at the end
542
+ coef_np = coef.get()
543
+ resid_np = resid.get()
544
+ if y.shape[1] > 1:
545
+ scale_np = scale.get()
546
+ else:
547
+ scale_np = float(scale.get()) if not cp.isnan(scale) else np.nan
548
+ X_design_np = X_design.get()
549
+
550
+ if self.compute_inference and not self._is_multi_output:
551
+ # Transfer inference results
552
+ self._bse = self._bse_gpu.get()
553
+ self._tvalues = self._tvalues_gpu.get()
554
+ self._pvalues = self._pvalues_gpu.get()
555
+ self._conf_int = self._conf_int_gpu.get()
556
+
557
+ # Store results
558
+ if self.fit_intercept:
559
+ if coef_np.shape[1] > 1:
560
+ self.intercept_ = coef_np[0, :].copy()
561
+ self.coef_ = coef_np[1:, :].T
562
+ self._params = coef_np.copy()
563
+ else:
564
+ self.intercept_ = float(coef_np[0, 0])
565
+ self.coef_ = coef_np[1:, 0]
566
+ self._params = coef_np[:, 0]
567
+ else:
568
+ if coef_np.shape[1] > 1:
569
+ self.intercept_ = np.zeros(coef_np.shape[1], dtype=coef_np.dtype)
570
+ self.coef_ = coef_np.T
571
+ self._params = coef_np.copy()
572
+ else:
573
+ self.intercept_ = 0.0
574
+ self.coef_ = coef_np[:, 0]
575
+ self._params = coef_np[:, 0]
576
+
577
+ self._X_design = X_design_np
578
+ if resid_np.shape[1] == 1:
579
+ self._resid = resid_np[:, 0]
580
+ else:
581
+ self._resid = resid_np
582
+ self._df_resid = df_resid
583
+ self._scale = scale_np
584
+ if self.compute_inference and not self._is_multi_output:
585
+ self._wrap_gaussian_inference_result()
586
+
587
+ # Release large temporary GPU tensors early.
588
+ try:
589
+ del X_design
590
+ except Exception:
591
+ pass
592
+ try:
593
+ del resid
594
+ except Exception:
595
+ pass
596
+ try:
597
+ del XtX
598
+ except Exception:
599
+ pass
600
+ try:
601
+ del Xty
602
+ except Exception:
603
+ pass
604
+ try:
605
+ del coef
606
+ except Exception:
607
+ pass
608
+ self._cleanup_cuda_memory()
609
+
610
+ def _cleanup_torch_memory(self):
611
+ """Best-effort Torch memory cleanup."""
612
+ if not self.gpu_memory_cleanup:
613
+ return
614
+ try:
615
+ import torch
616
+ if torch.cuda.is_available():
617
+ torch.cuda.empty_cache()
618
+ except Exception:
619
+ pass
620
+
621
+ def _hac_meat_torch(self, scores):
622
+ """Torch Bartlett-kernel HAC meat from per-observation score matrix."""
623
+ import torch
624
+
625
+ n_obs = int(scores.shape[0])
626
+ meat = scores.T @ scores
627
+ maxlags = self._resolve_hac_maxlags(n_obs)
628
+ if maxlags == 0:
629
+ return meat
630
+ for lag in range(1, maxlags + 1):
631
+ weight = 1.0 - (lag / (maxlags + 1.0))
632
+ gamma = scores[lag:].T @ scores[:-lag]
633
+ meat = meat + weight * (gamma + gamma.T)
634
+ return meat
635
+
636
+ def _robust_covariance_torch(self, X, resid, XtX_inv, device=None):
637
+ """Compute robust/HAC covariance matrix for OLS-like score equations on Torch GPU."""
638
+ import torch
639
+
640
+ n, k = X.shape
641
+ e = resid.reshape(-1)
642
+
643
+ if device is None:
644
+ device = 'cuda' if X.is_cuda else 'cpu'
645
+
646
+ if self.cov_type == "hac":
647
+ # HAC requires temporal ordering - compute score matrix and apply Bartlett kernel
648
+ scores = X * e[:, None]
649
+ meat = self._hac_meat_torch(scores)
650
+ return XtX_inv @ meat @ XtX_inv
651
+
652
+ if self.cov_type in ("hc2", "hc3"):
653
+ leverage = torch.einsum("ij,jk,ik->i", X, XtX_inv, X)
654
+ leverage = torch.clamp(leverage, 0.0, 1.0 - 1e-12)
655
+ if self.cov_type == "hc2":
656
+ e2 = torch.square(e) / (1.0 - leverage)
657
+ else:
658
+ e2 = torch.square(e) / torch.square(1.0 - leverage)
659
+ else:
660
+ e2 = torch.square(e)
661
+
662
+ Xw = X * e2[:, None]
663
+ meat = X.T @ Xw
664
+ cov_params = XtX_inv @ meat @ XtX_inv
665
+ if self.cov_type == "hc1" and n > k:
666
+ cov_params = cov_params * (n / (n - k))
667
+ return cov_params
668
+
669
+ def _fit_torch(self, X, y, sample_weight=None):
670
+ """Fit using Torch GPU with FULL GPU computation (including inference)."""
671
+ import torch
672
+ from statgpu.backends._gpu_inference_torch import (
673
+ compute_inference_torch,
674
+ compute_r2_torch,
675
+ compute_aic_bic_torch,
676
+ compute_f_stat_torch,
677
+ )
678
+ from statgpu.inference._distributions_backend import norm
679
+
680
+ n_samples, n_features = X.shape
681
+ self._nobs = n_samples
682
+
683
+ # Ensure Torch tensors on correct device
684
+ # Note: Device.TORCH.value is 'torch', but Torch expects 'cuda' or 'cpu'
685
+ torch_device = _get_torch_device_str()
686
+ if not isinstance(X, torch.Tensor):
687
+ X = torch.from_numpy(np.asarray(X)).to(torch_device)
688
+ if not isinstance(y, torch.Tensor):
689
+ y = torch.from_numpy(np.asarray(y)).to(torch_device)
690
+
691
+ if X.dtype != torch.float64:
692
+ X = X.to(torch.float64)
693
+ if y.dtype != torch.float64:
694
+ y = y.to(torch.float64)
695
+
696
+ if sample_weight is not None:
697
+ if not isinstance(sample_weight, torch.Tensor):
698
+ sample_weight = torch.from_numpy(np.asarray(sample_weight)).to(torch_device)
699
+ if sample_weight.dtype != torch.float64:
700
+ sample_weight = sample_weight.to(torch.float64)
701
+ sqrt_sw = torch.sqrt(sample_weight)
702
+ X = X * sqrt_sw[:, None]
703
+ y = y * sqrt_sw
704
+
705
+ if self.fit_intercept:
706
+ X_design = torch.cat([torch.ones(n_samples, 1, dtype=X.dtype, device=torch_device), X], dim=1)
707
+ else:
708
+ X_design = X.clone()
709
+
710
+ if y.ndim == 1:
711
+ y = y.reshape(-1, 1)
712
+
713
+ # Use normal equations: (X'X)^-1 X'y
714
+ XtX = X_design.T @ X_design
715
+ Xty = X_design.T @ y
716
+
717
+ try:
718
+ # Cholesky decomposition
719
+ L = torch.linalg.cholesky(XtX)
720
+ # Solve L @ tmp = Xty (L is lower triangular)
721
+ tmp = torch.linalg.solve_triangular(L, Xty, upper=False)
722
+ # Solve L.T @ coef = tmp (L.T is upper triangular)
723
+ coef = torch.linalg.solve_triangular(L.T, tmp, upper=True)
724
+ except Exception:
725
+ coef = torch.linalg.solve(XtX, Xty)
726
+
727
+ # Compute predictions and residuals on Torch
728
+ y_pred = X_design @ coef
729
+ resid = y - y_pred
730
+
731
+ # Compute scale on Torch
732
+ df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0))
733
+ if df_resid > 0:
734
+ if y.shape[1] > 1:
735
+ scale = torch.sum(resid ** 2, dim=0) / df_resid
736
+ else:
737
+ scale = torch.sum(resid ** 2) / df_resid
738
+ else:
739
+ if y.shape[1] > 1:
740
+ scale = torch.full((y.shape[1],), float('nan'), dtype=y.dtype, device=torch_device)
741
+ else:
742
+ scale = torch.tensor(float('nan'), dtype=y.dtype, device=torch_device)
743
+
744
+ # Compute inference-related statistics only when requested.
745
+ if self.compute_inference and not self._is_multi_output:
746
+ coef_flat = coef.flatten()
747
+ if self.cov_type == "nonrobust":
748
+ self._bse_gpu, self._tvalues_gpu, self._pvalues_gpu, self._conf_int_gpu = \
749
+ compute_inference_torch(X_design, resid, scale, df_resid, coef_flat, cov_type="nonrobust", device=torch_device)
750
+ else:
751
+ XtX_cov = X_design.T @ X_design
752
+ try:
753
+ XtX_inv = torch.linalg.inv(XtX_cov)
754
+ except Exception:
755
+ XtX_inv = torch.linalg.pinv(XtX_cov)
756
+ cov_params = self._robust_covariance_torch(X_design, resid, XtX_inv, device=torch_device)
757
+ self._bse_gpu = torch.sqrt(torch.clamp(torch.diag(cov_params), 0.0))
758
+ self._tvalues_gpu = coef_flat / (self._bse_gpu + 1e-30)
759
+ self._pvalues_gpu = torch.clamp(2.0 * norm.sf(torch.abs(self._tvalues_gpu), device=torch_device), 0.0, 1.0)
760
+ z_crit = norm.ppf(0.975, device=torch_device)
761
+ self._conf_int_gpu = torch.stack([
762
+ coef_flat - z_crit * self._bse_gpu,
763
+ coef_flat + z_crit * self._bse_gpu,
764
+ ], dim=1)
765
+
766
+ # R-squared on Torch
767
+ self._rsquared_gpu = compute_r2_torch(y, resid)
768
+
769
+ # AIC/BIC on Torch
770
+ k = n_features + (1 if self.fit_intercept else 0)
771
+ scale_mle = torch.sum(resid ** 2) / n_samples
772
+ self._aic_gpu, self._bic_gpu = compute_aic_bic_torch(n_samples, k, scale_mle, device=torch_device)
773
+
774
+ # F-statistic on Torch
775
+ self._fvalue_gpu, self._f_pvalue = compute_f_stat_torch(y, resid, X_design, df_resid, device=torch_device)
776
+
777
+ # Single transfer to CPU at the end
778
+ coef_np = coef.detach().cpu().numpy()
779
+ resid_np = resid.detach().cpu().numpy()
780
+ if y.shape[1] > 1:
781
+ scale_np = scale.detach().cpu().numpy()
782
+ else:
783
+ scale_val = scale.detach().cpu().item()
784
+ scale_np = float(scale_val) if not np.isnan(scale_val) else np.nan
785
+ X_design_np = X_design.detach().cpu().numpy()
786
+
787
+ if self.compute_inference and not self._is_multi_output:
788
+ # Transfer inference results
789
+ self._bse = self._bse_gpu.detach().cpu().numpy()
790
+ self._tvalues = self._tvalues_gpu.detach().cpu().numpy()
791
+ self._pvalues = self._pvalues_gpu.detach().cpu().numpy()
792
+ self._conf_int = self._conf_int_gpu.detach().cpu().numpy()
793
+
794
+ # Store results
795
+ if self.fit_intercept:
796
+ if coef_np.shape[1] > 1:
797
+ self.intercept_ = coef_np[0, :].copy()
798
+ self.coef_ = coef_np[1:, :].T
799
+ self._params = coef_np.copy()
800
+ else:
801
+ self.intercept_ = float(coef_np[0, 0])
802
+ self.coef_ = coef_np[1:, 0].copy() # Ensure 1D array
803
+ self._params = coef_np[:, 0].copy()
804
+ else:
805
+ if coef_np.shape[1] > 1:
806
+ self.intercept_ = np.zeros(coef_np.shape[1], dtype=coef_np.dtype)
807
+ self.coef_ = coef_np.T
808
+ self._params = coef_np.copy()
809
+ else:
810
+ self.intercept_ = 0.0
811
+ self.coef_ = coef_np[:, 0].copy() # Ensure 1D array
812
+ self._params = coef_np[:, 0].copy()
813
+
814
+ self._X_design = X_design_np
815
+ if resid_np.shape[1] == 1:
816
+ self._resid = resid_np[:, 0]
817
+ else:
818
+ self._resid = resid_np
819
+ self._df_resid = df_resid
820
+ self._scale = scale_np
821
+ if self.compute_inference and not self._is_multi_output:
822
+ self._wrap_gaussian_inference_result()
823
+
824
+ # Release large temporary Torch tensors early.
825
+ try:
826
+ del X_design
827
+ except Exception:
828
+ pass
829
+ try:
830
+ del resid
831
+ except Exception:
832
+ pass
833
+ try:
834
+ del XtX
835
+ except Exception:
836
+ pass
837
+ try:
838
+ del Xty
839
+ except Exception:
840
+ pass
841
+ try:
842
+ del coef
843
+ except Exception:
844
+ pass
845
+ self._cleanup_torch_memory()
846
+
847
+ def _compute_inference(self):
848
+ """Compute standard errors, t-stats, p-values."""
849
+ result = compute_gaussian_inference(
850
+ self._X_design,
851
+ self._params,
852
+ self._resid,
853
+ self._scale,
854
+ self._df_resid,
855
+ self.cov_type,
856
+ hac_maxlags=self.hac_maxlags,
857
+ )
858
+ if result is None:
859
+ self._clear_inference_result()
860
+ return
861
+ result.feature_names = self._inference_feature_names()
862
+ result.apply_to(self)
863
+
864
+ def _inference_feature_names(self):
865
+ if self._feature_names is not None:
866
+ names = list(self._feature_names)
867
+ if self.fit_intercept:
868
+ names.insert(0, "(Intercept)")
869
+ return names
870
+ if self.coef_ is None:
871
+ return None
872
+ n_features = int(np.asarray(self.coef_).shape[-1])
873
+ if self.fit_intercept:
874
+ return ["(Intercept)"] + [f"x{i+1}" for i in range(n_features)]
875
+ return [f"x{i+1}" for i in range(n_features)]
876
+
877
+ def _wrap_gaussian_inference_result(self):
878
+ method = "classical" if self.cov_type == "nonrobust" else "sandwich"
879
+ distribution = "t" if self.cov_type == "nonrobust" else "normal"
880
+ result = GaussianInferenceResult(
881
+ params=self._params,
882
+ bse=self._bse,
883
+ statistic=self._tvalues,
884
+ pvalues=self._pvalues,
885
+ conf_int=self._conf_int,
886
+ cov_type=self.cov_type,
887
+ distribution=distribution,
888
+ df=self._df_resid,
889
+ method=method,
890
+ feature_names=self._inference_feature_names(),
891
+ metadata={"alpha": 0.05},
892
+ )
893
+ result.apply_to(self)
894
+
895
+ @property
896
+ def rsquared(self):
897
+ """R-squared."""
898
+ if self._y is None or self._resid is None:
899
+ return None
900
+ y_mean = np.mean(self._y)
901
+ ss_tot = np.sum((self._y - y_mean) ** 2)
902
+ ss_res = np.sum(self._resid ** 2)
903
+ return 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
904
+
905
+ @property
906
+ def rsquared_adj(self):
907
+ """Adjusted R-squared."""
908
+ if self._nobs is None:
909
+ return None
910
+ r2 = self.rsquared
911
+ k = int(self._X_design.shape[1] - (1 if self.fit_intercept else 0))
912
+ return 1 - (1 - r2) * (self._nobs - 1) / self._df_resid
913
+
914
+ @property
915
+ def fvalue(self):
916
+ """F-statistic."""
917
+ if self._y is None or self._resid is None:
918
+ return None
919
+ y_mean = np.mean(self._y)
920
+ ss_tot = np.sum((self._y - y_mean) ** 2)
921
+ ss_res = np.sum(self._resid ** 2)
922
+ ss_reg = ss_tot - ss_res
923
+ k = int(self._X_design.shape[1] - (1 if self.fit_intercept else 0))
924
+ if k == 0 or ss_res <= 0:
925
+ return np.inf
926
+ return (ss_reg / k) / (ss_res / self._df_resid)
927
+
928
+ @property
929
+ def f_pvalue(self):
930
+ """p-value for F-statistic."""
931
+ fv = self.fvalue
932
+ if fv is None or fv == np.inf:
933
+ return 1.0
934
+ k = int(self._X_design.shape[1] - (1 if self.fit_intercept else 0))
935
+ return 1 - stats.f.cdf(fv, k, self._df_resid)
936
+
937
+ @property
938
+ def aic(self):
939
+ """Akaike Information Criterion."""
940
+ if self._is_multi_output:
941
+ return None
942
+ if self._nobs is None or self._scale is None:
943
+ return None
944
+ if np.any(np.isnan(self._scale)):
945
+ return None
946
+ # AIC = -2 * log-likelihood + 2 * k
947
+ return -2 * self.llf + 2 * len(self._params)
948
+
949
+ @property
950
+ def bic(self):
951
+ """Bayesian Information Criterion."""
952
+ if self._is_multi_output:
953
+ return None
954
+ if self._nobs is None or self._scale is None:
955
+ return None
956
+ if np.any(np.isnan(self._scale)):
957
+ return None
958
+ n = self._nobs
959
+ k = len(self._params)
960
+ # BIC = -2 * log-likelihood + k * log(n)
961
+ return -2 * self.llf + k * np.log(n)
962
+
963
+ @property
964
+ def llf(self):
965
+ """Log-likelihood (matches statsmodels/R)."""
966
+ if self._nobs is None or self._resid is None:
967
+ return None
968
+ n = self._nobs
969
+ # Use MLE estimate of sigma^2 = RSS/n (not RSS/df_resid)
970
+ sigma2_mle = np.sum(self._resid ** 2) / n
971
+ # LL = -n/2 * log(2*pi*sigma2_mle) - n/2
972
+ return -n/2 * np.log(2 * np.pi * sigma2_mle) - n/2
973
+
974
+ def summary(self):
975
+ """Print summary table similar to R's summary(lm())."""
976
+ if not self._fitted:
977
+ raise RuntimeError("Model has not been fitted yet.")
978
+
979
+ if not self.compute_inference:
980
+ raise RuntimeError(
981
+ "compute_inference=False: summary/inference statistics are not available. "
982
+ "Re-fit with compute_inference=True (default)."
983
+ )
984
+ if self._is_multi_output:
985
+ raise RuntimeError("summary() is only available for single-output linear regression.")
986
+ if self._bse is None or self._pvalues is None or self._conf_int is None:
987
+ raise RuntimeError(
988
+ "Inference statistics are not available for the current fit. "
989
+ "This can happen when residual degrees of freedom are non-positive."
990
+ )
991
+
992
+ # Build feature names
993
+ if self._feature_names is not None:
994
+ feature_names = list(self._feature_names)
995
+ if self.fit_intercept:
996
+ feature_names.insert(0, '(Intercept)')
997
+ elif self.fit_intercept:
998
+ feature_names = ['(Intercept)'] + [f'x{i+1}' for i in range(len(self.coef_))]
999
+ else:
1000
+ feature_names = [f'x{i+1}' for i in range(len(self.coef_))]
1001
+
1002
+ print("=" * 80)
1003
+ print(" Linear Regression Results")
1004
+ print("=" * 80)
1005
+ print(f"Covariance Type: {self.cov_type:>15}")
1006
+ print(f"No. Observations: {self._nobs:>15}")
1007
+ print(f"Degrees of Freedom: {self._df_resid:>15}")
1008
+ print(f"R-squared: {self.rsquared:>15.4f}")
1009
+ print(f"Adj. R-squared: {self.rsquared_adj:>15.4f}")
1010
+ print(f"F-statistic: {self.fvalue:>15.4f}")
1011
+ print(f"Prob (F-statistic): {self.f_pvalue:>15.4e}")
1012
+ print(f"Log-Likelihood: {self.llf:>15.4f}")
1013
+ print(f"AIC: {self.aic:>15.4f}")
1014
+ print(f"BIC: {self.bic:>15.4f}")
1015
+ print("-" * 80)
1016
+ print(f"{'':<15} {'coef':>12} {'std err':>12} {'t':>10} {'P>|t|':>10} {'[0.025':>12} {'0.975]':>12}")
1017
+ print("-" * 80)
1018
+
1019
+ for i, name in enumerate(feature_names):
1020
+ print(f"{name:<15} {self._params[i]:>12.4f} {self._bse[i]:>12.4f} "
1021
+ f"{self._tvalues[i]:>10.3f} {self._pvalues[i]:>10.4f} "
1022
+ f"{self._conf_int[i, 0]:>12.4f} {self._conf_int[i, 1]:>12.4f}")
1023
+
1024
+ print("=" * 80)
1025
+
1026
+ def predict(self, X):
1027
+ """Predict using the linear model.
1028
+
1029
+ Parameters
1030
+ ----------
1031
+ X : array-like or pd.DataFrame
1032
+ If a DataFrame is passed and the model was trained with a formula,
1033
+ the design matrix is automatically built using the stored
1034
+ ``design_info``.
1035
+
1036
+ Returns
1037
+ -------
1038
+ predictions : ndarray
1039
+ """
1040
+ self._check_is_fitted()
1041
+
1042
+ # If model was trained with formula and X is a DataFrame,
1043
+ # rebuild the design matrix using the stored design_info.
1044
+ if self._design_info is not None:
1045
+ import pandas as pd
1046
+ if isinstance(X, pd.DataFrame):
1047
+ from statgpu.core.formula import FormulaParser
1048
+ # Reconstruct parser from design_info
1049
+ parser = FormulaParser.__new__(FormulaParser)
1050
+ parser._design_info = self._design_info
1051
+ parser.formula = None
1052
+ X = parser.transform(X)
1053
+ # Drop intercept column to match the fitting path
1054
+ col_names = list(self._design_info.column_names)
1055
+ if self._formula_has_intercept and "Intercept" in col_names:
1056
+ intercept_idx = col_names.index("Intercept")
1057
+ X = np.delete(X, intercept_idx, axis=1)
1058
+ else:
1059
+ X = np.asarray(X)
1060
+ else:
1061
+ X = np.asarray(X)
1062
+
1063
+ device = self._get_compute_device()
1064
+ if device == Device.CUDA:
1065
+ import cupy as cp
1066
+
1067
+ X_gpu = cp.asarray(self._to_array(X, Device.CUDA))
1068
+ coef_gpu = cp.asarray(self.coef_)
1069
+ intercept_gpu = cp.asarray(self.intercept_, dtype=coef_gpu.dtype)
1070
+ if coef_gpu.ndim == 2:
1071
+ return X_gpu @ coef_gpu.T + intercept_gpu
1072
+ return X_gpu @ coef_gpu + intercept_gpu
1073
+ if device == Device.TORCH:
1074
+ import torch
1075
+
1076
+ X_torch = self._to_array(X, Device.TORCH, backend="torch").to(torch.float64)
1077
+ coef_torch = torch.as_tensor(self.coef_, dtype=X_torch.dtype, device=X_torch.device)
1078
+ intercept_torch = torch.as_tensor(
1079
+ self.intercept_, dtype=X_torch.dtype, device=X_torch.device
1080
+ )
1081
+ if coef_torch.ndim == 2:
1082
+ return X_torch @ coef_torch.T + intercept_torch
1083
+ return X_torch @ coef_torch + intercept_torch
1084
+ X = self._to_array(X, Device.CPU)
1085
+ X = np.asarray(X)
1086
+ if np.asarray(self.coef_).ndim == 2:
1087
+ return X @ self.coef_.T + self.intercept_
1088
+ return X @ self.coef_ + self.intercept_
1089
+
1090
+ def score(self, X, y):
1091
+ """Return R^2 score."""
1092
+ y_pred = self.predict(X)
1093
+ device = self._get_compute_device()
1094
+ if device == Device.CUDA:
1095
+ import cupy as cp
1096
+
1097
+ yb = cp.asarray(self._to_array(y, Device.CUDA))
1098
+ if y_pred.ndim == 1:
1099
+ ss_res = cp.sum((yb - y_pred) ** 2)
1100
+ ss_tot = cp.sum((yb - cp.mean(yb)) ** 2)
1101
+ return float((1 - ss_res / ss_tot).item()) if float(ss_tot.item()) > 0 else 0.0
1102
+ ss_res = cp.sum((yb - y_pred) ** 2, axis=0)
1103
+ ss_tot = cp.sum((yb - cp.mean(yb, axis=0)) ** 2, axis=0)
1104
+ r2 = cp.where(ss_tot > 0, 1 - ss_res / ss_tot, 0.0)
1105
+ return float(cp.mean(r2).item())
1106
+ if device == Device.TORCH:
1107
+ import torch
1108
+
1109
+ yb = self._to_array(y, Device.TORCH, backend="torch").to(y_pred.dtype)
1110
+ if y_pred.ndim == 1:
1111
+ ss_res = torch.sum((yb - y_pred) ** 2)
1112
+ ss_tot = torch.sum((yb - torch.mean(yb)) ** 2)
1113
+ return float((1 - ss_res / ss_tot).item()) if float(ss_tot.item()) > 0 else 0.0
1114
+ ss_res = torch.sum((yb - y_pred) ** 2, dim=0)
1115
+ ss_tot = torch.sum((yb - torch.mean(yb, dim=0)) ** 2, dim=0)
1116
+ r2 = torch.where(ss_tot > 0, 1 - ss_res / ss_tot, torch.zeros_like(ss_tot))
1117
+ return float(torch.mean(r2).item())
1118
+ y_pred = np.asarray(y_pred)
1119
+ y = self._to_numpy(y)
1120
+ if y_pred.ndim == 1:
1121
+ ss_res = np.sum((y - y_pred) ** 2)
1122
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
1123
+ return 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
1124
+ ss_res = np.sum((y - y_pred) ** 2, axis=0)
1125
+ ss_tot = np.sum((y - np.mean(y, axis=0)) ** 2, axis=0)
1126
+ r2 = np.where(ss_tot > 0, 1 - ss_res / ss_tot, 0.0)
1127
+ return float(np.mean(r2))