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,258 @@
|
|
|
1
|
+
"""Two-step training: SMO warm start (``smo_solver``) and Newton refinement (Strasdat, Ch. 8.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import smo_solver
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
|
|
13
|
+
from .linalg import solve_bordered
|
|
14
|
+
from .problem import TrainingProblem
|
|
15
|
+
|
|
16
|
+
Array = NDArray[np.float64]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConvergenceError(RuntimeError):
|
|
20
|
+
"""The Newton method could not find a descent step."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Solution:
|
|
25
|
+
"""Solution ``(a, b, c)`` of a training problem with ``ka = K a / lam``."""
|
|
26
|
+
|
|
27
|
+
a: Array
|
|
28
|
+
b: float
|
|
29
|
+
c: float
|
|
30
|
+
ka: Array
|
|
31
|
+
residual: float = np.nan
|
|
32
|
+
newton_steps: int = 0
|
|
33
|
+
smo_steps: int = 0
|
|
34
|
+
time_smo: float = 0.0
|
|
35
|
+
time_newton: float = 0.0
|
|
36
|
+
info: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def residual(prob: TrainingProblem, a: Array, b: float, c: float, ka: Array, use_c: bool) -> float:
|
|
40
|
+
"""Mean squared violation of the optimality conditions ``a + phi'(dec) = 0``, ``sum(a) = 0``
|
|
41
|
+
and (if the 1-norm constraint is active) ``s^T phi'(dec) + A = 0``."""
|
|
42
|
+
g = prob.loss.d1(prob.decision(b, c, ka), prob.terms)
|
|
43
|
+
res = float(np.mean((a + g) ** 2) + g.sum() ** 2)
|
|
44
|
+
if use_c:
|
|
45
|
+
res += float(g @ prob.sign + prob.max_asum) ** 2
|
|
46
|
+
return res
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _newton_direction(
|
|
50
|
+
prob: TrainingProblem, a: Array, g: Array, h: Array, use_c: bool
|
|
51
|
+
) -> tuple[Array, float, float]:
|
|
52
|
+
"""Semismooth Newton direction for ``a + phi'(K a / lam + b + s c) = 0`` and constraints."""
|
|
53
|
+
lam = prob.lam
|
|
54
|
+
p = h > 0.0
|
|
55
|
+
z = ~p
|
|
56
|
+
da = np.zeros_like(a)
|
|
57
|
+
da[z] = a[z] + g[z] # terms with vanishing curvature: a_Z = -phi'_Z after the step
|
|
58
|
+
k_pz_dz = prob.kernel(p, z) @ da[z] / lam if z.any() and p.any() else np.zeros(p.sum())
|
|
59
|
+
q = prob.kernel(p, p) / lam + np.diag(1.0 / h[p])
|
|
60
|
+
rhs = (a[p] + g[p]) / h[p] - k_pz_dz
|
|
61
|
+
cols = [np.ones(p.sum())]
|
|
62
|
+
rhs0 = [a[p].sum() - g[z].sum()]
|
|
63
|
+
if use_c:
|
|
64
|
+
s = prob.sign
|
|
65
|
+
cols.append(s[p])
|
|
66
|
+
rhs0.append(a @ s - prob.max_asum - s[z] @ da[z])
|
|
67
|
+
d_p, v = solve_bordered(q, np.stack(cols, axis=1), rhs, np.array(rhs0))
|
|
68
|
+
da[p] = d_p
|
|
69
|
+
return da, float(v[0]), float(v[1]) if use_c else 0.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def newton(
|
|
73
|
+
prob: TrainingProblem,
|
|
74
|
+
a: Array,
|
|
75
|
+
b: float,
|
|
76
|
+
c: float,
|
|
77
|
+
*,
|
|
78
|
+
tol: float = 1e-12,
|
|
79
|
+
max_steps: int = 100,
|
|
80
|
+
max_backtracks: int = 20,
|
|
81
|
+
sigma: float = 1e-4,
|
|
82
|
+
beta: float = 0.5,
|
|
83
|
+
verbose: int = 0,
|
|
84
|
+
) -> Solution:
|
|
85
|
+
"""Damped semismooth Newton method for the (smoothed) primal training problem.
|
|
86
|
+
|
|
87
|
+
Requires a loss with (piecewise) second derivative. The 1-norm constraint is treated as
|
|
88
|
+
active iff ``c > 0`` at the starting point.
|
|
89
|
+
"""
|
|
90
|
+
use_c = prob.max_asum is not None and c > 0.0
|
|
91
|
+
if not use_c:
|
|
92
|
+
c = 0.0
|
|
93
|
+
a = np.array(a, dtype=np.float64)
|
|
94
|
+
ka = prob.ka(a)
|
|
95
|
+
obj = prob.objective(a, b, c, ka)
|
|
96
|
+
for step in range(max_steps + 1):
|
|
97
|
+
dec = prob.decision(b, c, ka)
|
|
98
|
+
g = prob.loss.d1(dec, prob.terms)
|
|
99
|
+
r = a + g
|
|
100
|
+
res = float(np.mean(r**2) + g.sum() ** 2)
|
|
101
|
+
grad_c = float(g @ prob.sign + prob.max_asum) if use_c else 0.0
|
|
102
|
+
res += grad_c**2
|
|
103
|
+
if verbose:
|
|
104
|
+
print(f"[newton {step:3d}] f = {obj:.10g}, residual = {res:.3e}")
|
|
105
|
+
if res < tol or step == max_steps:
|
|
106
|
+
break
|
|
107
|
+
# gradient of the objective w.r.t. (a, b, c): (K r / lam, sum g, s^T g + A)
|
|
108
|
+
grad_b = float(g.sum())
|
|
109
|
+
h = prob.loss.d2(dec, prob.terms)
|
|
110
|
+
directions = []
|
|
111
|
+
try:
|
|
112
|
+
directions.append(_newton_direction(prob, a, g, h, use_c))
|
|
113
|
+
except np.linalg.LinAlgError:
|
|
114
|
+
pass
|
|
115
|
+
# preconditioned gradient as fallback
|
|
116
|
+
directions.append((r, prob.lam * grad_b, prob.lam * grad_c))
|
|
117
|
+
for da, db, dc in directions:
|
|
118
|
+
kda = prob.ka(da)
|
|
119
|
+
slope = float(r @ kda) + db * grad_b + dc * grad_c
|
|
120
|
+
if slope <= 0.0:
|
|
121
|
+
continue
|
|
122
|
+
t = 1.0
|
|
123
|
+
for _ in range(max_backtracks):
|
|
124
|
+
a_new, b_new, c_new = a - t * da, b - t * db, c - t * dc
|
|
125
|
+
ka_new = ka - t * kda
|
|
126
|
+
obj_new = prob.objective(a_new, b_new, c_new, ka_new)
|
|
127
|
+
if obj_new <= obj - sigma * t * slope + 1e-15 * abs(obj):
|
|
128
|
+
break
|
|
129
|
+
t *= beta
|
|
130
|
+
else:
|
|
131
|
+
continue
|
|
132
|
+
a, b, c, ka, obj = a_new, b_new, c_new, ka_new, obj_new
|
|
133
|
+
break
|
|
134
|
+
else:
|
|
135
|
+
if res < 1e3 * tol:
|
|
136
|
+
break
|
|
137
|
+
raise ConvergenceError(f"no descent step found (residual {res:.3e})")
|
|
138
|
+
# recompute ka from scratch to remove accumulated rounding errors
|
|
139
|
+
ka = prob.ka(a)
|
|
140
|
+
return Solution(
|
|
141
|
+
a, float(b), float(c), ka, residual(prob, a, b, c, ka, use_c), newton_steps=step
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
#: SMO tolerance used before a Newton refinement
|
|
146
|
+
ROUGH_SMO_TOL = 0.1
|
|
147
|
+
|
|
148
|
+
_SMO_OPTIONS = {
|
|
149
|
+
"tol",
|
|
150
|
+
"max_steps",
|
|
151
|
+
"time_limit",
|
|
152
|
+
"working_set",
|
|
153
|
+
"two_sided",
|
|
154
|
+
"shrinking",
|
|
155
|
+
"shrinking_period",
|
|
156
|
+
"cache_size_mb",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def smo_warm_start(
|
|
161
|
+
prob: TrainingProblem, alpha0: Array | None = None, **options: Any
|
|
162
|
+
) -> tuple[Array, float, float, int]:
|
|
163
|
+
"""Solves the dual with quadratic smoothing by SMO (``smo_solver``); zeros if not applicable.
|
|
164
|
+
|
|
165
|
+
``alpha0`` (e.g. the solution for neighbouring hyperparameters) is used as starting point
|
|
166
|
+
if it is feasible.
|
|
167
|
+
"""
|
|
168
|
+
unknown = set(options) - _SMO_OPTIONS
|
|
169
|
+
if unknown:
|
|
170
|
+
raise ValueError(f"unknown SMO options: {sorted(unknown)}")
|
|
171
|
+
loss = prob.loss
|
|
172
|
+
if loss.smo_kind is None:
|
|
173
|
+
return np.zeros(prob.size), 0.0, 0.0, 0
|
|
174
|
+
common = dict(gamma=1.0, weights=prob.w, smoothing=loss.smoothing, max_asum=prob.max_asum)
|
|
175
|
+
|
|
176
|
+
def run(start: Array | None) -> Any:
|
|
177
|
+
if loss.smo_kind == "classification":
|
|
178
|
+
return smo_solver.solve_classification(
|
|
179
|
+
prob.x, prob.y, prob.lam, shift=loss.shift, alpha0=start, **common, **options
|
|
180
|
+
)
|
|
181
|
+
return smo_solver.solve_regression(
|
|
182
|
+
prob.x, prob.y, prob.lam, epsilon=loss.epsilon, alpha0=start, **common, **options
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
res = None
|
|
186
|
+
if alpha0 is not None:
|
|
187
|
+
start = _feasible_start(prob, alpha0)
|
|
188
|
+
if start is not None:
|
|
189
|
+
try:
|
|
190
|
+
res = run(start)
|
|
191
|
+
except ValueError:
|
|
192
|
+
res = None
|
|
193
|
+
if res is None:
|
|
194
|
+
res = run(None)
|
|
195
|
+
return np.asarray(res.alpha), res.b, res.c, res.steps
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _feasible_start(prob: TrainingProblem, alpha0: Array) -> Array | None:
|
|
199
|
+
"""Projects a previous solution onto the box and repairs ``sum(a) = 0``; ``None`` if this
|
|
200
|
+
fails (e.g. different problem size)."""
|
|
201
|
+
if alpha0.shape != (prob.size,):
|
|
202
|
+
return None
|
|
203
|
+
t = prob.terms
|
|
204
|
+
lo = np.where(t.sign > 0, 0.0, -t.w) if prob.loss.signed else np.full(prob.size, -np.inf)
|
|
205
|
+
hi = np.where(t.sign > 0, t.w, 0.0) if prob.loss.signed else np.full(prob.size, np.inf)
|
|
206
|
+
a = np.clip(alpha0, lo, hi)
|
|
207
|
+
if prob.max_asum is not None and (t.sign * a).sum() > prob.max_asum:
|
|
208
|
+
a = a * (prob.max_asum / (t.sign * a).sum()) * (1 - 1e-12)
|
|
209
|
+
# distribute the violation of sum(a) = 0 over variables with room in that direction
|
|
210
|
+
excess = a.sum()
|
|
211
|
+
room = (a - lo) if excess > 0 else (hi - a)
|
|
212
|
+
total = room.sum()
|
|
213
|
+
if not total > abs(excess):
|
|
214
|
+
return None
|
|
215
|
+
a = a - excess * room / total
|
|
216
|
+
if prob.max_asum is not None and (t.sign * a).sum() > prob.max_asum:
|
|
217
|
+
return None
|
|
218
|
+
return np.ascontiguousarray(a)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def train(
|
|
222
|
+
prob: TrainingProblem,
|
|
223
|
+
smo_options: dict[str, Any] | None = None,
|
|
224
|
+
newton_options: dict[str, Any] | None = None,
|
|
225
|
+
alpha0: Array | None = None,
|
|
226
|
+
) -> Solution:
|
|
227
|
+
"""Two-step training: SMO warm start followed by Newton refinement.
|
|
228
|
+
|
|
229
|
+
For non-smooth losses (``smoothing = 0``) the SMO solution is already exact; for losses
|
|
230
|
+
without SMO formulation (squared, Poisson) Newton starts at zero.
|
|
231
|
+
"""
|
|
232
|
+
smo_options = dict(smo_options or {})
|
|
233
|
+
smooth = getattr(prob.loss, "smoothing", 1.0) > 0.0
|
|
234
|
+
if smooth:
|
|
235
|
+
# Newton refines the solution, so a rough SMO solution suffices as starting point
|
|
236
|
+
smo_options.setdefault("tol", ROUGH_SMO_TOL)
|
|
237
|
+
t0 = time.perf_counter()
|
|
238
|
+
a, b, c, smo_steps = smo_warm_start(prob, alpha0, **smo_options)
|
|
239
|
+
t_smo = time.perf_counter() - t0
|
|
240
|
+
t0 = time.perf_counter()
|
|
241
|
+
if not smooth:
|
|
242
|
+
sol = Solution(a, b, c, prob.ka(a))
|
|
243
|
+
else:
|
|
244
|
+
try:
|
|
245
|
+
sol = newton(prob, a, b, c, **(newton_options or {}))
|
|
246
|
+
if prob.max_asum is not None and sol.c < 0.0:
|
|
247
|
+
# the constraint is inactive at the solution: solve without it
|
|
248
|
+
sol = newton(prob, sol.a, sol.b, 0.0, **(newton_options or {}))
|
|
249
|
+
except ConvergenceError:
|
|
250
|
+
# fall back to an accurate SMO solution (exact for quadratic smoothing)
|
|
251
|
+
smo_options["tol"] = 1e-6
|
|
252
|
+
a, b, c, more = smo_warm_start(prob, a, **smo_options)
|
|
253
|
+
smo_steps += more
|
|
254
|
+
sol = Solution(a, b, c, prob.ka(a), info={"newton": "failed"})
|
|
255
|
+
sol.smo_steps = smo_steps
|
|
256
|
+
sol.time_smo = t_smo
|
|
257
|
+
sol.time_newton = time.perf_counter() - t0
|
|
258
|
+
return sol
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Validation error measures ``E(f) = sum_v L_v(f_v)`` for the upper level problem (Sec. 9.1.4).
|
|
2
|
+
|
|
3
|
+
All measures are differentiable so that the hyperparameter objective has a gradient.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
from scipy.special import gammaln
|
|
13
|
+
|
|
14
|
+
from .smooth_max import Degree, smooth_max
|
|
15
|
+
|
|
16
|
+
Array = NDArray[np.float64]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationLoss:
|
|
20
|
+
"""Returns the summed loss and its gradient w.r.t. the predictions."""
|
|
21
|
+
|
|
22
|
+
def __call__(self, f: Array, y: Array) -> tuple[float, Array]:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SmoothHingeValidation(ValidationLoss):
|
|
28
|
+
"""``sum_v w_v m_delta(shift - y_v f_v)``; ``balanced`` weights both classes equally."""
|
|
29
|
+
|
|
30
|
+
shift: float = 1.0
|
|
31
|
+
smoothing: float = 0.5
|
|
32
|
+
degree: Degree = "6"
|
|
33
|
+
balanced: bool = False
|
|
34
|
+
|
|
35
|
+
def __call__(self, f: Array, y: Array) -> tuple[float, Array]:
|
|
36
|
+
m, dm, _ = smooth_max(self.smoothing, self.degree)
|
|
37
|
+
w = np.ones_like(y)
|
|
38
|
+
if self.balanced:
|
|
39
|
+
pos = y > 0
|
|
40
|
+
frac = pos.mean()
|
|
41
|
+
if 0 < frac < 1:
|
|
42
|
+
w = 0.5 / np.where(pos, frac, 1.0 - frac)
|
|
43
|
+
arg = self.shift - y * f
|
|
44
|
+
return float(w @ m(arg)), -w * y * dm(arg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SmoothAbsoluteValidation(ValidationLoss):
|
|
49
|
+
"""Smoothed absolute error ``sum_v m_delta(f_v - y_v) + m_delta(y_v - f_v)`` (Huber-like)."""
|
|
50
|
+
|
|
51
|
+
smoothing: float = 0.1
|
|
52
|
+
degree: Degree = "2"
|
|
53
|
+
|
|
54
|
+
def __call__(self, f: Array, y: Array) -> tuple[float, Array]:
|
|
55
|
+
m, dm, _ = smooth_max(self.smoothing, self.degree)
|
|
56
|
+
e = f - y
|
|
57
|
+
return float(m(e).sum() + m(-e).sum()), dm(e) - dm(-e)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class SquaredValidation(ValidationLoss):
|
|
62
|
+
"""``1/2 sum_v (f_v - y_v)^2``."""
|
|
63
|
+
|
|
64
|
+
def __call__(self, f: Array, y: Array) -> tuple[float, Array]:
|
|
65
|
+
e = f - y
|
|
66
|
+
return 0.5 * float(e @ e), e
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class PoissonValidation(ValidationLoss):
|
|
71
|
+
"""Poisson deviance-type loss ``sum_v exp(f_v) - y_v f_v + log(y_v!)``."""
|
|
72
|
+
|
|
73
|
+
def __call__(self, f: Array, y: Array) -> tuple[float, Array]:
|
|
74
|
+
with np.errstate(over="ignore"):
|
|
75
|
+
ef = np.exp(f)
|
|
76
|
+
return float(ef.sum() - f @ y + gammaln(y + 1).sum()), ef - y
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: optimized-svm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Support vector machines with hyperparameters optimized by exact gradients (implicit function theorem)
|
|
5
|
+
Project-URL: Homepage, https://wotzlaff.github.io/svm-site/
|
|
6
|
+
Project-URL: Documentation, https://wotzlaff.github.io/optimized-svm/
|
|
7
|
+
Project-URL: Repository, https://github.com/wotzlaff/optimized-svm
|
|
8
|
+
Author-email: Nico Strasdat <nstrasdat@gmail.com>
|
|
9
|
+
License-Expression: BSD-3-Clause
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bilevel optimization,hyperparameter optimization,kernel methods,svm
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: numpy>=1.23
|
|
19
|
+
Requires-Dist: scikit-learn>=1.6
|
|
20
|
+
Requires-Dist: scipy>=1.10
|
|
21
|
+
Requires-Dist: smo-solver>=0.1
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# optimized-svm
|
|
25
|
+
|
|
26
|
+
**Support vector machines whose hyperparameters are tuned by exact gradients instead of grid search.**
|
|
27
|
+
|
|
28
|
+
`optimized-svm` treats hyperparameter selection as a bilevel optimization problem: the upper level
|
|
29
|
+
minimizes a (smoothed) cross-validation error, the lower level is the SVM training problem. Smoothing
|
|
30
|
+
the loss makes the training solution differentiable in the hyperparameters, so the gradient of the
|
|
31
|
+
CV error follows from the implicit function theorem and a quasi-Newton method (L-BFGS-B) can be used.
|
|
32
|
+
This works for the regularization parameter **and** for per-feature kernel scalings (automatic
|
|
33
|
+
relevance determination), where grid search is hopeless.
|
|
34
|
+
|
|
35
|
+
The methods are developed in
|
|
36
|
+
|
|
37
|
+
> N. Strasdat, *Duality, Derivative-Based Training Methods and Hyperparameter Optimization for
|
|
38
|
+
> Support Vector Machines*, Dissertation, Technische Universität Dresden, 2023.
|
|
39
|
+
> [urn:nbn:de:bsz:14-qucosa2-874731](https://nbn-resolving.org/urn:nbn:de:bsz:14-qucosa2-874731)
|
|
40
|
+
|
|
41
|
+
Training uses the Rust SMO solver [`smo-solver`](https://github.com/wotzlaff/smo-solver) as a warm start
|
|
42
|
+
followed by a semismooth Newton method on the smoothed primal problem.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- scikit-learn estimators: `OptimizedSVC`, `OptimizedSVR` (tuned) and `SmoothSVC`, `SmoothSVR`
|
|
47
|
+
(fixed hyperparameters); binary and one-vs-rest classification
|
|
48
|
+
- losses: smoothed hinge, smoothed ε-insensitive, squared (LS-SVM), Poisson; smoothing of degree
|
|
49
|
+
2, 3 or 6; sample/class weights; optional bound on `Σ|α|` (generalized ν-SVM)
|
|
50
|
+
- exact hypergradients via one adjoint linear system per fold (verified against finite differences)
|
|
51
|
+
- ARD kernels (`blocks="features"`) with two-stage optimization, warm starts, time limits,
|
|
52
|
+
full optimization history
|
|
53
|
+
- `decision_gradient()` for local feature sensitivities
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install optimized-svm
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
During development (sibling checkout of `smo-solver`, Rust toolchain required):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/wotzlaff/smo-solver
|
|
65
|
+
git clone https://github.com/wotzlaff/optimized-svm && cd optimized-svm
|
|
66
|
+
uv sync --group dev
|
|
67
|
+
uv run pytest
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Quick start
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from sklearn.datasets import make_friedman1
|
|
74
|
+
from optimized_svm import OptimizedSVR
|
|
75
|
+
|
|
76
|
+
x, y = make_friedman1(600, noise=0.5, random_state=0)
|
|
77
|
+
model = OptimizedSVR(blocks="features").fit(x, y) # one kernel scaling per feature
|
|
78
|
+
|
|
79
|
+
model.lam_ # optimized regularization parameter
|
|
80
|
+
model.scaling_opt_ # optimized scalings: features 5..9 (pure noise) are switched off
|
|
81
|
+
model.history_ # every evaluation of the CV error
|
|
82
|
+
model.predict(x[:5])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
On a 1000-sample classification task (`examples/quickstart.py`) the optimizer needs 16 CV
|
|
86
|
+
evaluations (≈10 s) versus 143 grid points (≈20 s) for `GridSearchCV` with `sklearn.svm.SVC`,
|
|
87
|
+
with comparable test accuracy (0.844 vs. 0.856).
|
|
88
|
+
|
|
89
|
+
## How it works
|
|
90
|
+
|
|
91
|
+
| step | method | thesis |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| training, warm start | SMO on the dual with quadratic smoothing (`smo-solver`) | Ch. 7 |
|
|
94
|
+
| training, refinement | semismooth Newton on `a + φ'(Ka/λ + b + sc) = 0` | Ch. 8.4 |
|
|
95
|
+
| CV error gradient | implicit function theorem, adjoint system per fold | Ch. 9.2 |
|
|
96
|
+
| hyperparameter search | L-BFGS-B over `(log λ, log s₁, …, log s_K)` | Ch. 9.1, 10.4 |
|
|
97
|
+
|
|
98
|
+
## Reproducing the thesis
|
|
99
|
+
|
|
100
|
+
`benchmarks/thesis_regression.py` repeats the protocol of Table 10.4.1 (LIBSVM regression data,
|
|
101
|
+
30 random 80/20 splits, ε-SVR with cubic smoothing and LS-SVM):
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python benchmarks/thesis_regression.py --datasets pyrim triazines mpg housing --splits 30
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Documentation
|
|
108
|
+
|
|
109
|
+
Full documentation including the mathematical background: <https://wotzlaff.github.io/optimized-svm/>.
|
|
110
|
+
Build locally with `uv run --group docs mkdocs serve`.
|
|
111
|
+
|
|
112
|
+
## Citation
|
|
113
|
+
|
|
114
|
+
If you use this software, please cite the dissertation:
|
|
115
|
+
|
|
116
|
+
```bibtex
|
|
117
|
+
@phdthesis{strasdat2023,
|
|
118
|
+
author = {Nico Strasdat},
|
|
119
|
+
title = {Duality, Derivative-Based Training Methods and
|
|
120
|
+
Hyperparameter Optimization for Support Vector Machines},
|
|
121
|
+
school = {Technische Universit{\"a}t Dresden},
|
|
122
|
+
type = {Dissertation},
|
|
123
|
+
year = {2023},
|
|
124
|
+
url = {https://nbn-resolving.org/urn:nbn:de:bsz:14-qucosa2-874731},
|
|
125
|
+
note = {urn:nbn:de:bsz:14-qucosa2-874731}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
BSD-3-Clause. The 2022 version (Nim backend) is preserved in the git tag `legacy-2022`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
optimized_svm/__init__.py,sha256=P7uqJ31jT2DxWYp1vjG7A3ikMIq7Gx5VeYpwi3I-mr4,1052
|
|
2
|
+
optimized_svm/estimators.py,sha256=RrwaFgIV29pcYzDaMLeY3_q3WiIejj7bX619w0N_1V4,27355
|
|
3
|
+
optimized_svm/hypergradient.py,sha256=Le2MNLtJz-q70SHW-KuAHuuKjArCYOkmIP9nQCGNq4U,5187
|
|
4
|
+
optimized_svm/kernels.py,sha256=DmaZUZTwhrTew-OPQVBAPb-FDw6zgsqFOmZSUz4dpE8,2439
|
|
5
|
+
optimized_svm/linalg.py,sha256=F324x9fuJic2EAEWgU3OXreeqMsdUbfX99zzSjN8AlA,734
|
|
6
|
+
optimized_svm/losses.py,sha256=5MzjNkyq4X2zeI-iE1UFFd3tuKwiyPlgNiC1wi8XEfQ,5651
|
|
7
|
+
optimized_svm/problem.py,sha256=plrjiZ4n4UfWUOGZ_7IPhIfJ8po8nVVXBhrShAeTbFM,3699
|
|
8
|
+
optimized_svm/smooth_max.py,sha256=l3Kwt9Gj68l-WAYVECW7yFX4PE_ODyPuOZUR79e5hDg,2966
|
|
9
|
+
optimized_svm/training.py,sha256=UJDhaldrKmVe8QjBsFs5wbV48YNBGgHermKfvoFqkoc,9141
|
|
10
|
+
optimized_svm/validation.py,sha256=SILMorbg1s3cDEMm8Jkuw8bGXJeahs2w2BMm2ryTkD0,2306
|
|
11
|
+
optimized_svm-0.1.0.dist-info/METADATA,sha256=wvknVL2hnhYEjPQXE_lz00nlhhYwu5Ybmwos7YM7YYw,5280
|
|
12
|
+
optimized_svm-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
optimized_svm-0.1.0.dist-info/licenses/LICENSE,sha256=xg1gX4NDaA0vqOtx1QTqjFg-r-edA4M823f1r876NK0,1505
|
|
14
|
+
optimized_svm-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026, Nico Strasdat
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|