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.
@@ -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