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,892 @@
1
+ """
2
+ ElasticNetCV: Cross-validated Elastic Net regression with GPU support.
3
+ """
4
+
5
+ __all__ = ["ElasticNetCV"]
6
+
7
+ from typing import Any, Dict, List, Optional, Tuple, Union
8
+ from collections import OrderedDict
9
+ import hashlib
10
+ import numpy as np
11
+
12
+ from statgpu._config import Device, cuda_available
13
+ from statgpu.cross_validation._base import CVEstimatorBase, batch_mse as _batch_mse_cv
14
+ from statgpu.backends import get_backend
15
+ from statgpu.linear_model.wrappers._elasticnet import ElasticNet
16
+
17
+
18
+ # =============================================================================
19
+ # CV Cache
20
+ # =============================================================================
21
+
22
+ import threading
23
+
24
+ _ELASTICNET_CV_CACHE_MAXSIZE = int(64)
25
+ _ELASTICNET_CV_CACHE: "OrderedDict[Tuple[Any, ...], Dict[str, Any]]" = OrderedDict()
26
+ _ELASTICNET_CV_CACHE_LOCK = threading.Lock()
27
+
28
+
29
+ def _elasticnet_cv_cache_get(cache_key: Optional[Tuple[Any, ...]]) -> Optional[Dict[str, Any]]:
30
+ """Get cached ElasticNet CV results."""
31
+ if cache_key is None:
32
+ return None
33
+ with _ELASTICNET_CV_CACHE_LOCK:
34
+ val = _ELASTICNET_CV_CACHE.get(cache_key)
35
+ if val is not None:
36
+ _ELASTICNET_CV_CACHE.move_to_end(cache_key)
37
+ return val
38
+
39
+
40
+ def _elasticnet_cv_cache_put(cache_key: Optional[Tuple[Any, ...]], value: Dict[str, Any]) -> None:
41
+ """Put cached ElasticNet CV results."""
42
+ if cache_key is None:
43
+ return
44
+ with _ELASTICNET_CV_CACHE_LOCK:
45
+ _ELASTICNET_CV_CACHE[cache_key] = value
46
+ _ELASTICNET_CV_CACHE.move_to_end(cache_key)
47
+ while len(_ELASTICNET_CV_CACHE) > _ELASTICNET_CV_CACHE_MAXSIZE:
48
+ _ELASTICNET_CV_CACHE.popitem(last=False)
49
+
50
+
51
+ def _make_elasticnet_cv_auto_cache_key(
52
+ X_shape: Tuple[int, ...],
53
+ y_shape: Tuple[int, ...],
54
+ l1_ratios: Tuple[float, ...],
55
+ alphas: Optional[np.ndarray],
56
+ n_alphas: int,
57
+ alpha_min_ratio: float,
58
+ folds: List[Tuple[np.ndarray, np.ndarray]],
59
+ fit_intercept: bool,
60
+ use_gpu: bool,
61
+ max_iter: int,
62
+ tol: float,
63
+ sample_weight_shape: Optional[Tuple[int, ...]] = None,
64
+ data_digest: Optional[bytes] = None,
65
+ ) -> Tuple[Any, ...]:
66
+ """Generate automatic cache key for ElasticNet CV."""
67
+ h = hashlib.blake2b(digest_size=32)
68
+ h.update(np.asarray(X_shape, dtype=np.int64).tobytes())
69
+ h.update(np.asarray(y_shape, dtype=np.int64).tobytes())
70
+ if data_digest is not None:
71
+ h.update(data_digest)
72
+ h.update(np.asarray(l1_ratios, dtype=np.float64).tobytes())
73
+ if alphas is not None:
74
+ h.update(np.asarray(alphas, dtype=np.float64).tobytes())
75
+ h.update(str(n_alphas).encode("utf-8"))
76
+ h.update(str(alpha_min_ratio).encode("utf-8"))
77
+ h.update(str(fit_intercept).encode("utf-8"))
78
+ h.update(str(use_gpu).encode("utf-8"))
79
+ h.update(str(max_iter).encode("utf-8"))
80
+ h.update(str(tol).encode("utf-8"))
81
+ h.update(str(len(folds)).encode("utf-8"))
82
+ for train_idx, test_idx in folds:
83
+ train_idx_arr = (
84
+ train_idx
85
+ if isinstance(train_idx, np.ndarray) and train_idx.dtype == np.int64
86
+ else np.asarray(train_idx, dtype=np.int64)
87
+ )
88
+ test_idx_arr = (
89
+ test_idx
90
+ if isinstance(test_idx, np.ndarray) and test_idx.dtype == np.int64
91
+ else np.asarray(test_idx, dtype=np.int64)
92
+ )
93
+ h.update(train_idx_arr.tobytes())
94
+ h.update(test_idx_arr.tobytes())
95
+ if sample_weight_shape is not None:
96
+ h.update(np.asarray(sample_weight_shape, dtype=np.int64).tobytes())
97
+ return h.hexdigest()
98
+
99
+
100
+ from statgpu.cross_validation._base import hash_cv_data as _hash_data
101
+
102
+
103
+ # =============================================================================
104
+ # K-fold helpers
105
+ # =============================================================================
106
+
107
+ from statgpu.cross_validation._base import kfold_indices as _kfold_indices, folds_are_complete as _folds_are_complete
108
+
109
+
110
+ # =============================================================================
111
+ # Alpha grid generation for ElasticNet
112
+ # =============================================================================
113
+
114
+ def _default_elasticnet_alpha_grid(
115
+ X,
116
+ y,
117
+ l1_ratio: float = 0.5,
118
+ n_alphas: int = 100,
119
+ alpha_min_ratio: float = 1e-3,
120
+ ) -> np.ndarray:
121
+ """
122
+ Generate default alpha grid for ElasticNet.
123
+
124
+ Parameters
125
+ ----------
126
+ X : array-like
127
+ Design matrix (n_samples, n_features).
128
+ y : array-like
129
+ Response vector.
130
+ l1_ratio : float
131
+ L1 ratio (0.0 = Ridge, 1.0 = Lasso).
132
+ n_alphas : int
133
+ Number of alpha values.
134
+ alpha_min_ratio : float
135
+ Minimum alpha as a ratio of max alpha.
136
+
137
+ Returns
138
+ -------
139
+ alphas : ndarray
140
+ Log-spaced alpha values.
141
+ """
142
+ X_arr = np.asarray(X, dtype=np.float64)
143
+ y_arr = np.asarray(y, dtype=np.float64).reshape(-1)
144
+
145
+ n_samples, n_features = X_arr.shape
146
+
147
+ # Handle intercept by centering
148
+ X_mean = np.mean(X_arr, axis=0)
149
+ y_mean = np.mean(y_arr)
150
+ X_centered = X_arr - X_mean
151
+ y_centered = y_arr - y_mean
152
+
153
+ # Compute correlation for alpha_max
154
+ Xty = X_centered.T @ y_centered
155
+
156
+ # alpha_max = max(|X'c yc|) / (n * l1_ratio)
157
+ # For l1_ratio=1 (Lasso): max(|X'y|) / n
158
+ # For l1_ratio<1: larger because L2 penalty contributes less
159
+ _l1r = max(float(l1_ratio), 1e-6)
160
+ alpha_max = float(np.max(np.abs(Xty))) / (n_samples * _l1r)
161
+ alpha_max = max(alpha_max, 1e-6)
162
+
163
+ if alpha_max <= 0:
164
+ alpha_max = 1.0
165
+
166
+ # Log-spaced grid
167
+ if int(n_alphas) <= 1:
168
+ return np.asarray([alpha_max], dtype=np.float64)
169
+
170
+ alpha_min = max(float(alpha_min_ratio) * alpha_max, 1e-6)
171
+ return np.geomspace(alpha_max, alpha_min, num=int(n_alphas)).astype(np.float64)
172
+
173
+
174
+ def _default_elasticnet_alpha_grid_backend(
175
+ X,
176
+ y,
177
+ backend,
178
+ l1_ratio: float = 0.5,
179
+ n_alphas: int = 100,
180
+ alpha_min_ratio: float = 1e-3,
181
+ ) -> np.ndarray:
182
+ """
183
+ Generate default alpha grid for ElasticNet using backend abstraction.
184
+
185
+ Parameters
186
+ ----------
187
+ X : array-like
188
+ Design matrix.
189
+ y : array-like
190
+ Response vector.
191
+ backend : BackendBase
192
+ Backend instance.
193
+ l1_ratio : float
194
+ L1 ratio.
195
+ n_alphas : int
196
+ Number of alpha values.
197
+ alpha_min_ratio : float
198
+ Minimum alpha ratio.
199
+
200
+ Returns
201
+ -------
202
+ alphas : ndarray
203
+ Log-spaced alpha values.
204
+ """
205
+ X_arr = backend.asarray(X, dtype=backend.float64)
206
+ y_arr = backend.asarray(y, dtype=backend.float64).reshape(-1)
207
+
208
+ n_samples = int(X_arr.shape[0])
209
+
210
+ # Center data
211
+ X_mean = backend.mean(X_arr, axis=0)
212
+ y_mean = backend.mean(y_arr)
213
+ X_centered = X_arr - X_mean
214
+ y_centered = y_arr - y_mean
215
+
216
+ # Compute Xty
217
+ Xty = X_centered.T @ y_centered
218
+
219
+ # Alpha max: max(|X'y|) / (n * l1_ratio)
220
+ _l1r = max(float(l1_ratio), 1e-6)
221
+ alpha_max = float(backend.max(backend.abs(Xty))) / (n_samples * _l1r)
222
+
223
+ if alpha_max <= 0:
224
+ alpha_max = 1.0
225
+
226
+ if int(n_alphas) <= 1:
227
+ return np.asarray([alpha_max], dtype=np.float64)
228
+
229
+ alpha_min = max(float(alpha_min_ratio) * alpha_max, 1e-6)
230
+ return np.geomspace(alpha_max, alpha_min, num=int(n_alphas)).astype(np.float64)
231
+
232
+
233
+ # =============================================================================
234
+ # CV main function
235
+ # =============================================================================
236
+
237
+ def _select_elasticnet_params_cv(
238
+ X,
239
+ y,
240
+ *,
241
+ l1_ratios=None,
242
+ alphas=None,
243
+ n_alphas: int = 100,
244
+ alpha_min_ratio: float = 1e-3,
245
+ cv_folds: int = 5,
246
+ cv_splits=None,
247
+ random_state: Optional[int] = None,
248
+ sample_weight=None,
249
+ fit_intercept: bool = True,
250
+ device: Union[str, Device] = Device.CPU,
251
+ max_iter: int = 1000,
252
+ tol: float = 1e-4,
253
+ return_details: bool = False,
254
+ cache_key: Optional[Tuple[Any, ...]] = None,
255
+ ):
256
+ """
257
+ Select alpha and l1_ratio for Elastic Net via K-fold cross-validation.
258
+
259
+ Parameters
260
+ ----------
261
+ X : array-like
262
+ Design matrix (n_samples, n_features).
263
+ y : array-like
264
+ Response vector.
265
+ l1_ratios : array-like or None
266
+ L1 ratios to try. If None, uses [0.2, 0.5, 0.7, 0.8, 0.9, 0.95, 0.99].
267
+ alphas : array-like or None
268
+ Alpha values to try. If None, generates n_alphas values.
269
+ n_alphas : int
270
+ Number of alpha values (if alphas is None).
271
+ alpha_min_ratio : float
272
+ Minimum alpha ratio.
273
+ cv_folds : int
274
+ Number of CV folds.
275
+ cv_splits : list or None
276
+ Pre-computed CV splits.
277
+ random_state : int or None
278
+ Random seed.
279
+ sample_weight : array-like or None
280
+ Sample weights.
281
+ fit_intercept : bool
282
+ Whether to fit intercept.
283
+ device : str or Device
284
+ Device to use.
285
+ max_iter : int
286
+ Maximum iterations.
287
+ tol : float
288
+ Convergence tolerance.
289
+ return_details : bool
290
+ Whether to return full CV details.
291
+ cache_key : tuple or None
292
+ Cache key.
293
+
294
+ Returns
295
+ -------
296
+ best_alpha : float
297
+ best_l1_ratio : float
298
+ details : dict (if return_details=True)
299
+ """
300
+ if isinstance(device, Device):
301
+ device_name = device.value
302
+ else:
303
+ device_name = str(device).lower()
304
+ if device_name.startswith("device."):
305
+ enum_name = device_name.split(".", 1)[1].upper()
306
+ if enum_name not in Device.__members__:
307
+ valid = ", ".join(sorted(d.value for d in Device))
308
+ raise ValueError(f"Invalid device '{device}'. Expected one of: {valid}")
309
+ device_name = Device[enum_name].value
310
+ if device_name == Device.AUTO.value:
311
+ use_gpu = bool(cuda_available())
312
+ elif device_name in (Device.CUDA.value, Device.TORCH.value):
313
+ use_gpu = True
314
+ else:
315
+ use_gpu = False
316
+ gpu_requested = use_gpu
317
+
318
+ # Detect GPU input
319
+ gpu_input_cupy = False
320
+ gpu_input_torch = False
321
+ if use_gpu:
322
+ try:
323
+ import cupy as cp
324
+ gpu_input_cupy = isinstance(X, cp.ndarray) and isinstance(y, cp.ndarray)
325
+ if sample_weight is not None and not isinstance(sample_weight, cp.ndarray):
326
+ gpu_input_cupy = False
327
+ except Exception:
328
+ pass
329
+ if not gpu_input_cupy:
330
+ try:
331
+ import torch
332
+ gpu_input_torch = isinstance(X, torch.Tensor) and isinstance(y, torch.Tensor)
333
+ if sample_weight is not None and not isinstance(sample_weight, torch.Tensor):
334
+ gpu_input_torch = False
335
+ except Exception:
336
+ pass
337
+
338
+ # Validate inputs
339
+ X_np = None
340
+ y_np = None
341
+ sample_weight_np = None
342
+
343
+ if gpu_input_cupy or gpu_input_torch:
344
+ if len(tuple(X.shape)) != 2:
345
+ raise ValueError("X must be a 2D array")
346
+ n_samples = int(X.shape[0])
347
+ backend = get_backend(backend='auto', device='cuda')
348
+ y_check = backend.asarray(y).reshape(-1)
349
+ if int(y_check.shape[0]) != n_samples:
350
+ raise ValueError("y must have the same number of rows as X")
351
+ else:
352
+ X_np = np.asarray(X, dtype=np.float64)
353
+ y_np = np.asarray(y, dtype=np.float64).reshape(-1)
354
+ if sample_weight is not None:
355
+ sample_weight_np = np.asarray(sample_weight, dtype=np.float64).reshape(-1)
356
+ if X_np.ndim != 2:
357
+ raise ValueError("X must be a 2D array")
358
+ if y_np.shape[0] != X_np.shape[0]:
359
+ raise ValueError("y must have the same number of rows as X")
360
+ n_samples = int(X_np.shape[0])
361
+
362
+ # Default l1_ratios
363
+ if l1_ratios is None:
364
+ l1_ratios_arr = np.asarray([0.2, 0.5, 0.7, 0.8, 0.9, 0.95, 0.99], dtype=np.float64)
365
+ else:
366
+ l1_ratios_arr = np.asarray(l1_ratios, dtype=np.float64)
367
+ l1_ratios_arr = l1_ratios_arr[(l1_ratios_arr >= 0.0) & (l1_ratios_arr <= 1.0)]
368
+
369
+ if l1_ratios_arr.size == 0:
370
+ l1_ratios_arr = np.asarray([0.5], dtype=np.float64)
371
+
372
+ n_l1_ratios = int(l1_ratios_arr.size)
373
+
374
+ # Generate alpha grids for each l1_ratio (use integer index as key)
375
+ alpha_grids = {}
376
+ for l1_idx, l1r in enumerate(l1_ratios_arr):
377
+ if alphas is None:
378
+ if gpu_input_cupy or gpu_input_torch:
379
+ backend = get_backend(backend='torch' if gpu_input_torch else 'cupy', device='cuda')
380
+ alpha_grids[l1_idx] = _default_elasticnet_alpha_grid_backend(
381
+ X, y, backend, l1_ratio=l1r, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio
382
+ )
383
+ else:
384
+ alpha_grids[l1_idx] = _default_elasticnet_alpha_grid(
385
+ X_np, y_np, l1_ratio=l1r, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio
386
+ )
387
+ else:
388
+ alpha_grid = np.asarray(alphas, dtype=np.float64)
389
+ alpha_grid = alpha_grid[np.isfinite(alpha_grid)]
390
+ alpha_grid = alpha_grid[alpha_grid > 0.0]
391
+ if alpha_grid.size == 0:
392
+ if gpu_input_cupy or gpu_input_torch:
393
+ backend = get_backend(backend='torch' if gpu_input_torch else 'cupy', device='cuda')
394
+ alpha_grids[l1_idx] = _default_elasticnet_alpha_grid_backend(
395
+ X, y, backend, l1_ratio=l1r, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio
396
+ )
397
+ else:
398
+ alpha_grids[l1_idx] = _default_elasticnet_alpha_grid(
399
+ X_np, y_np, l1_ratio=l1r, n_alphas=n_alphas, alpha_min_ratio=alpha_min_ratio
400
+ )
401
+ else:
402
+ alpha_grids[l1_idx] = alpha_grid
403
+
404
+ # Handle degenerate cases
405
+ if int(n_samples) < 4 or int(cv_folds) < 2:
406
+ # Use first l1_ratio and first alpha
407
+ l1r0 = float(l1_ratios_arr[0])
408
+ alpha0 = float(alpha_grids[0][0])
409
+ if not return_details:
410
+ return alpha0, l1r0
411
+ details = {
412
+ "alpha": alpha0,
413
+ "l1_ratio": l1r0,
414
+ "alphas": alpha_grids[0].astype(np.float64),
415
+ "l1_ratios": l1_ratios_arr.astype(np.float64),
416
+ "mse_path": np.full((int(n_l1_ratios), int(alpha_grids[0].size), 1), np.nan, dtype=np.float64),
417
+ "mean_mse": np.full((int(n_l1_ratios), int(alpha_grids[0].size)), np.nan, dtype=np.float64),
418
+ }
419
+ return alpha0, l1r0, details
420
+
421
+ # Generate CV folds
422
+ if cv_splits is not None:
423
+ from statgpu.linear_model.wrappers._lasso import _normalize_cv_splits
424
+ folds = _normalize_cv_splits(cv_splits, n_samples=int(n_samples))
425
+ else:
426
+ folds = _kfold_indices(n_samples=int(n_samples), n_splits=int(cv_folds), random_state=random_state)
427
+
428
+ n_folds = int(len(folds))
429
+
430
+ # Auto-cache disabled by default to prevent stale results across datasets.
431
+ cache_key_eff = cache_key
432
+
433
+ cached_result = _elasticnet_cv_cache_get(cache_key_eff)
434
+ if cached_result is not None:
435
+ if return_details:
436
+ return cached_result["alpha"], cached_result["l1_ratio"], cached_result
437
+ return cached_result["alpha"], cached_result["l1_ratio"]
438
+
439
+ # Initialize MSE storage
440
+ # mse_path: (n_l1_ratios, n_alphas, n_folds)
441
+ max_n_alphas = max(len(ag) for ag in alpha_grids.values())
442
+ mse_path = np.full((n_l1_ratios, max_n_alphas, n_folds), np.nan, dtype=np.float64)
443
+
444
+ # Get backend
445
+ if gpu_input_torch:
446
+ backend = get_backend(backend='torch', device='cuda')
447
+ elif gpu_input_cupy:
448
+ backend = get_backend(backend='cupy', device='cuda')
449
+ else:
450
+ backend = get_backend(backend='auto', device='cuda' if use_gpu else 'cpu')
451
+
452
+ xp = backend.xp
453
+
454
+ # Check if we should use warm-start path optimization
455
+ # Warm-start works when: CPU backend, no sample_weight, fit_intercept handled by centering
456
+ use_warm_start = (
457
+ backend.name == 'numpy'
458
+ and not use_gpu
459
+ and sample_weight_np is None
460
+ )
461
+
462
+ # Precompute per-fold data and XtX (independent of l1_ratio)
463
+ fold_data = [] # (X_train, y_train, X_val, y_val, sw_train, sw_val)
464
+ fold_xtx = [] # (XtX_fold, L_fold) for warm-start path
465
+
466
+ for fold_idx, (train_idx, val_idx) in enumerate(folds):
467
+ train_idx_arr = backend.asarray(train_idx)
468
+ val_idx_arr = backend.asarray(val_idx)
469
+
470
+ # Split data
471
+ if gpu_input_cupy or gpu_input_torch:
472
+ X_train_raw = X[train_idx_arr]
473
+ y_train_raw = y[train_idx_arr]
474
+ X_val = X[val_idx_arr]
475
+ y_val = y[val_idx_arr]
476
+ if sample_weight is not None:
477
+ sw_train = sample_weight[train_idx_arr]
478
+ sw_val = sample_weight[val_idx_arr]
479
+ else:
480
+ sw_train = None
481
+ sw_val = None
482
+ X_train = X_train_raw
483
+ y_train = y_train_raw
484
+ else:
485
+ X_train_np = X_np[train_idx]
486
+ y_train_np = y_np[train_idx]
487
+ X_val = backend.asarray(X_np[val_idx])
488
+ y_val = backend.asarray(y_np[val_idx])
489
+ if sample_weight_np is not None:
490
+ sw_train = backend.asarray(sample_weight_np[train_idx])
491
+ sw_val = backend.asarray(sample_weight_np[val_idx])
492
+ else:
493
+ sw_train = None
494
+ sw_val = None
495
+ X_train = X_train_np
496
+ y_train = y_train_np
497
+
498
+ fold_data.append((X_train, y_train, X_val, y_val, sw_train, sw_val))
499
+
500
+ # Precompute XtX and Lipschitz for warm-start path (independent of l1_ratio)
501
+ if use_warm_start:
502
+ if fit_intercept:
503
+ X_mean_fold = np.mean(X_train_np, axis=0)
504
+ y_mean_fold = np.mean(y_train_np)
505
+ Xc = X_train_np - X_mean_fold
506
+ yc = y_train_np - y_mean_fold
507
+ else:
508
+ Xc = X_train_np
509
+ yc = y_train_np
510
+
511
+ XtX_fold = Xc.T @ Xc
512
+ eig_max = np.linalg.eigvalsh(XtX_fold)[-1]
513
+ L_fold = float(eig_max / len(train_idx))
514
+ fold_xtx.append((XtX_fold, L_fold))
515
+ else:
516
+ fold_xtx.append(None)
517
+
518
+ # CV loop: iterate over l1_ratio and folds
519
+ for l1_idx, l1_ratio in enumerate(l1_ratios_arr):
520
+ alpha_grid = alpha_grids[l1_idx]
521
+ n_alphas_this = len(alpha_grid)
522
+
523
+ for fold_idx, (train_idx, val_idx) in enumerate(folds):
524
+ X_train, y_train, X_val, y_val, sw_train, sw_val = fold_data[fold_idx]
525
+
526
+ # For CPU warm-start path: precompute per-fold data to avoid redundant work
527
+ if use_warm_start:
528
+ # The alpha grid should be sorted descending for warm-start to work well
529
+ # (largest alpha first -> sparsest solution -> warm-start to smaller alpha)
530
+ alpha_grid_sorted = np.sort(alpha_grid)[::-1]
531
+ sort_indices = np.argsort(alpha_grid)[::-1]
532
+
533
+ # Sort alpha_grid for warm-start path
534
+ alpha_grid_ws = alpha_grid_sorted
535
+
536
+ # Reuse precomputed XtX and Lipschitz
537
+ XtX_fold, L_fold = fold_xtx[fold_idx]
538
+
539
+ # Fit alphas with warm-start (descending order)
540
+ prev_coef = None
541
+ for alpha_idx_ws, alpha in enumerate(alpha_grid_ws):
542
+ orig_idx = sort_indices[alpha_idx_ws]
543
+
544
+ # Create model with known L to avoid recomputation
545
+ model = ElasticNet(
546
+ alpha=alpha,
547
+ l1_ratio=l1_ratio,
548
+ max_iter=max_iter,
549
+ tol=tol,
550
+ fit_intercept=fit_intercept,
551
+ device='cpu',
552
+ lipschitz_L=L_fold,
553
+ )
554
+
555
+ model.fit(X_train, y_train, initial_coef=prev_coef)
556
+
557
+ # Store result
558
+ mse_val = _batch_mse_cv(
559
+ X_val, y_val,
560
+ model.coef_.reshape(1, -1),
561
+ np.array([model.intercept_]),
562
+ )
563
+ mse_path[l1_idx, orig_idx, fold_idx] = float(mse_val[0])
564
+ prev_coef = model.coef_.copy()
565
+ else:
566
+ # Original approach for GPU or with sample weights
567
+ for alpha_idx, alpha in enumerate(alpha_grid):
568
+ # Convert backend to device string that ElasticNet understands
569
+ if backend.name == 'numpy':
570
+ enet_device = 'cpu'
571
+ elif backend.name == 'cupy':
572
+ enet_device = 'cuda'
573
+ elif backend.name == 'torch':
574
+ enet_device = 'torch'
575
+ else:
576
+ enet_device = 'cpu'
577
+
578
+ model = ElasticNet(
579
+ alpha=alpha,
580
+ l1_ratio=l1_ratio,
581
+ max_iter=max_iter,
582
+ tol=tol,
583
+ fit_intercept=fit_intercept,
584
+ device=enet_device,
585
+ )
586
+ model.fit(X_train, y_train, sample_weight=sw_train)
587
+
588
+ # Compute validation MSE
589
+ mse_val = _batch_mse_cv(
590
+ X_val, y_val,
591
+ model.coef_.reshape(1, -1),
592
+ np.array([model.intercept_]),
593
+ sample_weight=sw_val,
594
+ )
595
+
596
+ mse_path[l1_idx, alpha_idx, fold_idx] = float(mse_val[0])
597
+
598
+ # Compute mean and std MSE across folds
599
+ mean_mse = np.nanmean(mse_path, axis=2) # (n_l1_ratios, n_alphas)
600
+ std_mse = np.nanstd(mse_path, axis=2)
601
+
602
+ # Find best (l1_ratio, alpha) combination
603
+ best_l1_idx, best_alpha_idx = np.unravel_index(np.nanargmin(mean_mse), mean_mse.shape)
604
+
605
+ best_l1_ratio = float(l1_ratios_arr[best_l1_idx])
606
+ best_alpha_grid = alpha_grids[best_l1_idx]
607
+ best_alpha = float(best_alpha_grid[best_alpha_idx])
608
+ best_mse = float(mean_mse[best_l1_idx, best_alpha_idx])
609
+
610
+ # Prepare details
611
+ details = {
612
+ "alpha": best_alpha,
613
+ "l1_ratio": best_l1_ratio,
614
+ "alphas": alpha_grids,
615
+ "l1_ratios": l1_ratios_arr.astype(np.float64),
616
+ "mse_path": mse_path.astype(np.float64),
617
+ "mean_mse": mean_mse.astype(np.float64),
618
+ "std_mse": std_mse.astype(np.float64),
619
+ "best_mse": best_mse,
620
+ "n_folds": n_folds,
621
+ }
622
+
623
+ # Cache result
624
+ if _ELASTICNET_CV_CACHE_MAXSIZE > 0:
625
+ _elasticnet_cv_cache_put(cache_key_eff, details)
626
+
627
+ if return_details:
628
+ return best_alpha, best_l1_ratio, details
629
+
630
+ return best_alpha, best_l1_ratio
631
+
632
+
633
+ # =============================================================================
634
+ # ElasticNetCV Class
635
+ # =============================================================================
636
+
637
+ class ElasticNetCV(CVEstimatorBase):
638
+ """
639
+ Cross-validated Elastic Net regression with GPU support.
640
+
641
+ Elastic Net combines L1 (Lasso) and L2 (Ridge) regularization:
642
+
643
+ minimize (1/(2n)) * ||y - Xw||²₂ + α * l1_ratio * ||w||₁ + 0.5 * α * (1 - l1_ratio) * ||w||²₂
644
+
645
+ This class uses K-fold cross-validation to select the optimal alpha and l1_ratio.
646
+
647
+ Parameters
648
+ ----------
649
+ l1_ratio : float or array-like, default=0.5
650
+ L1 regularization ratio. 0.0 = Ridge, 1.0 = Lasso.
651
+ If array-like, CV is performed over all values.
652
+ alphas : array-like or None
653
+ Alpha values to try. If None, generates n_alphas values.
654
+ n_alphas : int, default=100
655
+ Number of alpha values (if alphas is None).
656
+ alpha_min_ratio : float, default=1e-3
657
+ Minimum alpha as a ratio of max alpha.
658
+ cv : int, default=5
659
+ Number of CV folds.
660
+ fit_intercept : bool, default=True
661
+ Whether to fit intercept.
662
+ max_iter : int, default=1000
663
+ Maximum iterations for solver.
664
+ tol : float, default=1e-4
665
+ Convergence tolerance.
666
+ device : str or Device, default=Device.AUTO
667
+ Computation device: 'cpu', 'cuda', or 'auto'.
668
+ compute_inference : bool, default=False
669
+ Whether to compute inference statistics.
670
+ random_state : int or None
671
+ Random seed for CV splits.
672
+ n_jobs : int or None
673
+ Number of parallel jobs (not yet implemented).
674
+
675
+ Attributes
676
+ ----------
677
+ alpha_ : float
678
+ Selected alpha value.
679
+ l1_ratio_ : float
680
+ Selected l1_ratio value.
681
+ coef_ : ndarray
682
+ Coefficients of the final model.
683
+ intercept_ : float
684
+ Intercept of the final model.
685
+ cv_results_ : dict
686
+ CV results including mse_path and mean_mse.
687
+ best_score_ : float
688
+ Best (minimum) MSE across CV folds.
689
+
690
+ Examples
691
+ --------
692
+ >>> import numpy as np
693
+ >>> from statgpu.linear_model import ElasticNetCV
694
+ >>> X = np.random.randn(1000, 50)
695
+ >>> y = X @ np.random.randn(50) + 0.1 * np.random.randn(1000)
696
+ >>> model = ElasticNetCV(l1_ratio=[0.2, 0.5, 0.8], cv=5, device='cuda')
697
+ >>> model.fit(X, y)
698
+ >>> print(f"Selected alpha: {model.alpha_:.4f}")
699
+ >>> print(f"Selected l1_ratio: {model.l1_ratio_:.4f}")
700
+ """
701
+
702
+ def __init__(
703
+ self,
704
+ l1_ratio=0.5,
705
+ *,
706
+ alphas=None,
707
+ n_alphas: int = 100,
708
+ alpha_min_ratio: float = 1e-3,
709
+ cv: int = 5,
710
+ cv_splits=None,
711
+ fit_intercept: bool = True,
712
+ device: Union[str, Device] = Device.AUTO,
713
+ n_jobs: Optional[int] = None,
714
+ compute_inference: bool = False,
715
+ max_iter: int = 1000,
716
+ tol: float = 1e-4,
717
+ random_state: Optional[int] = None,
718
+ ):
719
+ super().__init__(
720
+ cv=cv,
721
+ random_state=random_state,
722
+ device=device,
723
+ n_jobs=n_jobs,
724
+ )
725
+ self.l1_ratio = l1_ratio
726
+ self.alphas = alphas
727
+ self.n_alphas = int(n_alphas)
728
+ self.alpha_min_ratio = float(alpha_min_ratio)
729
+ self.cv = int(cv)
730
+ self.cv_splits = cv_splits
731
+ self.fit_intercept = bool(fit_intercept)
732
+ self.compute_inference = bool(compute_inference)
733
+ self.max_iter = int(max_iter)
734
+ self.tol = float(tol)
735
+
736
+ # Output attributes
737
+ self.alpha_ = None
738
+ self.l1_ratio_ = None
739
+ self.coef_ = None
740
+ self.intercept_ = None
741
+ self.cv_results_ = None
742
+ self.best_score_ = None
743
+ self.n_iter_ = None
744
+ self.estimator_ = None
745
+
746
+ def _fit_cv(self, X, y, sample_weight=None):
747
+ """
748
+ Fit Elastic Net with K-fold cross-validation.
749
+
750
+ Parameters
751
+ ----------
752
+ X : array-like
753
+ Design matrix.
754
+ y : array-like
755
+ Response vector.
756
+ sample_weight : array-like, optional
757
+ Sample weights.
758
+
759
+ Returns
760
+ -------
761
+ self
762
+ """
763
+ compute_device = self._get_compute_device()
764
+
765
+ # Normalize l1_ratio to list
766
+ if isinstance(self.l1_ratio, (list, tuple, np.ndarray)):
767
+ l1_ratios = np.asarray(self.l1_ratio, dtype=np.float64)
768
+ else:
769
+ l1_ratios = np.asarray([self.l1_ratio], dtype=np.float64)
770
+
771
+ # Perform CV
772
+ best_alpha, best_l1_ratio, details = _select_elasticnet_params_cv(
773
+ X, y,
774
+ l1_ratios=l1_ratios,
775
+ alphas=self.alphas,
776
+ n_alphas=self.n_alphas,
777
+ alpha_min_ratio=self.alpha_min_ratio,
778
+ cv_folds=self.cv,
779
+ cv_splits=self.cv_splits,
780
+ random_state=self.random_state,
781
+ sample_weight=sample_weight,
782
+ fit_intercept=self.fit_intercept,
783
+ device=compute_device,
784
+ max_iter=self.max_iter,
785
+ tol=self.tol,
786
+ return_details=True,
787
+ )
788
+
789
+ # Store CV results
790
+ self.alpha_ = best_alpha
791
+ self.l1_ratio_ = best_l1_ratio
792
+ self.cv_results_ = {
793
+ "mse_path": details["mse_path"],
794
+ "mean_mse": details["mean_mse"],
795
+ "std_mse": details["std_mse"],
796
+ "alphas": details["alphas"],
797
+ "l1_ratios": details["l1_ratios"],
798
+ "best_alpha": self.alpha_,
799
+ "best_l1_ratio": self.l1_ratio_,
800
+ }
801
+ # sklearn convention: best_score_ is negative MSE (higher is better)
802
+ self.best_score_ = -float(details["best_mse"])
803
+
804
+ # Fit final model on full data with best parameters
805
+ final_model = ElasticNet(
806
+ alpha=self.alpha_,
807
+ l1_ratio=self.l1_ratio_,
808
+ max_iter=self.max_iter,
809
+ tol=self.tol,
810
+ fit_intercept=self.fit_intercept,
811
+ device=self.device,
812
+ )
813
+ final_model.fit(X, y, sample_weight=sample_weight)
814
+
815
+ self.coef_ = final_model.coef_.copy()
816
+ self.intercept_ = final_model.intercept_
817
+ self.n_iter_ = final_model.n_iter_
818
+ self.estimator_ = final_model
819
+ self._fitted = True
820
+
821
+ return self
822
+
823
+ def fit(self, X, y, sample_weight=None):
824
+ """
825
+ Fit Elastic Net model with cross-validation.
826
+
827
+ Parameters
828
+ ----------
829
+ X : array-like
830
+ Design matrix (n_samples, n_features).
831
+ y : array-like
832
+ Response vector (n_samples,).
833
+ sample_weight : array-like, optional
834
+ Sample weights.
835
+
836
+ Returns
837
+ -------
838
+ self
839
+ """
840
+ return self._fit_cv(X, y, sample_weight=sample_weight)
841
+
842
+ def predict(self, X):
843
+ """
844
+ Predict using Elastic Net model.
845
+
846
+ Parameters
847
+ ----------
848
+ X : array-like
849
+ Test features.
850
+
851
+ Returns
852
+ -------
853
+ y_pred : ndarray
854
+ Predicted values.
855
+ """
856
+ if self.coef_ is None:
857
+ raise ValueError("Model not fitted. Call fit() first.")
858
+
859
+ # Delegate to fitted estimator for backend-aware prediction
860
+ if self.estimator_ is not None:
861
+ return self.estimator_.predict(X)
862
+ X_arr = np.asarray(X, dtype=np.float64)
863
+ return X_arr @ self.coef_ + self.intercept_
864
+
865
+ def score(self, X, y):
866
+ """
867
+ Return R² score.
868
+
869
+ Parameters
870
+ ----------
871
+ X : array-like
872
+ Test features.
873
+ y : array-like
874
+ True values.
875
+
876
+ Returns
877
+ -------
878
+ score : float
879
+ R² score.
880
+ """
881
+ # Delegate to fitted estimator for backend-aware scoring
882
+ if self.estimator_ is not None:
883
+ return self.estimator_.score(X, y)
884
+ y_pred = self.predict(X)
885
+ y_arr = np.asarray(y, dtype=np.float64).reshape(-1)
886
+
887
+ ss_res = np.sum((y_arr - y_pred) ** 2)
888
+ ss_tot = np.sum((y_arr - np.mean(y_arr)) ** 2)
889
+
890
+ if ss_tot == 0.0:
891
+ return 0.0 if ss_res == 0.0 else float('-inf')
892
+ return 1.0 - ss_res / ss_tot