ferrolearn_linear/svm.rs
1//! Support Vector Machine with kernel trick.
2//!
3//! This module provides [`SVC`] (classification) and [`SVR`] (regression)
4//! support vector machines trained using the **Sequential Minimal Optimization
5//! (SMO)** algorithm (Platt, 1998).
6//!
7//! # Kernels
8//!
9//! Four built-in kernels are provided:
10//!
11//! - [`LinearKernel`]: `K(x, y) = x . y`
12//! - [`RbfKernel`]: `K(x, y) = exp(-gamma * ||x - y||^2)`
13//! - [`PolynomialKernel`]: `K(x, y) = (gamma * x . y + coef0)^degree`
14//! - [`SigmoidKernel`]: `K(x, y) = tanh(gamma * x . y + coef0)`
15//!
16//! Users can implement the [`Kernel`] trait for custom kernels.
17//!
18//! # Multiclass
19//!
20//! `SVC` uses a one-vs-one strategy for multiclass classification.
21//!
22//! # Examples
23//!
24//! ```
25//! use ferrolearn_linear::svm::{SVC, LinearKernel};
26//! use ferrolearn_core::{Fit, Predict};
27//! use ndarray::{array, Array2};
28//!
29//! let x = Array2::from_shape_vec((6, 2), vec![
30//! 1.0, 1.0, 2.0, 1.0, 1.0, 2.0,
31//! 5.0, 5.0, 6.0, 5.0, 5.0, 6.0,
32//! ]).unwrap();
33//! let y = array![0usize, 0, 0, 1, 1, 1];
34//!
35//! let model = SVC::<f64, LinearKernel>::new(LinearKernel);
36//! let fitted = model.fit(&x, &y).unwrap();
37//! let preds = fitted.predict(&x).unwrap();
38//! assert_eq!(preds.len(), 6);
39//! ```
40//!
41//! ## REQ status
42//!
43//! Binary (R-DEFER-2): SHIPPED = impl + non-test production consumer + tests +
44//! green oracle verification; NOT-STARTED = open blocker `#`. `SVC`/`SVR`/
45//! `FittedSVC`/`FittedSVR`/`Kernel` + the four kernels are boundary estimator
46//! types re-exported at the crate root (`pub use svm::{…}` in `lib.rs`) and
47//! consumed by `nu_svm.rs` (`NuSVC`/`NuSVR` delegate to `SVC`/`SVR`) and
48//! `one_class_svm.rs` (uses `Kernel`) — non-test production consumers; under
49//! S5/R-DEFER-1 the fitted-attribute accessors are part of that boundary public
50//! API surface. See `.design/linear/svm.md`.
51//!
52//! | REQ | Status | Evidence |
53//! |---|---|---|
54//! | REQ-1 (kernels + gamma scale/auto/float) | SHIPPED | The four kernel formulas + the three-way `pub enum Gamma<F> { Scale, Auto, Value }` (default `Scale`) resolved at fit time by `fn resolve_gamma in svm.rs` + `fn resolved_for_fit in svm.rs`: `Scale`=`1/(n_features·X.var())` (`_base.py:238-239`), `Auto`=`1/n_features` (`_base.py:240-241`), `Value(v)`=verbatim (`_base.py:242-243`). Builders `RbfKernel::with_gamma`/`with_gamma_scale`/`with_gamma_auto`. Non-test consumer: the kernel `gamma` field is resolved in the production `fn fit in svm.rs` (`self.kernel.resolved_for_fit(x)`). Pinned: `divergence_pin2_rbf_default_scale_gamma` (scale, green) + in-module `test_svc_gamma_auto_decision_function in svm.rs` (`_gamma=0.5`, df `[-0.9996,-0.9999,-0.9999,0.9999,0.9999,0.9996]` vs live `SVC(kernel='rbf',gamma='auto')`, R-CHAR-3, 1e-2) + `test_svc_gamma_scale_still_default` (`_gamma=0.118421`). |
55//! | REQ-2 (C-SVC SMO fit) | SHIPPED | `fn smo_binary in svm.rs` (Fan-Chen-Lin WSS) converges to libsvm's `α`; pinned by `divergence_pin5_binary_fitted_attributes in tests/divergence_svm_fit.rs` (`dual_coef_ [[-0.0408,-0.0408,0.0816]]`, `support_ [1,2,3]`, `intercept_ [-1.8565]` vs live `SVC(kernel='linear',C=1.0)`). |
56//! | REQ-3 (fitted attrs + binary sign flip) | SHIPPED | `FittedSVC::{support,support_vectors,n_support,dual_coef,intercept,coef} in svm.rs` emit the libsvm layout with the binary sign flip (`_base.py:258-262`); `coef_` is linear-only (`_base.py:642-666`). Pinned by `divergence_pin5_*` (binary) + `divergence_pin6_multiclass_dual_coef_packing` (multiclass `(n_class-1,n_SV)` packing). |
57//! | REQ-4 (decision_function shape/sign/ovr) | SHIPPED | `FittedSVC::decision_function in svm.rs` returns the `SvmScores<F>` enum: binary -> `SvmScores::Binary` 1-D `(n,)` = `-raw_ovo.ravel()` (positive -> `classes_[1]`, `_base.py:538-539`); multiclass -> `SvmScores::Multiclass` `(n, n_classes)` via `fn ovr_decision_function in svm.rs` (default `SvmDecisionShape::Ovr`, transcribed from `multiclass.py:520-562`) applied to `dec<0`/`-dec` (`_base.py:780`), or raw `(n, n·(n-1)/2)` for `SvmDecisionShape::Ovo`. `SVC::decision_function_shape` field + `with_decision_function_shape`. Sign normalized: `fn raw_ovo` negates `decision_value_binary` to restore libsvm's lower-index-class-`+1` ovo convention. Pinned by `divergence_pin8_multiclass_ovr_decision_function` (ovr `(9,3)` row0 `[2.2366,0.8167,-0.1833]`, row3 `[1.0606,2.2262,-0.2333]`), `divergence_pin9_multiclass_ovo_decision_function` (ovo `(9,3)` row0 `[1.2222,1.2222,0.0]`), `divergence_pin10_binary_shape_contract` (binary 1-D `(6,)`) in `tests/divergence_svm_fit.rs` (R-CHAR-3, 1e-2). Consumer: `FittedNuSVC::decision_function in nu_svm.rs` delegates (non-test, propagates `SvmScores`). |
58//! | REQ-5 (predict + tie-break) | SHIPPED | `fn predict in svm.rs` (FittedSVC) does libsvm ovo voting and breaks vote ties toward the LOWER class index via a strictly-greater first-max scan (keeps the first/lowest-index maximum since `classes` is `np.unique(y)`-sorted), matching libsvm/sklearn `super().predict` (`_base.py:813-814`) instead of `max_by_key`'s last-maximum. Pinned by `divergence_pin3_predict_labels` (separable-set labels) + `divergence_pin11_ovo_vote_tie_break_lower_index` (4-class vote tie `(0,2,2,2)` at `q=(-0.21,-8.976)` -> class 1) in `tests/divergence_svm_fit.rs` vs live `SVC(kernel='linear',C=1.0)`. |
59//! | REQ-6 (epsilon-SVR) | SHIPPED | `fn smo_svr in svm.rs` + `FittedSVR::{support,support_vectors,n_support,dual_coef,intercept}`; pinned by `divergence_pin4_svr_predict_values` (predict) + `divergence_pin7_svr_fitted_attributes` (`support_ [0,5]`, `dual_coef_ [[-0.392,0.392]]`, `intercept_ [0.14]` vs live `SVR(kernel='linear',C=100,epsilon=0.1)`). |
60//! | REQ-7 (multiclass one-vs-one) | SHIPPED | `fn fit in svm.rs` (SVC) trains one `smo_binary` per class pair, `classes` = `np.unique(y)`; pinned by `divergence_pin6_multiclass_dual_coef_packing` (3-class `dual_coef_ (2,6)` libsvm packing, `support_ [1,2,3,5,6,7]`, `n_support_ [2,2,2]`, `intercept_ [1.2222,1.2222,0.0]`). |
61//! | REQ-8 (constructor param surface + defaults) | SHIPPED | `shrinking` (`SVC`/`SVR`, default `true`, `with_shrinking`; accepted for API parity, shrinking-invariant optimum so DOES NOT alter results — R-DEV-7); `break_ties` (`SVC`, default `false`, `with_break_ties`; `fn predict in svm.rs` ovr-argmax branch for `break_ties=true`+ovr+`n_classes>2`, `InvalidParameter` for the ovo combo, `_base.py:801-814`); default alignment `cache_size=200`, `max_iter=0` (= sklearn `-1`, no iteration limit; the `smo_binary`/`smo_svr` loops treat `0` as unbounded); REQ-1's `gamma` enum (`scale`/`auto`/float); and now **`class_weight`** (`SVC`, `pub class_weight: ClassWeight<F>` default `None`, `with_class_weight`). `fn compute_class_weight in svm.rs` mirrors `sklearn.utils.compute_class_weight` as called by `BaseSVC._validate_targets` (`class_weight_ = compute_class_weight(class_weight, classes, y)`, `_base.py:740`): `None`→1.0, `Balanced`→`n_samples/(n_classes·count_c)` (`_classes.py:122-124`), `Explicit`→1.0 default overridden by map. `fn smo_binary in svm.rs` now takes per-class box bounds `(cp, cn)` (the `y=+1`/`y=-1` upper bounds) instead of a scalar `c`, applied in the WSS `in_up`/`in_low` tests, the analytic-update box clip, and the free-SV bias recovery (`0<alpha_i<C_i`); when `cp==cn` the math is identical to before (the 13 divergence pins stay green). `fn fit in svm.rs` (SVC) computes `weights = compute_class_weight(...)` ONCE over the full `y`, then per ovo pair `(ci,cj)`: `cp = C·weights[cj]`, `cn = C·weights[ci]` (libsvm `weighted_C`, `_base.py:740`). Non-test consumer: `fn fit in svm.rs` consumes `self.class_weight` (the boundary `SVC`/`FittedSVC` types are re-exported at the crate root + consumed by `nu_svm.rs`). Pinned: `test_svc_class_weight_smoke`/`test_compute_class_weight_balanced`/`test_svc_break_ties_changes_label`/`test_svc_break_ties_ovo_errors`/`test_svc_default_params in svm.rs` (live oracle on the imbalanced 8×2 set: None `dual_coef_ [[-0.5,-1,1,0.5]]`/`intercept_ [-2.0]`/`support_ [1,3,5,6]`; balanced `[[-0.8,-0.8,1.3333,0.2667]]`/`-1.6667`; `{0:1,1:5}` `support_ [1,3,4,5]`/`-2.0`; R-CHAR-3, 1e-2; None≠balanced intercept). **R-DEV-7 design difference (preserved contract, NOT a gap):** estimator-level `kernel`(string-select)/`degree`/`coef0` are the type parameter `K`, set by construction; `random_state` is unused (ferrolearn's SMO is deterministic). `class_weight` is SVC-only (sklearn SVR has no `class_weight`). |
62//! | REQ-9 (probability / predict_proba) | SHIPPED | `pub probability: bool` field on `SVC` (default `false`, `with_probability`) + `prob_a`/`prob_b`/`probability` on `FittedSVC`. The DETERMINISTIC Platt machinery is transcribed from libsvm: `fn sigmoid_train in svm.rs` (Newton iteration + prior init + target smoothing + step-halving line search, `svm.cpp:1919-2030`), `fn sigmoid_predict in svm.rs` (overflow-safe form, `svm.cpp:2032-2040`), `fn multiclass_probability in svm.rs` (Wu-Lin-Weng 2004 coupling, `svm.cpp:2043-2104`). `fn platt_cv_sigmoid in svm.rs` runs a per-ovo-pair 5-fold CV at fit time when `probability=true` (`svm.cpp:2107-2203`). `FittedSVC::predict_proba in svm.rs` builds the pairwise matrix via `sigmoid_predict` (clamped `[1e-7,1-1e-7]`, `svm.cpp:2937`) -> `multiclass_probability` -> `(n,n_classes)`; binary -> `[P(classes[0]),P(classes[1])]`; rows sum to 1. `FittedSVC::predict_log_proba` = `predict_proba.ln()` (`_base.py:866-894`). `probability=false` -> `InvalidParameter` carrying sklearn's `NotFittedError` text "predict_proba is not available when fitted with probability=False" (`_base.py:856-860`; no `NotFitted` variant by R-DEV-4 typestate). **RNG-CV boundary (documented divergence, NOT a gap):** libsvm's CV fold permutation is RNG-seeded, so sklearn's `probA_`/`probB_`/`predict_proba` are NON-DETERMINISTIC across `random_state` (`probA_` = -0.7749 at rs=0 vs -1.0541 at rs=1; the docstring admits CV-dependence). ferrolearn uses a DETERMINISTIC contiguous 5-fold split (analogous to the documented SGD shuffle boundary), so it does NOT bit-match sklearn's predict_proba VALUES — only the deterministic machinery + structural invariants + the raise contract are verified (R-CHAR-3: the asserted invariants are sklearn's DOCUMENTED contract, not copied values). Pinned by `test_svc_predict_proba_raises_when_probability_false`/`test_svc_predict_proba_binary_rows_sum_to_one`/`test_svc_predict_proba_binary_monotone_in_decision`/`test_svc_predict_log_proba_equals_log_of_proba`/`test_svc_predict_proba_multiclass_rows_sum_to_one`/`test_sigmoid_predict_overflow_safe`/`test_multiclass_probability_binary_reduces_to_pairwise in svm.rs`. Non-test consumer: `fn fit in svm.rs` (SVC) consumes `self.probability` (the boundary `SVC`/`FittedSVC` types are re-exported at the crate root + consumed by `nu_svm.rs`). |
63//! | REQ-10 (ferray substrate) | NOT-STARTED | open #643. `svm.rs` imports `ndarray::{Array1, Array2, ScalarOperand}`, not `ferray-core`/`ferray::linalg` (R-SUBSTRATE). |
64//! | REQ-11 (non-finite input rejected) | SHIPPED | Both fit entries reject any NaN/+/-inf BEFORE the 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.")` / `"Input y contains NaN."` / `"… contains infinity …"`. `SVC::fit in svm.rs` checks `X` (`y` is `Array1<usize>` labels, finite by type); `SVR::fit in svm.rs` checks `X` AND the float target `y`. ferrolearn's `Fit::fit` signature has no `sample_weight` argument, so the sklearn `sample_weight`-finiteness raise has no fit-entry counterpart here. `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (the guard never fires on finite input — the 13+ SVC/SVR divergence pins stay green). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): NaN/+inf/-inf in X for both, NaN/inf in y for SVR, all raise `ValueError` (`tests/divergence_svm_nonfinite.rs::{svc_*,svr_*}`). Non-test consumer: the existing `Fit::fit` consumers + the crate-root `pub use svm::{SVC, SVR, …}` re-exports. (#2269) |
65
66use std::collections::HashMap;
67
68use ferrolearn_core::error::FerroError;
69use ferrolearn_core::traits::{Fit, Predict};
70use ndarray::{Array1, Array2, ScalarOperand};
71use num_traits::Float;
72
73// ---------------------------------------------------------------------------
74// Kernel trait and built-in kernels
75// ---------------------------------------------------------------------------
76
77/// The `gamma` coefficient for the RBF / polynomial / sigmoid kernels,
78/// mirroring scikit-learn's three-way `gamma` parameter
79/// (`sklearn/svm/_base.py:235-243`,
80/// `StrOptions({"scale", "auto"}) | Interval(Real, 0.0, None)`).
81///
82/// Resolved at fit time against the training matrix `X`
83/// ([`Kernel::resolved_for_fit`]):
84///
85/// - [`Gamma::Scale`] (default): `1 / (n_features · X.var())` where `X.var()`
86/// is the population variance (ddof=0) of the whole flattened `X`
87/// (`_base.py:238-239`). When `X.var() == 0` sklearn falls back to `1.0`.
88/// - [`Gamma::Auto`]: `1 / n_features` (`_base.py:240-241`).
89/// - [`Gamma::Value`]: the float verbatim (`_base.py:242-243`).
90///
91/// The default is [`Gamma::Scale`], matching sklearn's `gamma='scale'`.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub enum Gamma<F> {
94 /// `gamma='scale'` (sklearn default): `1 / (n_features · X.var())`.
95 Scale,
96 /// `gamma='auto'`: `1 / n_features`.
97 Auto,
98 /// An explicit float gamma, used verbatim.
99 Value(F),
100}
101
102impl<F> Default for Gamma<F> {
103 /// sklearn's default is `gamma='scale'`.
104 fn default() -> Self {
105 Gamma::Scale
106 }
107}
108
109/// Per-class scaling of the regularization parameter `C` for [`SVC`].
110///
111/// Mirrors `sklearn.svm.SVC`'s `class_weight` parameter
112/// (`sklearn/svm/_classes.py:118-124`, constraint `{None, dict, 'balanced'}`):
113/// it sets the C of class `i` to `class_weight[i]·C` (libsvm's per-class
114/// `weighted_C[i] = C·class_weight_[i]`). The expanded per-class weights are
115/// computed by [`compute_class_weight`] following
116/// `sklearn.utils.compute_class_weight` semantics, as called from
117/// `BaseSVC._validate_targets`
118/// (`self.class_weight_ = compute_class_weight(self.class_weight, classes=cls,
119/// y=y_)`, `sklearn/svm/_base.py:740`).
120///
121/// This mirrors [`crate::linear_svc::ClassWeight`] for cross-estimator
122/// consistency, but is defined locally (no cross-import of `linear_svc`
123/// internals).
124#[derive(Debug, Clone, Default)]
125pub enum ClassWeight<F> {
126 /// Uniform weights (all classes weighted `1.0`). The default
127 /// (`class_weight=None`).
128 #[default]
129 None,
130 /// Balanced weights `n_samples / (n_classes · count_c)` per class `c`,
131 /// matching `sklearn.utils.compute_class_weight("balanced", ...)`
132 /// (`_classes.py:122-124`: `n_samples / (n_classes * np.bincount(y))`).
133 Balanced,
134 /// Explicit class-label -> weight map. Classes absent from the map default
135 /// to `1.0`, matching the dict branch of `compute_class_weight`.
136 Explicit(Vec<(usize, F)>),
137}
138
139/// Compute the expanded per-class weight vector aligned to `classes`
140/// (sorted ascending, matching sklearn's `classes_ = np.unique(y)`).
141///
142/// Faithful to `sklearn.utils.compute_class_weight`, as called by
143/// `BaseSVC._validate_targets`
144/// (`compute_class_weight(self.class_weight, classes=cls, y=y_)`,
145/// `sklearn/svm/_base.py:740`):
146/// - `None` -> all `1.0`.
147/// - `Balanced` -> `n_samples / (n_classes · count_c)` per class `c`,
148/// where `count_c` is the number of samples with label `c`
149/// (`_classes.py:122-124`).
150/// - `Explicit(map)` -> `1.0` default, overridden by the map entries matched by
151/// class label.
152///
153/// `classes` is the sorted unique label set; `y` is the per-sample label array.
154/// Mirrors `ferrolearn_linear::linear_svc::compute_class_weight` exactly.
155fn compute_class_weight<F: Float>(cw: &ClassWeight<F>, classes: &[usize], y: &[usize]) -> Vec<F> {
156 match cw {
157 ClassWeight::None => vec![F::one(); classes.len()],
158 ClassWeight::Balanced => {
159 // `recip_freq = len(y) / (n_classes * bincount(y))`, indexed per
160 // class (`_classes.py:124`).
161 let n_samples = F::from(y.len()).unwrap_or_else(F::zero);
162 let n_classes = F::from(classes.len()).unwrap_or_else(F::one);
163 classes
164 .iter()
165 .map(|&c| {
166 let count = y.iter().filter(|&&label| label == c).count();
167 let count_f = F::from(count).unwrap_or_else(F::one);
168 if count_f > F::zero() {
169 n_samples / (n_classes * count_f)
170 } else {
171 F::one()
172 }
173 })
174 .collect()
175 }
176 ClassWeight::Explicit(map) => classes
177 .iter()
178 .map(|&c| {
179 map.iter()
180 .find(|(label, _)| *label == c)
181 .map_or_else(F::one, |(_, w)| *w)
182 })
183 .collect(),
184 }
185}
186
187/// A kernel function for SVM.
188///
189/// Computes the inner product of two vectors in a (possibly implicit)
190/// higher-dimensional feature space.
191pub trait Kernel<F: Float>: Clone + Send + Sync {
192 /// Compute the kernel value between two vectors.
193 fn compute(&self, x: &[F], y: &[F]) -> F;
194
195 /// Resolve any data-dependent kernel parameters against the training data
196 /// at fit time, returning a copy of the kernel with those parameters fixed.
197 ///
198 /// For kernels with a [`Gamma<F>`] parameter, the three-way `gamma`
199 /// resolution mirrors scikit-learn (`sklearn/svm/_base.py:235-243`):
200 /// [`Gamma::Scale`] (default) -> `1 / (n_features * X.var())` where
201 /// `X.var()` is the population variance (ddof=0) over the whole flattened
202 /// training matrix; [`Gamma::Auto`] -> `1 / n_features`; [`Gamma::Value`]
203 /// is left verbatim. After resolution the stored `gamma` is always a
204 /// concrete [`Gamma::Value`].
205 ///
206 /// The default implementation is a no-op (returns `self.clone()`), which is
207 /// correct for parameter-free kernels such as [`LinearKernel`].
208 #[must_use]
209 fn resolved_for_fit(&self, _x: &Array2<F>) -> Self
210 where
211 Self: Sized,
212 {
213 self.clone()
214 }
215
216 /// Whether this is the linear kernel `K(x, y) = x . y`.
217 ///
218 /// sklearn exposes `coef_` (the primal weight vector
219 /// `dual_coef_ @ support_vectors_`) ONLY for the linear kernel and raises
220 /// `AttributeError` otherwise (`sklearn/svm/_base.py:650-651`). The default
221 /// is `false`; [`LinearKernel`] overrides it to `true`.
222 #[must_use]
223 fn is_linear(&self) -> bool {
224 false
225 }
226}
227
228/// Compute the population variance (ddof=0) of all elements of `x`, mirroring
229/// numpy's `X.var()` (`mean((x - mean)^2)`). Returns `None` when `x` is empty.
230fn population_variance<F: Float>(x: &Array2<F>) -> Option<F> {
231 let n = x.len();
232 if n == 0 {
233 return None;
234 }
235 let count = F::from(n)?;
236 let sum = x.iter().fold(F::zero(), |acc, &v| acc + v);
237 let mean = sum / count;
238 let sq = x
239 .iter()
240 .fold(F::zero(), |acc, &v| acc + (v - mean) * (v - mean));
241 Some(sq / count)
242}
243
244/// Extract the concrete float from a [`Gamma<F>`] for a direct `compute` call
245/// without training data. After [`Kernel::resolved_for_fit`] the gamma is
246/// always a [`Gamma::Value`], so this is the live path; an unresolved
247/// `Scale`/`Auto` (e.g. a kernel used standalone outside a fit) has no `X` to
248/// resolve against and falls back to `1.0`, matching the prior default-gamma
249/// behavior of a directly-evaluated kernel.
250fn gamma_value_or_one<F: Float>(gamma: Gamma<F>) -> F {
251 match gamma {
252 Gamma::Value(v) => v,
253 Gamma::Scale | Gamma::Auto => F::one(),
254 }
255}
256
257/// Resolve a [`Gamma<F>`] spec against the training matrix `X`, returning the
258/// concrete float gamma, mirroring scikit-learn (`sklearn/svm/_base.py:235-243`):
259///
260/// - [`Gamma::Scale`] -> `1 / (n_features * X.var())` (`_base.py:238-239`).
261/// When `X.var() == 0` (constant `X`) or `X` is empty, sklearn falls back to
262/// `1.0` (`_base.py:239`: `if X_var != 0 else 1.0`), so we do the same
263/// (avoiding a non-finite gamma).
264/// - [`Gamma::Auto`] -> `1 / n_features` (`_base.py:240-241`).
265/// - [`Gamma::Value`] -> the float verbatim (`_base.py:242-243`).
266fn resolve_gamma<F: Float>(gamma: Gamma<F>, x: &Array2<F>) -> F {
267 match gamma {
268 Gamma::Value(v) => v,
269 Gamma::Auto => match F::from(x.ncols()) {
270 Some(nf) if nf > F::zero() => F::one() / nf,
271 _ => F::one(),
272 },
273 Gamma::Scale => {
274 let n_features = match F::from(x.ncols()) {
275 Some(nf) if nf > F::zero() => nf,
276 _ => return F::one(),
277 };
278 match population_variance(x) {
279 Some(var) if var > F::zero() => F::one() / (n_features * var),
280 // var == 0 (constant X) or empty: sklearn falls back to 1.0.
281 _ => F::one(),
282 }
283 }
284 }
285}
286
287/// Linear kernel: `K(x, y) = x . y`.
288#[derive(Debug, Clone, Copy)]
289pub struct LinearKernel;
290
291impl<F: Float> Kernel<F> for LinearKernel {
292 fn compute(&self, x: &[F], y: &[F]) -> F {
293 x.iter()
294 .zip(y.iter())
295 .fold(F::zero(), |acc, (&a, &b)| acc + a * b)
296 }
297
298 fn is_linear(&self) -> bool {
299 true
300 }
301}
302
303/// Radial Basis Function (Gaussian) kernel.
304///
305/// `K(x, y) = exp(-gamma * ||x - y||^2)`
306#[derive(Debug, Clone, Copy)]
307pub struct RbfKernel<F> {
308 /// The gamma parameter, a three-way [`Gamma<F>`] spec resolved at fit time
309 /// (`sklearn/svm/_base.py:235-243`). Default [`Gamma::Scale`]
310 /// (= `1 / (n_features * X.var())`); [`Gamma::Auto`] = `1 / n_features`;
311 /// [`Gamma::Value`] is used verbatim.
312 pub gamma: Gamma<F>,
313}
314
315impl<F: Float> RbfKernel<F> {
316 /// Create a new RBF kernel with the default `gamma='scale'`.
317 #[must_use]
318 pub fn new() -> Self {
319 Self {
320 gamma: Gamma::Scale,
321 }
322 }
323
324 /// Create a new RBF kernel with an explicit float gamma
325 /// (`gamma=<float>`, [`Gamma::Value`]).
326 #[must_use]
327 pub fn with_gamma(gamma: F) -> Self {
328 Self {
329 gamma: Gamma::Value(gamma),
330 }
331 }
332
333 /// Create a new RBF kernel with `gamma='scale'` ([`Gamma::Scale`],
334 /// sklearn's default = `1 / (n_features * X.var())`).
335 #[must_use]
336 pub fn with_gamma_scale() -> Self {
337 Self {
338 gamma: Gamma::Scale,
339 }
340 }
341
342 /// Create a new RBF kernel with `gamma='auto'` ([`Gamma::Auto`]
343 /// = `1 / n_features`).
344 #[must_use]
345 pub fn with_gamma_auto() -> Self {
346 Self { gamma: Gamma::Auto }
347 }
348}
349
350impl<F: Float> Default for RbfKernel<F> {
351 fn default() -> Self {
352 Self::new()
353 }
354}
355
356impl<F: Float + Send + Sync> Kernel<F> for RbfKernel<F> {
357 fn compute(&self, x: &[F], y: &[F]) -> F {
358 let gamma = gamma_value_or_one(self.gamma);
359 let sq_dist = x.iter().zip(y.iter()).fold(F::zero(), |acc, (&a, &b)| {
360 let d = a - b;
361 acc + d * d
362 });
363 (-gamma * sq_dist).exp()
364 }
365
366 fn resolved_for_fit(&self, x: &Array2<F>) -> Self {
367 Self {
368 gamma: Gamma::Value(resolve_gamma(self.gamma, x)),
369 }
370 }
371}
372
373/// Polynomial kernel: `K(x, y) = (gamma * x . y + coef0)^degree`.
374#[derive(Debug, Clone, Copy)]
375pub struct PolynomialKernel<F> {
376 /// The gamma parameter, a three-way [`Gamma<F>`] spec resolved at fit time
377 /// (`sklearn/svm/_base.py:235-243`). Default [`Gamma::Scale`].
378 pub gamma: Gamma<F>,
379 /// Polynomial degree.
380 pub degree: usize,
381 /// Independent term.
382 pub coef0: F,
383}
384
385impl<F: Float> PolynomialKernel<F> {
386 /// Create a new polynomial kernel with defaults (`gamma='scale'`,
387 /// `degree=3`, `coef0=0`).
388 #[must_use]
389 pub fn new() -> Self {
390 Self {
391 gamma: Gamma::Scale,
392 degree: 3,
393 coef0: F::zero(),
394 }
395 }
396}
397
398impl<F: Float> Default for PolynomialKernel<F> {
399 fn default() -> Self {
400 Self::new()
401 }
402}
403
404impl<F: Float + Send + Sync> Kernel<F> for PolynomialKernel<F> {
405 fn compute(&self, x: &[F], y: &[F]) -> F {
406 let gamma = gamma_value_or_one(self.gamma);
407 let dot: F = x
408 .iter()
409 .zip(y.iter())
410 .fold(F::zero(), |acc, (&a, &b)| acc + a * b);
411 let val = gamma * dot + self.coef0;
412 let mut result = F::one();
413 for _ in 0..self.degree {
414 result = result * val;
415 }
416 result
417 }
418
419 fn resolved_for_fit(&self, x: &Array2<F>) -> Self {
420 Self {
421 gamma: Gamma::Value(resolve_gamma(self.gamma, x)),
422 degree: self.degree,
423 coef0: self.coef0,
424 }
425 }
426}
427
428/// Sigmoid kernel: `K(x, y) = tanh(gamma * x . y + coef0)`.
429#[derive(Debug, Clone, Copy)]
430pub struct SigmoidKernel<F> {
431 /// The gamma parameter, a three-way [`Gamma<F>`] spec resolved at fit time
432 /// (`sklearn/svm/_base.py:235-243`). Default [`Gamma::Scale`].
433 pub gamma: Gamma<F>,
434 /// Independent term.
435 pub coef0: F,
436}
437
438impl<F: Float> SigmoidKernel<F> {
439 /// Create a new sigmoid kernel with defaults (`gamma='scale'`, `coef0=0`).
440 #[must_use]
441 pub fn new() -> Self {
442 Self {
443 gamma: Gamma::Scale,
444 coef0: F::zero(),
445 }
446 }
447}
448
449impl<F: Float> Default for SigmoidKernel<F> {
450 fn default() -> Self {
451 Self::new()
452 }
453}
454
455impl<F: Float + Send + Sync> Kernel<F> for SigmoidKernel<F> {
456 fn compute(&self, x: &[F], y: &[F]) -> F {
457 let gamma = gamma_value_or_one(self.gamma);
458 let dot: F = x
459 .iter()
460 .zip(y.iter())
461 .fold(F::zero(), |acc, (&a, &b)| acc + a * b);
462 (gamma * dot + self.coef0).tanh()
463 }
464
465 fn resolved_for_fit(&self, x: &Array2<F>) -> Self {
466 Self {
467 gamma: Gamma::Value(resolve_gamma(self.gamma, x)),
468 coef0: self.coef0,
469 }
470 }
471}
472
473// ---------------------------------------------------------------------------
474// Kernel cache (LRU)
475// ---------------------------------------------------------------------------
476
477/// Simple LRU cache for kernel evaluations.
478struct KernelCache<F> {
479 cache: HashMap<(usize, usize), F>,
480 order: Vec<(usize, usize)>,
481 capacity: usize,
482}
483
484impl<F: Float> KernelCache<F> {
485 fn new(capacity: usize) -> Self {
486 Self {
487 cache: HashMap::with_capacity(capacity),
488 order: Vec::with_capacity(capacity),
489 capacity,
490 }
491 }
492
493 fn get_or_compute<K: Kernel<F>>(
494 &mut self,
495 i: usize,
496 j: usize,
497 kernel: &K,
498 data: &[Vec<F>],
499 ) -> F {
500 let key = if i <= j { (i, j) } else { (j, i) };
501 if let Some(&val) = self.cache.get(&key) {
502 return val;
503 }
504 let val = kernel.compute(&data[i], &data[j]);
505 if self.order.len() >= self.capacity
506 && let Some(old_key) = self.order.first().copied()
507 {
508 self.cache.remove(&old_key);
509 self.order.remove(0);
510 }
511 self.cache.insert(key, val);
512 self.order.push(key);
513 val
514 }
515}
516
517// ---------------------------------------------------------------------------
518// SMO solver for binary SVM
519// ---------------------------------------------------------------------------
520
521/// Result of a binary SMO solve.
522struct SmoResult<F> {
523 alphas: Vec<F>,
524 bias: F,
525}
526
527/// SMO implementation (Platt 1998, Fan-Chen-Lin 2005 WSS).
528///
529/// Uses the dual gradient `grad_i = (Q * alpha)_i - 1` where
530/// `Q_{ij} = y_i * y_j * K(x_i, x_j)`. Bias is computed after
531/// convergence from the KKT conditions.
532#[allow(
533 clippy::too_many_arguments,
534 reason = "the per-class box bounds (cp, cn) are separate args mirroring \
535 libsvm's per-sample upper bound C_i (Cp for y=+1, Cn for y=-1)"
536)]
537fn smo_binary<F: Float, K: Kernel<F>>(
538 data: &[Vec<F>],
539 labels: &[F],
540 kernel: &K,
541 cp: F,
542 cn: F,
543 tol: F,
544 max_iter: usize,
545 cache_size: usize,
546) -> Result<SmoResult<F>, FerroError> {
547 let n = data.len();
548 let mut alphas = vec![F::zero(); n];
549 let mut cache = KernelCache::new(cache_size);
550
551 // Per-sample box upper bound `C_i = (y_i > 0 ? Cp : Cn)` (libsvm `GETI`):
552 // `class_weight` scales C per class so the +1 group (class_pos) gets `Cp`
553 // and the -1 group (class_neg) gets `Cn`. When `cp == cn` the box is the
554 // uniform `[0, C]` of the no-class-weight case.
555 let c_of = |i: usize| -> F { if labels[i] > F::zero() { cp } else { cn } };
556
557 // Gradient of the dual objective: grad_i = (Q*alpha)_i - 1
558 // where Q_{ij} = y_i * y_j * K(x_i, x_j).
559 // Initially alpha = 0, so grad_i = -1 for all i.
560 let mut grad: Vec<F> = vec![-F::one(); n];
561
562 let two = F::one() + F::one();
563 let eps = F::from(1e-12).unwrap_or_else(F::epsilon);
564
565 // `max_iter == 0` is the sklearn `max_iter=-1` ("no iteration limit",
566 // libsvm runs to convergence) sentinel — the SMO loop then runs until the
567 // KKT gap closes. A non-zero `max_iter` caps the iteration count.
568 let mut iter = 0usize;
569 loop {
570 if max_iter != 0 && iter >= max_iter {
571 break;
572 }
573 iter += 1;
574 // Working set selection (Fan-Chen-Lin 2005):
575 // I_up = {i : (y_i=+1 and alpha_i < C) or (y_i=-1 and alpha_i > 0)}
576 // I_low = {j : (y_j=+1 and alpha_j > 0) or (y_j=-1 and alpha_j < C)}
577 // Select i = argmax_{t in I_up} -y_t * grad_t
578 // Select j = argmin_{t in I_low} -y_t * grad_t
579
580 let mut i_up = None;
581 let mut max_val = F::neg_infinity();
582 let mut j_low = None;
583 let mut min_val = F::infinity();
584
585 for t in 0..n {
586 let val = -labels[t] * grad[t];
587 let c_t = c_of(t);
588
589 let in_up = (labels[t] > F::zero() && alphas[t] < c_t - eps)
590 || (labels[t] < F::zero() && alphas[t] > eps);
591
592 let in_low = (labels[t] > F::zero() && alphas[t] > eps)
593 || (labels[t] < F::zero() && alphas[t] < c_t - eps);
594
595 if in_up && val > max_val {
596 max_val = val;
597 i_up = Some(t);
598 }
599 if in_low && val < min_val {
600 min_val = val;
601 j_low = Some(t);
602 }
603 }
604
605 // Stopping criterion: KKT gap < tol
606 if i_up.is_none() || j_low.is_none() || max_val - min_val < tol {
607 break;
608 }
609
610 let i = i_up.unwrap();
611 let j = j_low.unwrap();
612
613 if i == j {
614 break;
615 }
616
617 // Compute second-order info
618 let kii = cache.get_or_compute(i, i, kernel, data);
619 let kjj = cache.get_or_compute(j, j, kernel, data);
620 let kij = cache.get_or_compute(i, j, kernel, data);
621 let eta = kii + kjj - two * kij;
622
623 if eta <= eps {
624 continue;
625 }
626
627 // Bounds for alpha_j, respecting the per-sample box bounds
628 // `0 <= alpha_i <= C_i` and `0 <= alpha_j <= C_j` (libsvm allows a
629 // different upper bound per sample under `class_weight`).
630 let old_ai = alphas[i];
631 let old_aj = alphas[j];
632 let ci = c_of(i);
633 let cj = c_of(j);
634
635 let (lo, hi) = if labels[i] == labels[j] {
636 // alpha_i + alpha_j = sum (const): alpha_j in
637 // [max(0, sum - C_i), min(C_j, sum)].
638 let sum = old_ai + old_aj;
639 ((sum - ci).max(F::zero()), sum.min(cj))
640 } else {
641 // alpha_i = alpha_j - diff (const diff): alpha_j in
642 // [max(0, diff), min(C_j, C_i + diff)].
643 let diff = old_aj - old_ai;
644 (diff.max(F::zero()), (ci + diff).min(cj))
645 };
646
647 if (hi - lo).abs() < eps {
648 continue;
649 }
650
651 // Analytic update for alpha_j (Platt 1998).
652 // E_k = y_k * grad_k (dual error, where grad = Q*alpha - e).
653 // alpha_j_new = alpha_j + y_j * (E_i - E_j) / eta
654 // = alpha_j + y_j * (y_i * grad_i - y_j * grad_j) / eta
655 let mut new_aj = old_aj + labels[j] * (labels[i] * grad[i] - labels[j] * grad[j]) / eta;
656
657 // Clip to [lo, hi]
658 if new_aj > hi {
659 new_aj = hi;
660 }
661 if new_aj < lo {
662 new_aj = lo;
663 }
664
665 if (new_aj - old_aj).abs() < eps {
666 continue;
667 }
668
669 let new_ai = old_ai + labels[i] * labels[j] * (old_aj - new_aj);
670
671 alphas[i] = new_ai;
672 alphas[j] = new_aj;
673
674 // Update dual gradient: grad_k += delta_alpha_i * Q_{k,i} + delta_alpha_j * Q_{k,j}
675 // where Q_{k,t} = y_k * y_t * K(k,t)
676 let delta_ai = new_ai - old_ai;
677 let delta_aj = new_aj - old_aj;
678
679 for (k, grad_k) in grad.iter_mut().enumerate() {
680 let kki = cache.get_or_compute(k, i, kernel, data);
681 let kkj = cache.get_or_compute(k, j, kernel, data);
682 *grad_k = *grad_k
683 + delta_ai * labels[k] * labels[i] * kki
684 + delta_aj * labels[k] * labels[j] * kkj;
685 }
686 }
687
688 // Compute bias from KKT conditions.
689 // For support vectors with 0 < alpha_i < C:
690 // y_i * (sum_j alpha_j * y_j * K(i,j) + b) = 1
691 // b = y_i - sum_j alpha_j * y_j * K(i,j)
692 // (since y_i^2 = 1, y_i * (y_i * f) = f, so b = 1/y_i - sum = y_i - sum)
693 let mut b_sum = F::zero();
694 let mut b_count = 0usize;
695
696 for i in 0..n {
697 if alphas[i] > eps && alphas[i] < c_of(i) - eps {
698 // This is a free support vector (`0 < alpha_i < C_i`).
699 let mut f_no_b = F::zero();
700 for j in 0..n {
701 if alphas[j] > eps {
702 f_no_b =
703 f_no_b + alphas[j] * labels[j] * cache.get_or_compute(i, j, kernel, data);
704 }
705 }
706 b_sum = b_sum + labels[i] - f_no_b;
707 b_count += 1;
708 }
709 }
710
711 let bias = if b_count > 0 {
712 b_sum / F::from(b_count).unwrap()
713 } else {
714 // Fallback: use all support vectors (bounded ones too)
715 let mut b_sum_all = F::zero();
716 let mut b_count_all = 0usize;
717 for i in 0..n {
718 if alphas[i] > eps {
719 let mut f_no_b = F::zero();
720 for j in 0..n {
721 if alphas[j] > eps {
722 f_no_b = f_no_b
723 + alphas[j] * labels[j] * cache.get_or_compute(i, j, kernel, data);
724 }
725 }
726 b_sum_all = b_sum_all + labels[i] - f_no_b;
727 b_count_all += 1;
728 }
729 }
730 if b_count_all > 0 {
731 b_sum_all / F::from(b_count_all).unwrap()
732 } else {
733 F::zero()
734 }
735 };
736
737 Ok(SmoResult { alphas, bias })
738}
739
740// ---------------------------------------------------------------------------
741// Platt scaling (probability estimates)
742// ---------------------------------------------------------------------------
743
744/// Fit the Platt sigmoid `P(y=+1 | f) = 1 / (1 + exp(A·f + B))` to a set of
745/// decision values `dec_values` with binary labels `labels` (`+1` / `-1`),
746/// returning the `(A, B)` parameters.
747///
748/// A faithful transcription of libsvm's `sigmoid_train`
749/// (`sklearn/svm/src/libsvm/svm.cpp:1919-2030`): the prior-based initial point
750/// (`A=0`, `B=log((prior0+1)/(prior1+1))`), the `t` target smoothing
751/// (`hiTarget=(prior1+1)/(prior1+2)`, `loTarget=1/(prior0+2)`), the Newton
752/// iteration with the regularized Hessian (`H' = H + sigma·I`,
753/// `sigma=1e-12`), the gradient/Hessian accumulation, the step-halving line
754/// search (`min_step=1e-10`, sufficient-decrease constant `0.0001`),
755/// `max_iter=100`, and the `eps=1e-5` gradient stopping criterion. The
756/// overflow-safe `fApB>=0` branching matches the C code exactly.
757#[allow(
758 clippy::too_many_lines,
759 reason = "a faithful one-to-one transcription of libsvm's sigmoid_train \
760 Newton loop (svm.cpp:1919-2030); splitting it would obscure the \
761 line-by-line correspondence to the C oracle"
762)]
763fn sigmoid_train<F: Float>(dec_values: &[F], labels: &[F]) -> (F, F) {
764 let l = dec_values.len();
765 let zero = F::zero();
766 let one = F::one();
767 let two = one + one;
768
769 let mut prior1 = zero;
770 let mut prior0 = zero;
771 for &lab in labels {
772 if lab > zero {
773 prior1 = prior1 + one;
774 } else {
775 prior0 = prior0 + one;
776 }
777 }
778
779 let max_iter = 100usize;
780 let min_step = F::from(1e-10).unwrap_or_else(F::epsilon);
781 let sigma = F::from(1e-12).unwrap_or_else(F::epsilon);
782 let eps = F::from(1e-5).unwrap_or_else(F::epsilon);
783 let suff = F::from(0.0001).unwrap_or_else(F::epsilon);
784
785 let hi_target = (prior1 + one) / (prior1 + two);
786 let lo_target = one / (prior0 + two);
787
788 // Per-sample target smoothed labels `t`.
789 let t: Vec<F> = labels
790 .iter()
791 .map(|&lab| if lab > zero { hi_target } else { lo_target })
792 .collect();
793
794 // Initial point and initial function value.
795 let mut a = zero;
796 let mut b = ((prior0 + one) / (prior1 + one)).ln();
797
798 let funcval = |a: F, b: F| -> F {
799 let mut fval = zero;
800 for i in 0..l {
801 let f_ap_b = dec_values[i] * a + b;
802 if f_ap_b >= zero {
803 fval = fval + t[i] * f_ap_b + (one + (-f_ap_b).exp()).ln();
804 } else {
805 fval = fval + (t[i] - one) * f_ap_b + (one + f_ap_b.exp()).ln();
806 }
807 }
808 fval
809 };
810
811 let mut fval = funcval(a, b);
812
813 for _iter in 0..max_iter {
814 // Update gradient and Hessian (H' = H + sigma·I).
815 let mut h11 = sigma;
816 let mut h22 = sigma;
817 let mut h21 = zero;
818 let mut g1 = zero;
819 let mut g2 = zero;
820 for i in 0..l {
821 let f_ap_b = dec_values[i] * a + b;
822 let (p, q) = if f_ap_b >= zero {
823 let e = (-f_ap_b).exp();
824 (e / (one + e), one / (one + e))
825 } else {
826 let e = f_ap_b.exp();
827 (one / (one + e), e / (one + e))
828 };
829 let d2 = p * q;
830 h11 = h11 + dec_values[i] * dec_values[i] * d2;
831 h22 = h22 + d2;
832 h21 = h21 + dec_values[i] * d2;
833 let d1 = t[i] - p;
834 g1 = g1 + dec_values[i] * d1;
835 g2 = g2 + d1;
836 }
837
838 // Stopping criterion.
839 if g1.abs() < eps && g2.abs() < eps {
840 break;
841 }
842
843 // Newton direction: -inv(H')·g.
844 let det = h11 * h22 - h21 * h21;
845 let d_a = -(h22 * g1 - h21 * g2) / det;
846 let d_b = -(-h21 * g1 + h11 * g2) / det;
847 let gd = g1 * d_a + g2 * d_b;
848
849 // Line search (step halving).
850 let mut stepsize = one;
851 while stepsize >= min_step {
852 let new_a = a + stepsize * d_a;
853 let new_b = b + stepsize * d_b;
854 let newf = funcval(new_a, new_b);
855 if newf < fval + suff * stepsize * gd {
856 a = new_a;
857 b = new_b;
858 fval = newf;
859 break;
860 }
861 stepsize = stepsize / two;
862 }
863
864 if stepsize < min_step {
865 // Line search failed — libsvm bails out of the Newton loop.
866 break;
867 }
868 }
869
870 (a, b)
871}
872
873/// Evaluate the Platt sigmoid `P(y=+1 | f) = 1 / (1 + exp(A·f + B))` at a single
874/// decision value, in the overflow-safe form of libsvm's `sigmoid_predict`
875/// (`sklearn/svm/src/libsvm/svm.cpp:2032-2040`):
876/// `fApB = decision·A + B`; if `fApB >= 0` return `exp(-fApB)/(1+exp(-fApB))`,
877/// else `1/(1+exp(fApB))` (avoiding `exp` overflow / catastrophic
878/// cancellation).
879fn sigmoid_predict<F: Float>(decision: F, a: F, b: F) -> F {
880 let f_ap_b = decision * a + b;
881 if f_ap_b >= F::zero() {
882 let e = (-f_ap_b).exp();
883 e / (F::one() + e)
884 } else {
885 F::one() / (F::one() + f_ap_b.exp())
886 }
887}
888
889/// Wu-Lin-Weng (2004) pairwise coupling ("Method 2"): given the `k×k` pairwise
890/// probability matrix `r` (where `r[i][j] = P(class i | class i or j)`),
891/// produce the `k` coupled class probabilities `p`.
892///
893/// A faithful transcription of libsvm's `multiclass_probability`
894/// (`sklearn/svm/src/libsvm/svm.cpp:2043-2104`): build the `Q` matrix from the
895/// pairwise probabilities, then run the fixed-point iteration
896/// (`max_iter = max(100, k)`, `eps = 0.005/k`) that minimizes the coupling
897/// objective, normalized so the returned probabilities sum to 1.
898fn multiclass_probability<F: Float>(k: usize, r: &Array2<F>) -> Vec<F> {
899 let zero = F::zero();
900 let one = F::one();
901 let k_f = F::from(k).unwrap_or(one);
902
903 let mut p = vec![one / k_f; k];
904 // Q[t][j].
905 let mut q = Array2::<F>::zeros((k, k));
906 for t in 0..k {
907 for j in 0..t {
908 q[[t, t]] = q[[t, t]] + r[[j, t]] * r[[j, t]];
909 q[[t, j]] = q[[j, t]];
910 }
911 for j in (t + 1)..k {
912 q[[t, t]] = q[[t, t]] + r[[j, t]] * r[[j, t]];
913 q[[t, j]] = -r[[j, t]] * r[[t, j]];
914 }
915 }
916
917 let max_iter = 100.max(k);
918 let eps = F::from(0.005).unwrap_or_else(F::epsilon) / k_f;
919 let mut qp = vec![zero; k];
920
921 for _iter in 0..max_iter {
922 // Recompute Qp, pQp for numerical accuracy.
923 let mut p_qp = zero;
924 for t in 0..k {
925 qp[t] = zero;
926 for j in 0..k {
927 qp[t] = qp[t] + q[[t, j]] * p[j];
928 }
929 p_qp = p_qp + p[t] * qp[t];
930 }
931 let mut max_error = zero;
932 for &qpt in qp.iter().take(k) {
933 let error = (qpt - p_qp).abs();
934 if error > max_error {
935 max_error = error;
936 }
937 }
938 if max_error < eps {
939 break;
940 }
941
942 for t in 0..k {
943 let qtt = q[[t, t]];
944 if qtt == zero {
945 continue;
946 }
947 let diff = (-qp[t] + p_qp) / qtt;
948 p[t] = p[t] + diff;
949 p_qp = (p_qp + diff * (diff * qtt + two_qp(qp[t]))) / (one + diff) / (one + diff);
950 for j in 0..k {
951 qp[j] = (qp[j] + diff * q[[t, j]]) / (one + diff);
952 p[j] = p[j] / (one + diff);
953 }
954 }
955 }
956
957 p
958}
959
960/// `2·x` helper for [`multiclass_probability`] (libsvm `2*Qp[t]`).
961#[inline]
962fn two_qp<F: Float>(x: F) -> F {
963 x + x
964}
965
966/// Decision value of a freshly-trained binary SMO sub-model on a query sample,
967/// in ferrolearn's sign convention (positive favors the `+1` label, i.e. the
968/// higher-index `class_pos` group).
969fn sub_decision_value<F: Float, K: Kernel<F>>(
970 sv_data: &[Vec<F>],
971 sv_coefs: &[F],
972 bias: F,
973 kernel: &K,
974 q: &[F],
975) -> F {
976 let mut val = bias;
977 for (sv, &coef) in sv_data.iter().zip(sv_coefs.iter()) {
978 val = val + coef * kernel.compute(sv, q);
979 }
980 val
981}
982
983/// A freshly-trained binary sub-model in this crate's (ferrolearn) sign
984/// convention: support-vector feature rows, their coefficients
985/// (`alpha_i·y_i`, `class_pos = +1` side), and the decision bias such that
986/// [`sub_decision_value`] is positive favoring `class_pos`. Returned by the
987/// per-solver TRAINER closure that [`platt_cv_sigmoid`] invokes on each CV
988/// training fold.
989pub(crate) type SubModel<F> = (Vec<Vec<F>>, Vec<F>, F);
990
991/// Fit the per-ovo-pair Platt sigmoid `(A, B)` via a DETERMINISTIC 5-fold CV
992/// over the pair's samples, mirroring libsvm's `svm_binary_svc_probability`
993/// (`sklearn/svm/src/libsvm/svm.cpp:2107-2203`) EXCEPT for the fold
994/// permutation.
995///
996/// libsvm shuffles the fold assignment with an RNG seeded by `random_state`
997/// (`svm.cpp:2116-2122`), which makes the resulting `(A, B)` (sklearn's
998/// `probA_`/`probB_`) and thus `predict_proba` NON-DETERMINISTIC across
999/// `random_state`. To keep ferrolearn deterministic (it has no libsvm RNG
1000/// seed; cf. the documented SGD shuffle boundary, R-DEV-4), the folds here use a
1001/// DETERMINISTIC CLASS-STRATIFIED assignment instead of libsvm's random shuffle:
1002/// each sample's fold is its WITHIN-CLASS running index modulo `nr_fold`. Because
1003/// the per-ovo-pair samples arrive GROUPED by class, a naive contiguous
1004/// `[i·l/5, (i+1)·l/5)` split would make whole folds single-class — so the 4-fold
1005/// training set could miss a class entirely and `sigmoid_train` would collapse to
1006/// the degenerate `(A, B) = (0, 0)`. Stratifying within class keeps both classes
1007/// in every training set (when each class has ≥2 samples), restoring libsvm's
1008/// structural contract (a non-degenerate sigmoid) without its randomness. The
1009/// rest is a faithful transcription: train a binary sub-model on the 4 training
1010/// folds,
1011/// `predict_values` the held-out fold (in libsvm sign), with the degenerate
1012/// one-class-fold fallbacks (`+1` / `-1` / `0`, `svm.cpp:2161-2169`), then
1013/// [`sigmoid_train`] over all out-of-fold decisions.
1014///
1015/// # The `train_fold` trainer abstraction
1016///
1017/// libsvm's `svm_binary_svc_probability` trains each CV sub-model with the
1018/// SAME `svm_type` as the outer model (`svm.cpp:2147-2150`, a copy of the
1019/// outer `svm_parameter` with `probability=0`): C-SVC sub-models for `SVC`,
1020/// NU-SVC sub-models for `NuSVC`. ferrolearn threads that choice through a
1021/// `train_fold` closure: given the training-fold `(data, labels)` (in
1022/// ferrolearn sign, `class_pos = +1`), it returns the fitted [`SubModel`]
1023/// (`Some`) or `None` on a degenerate/failed sub-solve. `SVC` passes a
1024/// closure wrapping [`smo_binary`] (C-SVC); `NuSVC` passes a closure wrapping
1025/// [`solve_nu_svc`] (the genuine `Solver_NU`). The CV split, degenerate-fold
1026/// fallbacks, held-out scoring via [`sub_decision_value`], and the final
1027/// [`sigmoid_train`] are SOLVER-AGNOSTIC, so SVC's `(A, B)` is byte-identical
1028/// to the pre-refactor inline-`smo_binary` path.
1029///
1030/// `sub_labels` is ferrolearn's sign (`+1` = higher-index `class_pos`,
1031/// `-1` = lower-index `class_neg`). The decision values and labels passed to
1032/// [`sigmoid_train`] are converted to libsvm sign (`+1` = lower-index
1033/// `class_neg`, matching `raw_ovo`) so the fitted `(A, B)` is consistent with
1034/// the raw ovo decision used by [`FittedSVC::predict_proba`].
1035pub(crate) fn platt_cv_sigmoid<F: Float, K: Kernel<F>>(
1036 sub_data: &[Vec<F>],
1037 sub_labels: &[F],
1038 kernel: &K,
1039 train_fold: impl Fn(&[Vec<F>], &[F]) -> Option<SubModel<F>>,
1040) -> (F, F) {
1041 let l = sub_data.len();
1042 let nr_fold = 5usize;
1043 // Out-of-fold decision value per sample, in libsvm sign (+1 = class_neg).
1044 let mut dec_values = vec![F::zero(); l];
1045
1046 // DETERMINISTIC class-stratified fold assignment. libsvm shuffles the fold
1047 // permutation with an RNG (`svm.cpp:2116-2122`) so each fold mixes both
1048 // classes; ferrolearn stays deterministic (no libsvm RNG seed; cf. the
1049 // sanctioned SGD-shuffle boundary, R-DEV-4) by instead assigning each sample
1050 // to a fold by its WITHIN-CLASS running index modulo `nr_fold`. The
1051 // per-ovo-pair samples arrive GROUPED by class (`[class_neg..., class_pos...]`,
1052 // built by the `FittedSVC::fit` loop), so a CONTIGUOUS `[i·l/5, (i+1)·l/5)`
1053 // split would make whole folds single-class and the 4-fold training set could
1054 // MISS a class entirely → a trivial sub-model → constant held-out decisions →
1055 // `sigmoid_train` returns the degenerate `(A, B) = (0, 0)`. Spreading each
1056 // class proportionally across all folds keeps BOTH classes in every training
1057 // set whenever each class has ≥2 samples, restoring the structural contract
1058 // (a non-degenerate sigmoid) at every input — matching libsvm's intent
1059 // without its randomness.
1060 let mut pos_seen = 0usize;
1061 let mut neg_seen = 0usize;
1062 let mut fold_of = vec![0usize; l];
1063 for (j, &lab) in sub_labels.iter().enumerate() {
1064 if lab > F::zero() {
1065 fold_of[j] = pos_seen % nr_fold;
1066 pos_seen += 1;
1067 } else {
1068 fold_of[j] = neg_seen % nr_fold;
1069 neg_seen += 1;
1070 }
1071 }
1072
1073 for fold in 0..nr_fold {
1074 // Training set = all samples NOT assigned to this fold.
1075 let mut tr_data: Vec<Vec<F>> = Vec::with_capacity(l);
1076 let mut tr_labels: Vec<F> = Vec::with_capacity(l);
1077 for (j, row) in sub_data.iter().enumerate() {
1078 if fold_of[j] != fold {
1079 tr_data.push(row.clone());
1080 tr_labels.push(sub_labels[j]);
1081 }
1082 }
1083
1084 // Count classes in the training folds (ferrolearn sign).
1085 let mut p_count = 0usize;
1086 let mut n_count = 0usize;
1087 for &lab in &tr_labels {
1088 if lab > F::zero() {
1089 p_count += 1;
1090 } else {
1091 n_count += 1;
1092 }
1093 }
1094
1095 // Degenerate folds: libsvm assigns a constant decision
1096 // (`svm.cpp:2161-2169`). In ferrolearn sign a held-out sample gets
1097 // +1 (all-positive train), -1 (all-negative train), or 0 (empty); we
1098 // store the libsvm-sign value = negation. The held-out fold is now the
1099 // (non-contiguous) set `{ j : fold_of[j] == fold }`, not a slice.
1100 let held_out = (0..l).filter(|&j| fold_of[j] == fold);
1101 if p_count == 0 && n_count == 0 {
1102 for j in held_out {
1103 dec_values[j] = F::zero();
1104 }
1105 continue;
1106 } else if n_count == 0 {
1107 // train all +1 (class_pos) -> ferrolearn dec +1 -> libsvm -1.
1108 for j in held_out {
1109 dec_values[j] = -F::one();
1110 }
1111 continue;
1112 } else if p_count == 0 {
1113 for j in held_out {
1114 dec_values[j] = F::one();
1115 }
1116 continue;
1117 }
1118
1119 // Train a probability-free sub-model on the training folds via the
1120 // per-solver trainer (C-SVC for SVC, NU-SVC for NuSVC).
1121 let Some((sv_data, sv_coefs, bias)) = train_fold(&tr_data, &tr_labels) else {
1122 // A failed/degenerate sub-solve falls back to a neutral 0 decision.
1123 for j in held_out {
1124 dec_values[j] = F::zero();
1125 }
1126 continue;
1127 };
1128
1129 // Score the held-out fold; store in libsvm sign (negate ferrolearn).
1130 for j in held_out {
1131 let dec_ferro = sub_decision_value(&sv_data, &sv_coefs, bias, kernel, &sub_data[j]);
1132 dec_values[j] = -dec_ferro;
1133 }
1134 }
1135
1136 // libsvm labels for sigmoid_train: +1 = lower-index class_neg, matching
1137 // the libsvm-sign decision values (so `-sub_labels`).
1138 let libsvm_labels: Vec<F> = sub_labels.iter().map(|&lab| -lab).collect();
1139 sigmoid_train(&dec_values, &libsvm_labels)
1140}
1141
1142// ---------------------------------------------------------------------------
1143// decision_function shape + scores
1144// ---------------------------------------------------------------------------
1145
1146/// The shape convention for [`FittedSVC::decision_function`] in the multiclass
1147/// case, mirroring scikit-learn's `SVC.decision_function_shape`
1148/// (`sklearn/svm/_base.py:778-781`).
1149///
1150/// - [`SvmDecisionShape::Ovr`] (default): one-vs-rest scores, shape
1151/// `(n_samples, n_classes)`, produced by the `_ovr_decision_function`
1152/// transform (`sklearn/utils/multiclass.py:520-562`).
1153/// - [`SvmDecisionShape::Ovo`]: the raw one-vs-one decision values, shape
1154/// `(n_samples, n_class·(n_class-1)/2)`.
1155///
1156/// The binary case is unaffected (it always collapses to a 1-D `(n_samples,)`
1157/// score, `_base.py:538-539`).
1158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1159pub enum SvmDecisionShape {
1160 /// One-vs-rest: `(n_samples, n_classes)` via `_ovr_decision_function`
1161 /// (sklearn's default).
1162 #[default]
1163 Ovr,
1164 /// One-vs-one: raw `(n_samples, n_class·(n_class-1)/2)` decision values.
1165 Ovo,
1166}
1167
1168/// The result of [`FittedSVC::decision_function`].
1169///
1170/// Mirrors scikit-learn's polymorphic `SVC.decision_function` return
1171/// (`sklearn/svm/_base.py:536-541, 778-781`): the binary case collapses the
1172/// single ovo column to a 1-D `(n_samples,)` array (`-dec.ravel()`,
1173/// `_base.py:538-539`), while the multiclass case returns
1174/// `(n_samples, n_classes)` (ovr, default) or
1175/// `(n_samples, n·(n-1)/2)` (ovo). Structurally parallels
1176/// [`crate::linear_svc::DecisionScores`] for cross-estimator consistency.
1177#[derive(Debug, Clone, PartialEq)]
1178pub enum SvmScores<F> {
1179 /// Binary decision values, shape `(n_samples,)`. A POSITIVE value predicts
1180 /// `classes_[1]` (`-dec.ravel()`, `_base.py:538-539`).
1181 Binary(Array1<F>),
1182 /// Multiclass decision values: `(n_samples, n_classes)` for
1183 /// [`SvmDecisionShape::Ovr`] or `(n_samples, n·(n-1)/2)` for
1184 /// [`SvmDecisionShape::Ovo`].
1185 Multiclass(Array2<F>),
1186}
1187
1188impl<F: Clone> SvmScores<F> {
1189 /// Number of samples scored (the leading axis length in both variants).
1190 #[must_use]
1191 pub fn n_samples(&self) -> usize {
1192 match self {
1193 SvmScores::Binary(v) => v.len(),
1194 SvmScores::Multiclass(m) => m.nrows(),
1195 }
1196 }
1197
1198 /// Borrow the binary 1-D scores, if this is the binary case.
1199 #[must_use]
1200 pub fn as_binary(&self) -> Option<&Array1<F>> {
1201 match self {
1202 SvmScores::Binary(v) => Some(v),
1203 SvmScores::Multiclass(_) => None,
1204 }
1205 }
1206
1207 /// Borrow the multiclass score matrix, if this is the multiclass case.
1208 #[must_use]
1209 pub fn as_multiclass(&self) -> Option<&Array2<F>> {
1210 match self {
1211 SvmScores::Multiclass(m) => Some(m),
1212 SvmScores::Binary(_) => None,
1213 }
1214 }
1215}
1216
1217// ---------------------------------------------------------------------------
1218// SVC (Support Vector Classifier)
1219// ---------------------------------------------------------------------------
1220
1221/// Support Vector Classifier.
1222///
1223/// Uses Sequential Minimal Optimization (SMO) to solve the dual QP.
1224/// Supports multiclass via one-vs-one strategy.
1225///
1226/// # Type Parameters
1227///
1228/// - `F`: The floating-point type (`f32` or `f64`).
1229/// - `K`: The kernel type (e.g., [`LinearKernel`], [`RbfKernel`]).
1230#[derive(Debug, Clone)]
1231pub struct SVC<F, K> {
1232 /// The kernel function.
1233 pub kernel: K,
1234 /// Regularization parameter (penalty for misclassification).
1235 pub c: F,
1236 /// Convergence tolerance.
1237 pub tol: F,
1238 /// Maximum number of SMO iterations. `0` is the sklearn `max_iter=-1`
1239 /// sentinel meaning **no iteration limit** (the SMO runs to convergence);
1240 /// a non-zero value caps the iteration count
1241 /// (`sklearn/svm/_classes.py`, `max_iter` default `-1`).
1242 pub max_iter: usize,
1243 /// Size of the kernel evaluation LRU cache (perf-only; default `200` to
1244 /// match sklearn's `cache_size=200`).
1245 pub cache_size: usize,
1246 /// Whether to use libsvm's shrinking heuristic
1247 /// (`sklearn/svm/_base.py:339`, `_classes.py` `shrinking=True`).
1248 ///
1249 /// ferrolearn's SMO has no shrinking heuristic: shrinking is a libsvm
1250 /// performance optimization that does NOT change the converged optimum
1251 /// (R-DEV-7). This flag is accepted for API parity (default `true`,
1252 /// matching sklearn) but DOES NOT alter the fitted result — the converged
1253 /// `α`/`dual_coef_`/`intercept_` are shrinking-invariant.
1254 pub shrinking: bool,
1255 /// The multiclass `decision_function` shape convention
1256 /// (`sklearn/svm/_base.py:778-781`); default
1257 /// [`SvmDecisionShape::Ovr`] (sklearn's `decision_function_shape='ovr'`).
1258 pub decision_function_shape: SvmDecisionShape,
1259 /// Whether `predict` breaks ties by the one-vs-rest decision confidence
1260 /// instead of the libsvm vote (`break_ties`, `sklearn/svm/_classes.py`
1261 /// default `False`; semantics in `BaseSVC.predict`,
1262 /// `sklearn/svm/_base.py:801-814`).
1263 ///
1264 /// When `true` AND [`SvmDecisionShape::Ovr`] AND `n_classes > 2`,
1265 /// `predict = argmax(decision_function(X))` (the ovr decision, which breaks
1266 /// ties by confidence); otherwise the libsvm ovo vote (with lower-index
1267 /// tie-break) is used. `break_ties=true` with [`SvmDecisionShape::Ovo`] is
1268 /// rejected at predict time (`InvalidParameter`), matching sklearn
1269 /// (`_base.py:801-804`).
1270 pub break_ties: bool,
1271 /// Per-class scaling of `C` (`class_weight`, `sklearn/svm/_classes.py:118-124`).
1272 /// Default [`ClassWeight::None`] (all classes weighted `1.0`). For an ovo
1273 /// pair `(a, b)` with `a < b`, the C of the `y=+1` group (class `b`) is
1274 /// `C·class_weight_[b]` and the C of the `y=-1` group (class `a`) is
1275 /// `C·class_weight_[a]`; the weights are computed ONCE over the full `y`
1276 /// by [`compute_class_weight`] (`_base.py:740`).
1277 pub class_weight: ClassWeight<F>,
1278 /// Whether to enable Platt-scaling probability estimates
1279 /// (`probability`, `sklearn/svm/_classes.py`, default `False`).
1280 ///
1281 /// When `true`, [`Fit::fit`] runs an internal 5-fold cross-validation per
1282 /// one-vs-one pair, fits a sigmoid `1/(1+exp(A·f+B))` over the out-of-fold
1283 /// decision values ([`sigmoid_train`], libsvm `svm_binary_svc_probability`,
1284 /// `svm.cpp:2107-2203`), and stores the per-pair `(A, B)` so
1285 /// [`FittedSVC::predict_proba`]/[`FittedSVC::predict_log_proba`] are
1286 /// available. When `false` (the default) `predict_proba` returns an error
1287 /// (`_base.py:820-827`).
1288 ///
1289 /// **RNG boundary (documented divergence).** libsvm's
1290 /// `svm_binary_svc_probability` shuffles the CV fold assignment with an
1291 /// RNG seeded by `random_state`, so sklearn's `probA_`/`probB_` (and hence
1292 /// the exact `predict_proba` values) are NON-DETERMINISTIC across
1293 /// `random_state` — the docstring itself warns "the results can be slightly
1294 /// different than those obtained by predict". ferrolearn instead uses a
1295 /// DETERMINISTIC 5-fold split (contiguous folds, no RNG shuffle), so it
1296 /// CANNOT and DOES NOT bit-match sklearn's `predict_proba` values. What is
1297 /// reproduced exactly is the DETERMINISTIC machinery ([`sigmoid_train`],
1298 /// [`sigmoid_predict`], [`multiclass_probability`]) and the STRUCTURAL
1299 /// contract (rows sum to 1, entries in `[0, 1]`, monotone in the binary
1300 /// decision value, the raise-when-`probability=false`). This is analogous
1301 /// to the SGD shuffle boundary already documented in this codebase.
1302 pub probability: bool,
1303}
1304
1305impl<F: Float, K: Kernel<F>> SVC<F, K> {
1306 /// Create a new `SVC` with the given kernel and default hyperparameters
1307 /// matching sklearn (`sklearn/svm/_classes.py` `SVC.__init__`).
1308 ///
1309 /// Defaults: `C = 1.0`, `tol = 1e-3`, `max_iter = 0` (= sklearn `-1`, no
1310 /// iteration limit), `cache_size = 200`, `shrinking = true`,
1311 /// `decision_function_shape = Ovr`, `break_ties = false`,
1312 /// `class_weight = None`, `probability = false`.
1313 #[must_use]
1314 pub fn new(kernel: K) -> Self {
1315 Self {
1316 kernel,
1317 c: F::one(),
1318 tol: F::from(1e-3).unwrap_or_else(F::epsilon),
1319 max_iter: 0,
1320 cache_size: 200,
1321 shrinking: true,
1322 decision_function_shape: SvmDecisionShape::Ovr,
1323 break_ties: false,
1324 class_weight: ClassWeight::None,
1325 probability: false,
1326 }
1327 }
1328
1329 /// Enable/disable Platt-scaling probability estimates (`sklearn`
1330 /// `probability`, default `false`). When `true`, [`Fit::fit`] runs the
1331 /// internal per-pair 5-fold CV + [`sigmoid_train`] so
1332 /// [`FittedSVC::predict_proba`]/[`FittedSVC::predict_log_proba`] are
1333 /// available; when `false` they return an error.
1334 ///
1335 /// See the [`SVC::probability`] field doc for the documented RNG-CV
1336 /// exact-value boundary (sklearn is non-deterministic across
1337 /// `random_state`; only the deterministic machinery + structural
1338 /// invariants + the raise contract are reproduced).
1339 #[must_use]
1340 pub fn with_probability(mut self, probability: bool) -> Self {
1341 self.probability = probability;
1342 self
1343 }
1344
1345 /// Set the per-class `C` scaling (`sklearn` `class_weight`,
1346 /// `_classes.py:118-124`). [`ClassWeight::None`] (default) leaves every
1347 /// class at `1.0`; [`ClassWeight::Balanced`] uses
1348 /// `n_samples / (n_classes · count_c)`; [`ClassWeight::Explicit`] takes a
1349 /// `(label, weight)` map (unlisted classes default to `1.0`).
1350 #[must_use]
1351 pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
1352 self.class_weight = class_weight;
1353 self
1354 }
1355
1356 /// Set the `shrinking` flag (`sklearn` `shrinking`, default `true`).
1357 ///
1358 /// Accepted for API parity; does NOT alter the converged optimum
1359 /// (ferrolearn's SMO has no shrinking heuristic — R-DEV-7).
1360 #[must_use]
1361 pub fn with_shrinking(mut self, shrinking: bool) -> Self {
1362 self.shrinking = shrinking;
1363 self
1364 }
1365
1366 /// Set the `break_ties` flag (`sklearn` `break_ties`, default `false`,
1367 /// `sklearn/svm/_base.py:801-814`).
1368 #[must_use]
1369 pub fn with_break_ties(mut self, break_ties: bool) -> Self {
1370 self.break_ties = break_ties;
1371 self
1372 }
1373
1374 /// Set the multiclass `decision_function` shape convention
1375 /// (`'ovr'` default / `'ovo'`, `sklearn/svm/_base.py:778-781`).
1376 #[must_use]
1377 pub fn with_decision_function_shape(mut self, shape: SvmDecisionShape) -> Self {
1378 self.decision_function_shape = shape;
1379 self
1380 }
1381
1382 /// Set the regularization parameter C.
1383 #[must_use]
1384 pub fn with_c(mut self, c: F) -> Self {
1385 self.c = c;
1386 self
1387 }
1388
1389 /// Set the convergence tolerance.
1390 #[must_use]
1391 pub fn with_tol(mut self, tol: F) -> Self {
1392 self.tol = tol;
1393 self
1394 }
1395
1396 /// Set the maximum number of SMO iterations.
1397 #[must_use]
1398 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
1399 self.max_iter = max_iter;
1400 self
1401 }
1402
1403 /// Set the kernel cache size.
1404 #[must_use]
1405 pub fn with_cache_size(mut self, cache_size: usize) -> Self {
1406 self.cache_size = cache_size;
1407 self
1408 }
1409}
1410
1411/// A single binary SVM model (one pair of classes in one-vs-one).
1412#[derive(Debug, Clone)]
1413struct BinarySvm<F> {
1414 /// Support vectors (stored as rows).
1415 support_vectors: Vec<Vec<F>>,
1416 /// Original training-row index of each support vector (parallel to
1417 /// `support_vectors`/`dual_coefs`). Used to build the global, per-class
1418 /// grouped `support_` set (`sklearn/svm/_base.py:318-410`).
1419 sv_indices: Vec<usize>,
1420 /// Dual coefficients: `alpha_i * y_i` for each support vector, where this
1421 /// crate maps the lower-index class (`class_neg`) to `y = -1` and the
1422 /// higher-index class (`class_pos`) to `y = +1`. NOTE this is the OPPOSITE
1423 /// sign convention to libsvm internally (libsvm gives the lower-index class
1424 /// `+1`); the public-attribute layout compensates in
1425 /// [`FittedSVC::dual_coef`].
1426 dual_coefs: Vec<F>,
1427 /// Bias term.
1428 bias: F,
1429 /// The two class labels: (negative_class, positive_class). `class_neg` is
1430 /// the lower class index and `class_pos` the higher (the ovo pair `(a, b)`
1431 /// with `a < b`).
1432 class_neg: usize,
1433 class_pos: usize,
1434}
1435
1436/// Fitted Support Vector Classifier.
1437///
1438/// Stores one binary SVM per pair of classes (one-vs-one). Implements
1439/// [`Predict`] to produce class labels.
1440#[derive(Debug, Clone)]
1441pub struct FittedSVC<F, K> {
1442 /// The kernel used for predictions.
1443 kernel: K,
1444 /// One binary SVM per class pair, in libsvm ovo pair order
1445 /// `(0,1),(0,2),...,(0,k-1),(1,2),...` (the `(ci,cj)` double loop).
1446 binary_models: Vec<BinarySvm<F>>,
1447 /// Sorted unique classes (`classes_ = np.unique(y)`).
1448 classes: Vec<usize>,
1449 /// The training feature matrix, retained so the libsvm-layout fitted
1450 /// attributes (`support_`, `support_vectors_`) can index back into the
1451 /// original rows (`sklearn/svm/_base.py:318-410`).
1452 x_train: Array2<F>,
1453 /// The training labels (class index per row), retained so `support_` can
1454 /// be grouped by class.
1455 y_train: Vec<usize>,
1456 /// The multiclass `decision_function` shape convention carried over from
1457 /// the unfitted [`SVC`] (`sklearn/svm/_base.py:778-781`).
1458 decision_function_shape: SvmDecisionShape,
1459 /// The `break_ties` flag carried over from the unfitted [`SVC`]
1460 /// (`sklearn/svm/_base.py:801-814`).
1461 break_ties: bool,
1462 /// Whether probability estimates were fitted (`probability`,
1463 /// `sklearn/svm/_classes.py`). When `false`, [`Self::predict_proba`]
1464 /// returns an error (`_base.py:820-827`).
1465 probability: bool,
1466 /// Per-ovo-pair Platt sigmoid `A` parameter (`probA_`,
1467 /// `sklearn/svm/src/libsvm/svm.cpp:2200` via `sigmoid_train`), parallel to
1468 /// `binary_models`. Empty when `probability == false`.
1469 prob_a: Vec<F>,
1470 /// Per-ovo-pair Platt sigmoid `B` parameter (`probB_`), parallel to
1471 /// `binary_models`. Empty when `probability == false`.
1472 prob_b: Vec<F>,
1473}
1474
1475/// One ovo binary sub-model in **this crate's sign convention** (higher-index
1476/// `class_pos` is the `+1` side, matching [`BinarySvm`] and
1477/// [`FittedSVC::decision_value_binary`]). Used by [`FittedSVC::from_nu_ovo`] to
1478/// assemble a nu-SVC fitted model that reuses all of [`FittedSVC`]'s accessors
1479/// / `decision_function` / `predict`.
1480///
1481/// The nu-SVC solver ([`solve_nu_svc`]) is fed the per-pair labels in this same
1482/// convention (`class_pos = +1`), so `sv_coefs`/`bias_internal` are already in
1483/// this-crate sign and `from_nu_ovo` stores them verbatim.
1484pub(crate) struct NuOvoPair<F> {
1485 /// Support-vector feature rows for this pair.
1486 pub sv_data: Vec<Vec<F>>,
1487 /// Per-SV coefficient `alpha·y/r` (this-crate sign, `class_pos = +1`),
1488 /// equal to the public binary `dual_coef_` value (the nu_svc binary flip
1489 /// `public = -internal` cancels with `internal = -stored`).
1490 pub sv_coefs: Vec<F>,
1491 /// Original training-row index of each support vector.
1492 pub sv_indices: Vec<usize>,
1493 /// Decision bias for the `+1`-side (`class_pos`) in this crate's
1494 /// convention (`f(x) = Σ sv_coef·K + bias_internal`).
1495 pub bias_internal: F,
1496 /// Lower-index class label (this crate's `-1` side).
1497 pub class_neg: usize,
1498 /// Higher-index class label (this crate's `+1` side).
1499 pub class_pos: usize,
1500}
1501
1502impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> FittedSVC<F, K> {
1503 /// Assemble a [`FittedSVC`] from per-ovo-pair nu-SVC sub-models (in libsvm
1504 /// sign convention) so that [`NuSVC`](crate::nu_svm::NuSVC) reuses the full
1505 /// libsvm-layout fitted-attribute machinery (`support_`/`dual_coef_`/
1506 /// `intercept_`/`coef_`/`decision_function`/`predict`) without duplicating
1507 /// it (`sklearn/svm/_base.py:318-410`).
1508 ///
1509 /// Each [`NuOvoPair`] is already in this crate's [`BinarySvm`] sign
1510 /// convention (higher-index `class_pos` as the `+1` side, because
1511 /// [`solve_nu_svc`] is fed labels in that convention), so the coefficients
1512 /// and bias are stored verbatim. The resulting public `dual_coef_`/
1513 /// `intercept_` then carry the binary nu_svc sign flip exactly as `c_svc`
1514 /// does (`_base.py:258-262`, predicate `_impl in ["c_svc","nu_svc"]`).
1515 ///
1516 /// When `probability` is `true`, `prob_a`/`prob_b` are the per-ovo-pair
1517 /// sigmoid `(A, B)` parameters fitted by [`platt_cv_sigmoid`] with the
1518 /// NU-SVC sub-solver ([`solve_nu_svc`]) ([`NuSVC`](crate::nu_svm::NuSVC)
1519 /// REQ-9), so the assembled [`FittedSVC`]'s
1520 /// [`Self::predict_proba`]/[`Self::predict_log_proba`] work identically to
1521 /// a `probability=true` C-SVC fit — the coupling/`sigmoid_predict` path is
1522 /// solver-agnostic (it consumes only `raw_ovo` + `prob_a`/`prob_b`). When
1523 /// `probability` is `false`, `prob_a`/`prob_b` are empty.
1524 #[allow(
1525 clippy::too_many_arguments,
1526 reason = "carries the full nu-ovo assembly plus the probability state \
1527 (probability flag + per-pair probA/probB) in one constructor"
1528 )]
1529 pub(crate) fn from_nu_ovo(
1530 kernel: K,
1531 pairs: Vec<NuOvoPair<F>>,
1532 classes: Vec<usize>,
1533 x_train: Array2<F>,
1534 y_train: Vec<usize>,
1535 decision_function_shape: SvmDecisionShape,
1536 break_ties: bool,
1537 probability: bool,
1538 prob_a: Vec<F>,
1539 prob_b: Vec<F>,
1540 ) -> Self {
1541 let binary_models = pairs
1542 .into_iter()
1543 .map(|pair| BinarySvm {
1544 support_vectors: pair.sv_data,
1545 sv_indices: pair.sv_indices,
1546 dual_coefs: pair.sv_coefs,
1547 bias: pair.bias_internal,
1548 class_neg: pair.class_neg,
1549 class_pos: pair.class_pos,
1550 })
1551 .collect();
1552
1553 FittedSVC {
1554 kernel,
1555 binary_models,
1556 classes,
1557 x_train,
1558 y_train,
1559 decision_function_shape,
1560 break_ties,
1561 probability,
1562 prob_a,
1563 prob_b,
1564 }
1565 }
1566}
1567
1568impl<F: Float, K: Kernel<F>> FittedSVC<F, K> {
1569 /// Compute the decision function value for a single sample against a
1570 /// binary model.
1571 fn decision_value_binary(&self, x: &[F], model: &BinarySvm<F>) -> F {
1572 let mut val = model.bias;
1573 for (sv, &coef) in model.support_vectors.iter().zip(model.dual_coefs.iter()) {
1574 val = val + coef * self.kernel.compute(sv, x);
1575 }
1576 val
1577 }
1578
1579 /// Raw one-vs-one decision values in **libsvm sign convention**, shape
1580 /// `(n_samples, n·(n-1)/2)`, columns in ovo pair order
1581 /// `(0,1),(0,2),...,(1,2),...` (the `(ci,cj)` double loop).
1582 ///
1583 /// libsvm/sklearn use the LOWER-index class as the `+1` side, so a POSITIVE
1584 /// value means the lower-index class wins (`sklearn/svm/_base.py:520-524`).
1585 /// This crate's [`Self::decision_value_binary`] uses the HIGHER-index class
1586 /// (`class_pos`) as `+1`, the opposite sign — so the raw ovo value is the
1587 /// negation of `decision_value_binary` to restore libsvm's convention.
1588 fn raw_ovo(&self, x: &Array2<F>) -> Array2<F> {
1589 let n_samples = x.nrows();
1590 let n_models = self.binary_models.len();
1591 let mut result = Array2::<F>::zeros((n_samples, n_models));
1592
1593 for s in 0..n_samples {
1594 let xi: Vec<F> = x.row(s).to_vec();
1595 for (m, model) in self.binary_models.iter().enumerate() {
1596 // Negate to match libsvm's "lower-index class = +1" sign.
1597 result[[s, m]] = self.decision_value_binary(&xi, model).neg();
1598 }
1599 }
1600
1601 result
1602 }
1603
1604 /// The continuous one-vs-rest decision function transformed from the
1605 /// one-vs-one scores, mirroring `_ovr_decision_function`
1606 /// (`sklearn/utils/multiclass.py:520-562`), shape `(n_samples, n_classes)`.
1607 ///
1608 /// `predictions[s,k] = if raw_ovo[s,k] < 0 { 1 } else { 0 }` and
1609 /// `confidences[s,k] = -raw_ovo[s,k]`, matching sklearn's call
1610 /// `_ovr_decision_function(dec < 0, -dec, n_classes)`
1611 /// (`sklearn/svm/_base.py:780`). The ovo pair iteration `(i,j)` with `i<j`,
1612 /// `k = 0,1,...`, is the SAME order as the `raw_ovo` columns.
1613 fn ovr_decision_function(&self, raw_ovo: &Array2<F>) -> Array2<F> {
1614 let n_samples = raw_ovo.nrows();
1615 let n_classes = self.classes.len();
1616 let mut votes = Array2::<F>::zeros((n_samples, n_classes));
1617 let mut sum_of_confidences = Array2::<F>::zeros((n_samples, n_classes));
1618 let one = F::one();
1619
1620 let mut k = 0usize;
1621 for i in 0..n_classes {
1622 for j in (i + 1)..n_classes {
1623 for s in 0..n_samples {
1624 let dec = raw_ovo[[s, k]];
1625 let confidence = dec.neg(); // -dec
1626 // sum_of_confidences[:, i] -= confidences[:, k]
1627 sum_of_confidences[[s, i]] = sum_of_confidences[[s, i]] - confidence;
1628 // sum_of_confidences[:, j] += confidences[:, k]
1629 sum_of_confidences[[s, j]] = sum_of_confidences[[s, j]] + confidence;
1630 // predictions[s,k] = (dec < 0) ? 1 : 0
1631 // votes[predictions==0, i] += 1; votes[predictions==1, j] += 1
1632 if dec < F::zero() {
1633 votes[[s, j]] = votes[[s, j]] + one;
1634 } else {
1635 votes[[s, i]] = votes[[s, i]] + one;
1636 }
1637 }
1638 k += 1;
1639 }
1640 }
1641
1642 // transformed = sum_of_confidences / (3 * (|sum_of_confidences| + 1))
1643 // return votes + transformed.
1644 let three = match F::from(3.0) {
1645 Some(v) => v,
1646 None => one + one + one,
1647 };
1648 let mut out = votes;
1649 for s in 0..n_samples {
1650 for c in 0..n_classes {
1651 let soc = sum_of_confidences[[s, c]];
1652 let transformed = soc / (three * (soc.abs() + one));
1653 out[[s, c]] = out[[s, c]] + transformed;
1654 }
1655 }
1656 out
1657 }
1658
1659 /// The decision function for the samples in `x`
1660 /// (`sklearn/svm/_base.py:536-541, 778-781`).
1661 ///
1662 /// - **Binary** (`n_classes == 2`): [`SvmScores::Binary`], shape
1663 /// `(n_samples,)` = `-raw_ovo.ravel()` (sklearn flips the sign for
1664 /// `c_svc`/`nu_svc` binary, `_base.py:538-539`), so a POSITIVE value
1665 /// predicts `classes_[1]`. Because this crate's `decision_value_binary`
1666 /// already uses the higher-index class as `+1`, `-raw_ovo` equals
1667 /// `decision_value_binary` directly.
1668 /// - **Multiclass [`SvmDecisionShape::Ovr`]** (default):
1669 /// [`SvmScores::Multiclass`], shape `(n_samples, n_classes)` =
1670 /// `_ovr_decision_function(raw_ovo)` (`_base.py:780`).
1671 /// - **Multiclass [`SvmDecisionShape::Ovo`]**: [`SvmScores::Multiclass`],
1672 /// shape `(n_samples, n·(n-1)/2)` = the raw ovo values.
1673 ///
1674 /// # Errors
1675 ///
1676 /// Returns `Ok` for valid input.
1677 pub fn decision_function(&self, x: &Array2<F>) -> Result<SvmScores<F>, FerroError> {
1678 let raw_ovo = self.raw_ovo(x);
1679
1680 if self.classes.len() == 2 {
1681 // Binary: -raw_ovo.ravel() = +decision_value_binary (1-D).
1682 let n_samples = raw_ovo.nrows();
1683 let mut binary = Array1::<F>::zeros(n_samples);
1684 for s in 0..n_samples {
1685 binary[s] = raw_ovo[[s, 0]].neg();
1686 }
1687 return Ok(SvmScores::Binary(binary));
1688 }
1689
1690 match self.decision_function_shape {
1691 SvmDecisionShape::Ovo => Ok(SvmScores::Multiclass(raw_ovo)),
1692 SvmDecisionShape::Ovr => {
1693 Ok(SvmScores::Multiclass(self.ovr_decision_function(&raw_ovo)))
1694 }
1695 }
1696 }
1697
1698 /// Class probability estimates, shape `(n_samples, n_classes)`; columns
1699 /// correspond to `classes_` in sorted order
1700 /// (`sklearn/svm/_base.py:829-864`, `libsvm.predict_probability`,
1701 /// `svm.cpp:2918-2955`).
1702 ///
1703 /// Built from the per-pair Platt sigmoids fitted at `fit` time when
1704 /// `probability=true`: the raw one-vs-one decision values (libsvm sign,
1705 /// lower-index class `+1`) are mapped to pairwise probabilities via
1706 /// [`sigmoid_predict`] (clamped to `[1e-7, 1-1e-7]`, `svm.cpp:2929-2938`),
1707 /// then coupled by [`multiclass_probability`] (Wu-Lin-Weng 2004,
1708 /// `svm.cpp:2941`). For the binary case `multiclass_probability` reduces to
1709 /// `[sigmoid_predict(dec), 1 - sigmoid_predict(dec)]` =
1710 /// `[P(classes_[0]), P(classes_[1])]`. Each row sums to 1.
1711 ///
1712 /// **RNG-CV exact-value boundary (documented divergence).** Because the
1713 /// underlying `(A, B)` come from a cross-validation whose fold assignment
1714 /// is RNG-dependent in libsvm/sklearn (sklearn's `probA_`/`probB_` and
1715 /// `predict_proba` values change with `random_state`), ferrolearn uses a
1716 /// DETERMINISTIC contiguous 5-fold split instead and therefore does NOT
1717 /// bit-match sklearn's `predict_proba` values. The reproduced contract is
1718 /// structural: rows sum to 1, entries in `[0, 1]`, and (binary) the
1719 /// `classes_[1]` column is monotone non-decreasing in the
1720 /// `decision_function` value. See [`SVC::probability`].
1721 ///
1722 /// # Errors
1723 ///
1724 /// Returns [`FerroError::InvalidParameter`] when the model was fitted with
1725 /// `probability=false`, with the message mirroring sklearn's
1726 /// `NotFittedError` text "predict_proba is not available when fitted with
1727 /// probability=False" (`_base.py:856-860`). (This crate has no `NotFitted`
1728 /// variant — predict-before-fit is a compile error via the typestate,
1729 /// R-DEV-4 — so the "fitted-without-probability" runtime condition maps to
1730 /// `InvalidParameter`; the binding maps it to the matching `PyErr`.)
1731 pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1732 if !self.probability {
1733 return Err(FerroError::InvalidParameter {
1734 name: "probability".into(),
1735 reason: "predict_proba is not available when fitted with probability=False".into(),
1736 });
1737 }
1738
1739 let raw_ovo = self.raw_ovo(x);
1740 let n_samples = raw_ovo.nrows();
1741 let n_classes = self.classes.len();
1742 let min_prob = F::from(1e-7).unwrap_or_else(F::epsilon);
1743 let max_prob = F::one() - min_prob;
1744
1745 let mut out = Array2::<F>::zeros((n_samples, n_classes));
1746
1747 for s in 0..n_samples {
1748 // Build the k×k pairwise probability matrix for this sample.
1749 let mut pairwise = Array2::<F>::zeros((n_classes, n_classes));
1750 let mut k = 0usize;
1751 for i in 0..n_classes {
1752 for j in (i + 1)..n_classes {
1753 // dec_values[k] is the raw ovo value (libsvm sign: positive
1754 // favors the lower-index class i = classes_[i]).
1755 let dec = raw_ovo[[s, k]];
1756 let (a, b) = (self.prob_a[k], self.prob_b[k]);
1757 let mut pij = sigmoid_predict(dec, a, b);
1758 // Clamp to [min_prob, 1-min_prob] (`svm.cpp:2937`).
1759 if pij < min_prob {
1760 pij = min_prob;
1761 }
1762 if pij > max_prob {
1763 pij = max_prob;
1764 }
1765 pairwise[[i, j]] = pij;
1766 pairwise[[j, i]] = F::one() - pij;
1767 k += 1;
1768 }
1769 }
1770 let probs = multiclass_probability(n_classes, &pairwise);
1771 for (c, &pc) in probs.iter().enumerate() {
1772 out[[s, c]] = pc;
1773 }
1774 }
1775
1776 Ok(out)
1777 }
1778
1779 /// Natural-log class probability estimates, shape `(n_samples, n_classes)`
1780 /// = `predict_proba(x).ln()` elementwise (`sklearn/svm/_base.py:866-894`:
1781 /// `np.log(self.predict_proba(X))`).
1782 ///
1783 /// # Errors
1784 ///
1785 /// Returns [`FerroError::NotFitted`] when the model was fitted with
1786 /// `probability=false` (delegated from [`Self::predict_proba`]).
1787 pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1788 self.predict_proba(x).map(|p| p.mapv(F::ln))
1789 }
1790
1791 /// Whether Platt-scaling probability estimates were fitted
1792 /// (`probability`, `sklearn/svm/_classes.py`); when `false`,
1793 /// [`Self::predict_proba`]/[`Self::predict_log_proba`] raise.
1794 #[must_use]
1795 pub fn probability(&self) -> bool {
1796 self.probability
1797 }
1798
1799 /// The per-ovo-pair Platt sigmoid `A` parameters (`probA_`,
1800 /// `sklearn/svm/_base.py`), length `n_class·(n_class-1)/2`. Empty when
1801 /// fitted with `probability=false`.
1802 #[must_use]
1803 pub fn prob_a(&self) -> Array1<F> {
1804 Array1::from_vec(self.prob_a.clone())
1805 }
1806
1807 /// The per-ovo-pair Platt sigmoid `B` parameters (`probB_`,
1808 /// `sklearn/svm/_base.py`), length `n_class·(n_class-1)/2`. Empty when
1809 /// fitted with `probability=false`.
1810 #[must_use]
1811 pub fn prob_b(&self) -> Array1<F> {
1812 Array1::from_vec(self.prob_b.clone())
1813 }
1814}
1815
1816impl<F: Float + ScalarOperand + 'static, K: Kernel<F>> FittedSVC<F, K> {
1817 /// Build the global, per-class-grouped support-vector index set, mirroring
1818 /// libsvm's `support_` layout (`sklearn/svm/_base.py:318-410`): the indices
1819 /// of the training rows that are a support vector in AT LEAST ONE ovo
1820 /// binary problem, deduplicated, grouped by class (all of class
1821 /// `classes_[0]` first, then `classes_[1]`, ...), ascending within a class.
1822 ///
1823 /// Returns `(support, per_class_indices)` where `support` is the flat
1824 /// grouped index vector and `per_class_indices[c]` is the (ascending)
1825 /// list of global training-row indices that are SVs for class
1826 /// `classes_[c]`.
1827 fn build_support(&self) -> (Vec<usize>, Vec<Vec<usize>>) {
1828 let n_classes = self.classes.len();
1829 // Per-class set of training-row indices that are an SV anywhere.
1830 let mut per_class: Vec<Vec<usize>> = vec![Vec::new(); n_classes];
1831 let mut seen: Vec<bool> = vec![false; self.y_train.len()];
1832
1833 for model in &self.binary_models {
1834 for &idx in &model.sv_indices {
1835 if !seen[idx] {
1836 seen[idx] = true;
1837 let cls = self.y_train[idx];
1838 if let Some(ci) = self.classes.iter().position(|&c| c == cls) {
1839 per_class[ci].push(idx);
1840 }
1841 }
1842 }
1843 }
1844
1845 for group in &mut per_class {
1846 group.sort_unstable();
1847 }
1848
1849 let support: Vec<usize> = per_class.iter().flatten().copied().collect();
1850 (support, per_class)
1851 }
1852
1853 /// Indices of the support vectors into the training set, **grouped by
1854 /// class** (all class-`classes_[0]` SVs first, then `classes_[1]`, ...),
1855 /// ascending within each class.
1856 ///
1857 /// Mirrors `SVC.support_` (`sklearn/svm/_base.py:318-410`).
1858 #[must_use]
1859 pub fn support(&self) -> Array1<usize> {
1860 let (support, _) = self.build_support();
1861 Array1::from_vec(support)
1862 }
1863
1864 /// The support vectors `X[support_]`, shape `(n_SV, n_features)`.
1865 ///
1866 /// Mirrors `SVC.support_vectors_` (`sklearn/svm/_base.py:318-410`).
1867 #[must_use]
1868 pub fn support_vectors(&self) -> Array2<F> {
1869 let (support, _) = self.build_support();
1870 let n_features = self.x_train.ncols();
1871 let mut out = Array2::<F>::zeros((support.len(), n_features));
1872 for (row, &idx) in support.iter().enumerate() {
1873 out.row_mut(row).assign(&self.x_train.row(idx));
1874 }
1875 out
1876 }
1877
1878 /// Number of support vectors per class (`n_support_`,
1879 /// `sklearn/svm/_base.py:668-682`), parallel to `classes_`.
1880 #[must_use]
1881 pub fn n_support(&self) -> Vec<usize> {
1882 let (_, per_class) = self.build_support();
1883 per_class.iter().map(Vec::len).collect()
1884 }
1885
1886 /// Dual coefficients in the libsvm public layout, shape
1887 /// `(n_class - 1, n_SV)` (`sklearn/svm/_base.py:318-410`, the `dual_coef_`
1888 /// attribute), columns in `support_` (per-class-grouped) order.
1889 ///
1890 /// For an SV belonging to class `i`, row `m` holds its coefficient in the
1891 /// binary classifier between class `i` and the `m`-th OTHER class (the
1892 /// other classes in increasing index order, skipping `i`). In the ovo pair
1893 /// `(a, b)` with `a < b`, libsvm uses class `a` as the `+1` side and `b` as
1894 /// `-1`; the stored coefficient is `alpha * y_libsvm`.
1895 ///
1896 /// This crate stores `alpha * y` per pair with the OPPOSITE sign
1897 /// (`class_neg = a` mapped to `-1`), so the libsvm-internal coefficient is
1898 /// the negation of the stored value. For `n_class == 2` sklearn negates the
1899 /// internal coefficient again to form the PUBLIC binary attribute
1900 /// (`sklearn/svm/_base.py:258-262`: `dual_coef_ = -dual_coef_`), which
1901 /// leaves the public binary value equal to this crate's stored value; for
1902 /// `n_class > 2` the public value IS the libsvm-internal value (no flip).
1903 #[must_use]
1904 pub fn dual_coef(&self) -> Array2<F> {
1905 let n_classes = self.classes.len();
1906 let (support, _per_class) = self.build_support();
1907 let n_sv = support.len();
1908
1909 if n_classes == 2 {
1910 // Binary: public dual_coef_ = -internal, and internal = -stored,
1911 // so public = stored. The single ovo model holds one stored coef
1912 // per SV keyed by training index; map them into support_ column
1913 // order.
1914 let mut out = Array2::<F>::zeros((1, n_sv));
1915 if let Some(model) = self.binary_models.first() {
1916 for (sv_idx, &coef) in model.sv_indices.iter().zip(model.dual_coefs.iter()) {
1917 if let Some(col) = support.iter().position(|&s| s == *sv_idx) {
1918 out[[0, col]] = coef;
1919 }
1920 }
1921 }
1922 return out;
1923 }
1924
1925 // Multiclass: public dual_coef_ = libsvm-internal = -(stored). Row m
1926 // for an SV of class i is its coefficient in the pair (i, m-th other).
1927 let mut out = Array2::<F>::zeros((n_classes - 1, n_sv));
1928
1929 // Column index in `support_` for a given training-row index.
1930 let col_of: HashMap<usize, usize> =
1931 support.iter().enumerate().map(|(c, &i)| (i, c)).collect();
1932
1933 for model in &self.binary_models {
1934 let a = model.class_neg; // lower-index class in the pair
1935 let b = model.class_pos; // higher-index class
1936 let ai = self.classes.iter().position(|&c| c == a);
1937 let bi = self.classes.iter().position(|&c| c == b);
1938 let (ai, bi) = match (ai, bi) {
1939 (Some(ai), Some(bi)) => (ai, bi),
1940 _ => continue,
1941 };
1942
1943 for (sv_idx, &stored) in model.sv_indices.iter().zip(model.dual_coefs.iter()) {
1944 let Some(&col) = col_of.get(sv_idx) else {
1945 continue;
1946 };
1947 let cls = self.y_train[*sv_idx];
1948 let internal = stored.neg(); // libsvm internal = -(stored)
1949 // Determine which row this pair occupies for class `cls`:
1950 // the count of OTHER classes with index < the partner's index.
1951 let (own_class_index, partner_class_index) =
1952 if cls == a { (ai, bi) } else { (bi, ai) };
1953 // Row m = number of other classes (excluding own) with class
1954 // index < partner_class_index.
1955 let mut row = 0usize;
1956 for ci in 0..n_classes {
1957 if ci == own_class_index {
1958 continue;
1959 }
1960 if ci < partner_class_index {
1961 row += 1;
1962 }
1963 }
1964 out[[row, col]] = internal;
1965 }
1966 }
1967
1968 out
1969 }
1970
1971 /// The per-ovo-problem intercepts, length `n_class * (n_class - 1) / 2`,
1972 /// in pair order `(0,1),(0,2),(1,2),...` (`intercept_`,
1973 /// `sklearn/svm/_base.py:318-410`). For `n_class == 2` sklearn negates the
1974 /// internal bias to form the public attribute
1975 /// (`sklearn/svm/_base.py:258-262`: `intercept_ *= -1`).
1976 ///
1977 /// This crate's per-pair `bias` is recovered for the decision function
1978 /// `sum coef*K + bias` with `class_pos` (the higher index) as the `+1`
1979 /// side. libsvm/sklearn use the lower-index class as `+1`, so the
1980 /// libsvm-internal intercept is the negation of this crate's `bias`. For
1981 /// binary, the public attribute negates the internal again, leaving the
1982 /// public value equal to this crate's stored `bias`; for multiclass the
1983 /// public value IS the internal `-bias`.
1984 #[must_use]
1985 pub fn intercept(&self) -> Array1<F> {
1986 let n_classes = self.classes.len();
1987 let vals: Vec<F> = if n_classes == 2 {
1988 self.binary_models.iter().map(|m| m.bias).collect()
1989 } else {
1990 self.binary_models.iter().map(|m| m.bias.neg()).collect()
1991 };
1992 Array1::from_vec(vals)
1993 }
1994
1995 /// Primal weight vector `coef_ = dual_coef_ @ support_vectors_`, shape
1996 /// `(n_class - 1, n_features)` — available ONLY for the linear kernel
1997 /// (`sklearn/svm/_base.py:650-666`). Returns `None` for any other kernel
1998 /// (sklearn raises `AttributeError`).
1999 #[must_use]
2000 pub fn coef(&self) -> Option<Array2<F>> {
2001 if !self.kernel.is_linear() {
2002 return None;
2003 }
2004 let dual = self.dual_coef(); // (n_class-1, n_SV)
2005 let svs = self.support_vectors(); // (n_SV, n_features)
2006 Some(dual.dot(&svs))
2007 }
2008}
2009
2010impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static>
2011 Fit<Array2<F>, Array1<usize>> for SVC<F, K>
2012{
2013 type Fitted = FittedSVC<F, K>;
2014 type Error = FerroError;
2015
2016 /// Fit the SVC model using SMO.
2017 ///
2018 /// # Errors
2019 ///
2020 /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
2021 /// sample counts.
2022 /// Returns [`FerroError::InvalidParameter`] if `C` is not positive.
2023 /// Returns [`FerroError::InsufficientSamples`] if fewer than 2 classes.
2024 fn fit(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<FittedSVC<F, K>, FerroError> {
2025 let (n_samples, _n_features) = x.dim();
2026
2027 if self.c <= F::zero() {
2028 return Err(FerroError::InvalidParameter {
2029 name: "C".into(),
2030 reason: "must be positive".into(),
2031 });
2032 }
2033
2034 // Reject non-finite input (NaN / +/-inf) in X BEFORE the X/y length
2035 // (`ShapeMismatch`) check, mirroring sklearn's `BaseLibSVM.fit` ->
2036 // `_validate_data(X, y, ...)` (`sklearn/svm/_base.py:190`) which routes to
2037 // `check_X_y`: `check_array(X, force_all_finite=True)` runs BEFORE
2038 // `check_consistent_length(X, y)` (`_base.py:208`), so on an input that is
2039 // BOTH non-finite AND length-mismatched sklearn raises the finiteness
2040 // `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`,
2041 // NOT a consistency error (#2270). `y` is class labels
2042 // (`Array1<usize>`), already finite by type, so only `X` needs the float
2043 // finiteness check. `.iter().any(|v| !v.is_finite())` catches both NaN and
2044 // +/-inf; on finite input the guard never fires, so the fitted SVC
2045 // attributes (`support_`/`dual_coef_`/`intercept_`/`coef_`) are
2046 // byte-identical and a finite length-mismatch still yields `ShapeMismatch`.
2047 if x.iter().any(|v| !v.is_finite()) {
2048 return Err(FerroError::InvalidParameter {
2049 name: "X".into(),
2050 reason: "Input X contains NaN or infinity.".into(),
2051 });
2052 }
2053
2054 if n_samples != y.len() {
2055 return Err(FerroError::ShapeMismatch {
2056 expected: vec![n_samples],
2057 actual: vec![y.len()],
2058 context: "y length must match number of samples in X".into(),
2059 });
2060 }
2061
2062 // Determine unique classes.
2063 let mut classes: Vec<usize> = y.to_vec();
2064 classes.sort_unstable();
2065 classes.dedup();
2066
2067 if classes.len() < 2 {
2068 return Err(FerroError::InsufficientSamples {
2069 required: 2,
2070 actual: classes.len(),
2071 context: "SVC requires at least 2 distinct classes".into(),
2072 });
2073 }
2074
2075 // Resolve any data-dependent kernel parameters (e.g. a `None` gamma ->
2076 // sklearn's default `gamma='scale'` = 1/(n_features * X.var()),
2077 // `_base.py:236-239`) against the training data BEFORE fitting, and use
2078 // this resolved kernel for both fitting and prediction.
2079 let kernel = self.kernel.resolved_for_fit(x);
2080
2081 // Convert data to Vec<Vec<F>> for kernel cache.
2082 let data: Vec<Vec<F>> = (0..n_samples).map(|i| x.row(i).to_vec()).collect();
2083
2084 // Per-class weights computed ONCE over the FULL y (libsvm's
2085 // `class_weight_ = compute_class_weight(class_weight, classes, y)`,
2086 // `_base.py:740`); `weighted_C[c] = C·class_weight_[c]`.
2087 let y_vec: Vec<usize> = y.to_vec();
2088 let weights = compute_class_weight(&self.class_weight, &classes, &y_vec);
2089
2090 // One-vs-one: train one binary SVM per pair.
2091 let n_classes = classes.len();
2092 let mut binary_models = Vec::new();
2093 // Per-pair Platt sigmoid params (only filled when `probability`).
2094 let mut prob_a: Vec<F> = Vec::new();
2095 let mut prob_b: Vec<F> = Vec::new();
2096
2097 for ci in 0..n_classes {
2098 for cj in (ci + 1)..n_classes {
2099 let class_neg = classes[ci];
2100 let class_pos = classes[cj];
2101
2102 // Per-class box bounds for this ovo pair `(class_neg, class_pos)`:
2103 // the `y=+1` group (class_pos = classes[cj]) gets `Cp = C·w[cj]`
2104 // and the `y=-1` group (class_neg = classes[ci]) gets
2105 // `Cn = C·w[ci]` (`weighted_C`, `_base.py:740`). The `weights`
2106 // vector is aligned to `classes`, so the class-index = the
2107 // position in `classes` (`ci`/`cj`).
2108 let cp = self.c * weights[cj];
2109 let cn = self.c * weights[ci];
2110
2111 // Extract samples for these two classes.
2112 let mut sub_data = Vec::new();
2113 let mut sub_labels = Vec::new();
2114 let mut sub_indices = Vec::new();
2115
2116 for s in 0..n_samples {
2117 let label = y[s];
2118 if label == class_neg {
2119 sub_data.push(data[s].clone());
2120 sub_labels.push(-F::one());
2121 sub_indices.push(s);
2122 } else if label == class_pos {
2123 sub_data.push(data[s].clone());
2124 sub_labels.push(F::one());
2125 sub_indices.push(s);
2126 }
2127 }
2128
2129 let result = smo_binary(
2130 &sub_data,
2131 &sub_labels,
2132 &kernel,
2133 cp,
2134 cn,
2135 self.tol,
2136 self.max_iter,
2137 self.cache_size,
2138 )?;
2139
2140 // Extract support vectors (non-zero alphas).
2141 let eps = F::from(1e-8).unwrap_or_else(F::epsilon);
2142 let mut sv_data = Vec::new();
2143 let mut sv_coefs = Vec::new();
2144 let mut sv_idx = Vec::new();
2145
2146 for (k, &alpha) in result.alphas.iter().enumerate() {
2147 if alpha > eps {
2148 sv_data.push(sub_data[k].clone());
2149 sv_coefs.push(alpha * sub_labels[k]);
2150 // Record the ORIGINAL training-row index of this SV
2151 // (sub_indices maps the per-pair row k back to X).
2152 sv_idx.push(sub_indices[k]);
2153 }
2154 }
2155
2156 // Platt-scaling CV for this ovo pair (only when probability).
2157 // The CV sub-models are C-SVC (the SAME svm_type as the outer
2158 // model, libsvm `svm.cpp:2147-2150`): the `train_fold` closure
2159 // wraps `smo_binary` + SV extraction, returning the fitted
2160 // sub-model in this crate's sign (`class_pos = +1`).
2161 if self.probability {
2162 let (tol, max_iter, cache_size) = (self.tol, self.max_iter, self.cache_size);
2163 let sub_eps = F::from(1e-8).unwrap_or_else(F::epsilon);
2164 let (a, b) = platt_cv_sigmoid(
2165 &sub_data,
2166 &sub_labels,
2167 &kernel,
2168 |tr_data: &[Vec<F>], tr_labels: &[F]| {
2169 let sub = smo_binary(
2170 tr_data, tr_labels, &kernel, cp, cn, tol, max_iter, cache_size,
2171 )
2172 .ok()?;
2173 let mut sv_d: Vec<Vec<F>> = Vec::new();
2174 let mut sv_c: Vec<F> = Vec::new();
2175 for (k, &alpha) in sub.alphas.iter().enumerate() {
2176 if alpha > sub_eps {
2177 sv_d.push(tr_data[k].clone());
2178 sv_c.push(alpha * tr_labels[k]);
2179 }
2180 }
2181 Some((sv_d, sv_c, sub.bias))
2182 },
2183 );
2184 prob_a.push(a);
2185 prob_b.push(b);
2186 }
2187
2188 binary_models.push(BinarySvm {
2189 support_vectors: sv_data,
2190 sv_indices: sv_idx,
2191 dual_coefs: sv_coefs,
2192 bias: result.bias,
2193 class_neg,
2194 class_pos,
2195 });
2196 }
2197 }
2198
2199 Ok(FittedSVC {
2200 kernel,
2201 binary_models,
2202 classes,
2203 x_train: x.clone(),
2204 y_train: y.to_vec(),
2205 decision_function_shape: self.decision_function_shape,
2206 break_ties: self.break_ties,
2207 probability: self.probability,
2208 prob_a,
2209 prob_b,
2210 })
2211 }
2212}
2213
2214impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> Predict<Array2<F>>
2215 for FittedSVC<F, K>
2216{
2217 type Output = Array1<usize>;
2218 type Error = FerroError;
2219
2220 /// Predict class labels for the given feature matrix.
2221 ///
2222 /// Uses one-vs-one voting (each binary model casts a vote, the most-voted
2223 /// class wins, ties broken toward the lower class index), matching libsvm's
2224 /// `super().predict` (`sklearn/svm/_base.py:813-814`).
2225 ///
2226 /// When `break_ties == true` AND [`SvmDecisionShape::Ovr`] AND
2227 /// `n_classes > 2`, ties are instead broken by the one-vs-rest decision
2228 /// confidence: `predict = argmax(decision_function(X))`
2229 /// (`sklearn/svm/_base.py:806-811`).
2230 ///
2231 /// # Errors
2232 ///
2233 /// Returns [`FerroError::ShapeMismatch`] if the number of features
2234 /// does not match the training data. Returns
2235 /// [`FerroError::InvalidParameter`] when `break_ties == true` and the
2236 /// decision-function shape is [`SvmDecisionShape::Ovo`]
2237 /// (`sklearn/svm/_base.py:801-804`).
2238 fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
2239 let n_samples = x.nrows();
2240 let n_classes = self.classes.len();
2241
2242 // sklearn raises when break_ties=True and decision_function_shape='ovo'
2243 // (`_base.py:801-804`), regardless of n_classes.
2244 if self.break_ties && self.decision_function_shape == SvmDecisionShape::Ovo {
2245 return Err(FerroError::InvalidParameter {
2246 name: "break_ties".into(),
2247 reason: "break_ties must be False when decision_function_shape is 'ovo'".into(),
2248 });
2249 }
2250
2251 // break_ties=True, ovr, multiclass: predict = argmax(decision_function)
2252 // (`_base.py:806-811`). The ovr decision breaks vote ties by confidence.
2253 if self.break_ties && self.decision_function_shape == SvmDecisionShape::Ovr && n_classes > 2
2254 {
2255 let scores = self.decision_function(x)?;
2256 let mc = match scores {
2257 SvmScores::Multiclass(m) => m,
2258 // n_classes > 2 always yields the multiclass variant.
2259 SvmScores::Binary(_) => {
2260 return Err(FerroError::InvalidParameter {
2261 name: "break_ties".into(),
2262 reason: "ovr decision function unavailable for break-tie predict".into(),
2263 });
2264 }
2265 };
2266 let mut predictions = Array1::<usize>::zeros(n_samples);
2267 for s in 0..n_samples {
2268 // argmax over the row; ties keep the first (lowest) index via a
2269 // strictly-greater scan, matching numpy's argmax.
2270 let mut best_idx = 0usize;
2271 let mut best_val = mc[[s, 0]];
2272 for c in 1..n_classes {
2273 if mc[[s, c]] > best_val {
2274 best_val = mc[[s, c]];
2275 best_idx = c;
2276 }
2277 }
2278 predictions[s] = self.classes[best_idx];
2279 }
2280 return Ok(predictions);
2281 }
2282
2283 let mut predictions = Array1::<usize>::zeros(n_samples);
2284
2285 for s in 0..n_samples {
2286 let xi: Vec<F> = x.row(s).to_vec();
2287 let mut votes = vec![0usize; n_classes];
2288
2289 for model in &self.binary_models {
2290 let val = self.decision_value_binary(&xi, model);
2291 let winner = if val >= F::zero() {
2292 model.class_pos
2293 } else {
2294 model.class_neg
2295 };
2296 if let Some(idx) = self.classes.iter().position(|&c| c == winner) {
2297 votes[idx] += 1;
2298 }
2299 }
2300
2301 // libsvm/sklearn break ovo vote ties toward the LOWER class index
2302 // (`sklearn/svm/_base.py:813-814`: `super().predict` -> libsvm
2303 // `svm_predict` keeps the first argmax). `classes` is ascending
2304 // (`np.unique(y)`), so a strictly-greater scan keeps the
2305 // first/lowest-index maximum — unlike `max_by_key`, which returns
2306 // the LAST maximum (the higher index).
2307 let mut best_class_idx = 0usize;
2308 let mut best_votes = 0usize;
2309 for (i, &v) in votes.iter().enumerate() {
2310 if v > best_votes {
2311 best_votes = v;
2312 best_class_idx = i;
2313 }
2314 }
2315
2316 predictions[s] = self.classes[best_class_idx];
2317 }
2318
2319 Ok(predictions)
2320 }
2321}
2322
2323// ---------------------------------------------------------------------------
2324// SVR (Support Vector Regressor)
2325// ---------------------------------------------------------------------------
2326
2327/// Support Vector Regressor.
2328///
2329/// Uses SMO to solve the epsilon-SVR dual problem.
2330///
2331/// # Type Parameters
2332///
2333/// - `F`: The floating-point type (`f32` or `f64`).
2334/// - `K`: The kernel type.
2335#[derive(Debug, Clone)]
2336pub struct SVR<F, K> {
2337 /// The kernel function.
2338 pub kernel: K,
2339 /// Regularization parameter.
2340 pub c: F,
2341 /// Epsilon tube width (insensitive loss zone).
2342 pub epsilon: F,
2343 /// Convergence tolerance.
2344 pub tol: F,
2345 /// Maximum number of SMO iterations. `0` is the sklearn `max_iter=-1`
2346 /// sentinel meaning **no iteration limit** (the SMO runs to convergence).
2347 pub max_iter: usize,
2348 /// Size of the kernel evaluation LRU cache (perf-only; default `200`).
2349 pub cache_size: usize,
2350 /// Whether to use libsvm's shrinking heuristic (`sklearn` `shrinking`,
2351 /// default `true`). Accepted for API parity; does NOT alter the converged
2352 /// optimum (ferrolearn's SMO has no shrinking heuristic — R-DEV-7).
2353 pub shrinking: bool,
2354}
2355
2356impl<F: Float, K: Kernel<F>> SVR<F, K> {
2357 /// Create a new `SVR` with the given kernel and default hyperparameters
2358 /// matching sklearn (`sklearn/svm/_classes.py` `SVR.__init__`).
2359 ///
2360 /// Defaults: `C = 1.0`, `epsilon = 0.1`, `tol = 1e-3`, `max_iter = 0`
2361 /// (= sklearn `-1`, no iteration limit), `cache_size = 200`,
2362 /// `shrinking = true`.
2363 #[must_use]
2364 pub fn new(kernel: K) -> Self {
2365 Self {
2366 kernel,
2367 c: F::one(),
2368 epsilon: F::from(0.1).unwrap_or_else(F::epsilon),
2369 tol: F::from(1e-3).unwrap_or_else(F::epsilon),
2370 max_iter: 0,
2371 cache_size: 200,
2372 shrinking: true,
2373 }
2374 }
2375
2376 /// Set the `shrinking` flag (`sklearn` `shrinking`, default `true`).
2377 ///
2378 /// Accepted for API parity; does NOT alter the converged optimum
2379 /// (ferrolearn's SMO has no shrinking heuristic — R-DEV-7).
2380 #[must_use]
2381 pub fn with_shrinking(mut self, shrinking: bool) -> Self {
2382 self.shrinking = shrinking;
2383 self
2384 }
2385
2386 /// Set the regularization parameter C.
2387 #[must_use]
2388 pub fn with_c(mut self, c: F) -> Self {
2389 self.c = c;
2390 self
2391 }
2392
2393 /// Set the epsilon tube width.
2394 #[must_use]
2395 pub fn with_epsilon(mut self, epsilon: F) -> Self {
2396 self.epsilon = epsilon;
2397 self
2398 }
2399
2400 /// Set the convergence tolerance.
2401 #[must_use]
2402 pub fn with_tol(mut self, tol: F) -> Self {
2403 self.tol = tol;
2404 self
2405 }
2406
2407 /// Set the maximum number of SMO iterations.
2408 #[must_use]
2409 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
2410 self.max_iter = max_iter;
2411 self
2412 }
2413
2414 /// Set the kernel cache size.
2415 #[must_use]
2416 pub fn with_cache_size(mut self, cache_size: usize) -> Self {
2417 self.cache_size = cache_size;
2418 self
2419 }
2420}
2421
2422/// Fitted Support Vector Regressor.
2423///
2424/// Stores the support vectors, dual coefficients, and bias.
2425#[derive(Debug, Clone)]
2426pub struct FittedSVR<F, K> {
2427 /// The kernel used for predictions.
2428 kernel: K,
2429 /// Support vectors.
2430 support_vectors: Vec<Vec<F>>,
2431 /// Original training-row index of each support vector (parallel to
2432 /// `support_vectors`/`dual_coefs`), for the `support_` attribute.
2433 sv_indices: Vec<usize>,
2434 /// Dual coefficients (alpha_i* - alpha_i) for each support vector.
2435 dual_coefs: Vec<F>,
2436 /// Bias term.
2437 bias: F,
2438}
2439
2440impl<F: Float, K: Kernel<F>> FittedSVR<F, K> {
2441 /// Compute the decision function value for a single sample.
2442 fn decision_value(&self, x: &[F]) -> F {
2443 let mut val = self.bias;
2444 for (sv, &coef) in self.support_vectors.iter().zip(self.dual_coefs.iter()) {
2445 val = val + coef * self.kernel.compute(sv, x);
2446 }
2447 val
2448 }
2449
2450 /// Compute the raw decision function values for each sample.
2451 ///
2452 /// # Errors
2453 ///
2454 /// Returns `Ok` always (provided for API symmetry).
2455 pub fn decision_function(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
2456 let n_samples = x.nrows();
2457 let mut result = Array1::<F>::zeros(n_samples);
2458 for s in 0..n_samples {
2459 let xi: Vec<F> = x.row(s).to_vec();
2460 result[s] = self.decision_value(&xi);
2461 }
2462 Ok(result)
2463 }
2464
2465 /// Indices of the support vectors into the training set, ascending
2466 /// (`SVR.support_`, `sklearn/svm/_base.py:318-410`). SVR has a single
2467 /// "class", so there is no per-class grouping; the SVs are kept in
2468 /// training-row order.
2469 #[must_use]
2470 pub fn support(&self) -> Array1<usize> {
2471 Array1::from_vec(self.sv_indices.clone())
2472 }
2473
2474 /// The support vectors, shape `(n_SV, n_features)`
2475 /// (`SVR.support_vectors_`).
2476 #[must_use]
2477 pub fn support_vectors(&self) -> Array2<F> {
2478 let n_sv = self.support_vectors.len();
2479 let n_features = self.support_vectors.first().map_or(0, Vec::len);
2480 let mut out = Array2::<F>::zeros((n_sv, n_features));
2481 for (r, sv) in self.support_vectors.iter().enumerate() {
2482 for (c, &v) in sv.iter().enumerate() {
2483 out[[r, c]] = v;
2484 }
2485 }
2486 out
2487 }
2488
2489 /// Number of support vectors. For SVR `n_support_` has size 1
2490 /// (`sklearn/svm/_base.py:680-682`).
2491 #[must_use]
2492 pub fn n_support(&self) -> Vec<usize> {
2493 vec![self.support_vectors.len()]
2494 }
2495
2496 /// Dual coefficients `alpha*_i - alpha_i`, shape `(1, n_SV)`
2497 /// (`SVR.dual_coef_`). No sign flip applies to SVR
2498 /// (`sklearn/svm/_base.py:260` restricts the flip to `c_svc`/`nu_svc`).
2499 #[must_use]
2500 pub fn dual_coef(&self) -> Array2<F> {
2501 let n_sv = self.dual_coefs.len();
2502 let mut out = Array2::<F>::zeros((1, n_sv));
2503 for (c, &v) in self.dual_coefs.iter().enumerate() {
2504 out[[0, c]] = v;
2505 }
2506 out
2507 }
2508
2509 /// The intercept, length 1 (`SVR.intercept_`). The SVR decision function is
2510 /// `sum coef*K + bias`, matching libsvm's `f(x) = ... + rho`, so the public
2511 /// intercept equals this crate's stored `bias` (no sign flip).
2512 #[must_use]
2513 pub fn intercept(&self) -> Array1<F> {
2514 Array1::from_vec(vec![self.bias])
2515 }
2516}
2517
2518/// Solve epsilon-SVR using SMO.
2519///
2520/// Reformulates the epsilon-SVR dual as a standard 2n-variable QP and
2521/// solves it with the Fan-Chen-Lin WSS approach, analogous to `smo_binary`.
2522///
2523/// The 2n variables are indexed 0..2n:
2524/// - Index `k` (k < n) corresponds to alpha\*\_k with label +1
2525/// - Index `k` (k >= n) corresponds to alpha\_{k-n} with label -1
2526///
2527/// The Q matrix is: `Q_{ij} = s_i * s_j * K(p_i, p_j)` where `s` is the
2528/// sign (+1 or -1) and `p` maps to the original sample index.
2529///
2530/// The linear term is: `q_k = epsilon - y_{p_k} * s_k`.
2531#[allow(clippy::too_many_arguments)]
2532fn smo_svr<F: Float, K: Kernel<F>>(
2533 data: &[Vec<F>],
2534 targets: &[F],
2535 kernel: &K,
2536 c: F,
2537 epsilon: F,
2538 tol: F,
2539 max_iter: usize,
2540 cache_size: usize,
2541) -> Result<(Vec<F>, F), FerroError> {
2542 let n = data.len();
2543 let m = 2 * n; // Total number of dual variables.
2544
2545 // Encoding: variable k in [0, m)
2546 // k < n => alpha*_k (sign = +1, sample index = k)
2547 // k >= n => alpha_{k-n} (sign = -1, sample index = k - n)
2548 //
2549 // The dual is: min 1/2 * beta^T Q beta + q^T beta
2550 // s.t. 0 <= beta_k <= C, sum_k s_k * beta_k = 0
2551 // where beta_k = alpha*_k or alpha_{k-n},
2552 // Q_{ij} = s_i * s_j * K(p_i, p_j),
2553 // q_k = epsilon - y_{p_k} * s_k.
2554 //
2555 // This has the same structure as the SVC dual.
2556
2557 let sign = |k: usize| -> F { if k < n { F::one() } else { -F::one() } };
2558 let sample = |k: usize| -> usize { if k < n { k } else { k - n } };
2559
2560 let mut beta = vec![F::zero(); m];
2561 let mut cache = KernelCache::new(cache_size);
2562
2563 // Gradient: grad_k = (Q * beta)_k + q_k. Initially beta=0 so grad_k = q_k.
2564 // q_k = epsilon - y_{p_k} * s_k
2565 let mut grad: Vec<F> = (0..m)
2566 .map(|k| epsilon - targets[sample(k)] * sign(k))
2567 .collect();
2568
2569 let two = F::one() + F::one();
2570 let eps_num = F::from(1e-12).unwrap_or_else(F::epsilon);
2571
2572 // `max_iter == 0` is the sklearn `max_iter=-1` ("no iteration limit")
2573 // sentinel — run until the KKT gap closes; non-zero caps the count.
2574 let mut iter = 0usize;
2575 loop {
2576 if max_iter != 0 && iter >= max_iter {
2577 break;
2578 }
2579 iter += 1;
2580 // WSS: same as SVC but with the extended variables.
2581 // I_up = {k : (s_k=+1 and beta_k < C) or (s_k=-1 and beta_k > 0)}
2582 // I_low = {k : (s_k=+1 and beta_k > 0) or (s_k=-1 and beta_k < C)}
2583 // Select i = argmax_{k in I_up} -s_k * grad_k
2584 // Select j = argmin_{k in I_low} -s_k * grad_k
2585
2586 let mut i_up = None;
2587 let mut max_val = F::neg_infinity();
2588 let mut j_low = None;
2589 let mut min_val = F::infinity();
2590
2591 for k in 0..m {
2592 let sk = sign(k);
2593 let val = -sk * grad[k];
2594
2595 let in_up =
2596 (sk > F::zero() && beta[k] < c - eps_num) || (sk < F::zero() && beta[k] > eps_num);
2597 let in_low =
2598 (sk > F::zero() && beta[k] > eps_num) || (sk < F::zero() && beta[k] < c - eps_num);
2599
2600 if in_up && val > max_val {
2601 max_val = val;
2602 i_up = Some(k);
2603 }
2604 if in_low && val < min_val {
2605 min_val = val;
2606 j_low = Some(k);
2607 }
2608 }
2609
2610 if i_up.is_none() || j_low.is_none() || max_val - min_val < tol {
2611 break;
2612 }
2613
2614 let i = i_up.unwrap();
2615 let j = j_low.unwrap();
2616
2617 if i == j {
2618 break;
2619 }
2620
2621 let si = sign(i);
2622 let sj = sign(j);
2623 let pi = sample(i);
2624 let pj = sample(j);
2625
2626 // Q_{ii} = si*si*K(pi,pi) = K(pi,pi), similarly for jj and ij
2627 let kii = cache.get_or_compute(pi, pi, kernel, data);
2628 let kjj = cache.get_or_compute(pj, pj, kernel, data);
2629 let kij = cache.get_or_compute(pi, pj, kernel, data);
2630
2631 // eta = Q_{ii} + Q_{jj} - 2*Q_{ij} = K(pi,pi) + K(pj,pj) - 2*si*sj*K(pi,pj)
2632 let eta = kii + kjj - two * si * sj * kij;
2633
2634 if eta <= eps_num {
2635 continue;
2636 }
2637
2638 // Bounds for beta_j
2639 let old_bi = beta[i];
2640 let old_bj = beta[j];
2641
2642 let (lo, hi) = if si == sj {
2643 let sum = old_bi + old_bj;
2644 ((sum - c).max(F::zero()), sum.min(c))
2645 } else {
2646 let diff = old_bj - old_bi;
2647 (diff.max(F::zero()), (c + diff).min(c))
2648 };
2649
2650 if (hi - lo).abs() < eps_num {
2651 continue;
2652 }
2653
2654 // Analytic update: beta_j += s_j * (E_i - E_j) / eta
2655 // where E_k = s_k * grad_k
2656 let mut new_bj = old_bj + sj * (si * grad[i] - sj * grad[j]) / eta;
2657
2658 if new_bj > hi {
2659 new_bj = hi;
2660 }
2661 if new_bj < lo {
2662 new_bj = lo;
2663 }
2664
2665 if (new_bj - old_bj).abs() < eps_num {
2666 continue;
2667 }
2668
2669 let new_bi = old_bi + si * sj * (old_bj - new_bj);
2670
2671 beta[i] = new_bi;
2672 beta[j] = new_bj;
2673
2674 // Update gradient: grad_k += delta_bi * Q_{k,i} + delta_bj * Q_{k,j}
2675 // Q_{k,t} = s_k * s_t * K(p_k, p_t)
2676 let delta_bi = new_bi - old_bi;
2677 let delta_bj = new_bj - old_bj;
2678
2679 for (k, grad_k) in grad.iter_mut().enumerate() {
2680 let sk = sign(k);
2681 let pk = sample(k);
2682 let kki = cache.get_or_compute(pk, pi, kernel, data);
2683 let kkj = cache.get_or_compute(pk, pj, kernel, data);
2684 *grad_k = *grad_k + delta_bi * sk * si * kki + delta_bj * sk * sj * kkj;
2685 }
2686 }
2687
2688 // Recover alpha*_i = beta_i (i < n) and alpha_i = beta_{i+n} (i >= n).
2689 // Coefficient for prediction: coef_i = alpha*_i - alpha_i.
2690 let coefs: Vec<F> = (0..n).map(|i| beta[i] - beta[i + n]).collect();
2691
2692 // Compute bias from KKT conditions on free support vectors.
2693 // For k where 0 < beta_k < C:
2694 // grad_k = 0 at optimality => (Q*beta)_k + q_k = 0
2695 // sum_t beta_t * s_k * s_t * K(p_k, p_t) + epsilon - y_{p_k} * s_k = 0
2696 // s_k * sum_t (beta_t * s_t) * K(p_k, p_t) = y_{p_k} * s_k - epsilon
2697 // sum_t coef_t_effective * K(p_k, p_t) = y_{p_k} - epsilon / s_k (nah, let's use f directly)
2698 //
2699 // Prediction: f(x) = sum_i coef_i * K(x_i, x) + b
2700 // For free alpha*_i (0 < alpha*_i < C): y_i - f(x_i) = epsilon => b = y_i - epsilon - sum coef_j * K(i,j)
2701 // For free alpha_i (0 < alpha_i < C): f(x_i) - y_i = epsilon => b = y_i + epsilon - sum coef_j * K(i,j)
2702
2703 let mut b_sum = F::zero();
2704 let mut b_count = 0usize;
2705
2706 for i in 0..n {
2707 let mut kernel_sum = F::zero();
2708 let has_free = (beta[i] > eps_num && beta[i] < c - eps_num)
2709 || (beta[i + n] > eps_num && beta[i + n] < c - eps_num);
2710
2711 if !has_free {
2712 continue;
2713 }
2714
2715 for (j, &cj) in coefs.iter().enumerate() {
2716 if cj.abs() > eps_num {
2717 kernel_sum = kernel_sum + cj * cache.get_or_compute(i, j, kernel, data);
2718 }
2719 }
2720
2721 if beta[i] > eps_num && beta[i] < c - eps_num {
2722 // alpha*_i is free: y_i - f(x_i) = epsilon => b = y_i - epsilon - kernel_sum
2723 b_sum = b_sum + targets[i] - epsilon - kernel_sum;
2724 b_count += 1;
2725 }
2726 if beta[i + n] > eps_num && beta[i + n] < c - eps_num {
2727 // alpha_i is free: f(x_i) - y_i = epsilon => b = y_i + epsilon - kernel_sum
2728 b_sum = b_sum + targets[i] + epsilon - kernel_sum;
2729 b_count += 1;
2730 }
2731 }
2732
2733 let bias = if b_count > 0 {
2734 b_sum / F::from(b_count).unwrap()
2735 } else {
2736 F::zero()
2737 };
2738
2739 Ok((coefs, bias))
2740}
2741
2742// ---------------------------------------------------------------------------
2743// Solver_NU — the libsvm nu-parameterized solver (nu-SVC / nu-SVR)
2744// ---------------------------------------------------------------------------
2745
2746/// Output of the generic [`solver_nu_core`] solve.
2747struct NuResult<F> {
2748 /// The dual variables `alpha` (length `l`, the solver-internal variables).
2749 alpha: Vec<F>,
2750 /// `rho = (r1 - r2) / 2` (the per-pair bias term, `Solver_NU::calculate_rho`
2751 /// returns this; `sklearn/svm/src/libsvm/svm.cpp:1417`).
2752 rho: F,
2753 /// `r = (r1 + r2) / 2` (`si->r`, the nu-SVC `/r` rescale factor /
2754 /// the nu-SVR `-epsilon`, `svm.cpp:1416`).
2755 r: F,
2756}
2757
2758/// The generic libsvm `Solver_NU` core: solves
2759/// `min 0.5 αᵀQα + pᵀα s.t. 0≤α_k≤C_k, yᵀα=0, eᵀα=const`
2760/// where `Q[k][t] = y_k·y_t·K(sample(k), sample(t))`, with the nu-specific
2761/// second-order working-set selection (separately over the `y=+1` / `y=-1`
2762/// groups, four running maxima `Gmaxp/Gmaxp2/Gmaxn/Gmaxn2`) and the
2763/// two-group `rho`/`r` recovery.
2764///
2765/// A faithful transcription of libsvm's `Solver_NU` + the shared
2766/// `Solver::Solve` update step (`sklearn/svm/src/libsvm/svm.cpp:1166-1418`
2767/// for the nu-specific `select_working_set`/`calculate_rho`, and `:663-940`
2768/// for the gradient init + analytic 2-variable update + objective). Like the
2769/// existing [`smo_binary`]/[`smo_svr`] solvers this is a NATURAL-ORDER,
2770/// no-shrinking, DETERMINISTIC variant: libsvm's shrinking heuristic
2771/// (`Solver_NU::do_shrinking`, `svm.cpp:1318`) is a performance optimization
2772/// that does NOT change the converged optimum (R-DEV-7), so it is omitted; the
2773/// `active_size` always equals `l`. The result (`alpha`, `rho`, `r`) is in
2774/// libsvm's convention so the caller can reconstruct `dual_coef_`/`intercept_`
2775/// that match the live `NuSVC`/`NuSVR` oracle after the public sign flip.
2776///
2777/// Arguments:
2778/// - `data`: the ORIGINAL per-sample feature rows (length `n`), keyed by the
2779/// [`KernelCache`] via `sample(i)` for the kernel evaluation.
2780/// - `m`: number of solver variables (`l` in libsvm; `= n` for nu-SVC,
2781/// `= 2n` for nu-SVR).
2782/// - `sample`: maps a solver variable index `k` to the original sample index
2783/// (into `data`) used for the kernel evaluation `K(sample(i), sample(j))`.
2784/// - `y`: the per-variable sign (`+1` / `-1`).
2785/// - `p`: the linear term (`p[k]`; `0` for nu-SVC, `∓prob.y` for nu-SVR).
2786/// - `c`: the per-variable upper bound `C_k`.
2787/// - `alpha`: the (greedily-initialized) starting dual variables, modified
2788/// in place into the solution.
2789#[allow(
2790 clippy::too_many_arguments,
2791 reason = "a faithful transcription of libsvm's Solver_NU::Solve threads the \
2792 problem (n, m, sample, y, p, c, alpha) + hyperparameters through \
2793 one call; splitting them would obscure the C oracle correspondence"
2794)]
2795#[allow(
2796 clippy::too_many_lines,
2797 reason = "a one-to-one transcription of libsvm's Solver_NU select_working_set \
2798 + the shared Solver analytic 2-variable update + calculate_rho \
2799 (svm.cpp:663-940, 1186-1418); kept inline to preserve the \
2800 line-by-line correspondence to the C oracle"
2801)]
2802fn solver_nu_core<F: Float, K: Kernel<F>>(
2803 data: &[Vec<F>],
2804 m: usize,
2805 sample: &dyn Fn(usize) -> usize,
2806 y: &[F],
2807 p: &[F],
2808 c: &[F],
2809 mut alpha: Vec<F>,
2810 kernel: &K,
2811 tol: F,
2812 max_iter: usize,
2813 cache_size: usize,
2814) -> NuResult<F> {
2815 let zero = F::zero();
2816 let two = F::one() + F::one();
2817 // libsvm's TAU (`svm.cpp:316`): a tiny positive floor for the quadratic
2818 // coefficient when the kernel is not strictly positive-definite.
2819 let tau = F::from(1e-12).unwrap_or_else(F::epsilon);
2820 let eps_bound = F::from(1e-12).unwrap_or_else(F::epsilon);
2821
2822 let mut cache = KernelCache::new(cache_size);
2823 // `QD[i] = K(sample(i), sample(i))` (since `y_i^2 = 1`, `Q_ii = K(i,i)`).
2824 // The cache keys on the ORIGINAL-sample index `sample(i)` into `data`.
2825 let qd: Vec<F> = (0..m)
2826 .map(|i| {
2827 let si = sample(i);
2828 cache.get_or_compute(si, si, kernel, data)
2829 })
2830 .collect();
2831
2832 // `q(i, j) = y_i·y_j·K(sample(i), sample(j))` (libsvm `SVC_Q::get_Q`,
2833 // `svm.cpp:1436-1446`). The cache is keyed by original-sample index.
2834 let q_entry = |i: usize, j: usize, cache: &mut KernelCache<F>| -> F {
2835 y[i] * y[j] * cache.get_or_compute(sample(i), sample(j), kernel, data)
2836 };
2837
2838 // Bound predicates (`Solver::update_alpha_status`, `svm.cpp:588-598`).
2839 let is_upper = |k: usize, a: &[F]| -> bool { a[k] >= c[k] - eps_bound };
2840 let is_lower = |k: usize, a: &[F]| -> bool { a[k] <= eps_bound };
2841
2842 // Gradient `G[k] = (Q·alpha)_k + p[k]` (`svm.cpp:693-715`). Since the
2843 // greedy init has some `alpha_k > 0`, accumulate the full product.
2844 let mut grad: Vec<F> = p.to_vec();
2845 #[allow(
2846 clippy::needless_range_loop,
2847 reason = "i indexes alpha while the inner loop forms Q[i][j]·alpha[i]"
2848 )]
2849 for i in 0..m {
2850 if alpha[i] > eps_bound {
2851 let ai = alpha[i];
2852 for (j, g) in grad.iter_mut().enumerate() {
2853 *g = *g + ai * q_entry(i, j, &mut cache);
2854 }
2855 }
2856 }
2857
2858 let mut iter = 0usize;
2859 loop {
2860 if max_iter != 0 && iter >= max_iter {
2861 break;
2862 }
2863 iter += 1;
2864
2865 // ---- Solver_NU::select_working_set (svm.cpp:1186-1296) ----
2866 let mut gmaxp = F::neg_infinity();
2867 let mut gmaxp2 = F::neg_infinity();
2868 let mut gmaxp_idx: isize = -1;
2869 let mut gmaxn = F::neg_infinity();
2870 let mut gmaxn2 = F::neg_infinity();
2871 let mut gmaxn_idx: isize = -1;
2872
2873 for t in 0..m {
2874 if y[t] > zero {
2875 if !is_upper(t, &alpha) && -grad[t] >= gmaxp {
2876 gmaxp = -grad[t];
2877 gmaxp_idx = t as isize;
2878 }
2879 } else if !is_lower(t, &alpha) && grad[t] >= gmaxn {
2880 gmaxn = grad[t];
2881 gmaxn_idx = t as isize;
2882 }
2883 }
2884
2885 let ip = gmaxp_idx;
2886 let in_ = gmaxn_idx;
2887
2888 let mut gmin_idx: isize = -1;
2889 let mut obj_diff_min = F::infinity();
2890
2891 for j in 0..m {
2892 if y[j] > zero {
2893 if !is_lower(j, &alpha) {
2894 let grad_diff = gmaxp + grad[j];
2895 if grad[j] >= gmaxp2 {
2896 gmaxp2 = grad[j];
2897 }
2898 if grad_diff > zero && ip != -1 {
2899 let ipi = ip as usize;
2900 let quad_coef = qd[ipi] + qd[j] - two * q_entry(ipi, j, &mut cache);
2901 let obj_diff = if quad_coef > zero {
2902 -(grad_diff * grad_diff) / quad_coef
2903 } else {
2904 -(grad_diff * grad_diff) / tau
2905 };
2906 if obj_diff <= obj_diff_min {
2907 gmin_idx = j as isize;
2908 obj_diff_min = obj_diff;
2909 }
2910 }
2911 }
2912 } else if !is_upper(j, &alpha) {
2913 let grad_diff = gmaxn - grad[j];
2914 if -grad[j] >= gmaxn2 {
2915 gmaxn2 = -grad[j];
2916 }
2917 if grad_diff > zero && in_ != -1 {
2918 let ini = in_ as usize;
2919 let quad_coef = qd[ini] + qd[j] - two * q_entry(ini, j, &mut cache);
2920 let obj_diff = if quad_coef > zero {
2921 -(grad_diff * grad_diff) / quad_coef
2922 } else {
2923 -(grad_diff * grad_diff) / tau
2924 };
2925 if obj_diff <= obj_diff_min {
2926 gmin_idx = j as isize;
2927 obj_diff_min = obj_diff;
2928 }
2929 }
2930 }
2931 }
2932
2933 // Stopping criterion (`svm.cpp:1286`).
2934 if (gmaxp + gmaxp2).max(gmaxn + gmaxn2) < tol || gmin_idx == -1 {
2935 break;
2936 }
2937
2938 let i = if y[gmin_idx as usize] > zero {
2939 gmaxp_idx
2940 } else {
2941 gmaxn_idx
2942 };
2943 if i == -1 {
2944 break;
2945 }
2946 let i = i as usize;
2947 let j = gmin_idx as usize;
2948 if i == j {
2949 break;
2950 }
2951
2952 // ---- Solver::Solve analytic 2-variable update (svm.cpp:756-852) ----
2953 let q_ij = q_entry(i, j, &mut cache);
2954 let c_i = c[i];
2955 let c_j = c[j];
2956 let old_alpha_i = alpha[i];
2957 let old_alpha_j = alpha[j];
2958
2959 if y[i] != y[j] {
2960 // `Q_i[j] = y_i·y_j·K = -K(i,j)` here, so `+2·Q_i[j]` in libsvm.
2961 let mut quad_coef = qd[i] + qd[j] + two * q_ij;
2962 if quad_coef <= zero {
2963 quad_coef = tau;
2964 }
2965 let delta = (-grad[i] - grad[j]) / quad_coef;
2966 let diff = alpha[i] - alpha[j];
2967 alpha[i] = alpha[i] + delta;
2968 alpha[j] = alpha[j] + delta;
2969
2970 if diff > zero {
2971 if alpha[j] < zero {
2972 alpha[j] = zero;
2973 alpha[i] = diff;
2974 }
2975 } else if alpha[i] < zero {
2976 alpha[i] = zero;
2977 alpha[j] = -diff;
2978 }
2979 if diff > c_i - c_j {
2980 if alpha[i] > c_i {
2981 alpha[i] = c_i;
2982 alpha[j] = c_i - diff;
2983 }
2984 } else if alpha[j] > c_j {
2985 alpha[j] = c_j;
2986 alpha[i] = c_j + diff;
2987 }
2988 } else {
2989 let mut quad_coef = qd[i] + qd[j] - two * q_ij;
2990 if quad_coef <= zero {
2991 quad_coef = tau;
2992 }
2993 let delta = (grad[i] - grad[j]) / quad_coef;
2994 let sum = alpha[i] + alpha[j];
2995 alpha[i] = alpha[i] - delta;
2996 alpha[j] = alpha[j] + delta;
2997
2998 if sum > c_i {
2999 if alpha[i] > c_i {
3000 alpha[i] = c_i;
3001 alpha[j] = sum - c_i;
3002 }
3003 } else if alpha[j] < zero {
3004 alpha[j] = zero;
3005 alpha[i] = sum;
3006 }
3007 if sum > c_j {
3008 if alpha[j] > c_j {
3009 alpha[j] = c_j;
3010 alpha[i] = sum - c_j;
3011 }
3012 } else if alpha[i] < zero {
3013 alpha[i] = zero;
3014 alpha[j] = sum;
3015 }
3016 }
3017
3018 // ---- Update gradient (svm.cpp:856-862) ----
3019 let delta_alpha_i = alpha[i] - old_alpha_i;
3020 let delta_alpha_j = alpha[j] - old_alpha_j;
3021 #[allow(
3022 clippy::needless_range_loop,
3023 reason = "k indexes grad and is also the kernel column Q[k][i]/Q[k][j]"
3024 )]
3025 for k in 0..m {
3026 let q_ki = q_entry(k, i, &mut cache);
3027 let q_kj = q_entry(k, j, &mut cache);
3028 grad[k] = grad[k] + q_ki * delta_alpha_i + q_kj * delta_alpha_j;
3029 }
3030 }
3031
3032 // ---- Solver_NU::calculate_rho (svm.cpp:1370-1418) ----
3033 let mut nr_free1 = 0usize;
3034 let mut nr_free2 = 0usize;
3035 let mut ub1 = F::infinity();
3036 let mut ub2 = F::infinity();
3037 let mut lb1 = F::neg_infinity();
3038 let mut lb2 = F::neg_infinity();
3039 let mut sum_free1 = zero;
3040 let mut sum_free2 = zero;
3041
3042 for i in 0..m {
3043 let upper = is_upper(i, &alpha);
3044 let lower = is_lower(i, &alpha);
3045 if y[i] > zero {
3046 if upper {
3047 lb1 = lb1.max(grad[i]);
3048 } else if lower {
3049 ub1 = ub1.min(grad[i]);
3050 } else {
3051 nr_free1 += 1;
3052 sum_free1 = sum_free1 + grad[i];
3053 }
3054 } else if upper {
3055 lb2 = lb2.max(grad[i]);
3056 } else if lower {
3057 ub2 = ub2.min(grad[i]);
3058 } else {
3059 nr_free2 += 1;
3060 sum_free2 = sum_free2 + grad[i];
3061 }
3062 }
3063
3064 let r1 = if nr_free1 > 0 {
3065 sum_free1 / F::from(nr_free1).unwrap_or_else(F::one)
3066 } else {
3067 (ub1 + lb1) / two
3068 };
3069 let r2 = if nr_free2 > 0 {
3070 sum_free2 / F::from(nr_free2).unwrap_or_else(F::one)
3071 } else {
3072 (ub2 + lb2) / two
3073 };
3074
3075 NuResult {
3076 alpha,
3077 rho: (r1 - r2) / two,
3078 r: (r1 + r2) / two,
3079 }
3080}
3081
3082/// The recovered nu-SVC binary sub-model: support vectors, their `dual_coef`
3083/// values (`alpha_i·y_i/r`, libsvm's `sv_coef`), original indices, and the
3084/// libsvm-internal bias `-rho/r` (so the decision function is
3085/// `f(x) = Σ sv_coef·K(sv, x) - rho/r`).
3086pub(crate) struct NuSvcModel<F> {
3087 pub sv_data: Vec<Vec<F>>,
3088 pub sv_coefs: Vec<F>,
3089 pub sv_indices: Vec<usize>,
3090 /// libsvm-internal bias term: `-rho/r`. Used directly as the `+1`-side
3091 /// decision bias for the LOWER-index class (libsvm convention).
3092 pub bias_internal: F,
3093}
3094
3095/// Solve the libsvm **nu-SVC** dual for a single binary sub-problem
3096/// (`solve_nu_svc`, `sklearn/svm/src/libsvm/svm.cpp:1646-1708`):
3097/// `min 0.5 αᵀQα s.t. yᵀα=0, eᵀα=ν·l, 0≤α_i≤1`, `Q_ij=y_iy_jK`.
3098///
3099/// `data`/`labels` are the per-pair samples and signs (`+1` for the higher-index
3100/// `class_pos`, `-1` for the lower-index `class_neg`, matching this crate's
3101/// [`BinarySvm`] convention). The greedy `alpha` init (`svm.cpp:1667-1682`)
3102/// fills each class up to `min(C_i, nu·l/2 − running_sum)`. After
3103/// [`solver_nu_core`], libsvm rescales `alpha_i ← alpha_i·y_i/r` and
3104/// `rho ← rho/r` (`svm.cpp:1696-1702`); the support-vector coefficient is
3105/// `sv_coef = alpha·y/r` and the decision bias is `−rho/r`.
3106///
3107/// Returns `None` when `r ≈ 0` (degenerate — no usable rescale, e.g. a
3108/// pathological all-bound solution), letting the caller surface a clean error.
3109#[allow(
3110 clippy::too_many_arguments,
3111 reason = "the solver + per-pair samples/labels + hyperparameters thread \
3112 through one call mirroring libsvm's solve_nu_svc"
3113)]
3114pub(crate) fn solve_nu_svc<F: Float, K: Kernel<F>>(
3115 data: &[Vec<F>],
3116 labels: &[F],
3117 kernel: &K,
3118 nu: F,
3119 tol: F,
3120 max_iter: usize,
3121 cache_size: usize,
3122) -> Option<NuSvcModel<F>> {
3123 let l = data.len();
3124 let zero = F::zero();
3125 let one = F::one();
3126 let two = one + one;
3127 // `C[i] = prob->W[i] = 1` (unit instance weights, `svm.cpp:1664`).
3128 let c = vec![one; l];
3129
3130 // Greedy alpha init (`svm.cpp:1667-1682`): `nu_l = Σ nu·C[i] = nu·l`,
3131 // `sum_pos = sum_neg = nu_l/2`, fill each class greedily up to `C[i]`.
3132 let nu_l = nu * F::from(l).unwrap_or_else(F::zero);
3133 let mut sum_pos = nu_l / two;
3134 let mut sum_neg = nu_l / two;
3135 let mut alpha = vec![zero; l];
3136 for i in 0..l {
3137 if labels[i] > zero {
3138 alpha[i] = c[i].min(sum_pos);
3139 sum_pos = sum_pos - alpha[i];
3140 } else {
3141 alpha[i] = c[i].min(sum_neg);
3142 sum_neg = sum_neg - alpha[i];
3143 }
3144 }
3145
3146 let p = vec![zero; l]; // nu-SVC linear term is 0 (`zeros`, svm.cpp:1684).
3147 let sample = |k: usize| k;
3148 let res = solver_nu_core(
3149 data, l, &sample, labels, &p, &c, alpha, kernel, tol, max_iter, cache_size,
3150 );
3151
3152 let r = res.r;
3153 if r.abs() <= F::from(1e-12).unwrap_or_else(F::epsilon) {
3154 return None;
3155 }
3156
3157 // libsvm: `alpha_i *= y_i / r`, `rho /= r` (`svm.cpp:1696-1702`).
3158 // `sv_coef = alpha·y/r`; decision bias = `-rho/r`.
3159 let eps_sv = F::from(1e-8).unwrap_or_else(F::epsilon);
3160 let mut sv_data = Vec::new();
3161 let mut sv_coefs = Vec::new();
3162 let mut sv_indices = Vec::new();
3163 #[allow(
3164 clippy::needless_range_loop,
3165 reason = "i indexes alpha, the sign labels[i], and the sample data[i] together"
3166 )]
3167 for i in 0..l {
3168 let coef = res.alpha[i] * labels[i] / r;
3169 if coef.abs() > eps_sv {
3170 sv_data.push(data[i].clone());
3171 sv_coefs.push(coef);
3172 sv_indices.push(i);
3173 }
3174 }
3175
3176 Some(NuSvcModel {
3177 sv_data,
3178 sv_coefs,
3179 sv_indices,
3180 bias_internal: (res.rho / r).neg(),
3181 })
3182}
3183
3184/// The recovered nu-SVR model: prediction coefficients `α*−α` per sample,
3185/// support indices, and the bias.
3186pub(crate) struct NuSvrModel<F> {
3187 pub sv_data: Vec<Vec<F>>,
3188 pub sv_coefs: Vec<F>,
3189 pub sv_indices: Vec<usize>,
3190 /// Prediction bias: `f(x) = Σ coef·K(sv, x) + bias`. libsvm's decision
3191 /// function is `Σ coef·K − rho`, so `bias = −rho`.
3192 pub bias: F,
3193}
3194
3195/// Solve the libsvm **nu-SVR** dual (`solve_nu_svr`,
3196/// `sklearn/svm/src/libsvm/svm.cpp:1795-1839`): a `2l`-variable
3197/// `(α, α*)` dual with the learned-tube `nu` constraint, both `nu` AND `C`
3198/// used (`epsilon` is replaced by `nu`).
3199///
3200/// Variable layout (libsvm): `k < l` is `α*_k` with sign `+1` and linear term
3201/// `−y_k`; `k ≥ l` is `α_{k−l}` with sign `−1` and linear term `+y_{k−l}`;
3202/// `C_k = W·C` for all (`svm.cpp:1806-1824`). The greedy init fills both halves
3203/// to `min(sum, C)` where `sum = (Σ C·nu)/2` (`svm.cpp:1814-1817`). After
3204/// [`solver_nu_core`] the prediction coefficient is `coef_k = α*_k − α_k`
3205/// (`svm.cpp:1832-1833`) and the bias is `−rho` (libsvm `f = Σ coef·K − rho`).
3206#[allow(
3207 clippy::too_many_arguments,
3208 reason = "the solver + samples/targets + (nu, C) + hyperparameters thread \
3209 through one call mirroring libsvm's solve_nu_svr"
3210)]
3211pub(crate) fn solve_nu_svr<F: Float, K: Kernel<F>>(
3212 data: &[Vec<F>],
3213 targets: &[F],
3214 kernel: &K,
3215 nu: F,
3216 c_param: F,
3217 tol: F,
3218 max_iter: usize,
3219 cache_size: usize,
3220) -> NuSvrModel<F> {
3221 let l = data.len();
3222 let m = 2 * l;
3223 let zero = F::zero();
3224 let one = F::one();
3225 let two = one + one;
3226
3227 // `C[i] = C[i+l] = W·C` (`svm.cpp:1809`); `sum = (Σ C·nu)/2`.
3228 let c = vec![c_param; m];
3229 let mut sum = c_param * nu * F::from(l).unwrap_or_else(F::zero) / two;
3230
3231 let mut alpha = vec![zero; m];
3232 let mut y = vec![zero; m];
3233 let mut p = vec![zero; m];
3234 for i in 0..l {
3235 let a = sum.min(c[i]); // alpha2[i] = alpha2[i+l] = min(sum, C[i])
3236 alpha[i] = a;
3237 alpha[i + l] = a;
3238 sum = sum - a;
3239 p[i] = targets[i].neg(); // linear_term[i] = -y_i
3240 y[i] = one;
3241 p[i + l] = targets[i]; // linear_term[i+l] = +y_i
3242 y[i + l] = -one;
3243 }
3244
3245 let sample = |k: usize| if k < l { k } else { k - l };
3246 let res = solver_nu_core(
3247 data, m, &sample, &y, &p, &c, alpha, kernel, tol, max_iter, cache_size,
3248 );
3249
3250 // coef_i = alpha2[i] - alpha2[i+l] = α*_i - α_i (`svm.cpp:1832-1833`).
3251 let eps_sv = F::from(1e-8).unwrap_or_else(F::epsilon);
3252 let mut sv_data = Vec::new();
3253 let mut sv_coefs = Vec::new();
3254 let mut sv_indices = Vec::new();
3255 #[allow(
3256 clippy::needless_range_loop,
3257 reason = "i pairs alpha2[i] with alpha2[i+l] and indexes the sample data[i]"
3258 )]
3259 for i in 0..l {
3260 let coef = res.alpha[i] - res.alpha[i + l];
3261 if coef.abs() > eps_sv {
3262 sv_data.push(data[i].clone());
3263 sv_coefs.push(coef);
3264 sv_indices.push(i);
3265 }
3266 }
3267
3268 NuSvrModel {
3269 sv_data,
3270 sv_coefs,
3271 sv_indices,
3272 bias: res.rho.neg(),
3273 }
3274}
3275
3276impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static>
3277 Fit<Array2<F>, Array1<F>> for SVR<F, K>
3278{
3279 type Fitted = FittedSVR<F, K>;
3280 type Error = FerroError;
3281
3282 /// Fit the SVR model using SMO.
3283 ///
3284 /// # Errors
3285 ///
3286 /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
3287 /// sample counts.
3288 /// Returns [`FerroError::InvalidParameter`] if `C` is not positive.
3289 fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedSVR<F, K>, FerroError> {
3290 let (n_samples, _n_features) = x.dim();
3291
3292 if self.c <= F::zero() {
3293 return Err(FerroError::InvalidParameter {
3294 name: "C".into(),
3295 reason: "must be positive".into(),
3296 });
3297 }
3298
3299 if n_samples == 0 {
3300 return Err(FerroError::InsufficientSamples {
3301 required: 1,
3302 actual: 0,
3303 context: "SVR requires at least one sample".into(),
3304 });
3305 }
3306
3307 // Reject non-finite input (NaN / +/-inf) in X or the float target y
3308 // BEFORE the X/y length (`ShapeMismatch`) check, mirroring sklearn's
3309 // `BaseLibSVM.fit` -> `_validate_data(X, y, ...)`
3310 // (`sklearn/svm/_base.py:190`) which routes to `check_X_y`:
3311 // `check_array(X, force_all_finite=True)` then `check_array(y)` both run
3312 // BEFORE `check_consistent_length(X, y)` (`_base.py:208`), so on an input
3313 // that is BOTH non-finite AND length-mismatched sklearn raises the
3314 // finiteness `ValueError("Input X contains NaN.")` /
3315 // `"Input y contains NaN."` / `"... contains infinity ..."`, NOT a
3316 // consistency error (#2270). SVR's `y` is float regression targets, so
3317 // both X and y are finiteness-checked. `.iter().any(|v| !v.is_finite())`
3318 // catches NaN and +/-inf; on finite input the guard never fires, so the
3319 // fitted SVR attributes are byte-identical and a finite length-mismatch
3320 // still yields `ShapeMismatch`.
3321 if x.iter().any(|v| !v.is_finite()) {
3322 return Err(FerroError::InvalidParameter {
3323 name: "X".into(),
3324 reason: "Input X contains NaN or infinity.".into(),
3325 });
3326 }
3327 if y.iter().any(|v| !v.is_finite()) {
3328 return Err(FerroError::InvalidParameter {
3329 name: "y".into(),
3330 reason: "Input y contains NaN or infinity.".into(),
3331 });
3332 }
3333
3334 if n_samples != y.len() {
3335 return Err(FerroError::ShapeMismatch {
3336 expected: vec![n_samples],
3337 actual: vec![y.len()],
3338 context: "y length must match number of samples in X".into(),
3339 });
3340 }
3341
3342 // Resolve any data-dependent kernel parameters (e.g. a `None` gamma ->
3343 // sklearn's default `gamma='scale'` = 1/(n_features * X.var()),
3344 // `_base.py:236-239`) against the training data BEFORE fitting, and use
3345 // this resolved kernel for both fitting and prediction.
3346 let kernel = self.kernel.resolved_for_fit(x);
3347
3348 let data: Vec<Vec<F>> = (0..n_samples).map(|i| x.row(i).to_vec()).collect();
3349 let targets: Vec<F> = y.to_vec();
3350
3351 let (coefs, bias) = smo_svr(
3352 &data,
3353 &targets,
3354 &kernel,
3355 self.c,
3356 self.epsilon,
3357 self.tol,
3358 self.max_iter,
3359 self.cache_size,
3360 )?;
3361
3362 // Extract support vectors (non-zero coefficients).
3363 let eps = F::from(1e-8).unwrap_or_else(F::epsilon);
3364 let mut sv_data = Vec::new();
3365 let mut sv_coefs = Vec::new();
3366 let mut sv_idx = Vec::new();
3367
3368 for (i, &coef) in coefs.iter().enumerate() {
3369 if coef.abs() > eps {
3370 sv_data.push(data[i].clone());
3371 sv_coefs.push(coef);
3372 sv_idx.push(i);
3373 }
3374 }
3375
3376 Ok(FittedSVR {
3377 kernel,
3378 support_vectors: sv_data,
3379 sv_indices: sv_idx,
3380 dual_coefs: sv_coefs,
3381 bias,
3382 })
3383 }
3384}
3385
3386impl<F: Float + Send + Sync + ScalarOperand + 'static, K: Kernel<F> + 'static> Predict<Array2<F>>
3387 for FittedSVR<F, K>
3388{
3389 type Output = Array1<F>;
3390 type Error = FerroError;
3391
3392 /// Predict target values for the given feature matrix.
3393 ///
3394 /// # Errors
3395 ///
3396 /// Returns `Ok` always for valid input.
3397 fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
3398 self.decision_function(x)
3399 }
3400}
3401
3402#[cfg(test)]
3403mod tests {
3404 use super::*;
3405 use approx::assert_relative_eq;
3406 use ndarray::array;
3407
3408 #[test]
3409 fn test_linear_kernel() {
3410 let k = LinearKernel;
3411 let x = vec![1.0, 2.0, 3.0];
3412 let y = vec![4.0, 5.0, 6.0];
3413 assert_relative_eq!(k.compute(&x, &y), 32.0, epsilon = 1e-10);
3414 }
3415
3416 #[test]
3417 fn test_rbf_kernel() {
3418 let k = RbfKernel::with_gamma(1.0);
3419 let x = vec![0.0, 0.0];
3420 let y = vec![0.0, 0.0];
3421 assert_relative_eq!(k.compute(&x, &y), 1.0, epsilon = 1e-10);
3422
3423 // Different points should give value < 1
3424 let y2 = vec![1.0, 0.0];
3425 let val: f64 = k.compute(&x, &y2);
3426 assert!(val < 1.0);
3427 assert!(val > 0.0);
3428 }
3429
3430 #[test]
3431 fn test_polynomial_kernel() {
3432 let k = PolynomialKernel {
3433 gamma: Gamma::Value(1.0),
3434 degree: 2,
3435 coef0: 1.0,
3436 };
3437 let x = vec![1.0f64, 0.0];
3438 let y = vec![1.0, 0.0];
3439 // (1.0 * 1.0 + 1.0)^2 = 4.0
3440 assert_relative_eq!(k.compute(&x, &y), 4.0, epsilon = 1e-10);
3441 }
3442
3443 #[test]
3444 fn test_sigmoid_kernel() {
3445 let k = SigmoidKernel {
3446 gamma: Gamma::Value(1.0),
3447 coef0: 0.0,
3448 };
3449 let x = vec![0.0f64];
3450 let y = vec![0.0];
3451 // tanh(0) = 0
3452 assert_relative_eq!(k.compute(&x, &y), 0.0, epsilon = 1e-10);
3453 }
3454
3455 #[test]
3456 fn test_svc_linear_separable() {
3457 // Two well-separated clusters.
3458 let x = Array2::from_shape_vec(
3459 (8, 2),
3460 vec![
3461 1.0, 1.0, 1.5, 1.0, 1.0, 1.5, 1.5, 1.5, // class 0
3462 5.0, 5.0, 5.5, 5.0, 5.0, 5.5, 5.5, 5.5, // class 1
3463 ],
3464 )
3465 .unwrap();
3466 let y = array![0usize, 0, 0, 0, 1, 1, 1, 1];
3467
3468 let model = SVC::<f64, LinearKernel>::new(LinearKernel).with_c(10.0);
3469 let fitted = model.fit(&x, &y).unwrap();
3470 let preds = fitted.predict(&x).unwrap();
3471
3472 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
3473 assert!(correct >= 6, "Expected at least 6 correct, got {correct}");
3474 }
3475
3476 #[test]
3477 fn test_svc_rbf_xor() {
3478 // XOR problem: not linearly separable, needs RBF kernel.
3479 let x = Array2::from_shape_vec(
3480 (8, 2),
3481 vec![
3482 0.0, 0.0, 0.1, 0.1, // class 0
3483 1.0, 1.0, 1.1, 1.1, // class 0
3484 1.0, 0.0, 1.1, 0.1, // class 1
3485 0.0, 1.0, 0.1, 1.1, // class 1
3486 ],
3487 )
3488 .unwrap();
3489 let y = array![0usize, 0, 0, 0, 1, 1, 1, 1];
3490
3491 let kernel = RbfKernel::with_gamma(5.0);
3492 let model = SVC::new(kernel).with_c(100.0).with_max_iter(50000);
3493 let fitted = model.fit(&x, &y).unwrap();
3494 let preds = fitted.predict(&x).unwrap();
3495
3496 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
3497 assert!(
3498 correct >= 6,
3499 "Expected at least 6 correct for XOR, got {correct}"
3500 );
3501 }
3502
3503 #[test]
3504 fn test_svc_multiclass() {
3505 // Three linearly separable clusters.
3506 let x = Array2::from_shape_vec(
3507 (9, 2),
3508 vec![
3509 0.0, 0.0, 0.5, 0.0, 0.0, 0.5, // class 0
3510 5.0, 0.0, 5.5, 0.0, 5.0, 0.5, // class 1
3511 0.0, 5.0, 0.5, 5.0, 0.0, 5.5, // class 2
3512 ],
3513 )
3514 .unwrap();
3515 let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
3516
3517 let model = SVC::new(LinearKernel).with_c(10.0);
3518 let fitted = model.fit(&x, &y).unwrap();
3519 let preds = fitted.predict(&x).unwrap();
3520
3521 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
3522 assert!(
3523 correct >= 7,
3524 "Expected at least 7 correct for multiclass, got {correct}"
3525 );
3526 }
3527
3528 #[test]
3529 fn test_svc_decision_function() -> TestResult {
3530 let x = Array2::from_shape_vec(
3531 (6, 2),
3532 vec![
3533 1.0, 1.0, 1.5, 1.0, 1.0, 1.5, // class 0
3534 5.0, 5.0, 5.5, 5.0, 5.0, 5.5, // class 1
3535 ],
3536 )
3537 .map_err(|_| err("shape"))?;
3538 let y = array![0usize, 0, 0, 1, 1, 1];
3539
3540 let model = SVC::new(LinearKernel).with_c(10.0);
3541 let fitted = model.fit(&x, &y)?;
3542
3543 let df = fitted.decision_function(&x)?;
3544 // Binary: 1-D (n,) score (sklearn ravels the single ovo column,
3545 // `_base.py:538-539`); the multiclass borrow is None.
3546 assert!(df.as_multiclass().is_none());
3547 let bin = df.as_binary().ok_or_else(|| err("binary 1-D"))?;
3548 assert_eq!(bin.len(), 6);
3549
3550 // Class 0 samples should have negative decision values,
3551 // class 1 should have positive (positive -> classes_[1]).
3552 for (i, &v) in bin.iter().enumerate().take(3) {
3553 assert!(v < 0.5, "Class 0 sample {i} has decision value {v}");
3554 }
3555 Ok(())
3556 }
3557
3558 // -----------------------------------------------------------------------
3559 // REQ-4 smoke tests: decision_function shape + sign + ovr/ovo transform.
3560 //
3561 // Expected values from the LIVE sklearn 1.5.2 oracle (R-CHAR-3):
3562 //
3563 // import numpy as np; from sklearn.svm import SVC
3564 // X=np.array([[1.,1.],[2.,1.],[1.,2.],[5.,5.],[6.,5.],[5.,6.]])
3565 // y=np.array([0,0,0,1,1,1]); m=SVC(kernel='linear',C=1.0).fit(X,y)
3566 // m.decision_function(X) # (6,) [-1.2853,-0.9997,-0.9997,0.9995,1.2851,1.2851]
3567 //
3568 // X3=[[0,0],[.5,0],[0,.5],[5,0],[5.5,0],[5,.5],[0,5],[.5,5],[0,5.5]]
3569 // y3=[0,0,0,1,1,1,2,2,2]; m3=SVC(kernel='linear',C=1.0).fit(X3,y3)
3570 // m3.decision_function(X3) # ovr (9,3) row0 [2.2366,0.8167,-0.1833]
3571 // # row3 [1.0606,2.2262,-0.2333]
3572 // SVC(...,decision_function_shape='ovo').fit(X3,y3).decision_function(X3)
3573 // # ovo (9,3) row0 [1.2222,1.2222,0.0]
3574 // -----------------------------------------------------------------------
3575
3576 fn three_class_9x2() -> Result<(Array2<f64>, Array1<usize>), FerroError> {
3577 let x = Array2::from_shape_vec(
3578 (9, 2),
3579 vec![
3580 0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 5.0, 0.0, 5.5, 0.0, 5.0, 0.5, 0.0, 5.0, 0.5, 5.0,
3581 0.0, 5.5,
3582 ],
3583 )
3584 .map_err(|_| err("shape"))?;
3585 let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
3586 Ok((x, y))
3587 }
3588
3589 #[test]
3590 fn test_svc_decision_function_binary_values() -> TestResult {
3591 let m = binary_fit()?;
3592 let x = Array2::from_shape_vec(
3593 (6, 2),
3594 vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
3595 )
3596 .map_err(|_| err("shape"))?;
3597 let df = m.decision_function(&x)?;
3598 let bin = df.as_binary().ok_or_else(|| err("binary"))?;
3599 assert_eq!(bin.len(), 6);
3600 let oracle = [-1.2853, -0.9997, -0.9997, 0.9995, 1.2851, 1.2851];
3601 for (i, &exp) in oracle.iter().enumerate() {
3602 assert!(
3603 (bin[i] - exp).abs() < 1e-2,
3604 "binary df[{i}] = {} vs oracle {exp}",
3605 bin[i]
3606 );
3607 }
3608 Ok(())
3609 }
3610
3611 #[test]
3612 fn test_svc_decision_function_ovr() -> TestResult {
3613 let (x, y) = three_class_9x2()?;
3614 let m = SVC::new(LinearKernel)
3615 .with_c(1.0)
3616 .with_tol(1e-6)
3617 .with_max_iter(200_000)
3618 .fit(&x, &y)?;
3619 let df = m.decision_function(&x)?;
3620 let mc = df.as_multiclass().ok_or_else(|| err("multiclass"))?;
3621 assert_eq!(mc.dim(), (9, 3));
3622 // ovr (default): row0 [2.2366,0.8167,-0.1833], row3 [1.0606,2.2262,-0.2333].
3623 let row0 = [2.2366, 0.8167, -0.1833];
3624 let row3 = [1.0606, 2.2262, -0.2333];
3625 for (c, &v) in row0.iter().enumerate() {
3626 assert!(
3627 (mc[[0, c]] - v).abs() < 1e-2,
3628 "ovr row0[{c}] = {} vs oracle {v}",
3629 mc[[0, c]]
3630 );
3631 }
3632 for (c, &v) in row3.iter().enumerate() {
3633 assert!(
3634 (mc[[3, c]] - v).abs() < 1e-2,
3635 "ovr row3[{c}] = {} vs oracle {v}",
3636 mc[[3, c]]
3637 );
3638 }
3639 Ok(())
3640 }
3641
3642 #[test]
3643 fn test_svc_decision_function_ovo() -> TestResult {
3644 let (x, y) = three_class_9x2()?;
3645 let m = SVC::new(LinearKernel)
3646 .with_c(1.0)
3647 .with_tol(1e-6)
3648 .with_max_iter(200_000)
3649 .with_decision_function_shape(SvmDecisionShape::Ovo)
3650 .fit(&x, &y)?;
3651 let df = m.decision_function(&x)?;
3652 let mc = df.as_multiclass().ok_or_else(|| err("multiclass"))?;
3653 assert_eq!(mc.dim(), (9, 3));
3654 // ovo: row0 [1.2222,1.2222,0.0].
3655 let row0 = [1.2222, 1.2222, 0.0];
3656 for (c, &v) in row0.iter().enumerate() {
3657 assert!(
3658 (mc[[0, c]] - v).abs() < 1e-2,
3659 "ovo row0[{c}] = {} vs oracle {v}",
3660 mc[[0, c]]
3661 );
3662 }
3663 Ok(())
3664 }
3665
3666 #[test]
3667 fn test_svc_invalid_c() {
3668 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
3669 let y = array![0usize, 0, 1, 1];
3670
3671 let model = SVC::new(LinearKernel).with_c(0.0);
3672 assert!(model.fit(&x, &y).is_err());
3673 }
3674
3675 #[test]
3676 fn test_svc_single_class_error() {
3677 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
3678 let y = array![0usize, 0, 0];
3679
3680 let model = SVC::new(LinearKernel);
3681 assert!(model.fit(&x, &y).is_err());
3682 }
3683
3684 #[test]
3685 fn test_svc_shape_mismatch() {
3686 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
3687 let y = array![0usize, 1];
3688
3689 let model = SVC::new(LinearKernel);
3690 assert!(model.fit(&x, &y).is_err());
3691 }
3692
3693 #[test]
3694 fn test_svr_simple() {
3695 // Simple linear regression: y = 2x
3696 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
3697 let y = array![2.0, 4.0, 6.0, 8.0, 10.0, 12.0];
3698
3699 let model = SVR::new(LinearKernel)
3700 .with_c(100.0)
3701 .with_epsilon(0.1)
3702 .with_max_iter(50000);
3703 let fitted = model.fit(&x, &y).unwrap();
3704 let preds = fitted.predict(&x).unwrap();
3705
3706 // Check predictions are reasonably close.
3707 for (p, &actual) in preds.iter().zip(y.iter()) {
3708 assert!(
3709 (*p - actual).abs() < 2.0,
3710 "SVR prediction {p} too far from actual {actual}"
3711 );
3712 }
3713 }
3714
3715 #[test]
3716 fn test_svr_decision_function() {
3717 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
3718 let y = array![2.0, 4.0, 6.0, 8.0];
3719
3720 let model = SVR::new(LinearKernel).with_c(100.0).with_epsilon(0.1);
3721 let fitted = model.fit(&x, &y).unwrap();
3722
3723 let df = fitted.decision_function(&x).unwrap();
3724 let preds = fitted.predict(&x).unwrap();
3725
3726 // Decision function and predict should return the same values.
3727 for i in 0..4 {
3728 assert_relative_eq!(df[i], preds[i], epsilon = 1e-10);
3729 }
3730 }
3731
3732 #[test]
3733 fn test_svr_invalid_c() {
3734 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
3735 let y = array![1.0, 2.0, 3.0, 4.0];
3736
3737 let model = SVR::new(LinearKernel).with_c(-1.0);
3738 assert!(model.fit(&x, &y).is_err());
3739 }
3740
3741 #[test]
3742 fn test_svr_shape_mismatch() {
3743 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
3744 let y = array![1.0, 2.0];
3745
3746 let model = SVR::new(LinearKernel);
3747 assert!(model.fit(&x, &y).is_err());
3748 }
3749
3750 // -----------------------------------------------------------------------
3751 // REQ-3 smoke tests: libsvm-layout fitted attributes (binary sign flip).
3752 //
3753 // Expected values from the LIVE sklearn 1.5.2 oracle (R-CHAR-3), never
3754 // copied from the ferrolearn side:
3755 //
3756 // python3 -c "import numpy as np; from sklearn.svm import SVC, SVR
3757 // X=np.array([[1.,1.],[2.,1.],[1.,2.],[5.,5.],[6.,5.],[5.,6.]])
3758 // y=np.array([0,0,0,1,1,1]); m=SVC(kernel='linear',C=1.0).fit(X,y)
3759 // print(m.support_.tolist(), m.n_support_.tolist(),
3760 // m.dual_coef_.tolist(), m.intercept_.tolist(), m.coef_.tolist())"
3761 // # [1, 2, 3] [2, 1] [[-0.0408,-0.0408,0.0816]] [-1.8565] [[0.2856,0.2856]]
3762 //
3763 // X3=[[0,0],[.5,0],[0,.5],[5,0],[5.5,0],[5,.5],[0,5],[.5,5],[0,5.5]]
3764 // y3=[0,0,0,1,1,1,2,2,2]; m3=SVC(kernel='linear',C=1.0).fit(X3,y3)
3765 // # support_ [1,2,3,5,6,7] n_support_ [2,2,2]
3766 // # dual_coef_ [[0.0988,0,-0.0988,0,-0.0988,0],[0,0.0988,0,0.0494,0,-0.0494]]
3767 // # intercept_ [1.2222,1.2222,0.0]
3768 //
3769 // Xr=[[1],[2],[3],[4],[5],[6]]; yr=[2,4,6,8,10,12]
3770 // mr=SVR(kernel='linear',C=100,epsilon=0.1).fit(Xr,yr)
3771 // # support_ [0,5] dual_coef_ [[-0.392,0.392]] intercept_ [0.14] n_support_ [2]
3772 //
3773 // The tests return `Result` and use `?`/`ok_or` (no unwrap/expect/panic).
3774 // -----------------------------------------------------------------------
3775
3776 type TestResult = Result<(), FerroError>;
3777
3778 fn err(msg: &str) -> FerroError {
3779 FerroError::InvalidParameter {
3780 name: "test".into(),
3781 reason: msg.into(),
3782 }
3783 }
3784
3785 fn binary_fit() -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
3786 let x = Array2::from_shape_vec(
3787 (6, 2),
3788 vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
3789 )
3790 .map_err(|_| err("shape"))?;
3791 let y = array![0usize, 0, 0, 1, 1, 1];
3792 SVC::new(LinearKernel)
3793 .with_c(1.0)
3794 .with_tol(1e-6)
3795 .with_max_iter(200_000)
3796 .fit(&x, &y)
3797 }
3798
3799 #[test]
3800 fn test_svc_binary_support_attrs() -> TestResult {
3801 let m = binary_fit()?;
3802 // support_ [1,2,3], grouped by class (class0:[1,2], class1:[3]).
3803 assert_eq!(m.support().to_vec(), vec![1, 2, 3]);
3804 // n_support_ [2,1].
3805 assert_eq!(m.n_support(), vec![2, 1]);
3806 // support_vectors_ = X[support_].
3807 let svs = m.support_vectors();
3808 assert_eq!(svs.dim(), (3, 2));
3809 let expected = [[2.0, 1.0], [1.0, 2.0], [5.0, 5.0]];
3810 for (r, row) in expected.iter().enumerate() {
3811 for (c, &v) in row.iter().enumerate() {
3812 assert_relative_eq!(svs[[r, c]], v, epsilon = 1e-10);
3813 }
3814 }
3815 Ok(())
3816 }
3817
3818 #[test]
3819 fn test_svc_binary_dual_coef_sign_flip() -> TestResult {
3820 let m = binary_fit()?;
3821 // dual_coef_ shape (1,3) = [[-0.0408,-0.0408,0.0816]] (binary sign flip).
3822 let dc = m.dual_coef();
3823 assert_eq!(dc.dim(), (1, 3));
3824 let oracle = [-0.0408, -0.0408, 0.0816];
3825 for (c, &v) in oracle.iter().enumerate() {
3826 assert!(
3827 (dc[[0, c]] - v).abs() < 1e-2,
3828 "dual_coef_[0,{c}] = {} vs oracle {v}",
3829 dc[[0, c]]
3830 );
3831 }
3832 Ok(())
3833 }
3834
3835 #[test]
3836 fn test_svc_binary_intercept_and_coef() -> TestResult {
3837 let m = binary_fit()?;
3838 // intercept_ [-1.8565], length 1 (binary sign flip).
3839 let ic = m.intercept();
3840 assert_eq!(ic.len(), 1);
3841 assert!(
3842 (ic[0] - (-1.8565)).abs() < 1e-2,
3843 "intercept_ = {} vs oracle -1.8565",
3844 ic[0]
3845 );
3846 // coef_ [[0.2856,0.2856]] shape (1,2) for the linear kernel.
3847 let coef = m.coef().ok_or_else(|| err("linear kernel exposes coef_"))?;
3848 assert_eq!(coef.dim(), (1, 2));
3849 for c in 0..2 {
3850 assert!(
3851 (coef[[0, c]] - 0.2856).abs() < 1e-2,
3852 "coef_[0,{c}] = {} vs oracle 0.2856",
3853 coef[[0, c]]
3854 );
3855 }
3856 Ok(())
3857 }
3858
3859 #[test]
3860 fn test_svc_coef_none_for_nonlinear() -> TestResult {
3861 // coef_ is only available for the linear kernel; RBF -> None
3862 // (sklearn raises AttributeError, _base.py:650-651).
3863 let x = Array2::from_shape_vec(
3864 (6, 2),
3865 vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
3866 )
3867 .map_err(|_| err("shape"))?;
3868 let y = array![0usize, 0, 0, 1, 1, 1];
3869 let m = SVC::new(RbfKernel::with_gamma(0.5)).fit(&x, &y)?;
3870 assert!(m.coef().is_none());
3871 Ok(())
3872 }
3873
3874 fn multiclass_fit() -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
3875 let x = Array2::from_shape_vec(
3876 (9, 2),
3877 vec![
3878 0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 5.0, 0.0, 5.5, 0.0, 5.0, 0.5, 0.0, 5.0, 0.5, 5.0,
3879 0.0, 5.5,
3880 ],
3881 )
3882 .map_err(|_| err("shape"))?;
3883 let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
3884 SVC::new(LinearKernel)
3885 .with_c(1.0)
3886 .with_tol(1e-6)
3887 .with_max_iter(200_000)
3888 .fit(&x, &y)
3889 }
3890
3891 #[test]
3892 fn test_svc_multiclass_support_attrs() -> TestResult {
3893 let m = multiclass_fit()?;
3894 // support_ [1,2,3,5,6,7] grouped by class; n_support_ [2,2,2].
3895 assert_eq!(m.support().to_vec(), vec![1, 2, 3, 5, 6, 7]);
3896 assert_eq!(m.n_support(), vec![2, 2, 2]);
3897 // intercept_ [1.2222,1.2222,0.0] (no sign flip for multiclass).
3898 let ic = m.intercept();
3899 assert_eq!(ic.len(), 3);
3900 let oracle_ic = [1.2222, 1.2222, 0.0];
3901 for (i, &v) in oracle_ic.iter().enumerate() {
3902 assert!(
3903 (ic[i] - v).abs() < 1e-2,
3904 "intercept_[{i}] = {} vs oracle {v}",
3905 ic[i]
3906 );
3907 }
3908 Ok(())
3909 }
3910
3911 #[test]
3912 fn test_svc_multiclass_dual_coef_packing() -> TestResult {
3913 let m = multiclass_fit()?;
3914 // dual_coef_ shape (2,6), libsvm packing (cols = SVs [1,2,3,5,6,7]):
3915 // row0 = [0.0988, 0.0, -0.0988, 0.0, -0.0988, 0.0]
3916 // row1 = [0.0, 0.0988, 0.0, 0.0494, 0.0, -0.0494]
3917 let dc = m.dual_coef();
3918 assert_eq!(dc.dim(), (2, 6));
3919 let oracle = [
3920 [0.0988, 0.0, -0.0988, 0.0, -0.0988, 0.0],
3921 [0.0, 0.0988, 0.0, 0.0494, 0.0, -0.0494],
3922 ];
3923 for (r, row) in oracle.iter().enumerate() {
3924 for (c, &v) in row.iter().enumerate() {
3925 assert!(
3926 (dc[[r, c]] - v).abs() < 1e-2,
3927 "dual_coef_[{r},{c}] = {} vs oracle {v}",
3928 dc[[r, c]]
3929 );
3930 }
3931 }
3932 Ok(())
3933 }
3934
3935 #[test]
3936 fn test_svr_linear_attrs() -> TestResult {
3937 // SVR(kernel='linear', C=100, epsilon=0.1) on the 6x1 set.
3938 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
3939 .map_err(|_| err("shape"))?;
3940 let y = array![2.0, 4.0, 6.0, 8.0, 10.0, 12.0];
3941 let m = SVR::new(LinearKernel)
3942 .with_c(100.0)
3943 .with_epsilon(0.1)
3944 .with_tol(1e-6)
3945 .with_max_iter(200_000)
3946 .fit(&x, &y)?;
3947 // support_ [0,5]; n_support_ [2]; dual_coef_ (1,2) [[-0.392,0.392]];
3948 // intercept_ [0.14].
3949 assert_eq!(m.support().to_vec(), vec![0, 5]);
3950 assert_eq!(m.n_support(), vec![2]);
3951 let dc = m.dual_coef();
3952 assert_eq!(dc.dim(), (1, 2));
3953 assert!(
3954 (dc[[0, 0]] - (-0.392)).abs() < 1e-2,
3955 "dual_coef_[0,0] = {} vs oracle -0.392",
3956 dc[[0, 0]]
3957 );
3958 assert!(
3959 (dc[[0, 1]] - 0.392).abs() < 1e-2,
3960 "dual_coef_[0,1] = {} vs oracle 0.392",
3961 dc[[0, 1]]
3962 );
3963 let ic = m.intercept();
3964 assert_eq!(ic.len(), 1);
3965 assert!(
3966 (ic[0] - 0.14).abs() < 1e-2,
3967 "intercept_ = {} vs oracle 0.14",
3968 ic[0]
3969 );
3970 Ok(())
3971 }
3972
3973 // -----------------------------------------------------------------------
3974 // REQ-1 (gamma='auto') + REQ-8 (break_ties) smoke tests.
3975 //
3976 // Expected values from the LIVE sklearn 1.5.2 oracle (R-CHAR-3):
3977 //
3978 // import numpy as np; from sklearn.svm import SVC
3979 // X=np.array([[1.,1.],[2.,1.],[1.,2.],[5.,5.],[6.,5.],[5.,6.]])
3980 // y=np.array([0,0,0,1,1,1])
3981 // m=SVC(kernel='rbf',C=1.0,gamma='auto').fit(X,y)
3982 // m._gamma # 0.5 (= 1/n_features = 1/2)
3983 // m.decision_function(X) # [-0.9996,-0.9999,-0.9999,
3984 // # 0.9999, 0.9999, 0.9996]
3985 //
3986 // break_ties: a symmetric, cleanly-separable 3-class set so ferrolearn's
3987 // SMO converges to libsvm's optimum (each class has 2 SVs). Near the
3988 // centroid the three ovr scores are ~1.0 (a near 1-1-1 vote tie), so the
3989 // libsvm vote breaks toward the LOWEST class index (0) while ovr-argmax
3990 // breaks by confidence. Oracle (re-derived vs the live oracle):
3991 // Xb=[[0,0],[.5,0],[0,.5],[10,0],[10.5,0],[10,.5],[5,9],[5.5,9],[5,9.5]]
3992 // yb=[0,0,0,1,1,1,2,2,2]
3993 // q1=[[5.19,3.342]]: vote -> 0, break_ties -> 2 (df [0.9942,0.999,1.0068])
3994 // q2=[[5.19,3.241]]: vote -> 0, break_ties -> 1 (df [0.9999,1.0051,0.9949])
3995 // SVC(...,decision_function_shape='ovo',break_ties=True) -> ValueError
3996 // -----------------------------------------------------------------------
3997
3998 #[test]
3999 fn test_svc_gamma_auto_decision_function() -> TestResult {
4000 // gamma='auto' on the 6x2 set: _gamma = 1/n_features = 0.5.
4001 let x = Array2::from_shape_vec(
4002 (6, 2),
4003 vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
4004 )
4005 .map_err(|_| err("shape"))?;
4006 let y = array![0usize, 0, 0, 1, 1, 1];
4007
4008 // Confirm the resolved gamma is 1/n_features (sklearn `_base.py:241`).
4009 let resolved = RbfKernel::<f64>::with_gamma_auto().resolved_for_fit(&x);
4010 assert!(
4011 (gamma_value_or_one(resolved.gamma) - 0.5).abs() < 1e-12,
4012 "gamma='auto' resolved to {} vs oracle 0.5",
4013 gamma_value_or_one(resolved.gamma)
4014 );
4015
4016 let m = SVC::new(RbfKernel::<f64>::with_gamma_auto())
4017 .with_c(1.0)
4018 .with_tol(1e-6)
4019 .with_max_iter(200_000)
4020 .fit(&x, &y)?;
4021 let df = m.decision_function(&x)?;
4022 let bin = df.as_binary().ok_or_else(|| err("binary"))?;
4023 assert_eq!(bin.len(), 6);
4024 let oracle = [-0.9996, -0.9999, -0.9999, 0.9999, 0.9999, 0.9996];
4025 for (i, &exp) in oracle.iter().enumerate() {
4026 assert!(
4027 (bin[i] - exp).abs() < 1e-2,
4028 "gamma=auto df[{i}] = {} vs oracle {exp}",
4029 bin[i]
4030 );
4031 }
4032 Ok(())
4033 }
4034
4035 #[test]
4036 fn test_svc_gamma_scale_still_default() -> TestResult {
4037 // gamma='scale' (the default) must STILL resolve to
4038 // 1/(n_features * X.var()); on the 6x2 set X.var()=4.2222 ->
4039 // _gamma = 1/(2*4.2222) = 0.11842 (sklearn `_base.py:238-239`).
4040 let x = Array2::from_shape_vec(
4041 (6, 2),
4042 vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0],
4043 )
4044 .map_err(|_| err("shape"))?;
4045 let resolved = RbfKernel::<f64>::new().resolved_for_fit(&x);
4046 assert!(
4047 (gamma_value_or_one(resolved.gamma) - 0.118_421).abs() < 1e-4,
4048 "gamma='scale' resolved to {} vs oracle 0.118421",
4049 gamma_value_or_one(resolved.gamma)
4050 );
4051 Ok(())
4052 }
4053
4054 fn break_ties_set() -> Result<(Array2<f64>, Array1<usize>), FerroError> {
4055 let x = Array2::from_shape_vec(
4056 (9, 2),
4057 vec![
4058 0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 10.0, 0.0, 10.5, 0.0, 10.0, 0.5, 5.0, 9.0, 5.5, 9.0,
4059 5.0, 9.5,
4060 ],
4061 )
4062 .map_err(|_| err("shape"))?;
4063 let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
4064 Ok((x, y))
4065 }
4066
4067 fn break_ties_fit(
4068 break_ties: bool,
4069 shape: SvmDecisionShape,
4070 ) -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
4071 let (x, y) = break_ties_set()?;
4072 SVC::new(LinearKernel)
4073 .with_c(1.0)
4074 .with_tol(1e-6)
4075 .with_max_iter(200_000)
4076 .with_break_ties(break_ties)
4077 .with_decision_function_shape(shape)
4078 .fit(&x, &y)
4079 }
4080
4081 #[test]
4082 fn test_svc_break_ties_changes_label() -> TestResult {
4083 // q1=(5.19,3.342): vote -> 0, break_ties -> 2.
4084 let q1 = Array2::from_shape_vec((1, 2), vec![5.19, 3.342]).map_err(|_| err("shape"))?;
4085 // q2=(5.19,3.241): vote -> 0, break_ties -> 1.
4086 let q2 = Array2::from_shape_vec((1, 2), vec![5.19, 3.241]).map_err(|_| err("shape"))?;
4087
4088 let vote = break_ties_fit(false, SvmDecisionShape::Ovr)?;
4089 let bt = break_ties_fit(true, SvmDecisionShape::Ovr)?;
4090
4091 // break_ties=false (default): libsvm vote -> lowest-index class 0.
4092 assert_eq!(vote.predict(&q1)?[0], 0, "vote q1 should be 0");
4093 assert_eq!(vote.predict(&q2)?[0], 0, "vote q2 should be 0");
4094
4095 // break_ties=true + ovr: argmax(decision_function).
4096 assert_eq!(
4097 bt.predict(&q1)?[0],
4098 2,
4099 "break_ties q1 (ovr-argmax) should be 2"
4100 );
4101 assert_eq!(
4102 bt.predict(&q2)?[0],
4103 1,
4104 "break_ties q2 (ovr-argmax) should be 1"
4105 );
4106 Ok(())
4107 }
4108
4109 #[test]
4110 fn test_svc_break_ties_ovo_errors() -> TestResult {
4111 // sklearn raises when break_ties=True and decision_function_shape='ovo'
4112 // (`_base.py:801-804`).
4113 let m = break_ties_fit(true, SvmDecisionShape::Ovo)?;
4114 let q = Array2::from_shape_vec((1, 2), vec![5.19, 3.342]).map_err(|_| err("shape"))?;
4115 assert!(m.predict(&q).is_err());
4116 Ok(())
4117 }
4118
4119 #[test]
4120 fn test_svc_default_params() {
4121 // sklearn defaults: cache_size=200, max_iter=-1 (= 0 sentinel),
4122 // shrinking=True, break_ties=False, decision_function_shape='ovr'.
4123 let m = SVC::<f64, LinearKernel>::new(LinearKernel);
4124 assert_eq!(m.cache_size, 200);
4125 assert_eq!(m.max_iter, 0);
4126 assert!(m.shrinking);
4127 assert!(!m.break_ties);
4128 assert_eq!(m.decision_function_shape, SvmDecisionShape::Ovr);
4129 let r = SVR::<f64, LinearKernel>::new(LinearKernel);
4130 assert_eq!(r.cache_size, 200);
4131 assert_eq!(r.max_iter, 0);
4132 assert!(r.shrinking);
4133 }
4134
4135 /// The overlapping imbalanced binary set used to pin `class_weight`.
4136 fn class_weight_xy() -> Result<(Array2<f64>, Array1<usize>), FerroError> {
4137 let x = Array2::from_shape_vec(
4138 (8, 2),
4139 vec![
4140 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.5, 0.5, 1.5, 0.5, 2.0, 2.0, 2.5, 2.5,
4141 ],
4142 )
4143 .map_err(|_| err("shape"))?;
4144 let y = array![0usize, 0, 0, 0, 0, 1, 1, 1];
4145 Ok((x, y))
4146 }
4147
4148 fn cw_fit(cw: ClassWeight<f64>) -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
4149 let (x, y) = class_weight_xy()?;
4150 SVC::new(LinearKernel)
4151 .with_c(1.0)
4152 .with_tol(1e-7)
4153 .with_max_iter(500_000)
4154 .with_class_weight(cw)
4155 .fit(&x, &y)
4156 }
4157
4158 /// `class_weight` per-class C in the C-SVC SMO (REQ-8, #641).
4159 ///
4160 /// Oracle (live `SVC(kernel='linear', C=1.0, class_weight=...)` on the
4161 /// overlapping imbalanced binary set, R-CHAR-3):
4162 /// ```text
4163 /// X=[[0,0],[1,0],[0,1],[1,1],[0.5,0.5],[1.5,0.5],[2,2],[2.5,2.5]] y=[0,0,0,0,0,1,1,1]
4164 /// None -> dual_coef_ [[-0.5,-1.0,1.0,0.5]] intercept_ [-2.0] support_ [1,3,5,6]
4165 /// balanced -> dual_coef_ [[-0.8,-0.8,1.3333,0.2667]] intercept_ [-1.6667] support_ [1,3,5,6]
4166 /// {0:1,1:5}-> support_ [1,3,4,5] intercept_ [-2.0]
4167 /// ```
4168 #[test]
4169 fn test_svc_class_weight_smoke() -> TestResult {
4170 // class_weight=None.
4171 let none = cw_fit(ClassWeight::None)?;
4172 assert_eq!(none.support().to_vec(), vec![1, 3, 5, 6]);
4173 let dc = none.dual_coef();
4174 for (c, &v) in [-0.5, -1.0, 1.0, 0.5].iter().enumerate() {
4175 assert!(
4176 (dc[[0, c]] - v).abs() < 1e-2,
4177 "None dual_coef_[0,{c}] = {} vs {v}",
4178 dc[[0, c]]
4179 );
4180 }
4181 let none_int = none.intercept()[0];
4182 assert!(
4183 (none_int - (-2.0)).abs() < 1e-2,
4184 "None intercept_ {none_int}"
4185 );
4186
4187 // class_weight='balanced' (weights [0.8, 1.3333]).
4188 let bal = cw_fit(ClassWeight::Balanced)?;
4189 assert_eq!(bal.support().to_vec(), vec![1, 3, 5, 6]);
4190 let dcb = bal.dual_coef();
4191 for (c, &v) in [-0.8, -0.8, 1.3333, 0.2667].iter().enumerate() {
4192 assert!(
4193 (dcb[[0, c]] - v).abs() < 1e-2,
4194 "balanced dual_coef_[0,{c}] = {} vs {v}",
4195 dcb[[0, c]]
4196 );
4197 }
4198 let bal_int = bal.intercept()[0];
4199 assert!(
4200 (bal_int - (-1.6667)).abs() < 1e-2,
4201 "balanced intercept_ {bal_int}"
4202 );
4203
4204 // class_weight={0:1, 1:5}.
4205 let exp = cw_fit(ClassWeight::Explicit(vec![(0, 1.0), (1, 5.0)]))?;
4206 assert_eq!(exp.support().to_vec(), vec![1, 3, 4, 5]);
4207 let exp_int = exp.intercept()[0];
4208 assert!(
4209 (exp_int - (-2.0)).abs() < 1e-2,
4210 "explicit intercept_ {exp_int}"
4211 );
4212
4213 // None vs balanced MUST give different intercepts — fails if
4214 // class_weight were ignored (R-CHAR-1).
4215 assert!(
4216 (none_int - bal_int).abs() > 1e-2,
4217 "None intercept {none_int} must differ from balanced {bal_int}"
4218 );
4219 Ok(())
4220 }
4221
4222 // -----------------------------------------------------------------------
4223 // REQ-9 smoke tests: probability / predict_proba (Platt scaling).
4224 //
4225 // These pin the DETERMINISTIC contract + STRUCTURAL invariants only, NOT
4226 // exact probA/probB or predict_proba values. sklearn's predict_proba is
4227 // RNG-CV-dependent (probA_ = -0.7749 at random_state=0 vs -1.0541 at
4228 // random_state=1 on the binary set), so exact values are NOT a stable
4229 // oracle (R-CHAR-3: the asserted invariants are sklearn's DOCUMENTED
4230 // contract — `_base.py:829-864` "columns correspond to classes_ in sorted
4231 // order", `predict_proba` rows are a probability distribution — never
4232 // copied from the ferrolearn side).
4233 // -----------------------------------------------------------------------
4234
4235 #[test]
4236 fn test_svc_predict_proba_raises_when_probability_false() -> TestResult {
4237 // probability=false (default): predict_proba/predict_log_proba error,
4238 // mirroring sklearn's raise (`_base.py:820-827`/`856-860`).
4239 let m = binary_fit()?; // default probability=false
4240 let x = Array2::from_shape_vec((1, 2), vec![3.0, 3.0]).map_err(|_| err("shape"))?;
4241 assert!(m.predict_proba(&x).is_err());
4242 assert!(m.predict_log_proba(&x).is_err());
4243 Ok(())
4244 }
4245
4246 fn proba_binary_fit() -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
4247 let x = Array2::from_shape_vec(
4248 (10, 2),
4249 vec![
4250 1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 2.0, 2.0, 1.5, 1.5, 5.0, 5.0, 6.0, 5.0, 5.0, 6.0,
4251 6.0, 6.0, 5.5, 5.5,
4252 ],
4253 )
4254 .map_err(|_| err("shape"))?;
4255 let y = array![0usize, 0, 0, 0, 0, 1, 1, 1, 1, 1];
4256 SVC::new(LinearKernel)
4257 .with_c(1.0)
4258 .with_tol(1e-6)
4259 .with_max_iter(200_000)
4260 .with_probability(true)
4261 .fit(&x, &y)
4262 }
4263
4264 #[test]
4265 fn test_svc_predict_proba_binary_rows_sum_to_one() -> TestResult {
4266 let m = proba_binary_fit()?;
4267 let x = Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 1.5, 1.5, 5.0, 5.0, 5.5, 5.5])
4268 .map_err(|_| err("shape"))?;
4269 let p = m.predict_proba(&x)?;
4270 assert_eq!(p.dim(), (4, 2));
4271 for s in 0..4 {
4272 let row_sum = p[[s, 0]] + p[[s, 1]];
4273 assert!((row_sum - 1.0).abs() < 1e-9, "row {s} sums to {row_sum}");
4274 for c in 0..2 {
4275 assert!(
4276 p[[s, c]] >= 0.0 && p[[s, c]] <= 1.0,
4277 "p[{s},{c}] = {} out of [0,1]",
4278 p[[s, c]]
4279 );
4280 }
4281 }
4282 Ok(())
4283 }
4284
4285 #[test]
4286 fn test_svc_predict_proba_binary_monotone_in_decision() -> TestResult {
4287 // STRUCTURAL invariant: P(classes_[1]) is monotone non-decreasing in
4288 // the (binary) decision_function value (higher decision -> higher
4289 // P(class_1)), per the sigmoid `1/(1+exp(A f + B))` contract.
4290 let m = proba_binary_fit()?;
4291 // A grid of query points sweeping from the class-0 to the class-1 side.
4292 let x = Array2::from_shape_vec(
4293 (5, 2),
4294 vec![1.0, 1.0, 2.5, 2.5, 3.5, 3.5, 4.5, 4.5, 6.0, 6.0],
4295 )
4296 .map_err(|_| err("shape"))?;
4297 let p = m.predict_proba(&x)?;
4298 let df = m.decision_function(&x)?;
4299 let bin = df.as_binary().ok_or_else(|| err("binary"))?;
4300
4301 // Sort sample indices by decision value, then P(class_1) must be
4302 // non-decreasing along that order.
4303 let mut order: Vec<usize> = (0..5).collect();
4304 order.sort_by(|&a, &b| {
4305 bin[a]
4306 .partial_cmp(&bin[b])
4307 .unwrap_or(std::cmp::Ordering::Equal)
4308 });
4309 let mut prev = f64::NEG_INFINITY;
4310 for &s in &order {
4311 let p1 = p[[s, 1]];
4312 assert!(
4313 p1 >= prev - 1e-9,
4314 "P(class_1) not monotone in decision: sample {s} df={} p1={p1} prev={prev}",
4315 bin[s]
4316 );
4317 prev = p1;
4318 }
4319 Ok(())
4320 }
4321
4322 #[test]
4323 fn test_svc_predict_log_proba_equals_log_of_proba() -> TestResult {
4324 let m = proba_binary_fit()?;
4325 let x = Array2::from_shape_vec((3, 2), vec![1.0, 1.0, 3.5, 3.5, 6.0, 6.0])
4326 .map_err(|_| err("shape"))?;
4327 let p = m.predict_proba(&x)?;
4328 let lp = m.predict_log_proba(&x)?;
4329 assert_eq!(lp.dim(), p.dim());
4330 for s in 0..3 {
4331 for c in 0..2 {
4332 assert_relative_eq!(lp[[s, c]], p[[s, c]].ln(), epsilon = 1e-12);
4333 }
4334 }
4335 Ok(())
4336 }
4337
4338 fn proba_multiclass_fit() -> Result<FittedSVC<f64, LinearKernel>, FerroError> {
4339 let x = Array2::from_shape_vec(
4340 (9, 2),
4341 vec![
4342 0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 5.0, 0.0, 5.5, 0.0, 5.0, 0.5, 0.0, 5.0, 0.5, 5.0,
4343 0.0, 5.5,
4344 ],
4345 )
4346 .map_err(|_| err("shape"))?;
4347 let y = array![0usize, 0, 0, 1, 1, 1, 2, 2, 2];
4348 SVC::new(LinearKernel)
4349 .with_c(1.0)
4350 .with_tol(1e-6)
4351 .with_max_iter(200_000)
4352 .with_probability(true)
4353 .fit(&x, &y)
4354 }
4355
4356 #[test]
4357 fn test_svc_predict_proba_multiclass_rows_sum_to_one() -> TestResult {
4358 // 3-class: predict_proba is (n, 3), each row a probability
4359 // distribution (Wu-Lin-Weng coupling, `svm.cpp:2941`).
4360 let m = proba_multiclass_fit()?;
4361 let x = Array2::from_shape_vec((3, 2), vec![0.25, 0.25, 5.0, 0.25, 0.25, 5.0])
4362 .map_err(|_| err("shape"))?;
4363 let p = m.predict_proba(&x)?;
4364 assert_eq!(p.dim(), (3, 3));
4365 for s in 0..3 {
4366 let row_sum: f64 = (0..3).map(|c| p[[s, c]]).sum();
4367 assert!((row_sum - 1.0).abs() < 1e-9, "row {s} sums to {row_sum}");
4368 for c in 0..3 {
4369 assert!(
4370 p[[s, c]] >= 0.0 && p[[s, c]] <= 1.0,
4371 "p[{s},{c}] = {} out of [0,1]",
4372 p[[s, c]]
4373 );
4374 }
4375 }
4376 Ok(())
4377 }
4378
4379 #[test]
4380 fn test_sigmoid_predict_overflow_safe() {
4381 // sigmoid_predict matches `1/(1+exp(A f + B))` and is overflow-safe at
4382 // extreme decision values (`svm.cpp:2032-2040`).
4383 let a = -1.0f64;
4384 let b = 0.0;
4385 // f large positive -> fApB = -f large negative -> p -> 1.
4386 let p_pos = sigmoid_predict(1000.0, a, b);
4387 assert!(p_pos.is_finite() && (p_pos - 1.0).abs() < 1e-6);
4388 // f large negative -> p -> 0.
4389 let p_neg = sigmoid_predict(-1000.0, a, b);
4390 assert!(p_neg.is_finite() && p_neg.abs() < 1e-6);
4391 // f = 0 -> 1/(1+exp(0)) = 0.5.
4392 assert_relative_eq!(sigmoid_predict(0.0, a, b), 0.5, epsilon = 1e-12);
4393 }
4394
4395 #[test]
4396 fn test_multiclass_probability_binary_reduces_to_pairwise() {
4397 // For k=2 the Wu-Lin-Weng coupling reduces to [r01, 1-r01].
4398 let mut r = Array2::<f64>::zeros((2, 2));
4399 r[[0, 1]] = 0.7;
4400 r[[1, 0]] = 0.3;
4401 let p = multiclass_probability(2, &r);
4402 assert_relative_eq!(p[0], 0.7, epsilon = 1e-6);
4403 assert_relative_eq!(p[1], 0.3, epsilon = 1e-6);
4404 }
4405
4406 /// `compute_class_weight` matches `sklearn.utils.compute_class_weight`
4407 /// (`_classes.py:122-124` balanced formula) on the imbalanced set.
4408 #[test]
4409 fn test_compute_class_weight_balanced() {
4410 // 8 samples, 2 classes; class0 count=5, class1 count=3.
4411 // balanced[c] = 8 / (2 * count_c): [8/10, 8/6] = [0.8, 1.3333].
4412 let classes = [0usize, 1];
4413 let y = [0usize, 0, 0, 0, 0, 1, 1, 1];
4414 let w = compute_class_weight::<f64>(&ClassWeight::Balanced, &classes, &y);
4415 assert_relative_eq!(w[0], 0.8, epsilon = 1e-9);
4416 assert_relative_eq!(w[1], 8.0 / 6.0, epsilon = 1e-9);
4417 // None -> all 1.0.
4418 let wn = compute_class_weight::<f64>(&ClassWeight::None, &classes, &y);
4419 assert_eq!(wn, vec![1.0, 1.0]);
4420 // Explicit map, unlisted defaults to 1.0.
4421 let we = compute_class_weight::<f64>(&ClassWeight::Explicit(vec![(1, 5.0)]), &classes, &y);
4422 assert_eq!(we, vec![1.0, 5.0]);
4423 }
4424}