Skip to main content

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}