statgpu 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- statgpu/__init__.py +174 -0
- statgpu/_base.py +544 -0
- statgpu/_config.py +127 -0
- statgpu/anova/__init__.py +5 -0
- statgpu/anova/_oneway.py +194 -0
- statgpu/backends/__init__.py +83 -0
- statgpu/backends/_array_ops.py +529 -0
- statgpu/backends/_base.py +184 -0
- statgpu/backends/_cupy.py +453 -0
- statgpu/backends/_factory.py +65 -0
- statgpu/backends/_gpu_inference_cupy.py +214 -0
- statgpu/backends/_gpu_inference_torch.py +422 -0
- statgpu/backends/_numpy.py +324 -0
- statgpu/backends/_torch.py +685 -0
- statgpu/backends/_torch_safe.py +47 -0
- statgpu/backends/_utils.py +423 -0
- statgpu/core/__init__.py +10 -0
- statgpu/core/formula/__init__.py +33 -0
- statgpu/core/formula/_design.py +99 -0
- statgpu/core/formula/_parser.py +191 -0
- statgpu/core/formula/_terms.py +70 -0
- statgpu/core/formula/tests/__init__.py +0 -0
- statgpu/core/formula/tests/test_parser.py +194 -0
- statgpu/covariance/__init__.py +6 -0
- statgpu/covariance/_empirical.py +310 -0
- statgpu/covariance/_shrinkage.py +248 -0
- statgpu/cross_validation/__init__.py +31 -0
- statgpu/cross_validation/_base.py +410 -0
- statgpu/cross_validation/_engine.py +167 -0
- statgpu/diagnostics/__init__.py +7 -0
- statgpu/diagnostics/_regression_diagnostics.py +188 -0
- statgpu/feature_selection/__init__.py +24 -0
- statgpu/feature_selection/_knockoff.py +870 -0
- statgpu/feature_selection/_knockoff_utils.py +1003 -0
- statgpu/feature_selection/_stepwise.py +300 -0
- statgpu/glm_core/__init__.py +81 -0
- statgpu/glm_core/_base.py +202 -0
- statgpu/glm_core/_family.py +362 -0
- statgpu/glm_core/_fused.py +149 -0
- statgpu/glm_core/_gamma.py +111 -0
- statgpu/glm_core/_inverse_gaussian.py +62 -0
- statgpu/glm_core/_irls.py +561 -0
- statgpu/glm_core/_logistic.py +82 -0
- statgpu/glm_core/_negative_binomial.py +68 -0
- statgpu/glm_core/_poisson.py +60 -0
- statgpu/glm_core/_solver_legacy.py +100 -0
- statgpu/glm_core/_squared.py +53 -0
- statgpu/glm_core/_tweedie.py +74 -0
- statgpu/inference/__init__.py +239 -0
- statgpu/inference/_distributions_backend.py +2610 -0
- statgpu/inference/_multiple_testing.py +391 -0
- statgpu/inference/_resampling.py +1400 -0
- statgpu/inference/_results.py +265 -0
- statgpu/linear_model/__init__.py +75 -0
- statgpu/linear_model/_gaussian_inference.py +306 -0
- statgpu/linear_model/_glm_base.py +1261 -0
- statgpu/linear_model/_ordered_logit.py +52 -0
- statgpu/linear_model/_ordered_probit.py +50 -0
- statgpu/linear_model/_stats.py +170 -0
- statgpu/linear_model/cv/__init__.py +13 -0
- statgpu/linear_model/cv/_elasticnet_cv.py +892 -0
- statgpu/linear_model/cv/_lasso_cv.py +253 -0
- statgpu/linear_model/cv/_logistic_cv.py +895 -0
- statgpu/linear_model/cv/_ridge_cv.py +1160 -0
- statgpu/linear_model/legacy/__init__.py +1 -0
- statgpu/linear_model/legacy/_distributions_legacy_gpu.py +340 -0
- statgpu/linear_model/legacy/_elasticnet_legacy.py +936 -0
- statgpu/linear_model/legacy/_lasso_legacy.py +4876 -0
- statgpu/linear_model/legacy/_penalized_legacy.py +1174 -0
- statgpu/linear_model/legacy/_ridge_legacy.py +863 -0
- statgpu/linear_model/legacy/_solver_legacy.py +104 -0
- statgpu/linear_model/penalized/__init__.py +25 -0
- statgpu/linear_model/penalized/_base.py +437 -0
- statgpu/linear_model/penalized/_fit_mixin.py +1877 -0
- statgpu/linear_model/penalized/_inference_mixin.py +1179 -0
- statgpu/linear_model/penalized/_penalized_cv.py +2699 -0
- statgpu/linear_model/penalized/_penalized_gamma.py +86 -0
- statgpu/linear_model/penalized/_penalized_inverse_gaussian.py +62 -0
- statgpu/linear_model/penalized/_penalized_linear.py +236 -0
- statgpu/linear_model/penalized/_penalized_logistic.py +100 -0
- statgpu/linear_model/penalized/_penalized_negative_binomial.py +65 -0
- statgpu/linear_model/penalized/_penalized_poisson.py +62 -0
- statgpu/linear_model/penalized/_penalized_tweedie.py +65 -0
- statgpu/linear_model/penalized/_predict_mixin.py +182 -0
- statgpu/linear_model/wrappers/__init__.py +31 -0
- statgpu/linear_model/wrappers/_adaptive_lasso.py +63 -0
- statgpu/linear_model/wrappers/_elasticnet.py +75 -0
- statgpu/linear_model/wrappers/_gamma.py +67 -0
- statgpu/linear_model/wrappers/_inverse_gaussian.py +47 -0
- statgpu/linear_model/wrappers/_lasso.py +2124 -0
- statgpu/linear_model/wrappers/_linear.py +1127 -0
- statgpu/linear_model/wrappers/_logistic.py +1435 -0
- statgpu/linear_model/wrappers/_mcp.py +58 -0
- statgpu/linear_model/wrappers/_negative_binomial.py +58 -0
- statgpu/linear_model/wrappers/_poisson.py +48 -0
- statgpu/linear_model/wrappers/_ridge.py +166 -0
- statgpu/linear_model/wrappers/_scad.py +58 -0
- statgpu/linear_model/wrappers/_tweedie.py +57 -0
- statgpu/metrics/__init__.py +21 -0
- statgpu/metrics/_classification.py +591 -0
- statgpu/nonparametric/__init__.py +50 -0
- statgpu/nonparametric/kernel_methods/__init__.py +25 -0
- statgpu/nonparametric/kernel_methods/_kernels.py +246 -0
- statgpu/nonparametric/kernel_methods/_krr.py +234 -0
- statgpu/nonparametric/kernel_methods/_krr_cv.py +380 -0
- statgpu/nonparametric/kernel_smoothing/__init__.py +39 -0
- statgpu/nonparametric/kernel_smoothing/_bandwidth_selection.py +1083 -0
- statgpu/nonparametric/kernel_smoothing/_kde.py +761 -0
- statgpu/nonparametric/kernel_smoothing/_kernel_common.py +348 -0
- statgpu/nonparametric/kernel_smoothing/_kernel_regression.py +748 -0
- statgpu/nonparametric/splines/__init__.py +5 -0
- statgpu/nonparametric/splines/_bspline_basis.py +336 -0
- statgpu/nonparametric/splines/_penalized.py +349 -0
- statgpu/panel/__init__.py +19 -0
- statgpu/panel/_covariance.py +140 -0
- statgpu/panel/_fixed_effects.py +420 -0
- statgpu/panel/_random_effects.py +385 -0
- statgpu/panel/_utils.py +482 -0
- statgpu/penalties/__init__.py +139 -0
- statgpu/penalties/_adaptive_l1.py +313 -0
- statgpu/penalties/_base.py +261 -0
- statgpu/penalties/_categories.py +39 -0
- statgpu/penalties/_elasticnet.py +98 -0
- statgpu/penalties/_group_lasso.py +678 -0
- statgpu/penalties/_group_mcp.py +553 -0
- statgpu/penalties/_group_scad.py +605 -0
- statgpu/penalties/_l1.py +107 -0
- statgpu/penalties/_l2.py +77 -0
- statgpu/penalties/_mcp.py +237 -0
- statgpu/penalties/_scad.py +260 -0
- statgpu/semiparametric/__init__.py +5 -0
- statgpu/semiparametric/_gam.py +401 -0
- statgpu/solvers/__init__.py +24 -0
- statgpu/solvers/_admm.py +241 -0
- statgpu/solvers/_constants.py +15 -0
- statgpu/solvers/_convergence.py +6 -0
- statgpu/solvers/_fista.py +436 -0
- statgpu/solvers/_fista_bb.py +513 -0
- statgpu/solvers/_fista_lla.py +541 -0
- statgpu/solvers/_lbfgs.py +206 -0
- statgpu/solvers/_newton.py +149 -0
- statgpu/solvers/_utils.py +277 -0
- statgpu/survival/__init__.py +14 -0
- statgpu/survival/_cox.py +3974 -0
- statgpu/survival/_cox_breslow_triton_kernel.py +106 -0
- statgpu/survival/_cox_cv.py +1159 -0
- statgpu/survival/_cox_efron_cuda.py +1280 -0
- statgpu/survival/_cox_efron_triton.py +359 -0
- statgpu/unsupervised/__init__.py +29 -0
- statgpu/unsupervised/_agglomerative.py +307 -0
- statgpu/unsupervised/_dbscan.py +263 -0
- statgpu/unsupervised/_dbscan_cpu.pyx +125 -0
- statgpu/unsupervised/_gmm.py +332 -0
- statgpu/unsupervised/_incremental_pca.py +176 -0
- statgpu/unsupervised/_kmeans.py +261 -0
- statgpu/unsupervised/_minibatch_kmeans.py +299 -0
- statgpu/unsupervised/_minibatch_nmf.py +252 -0
- statgpu/unsupervised/_nmf.py +190 -0
- statgpu/unsupervised/_pca.py +189 -0
- statgpu/unsupervised/_truncated_svd.py +132 -0
- statgpu/unsupervised/_tsne.py +192 -0
- statgpu/unsupervised/_umap.py +224 -0
- statgpu/unsupervised/_utils.py +134 -0
- statgpu-0.1.0.dist-info/METADATA +245 -0
- statgpu-0.1.0.dist-info/RECORD +168 -0
- statgpu-0.1.0.dist-info/WHEEL +5 -0
- statgpu-0.1.0.dist-info/licenses/LICENSE +199 -0
- statgpu-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|