ferrolearn_linear/one_class_svm.rs
1//! One-Class SVM for novelty detection.
2//!
3//! This module provides [`OneClassSVM`], which learns a decision boundary
4//! around the training data and classifies new points as inliers (`+1`) or
5//! outliers (`-1`).
6//!
7//! # Algorithm
8//!
9//! One-Class SVM trains a standard binary SVC where all training data is
10//! assigned label `+1` and a synthetic origin point is assigned label `-1`.
11//! The decision function then separates the data from the origin in kernel
12//! feature space. Points with positive decision values are inliers; negative
13//! are outliers.
14//!
15//! # Examples
16//!
17//! ```
18//! use ferrolearn_linear::one_class_svm::OneClassSVM;
19//! use ferrolearn_linear::svm::RbfKernel;
20//! use ferrolearn_core::{Fit, Predict};
21//! use ndarray::{array, Array2, Array1};
22//!
23//! let x_train = Array2::from_shape_vec((6, 2), vec![
24//! 1.0, 1.0, 1.5, 1.0, 1.0, 1.5,
25//! 1.2, 1.3, 1.3, 1.2, 1.1, 1.1,
26//! ]).unwrap();
27//!
28//! let model = OneClassSVM::<f64, RbfKernel<f64>>::new(RbfKernel::with_gamma(1.0));
29//! let fitted = model.fit(&x_train, &()).unwrap();
30//!
31//! // Most training data should be classified as inliers.
32//! let preds = fitted.predict(&x_train).unwrap();
33//! let inliers: usize = preds.iter().filter(|&&p| p == 1).count();
34//! assert!(inliers >= 4);
35//! ```
36//!
37//! ## REQ status
38//!
39//! Classification (R-DEFER-2): SHIPPED = impl + non-test production consumer +
40//! tests + green oracle verification; NOT-STARTED = open blocker `#`.
41//! `OneClassSVM`/`FittedOneClassSVM` are boundary estimator types re-exported at
42//! the crate root (`pub use one_class_svm::{…}` in `lib.rs`) — under S5/R-DEFER-1
43//! the consumer surface exists for the grandfathered public API. See
44//! `.design/linear/one_class_svm.md`.
45//!
46//! | REQ | Status | Evidence |
47//! |---|---|---|
48//! | REQ-1 (ONE_CLASS nu dual + nu validation) | SHIPPED | `fn fit in one_class_svm.rs` validates `nu ∈ (0,1]` (`InvalidParameter`) and solves the one-class dual `0≤α≤1/(n·ν), Σα=1`, rescaled to libsvm's `Σα=ν·n` convention (`let scale = F::one()/c; rho * scale`, `dual_coefs.push(alpha*scale)`). On a NON-DEGENERATE (unique-optimum) set the SMO recovers libsvm's EXACT decomposition — `support_`/`n_support_`/`dual_coef_`/`intercept_` match the live oracle within 1e-8 (pinned by `divergence_pin5_sv_decomposition_nondegenerate_646 in tests/divergence_one_class_svm.rs`: `support_ [0,1,2,4]`, `dual_coef_ [1,0.569,0.431,1]`, `intercept_ [-1.616]`, verified unique via perturbation). DEGENERACY BOUNDARY (documented, not a gap): on the symmetric toy set the optimal face is degenerate (margin points 1,4,5 satisfy `0.5·x₁=0.25·x₄+0.25·x₅` → identical `w`), so ferrolearn's deterministic WSS reaches a different but equally-optimal vertex (5 SVs vs libsvm's 4) — sanctioned α-decomposition non-uniqueness; the hyperplane/`decision_function`/`predict` are IDENTICAL (pin1/pin3 green). |
49//! | REQ-2 (kernels & gamma resolution) | SHIPPED | `fn fit in one_class_svm.rs` resolves the kernel against X at fit time via `let kernel = self.kernel.resolved_for_fit(x);` (mirroring `svm.rs`'s `SVC::fit`), used for ALL kernel evaluations in the SMO solve and stored on `FittedOneClassSVM` so decision_function/predict reuse the same gamma. `Gamma::Scale` (default) resolves to `1/(n_features·X.var())`, `Auto` to `1/n_features`, `Value` verbatim (`crate::svm::Kernel::resolved_for_fit`, `_base.py:236-243`). Pinned: `divergence_pin2_gamma_scale_default_647 in tests/divergence_one_class_svm.rs` — default `RbfKernel` (`Gamma::Scale`) on the 7×2 set gives `_gamma≈0.46578` and df matching the live `OneClassSVM(kernel='rbf',nu=0.5)` oracle `[0.022499,0.022633,0.000122,0.0,0.0,0.000387,-1.44231]` (R-CHAR-3, 1e-2). |
50//! | REQ-3 (fitted attributes + offset_) | SHIPPED | The libsvm-layout accessor surface now exists: `FittedOneClassSVM::{support,support_vectors,n_support,dual_coef,intercept,offset,coef} in one_class_svm.rs` — `support_` (SV indices via the new `sv_indices` field), `support_vectors_` shape `(n_SV,n_features)`, `n_support_` `vec![n_SV]` (length 1), `dual_coef_` shape `(1,n_SV)` (libsvm scale, raw α — no `α·y` flip, `Σ=ν·n`), `intercept_=[-rho]`, `offset_=rho=-intercept_` (`_classes.py:1767`), linear-only `coef_=dual_coef_@support_vectors_` (gated on `Kernel::is_linear`, else `None`, `_base.py:650-666`). The hyperplane attributes match the live oracle: `intercept_ [-0.01]`, `offset_ 0.01` (= `-intercept_`), `coef_ [[0.05,0.05]]`. Consumer: the crate-root re-export. Pinned by `test_one_class_svm_fitted_attributes_linear_oracle` (offset_/coef_/intercept_/shapes/the `offset_=-intercept_` identity + `dual_coef_` sum `=ν·n`) + `test_one_class_svm_coef_none_for_rbf` (rbf → `None`) in `one_class_svm.rs` (R-CHAR-3, 1e-2). The `support_`/`dual_coef_`/`n_support_` decomposition matches the oracle on NON-DEGENERATE (unique) optima (`divergence_pin5_sv_decomposition_nondegenerate_646`); on the symmetric toy set the decomposition is a sanctioned non-unique vertex (REQ-1's documented degeneracy boundary — same hyperplane). |
51//! | REQ-4 (decision_function / score_samples) | SHIPPED | `pub fn decision_function in one_class_svm.rs` returns `Array1<F>` `(n,)` = `Σ coef·K(sv,x) − rho` in libsvm scale (the #646 rescale: `let scale = F::one()/c; rho * scale`, `dual_coefs.push(alpha*scale)`, `svm.cpp:2834` `sum -= rho`). `pub fn score_samples in one_class_svm.rs` now returns `decision_function(X) + offset_` (`_classes.py:1801`). Pinned: `divergence_pin1_decision_function_scaling_646 in tests/divergence_one_class_svm.rs` — linear `nu=0.5` on the 7×2 set gives df `[-0.01,0.0,-0.01,-0.01,0.0,0.0,0.29]` matching the live oracle; `test_one_class_svm_score_samples_linear_oracle in one_class_svm.rs` pins `score_samples [0,0.01,0,0,0.01,0.01,0.3]` against the live oracle (R-CHAR-3, 1e-2). |
52//! | REQ-5 (predict +1/-1) | SHIPPED | `fn predict in one_class_svm.rs` returns `+1` (inlier) / `-1` (outlier); labels match the live oracle `[-1,1,-1,-1,1,1,1]` (pinned by `divergence_pin3_predict_labels_648 in tests/divergence_one_class_svm.rs`, R-CHAR-3). The boundary uses a `|rho|`-relative slack so on-margin points (`decision≈0` modulo float roundoff) take libsvm's observable label (`+1`), reproducing the oracle (R-DEV-3 observable contract); libsvm's exact `(sum>0)?+1:-1` (`svm.cpp:2837-2838`) differs only at a genuine `decision==0` (measure-zero / degenerate edge). |
53//! | REQ-6 (constructor params/defaults) | SHIPPED | `OneClassSVM::new` now mirrors sklearn's exact param surface defaults (`_classes.py:1712-1721`, live `inspect.signature`): `nu=0.5`, `tol=1e-3`, `cache_size=200` (was `1024`; accepted for parity, no kernel cache in this module), `max_iter=0` (was `10000`) = sklearn `max_iter=-1` ("no iteration limit"), and a new `pub shrinking: bool` field (default `true`) + `#[must_use] with_shrinking` — accepted for API parity, shrinking-invariant one-class optimum so DOES NOT alter the fit (no shrinking heuristic, R-DEV-7), mirroring `svm.rs`'s `SVC`/`SVR`. `fn fit`'s SMO loop treats `max_iter == 0` as unbounded (run to convergence) via the sentinel guard `if self.max_iter != 0 && iter >= self.max_iter { break; }` (same as `svm.rs`'s `smo_binary`/`smo_svr`); the KKT-gap break (`i_max_grad - j_min_grad < tol`) terminates the default-0 fit. R-DEV-7 design difference (preserved contract, NOT a gap): estimator-level `kernel`(string)/`degree`/`coef0` are the type parameter `K` set by construction, `gamma` resolution is REQ-2; `verbose`/`random_state` are unused (deterministic SMO). Pinned by `test_one_class_svm_default_params` (asserts `nu==0.5`, `tol==1e-3`, `max_iter==0`, `cache_size==200`, `shrinking==true` against the live `OneClassSVM.__init__` signature, R-DEV-2) + `test_one_class_svm_default_max_iter_converges` (default-0 fit converges, no infinite loop) + `test_one_class_svm_builder_pattern` (`with_shrinking`) in `one_class_svm.rs`. The 6 divergence pins use explicit `with_max_iter(1_000_000)` and stay green. |
54//! | REQ-7 (ferray substrate) | NOT-STARTED | open prereq blocker #652. `one_class_svm.rs` imports `ndarray::{Array1, Array2, ScalarOperand}`, not `ferray-core` (R-SUBSTRATE). |
55//! | REQ-8 (non-finite input rejected) | SHIPPED | `fn fit in one_class_svm.rs` rejects any NaN/+/-inf in X BEFORE the one-class SMO solve with `FerroError::InvalidParameter`, mirroring sklearn's `BaseLibSVM.fit` -> `_validate_data(X, y, …)` (`_base.py:190-197`, default `force_all_finite=True`) -> `ValueError("Input X contains NaN.")` / `"… contains infinity …"`. OneClassSVM is unsupervised (`Fit<Array2<F>, ()>`, X only — sklearn's `OneClassSVM.fit(X)` passes a synthetic all-ones `y`), so only X is checked; ferrolearn's `Fit::fit` has no `sample_weight` argument. `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (the 6 one-class divergence pins stay green). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): NaN/+inf/-inf in X all raise `ValueError` (`tests/divergence_svm_nonfinite.rs::ocs_*`). Non-test consumer: the existing `Fit::fit` consumer + the crate-root `pub use one_class_svm::{OneClassSVM, …}` re-export. (#2269) |
56
57use ferrolearn_core::error::FerroError;
58use ferrolearn_core::traits::{Fit, Predict};
59use ndarray::{Array1, Array2, ScalarOperand};
60use num_traits::Float;
61
62use crate::svm::Kernel;
63
64// ---------------------------------------------------------------------------
65// OneClassSVM
66// ---------------------------------------------------------------------------
67
68/// One-Class SVM for novelty detection.
69///
70/// Learns a decision boundary around the training data. New points are
71/// classified as inliers (`+1`) or outliers (`-1`).
72///
73/// # Type Parameters
74///
75/// - `F`: The floating-point type (`f32` or `f64`).
76/// - `K`: The kernel type (e.g., [`RbfKernel`](super::svm::RbfKernel)).
77#[derive(Debug, Clone)]
78pub struct OneClassSVM<F, K> {
79 /// The nu parameter: upper bound on the fraction of outliers.
80 /// Must be in `(0, 1]`. Default: `0.5`.
81 pub nu: F,
82 /// The kernel function.
83 pub kernel: K,
84 /// Convergence tolerance.
85 pub tol: F,
86 /// Maximum number of SMO iterations. `0` is the sklearn `max_iter=-1`
87 /// sentinel ("no iteration limit"; libsvm runs to convergence) — the SMO
88 /// loop then runs until the KKT gap closes; a non-zero value caps the
89 /// iteration count (`sklearn/svm/_classes.py:1721`, `max_iter` default `-1`).
90 pub max_iter: usize,
91 /// Size of the kernel evaluation cache (`sklearn` `cache_size`, default
92 /// `200`). Accepted for API parity; this module has no kernel cache.
93 pub cache_size: usize,
94 /// Whether to use libsvm's shrinking heuristic (`sklearn` `shrinking`,
95 /// `_classes.py:1718`, default `true`).
96 ///
97 /// Accepted for API parity. The one-class optimum is shrinking-invariant
98 /// and ferrolearn's SMO implements no shrinking heuristic, so this flag
99 /// DOES NOT alter the fitted `α`/`dual_coef_`/`intercept_` (R-DEV-7).
100 pub shrinking: bool,
101}
102
103impl<F: Float, K: Kernel<F>> OneClassSVM<F, K> {
104 /// Create a new `OneClassSVM` with the given kernel and default hyperparameters.
105 ///
106 /// Defaults: `nu = 0.5`, `tol = 1e-3`, `max_iter = 0` (= sklearn `-1`, no
107 /// iteration limit — runs to convergence), `cache_size = 200`,
108 /// `shrinking = true` (`sklearn/svm/_classes.py:1712-1721`).
109 #[must_use]
110 pub fn new(kernel: K) -> Self {
111 Self {
112 nu: F::from(0.5).unwrap_or_else(F::zero),
113 kernel,
114 tol: F::from(1e-3).unwrap_or_else(F::zero),
115 max_iter: 0,
116 cache_size: 200,
117 shrinking: true,
118 }
119 }
120
121 /// Set the nu parameter.
122 #[must_use]
123 pub fn with_nu(mut self, nu: F) -> Self {
124 self.nu = nu;
125 self
126 }
127
128 /// Set the convergence tolerance.
129 #[must_use]
130 pub fn with_tol(mut self, tol: F) -> Self {
131 self.tol = tol;
132 self
133 }
134
135 /// Set the maximum number of SMO iterations.
136 #[must_use]
137 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
138 self.max_iter = max_iter;
139 self
140 }
141
142 /// Set the kernel cache size.
143 #[must_use]
144 pub fn with_cache_size(mut self, cache_size: usize) -> Self {
145 self.cache_size = cache_size;
146 self
147 }
148
149 /// Set the `shrinking` flag (`sklearn` `shrinking`, default `true`).
150 ///
151 /// Accepted for API parity; the one-class optimum is shrinking-invariant
152 /// (ferrolearn's SMO has no shrinking heuristic — R-DEV-7), so this does
153 /// not change the fit.
154 #[must_use]
155 pub fn with_shrinking(mut self, shrinking: bool) -> Self {
156 self.shrinking = shrinking;
157 self
158 }
159}
160
161/// Fitted One-Class SVM.
162///
163/// Stores the support vectors and decision boundary. Points are classified
164/// as inliers (+1) or outliers (-1) based on the sign of the decision
165/// function.
166#[derive(Debug, Clone)]
167pub struct FittedOneClassSVM<F, K> {
168 /// The kernel used for predictions.
169 kernel: K,
170 /// Support vectors (stored as rows).
171 support_vectors: Vec<Vec<F>>,
172 /// Original training-row index of each support vector, ascending. Mirrors
173 /// libsvm's `support_` accounting (`sklearn/svm/_base.py:318-410`) so the
174 /// public `support_`/`support_vectors_` attributes can index back into X.
175 sv_indices: Vec<usize>,
176 /// Dual coefficients for each support vector.
177 dual_coefs: Vec<F>,
178 /// Bias (rho) term. Decision function: sign(f(x) - rho).
179 rho: F,
180}
181
182impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> Fit<Array2<F>, ()>
183 for OneClassSVM<F, K>
184{
185 type Fitted = FittedOneClassSVM<F, K>;
186 type Error = FerroError;
187
188 /// Fit the One-Class SVM.
189 ///
190 /// # Errors
191 ///
192 /// - [`FerroError::InvalidParameter`] if `nu` is not in `(0, 1]`.
193 /// - [`FerroError::InsufficientSamples`] if no training data is provided.
194 fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedOneClassSVM<F, K>, FerroError> {
195 if self.nu <= F::zero() || self.nu > F::one() {
196 return Err(FerroError::InvalidParameter {
197 name: "nu".into(),
198 reason: "must be in (0, 1]".into(),
199 });
200 }
201
202 let n_samples = x.nrows();
203 let n_features = x.ncols();
204
205 if n_samples == 0 {
206 return Err(FerroError::InsufficientSamples {
207 required: 1,
208 actual: 0,
209 context: "OneClassSVM requires at least one sample".into(),
210 });
211 }
212
213 // Reject non-finite input (NaN / +/-inf) in X BEFORE the one-class SMO
214 // solve, mirroring sklearn's `BaseLibSVM.fit` -> `_validate_data(X, y, …)`
215 // (`sklearn/svm/_base.py:190-197`, default `force_all_finite=True`) ->
216 // `ValueError("Input X contains NaN.")` / `"… contains infinity …"`.
217 // OneClassSVM is unsupervised (`Fit<Array2<F>, ()>`, X only — sklearn's
218 // `OneClassSVM.fit(X)` passes a synthetic all-ones `y`), so only X is
219 // finiteness-checked; ferrolearn's `Fit::fit` has no `sample_weight`
220 // argument. `.iter().any(|v| !v.is_finite())` catches both NaN and
221 // +/-inf; on finite input the guard never fires (the fitted
222 // `support_`/`dual_coef_`/`intercept_`/`offset_` are byte-identical).
223 if x.iter().any(|v| !v.is_finite()) {
224 return Err(FerroError::InvalidParameter {
225 name: "X".into(),
226 reason: "Input X contains NaN or infinity.".into(),
227 });
228 }
229
230 // Resolve the kernel against X at fit time (gamma='scale'/'auto'/float),
231 // exactly as `svm.rs`'s `SVC::fit` does (`self.kernel.resolved_for_fit(x)`).
232 // sklearn resolves `gamma='scale'` to `1/(n_features·X.var())` and
233 // `'auto'` to `1/n_features` against the training X (`_base.py:236-243`);
234 // without this a default `RbfKernel` (`Gamma::Scale`) silently fits with
235 // `gamma=1.0`. The resolved kernel is used for ALL kernel evaluations
236 // below and stored on `FittedOneClassSVM` so decision_function/predict
237 // reuse the same resolved gamma.
238 let kernel = self.kernel.resolved_for_fit(x);
239
240 // Solve the one-class SVM dual:
241 // max sum_i alpha_i - 0.5 * sum_{i,j} alpha_i * alpha_j * K(x_i, x_j)
242 // s.t. 0 <= alpha_i <= 1/(n * nu), sum alpha_i = 1
243 //
244 // We use a simplified approach: initialize alphas uniformly, then
245 // iterate with SMO-style updates.
246
247 let c = F::one() / (F::from(n_samples).unwrap() * self.nu);
248 let data: Vec<Vec<F>> = (0..n_samples).map(|i| x.row(i).to_vec()).collect();
249
250 // Initialize alphas uniformly: alpha_i = 1/n
251 let init_alpha = F::one() / F::from(n_samples).unwrap();
252 let mut alphas = vec![init_alpha.min(c); n_samples];
253
254 // Ensure sum(alphas) = 1 after capping at c.
255 let alpha_sum: F = alphas.iter().copied().fold(F::zero(), |a, b| a + b);
256 if alpha_sum < F::one() {
257 // Distribute remaining mass.
258 let remaining = F::one() - alpha_sum;
259 let per_sample = remaining / F::from(n_samples).unwrap();
260 for alpha in &mut alphas {
261 *alpha = (*alpha + per_sample).min(c);
262 }
263 }
264
265 // Compute initial gradient: grad_i = sum_j alpha_j * K(x_i, x_j)
266 let eps = F::from(1e-12).unwrap_or_else(F::epsilon);
267 let two = F::one() + F::one();
268
269 let mut grad = vec![F::zero(); n_samples];
270 for i in 0..n_samples {
271 for j in 0..n_samples {
272 grad[i] = grad[i] + alphas[j] * kernel.compute(&data[i], &data[j]);
273 }
274 }
275
276 // SMO iterations. `max_iter == 0` is the sklearn `max_iter=-1` ("no
277 // iteration limit", libsvm runs to convergence) sentinel — the loop
278 // then runs until the KKT gap closes (the `i_max_grad - j_min_grad <
279 // tol` break below). A non-zero `max_iter` caps the iteration count,
280 // mirroring `svm.rs`'s `smo_binary`/`smo_svr` loop guard.
281 let mut iter = 0usize;
282 loop {
283 if self.max_iter != 0 && iter >= self.max_iter {
284 break;
285 }
286 iter += 1;
287 // Select working set: i with largest gradient (and alpha_i > 0),
288 // j with smallest gradient (and alpha_j < c).
289 let mut i_best = None;
290 let mut i_max_grad = F::neg_infinity();
291 let mut j_best = None;
292 let mut j_min_grad = F::infinity();
293
294 for k in 0..n_samples {
295 if alphas[k] > eps && grad[k] > i_max_grad {
296 i_max_grad = grad[k];
297 i_best = Some(k);
298 }
299 if alphas[k] < c - eps && grad[k] < j_min_grad {
300 j_min_grad = grad[k];
301 j_best = Some(k);
302 }
303 }
304
305 if i_best.is_none() || j_best.is_none() || i_max_grad - j_min_grad < self.tol {
306 break;
307 }
308
309 let i = i_best.unwrap();
310 let j = j_best.unwrap();
311
312 if i == j {
313 break;
314 }
315
316 let kii = kernel.compute(&data[i], &data[i]);
317 let kjj = kernel.compute(&data[j], &data[j]);
318 let kij = kernel.compute(&data[i], &data[j]);
319 let eta = kii + kjj - two * kij;
320
321 if eta <= eps {
322 continue;
323 }
324
325 // Update: move mass from i to j.
326 let delta = (grad[i] - grad[j]) / eta;
327 let delta = delta.min(alphas[i]).min(c - alphas[j]);
328
329 if delta.abs() < eps {
330 continue;
331 }
332
333 alphas[i] = alphas[i] - delta;
334 alphas[j] = alphas[j] + delta;
335
336 // Update gradients.
337 for k in 0..n_samples {
338 let kki = kernel.compute(&data[k], &data[i]);
339 let kkj = kernel.compute(&data[k], &data[j]);
340 grad[k] = grad[k] - delta * kki + delta * kkj;
341 }
342 }
343
344 // Compute rho from the KKT conditions.
345 // For free SVs (0 < alpha_i < c): rho = grad_i = sum_j alpha_j * K(i, j).
346 let mut rho_sum = F::zero();
347 let mut rho_count = 0usize;
348
349 for i in 0..n_samples {
350 if alphas[i] > eps && alphas[i] < c - eps {
351 rho_sum = rho_sum + grad[i];
352 rho_count += 1;
353 }
354 }
355
356 let rho = if rho_count > 0 {
357 rho_sum / F::from(rho_count).unwrap()
358 } else {
359 // Fallback: use the midpoint of the gradient range among all SVs.
360 let sv_grads: Vec<F> = (0..n_samples)
361 .filter(|&i| alphas[i] > eps)
362 .map(|i| grad[i])
363 .collect();
364
365 if sv_grads.is_empty() {
366 F::zero()
367 } else {
368 let min_g = sv_grads.iter().fold(F::infinity(), |a, &b| a.min(b));
369 let max_g = sv_grads.iter().fold(F::neg_infinity(), |a, &b| a.max(b));
370 (min_g + max_g) / two
371 }
372 };
373
374 // Rescale from the normalized one-class dual (0<=a<=1/(n*nu), Sum a = 1)
375 // to libsvm's un-normalized convention (0<=a<=1, Sum a = nu*n). The two
376 // optima are the SAME point scaled by `nu*n`, so the reported
377 // coefficients and `rho` must be multiplied by `nu*n` for the decision
378 // function `Sum a_i K - rho` to match the sklearn/libsvm oracle
379 // (libsvm `solve_one_class` svm.cpp:1722-1736, decision svm.cpp:2834).
380 // `c == 1/(n*nu)`, so the scale factor `nu*n` is exactly `1/c`.
381 let scale = F::one() / c;
382 let rho = rho * scale;
383
384 // Extract support vectors. `alphas` is in training-row order, so the
385 // recorded `sv_indices` are already ascending — matching libsvm's
386 // `support_` ordering (`svm.cpp` keeps SVs in input order for one-class).
387 let mut support_vectors = Vec::new();
388 let mut sv_indices = Vec::new();
389 let mut dual_coefs = Vec::new();
390
391 for (i, &alpha) in alphas.iter().enumerate() {
392 if alpha > eps {
393 support_vectors.push(data[i].clone());
394 sv_indices.push(i);
395 dual_coefs.push(alpha * scale);
396 }
397 }
398
399 // If no support vectors found, use all data as fallback: distribute the
400 // total mass `nu*n` (== scale) uniformly across all n samples. With
401 // `c == 1/(n*nu)`, the per-sample weight `scale/n == scale*c*nu`.
402 if support_vectors.is_empty() {
403 let weight = scale * c * self.nu;
404 for (i, row) in data.iter().enumerate() {
405 support_vectors.push(row.clone());
406 sv_indices.push(i);
407 dual_coefs.push(weight);
408 }
409 }
410
411 let _ = n_features; // used for validation context
412
413 Ok(FittedOneClassSVM {
414 kernel,
415 support_vectors,
416 sv_indices,
417 dual_coefs,
418 rho,
419 })
420 }
421}
422
423impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static>
424 FittedOneClassSVM<F, K>
425{
426 /// Compute the decision function value for a single sample.
427 ///
428 /// Returns `f(x) - rho`, where `f(x) = sum_i alpha_i * K(sv_i, x)`.
429 fn decision_value(&self, x: &[F]) -> F {
430 let mut val = F::zero();
431 for (sv, &coef) in self.support_vectors.iter().zip(self.dual_coefs.iter()) {
432 val = val + coef * self.kernel.compute(sv, x);
433 }
434 val - self.rho
435 }
436
437 /// Compute the raw decision function values for each sample.
438 ///
439 /// Returns an array of shape `(n_samples,)`. Positive values indicate
440 /// inliers, negative values indicate outliers.
441 ///
442 /// # Errors
443 ///
444 /// Returns `Ok` always for valid input.
445 pub fn decision_function(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
446 let n_samples = x.nrows();
447 let mut result = Array1::<F>::zeros(n_samples);
448 for s in 0..n_samples {
449 let xi: Vec<F> = x.row(s).to_vec();
450 result[s] = self.decision_value(&xi);
451 }
452 Ok(result)
453 }
454
455 /// Indices of the support vectors into the training set, ascending
456 /// (`OneClassSVM.support_`, `sklearn/svm/_base.py:318-410`). One-class has a
457 /// single "class", so the SVs are kept in training-row order.
458 #[must_use]
459 pub fn support(&self) -> Array1<usize> {
460 Array1::from_vec(self.sv_indices.clone())
461 }
462
463 /// The support vectors, shape `(n_SV, n_features)`
464 /// (`OneClassSVM.support_vectors_`). Equals `X[support_]`.
465 #[must_use]
466 pub fn support_vectors(&self) -> Array2<F> {
467 let n_sv = self.support_vectors.len();
468 let n_features = self.support_vectors.first().map_or(0, Vec::len);
469 let mut out = Array2::<F>::zeros((n_sv, n_features));
470 for (r, sv) in self.support_vectors.iter().enumerate() {
471 for (c, &v) in sv.iter().enumerate() {
472 out[[r, c]] = v;
473 }
474 }
475 out
476 }
477
478 /// Number of support vectors. For one-class `n_support_` has size 1 — a
479 /// single count of all SVs (`sklearn/svm/_classes.py:1664`,
480 /// `sklearn/svm/_base.py:680-682`).
481 #[must_use]
482 pub fn n_support(&self) -> Vec<usize> {
483 vec![self.support_vectors.len()]
484 }
485
486 /// Dual coefficients `alpha`, shape `(1, n_SV)` (`OneClassSVM.dual_coef_`).
487 /// For one-class these are the raw `alpha` (NOT `alpha*y`), already rescaled
488 /// in `fn fit` to libsvm's `Sum alpha = nu*n` convention
489 /// (`sklearn/svm/_classes.py:1639`). No sign flip applies to one-class
490 /// (`sklearn/svm/_base.py:258-262` restricts the flip to `c_svc`/`nu_svc`).
491 #[must_use]
492 pub fn dual_coef(&self) -> Array2<F> {
493 let n_sv = self.dual_coefs.len();
494 let mut out = Array2::<F>::zeros((1, n_sv));
495 for (c, &v) in self.dual_coefs.iter().enumerate() {
496 out[[0, c]] = v;
497 }
498 out
499 }
500
501 /// The intercept, length 1 (`OneClassSVM.intercept_`) = `-rho`. The
502 /// one-class decision function is `Sum alpha*K - rho`, so the public
503 /// intercept is `-rho` (libsvm `svm.cpp:2834` `sum -= rho`,
504 /// `sklearn/svm/_base.py` `_intercept_`).
505 #[must_use]
506 pub fn intercept(&self) -> Array1<F> {
507 Array1::from_vec(vec![-self.rho])
508 }
509
510 /// The offset, a scalar (`OneClassSVM.offset_`) = `rho` = `-intercept_`
511 /// (`sklearn/svm/_classes.py:1767`: `self.offset_ = -self._intercept_`).
512 /// Used to shift the decision function back to the raw score:
513 /// `decision_function = score_samples - offset_`.
514 #[must_use]
515 pub fn offset(&self) -> F {
516 self.rho
517 }
518
519 /// Primal weight vector `coef_ = dual_coef_ @ support_vectors_`, shape
520 /// `(1, n_features)` — available ONLY for the linear kernel
521 /// (`sklearn/svm/_base.py:650-666`). Returns `None` for any other kernel
522 /// (sklearn raises `AttributeError`).
523 #[must_use]
524 pub fn coef(&self) -> Option<Array2<F>> {
525 if !self.kernel.is_linear() {
526 return None;
527 }
528 let dual = self.dual_coef(); // (1, n_SV)
529 let svs = self.support_vectors(); // (n_SV, n_features)
530 Some(dual.dot(&svs))
531 }
532
533 /// Raw (unshifted) scoring function of the samples,
534 /// `score_samples(X) = decision_function(X) + offset_`
535 /// (`sklearn/svm/_classes.py:1801`). Equals the unshifted `Sum alpha*K`.
536 ///
537 /// # Errors
538 ///
539 /// Propagates any error from [`Self::decision_function`].
540 pub fn score_samples(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
541 let dec = self.decision_function(x)?;
542 let off = self.offset();
543 Ok(dec.mapv(|v| v + off))
544 }
545}
546
547impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> Predict<Array2<F>>
548 for FittedOneClassSVM<F, K>
549{
550 type Output = Array1<isize>;
551 type Error = FerroError;
552
553 /// Predict inlier (+1) or outlier (-1) for each sample.
554 ///
555 /// # Errors
556 ///
557 /// Returns `Ok` always for valid input.
558 fn predict(&self, x: &Array2<F>) -> Result<Array1<isize>, FerroError> {
559 let n_samples = x.nrows();
560 let mut predictions = Array1::<isize>::zeros(n_samples);
561
562 // libsvm ONE_CLASS uses `(sum > 0) ? +1 : -1` (svm.cpp:2837-2838). A
563 // point exactly on the decision boundary (a free support vector, whose
564 // decision value is mathematically 0) is reported `+1` by libsvm: its
565 // converged `rho` lands fractionally below the on-boundary kernel sum
566 // (e.g. 0.00999999977 vs 0.01), so `sum` is a small positive. ferrolearn
567 // recovers `rho` exactly, leaving boundary points at machine-epsilon
568 // noise of either sign. To reproduce libsvm's observable labels
569 // (R-DEV-3), treat decision values within solver-precision of 0 as the
570 // inlier side: a relative slack off `|rho|` separates true outliers
571 // (whose `|sum|` is order `|rho|`) from on-boundary roundoff.
572 let boundary = self.rho.abs() * F::from(1e-9).unwrap_or_else(F::epsilon);
573 for s in 0..n_samples {
574 let xi: Vec<F> = x.row(s).to_vec();
575 let val = self.decision_value(&xi);
576 predictions[s] = if val > -boundary { 1 } else { -1 };
577 }
578
579 Ok(predictions)
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586 use crate::svm::{LinearKernel, RbfKernel};
587 use ndarray::Array2;
588
589 fn make_cluster_data() -> Array2<f64> {
590 Array2::from_shape_vec(
591 (8, 2),
592 vec![
593 1.0, 1.0, 1.1, 1.0, 1.0, 1.1, 1.1, 1.1, 0.9, 0.9, 1.0, 0.9, 0.9, 1.0, 1.05, 1.05,
594 ],
595 )
596 .unwrap()
597 }
598
599 #[test]
600 fn test_one_class_svm_fit() {
601 let x = make_cluster_data();
602 let model = OneClassSVM::<f64, RbfKernel<f64>>::new(RbfKernel::with_gamma(10.0));
603 let result = model.fit(&x, &());
604 assert!(result.is_ok());
605 }
606
607 #[test]
608 fn test_one_class_svm_inliers() {
609 let x = make_cluster_data();
610 let model = OneClassSVM::new(RbfKernel::with_gamma(10.0)).with_nu(0.1);
611 let fitted = model.fit(&x, &()).unwrap();
612 let preds = fitted.predict(&x).unwrap();
613
614 // Most training points should be classified as inliers.
615 let inliers: usize = preds.iter().filter(|&&p| p == 1).count();
616 assert!(inliers >= 6, "Expected at least 6 inliers, got {inliers}");
617 }
618
619 #[test]
620 fn test_one_class_svm_outlier_detection() {
621 let x_train = Array2::from_shape_vec(
622 (8, 2),
623 vec![
624 0.0, 0.0, 0.1, 0.0, 0.0, 0.1, 0.1, 0.1, -0.1, 0.0, 0.0, -0.1, 0.05, 0.05, -0.05,
625 -0.05,
626 ],
627 )
628 .unwrap();
629
630 let model = OneClassSVM::new(RbfKernel::with_gamma(10.0)).with_nu(0.1);
631 let fitted = model.fit(&x_train, &()).unwrap();
632
633 // A far-away point should be an outlier.
634 let x_outlier = Array2::from_shape_vec((1, 2), vec![100.0, 100.0]).unwrap();
635 let preds = fitted.predict(&x_outlier).unwrap();
636 assert_eq!(preds[0], -1, "Far-away point should be an outlier");
637 }
638
639 #[test]
640 fn test_one_class_svm_decision_function() {
641 let x = make_cluster_data();
642 let model = OneClassSVM::new(RbfKernel::with_gamma(10.0)).with_nu(0.1);
643 let fitted = model.fit(&x, &()).unwrap();
644
645 let df = fitted.decision_function(&x).unwrap();
646 assert_eq!(df.len(), 8);
647
648 // Most decision values should be non-negative for training data.
649 let positive: usize = df.iter().filter(|&&v| v >= 0.0).count();
650 assert!(
651 positive >= 6,
652 "Expected at least 6 positive df, got {positive}"
653 );
654 }
655
656 #[test]
657 fn test_one_class_svm_invalid_nu() {
658 let x = Array2::from_shape_vec((4, 2), vec![1.0; 8]).unwrap();
659
660 let model = OneClassSVM::new(RbfKernel::<f64>::new()).with_nu(0.0);
661 assert!(model.fit(&x, &()).is_err());
662
663 let model2 = OneClassSVM::new(RbfKernel::<f64>::new()).with_nu(1.5);
664 assert!(model2.fit(&x, &()).is_err());
665 }
666
667 #[test]
668 fn test_one_class_svm_empty_data() {
669 let x = Array2::<f64>::zeros((0, 2));
670 let model = OneClassSVM::new(RbfKernel::<f64>::new());
671 assert!(model.fit(&x, &()).is_err());
672 }
673
674 #[test]
675 fn test_one_class_svm_builder_pattern() {
676 let model = OneClassSVM::<f64, LinearKernel>::new(LinearKernel)
677 .with_nu(0.3)
678 .with_tol(1e-4)
679 .with_max_iter(5000)
680 .with_cache_size(2048)
681 .with_shrinking(false);
682
683 assert!((model.nu - 0.3).abs() < 1e-10);
684 assert!((model.tol - 1e-4).abs() < 1e-10);
685 assert_eq!(model.max_iter, 5000);
686 assert_eq!(model.cache_size, 2048);
687 assert!(!model.shrinking);
688 }
689
690 /// REQ-6 (R-DEV-2): `OneClassSVM::new` exposes sklearn's exact param
691 /// surface defaults. Expected values from the live oracle:
692 /// python3 -c "from sklearn.svm import OneClassSVM; import inspect; \
693 /// print({k:v.default for k,v in \
694 /// inspect.signature(OneClassSVM.__init__).parameters.items() if k!='self'})"
695 /// # kernel='rbf' degree=3 gamma='scale' coef0=0.0 tol=1e-3 nu=0.5
696 /// # shrinking=True cache_size=200 max_iter=-1
697 /// (`max_iter=-1` maps to ferrolearn's `0` sentinel = no iteration limit).
698 #[test]
699 fn test_one_class_svm_default_params() {
700 let model = OneClassSVM::<f64, LinearKernel>::new(LinearKernel);
701 assert!((model.nu - 0.5).abs() < 1e-12, "nu default 0.5");
702 assert!((model.tol - 1e-3).abs() < 1e-12, "tol default 1e-3");
703 assert_eq!(model.max_iter, 0, "max_iter default 0 (= sklearn -1)");
704 assert_eq!(model.cache_size, 200, "cache_size default 200");
705 assert!(model.shrinking, "shrinking default true");
706 }
707
708 /// A default-`max_iter` (`0` = unbounded) fit must run to convergence, NOT
709 /// run zero iterations or spin forever: the `i_max_grad - j_min_grad < tol`
710 /// break terminates the loop. Verifies the sentinel loop guard.
711 #[test]
712 fn test_one_class_svm_default_max_iter_converges() {
713 let x = oracle_7x2();
714 let model = OneClassSVM::new(LinearKernel).with_nu(0.5);
715 assert_eq!(model.max_iter, 0, "uses the default unbounded sentinel");
716 let fit = model.fit(&x, &());
717 assert!(
718 fit.is_ok(),
719 "default max_iter=0 fit converges, no infinite loop"
720 );
721 let Ok(fitted) = fit else { return };
722 // Converged to a usable boundary: it produces a label per sample.
723 let preds = fitted.predict(&x);
724 assert!(preds.is_ok(), "predict succeeds on converged fit");
725 let Ok(preds) = preds else { return };
726 assert_eq!(preds.len(), 7);
727 }
728
729 #[test]
730 fn test_one_class_svm_linear_kernel() {
731 let x = make_cluster_data();
732 let model = OneClassSVM::new(LinearKernel).with_nu(0.5);
733 let fitted = model.fit(&x, &()).unwrap();
734 let preds = fitted.predict(&x).unwrap();
735 assert_eq!(preds.len(), 8);
736 }
737
738 #[test]
739 fn test_one_class_svm_single_sample() {
740 let x = Array2::from_shape_vec((1, 2), vec![1.0, 1.0]).unwrap();
741 let model = OneClassSVM::new(RbfKernel::with_gamma(1.0)).with_nu(0.5);
742 let result = model.fit(&x, &());
743 assert!(result.is_ok());
744 }
745
746 /// The 7x2 contract set; built with `arr2` (no `Result`) so the smoke
747 /// tests stay free of the forbidden `.unwrap()` token even under the
748 /// Edit-path gate (which does not exempt `#[cfg(test)]`).
749 fn oracle_7x2() -> Array2<f64> {
750 ndarray::arr2(&[
751 [0.0, 0.0],
752 [0.1, 0.1],
753 [-0.1, 0.1],
754 [0.1, -0.1],
755 [0.0, 0.2],
756 [0.2, 0.0],
757 [3.0, 3.0],
758 ])
759 }
760
761 // Expected values come from the live sklearn 1.5.2 oracle (R-CHAR-3),
762 // NOT copied from ferrolearn:
763 //
764 // python3 -c "import numpy as np; from sklearn.svm import OneClassSVM; \
765 // X=np.array([[0,0],[0.1,0.1],[-0.1,0.1],[0.1,-0.1],[0,0.2],[0.2,0],[3,3]],dtype=float); \
766 // m=OneClassSVM(kernel='linear',nu=0.5).fit(X); \
767 // print(m.support_.tolist(), m.n_support_.tolist()); \
768 // print(np.round(m.dual_coef_,6).tolist()); \
769 // print(np.round(m.intercept_,6).tolist(), np.round(m.offset_,6).tolist()); \
770 // print(np.round(m.coef_,6).tolist()); \
771 // print(np.round(m.score_samples(X),6).tolist())"
772 //
773 // support_ [0,1,2,3] n_support_ [4]
774 // dual_coef_ [[1.0,0.5,1.0,1.0]] (sum 3.5 = nu*n = 0.5*7)
775 // intercept_ [-0.01] offset_ [0.01] coef_ [[0.05,0.05]]
776 // score_samples [0.0,0.01,0.0,0.0,0.01,0.01,0.3]
777
778 #[test]
779 fn test_one_class_svm_fitted_attributes_linear_oracle() {
780 let fit = OneClassSVM::new(LinearKernel)
781 .with_nu(0.5)
782 .fit(&oracle_7x2(), &());
783 assert!(fit.is_ok(), "linear one-class fit should succeed");
784 let Ok(fitted) = fit else { return };
785
786 // The hyperplane-level attributes match the live oracle exactly
787 // (coef_/intercept_/offset_ and decision_function/score_samples — see
788 // the score_samples test). NOTE: the SV-decomposition attributes
789 // (support_/dual_coef_/n_support_) DIVERGE — ferrolearn's SMO converges
790 // to a different vertex of the same optimal face: it reports 5 SVs
791 // {0,2,3,4,5} with dual_coef_ [[1,1,1,0.25,0.25]] vs the live oracle's
792 // 4 SVs {0,1,2,3} with [[1,0.5,1,1]]. Both sum to nu*n=3.5 and yield
793 // the SAME hyperplane (coef_=[[0.05,0.05]], intercept_=[-0.01]), so the
794 // decision function matches. The SV-set divergence is a solver-optimum
795 // divergence (REQ-1), filed as a new blocker for the critic to pin
796 // rigorously and a fixer to resolve in the SMO working-set selection;
797 // these accessors faithfully report whatever the solver converged to.
798
799 // support_/support_vectors_ shapes are coherent with each other.
800 let support = fitted.support();
801 let svs = fitted.support_vectors();
802 assert_eq!(
803 svs.nrows(),
804 support.len(),
805 "support_vectors_ rows == |support_|"
806 );
807 assert_eq!(svs.ncols(), 2, "support_vectors_ n_features == 2");
808 // support_ is ascending and indexes valid training rows.
809 for w in support.windows(2).into_iter() {
810 assert!(w[0] < w[1], "support_ strictly ascending");
811 }
812 for &i in support.iter() {
813 assert!(i < 7, "support_ index in range");
814 }
815
816 // n_support_ has length 1 (one-class single "class") and equals |SV|.
817 let n_support = fitted.n_support();
818 assert_eq!(n_support.len(), 1, "n_support_ length 1 for one-class");
819 assert_eq!(n_support[0], support.len(), "n_support_[0] == |support_|");
820
821 // dual_coef_ shape (1, n_SV); its sum is the libsvm-scale total nu*n=3.5
822 // (the rescale identity, scale-invariant of the SV decomposition).
823 let dual = fitted.dual_coef();
824 assert_eq!(dual.dim(), (1, support.len()), "dual_coef_ shape (1, n_SV)");
825 let dual_sum: f64 = dual.iter().sum();
826 assert!((dual_sum - 3.5).abs() < 1e-2, "dual_coef_ sum = nu*n = 3.5");
827
828 // intercept_ = [-0.01], offset_ = 0.01 = -intercept_ (matches oracle).
829 let intercept = fitted.intercept();
830 assert_eq!(intercept.len(), 1, "intercept_ length 1");
831 assert!(
832 (intercept[0] - (-0.01)).abs() < 1e-2,
833 "intercept_ vs oracle [-0.01]"
834 );
835 let offset = fitted.offset();
836 assert!((offset - 0.01).abs() < 1e-2, "offset_ vs oracle 0.01");
837 assert!(
838 (offset - (-intercept[0])).abs() < 1e-12,
839 "offset_ = -intercept_"
840 );
841
842 // coef_ = dual_coef_ @ support_vectors_ = [[0.05, 0.05]] (matches oracle:
843 // the primal hyperplane is identical despite the different SV set).
844 let coef = fitted.coef();
845 assert!(coef.is_some(), "linear kernel exposes coef_");
846 if let Some(coef) = coef {
847 assert_eq!(coef.dim(), (1, 2), "coef_ shape (1, n_features)");
848 assert!(
849 (coef[[0, 0]] - 0.05).abs() < 1e-2,
850 "coef_[0][0] vs oracle 0.05"
851 );
852 assert!(
853 (coef[[0, 1]] - 0.05).abs() < 1e-2,
854 "coef_[0][1] vs oracle 0.05"
855 );
856 }
857 }
858
859 #[test]
860 fn test_one_class_svm_score_samples_linear_oracle() {
861 let fit = OneClassSVM::new(LinearKernel)
862 .with_nu(0.5)
863 .fit(&oracle_7x2(), &());
864 assert!(fit.is_ok(), "linear one-class fit should succeed");
865 let Ok(fitted) = fit else { return };
866
867 // score_samples = decision_function + offset_ = [0,0.01,0,0,0.01,0.01,0.3].
868 let scores_res = fitted.score_samples(&oracle_7x2());
869 assert!(scores_res.is_ok(), "score_samples should succeed");
870 let df_res = fitted.decision_function(&oracle_7x2());
871 assert!(df_res.is_ok(), "decision_function should succeed");
872 let (Ok(scores), Ok(df)) = (scores_res, df_res) else {
873 return;
874 };
875 let expected = [0.0, 0.01, 0.0, 0.0, 0.01, 0.01, 0.3];
876 assert_eq!(scores.len(), expected.len());
877 for (i, &v) in expected.iter().enumerate() {
878 assert!(
879 (scores[i] - v).abs() < 1e-2,
880 "score_samples[{i}] = {} vs oracle {v}",
881 scores[i]
882 );
883 }
884
885 // Cross-check the identity score_samples = decision_function + offset_.
886 for i in 0..scores.len() {
887 assert!((scores[i] - (df[i] + fitted.offset())).abs() < 1e-12);
888 }
889 }
890
891 #[test]
892 fn test_one_class_svm_coef_none_for_rbf() {
893 // coef_ is linear-only; non-linear kernels return None (sklearn raises
894 // AttributeError, `sklearn/svm/_base.py:650-651`).
895 let fit = OneClassSVM::new(RbfKernel::with_gamma(1.0))
896 .with_nu(0.5)
897 .fit(&oracle_7x2(), &());
898 assert!(fit.is_ok(), "rbf one-class fit should succeed");
899 let Ok(fitted) = fit else { return };
900 assert!(fitted.coef().is_none(), "rbf kernel has no coef_");
901 }
902}