optimized-svm 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.
- optimized_svm/__init__.py +38 -0
- optimized_svm/estimators.py +735 -0
- optimized_svm/hypergradient.py +153 -0
- optimized_svm/kernels.py +63 -0
- optimized_svm/linalg.py +24 -0
- optimized_svm/losses.py +176 -0
- optimized_svm/problem.py +99 -0
- optimized_svm/smooth_max.py +90 -0
- optimized_svm/training.py +258 -0
- optimized_svm/validation.py +76 -0
- optimized_svm-0.1.0.dist-info/METADATA +131 -0
- optimized_svm-0.1.0.dist-info/RECORD +14 -0
- optimized_svm-0.1.0.dist-info/WHEEL +4 -0
- optimized_svm-0.1.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""scikit-learn compatible estimators.
|
|
2
|
+
|
|
3
|
+
* :class:`SmoothSVC`, :class:`SmoothSVR`: kernel SVMs with smoothed losses and *fixed*
|
|
4
|
+
hyperparameters, trained by SMO + Newton.
|
|
5
|
+
* :class:`OptimizedSVC`, :class:`OptimizedSVR`: the same models whose regularization parameter
|
|
6
|
+
and kernel scalings are tuned by minimising a cross-validation error with exact gradients
|
|
7
|
+
(bilevel approach of Strasdat, Ch. 9).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from numbers import Integral, Real
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from numpy.typing import ArrayLike, NDArray
|
|
19
|
+
from scipy.optimize import minimize
|
|
20
|
+
from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin, clone
|
|
21
|
+
from sklearn.model_selection import KFold, StratifiedKFold
|
|
22
|
+
from sklearn.utils.multiclass import check_classification_targets
|
|
23
|
+
from sklearn.utils.validation import _check_sample_weight, check_is_fitted, validate_data
|
|
24
|
+
|
|
25
|
+
from .hypergradient import Fold, cv_error_and_gradient
|
|
26
|
+
from .kernels import Blocks, rbf, scale_features
|
|
27
|
+
from .losses import (
|
|
28
|
+
ApproxPoissonLoss,
|
|
29
|
+
EpsilonInsensitiveLoss,
|
|
30
|
+
HingeLoss,
|
|
31
|
+
Loss,
|
|
32
|
+
PoissonLoss,
|
|
33
|
+
SquaredLoss,
|
|
34
|
+
)
|
|
35
|
+
from .problem import TrainingProblem
|
|
36
|
+
from .training import train
|
|
37
|
+
from .validation import (
|
|
38
|
+
PoissonValidation,
|
|
39
|
+
SmoothAbsoluteValidation,
|
|
40
|
+
SmoothHingeValidation,
|
|
41
|
+
SquaredValidation,
|
|
42
|
+
ValidationLoss,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
Array = NDArray[np.float64]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------------------------
|
|
49
|
+
# shared helpers
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_blocks(blocks: Any, n_features: int) -> Blocks:
|
|
53
|
+
"""``None`` (one scaling), ``"features"`` (one per feature) or explicit index lists."""
|
|
54
|
+
if blocks is None:
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(blocks, str):
|
|
57
|
+
if blocks != "features":
|
|
58
|
+
raise ValueError(f"blocks must be None, 'features' or a list, got {blocks!r}")
|
|
59
|
+
return [[j] for j in range(n_features)]
|
|
60
|
+
blocks = [list(map(int, b)) for b in blocks]
|
|
61
|
+
used = sorted(j for b in blocks for j in b)
|
|
62
|
+
if used != list(range(n_features)):
|
|
63
|
+
raise ValueError("blocks must partition the feature indices 0..n_features-1")
|
|
64
|
+
return blocks
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def auto_scaling(x: Array) -> float:
|
|
68
|
+
"""Initial scaling with ``E|x - z|^2 * s^2 = log 2`` (kernel value 1/2 on average)."""
|
|
69
|
+
spread = float(((x - x.mean(axis=0)) ** 2).sum(axis=1).mean())
|
|
70
|
+
return float(np.sqrt(np.log(2.0) / (2.0 * spread))) if spread > 0 else 1.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _LabelScaler:
|
|
74
|
+
"""Affine normalisation of regression targets."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, mode: str) -> None:
|
|
77
|
+
self.mode = mode
|
|
78
|
+
self.offset, self.scale = 0.0, 1.0
|
|
79
|
+
|
|
80
|
+
def fit(self, y: Array) -> _LabelScaler:
|
|
81
|
+
if self.mode == "median":
|
|
82
|
+
self.offset = float(np.median(y))
|
|
83
|
+
self.scale = float(np.abs(y - self.offset).mean())
|
|
84
|
+
elif self.mode == "mean":
|
|
85
|
+
self.offset = float(y.mean())
|
|
86
|
+
self.scale = float(y.std())
|
|
87
|
+
if not self.scale > 0:
|
|
88
|
+
self.scale = 1.0
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def transform(self, y: Array) -> Array:
|
|
92
|
+
return (y - self.offset) / self.scale
|
|
93
|
+
|
|
94
|
+
def inverse(self, f: Array) -> Array:
|
|
95
|
+
return f * self.scale + self.offset
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class _Model:
|
|
100
|
+
"""Kernel expansion ``f(x) = sum_k coef_k exp(-|S x - S sv_k|^2) + b``."""
|
|
101
|
+
|
|
102
|
+
support_vectors: Array # scaled
|
|
103
|
+
coef: Array
|
|
104
|
+
intercept: float
|
|
105
|
+
|
|
106
|
+
def decision(self, xs: Array) -> Array:
|
|
107
|
+
return rbf(xs, self.support_vectors) @ self.coef + self.intercept
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _SmoothBase(BaseEstimator):
|
|
111
|
+
"""Fixed-hyperparameter training shared by :class:`SmoothSVC` and :class:`SmoothSVR`."""
|
|
112
|
+
|
|
113
|
+
def _loss(self) -> Loss:
|
|
114
|
+
raise NotImplementedError
|
|
115
|
+
|
|
116
|
+
def _check_params(self) -> None:
|
|
117
|
+
if not isinstance(self.lam, Real) or self.lam <= 0:
|
|
118
|
+
raise ValueError(f"lam must be positive, got {self.lam!r}")
|
|
119
|
+
if not isinstance(self.smoothing, Real) or self.smoothing < 0:
|
|
120
|
+
raise ValueError(f"smoothing must be non-negative, got {self.smoothing!r}")
|
|
121
|
+
|
|
122
|
+
def _scaling(self, x: Array) -> Array:
|
|
123
|
+
n = 1 if self.blocks_ is None else len(self.blocks_)
|
|
124
|
+
if isinstance(self.scaling, str):
|
|
125
|
+
if self.scaling != "auto":
|
|
126
|
+
raise ValueError(f"scaling must be 'auto' or positive, got {self.scaling!r}")
|
|
127
|
+
return np.full(n, auto_scaling(x))
|
|
128
|
+
s = np.atleast_1d(np.asarray(self.scaling, dtype=np.float64))
|
|
129
|
+
if s.size == 1:
|
|
130
|
+
s = np.full(n, s[0])
|
|
131
|
+
if s.size != n or np.any(s <= 0):
|
|
132
|
+
raise ValueError(f"scaling must be positive with {n} entries")
|
|
133
|
+
return s
|
|
134
|
+
|
|
135
|
+
def _fit_model(self, x: Array, y: Array, w: Array) -> None:
|
|
136
|
+
self.blocks_ = resolve_blocks(self.blocks, x.shape[1])
|
|
137
|
+
self.scaling_ = self._scaling(x)
|
|
138
|
+
xs = scale_features(x, self.scaling_, self.blocks_)
|
|
139
|
+
prob = TrainingProblem(xs, y, self._loss(), self.lam, w, self.max_asum)
|
|
140
|
+
sol = train(prob, self.smo_options, self.newton_options)
|
|
141
|
+
beta = prob.sample_coefficients(sol.a)
|
|
142
|
+
support = np.flatnonzero(beta)
|
|
143
|
+
self.support_ = support
|
|
144
|
+
self.dual_coef_ = beta[support] / self.lam
|
|
145
|
+
self.intercept_ = sol.b
|
|
146
|
+
self.n_support_ = support.size
|
|
147
|
+
self.training_info_ = {
|
|
148
|
+
"residual": sol.residual,
|
|
149
|
+
"smo_steps": sol.smo_steps,
|
|
150
|
+
"newton_steps": sol.newton_steps,
|
|
151
|
+
"time_smo": sol.time_smo,
|
|
152
|
+
"time_newton": sol.time_newton,
|
|
153
|
+
"c": sol.c,
|
|
154
|
+
}
|
|
155
|
+
self._model = _Model(xs[support], self.dual_coef_, sol.b)
|
|
156
|
+
|
|
157
|
+
def _decision(self, X: ArrayLike) -> Array:
|
|
158
|
+
check_is_fitted(self)
|
|
159
|
+
x = validate_data(self, X, reset=False, dtype=np.float64)
|
|
160
|
+
return self._model.decision(scale_features(x, self.scaling_, self.blocks_))
|
|
161
|
+
|
|
162
|
+
def decision_gradient(self, X: ArrayLike) -> Array:
|
|
163
|
+
"""Gradient of the decision function w.r.t. the input features, shape ``(n, d)``.
|
|
164
|
+
|
|
165
|
+
Useful as a local sensitivity / feature relevance measure.
|
|
166
|
+
"""
|
|
167
|
+
check_is_fitted(self)
|
|
168
|
+
x = validate_data(self, X, reset=False, dtype=np.float64)
|
|
169
|
+
xs = scale_features(x, self.scaling_, self.blocks_)
|
|
170
|
+
sv = self._model.support_vectors
|
|
171
|
+
k = rbf(xs, sv) * self._model.coef # (n, n_sv)
|
|
172
|
+
grad_scaled = -2.0 * (k.sum(axis=1)[:, None] * xs - k @ sv)
|
|
173
|
+
factor = scale_features(np.ones((1, x.shape[1])), self.scaling_, self.blocks_)[0]
|
|
174
|
+
return grad_scaled * factor
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
_SMOOTH_DOC = """
|
|
178
|
+
lam : float
|
|
179
|
+
Regularization parameter ``lambda > 0`` (``1 / C`` in LIBSVM terms).
|
|
180
|
+
scaling : float, array or "auto"
|
|
181
|
+
Feature scaling(s) ``s_k``; the kernel is ``exp(-sum_k s_k^2 |x_B_k - z_B_k|^2)``.
|
|
182
|
+
"auto" chooses ``s`` such that the mean kernel value is about 1/2.
|
|
183
|
+
blocks : None, "features" or list of lists
|
|
184
|
+
Feature blocks sharing one scaling (None: a single scaling, i.e. an RBF kernel).
|
|
185
|
+
smoothing : float
|
|
186
|
+
Smoothing parameter ``delta`` of the loss (``0``: non-smooth, SMO only).
|
|
187
|
+
degree : {"2", "3", "6"}
|
|
188
|
+
Degree of the smoothed maximum function (``"2"`` is solved exactly by SMO).
|
|
189
|
+
max_asum : float, optional
|
|
190
|
+
Bound on ``sum |alpha|`` (generalised nu-SVM).
|
|
191
|
+
smo_options, newton_options : dict, optional
|
|
192
|
+
Options of :func:`smo_solver.solve_classification` / :func:`optimized_svm.training.newton`.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class SmoothSVC(ClassifierMixin, _SmoothBase):
|
|
197
|
+
__doc__ = (
|
|
198
|
+
"""Binary SVM classifier with a smoothed hinge loss and fixed hyperparameters.
|
|
199
|
+
|
|
200
|
+
Solves ``min lam/2 |w|^2 + sum_i c_i m_delta(shift - y_i f(x_i))``.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------"""
|
|
204
|
+
+ _SMOOTH_DOC
|
|
205
|
+
+ """ shift : float
|
|
206
|
+
Margin of the hinge loss.
|
|
207
|
+
class_weight : "balanced", dict or None
|
|
208
|
+
Per-class weights multiplied with ``sample_weight``.
|
|
209
|
+
"""
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
lam: float = 1.0,
|
|
215
|
+
*,
|
|
216
|
+
scaling: float | ArrayLike | str = "auto",
|
|
217
|
+
blocks: Any = None,
|
|
218
|
+
smoothing: float = 0.5,
|
|
219
|
+
degree: str = "2",
|
|
220
|
+
shift: float = 1.0,
|
|
221
|
+
max_asum: float | None = None,
|
|
222
|
+
class_weight: Any = None,
|
|
223
|
+
smo_options: dict[str, Any] | None = None,
|
|
224
|
+
newton_options: dict[str, Any] | None = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
self.lam = lam
|
|
227
|
+
self.scaling = scaling
|
|
228
|
+
self.blocks = blocks
|
|
229
|
+
self.smoothing = smoothing
|
|
230
|
+
self.degree = degree
|
|
231
|
+
self.shift = shift
|
|
232
|
+
self.max_asum = max_asum
|
|
233
|
+
self.class_weight = class_weight
|
|
234
|
+
self.smo_options = smo_options
|
|
235
|
+
self.newton_options = newton_options
|
|
236
|
+
|
|
237
|
+
def _loss(self) -> Loss:
|
|
238
|
+
return HingeLoss(self.shift, self.smoothing, self.degree)
|
|
239
|
+
|
|
240
|
+
def fit(self, X: ArrayLike, y: ArrayLike, sample_weight: ArrayLike | None = None) -> SmoothSVC:
|
|
241
|
+
self._check_params()
|
|
242
|
+
x, y = validate_data(self, X, y, dtype=np.float64)
|
|
243
|
+
if _fit_one_vs_rest(self, x, y, sample_weight):
|
|
244
|
+
return self
|
|
245
|
+
labels, w = _binary_labels(self, y, x, sample_weight)
|
|
246
|
+
self._fit_model(x, labels, w)
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
def decision_function(self, X: ArrayLike) -> Array:
|
|
250
|
+
"""Signed decision values: shape ``(n,)`` for two classes (positive values predict
|
|
251
|
+
``classes_[1]``), otherwise ``(n, n_classes)`` (one-vs-rest)."""
|
|
252
|
+
return _ovr_decision(self, X)
|
|
253
|
+
|
|
254
|
+
def predict(self, X: ArrayLike) -> NDArray[Any]:
|
|
255
|
+
return _ovr_predict(self, X)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _fit_one_vs_rest(est: Any, x: Array, y: Array, sample_weight: ArrayLike | None) -> bool:
|
|
259
|
+
"""Fits one binary clone per class if there are more than two classes."""
|
|
260
|
+
check_classification_targets(y)
|
|
261
|
+
classes = np.unique(y)
|
|
262
|
+
est.estimators_ = None
|
|
263
|
+
if classes.size <= 2:
|
|
264
|
+
return False
|
|
265
|
+
est.classes_ = classes
|
|
266
|
+
w = _weights(sample_weight, x)
|
|
267
|
+
if isinstance(est.class_weight, dict):
|
|
268
|
+
w = w * np.array([est.class_weight.get(c, 1.0) for c in y])
|
|
269
|
+
params = {"class_weight": None} if isinstance(est.class_weight, dict) else {}
|
|
270
|
+
est.estimators_ = [
|
|
271
|
+
clone(est).set_params(**params).fit(x, (y == c).astype(int), sample_weight=w)
|
|
272
|
+
for c in classes
|
|
273
|
+
]
|
|
274
|
+
if hasattr(est.estimators_[0], "n_iter_"):
|
|
275
|
+
est.n_iter_ = max(e.n_iter_ for e in est.estimators_)
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _ovr_decision(est: Any, X: ArrayLike) -> Array:
|
|
280
|
+
check_is_fitted(est)
|
|
281
|
+
if est.estimators_ is None:
|
|
282
|
+
return est._decision(X)
|
|
283
|
+
x = validate_data(est, X, reset=False, dtype=np.float64)
|
|
284
|
+
return np.stack([e.decision_function(x) for e in est.estimators_], axis=1)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _ovr_predict(est: Any, X: ArrayLike) -> NDArray[Any]:
|
|
288
|
+
dec = _ovr_decision(est, X)
|
|
289
|
+
idx = (dec > 0).astype(int) if dec.ndim == 1 else dec.argmax(axis=1)
|
|
290
|
+
return est.classes_[idx]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _binary_labels(
|
|
294
|
+
est: Any, y: ArrayLike, x: Array, sample_weight: ArrayLike | None
|
|
295
|
+
) -> tuple[Array, Array]:
|
|
296
|
+
check_classification_targets(y)
|
|
297
|
+
est.classes_, yi = np.unique(y, return_inverse=True)
|
|
298
|
+
if est.classes_.size != 2:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f"The number of classes has to be greater than one; got {est.classes_.size} class"
|
|
301
|
+
)
|
|
302
|
+
w = _weights(sample_weight, x)
|
|
303
|
+
if est.class_weight == "balanced":
|
|
304
|
+
counts = np.bincount(yi, minlength=2)
|
|
305
|
+
w = w * (yi.size / (2.0 * counts[yi]))
|
|
306
|
+
elif isinstance(est.class_weight, dict):
|
|
307
|
+
w = w * np.array([est.class_weight.get(c, 1.0) for c in est.classes_])[yi]
|
|
308
|
+
elif est.class_weight is not None:
|
|
309
|
+
raise ValueError(f"invalid class_weight {est.class_weight!r}")
|
|
310
|
+
return np.where(yi == 1, 1.0, -1.0), w
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _weights(sample_weight: ArrayLike | None, x: Array) -> Array:
|
|
314
|
+
w = _check_sample_weight(sample_weight, x, dtype=np.float64, ensure_non_negative=True)
|
|
315
|
+
if not np.any(w > 0):
|
|
316
|
+
raise ValueError("All sample weights are zero.")
|
|
317
|
+
return w
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
_REGRESSION_LOSSES = ("epsilon_insensitive", "squared", "poisson", "approx_poisson")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class SmoothSVR(RegressorMixin, _SmoothBase):
|
|
324
|
+
__doc__ = (
|
|
325
|
+
"""SVM regression with a smoothed loss and fixed hyperparameters.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------"""
|
|
329
|
+
+ _SMOOTH_DOC
|
|
330
|
+
+ """ loss : {"epsilon_insensitive", "squared", "poisson", "approx_poisson"}
|
|
331
|
+
``epsilon_insensitive``: smoothed epsilon-SVR; ``squared``: LS-SVM; ``poisson``:
|
|
332
|
+
Poisson regression with log link (predictions are ``exp(f)``).
|
|
333
|
+
epsilon : float
|
|
334
|
+
Width of the insensitive tube (in normalised units if ``normalize_y``).
|
|
335
|
+
normalize_y : bool
|
|
336
|
+
Normalise targets (median/mean absolute deviation for ``epsilon_insensitive``,
|
|
337
|
+
mean/std for ``squared``; ignored for Poisson losses).
|
|
338
|
+
"""
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def __init__(
|
|
342
|
+
self,
|
|
343
|
+
lam: float = 1.0,
|
|
344
|
+
*,
|
|
345
|
+
loss: str = "epsilon_insensitive",
|
|
346
|
+
epsilon: float = 0.1,
|
|
347
|
+
scaling: float | ArrayLike | str = "auto",
|
|
348
|
+
blocks: Any = None,
|
|
349
|
+
smoothing: float = 0.5,
|
|
350
|
+
degree: str = "2",
|
|
351
|
+
max_asum: float | None = None,
|
|
352
|
+
normalize_y: bool = True,
|
|
353
|
+
smo_options: dict[str, Any] | None = None,
|
|
354
|
+
newton_options: dict[str, Any] | None = None,
|
|
355
|
+
) -> None:
|
|
356
|
+
self.lam = lam
|
|
357
|
+
self.loss = loss
|
|
358
|
+
self.epsilon = epsilon
|
|
359
|
+
self.scaling = scaling
|
|
360
|
+
self.blocks = blocks
|
|
361
|
+
self.smoothing = smoothing
|
|
362
|
+
self.degree = degree
|
|
363
|
+
self.max_asum = max_asum
|
|
364
|
+
self.normalize_y = normalize_y
|
|
365
|
+
self.smo_options = smo_options
|
|
366
|
+
self.newton_options = newton_options
|
|
367
|
+
|
|
368
|
+
def _loss(self) -> Loss:
|
|
369
|
+
if self.loss == "epsilon_insensitive":
|
|
370
|
+
return EpsilonInsensitiveLoss(self.epsilon, self.smoothing, self.degree)
|
|
371
|
+
if self.loss == "squared":
|
|
372
|
+
return SquaredLoss()
|
|
373
|
+
if self.loss == "poisson":
|
|
374
|
+
return PoissonLoss()
|
|
375
|
+
if self.loss == "approx_poisson":
|
|
376
|
+
return ApproxPoissonLoss()
|
|
377
|
+
raise ValueError(f"loss must be one of {_REGRESSION_LOSSES}, got {self.loss!r}")
|
|
378
|
+
|
|
379
|
+
def _label_mode(self) -> str:
|
|
380
|
+
if not self.normalize_y or self.loss in ("poisson", "approx_poisson"):
|
|
381
|
+
return "none"
|
|
382
|
+
return "median" if self.loss == "epsilon_insensitive" else "mean"
|
|
383
|
+
|
|
384
|
+
def fit(self, X: ArrayLike, y: ArrayLike, sample_weight: ArrayLike | None = None) -> SmoothSVR:
|
|
385
|
+
self._check_params()
|
|
386
|
+
x, y = validate_data(self, X, y, dtype=np.float64, y_numeric=True)
|
|
387
|
+
w = _weights(sample_weight, x)
|
|
388
|
+
self._loss() # validates `loss`
|
|
389
|
+
if self.loss in ("poisson", "approx_poisson") and np.any(y < 0):
|
|
390
|
+
raise ValueError("Poisson losses require non-negative targets")
|
|
391
|
+
self.label_scaler_ = _LabelScaler(self._label_mode()).fit(y)
|
|
392
|
+
self._fit_model(x, self.label_scaler_.transform(y), w)
|
|
393
|
+
return self
|
|
394
|
+
|
|
395
|
+
def predict(self, X: ArrayLike) -> Array:
|
|
396
|
+
check_is_fitted(self)
|
|
397
|
+
f = self.label_scaler_.inverse(self._decision(X))
|
|
398
|
+
return np.exp(f) if self.loss in ("poisson", "approx_poisson") else f
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# --------------------------------------------------------------------------------------------
|
|
402
|
+
# hyperparameter optimization
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class _Timeout(Exception):
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class _OptimizedMixin:
|
|
410
|
+
"""L-BFGS-B on the cross-validation error w.r.t. ``(log lam, log scalings)``."""
|
|
411
|
+
|
|
412
|
+
def _validation(self) -> ValidationLoss:
|
|
413
|
+
raise NotImplementedError
|
|
414
|
+
|
|
415
|
+
def _folds(self, x: Array, y: Array) -> list[Fold]:
|
|
416
|
+
if not isinstance(self.cv, Integral):
|
|
417
|
+
return [(np.asarray(tr), np.asarray(va)) for tr, va in self.cv]
|
|
418
|
+
splitter_cls = StratifiedKFold if isinstance(self, ClassifierMixin) else KFold
|
|
419
|
+
splitter = splitter_cls(int(self.cv), shuffle=True, random_state=self.random_state)
|
|
420
|
+
return [(tr, va) for tr, va in splitter.split(x, y)]
|
|
421
|
+
|
|
422
|
+
def _optimize(self, x: Array, y: Array, w: Array) -> None:
|
|
423
|
+
loss, validation = self._loss(), self._validation()
|
|
424
|
+
folds = self._folds(x, y)
|
|
425
|
+
start = time.perf_counter()
|
|
426
|
+
history: list[dict[str, Any]] = []
|
|
427
|
+
lam_bounds = np.log(self.lam_bounds)
|
|
428
|
+
s_bounds = np.log(self.scaling_bounds)
|
|
429
|
+
smo_options = dict(self.smo_options or {})
|
|
430
|
+
warm: list[Array | None] = [None] * len(folds) if self.warm_start else None
|
|
431
|
+
|
|
432
|
+
def run_stage(blocks: Blocks, theta0: Array) -> Array:
|
|
433
|
+
def fun(theta: Array) -> tuple[float, Array]:
|
|
434
|
+
if self.time_limit is not None and time.perf_counter() - start > self.time_limit:
|
|
435
|
+
raise _Timeout
|
|
436
|
+
res = cv_error_and_gradient(
|
|
437
|
+
theta,
|
|
438
|
+
x,
|
|
439
|
+
y,
|
|
440
|
+
w,
|
|
441
|
+
folds,
|
|
442
|
+
loss,
|
|
443
|
+
validation,
|
|
444
|
+
blocks,
|
|
445
|
+
self.max_asum,
|
|
446
|
+
smo_options,
|
|
447
|
+
self.newton_options,
|
|
448
|
+
warm,
|
|
449
|
+
)
|
|
450
|
+
history.append(
|
|
451
|
+
{
|
|
452
|
+
"lam": float(np.exp(theta[0])),
|
|
453
|
+
"scaling": np.exp(theta[1:]),
|
|
454
|
+
"error": res.error,
|
|
455
|
+
"gradient_norm": float(np.linalg.norm(res.gradient)),
|
|
456
|
+
"n_support": float(np.mean([f.n_support for f in res.folds])),
|
|
457
|
+
"time": time.perf_counter() - start,
|
|
458
|
+
"stage": "features"
|
|
459
|
+
if blocks is not None and self.blocks is not None
|
|
460
|
+
else "global",
|
|
461
|
+
}
|
|
462
|
+
)
|
|
463
|
+
if self.verbose:
|
|
464
|
+
h = history[-1]
|
|
465
|
+
print(
|
|
466
|
+
f"[{len(history):3d}] lam = {h['lam']:.3e}, mean scaling = "
|
|
467
|
+
f"{np.mean(h['scaling']):.3e}, cv error = {h['error']:.6f}"
|
|
468
|
+
)
|
|
469
|
+
return res.error, res.gradient
|
|
470
|
+
|
|
471
|
+
bounds = [tuple(lam_bounds)] + [tuple(s_bounds)] * (theta0.size - 1)
|
|
472
|
+
best_before = len(history)
|
|
473
|
+
try:
|
|
474
|
+
result = minimize(
|
|
475
|
+
fun,
|
|
476
|
+
theta0,
|
|
477
|
+
jac=True,
|
|
478
|
+
method="L-BFGS-B",
|
|
479
|
+
bounds=bounds,
|
|
480
|
+
options={"maxiter": self.max_iter, "ftol": self.tol, "gtol": self.tol},
|
|
481
|
+
)
|
|
482
|
+
self.optimize_result_ = result
|
|
483
|
+
self.n_iter_ = getattr(self, "n_iter_", 0) + int(result.nit)
|
|
484
|
+
except _Timeout:
|
|
485
|
+
self.optimize_result_ = None
|
|
486
|
+
stage = history[best_before:]
|
|
487
|
+
if not stage:
|
|
488
|
+
return theta0
|
|
489
|
+
best = min(stage, key=lambda h: h["error"])
|
|
490
|
+
return np.log(np.concatenate(([best["lam"]], best["scaling"])))
|
|
491
|
+
|
|
492
|
+
self.n_iter_ = 0
|
|
493
|
+
blocks = resolve_blocks(self.blocks, x.shape[1])
|
|
494
|
+
s0 = auto_scaling(x) if self.scaling_init == "auto" else float(self.scaling_init)
|
|
495
|
+
theta = np.log([self.lam_init, s0])
|
|
496
|
+
if blocks is not None and self.two_stage:
|
|
497
|
+
# first a common scaling for all blocks, then individual ones
|
|
498
|
+
theta = run_stage(None, theta)
|
|
499
|
+
if blocks is not None:
|
|
500
|
+
theta = np.concatenate(([theta[0]], np.full(len(blocks), theta[1])))
|
|
501
|
+
theta = run_stage(blocks, theta)
|
|
502
|
+
else:
|
|
503
|
+
theta = run_stage(None, theta)
|
|
504
|
+
self.lam_ = float(np.exp(theta[0]))
|
|
505
|
+
self.scaling_opt_ = np.exp(theta[1:])
|
|
506
|
+
self.blocks_opt_ = blocks
|
|
507
|
+
self.history_ = history
|
|
508
|
+
self.cv_error_ = min(h["error"] for h in history) if history else np.nan
|
|
509
|
+
self.time_ = time.perf_counter() - start
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
_OPT_DOC = """
|
|
513
|
+
lam_init : float
|
|
514
|
+
Initial value of ``lambda``.
|
|
515
|
+
scaling_init : float or "auto"
|
|
516
|
+
Initial scaling (shared by all blocks).
|
|
517
|
+
blocks : None, "features" or list of lists
|
|
518
|
+
Feature blocks with individual scalings (``"features"``: ARD kernel).
|
|
519
|
+
two_stage : bool
|
|
520
|
+
With blocks, first optimise one common scaling, then the individual ones.
|
|
521
|
+
cv : int or iterable of (train, validation) index arrays
|
|
522
|
+
Folds of the cross-validation error.
|
|
523
|
+
lam_bounds, scaling_bounds : tuple of float
|
|
524
|
+
Box constraints of the hyperparameters.
|
|
525
|
+
time_limit : float, optional
|
|
526
|
+
Wall-clock limit in seconds; the best parameters found so far are used.
|
|
527
|
+
max_iter, tol : int, float
|
|
528
|
+
Options of L-BFGS-B.
|
|
529
|
+
random_state : int, optional
|
|
530
|
+
Seed of the fold shuffling.
|
|
531
|
+
verbose : int
|
|
532
|
+
Print progress.
|
|
533
|
+
warm_start : bool
|
|
534
|
+
Start SMO at the solution of the previous evaluation (per fold).
|
|
535
|
+
|
|
536
|
+
Attributes
|
|
537
|
+
----------
|
|
538
|
+
lam_, scaling_opt_ : float, ndarray
|
|
539
|
+
Optimised hyperparameters.
|
|
540
|
+
history_ : list of dict
|
|
541
|
+
All evaluations of the cross-validation error.
|
|
542
|
+
best_estimator_ : estimator
|
|
543
|
+
Model refitted on all data with the optimised hyperparameters.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
class OptimizedSVC(_OptimizedMixin, SmoothSVC):
|
|
548
|
+
__doc__ = (
|
|
549
|
+
""":class:`SmoothSVC` whose ``lam`` and kernel scalings are optimised by minimising a
|
|
550
|
+
smoothed cross-validation hinge error with exact gradients.
|
|
551
|
+
|
|
552
|
+
Parameters
|
|
553
|
+
----------"""
|
|
554
|
+
+ _OPT_DOC
|
|
555
|
+
+ "\n Remaining parameters: see :class:`SmoothSVC`.\n"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def __init__(
|
|
559
|
+
self,
|
|
560
|
+
*,
|
|
561
|
+
lam_init: float = 1e-2,
|
|
562
|
+
scaling_init: float | str = "auto",
|
|
563
|
+
blocks: Any = None,
|
|
564
|
+
two_stage: bool = True,
|
|
565
|
+
cv: Any = 5,
|
|
566
|
+
lam_bounds: tuple[float, float] = (1e-6, 1.0),
|
|
567
|
+
scaling_bounds: tuple[float, float] = (1e-4, 1e2),
|
|
568
|
+
time_limit: float | None = None,
|
|
569
|
+
max_iter: int = 100,
|
|
570
|
+
tol: float = 1e-6,
|
|
571
|
+
random_state: int | None = 0,
|
|
572
|
+
verbose: int = 0,
|
|
573
|
+
warm_start: bool = True,
|
|
574
|
+
smoothing: float = 0.5,
|
|
575
|
+
degree: str = "2",
|
|
576
|
+
shift: float = 1.0,
|
|
577
|
+
max_asum: float | None = None,
|
|
578
|
+
class_weight: Any = None,
|
|
579
|
+
validation_smoothing: float = 0.5,
|
|
580
|
+
smo_options: dict[str, Any] | None = None,
|
|
581
|
+
newton_options: dict[str, Any] | None = None,
|
|
582
|
+
) -> None:
|
|
583
|
+
self.lam_init = lam_init
|
|
584
|
+
self.scaling_init = scaling_init
|
|
585
|
+
self.blocks = blocks
|
|
586
|
+
self.two_stage = two_stage
|
|
587
|
+
self.cv = cv
|
|
588
|
+
self.lam_bounds = lam_bounds
|
|
589
|
+
self.scaling_bounds = scaling_bounds
|
|
590
|
+
self.time_limit = time_limit
|
|
591
|
+
self.max_iter = max_iter
|
|
592
|
+
self.tol = tol
|
|
593
|
+
self.random_state = random_state
|
|
594
|
+
self.verbose = verbose
|
|
595
|
+
self.warm_start = warm_start
|
|
596
|
+
self.smoothing = smoothing
|
|
597
|
+
self.degree = degree
|
|
598
|
+
self.shift = shift
|
|
599
|
+
self.max_asum = max_asum
|
|
600
|
+
self.class_weight = class_weight
|
|
601
|
+
self.validation_smoothing = validation_smoothing
|
|
602
|
+
self.smo_options = smo_options
|
|
603
|
+
self.newton_options = newton_options
|
|
604
|
+
|
|
605
|
+
@property
|
|
606
|
+
def lam(self) -> float: # used by the fixed-parameter base class
|
|
607
|
+
return getattr(self, "lam_", self.lam_init)
|
|
608
|
+
|
|
609
|
+
@property
|
|
610
|
+
def scaling(self) -> Any:
|
|
611
|
+
return getattr(self, "scaling_opt_", self.scaling_init)
|
|
612
|
+
|
|
613
|
+
def _validation(self) -> ValidationLoss:
|
|
614
|
+
return SmoothHingeValidation(
|
|
615
|
+
self.shift, self.validation_smoothing, "6", balanced=self.class_weight == "balanced"
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def fit(
|
|
619
|
+
self, X: ArrayLike, y: ArrayLike, sample_weight: ArrayLike | None = None
|
|
620
|
+
) -> OptimizedSVC:
|
|
621
|
+
for name in ("lam_", "scaling_opt_"):
|
|
622
|
+
self.__dict__.pop(name, None)
|
|
623
|
+
x, y = validate_data(self, X, y, dtype=np.float64)
|
|
624
|
+
if _fit_one_vs_rest(self, x, y, sample_weight):
|
|
625
|
+
return self
|
|
626
|
+
labels, w = _binary_labels(self, y, x, sample_weight)
|
|
627
|
+
self._optimize(x, labels, w)
|
|
628
|
+
self._fit_model(x, labels, w)
|
|
629
|
+
return self
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
class OptimizedSVR(_OptimizedMixin, SmoothSVR):
|
|
633
|
+
__doc__ = (
|
|
634
|
+
""":class:`SmoothSVR` whose ``lam`` and kernel scalings are optimised by minimising a
|
|
635
|
+
cross-validation error with exact gradients.
|
|
636
|
+
|
|
637
|
+
Parameters
|
|
638
|
+
----------"""
|
|
639
|
+
+ _OPT_DOC
|
|
640
|
+
+ """ validation : {"absolute", "squared", "poisson"} or None
|
|
641
|
+
Validation error; default: smoothed absolute error (``epsilon_insensitive``),
|
|
642
|
+
squared error (``squared``) or Poisson loss.
|
|
643
|
+
validation_smoothing : float
|
|
644
|
+
Smoothing of the absolute error (Huber-like, in normalised units).
|
|
645
|
+
|
|
646
|
+
Remaining parameters: see :class:`SmoothSVR`.
|
|
647
|
+
"""
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def __init__(
|
|
651
|
+
self,
|
|
652
|
+
*,
|
|
653
|
+
loss: str = "epsilon_insensitive",
|
|
654
|
+
epsilon: float = 0.1,
|
|
655
|
+
lam_init: float = 1e-2,
|
|
656
|
+
scaling_init: float | str = "auto",
|
|
657
|
+
blocks: Any = None,
|
|
658
|
+
two_stage: bool = True,
|
|
659
|
+
cv: Any = 5,
|
|
660
|
+
lam_bounds: tuple[float, float] = (1e-6, 1.0),
|
|
661
|
+
scaling_bounds: tuple[float, float] = (1e-4, 1e2),
|
|
662
|
+
time_limit: float | None = None,
|
|
663
|
+
max_iter: int = 100,
|
|
664
|
+
tol: float = 1e-6,
|
|
665
|
+
random_state: int | None = 0,
|
|
666
|
+
verbose: int = 0,
|
|
667
|
+
warm_start: bool = True,
|
|
668
|
+
smoothing: float = 0.5,
|
|
669
|
+
degree: str = "2",
|
|
670
|
+
max_asum: float | None = None,
|
|
671
|
+
normalize_y: bool = True,
|
|
672
|
+
validation: str | None = None,
|
|
673
|
+
validation_smoothing: float = 0.1,
|
|
674
|
+
smo_options: dict[str, Any] | None = None,
|
|
675
|
+
newton_options: dict[str, Any] | None = None,
|
|
676
|
+
) -> None:
|
|
677
|
+
self.loss = loss
|
|
678
|
+
self.epsilon = epsilon
|
|
679
|
+
self.lam_init = lam_init
|
|
680
|
+
self.scaling_init = scaling_init
|
|
681
|
+
self.blocks = blocks
|
|
682
|
+
self.two_stage = two_stage
|
|
683
|
+
self.cv = cv
|
|
684
|
+
self.lam_bounds = lam_bounds
|
|
685
|
+
self.scaling_bounds = scaling_bounds
|
|
686
|
+
self.time_limit = time_limit
|
|
687
|
+
self.max_iter = max_iter
|
|
688
|
+
self.tol = tol
|
|
689
|
+
self.random_state = random_state
|
|
690
|
+
self.verbose = verbose
|
|
691
|
+
self.warm_start = warm_start
|
|
692
|
+
self.smoothing = smoothing
|
|
693
|
+
self.degree = degree
|
|
694
|
+
self.max_asum = max_asum
|
|
695
|
+
self.normalize_y = normalize_y
|
|
696
|
+
self.validation = validation
|
|
697
|
+
self.validation_smoothing = validation_smoothing
|
|
698
|
+
self.smo_options = smo_options
|
|
699
|
+
self.newton_options = newton_options
|
|
700
|
+
|
|
701
|
+
@property
|
|
702
|
+
def lam(self) -> float:
|
|
703
|
+
return getattr(self, "lam_", self.lam_init)
|
|
704
|
+
|
|
705
|
+
@property
|
|
706
|
+
def scaling(self) -> Any:
|
|
707
|
+
return getattr(self, "scaling_opt_", self.scaling_init)
|
|
708
|
+
|
|
709
|
+
def _validation(self) -> ValidationLoss:
|
|
710
|
+
kind = self.validation
|
|
711
|
+
if kind is None:
|
|
712
|
+
kind = {"epsilon_insensitive": "absolute", "squared": "squared"}.get(
|
|
713
|
+
self.loss, "poisson"
|
|
714
|
+
)
|
|
715
|
+
if kind == "absolute":
|
|
716
|
+
return SmoothAbsoluteValidation(self.validation_smoothing)
|
|
717
|
+
if kind == "squared":
|
|
718
|
+
return SquaredValidation()
|
|
719
|
+
if kind == "poisson":
|
|
720
|
+
return PoissonValidation()
|
|
721
|
+
raise ValueError(f"unknown validation {kind!r}")
|
|
722
|
+
|
|
723
|
+
def fit(
|
|
724
|
+
self, X: ArrayLike, y: ArrayLike, sample_weight: ArrayLike | None = None
|
|
725
|
+
) -> OptimizedSVR:
|
|
726
|
+
for name in ("lam_", "scaling_opt_"):
|
|
727
|
+
self.__dict__.pop(name, None)
|
|
728
|
+
x, y = validate_data(self, X, y, dtype=np.float64, y_numeric=True)
|
|
729
|
+
w = _weights(sample_weight, x)
|
|
730
|
+
self._loss()
|
|
731
|
+
self.label_scaler_ = _LabelScaler(self._label_mode()).fit(y)
|
|
732
|
+
yn = self.label_scaler_.transform(y)
|
|
733
|
+
self._optimize(x, yn, w)
|
|
734
|
+
self._fit_model(x, yn, w)
|
|
735
|
+
return self
|