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,1435 @@
1
+ """
2
+ Logistic regression with full statistical inference and GPU support.
3
+ Uses IRLS (Iteratively Reweighted Least Squares) algorithm.
4
+ """
5
+
6
+ __all__ = ["LogisticRegression"]
7
+
8
+ from typing import Any, Dict, Optional, Union, Tuple
9
+ import numpy as np
10
+ from scipy import stats
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.metrics import (
16
+ binary_average_precision_score,
17
+ binary_precision_recall_curve,
18
+ binary_roc_auc_score,
19
+ binary_roc_curve,
20
+ evaluate_binary_classification,
21
+ )
22
+
23
+
24
+ def _require_cupy(context: str):
25
+ """Import CuPy or raise a clear ImportError when it is unavailable.
26
+
27
+ Parameters
28
+ ----------
29
+ context : str
30
+ Short description of the caller (used in the error message).
31
+
32
+ Returns
33
+ -------
34
+ module
35
+ The ``cupy`` module.
36
+
37
+ Raises
38
+ ------
39
+ ImportError
40
+ If CuPy is not installed, with a message that explains how to
41
+ install it and why it is required here.
42
+ """
43
+ try:
44
+ import cupy as cp
45
+ return cp
46
+ except ImportError as exc:
47
+ raise ImportError(
48
+ f"{context} requires CuPy for GPU computation, but CuPy is not "
49
+ "installed. Install CuPy matching your CUDA version, e.g.: "
50
+ "`pip install cupy-cuda12x` (CUDA 12.x) or "
51
+ "`pip install cupy-cuda11x` (CUDA 11.x)."
52
+ ) from exc
53
+
54
+
55
+
56
+ class LogisticRegression(BaseEstimator):
57
+ """
58
+ Logistic regression with GPU acceleration and full statistical inference.
59
+
60
+ Uses IRLS (Iteratively Reweighted Least Squares) algorithm with
61
+ optional L2 regularization.
62
+
63
+ Parameters
64
+ ----------
65
+ fit_intercept : bool, default=True
66
+ Whether to calculate the intercept.
67
+ C : float, default=1.0
68
+ Inverse of regularization strength; must be a positive float.
69
+ Smaller values specify stronger regularization.
70
+ max_iter : int, default=100
71
+ Maximum number of iterations for IRLS.
72
+ tol : float, default=1e-4
73
+ Tolerance for stopping criteria.
74
+ device : str or Device, default='auto'
75
+ Computation device: 'cpu', 'cuda', or 'auto'.
76
+ n_jobs : int, optional
77
+ Number of parallel jobs for CPU computation.
78
+
79
+ Attributes
80
+ ----------
81
+ coef_ : ndarray of shape (n_features,)
82
+ Estimated coefficients.
83
+ intercept_ : float
84
+ Independent term.
85
+ n_iter_ : int
86
+ Number of iterations run.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ fit_intercept: bool = True,
92
+ C: float = 1.0,
93
+ max_iter: int = 100,
94
+ tol: float = 1e-4,
95
+ device: Union[str, Device] = Device.AUTO,
96
+ n_jobs: Optional[int] = None,
97
+ compute_inference: bool = True,
98
+ cov_type: str = "nonrobust",
99
+ gpu_memory_cleanup: bool = False,
100
+ hac_maxlags: Optional[int] = None,
101
+ ):
102
+ super().__init__(device=device, n_jobs=n_jobs)
103
+ self.fit_intercept = fit_intercept
104
+ self.C = C
105
+ self.max_iter = max_iter
106
+ self.tol = tol
107
+ self.compute_inference = compute_inference
108
+ self.cov_type = cov_type.lower()
109
+ if self.cov_type not in ("nonrobust", "hc0", "hc1", "hc2", "hc3", "hac"):
110
+ raise ValueError(
111
+ "cov_type must be one of: 'nonrobust', 'hc0', 'hc1', 'hc2', 'hc3', 'hac'"
112
+ )
113
+ if hac_maxlags is not None and int(hac_maxlags) < 0:
114
+ raise ValueError("hac_maxlags must be a non-negative integer or None")
115
+ self.hac_maxlags = None if hac_maxlags is None else int(hac_maxlags)
116
+ self.gpu_memory_cleanup = bool(gpu_memory_cleanup)
117
+ self.coef_ = None
118
+ self.intercept_ = None
119
+ self.n_iter_ = None
120
+
121
+ # Internal storage for inference
122
+ self._X_design = None
123
+ self._y = None
124
+ self._nobs = None
125
+ self._df_resid = None
126
+ self._params = None
127
+ self._bse = None
128
+ self._zvalues = None
129
+ self._pvalues = None
130
+ self._conf_int = None
131
+ self._loglik = None
132
+ self._loglik_null = None
133
+ self._train_pred_cache = None
134
+ self._train_eval_cache = None
135
+
136
+ def _cleanup_cuda_memory(self):
137
+ """Best-effort CuPy memory pool cleanup."""
138
+ self._train_pred_cache = None
139
+ self._train_eval_cache = None
140
+ if not self.gpu_memory_cleanup:
141
+ return
142
+ try:
143
+ import cupy as cp
144
+ cp.get_default_memory_pool().free_all_blocks()
145
+ cp.get_default_pinned_memory_pool().free_all_blocks()
146
+ except Exception:
147
+ pass
148
+
149
+ def _resolve_hac_maxlags(self, n_obs: int) -> int:
150
+ """Resolve HAC lag count with a Newey-West style default rule."""
151
+ if n_obs <= 1:
152
+ return 0
153
+ if self.hac_maxlags is None:
154
+ maxlags = int(np.floor(4.0 * (n_obs / 100.0) ** (2.0 / 9.0)))
155
+ else:
156
+ maxlags = int(self.hac_maxlags)
157
+ return max(0, min(maxlags, n_obs - 1))
158
+
159
+ def _hac_meat_numpy(self, scores: np.ndarray) -> np.ndarray:
160
+ """Bartlett-kernel HAC meat from per-observation score matrix."""
161
+ n_obs = int(scores.shape[0])
162
+ meat = scores.T @ scores
163
+ maxlags = self._resolve_hac_maxlags(n_obs)
164
+ if maxlags == 0:
165
+ return meat
166
+ for lag in range(1, maxlags + 1):
167
+ weight = 1.0 - (lag / (maxlags + 1.0))
168
+ gamma = scores[lag:].T @ scores[:-lag]
169
+ meat = meat + weight * (gamma + gamma.T)
170
+ return meat
171
+
172
+ def _hac_meat_cupy(self, scores):
173
+ """CuPy Bartlett-kernel HAC meat from per-observation score matrix."""
174
+ cp = _require_cupy("_hac_meat_cupy")
175
+
176
+ n_obs = int(scores.shape[0])
177
+ meat = scores.T @ scores
178
+ maxlags = self._resolve_hac_maxlags(n_obs)
179
+ if maxlags == 0:
180
+ return meat
181
+ for lag in range(1, maxlags + 1):
182
+ weight = 1.0 - (lag / (maxlags + 1.0))
183
+ gamma = scores[lag:].T @ scores[:-lag]
184
+ meat = meat + weight * (gamma + gamma.T)
185
+ return meat
186
+
187
+ def _sigmoid(self, z):
188
+ """Sigmoid function."""
189
+ return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
190
+
191
+ def fit(self, X, y, sample_weight=None):
192
+ """
193
+ Fit logistic regression model.
194
+
195
+ Parameters
196
+ ----------
197
+ X : array-like of shape (n_samples, n_features)
198
+ Training data.
199
+ y : array-like of shape (n_samples,)
200
+ Target values (0 or 1).
201
+ sample_weight : array-like of shape (n_samples,), default=None
202
+ Sample weights.
203
+
204
+ Returns
205
+ -------
206
+ self : object
207
+ """
208
+ self._y = self._to_numpy(y).astype(float)
209
+ self._train_pred_cache = None
210
+ self._train_eval_cache = None
211
+
212
+ # Get backend - support explicit torch backend selection
213
+ backend = self._get_backend(backend="auto")
214
+ backend_name = backend.name
215
+
216
+ X_arr = self._to_array(X, backend=backend_name)
217
+ # Handle dtype conversion based on backend
218
+ if backend_name == "torch":
219
+ import torch
220
+ y_arr = self._to_array(y, backend=backend_name)
221
+ if y_arr.dtype != torch.float64:
222
+ y_arr = y_arr.to(torch.float64)
223
+ elif backend_name == "cupy":
224
+ import cupy as cp
225
+ y_arr = self._to_array(y, backend=backend_name).astype(cp.float64)
226
+ else:
227
+ y_arr = self._to_array(y, backend=backend_name).astype(float)
228
+
229
+ device = self._get_compute_device()
230
+
231
+ # Route to appropriate backend
232
+ if backend_name == "torch":
233
+ self._fit_torch(X_arr, y_arr, sample_weight)
234
+ elif backend_name == "cupy":
235
+ self._fit_gpu(X_arr, y_arr, sample_weight)
236
+ else:
237
+ self._fit_cpu(X_arr, y_arr, sample_weight)
238
+
239
+ if self.compute_inference and device == Device.CPU:
240
+ self._compute_inference()
241
+ self._fitted = True
242
+ return self
243
+
244
+ def _fit_cpu(self, X, y, sample_weight=None):
245
+ """Fit using CPU with IRLS."""
246
+ X = np.asarray(X)
247
+ y = np.asarray(y)
248
+
249
+ n_samples, n_features = X.shape
250
+ self._nobs = n_samples
251
+
252
+ # Add intercept if needed
253
+ if self.fit_intercept:
254
+ self._X_design = np.column_stack([np.ones(n_samples, dtype=X.dtype), X])
255
+ else:
256
+ self._X_design = X.copy()
257
+
258
+ # Initialize parameters
259
+ params = np.zeros(self._X_design.shape[1])
260
+
261
+ # Regularization parameter (lambda = 1 / (2*C))
262
+ alpha = 1.0 / self.C if self.C > 0 else 0.0
263
+
264
+ # IRLS iteration
265
+ iteration = 0
266
+ for iteration in range(self.max_iter):
267
+ params_old = params.copy()
268
+
269
+ # Predicted probabilities
270
+ eta = self._X_design @ params
271
+ p = self._sigmoid(eta)
272
+
273
+ # Weights for WLS
274
+ W = p * (1 - p)
275
+ W = np.clip(W, 1e-8, 1 - 1e-8) # Avoid numerical issues
276
+
277
+ if sample_weight is not None:
278
+ W = W * np.asarray(sample_weight)
279
+
280
+ # Working response
281
+ z = eta + (y - p) / W
282
+
283
+ # Weighted least squares
284
+ # (X'WX + alpha*I) * params = X'Wz
285
+ XtWX = self._X_design.T @ (self._X_design * W[:, np.newaxis])
286
+
287
+ # Add L2 regularization (don't regularize intercept)
288
+ if alpha > 0:
289
+ reg_diag = np.full(XtWX.shape[0], alpha)
290
+ if self.fit_intercept:
291
+ reg_diag[0] = 0.0 # Don't regularize intercept
292
+ XtWX += np.diag(reg_diag)
293
+
294
+ Xtz = self._X_design.T @ (W * z)
295
+
296
+ try:
297
+ params = np.linalg.solve(XtWX, Xtz)
298
+ except np.linalg.LinAlgError:
299
+ params = np.linalg.lstsq(XtWX, Xtz, rcond=None)[0]
300
+
301
+ # Check convergence
302
+ if np.linalg.norm(params - params_old) < self.tol:
303
+ break
304
+
305
+ self.n_iter_ = iteration + 1
306
+ self._params = params
307
+
308
+ if self.fit_intercept:
309
+ self.intercept_ = float(params[0])
310
+ self.coef_ = params[1:]
311
+ else:
312
+ self.intercept_ = 0.0
313
+ self.coef_ = params.copy()
314
+
315
+ # Degrees of freedom
316
+ self._df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0))
317
+
318
+ def _fit_gpu(self, X, y, sample_weight=None):
319
+ """Fit using GPU with IRLS."""
320
+ import cupy as cp
321
+ from statgpu.inference._distributions_backend import norm
322
+
323
+ n_samples, n_features = X.shape
324
+ self._nobs = n_samples
325
+
326
+ # Add intercept if needed
327
+ if self.fit_intercept:
328
+ X_design = cp.column_stack([cp.ones(n_samples, dtype=X.dtype), X])
329
+ else:
330
+ X_design = X
331
+
332
+ # Initialize parameters
333
+ params = cp.zeros(X_design.shape[1])
334
+
335
+ # Regularization parameter
336
+ alpha = 1.0 / self.C if self.C > 0 else 0.0
337
+
338
+ # IRLS iteration
339
+ iteration = 0
340
+ for iteration in range(self.max_iter):
341
+ params_old = params.copy()
342
+
343
+ # Predicted probabilities
344
+ eta = X_design @ params
345
+ p = 1 / (1 + cp.exp(-cp.clip(eta, -500, 500)))
346
+
347
+ # Weights for WLS
348
+ W = p * (1 - p)
349
+ W = cp.clip(W, 1e-8, 1 - 1e-8)
350
+
351
+ if sample_weight is not None:
352
+ W = W * cp.asarray(sample_weight)
353
+
354
+ # Working response
355
+ z = eta + (y - p) / W
356
+
357
+ # Weighted least squares
358
+ XtWX = X_design.T @ (X_design * W[:, cp.newaxis])
359
+
360
+ # Add L2 regularization
361
+ if alpha > 0:
362
+ reg_diag = cp.full(XtWX.shape[0], alpha)
363
+ if self.fit_intercept:
364
+ reg_diag[0] = 0.0
365
+ XtWX += cp.diag(reg_diag)
366
+
367
+ Xtz = X_design.T @ (W * z)
368
+
369
+ try:
370
+ params = cp.linalg.solve(XtWX, Xtz)
371
+ except Exception:
372
+ params = cp.linalg.lstsq(XtWX, Xtz)[0]
373
+
374
+ # Check convergence
375
+ if cp.linalg.norm(params - params_old) < self.tol:
376
+ break
377
+
378
+ self.n_iter_ = iteration + 1
379
+
380
+ # Compute log-likelihood on GPU
381
+ eta = X_design @ params
382
+ p = 1 / (1 + cp.exp(-cp.clip(eta, -500, 500)))
383
+ loglik = cp.sum(y * cp.log(p + 1e-10) + (1 - y) * cp.log(1 - p + 1e-10))
384
+
385
+ # Compute accuracy on GPU
386
+ y_pred = (p > 0.5).astype(cp.int32)
387
+ accuracy = cp.mean(y_pred == y)
388
+
389
+ # Store GPU results temporarily
390
+ self._loglik_gpu = loglik
391
+ self._accuracy_gpu = accuracy
392
+
393
+ if self.compute_inference:
394
+ # Bread: inverse Hessian, H = X'WX (+ ridge)
395
+ W_inf = p * (1 - p)
396
+ W_inf = cp.clip(W_inf, 1e-8, 1 - 1e-8)
397
+ H = X_design.T @ (X_design * W_inf[:, cp.newaxis])
398
+ if alpha > 0:
399
+ reg_diag_inf = cp.full(H.shape[0], alpha)
400
+ if self.fit_intercept:
401
+ reg_diag_inf[0] = 0.0
402
+ H += cp.diag(reg_diag_inf)
403
+ try:
404
+ eye = cp.eye(H.shape[0], dtype=H.dtype)
405
+ bread = cp.linalg.solve(H, eye)
406
+ except Exception:
407
+ bread = cp.linalg.pinv(H)
408
+
409
+ if self.cov_type == "nonrobust":
410
+ cov_params = bread
411
+ else:
412
+ resid_score = y - p
413
+ scores = X_design * resid_score[:, cp.newaxis]
414
+
415
+ if self.cov_type == "hac":
416
+ meat = self._hac_meat_cupy(scores)
417
+ else:
418
+ if self.cov_type in ("hc2", "hc3"):
419
+ leverage = W_inf * cp.einsum("ij,jk,ik->i", X_design, bread, X_design)
420
+ leverage = cp.clip(leverage, 0.0, 1.0 - 1e-12)
421
+ if self.cov_type == "hc2":
422
+ scores = scores / cp.sqrt(1.0 - leverage)[:, cp.newaxis]
423
+ else:
424
+ scores = scores / (1.0 - leverage)[:, cp.newaxis]
425
+ meat = scores.T @ scores
426
+
427
+ cov_params = bread @ meat @ bread
428
+ if self.cov_type == "hc1":
429
+ n = X_design.shape[0]
430
+ k = X_design.shape[1]
431
+ if n > k:
432
+ cov_params = cov_params * (n / (n - k))
433
+
434
+ bse_gpu = cp.sqrt(cp.maximum(cp.diag(cov_params), 0.0))
435
+ zvalues_gpu = params / (bse_gpu + 1e-30)
436
+ pvalues_gpu = cp.minimum(1.0, 2.0 * norm.sf(cp.abs(zvalues_gpu)))
437
+ z_crit = norm.ppf(0.975)
438
+ conf_int_gpu = cp.stack(
439
+ [params - z_crit * bse_gpu, params + z_crit * bse_gpu], axis=1
440
+ )
441
+
442
+ self._bse = bse_gpu.get()
443
+ self._zvalues = zvalues_gpu.get()
444
+ self._pvalues = pvalues_gpu.get()
445
+ self._conf_int = conf_int_gpu.get()
446
+
447
+ # Single transfer at the end
448
+ params_np = params.get()
449
+ X_design_np = X_design.get()
450
+
451
+ self._X_design = X_design_np
452
+ self._params = params_np
453
+
454
+ if self.fit_intercept:
455
+ self.intercept_ = float(params_np[0])
456
+ self.coef_ = params_np[1:]
457
+ else:
458
+ self.intercept_ = 0.0
459
+ self.coef_ = params_np.copy()
460
+
461
+ self._df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0))
462
+ self._loglik = float(cp.asnumpy(self._loglik_gpu))
463
+ self._accuracy = float(cp.asnumpy(self._accuracy_gpu))
464
+ y_mean = cp.mean(y)
465
+ y_mean = cp.clip(y_mean, 1e-15, 1 - 1e-15)
466
+ self._loglik_null = float(
467
+ cp.asnumpy(cp.sum(y * cp.log(y_mean) + (1 - y) * cp.log(1 - y_mean)))
468
+ )
469
+
470
+ # Release large temporary GPU tensors early.
471
+ try:
472
+ del X_design
473
+ except Exception:
474
+ pass
475
+ try:
476
+ del XtWX
477
+ except Exception:
478
+ pass
479
+ try:
480
+ del Xtz
481
+ except Exception:
482
+ pass
483
+ try:
484
+ del params
485
+ except Exception:
486
+ pass
487
+ try:
488
+ del W
489
+ except Exception:
490
+ pass
491
+ try:
492
+ del z
493
+ except Exception:
494
+ pass
495
+ try:
496
+ del eta
497
+ except Exception:
498
+ pass
499
+ try:
500
+ del p
501
+ except Exception:
502
+ pass
503
+ self._cleanup_cuda_memory()
504
+
505
+ def _cleanup_torch_memory(self):
506
+ """Best-effort Torch CUDA memory cleanup."""
507
+ self._train_pred_cache = None
508
+ self._train_eval_cache = None
509
+ if not self.gpu_memory_cleanup:
510
+ return
511
+ try:
512
+ import torch
513
+ if torch.cuda.is_available():
514
+ torch.cuda.empty_cache()
515
+ torch.cuda.synchronize()
516
+ except Exception:
517
+ pass
518
+
519
+ def _fit_torch(self, X, y, sample_weight=None):
520
+ """Fit using Torch GPU with IRLS."""
521
+ import torch
522
+ from statgpu.inference._distributions_backend import norm
523
+
524
+ # Note: Device.TORCH.value is 'torch', but Torch expects 'cuda' or 'cpu'.
525
+ torch_device = _get_torch_device_str()
526
+
527
+ n_samples, n_features = X.shape
528
+ self._nobs = n_samples
529
+
530
+ # Ensure Torch tensors on GPU
531
+ if not isinstance(X, torch.Tensor):
532
+ X = torch.from_numpy(X).to(torch_device)
533
+ if not isinstance(y, torch.Tensor):
534
+ y = torch.from_numpy(y).to(torch_device)
535
+ if y.dtype != torch.float64:
536
+ y = y.to(torch.float64)
537
+ if X.dtype != torch.float64:
538
+ X = X.to(torch.float64)
539
+
540
+ # Add intercept if needed
541
+ if self.fit_intercept:
542
+ X_design = torch.cat([torch.ones(n_samples, 1, dtype=torch.float64, device=torch_device), X], dim=1)
543
+ else:
544
+ X_design = X
545
+
546
+ # Initialize parameters
547
+ params = torch.zeros(X_design.shape[1], dtype=torch.float64, device=torch_device)
548
+
549
+ # Regularization parameter (lambda = 1 / (2*C))
550
+ alpha = 1.0 / self.C if self.C > 0 else 0.0
551
+
552
+ # IRLS iteration
553
+ iteration = 0
554
+ for iteration in range(self.max_iter):
555
+ params_old = params.clone()
556
+
557
+ # Predicted probabilities
558
+ eta = X_design @ params
559
+ p = 1 / (1 + torch.exp(-torch.clamp(eta, -500, 500)))
560
+
561
+ # Weights for WLS
562
+ W = p * (1 - p)
563
+ W = torch.clamp(W, 1e-8, 1 - 1e-8)
564
+
565
+ if sample_weight is not None:
566
+ if not isinstance(sample_weight, torch.Tensor):
567
+ sample_weight_torch = torch.from_numpy(sample_weight).to(torch_device)
568
+ else:
569
+ sample_weight_torch = sample_weight.to(torch_device)
570
+ if sample_weight_torch.dtype != torch.float64:
571
+ sample_weight_torch = sample_weight_torch.to(torch.float64)
572
+ W = W * sample_weight_torch
573
+
574
+ # Working response
575
+ z = eta + (y - p) / W
576
+
577
+ # Weighted least squares
578
+ XtWX = X_design.T @ (X_design * W[:, None])
579
+
580
+ # Add L2 regularization
581
+ if alpha > 0:
582
+ reg_diag = torch.full((XtWX.shape[0],), alpha, dtype=torch.float64, device=torch_device)
583
+ if self.fit_intercept:
584
+ reg_diag[0] = 0.0
585
+ XtWX += torch.diag(reg_diag)
586
+
587
+ Xtz = X_design.T @ (W * z)
588
+
589
+ try:
590
+ params = torch.linalg.solve(XtWX, Xtz)
591
+ except Exception:
592
+ params = torch.linalg.lstsq(XtWX, Xtz)[0]
593
+
594
+ # Check convergence
595
+ if torch.linalg.norm(params - params_old) < self.tol:
596
+ break
597
+
598
+ self.n_iter_ = iteration + 1
599
+
600
+ # Compute log-likelihood on GPU
601
+ eta = X_design @ params
602
+ p = 1 / (1 + torch.exp(-torch.clamp(eta, -500, 500)))
603
+ loglik = torch.sum(y * torch.log(p + 1e-10) + (1 - y) * torch.log(1 - p + 1e-10))
604
+
605
+ # Compute accuracy on GPU
606
+ y_pred = (p > 0.5).to(torch.int32)
607
+ y_true = y.to(torch.int32).reshape(y_pred.shape)
608
+ accuracy = torch.mean((y_pred == y_true).to(torch.float64))
609
+
610
+ # Store GPU results temporarily
611
+ self._loglik_gpu = loglik
612
+ self._accuracy_gpu = accuracy
613
+
614
+ if self.compute_inference:
615
+ # Bread: inverse Hessian, H = X'WX (+ ridge)
616
+ W_inf = p * (1 - p)
617
+ W_inf = torch.clamp(W_inf, 1e-8, 1 - 1e-8)
618
+ H = X_design.T @ (X_design * W_inf[:, None])
619
+ if alpha > 0:
620
+ reg_diag_inf = torch.full((H.shape[0],), alpha, dtype=torch.float64, device=torch_device)
621
+ if self.fit_intercept:
622
+ reg_diag_inf[0] = 0.0
623
+ H += torch.diag(reg_diag_inf)
624
+ try:
625
+ eye = torch.eye(H.shape[0], dtype=H.dtype, device=torch_device)
626
+ bread = torch.linalg.solve(H, eye)
627
+ except Exception:
628
+ bread = torch.linalg.pinv(H)
629
+
630
+ if self.cov_type == "nonrobust":
631
+ cov_params = bread
632
+ else:
633
+ resid_score = y - p
634
+ scores = X_design * resid_score[:, None]
635
+
636
+ if self.cov_type == "hac":
637
+ meat = self._hac_meat_torch(scores)
638
+ else:
639
+ if self.cov_type in ("hc2", "hc3"):
640
+ leverage = W_inf * torch.einsum("ij,jk,ik->i", X_design, bread, X_design)
641
+ leverage = torch.clamp(leverage, 0.0, 1.0 - 1e-12)
642
+ if self.cov_type == "hc2":
643
+ scores = scores / torch.sqrt(1.0 - leverage)[:, None]
644
+ else:
645
+ scores = scores / (1.0 - leverage)[:, None]
646
+ meat = scores.T @ scores
647
+
648
+ cov_params = bread @ meat @ bread
649
+ if self.cov_type == "hc1":
650
+ n = X_design.shape[0]
651
+ k = X_design.shape[1]
652
+ if n > k:
653
+ cov_params = cov_params * (n / (n - k))
654
+
655
+ bse_gpu = torch.sqrt(torch.clamp(torch.diag(cov_params), 0.0))
656
+ zvalues_gpu = params / (bse_gpu + 1e-30)
657
+ pvalues_gpu = torch.minimum(torch.tensor(1.0, device=torch_device), 2.0 * norm.sf(torch.abs(zvalues_gpu), device=torch_device))
658
+ z_crit = norm.ppf(0.975, device=torch_device)
659
+ conf_int_gpu = torch.stack(
660
+ [params - z_crit * bse_gpu, params + z_crit * bse_gpu], dim=1
661
+ )
662
+
663
+ self._bse = bse_gpu.cpu().numpy()
664
+ self._zvalues = zvalues_gpu.cpu().numpy()
665
+ self._pvalues = pvalues_gpu.cpu().numpy()
666
+ self._conf_int = conf_int_gpu.cpu().numpy()
667
+
668
+ # Single transfer at the end
669
+ params_np = params.cpu().numpy()
670
+ X_design_np = X_design.cpu().numpy()
671
+
672
+ self._X_design = X_design_np
673
+ self._params = params_np
674
+
675
+ if self.fit_intercept:
676
+ self.intercept_ = float(params_np[0])
677
+ self.coef_ = params_np[1:]
678
+ else:
679
+ self.intercept_ = 0.0
680
+ self.coef_ = params_np.copy()
681
+
682
+ self._df_resid = n_samples - (n_features + (1 if self.fit_intercept else 0))
683
+ self._loglik = float(self._loglik_gpu.cpu().numpy())
684
+ self._accuracy = float(self._accuracy_gpu.cpu().numpy())
685
+ y_mean = torch.mean(y)
686
+ y_mean = torch.clamp(y_mean, 1e-15, 1 - 1e-15)
687
+ self._loglik_null = float(torch.sum(y * torch.log(y_mean) + (1 - y) * torch.log(1 - y_mean)).cpu().numpy())
688
+
689
+ # Release large temporary GPU tensors early.
690
+ try:
691
+ del X_design
692
+ except Exception:
693
+ pass
694
+ try:
695
+ del XtWX
696
+ except Exception:
697
+ pass
698
+ try:
699
+ del Xtz
700
+ except Exception:
701
+ pass
702
+ try:
703
+ del params
704
+ except Exception:
705
+ pass
706
+ try:
707
+ del W
708
+ except Exception:
709
+ pass
710
+ try:
711
+ del z
712
+ except Exception:
713
+ pass
714
+ try:
715
+ del eta
716
+ except Exception:
717
+ pass
718
+ try:
719
+ del p
720
+ except Exception:
721
+ pass
722
+ self._cleanup_torch_memory()
723
+
724
+ def _hac_meat_torch(self, scores):
725
+ """Torch Bartlett-kernel HAC meat from per-observation score matrix."""
726
+ import torch
727
+
728
+ n_obs = int(scores.shape[0])
729
+ meat = scores.T @ scores
730
+ maxlags = self._resolve_hac_maxlags(n_obs)
731
+ if maxlags == 0:
732
+ return meat
733
+ for lag in range(1, maxlags + 1):
734
+ weight = 1.0 - (lag / (maxlags + 1.0))
735
+ gamma = scores[lag:].T @ scores[:-lag]
736
+ meat = meat + weight * (gamma + gamma.T)
737
+ return meat
738
+
739
+ def _compute_inference(self):
740
+ """Compute standard errors, z-stats, p-values, and confidence intervals."""
741
+ if self._X_design is None or self._params is None:
742
+ return
743
+
744
+ # Predicted probabilities
745
+ eta = self._X_design @ self._params
746
+ p = self._sigmoid(eta)
747
+
748
+ # Compute Hessian (information matrix)
749
+ W = p * (1 - p)
750
+ W = np.clip(W, 1e-8, 1 - 1e-8)
751
+
752
+ XtWX = self._X_design.T @ (self._X_design * W[:, np.newaxis])
753
+
754
+ # Add regularization to Hessian
755
+ alpha = 1.0 / self.C if self.C > 0 else 0.0
756
+ if alpha > 0:
757
+ reg_diag = np.full(XtWX.shape[0], alpha)
758
+ if self.fit_intercept:
759
+ reg_diag[0] = 0.0
760
+ XtWX += np.diag(reg_diag)
761
+
762
+ try:
763
+ bread = np.linalg.solve(XtWX, np.eye(XtWX.shape[0]))
764
+ except np.linalg.LinAlgError:
765
+ bread = np.linalg.pinv(XtWX)
766
+
767
+ if self.cov_type == "nonrobust":
768
+ cov_params = bread
769
+ else:
770
+ resid_score = self._y - p
771
+
772
+ scores = self._X_design * resid_score[:, np.newaxis]
773
+ if self.cov_type == "hac":
774
+ meat = self._hac_meat_numpy(scores)
775
+ else:
776
+ if self.cov_type in ("hc2", "hc3"):
777
+ leverage = W * np.einsum("ij,jk,ik->i", self._X_design, bread, self._X_design)
778
+ leverage = np.clip(leverage, 0.0, 1.0 - 1e-12)
779
+ if self.cov_type == "hc2":
780
+ scores = scores / np.sqrt(1.0 - leverage)[:, np.newaxis]
781
+ else:
782
+ scores = scores / (1.0 - leverage)[:, np.newaxis]
783
+ meat = scores.T @ scores
784
+
785
+ cov_params = bread @ meat @ bread
786
+ if self.cov_type == "hc1":
787
+ n = self._X_design.shape[0]
788
+ k = self._X_design.shape[1]
789
+ if n > k:
790
+ cov_params = cov_params * (n / (n - k))
791
+
792
+ # Standard errors
793
+ self._bse = np.sqrt(np.maximum(np.diag(cov_params), 0.0))
794
+
795
+ # z-values (asymptotic normal, add epsilon to avoid division by zero)
796
+ self._zvalues = self._params / (self._bse + 1e-30)
797
+
798
+ # p-values (two-tailed)
799
+ self._pvalues = 2 * (1 - stats.norm.cdf(np.abs(self._zvalues)))
800
+
801
+ # 95% confidence intervals
802
+ alpha = 0.05
803
+ z_crit = stats.norm.ppf(1 - alpha/2)
804
+ self._conf_int = np.column_stack([
805
+ self._params - z_crit * self._bse,
806
+ self._params + z_crit * self._bse
807
+ ])
808
+
809
+ # Log-likelihood
810
+ eps = 1e-15 # Avoid log(0)
811
+ p_clipped = np.clip(p, eps, 1 - eps)
812
+ self._loglik = np.sum(self._y * np.log(p_clipped) + (1 - self._y) * np.log(1 - p_clipped))
813
+
814
+ # Null log-likelihood (intercept-only model)
815
+ y_mean = np.mean(self._y)
816
+ y_mean = np.clip(y_mean, eps, 1 - eps)
817
+ self._loglik_null = np.sum(self._y * np.log(y_mean) + (1 - self._y) * np.log(1 - y_mean))
818
+
819
+ def _train_classification_table(self):
820
+ """Training-set classification table on current device.
821
+
822
+ Results are cached in ``_train_eval_cache`` so that multiple
823
+ properties (accuracy, precision, recall, f1, auc, average_precision)
824
+ sharing the same training data only trigger a single forward pass.
825
+ """
826
+ if self._y is None or not self._fitted:
827
+ return None
828
+
829
+ if self._train_eval_cache is not None:
830
+ return self._train_eval_cache.get("classification_table")
831
+
832
+ X_train = self._X_design[:, 1:] if self.fit_intercept else self._X_design
833
+ device = self._get_compute_device()
834
+ if device == Device.CUDA:
835
+ cp = _require_cupy("_train_classification_table")
836
+
837
+ y_true = cp.asarray(self._to_array(self._y, Device.CUDA)).reshape(-1)
838
+ y_score = cp.asarray(self.predict_proba(X_train))[:, 1]
839
+ self._train_eval_cache = evaluate_binary_classification(
840
+ y_true,
841
+ y_score,
842
+ threshold=0.5,
843
+ include_curves=False,
844
+ backend="cupy",
845
+ )
846
+ return self._train_eval_cache["classification_table"]
847
+ if device == Device.TORCH:
848
+ import torch
849
+
850
+ y_true = self._to_array(self._y, Device.TORCH, backend="torch").reshape(-1)
851
+ y_score = self.predict_proba(X_train)[:, 1]
852
+ if not isinstance(y_score, torch.Tensor):
853
+ y_score = torch.as_tensor(y_score, dtype=torch.float64, device=y_true.device)
854
+ self._train_eval_cache = evaluate_binary_classification(
855
+ y_true,
856
+ y_score,
857
+ threshold=0.5,
858
+ include_curves=False,
859
+ backend="torch",
860
+ )
861
+ return self._train_eval_cache["classification_table"]
862
+
863
+ y_score = self._to_numpy(self.predict_proba(X_train))[:, 1]
864
+ self._train_eval_cache = evaluate_binary_classification(
865
+ self._y,
866
+ y_score,
867
+ threshold=0.5,
868
+ include_curves=False,
869
+ backend="numpy",
870
+ )
871
+ return self._train_eval_cache["classification_table"]
872
+
873
+ @staticmethod
874
+ def _to_python_float(value):
875
+ """Convert scalar-like values (including CuPy scalars) to float."""
876
+ if value is None:
877
+ return float("nan")
878
+ try:
879
+ import cupy as cp
880
+
881
+ if isinstance(value, cp.ndarray):
882
+ return float(value.item())
883
+ if type(value).__module__.startswith("cupy"):
884
+ return float(value.item())
885
+ except Exception:
886
+ pass
887
+ if hasattr(value, "item"):
888
+ try:
889
+ return float(value.item())
890
+ except Exception:
891
+ pass
892
+ return float(value)
893
+
894
+ def predict_proba(self, X):
895
+ """
896
+ Predict class probabilities.
897
+
898
+ Parameters
899
+ ----------
900
+ X : array-like of shape (n_samples, n_features)
901
+ Samples.
902
+
903
+ Returns
904
+ -------
905
+ ndarray of shape (n_samples, 2)
906
+ Returns the probability of the samples for each class.
907
+ """
908
+ self._check_is_fitted()
909
+ device = self._get_compute_device()
910
+ if device == Device.CUDA:
911
+ import cupy as cp
912
+
913
+ X_gpu = cp.asarray(self._to_array(X, Device.CUDA))
914
+ coef_gpu = cp.asarray(self.coef_)
915
+ intercept_gpu = cp.asarray(self.intercept_, dtype=coef_gpu.dtype)
916
+ eta = X_gpu @ coef_gpu + intercept_gpu
917
+ p1 = 1.0 / (1.0 + cp.exp(-cp.clip(eta, -500, 500)))
918
+ return cp.column_stack([1 - p1, p1])
919
+ if device == Device.TORCH:
920
+ import torch
921
+
922
+ X_torch = self._to_array(X, Device.TORCH, backend="torch").to(torch.float64)
923
+ coef_torch = torch.as_tensor(self.coef_, dtype=X_torch.dtype, device=X_torch.device)
924
+ intercept_torch = torch.as_tensor(
925
+ self.intercept_, dtype=X_torch.dtype, device=X_torch.device
926
+ )
927
+ eta = X_torch @ coef_torch + intercept_torch
928
+ p1 = 1.0 / (1.0 + torch.exp(-torch.clamp(eta, -500, 500)))
929
+ return torch.column_stack([1 - p1, p1])
930
+ X = self._to_array(X, Device.CPU)
931
+ X = np.asarray(X)
932
+ eta = X @ self.coef_ + self.intercept_
933
+ p1 = self._sigmoid(eta)
934
+ return np.column_stack([1 - p1, p1])
935
+
936
+ def predict(self, X):
937
+ """
938
+ Predict class labels.
939
+
940
+ Parameters
941
+ ----------
942
+ X : array-like of shape (n_samples, n_features)
943
+ Samples.
944
+
945
+ Returns
946
+ -------
947
+ ndarray of shape (n_samples,)
948
+ Predicted class labels.
949
+ """
950
+ proba = self.predict_proba(X)
951
+ if hasattr(proba, 'is_floating_point'): # torch tensor
952
+ return (proba[:, 1] >= 0.5).to(dtype=proba.dtype)
953
+ return (proba[:, 1] >= 0.5).astype(int)
954
+
955
+ def predict_with_threshold(self, X, threshold: float = 0.5):
956
+ """
957
+ Predict class labels using a custom probability threshold.
958
+
959
+ Parameters
960
+ ----------
961
+ X : array-like of shape (n_samples, n_features)
962
+ Samples.
963
+ threshold : float, default=0.5
964
+ Probability threshold for positive class assignment.
965
+
966
+ Returns
967
+ -------
968
+ ndarray of shape (n_samples,)
969
+ Predicted class labels.
970
+ """
971
+ if threshold < 0.0 or threshold > 1.0:
972
+ raise ValueError("threshold must be in [0, 1]")
973
+ proba = self.predict_proba(X)
974
+ if hasattr(proba, "to") and hasattr(proba, "dtype"):
975
+ return (proba[:, 1] >= threshold).to(dtype=proba.dtype)
976
+ return (proba[:, 1] >= threshold).astype(int)
977
+
978
+ def score(self, X, y):
979
+ """
980
+ Return mean accuracy.
981
+
982
+ Parameters
983
+ ----------
984
+ X : array-like of shape (n_samples, n_features)
985
+ Test samples.
986
+ y : array-like of shape (n_samples,)
987
+ True labels.
988
+
989
+ Returns
990
+ -------
991
+ float
992
+ Mean accuracy.
993
+ """
994
+ y_pred = self.predict(X)
995
+ device = self._get_compute_device()
996
+ if device == Device.CUDA:
997
+ import cupy as cp
998
+
999
+ yb = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1)
1000
+ return float(cp.mean(y_pred.reshape(-1) == yb).item())
1001
+ if device == Device.TORCH:
1002
+ import torch
1003
+
1004
+ yb = self._to_array(y, Device.TORCH, backend="torch").reshape(-1)
1005
+ return float(torch.mean((y_pred.reshape(-1) == yb).to(torch.float64)).item())
1006
+ y_pred = self._to_numpy(y_pred)
1007
+ y = self._to_numpy(y)
1008
+ return np.mean(y_pred == y)
1009
+
1010
+ def confusion_matrix(self, X, y, threshold: float = 0.5) -> np.ndarray:
1011
+ """Compute binary confusion matrix on a dataset."""
1012
+ if self._get_compute_device() == Device.CUDA:
1013
+ cp = _require_cupy("confusion_matrix")
1014
+
1015
+ y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1)
1016
+ y_score = cp.asarray(self.predict_proba(X))[:, 1]
1017
+ out = evaluate_binary_classification(
1018
+ y_true,
1019
+ y_score,
1020
+ threshold=threshold,
1021
+ include_curves=False,
1022
+ backend="cupy",
1023
+ )
1024
+ return out["confusion_matrix"]
1025
+ if self._get_compute_device() == Device.TORCH:
1026
+ y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1)
1027
+ y_score = self.predict_proba(X)[:, 1]
1028
+ out = evaluate_binary_classification(
1029
+ y_true,
1030
+ y_score,
1031
+ threshold=threshold,
1032
+ include_curves=False,
1033
+ backend="torch",
1034
+ )
1035
+ return out["confusion_matrix"]
1036
+
1037
+ y_true = self._to_numpy(y)
1038
+ y_score = self._to_numpy(self.predict_proba(X))[:, 1]
1039
+ out = evaluate_binary_classification(
1040
+ y_true,
1041
+ y_score,
1042
+ threshold=threshold,
1043
+ include_curves=False,
1044
+ backend="numpy",
1045
+ )
1046
+ return out["confusion_matrix"]
1047
+
1048
+ def classification_table(self, X, y, threshold: float = 0.5) -> Dict[str, float]:
1049
+ """Return a compact classification table on a dataset."""
1050
+ if self._get_compute_device() == Device.CUDA:
1051
+ cp = _require_cupy("classification_table")
1052
+
1053
+ y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1)
1054
+ y_score = cp.asarray(self.predict_proba(X))[:, 1]
1055
+ out = evaluate_binary_classification(
1056
+ y_true,
1057
+ y_score,
1058
+ threshold=threshold,
1059
+ include_curves=False,
1060
+ backend="cupy",
1061
+ )
1062
+ return out["classification_table"]
1063
+ if self._get_compute_device() == Device.TORCH:
1064
+ y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1)
1065
+ y_score = self.predict_proba(X)[:, 1]
1066
+ out = evaluate_binary_classification(
1067
+ y_true,
1068
+ y_score,
1069
+ threshold=threshold,
1070
+ include_curves=False,
1071
+ backend="torch",
1072
+ )
1073
+ return out["classification_table"]
1074
+
1075
+ y_true = self._to_numpy(y)
1076
+ y_score = self._to_numpy(self.predict_proba(X))[:, 1]
1077
+ out = evaluate_binary_classification(
1078
+ y_true,
1079
+ y_score,
1080
+ threshold=threshold,
1081
+ include_curves=False,
1082
+ backend="numpy",
1083
+ )
1084
+ return out["classification_table"]
1085
+
1086
+ def roc_curve(self, X, y) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
1087
+ """Compute ROC curve arrays (fpr, tpr, thresholds)."""
1088
+ if self._get_compute_device() == Device.CUDA:
1089
+ cp = _require_cupy("roc_curve")
1090
+
1091
+ y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1)
1092
+ y_score = cp.asarray(self.predict_proba(X))[:, 1]
1093
+ return binary_roc_curve(y_true, y_score, backend="cupy")
1094
+ if self._get_compute_device() == Device.TORCH:
1095
+ y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1)
1096
+ y_score = self.predict_proba(X)[:, 1]
1097
+ return binary_roc_curve(y_true, y_score, backend="torch")
1098
+
1099
+ y_true = self._to_numpy(y)
1100
+ y_score = self._to_numpy(self.predict_proba(X))[:, 1]
1101
+ return binary_roc_curve(y_true, y_score, backend="numpy")
1102
+
1103
+ def roc_auc_score(self, X, y) -> float:
1104
+ """Compute ROC-AUC on a dataset."""
1105
+ if self._get_compute_device() == Device.CUDA:
1106
+ cp = _require_cupy("roc_auc_score")
1107
+
1108
+ y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1)
1109
+ y_score = cp.asarray(self.predict_proba(X))[:, 1]
1110
+ return binary_roc_auc_score(y_true, y_score, backend="cupy")
1111
+ if self._get_compute_device() == Device.TORCH:
1112
+ y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1)
1113
+ y_score = self.predict_proba(X)[:, 1]
1114
+ return binary_roc_auc_score(y_true, y_score, backend="torch")
1115
+
1116
+ y_true = self._to_numpy(y)
1117
+ y_score = self._to_numpy(self.predict_proba(X))[:, 1]
1118
+ return binary_roc_auc_score(y_true, y_score, backend="numpy")
1119
+
1120
+ def precision_recall_curve(self, X, y) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
1121
+ """Compute precision-recall arrays (precision, recall, thresholds)."""
1122
+ if self._get_compute_device() == Device.CUDA:
1123
+ cp = _require_cupy("precision_recall_curve")
1124
+
1125
+ y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1)
1126
+ y_score = cp.asarray(self.predict_proba(X))[:, 1]
1127
+ return binary_precision_recall_curve(y_true, y_score, backend="cupy")
1128
+ if self._get_compute_device() == Device.TORCH:
1129
+ y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1)
1130
+ y_score = self.predict_proba(X)[:, 1]
1131
+ return binary_precision_recall_curve(y_true, y_score, backend="torch")
1132
+
1133
+ y_true = self._to_numpy(y)
1134
+ y_score = self._to_numpy(self.predict_proba(X))[:, 1]
1135
+ return binary_precision_recall_curve(y_true, y_score, backend="numpy")
1136
+
1137
+ def average_precision_score(self, X, y) -> float:
1138
+ """Compute average precision on a dataset."""
1139
+ if self._get_compute_device() == Device.CUDA:
1140
+ cp = _require_cupy("average_precision_score")
1141
+
1142
+ y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1)
1143
+ y_score = cp.asarray(self.predict_proba(X))[:, 1]
1144
+ return binary_average_precision_score(y_true, y_score, backend="cupy")
1145
+ if self._get_compute_device() == Device.TORCH:
1146
+ y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1)
1147
+ y_score = self.predict_proba(X)[:, 1]
1148
+ return binary_average_precision_score(y_true, y_score, backend="torch")
1149
+
1150
+ y_true = self._to_numpy(y)
1151
+ y_score = self._to_numpy(self.predict_proba(X))[:, 1]
1152
+ return binary_average_precision_score(y_true, y_score, backend="numpy")
1153
+
1154
+ def evaluate_classification(
1155
+ self,
1156
+ X,
1157
+ y,
1158
+ threshold: float = 0.5,
1159
+ include_curves: bool = True,
1160
+ ) -> Dict[str, Any]:
1161
+ """
1162
+ Compute classification metrics in one pass from a single probability call.
1163
+
1164
+ Parameters
1165
+ ----------
1166
+ X : array-like of shape (n_samples, n_features)
1167
+ Samples.
1168
+ y : array-like of shape (n_samples,)
1169
+ Binary true labels encoded as 0/1.
1170
+ threshold : float, default=0.5
1171
+ Probability threshold used for hard predictions.
1172
+ include_curves : bool, default=True
1173
+ Whether to include full ROC/PR curve arrays in the output.
1174
+
1175
+ Returns
1176
+ -------
1177
+ dict
1178
+ A dictionary with batched metrics. On CUDA device, arrays/scalars
1179
+ are GPU-backed (CuPy) except ``threshold``.
1180
+ """
1181
+ if threshold < 0.0 or threshold > 1.0:
1182
+ raise ValueError("threshold must be in [0, 1]")
1183
+
1184
+ if self._get_compute_device() == Device.CUDA:
1185
+ cp = _require_cupy("evaluate_classification")
1186
+
1187
+ y_true = cp.asarray(self._to_array(y, Device.CUDA)).reshape(-1)
1188
+ y_score = cp.asarray(self.predict_proba(X))[:, 1]
1189
+ return evaluate_binary_classification(
1190
+ y_true,
1191
+ y_score,
1192
+ threshold=threshold,
1193
+ include_curves=include_curves,
1194
+ backend="cupy",
1195
+ )
1196
+ if self._get_compute_device() == Device.TORCH:
1197
+ y_true = self._to_array(y, Device.TORCH, backend="torch").reshape(-1)
1198
+ y_score = self.predict_proba(X)[:, 1]
1199
+ return evaluate_binary_classification(
1200
+ y_true,
1201
+ y_score,
1202
+ threshold=threshold,
1203
+ include_curves=include_curves,
1204
+ backend="torch",
1205
+ )
1206
+
1207
+ y_true = self._to_numpy(y).reshape(-1)
1208
+ y_score = self._to_numpy(self.predict_proba(X))[:, 1]
1209
+ return evaluate_binary_classification(
1210
+ y_true,
1211
+ y_score,
1212
+ threshold=threshold,
1213
+ include_curves=include_curves,
1214
+ backend="numpy",
1215
+ )
1216
+
1217
+ def plot_roc_curve(self, X, y, ax=None, label: Optional[str] = None):
1218
+ """
1219
+ Plot ROC curve with matplotlib and return the axes object.
1220
+
1221
+ Raises
1222
+ ------
1223
+ ImportError
1224
+ If matplotlib is not installed.
1225
+ """
1226
+ try:
1227
+ import matplotlib.pyplot as plt
1228
+ except ImportError as exc:
1229
+ raise ImportError(
1230
+ "matplotlib is required for plot_roc_curve(). "
1231
+ "Install it with: pip install matplotlib"
1232
+ ) from exc
1233
+
1234
+ fpr, tpr, _ = self.roc_curve(X, y)
1235
+ auc = self.roc_auc_score(X, y)
1236
+ fpr_plot = self._to_numpy(fpr)
1237
+ tpr_plot = self._to_numpy(tpr)
1238
+
1239
+ if ax is None:
1240
+ _, ax = plt.subplots(figsize=(6, 5))
1241
+
1242
+ line_label = label if label is not None else f"ROC (AUC={self._to_python_float(auc):.3f})"
1243
+ ax.plot(fpr_plot, tpr_plot, label=line_label)
1244
+ ax.plot([0.0, 1.0], [0.0, 1.0], linestyle="--", color="gray", linewidth=1.0)
1245
+ ax.set_xlim(0.0, 1.0)
1246
+ ax.set_ylim(0.0, 1.0)
1247
+ ax.set_xlabel("False Positive Rate")
1248
+ ax.set_ylabel("True Positive Rate")
1249
+ ax.set_title("ROC Curve")
1250
+ ax.legend(loc="lower right")
1251
+ return ax
1252
+
1253
+ def plot_precision_recall_curve(self, X, y, ax=None, label: Optional[str] = None):
1254
+ """
1255
+ Plot precision-recall curve with matplotlib and return the axes object.
1256
+
1257
+ Raises
1258
+ ------
1259
+ ImportError
1260
+ If matplotlib is not installed.
1261
+ """
1262
+ try:
1263
+ import matplotlib.pyplot as plt
1264
+ except ImportError as exc:
1265
+ raise ImportError(
1266
+ "matplotlib is required for plot_precision_recall_curve(). "
1267
+ "Install it with: pip install matplotlib"
1268
+ ) from exc
1269
+
1270
+ precision, recall, _ = self.precision_recall_curve(X, y)
1271
+ ap = self.average_precision_score(X, y)
1272
+ precision_plot = self._to_numpy(precision)
1273
+ recall_plot = self._to_numpy(recall)
1274
+
1275
+ if ax is None:
1276
+ _, ax = plt.subplots(figsize=(6, 5))
1277
+
1278
+ line_label = label if label is not None else f"PR (AP={self._to_python_float(ap):.3f})"
1279
+ ax.plot(recall_plot, precision_plot, label=line_label)
1280
+ ax.set_xlim(0.0, 1.0)
1281
+ ax.set_ylim(0.0, 1.0)
1282
+ ax.set_xlabel("Recall")
1283
+ ax.set_ylabel("Precision")
1284
+ ax.set_title("Precision-Recall Curve")
1285
+ ax.legend(loc="lower left")
1286
+ return ax
1287
+
1288
+ @property
1289
+ def loglikelihood(self):
1290
+ """Log-likelihood of the fitted model."""
1291
+ return self._loglik
1292
+
1293
+ @property
1294
+ def loglikelihood_null(self):
1295
+ """Log-likelihood of the null model."""
1296
+ return self._loglik_null
1297
+
1298
+ @property
1299
+ def aic(self):
1300
+ """Akaike Information Criterion."""
1301
+ if self._loglik is None:
1302
+ return None
1303
+ k = len(self._params)
1304
+ return -2 * self._loglik + 2 * k
1305
+
1306
+ @property
1307
+ def bic(self):
1308
+ """Bayesian Information Criterion."""
1309
+ if self._loglik is None or self._nobs is None:
1310
+ return None
1311
+ k = len(self._params)
1312
+ return -2 * self._loglik + k * np.log(self._nobs)
1313
+
1314
+ @property
1315
+ def pseudo_rsquared(self):
1316
+ """
1317
+ Pseudo R-squared (McFadden's).
1318
+
1319
+ Measures the improvement of the full model over the null model.
1320
+ """
1321
+ if self._loglik is None or self._loglik_null is None:
1322
+ return None
1323
+ if self._loglik_null == 0:
1324
+ return 0.0
1325
+ return 1 - (self._loglik / self._loglik_null)
1326
+
1327
+ @property
1328
+ def accuracy(self):
1329
+ """Classification accuracy on training data."""
1330
+ table = self._train_classification_table()
1331
+ if table is None:
1332
+ return None
1333
+ return table["accuracy"]
1334
+
1335
+ @property
1336
+ def precision(self):
1337
+ """Precision on training data."""
1338
+ table = self._train_classification_table()
1339
+ if table is None:
1340
+ return None
1341
+ return table["precision"]
1342
+
1343
+ @property
1344
+ def recall(self):
1345
+ """Recall on training data."""
1346
+ table = self._train_classification_table()
1347
+ if table is None:
1348
+ return None
1349
+ return table["recall"]
1350
+
1351
+ @property
1352
+ def f1(self):
1353
+ """F1 score on training data."""
1354
+ table = self._train_classification_table()
1355
+ if table is None:
1356
+ return None
1357
+ return table["f1"]
1358
+
1359
+ @property
1360
+ def auc(self):
1361
+ """ROC-AUC on training data."""
1362
+ if self._y is None or not self._fitted:
1363
+ return None
1364
+ # Use cached eval result if available (populated by _train_classification_table)
1365
+ if self._train_eval_cache is not None:
1366
+ return self._train_eval_cache.get("roc_auc")
1367
+ # Trigger cache population via _train_classification_table
1368
+ self._train_classification_table()
1369
+ if self._train_eval_cache is not None:
1370
+ return self._train_eval_cache.get("roc_auc")
1371
+ return None
1372
+
1373
+ @property
1374
+ def average_precision(self):
1375
+ """Average precision on training data."""
1376
+ if self._y is None or not self._fitted:
1377
+ return None
1378
+ # Use cached eval result if available (populated by _train_classification_table)
1379
+ if self._train_eval_cache is not None:
1380
+ return self._train_eval_cache.get("average_precision")
1381
+ # Trigger cache population via _train_classification_table
1382
+ self._train_classification_table()
1383
+ if self._train_eval_cache is not None:
1384
+ return self._train_eval_cache.get("average_precision")
1385
+ return None
1386
+
1387
+ def summary(self):
1388
+ """Print summary table similar to statsmodels/R."""
1389
+ if not self._fitted:
1390
+ raise RuntimeError("Model has not been fitted yet.")
1391
+
1392
+ if self._bse is None or self._pvalues is None or self._conf_int is None:
1393
+ raise RuntimeError(
1394
+ "compute_inference=False: inference statistics are not available. "
1395
+ "Re-fit with compute_inference=True (default) to use summary()."
1396
+ )
1397
+
1398
+ # Build feature names
1399
+ if self.fit_intercept:
1400
+ feature_names = ['(Intercept)'] + [f'x{i+1}' for i in range(len(self.coef_))]
1401
+ else:
1402
+ feature_names = [f'x{i+1}' for i in range(len(self.coef_))]
1403
+
1404
+ print("=" * 80)
1405
+ print(" Logistic Regression Results")
1406
+ print("=" * 80)
1407
+ print(f"No. Observations: {self._nobs:>15}")
1408
+ print(f"Degrees of Freedom: {self._df_resid:>15}")
1409
+ print(f"Iterations: {self.n_iter_:>15}")
1410
+ print(f"Covariance Type: {self.cov_type:>15}")
1411
+ print(f"Log-Likelihood: {self.loglikelihood:>15.4f}")
1412
+ print(f"Log-Likelihood (Null): {self.loglikelihood_null:>15.4f}")
1413
+ print(f"Pseudo R-squared: {self.pseudo_rsquared:>15.4f}")
1414
+ print(f"AIC: {self.aic:>15.4f}")
1415
+ print(f"BIC: {self.bic:>15.4f}")
1416
+ print(f"Accuracy: {self._to_python_float(self.accuracy):>15.4f}")
1417
+ print(f"Precision: {self._to_python_float(self.precision):>15.4f}")
1418
+ print(f"Recall: {self._to_python_float(self.recall):>15.4f}")
1419
+ print(f"F1 Score: {self._to_python_float(self.f1):>15.4f}")
1420
+ auc = self.auc
1421
+ auc_display = self._to_python_float(auc)
1422
+ print(f"ROC-AUC: {auc_display:>15.4f}")
1423
+ ap = self.average_precision
1424
+ ap_display = self._to_python_float(ap)
1425
+ print(f"Avg Precision: {ap_display:>15.4f}")
1426
+ print("-" * 80)
1427
+ print(f"{'':<15} {'coef':>12} {'std err':>12} {'z':>10} {'P>|z|':>10} {'[0.025':>12} {'0.975]':>12}")
1428
+ print("-" * 80)
1429
+
1430
+ for i, name in enumerate(feature_names):
1431
+ print(f"{name:<15} {self._params[i]:>12.4f} {self._bse[i]:>12.4f} "
1432
+ f"{self._zvalues[i]:>10.3f} {self._pvalues[i]:>10.4f} "
1433
+ f"{self._conf_int[i, 0]:>12.4f} {self._conf_int[i, 1]:>12.4f}")
1434
+
1435
+ print("=" * 80)