ferrolearn_linear/linear_svc.rs
1//! Linear Support Vector Classifier.
2//!
3//! This module provides [`LinearSVC`], a liblinear-faithful linear support
4//! vector classifier that operates directly in the primal space without the
5//! overhead of a kernel function. The fit minimizes the L2-regularized
6//! hinge (or squared-hinge) classification objective
7//!
8//! ```text
9//! min_w 0.5 * ||w||^2 + C * sum_i L(y_i, w . x_i)
10//! ```
11//!
12//! with `y_i ∈ {-1, +1}` per one-vs-rest sub-problem (NO `1/n` averaging — the
13//! summed loss is scaled by `C`, matching `sklearn/svm/_base.py`
14//! `_fit_liblinear`). The solver is liblinear's dual coordinate descent for
15//! classification (`solve_l2r_l1l2_svc` in
16//! `sklearn/svm/src/liblinear/linear.cpp:819`), which converges to the unique
17//! minimizer of the strongly convex objective.
18//!
19//! Unlike [`SVC`](crate::svm::SVC) with a [`LinearKernel`](crate::svm::LinearKernel),
20//! `LinearSVC` avoids computing and caching the full kernel matrix, making it
21//! significantly faster for high-dimensional data.
22//!
23//! # Examples
24//!
25//! ```
26//! use ferrolearn_linear::linear_svc::LinearSVC;
27//! use ferrolearn_core::{Fit, Predict};
28//! use ndarray::{array, Array2};
29//!
30//! let x = Array2::from_shape_vec((6, 2), vec![
31//! 1.0, 1.0, 1.0, 2.0, 2.0, 1.0,
32//! 5.0, 5.0, 5.0, 6.0, 6.0, 5.0,
33//! ]).unwrap();
34//! let y = array![0usize, 0, 0, 1, 1, 1];
35//!
36//! let model = LinearSVC::<f64>::new();
37//! let fitted = model.fit(&x, &y).unwrap();
38//! let preds = fitted.predict(&x).unwrap();
39//! assert_eq!(preds.len(), 6);
40//! ```
41//!
42//! ## REQ status
43//!
44//! Binary (R-DEFER-2): SHIPPED = impl + non-test production consumer + tests +
45//! green oracle verification; NOT-STARTED = open blocker `#`. `LinearSVC`/
46//! `FittedLinearSVC`/`LinearSVCLoss` are boundary estimator types re-exported at
47//! the crate root (`pub use linear_svc::{…}` in `lib.rs`) and registered as the
48//! PyO3 `RsLinearSVC` estimator (`ferrolearn-python/src/extras.rs`); under
49//! S5/R-DEFER-1 those ARE the non-test production-consumer surface. See
50//! `.design/linear/linear_svc.md`.
51//!
52//! | REQ | Status | Evidence |
53//! |---|---|---|
54//! | REQ-1 (fit parity — coef_/intercept_ vs liblinear oracle) | SHIPPED | `fn solve_binary_dual` minimizes liblinear's `0.5·‖w‖² + C·Σ L` via the dual CD (`solve_l2r_l1l2_svc`, `linear.cpp:819`); `fn fit` maps `classes_[1]→+1` and extracts `coef_ = w[:n_features]`, `intercept_ = intercept_scaling·w_last` (`_base.py:1240-1245`). Pinned by `tests/divergence_linear_svc_fit.rs::linear_svc_coef_parity` (live oracle `coef_ [[0.12835213611984458, 0.12835213611984475]]`, `intercept_ [-1.1943776585907158]`, C=1.0, squared_hinge, fit_intercept=True). Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
55//! | REQ-2 (decision_function shape `(n,)` + values) | SHIPPED | `fn decision_function` returns [`DecisionScores::Binary`] = 1-D `X·w + b` for the binary case (sklearn ravels the single-column score to `(n,)`, `linear_model/_base.py:365`) and [`DecisionScores::Multiclass`] `(n, n_classes)` otherwise. Pinned by `tests/divergence_linear_svc_fit.rs::linear_svc_decision_function` (live oracle 1-D `(8,)` values). Consumer: `fn predict` reads the binary scores' sign. |
56//! | REQ-3 (predict + classes_) | SHIPPED | `fn predict` uses the sign of the binary decision (`>= 0 → classes_[1]`) / argmax of the OvR scores; `HasClasses::classes` = sorted unique `y` (`classes_ = np.unique(y)`, `_classes.py:311`). The labels are downstream of the liblinear-parity fit and pinned against the live oracle by `linear_svc_predict_parity in tests/divergence_linear_svc_fit.rs` (#620; 8×2 set: `predict [0,0,0,0,1,1,1,1]`, `classes_ [0,1]`). |
57//! | REQ-4 (loss {hinge, squared_hinge}) | SHIPPED | The dual CD solves BOTH the true `hinge` (`U=C`, `diag=0`) and `squared_hinge` (`U=∞`, `diag=0.5/C`) optima (`solve_l2r_l1l2_svc`, `linear.cpp:849-858`). The `hinge` optimum is pinned against the live oracle by `linear_svc_hinge_coef_parity in tests/divergence_linear_svc_fit.rs` (#621; 8×2 set, `loss='hinge'`, `C=1.0`: `coef_ [[0.15384615383852776, 0.15384615383915584]]`, `intercept_ [-1.4615384615168394]`). |
58//! | REQ-5 (penalty {l1, l2}) | SHIPPED | `LinearSVC<F>` exposes `pub penalty: LinearSVCPenalty` (default `L2`) + `#[must_use] with_penalty`. `penalty=l1` routes to `fn solve_binary_l1r_l2` — liblinear's feature-major coordinate descent (`solve_l1r_l2_svc`, `linear.cpp:1467`, solver type 5, `_base.py:1014`) minimizing `‖w‖₁ + C·Σ max(0,1−yf)²` (sparse `coef_`); `penalty=l2` keeps `fn solve_binary_dual`. `liblinear_solver_type` rejects `('l1','hinge')` (`_base.py:1013`). Pinned by `test_l1_penalty_smoke` (live oracle 8×2 `l1,squared_hinge,dual=False,C=1`: `coef_ [[0.1283185834966579, 0.12831858464059265]]`, `intercept_ [-1.2079646017762715]`; ferrolearn lands within ~1.2e-9) + `test_unsupported_combinations_rejected`. Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
59//! | REQ-6 (multi_class {ovr, crammer_singer}) | SHIPPED | `LinearSVC<F>` exposes `pub multi_class: MultiClass` (`Ovr`/`CrammerSinger`, default `Ovr`, `_classes.py:239`) + `#[must_use] with_multi_class`. `multi_class=CrammerSinger` selects liblinear solver type 4 REGARDLESS of penalty/loss/dual (`_get_liblinear_solver_type`, `_base.py:1017,1020-1021`), so `fn fit` short-circuits to `fn fit_crammer_singer` (ignoring penalty/loss/dual) which runs ONE joint solve `fn solve_crammer_singer` — a faithful transcription of `Solver_MCSVM_CS` (`linear.cpp:510`, the class at `:493-787`): flattened `w[feature*nr_class + m]`, `alpha[i*nr_class + m]` with `Σ_m alpha=0`, `C[i] = weighted_C[y_i]`, per-sample shrinking (`active_size_i`/`alpha_index`/`be_shrunk`), the simplex inner solve `fn cs_solve_sub_problem` (sort-descending breakpoint, `linear.cpp:541-564`), and the two-level `eps_shrink = max(10·tol, 1)` stopping (`linear.cpp:738-753`). Extraction: `coef_[m][feature] = w[feature*nr_class + m]`, `intercept_[m] = intercept_scaling·w[n_features*nr_class + m]` (`_base.py:1240-1245`). BINARY: collapse to a single weight vector `coef_ = row_1 − row_0`, `intercept_ = int_1 − int_0` (`_classes.py:340-344`), `is_binary=true`. ferrolearn sweeps natural order (no `bounded_rand_int` shuffle, `linear.cpp:629`); the CS optimum is unique so the limit is identical (documented RNG/shrink-path boundary). Pinned by `test_crammer_singer_smoke in linear_svc.rs` (live oracle 3-class set `coef [[-0.06762,-0.24341],[0.30048,0.02171],[-0.23286,0.22171]]`, `int [0.91078,-0.62206,-0.28873]`, predict all-correct; binary 8×2 collapse `coef [[0.15504,0.15504]]`, `int [-1.48062]`; within 1e-2). The rigorous oracle pin in `tests/divergence_linear_svc_fit.rs` is the critic's next step. Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
60//! | REQ-7 (fit_intercept + intercept_scaling) | SHIPPED | `LinearSVC<F>` exposes `pub fit_intercept: bool` (default true) + `pub intercept_scaling: F` (default 1.0) + `#[must_use]` builders. When fitting an intercept the design matrix is augmented with a penalized constant column = `intercept_scaling`, and `intercept_ = intercept_scaling·w_last` (`_base.py:1188-1198,:1240-1245`); `intercept_scaling > 0` is validated. Pinned by `linear_svc_coef_parity` + module `test_fit_intercept_false_zero_intercept`/`test_invalid_intercept_scaling`. |
61//! | REQ-8 (dual param) | SHIPPED | `LinearSVC<F>` exposes `pub dual: DualMode` (default `Auto`) + `#[must_use] with_dual`. `fn resolve_dual` resolves `Auto→bool` (`_validate_dual_parameter`, `_classes.py:13-29`: `n<f`→prefer dual, else→prefer primal, with fallback) against `fn liblinear_solver_type` (the `_get_liblinear_solver_type` matrix, `_base.py:995-1018`), and `fn fit` validates the resolved combo (`hinge+dual=false`, `l1+dual=true`, `l1+hinge` all rejected → `FerroError::InvalidParameter`). R-DEV-7: the resolved `dual` is **observably immaterial for `penalty=l2`** — the l2 dual CD and l2 primal minimize the same `0.5·‖w‖² + C·Σ L` and reach the same `coef_`/`intercept_`, so `penalty=l2` keeps `fn solve_binary_dual` regardless of `dual`. Pinned by `test_unsupported_combinations_rejected` + `test_dual_auto_resolution`. Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
62//! | REQ-9 (class_weight) | SHIPPED | `LinearSVC<F>` exposes `pub class_weight: ClassWeight<F>` (`None`/`Balanced`/`Explicit`, default `None`) + `#[must_use] with_class_weight`. `fn compute_class_weight` (mirroring `sklearn.utils.compute_class_weight`, `class_weight.py:63-81`, as called at `_base.py:1179`) expands per-class weights; `fn fit` scales `C` per class: binary `cp = C·weights[idx(classes[1])]`, `cn = C·weights[idx(classes[0])]` (`train_one(Cp=weighted_C[1], Cn=weighted_C[0])`, `linear.cpp:2543-2551`), OvR class `k` `cp = C·weights[k]`, `cn = C` base (the negative rest is UNWEIGHTED, `linear.cpp:2559-2571`). `SolverConfig` now carries `(cp, cn)`; `solve_binary_dual`/`solve_binary_l1r_l2` apply the per-sample `C_[i] = (y_i>0?cp:cn)` (`diag[i]`/`upper_bound[i]`/`C[i]`, `linear.cpp:843-858`,`:1504-1509`). When `cp == cn` (no class_weight) the math is identical to before (the 9 divergence pins stay green). Pinned by `test_class_weight_smoke in linear_svc.rs` (live oracle 8×2 imbalanced set, `squared_hinge,dual=True,C=1.0`: `None coef [[0.10056,0.15957]] int [-1.26346]`; `balanced coef [[0.09937,0.16666]] int [-1.21320]` weights `[0.6667,2.0]`; `{0:1,1:5} coef [[0.11059,0.17164]] int [-1.29547]`; ferrolearn within 1e-2). The rigorous oracle pin in `tests/divergence_linear_svc_fit.rs` is the critic's next step. Consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). |
63//! | REQ-10 (C-scaling convention) | SHIPPED | the `c / n_f` division is removed; the dual CD uses `upper_bound = C` (hinge) / `diag = 0.5/C` (squared_hinge), so `coef_` tracks `C` like liblinear. Pinned by `linear_svc_coef_c_dependence` (C=0.1 → `0.0784651864625997`, C=1.0 → `0.12835213611984458`). |
64//! | REQ-11 (n_iter_/n_features_in_ + param validation) | SHIPPED | `fn n_features_in` (returns the stored `n_features`, set by `_validate_data`, `_classes.py:302`) and `fn n_iter` (the max dual-CD outer-iteration count across the binary/OvR fits, `n_iter_ = n_iter_.max().item()`, `_classes.py:338`) on `FittedLinearSVC`; `fn fit` validates `tol > 0` (`Interval(Real, 0.0, None, closed="neither")`, `_classes.py:237`). Pinned by `linear_svc_attrs_and_tol_validation in tests/divergence_linear_svc_fit.rs` (#627). `n_features_in_` (oracle `2`) and the `tol <= 0` reject are exact; `n_iter_` is the documented shuffle-path RNG boundary (ferrolearn sweeps natural order, sklearn's liblinear shuffles `index` each sweep, cf. SGD), so the pin bounds `n_iter` in `[1, max_iter]` rather than exact-matching. |
65//! | REQ-12 (ferray substrate) | NOT-STARTED | open prereq blocker #628. Imports `ndarray`, not `ferray-core`/`ferray::linalg` (R-SUBSTRATE). |
66//! | REQ-13 (non-finite input rejected) | SHIPPED | `fn fit` rejects any NaN/+/-inf in X BEFORE the dual coordinate-descent solve with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`svm/_classes.py:302`, the liblinear `_fit_liblinear` path; cf. `svm/_base.py:190`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. `y` is `Array1<usize>` (integer labels), finite by type, and ferrolearn's `LinearSVC::fit` takes no `sample_weight` argument, so X is the only runtime check. The guard sits ahead of both the OvR and Crammer-Singer dispatch, so it covers every solver path. `.iter().any(|v| !v.is_finite())` catches NaN and Inf; the finite path is byte-identical. Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `LinearSVC().fit` raises `ValueError` for NaN/+inf/-inf in X (`tests/divergence_linear_nonfinite_batch4.rs::linsvc_*`). Non-test consumer: `pub use linear_svc::{…}` (`lib.rs`) + `RsLinearSVC` (PyO3). (#2263) |
67
68use ferrolearn_core::error::FerroError;
69use ferrolearn_core::introspection::{HasClasses, HasCoefficients};
70use ferrolearn_core::traits::{Fit, Predict};
71use ndarray::{Array1, Array2, ScalarOperand};
72use num_traits::Float;
73
74/// Penalty (regularizer) norm for [`LinearSVC`].
75///
76/// Mirrors `sklearn.svm.LinearSVC`'s `penalty` parameter
77/// (`sklearn/svm/_classes.py:51-54`): `'l2'` (default) is the standard SVC
78/// `0.5·‖w‖²` regularizer; `'l1'` is the `‖w‖₁` regularizer, which yields a
79/// sparse `coef_`. The penalty interacts with [`LinearSVCLoss`] / [`DualMode`]
80/// via liblinear's solver-selection matrix (`_get_liblinear_solver_type`,
81/// `_base.py:1011-1018`): `'l1'` is only supported with `squared_hinge` +
82/// `dual=False` (solver type 5).
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum LinearSVCPenalty {
85 /// `‖w‖₁` regularizer — sparse `coef_`. Only valid with `squared_hinge` +
86 /// `dual=false` (liblinear solver type 5, `_base.py:1014`).
87 L1,
88 /// `0.5·‖w‖²` regularizer (default), the standard SVC penalty.
89 #[default]
90 L2,
91}
92
93/// Dual / primal optimization-problem selector for [`LinearSVC`].
94///
95/// Mirrors `sklearn.svm.LinearSVC`'s `dual` parameter
96/// (`sklearn/svm/_classes.py:62-71`, default `"auto"`). `Auto` resolves to a
97/// concrete `bool` via `_validate_dual_parameter` (`_classes.py:13-29`): when
98/// `n_samples < n_features` it prefers `dual=true` (falling back to `false` if
99/// that penalty×loss combination has no dual solver); otherwise it prefers
100/// `dual=false` (falling back to `true` if there is no primal solver, e.g.
101/// `hinge`). The resolved `dual` selects the liblinear solver type
102/// (`_get_liblinear_solver_type`, `_base.py:1011-1018`).
103///
104/// Under R-DEV-7 the resolved `dual` is **observably immaterial for
105/// `penalty=l2`**: the l2 dual coordinate descent and the l2 primal both
106/// minimize the same strongly convex `0.5·‖w‖² + C·Σ L` and reach the same
107/// `coef_`/`intercept_`. It is load-bearing only for the unsupported-combination
108/// rejects and for selecting the genuinely different `l1` primal solver.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum DualMode {
111 /// Resolve to `true`/`false` automatically via `_validate_dual_parameter`
112 /// (`_classes.py:13-29`); the default.
113 #[default]
114 Auto,
115 /// Solve the dual optimization problem.
116 True,
117 /// Solve the primal optimization problem.
118 False,
119}
120
121/// Multiclass strategy for [`LinearSVC`].
122///
123/// Mirrors `sklearn.svm.LinearSVC`'s `multi_class` parameter
124/// (`sklearn/svm/_classes.py:239`, constraint `{"ovr", "crammer_singer"}`,
125/// default `"ovr"`). `Ovr` trains one binary classifier per class (the default,
126/// using the penalty/loss/dual solver matrix); `CrammerSinger` runs the joint
127/// Crammer-Singer multiclass SVM solver (`Solver_MCSVM_CS`,
128/// `liblinear/linear.cpp:493-787`, solver type 4).
129///
130/// When `CrammerSinger` is selected, `_get_liblinear_solver_type` returns 4
131/// **regardless of penalty/loss/dual** (`_base.py:1017,1020-1021`), so the
132/// penalty/loss/dual parameters are ignored and the joint solver runs.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum MultiClass {
135 /// One-vs-rest: one binary sub-problem per class (the default).
136 #[default]
137 Ovr,
138 /// Joint Crammer-Singer multiclass SVM (`Solver_MCSVM_CS`, solver type 4,
139 /// `linear.cpp:493-787`). Ignores penalty/loss/dual (`_base.py:1017`).
140 CrammerSinger,
141}
142
143/// Loss function for [`LinearSVC`].
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum LinearSVCLoss {
146 /// Standard hinge loss: `max(0, 1 - y * f(x))`. liblinear solver
147 /// `L2R_L1LOSS_SVC_DUAL` (type 3): box `0 <= alpha <= C`, `diag = 0`.
148 Hinge,
149 /// Squared hinge loss: `max(0, 1 - y * f(x))^2` (default). liblinear solver
150 /// `L2R_L2LOSS_SVC_DUAL` (type 1): box `0 <= alpha <= +inf`,
151 /// `diag = 0.5 / C`.
152 SquaredHinge,
153}
154
155/// Per-class weighting strategy for [`LinearSVC`].
156///
157/// Mirrors `sklearn.svm.LinearSVC`'s `class_weight` parameter
158/// (`sklearn/svm/_classes.py:118-124`, constraint `{None, dict, 'balanced'}`):
159/// it scales the inverse-regularization `C` per class so the effective penalty
160/// for class `i` is `class_weight[i]·C` (`compute_class_weight`,
161/// `sklearn/svm/_base.py:1179`; `weighted_C[i] = C·class_weight[i]`,
162/// `liblinear/linear.cpp:2496-2507`). The expanded per-class weights are
163/// computed by [`compute_class_weight`] following
164/// `sklearn.utils.compute_class_weight` semantics
165/// (`sklearn/utils/class_weight.py:63-81`).
166///
167/// This mirrors `ferrolearn_linear::sgd::ClassWeight` for cross-estimator
168/// consistency, but is defined locally (no cross-import of `sgd` internals).
169#[derive(Debug, Clone, Default)]
170pub enum ClassWeight<F> {
171 /// Uniform weights (all classes weighted `1.0`). The default
172 /// (`class_weight=None`, `class_weight.py:63-65`).
173 #[default]
174 None,
175 /// Balanced weights `n_samples / (n_classes · count_c)` per class `c`,
176 /// matching `sklearn.utils.compute_class_weight("balanced", ...)`
177 /// (`class_weight.py:66-74`).
178 Balanced,
179 /// Explicit class-label -> weight map. Classes absent from the map default
180 /// to `1.0`, matching the dict branch of `compute_class_weight`
181 /// (`class_weight.py:75-81`).
182 Explicit(Vec<(usize, F)>),
183}
184
185/// Compute the expanded per-class weight vector aligned to `classes`
186/// (sorted ascending, matching sklearn's `classes_ = np.unique(y)`).
187///
188/// Faithful to `sklearn.utils.compute_class_weight`
189/// (`sklearn/utils/class_weight.py:63-81`), as called by `_fit_liblinear`
190/// (`compute_class_weight(class_weight, classes=classes_, y=y)`,
191/// `sklearn/svm/_base.py:1179`):
192/// - `None` -> all `1.0` (`:63-65`).
193/// - `Balanced` -> `n_samples / (n_classes · count_c)` per class `c`,
194/// where `count_c` is the number of samples with label `c` (`:66-74`).
195/// - `Explicit(map)` -> `1.0` default, overridden by the map entries matched by
196/// class label (`:75-81`).
197///
198/// `classes` is the sorted unique label set; `y` is the per-sample label array.
199/// Mirrors `ferrolearn_linear::sgd::compute_class_weight` exactly.
200fn compute_class_weight<F: Float>(cw: &ClassWeight<F>, classes: &[usize], y: &[usize]) -> Vec<F> {
201 match cw {
202 ClassWeight::None => vec![F::one(); classes.len()],
203 ClassWeight::Balanced => {
204 // `recip_freq = len(y) / (n_classes * bincount(y_ind))`
205 // (`class_weight.py:73`), indexed per class.
206 let n_samples = F::from(y.len()).unwrap_or_else(F::zero);
207 let n_classes = F::from(classes.len()).unwrap_or_else(F::one);
208 classes
209 .iter()
210 .map(|&c| {
211 let count = y.iter().filter(|&&label| label == c).count();
212 let count_f = F::from(count).unwrap_or_else(F::one);
213 if count_f > F::zero() {
214 n_samples / (n_classes * count_f)
215 } else {
216 F::one()
217 }
218 })
219 .collect()
220 }
221 ClassWeight::Explicit(map) => classes
222 .iter()
223 .map(|&c| {
224 map.iter()
225 .find(|(label, _)| *label == c)
226 .map_or_else(F::one, |(_, w)| *w)
227 })
228 .collect(),
229 }
230}
231
232/// Confidence scores returned by [`FittedLinearSVC::decision_function`].
233///
234/// Mirrors `sklearn.svm.LinearSVC.decision_function`
235/// (`LinearClassifierMixin.decision_function`,
236/// `sklearn/linear_model/_base.py:341-365`): the binary case collapses the
237/// single-column score matrix to a 1-D `(n_samples,)` array
238/// (`return xp.reshape(scores, (-1,)) if scores.shape[1] == 1 else scores`,
239/// `_base.py:365`), the multiclass case returns `(n_samples, n_classes)`.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum DecisionScores<F> {
242 /// Binary scores `X · w + b` for the positive class (`classes_[1]`), shape
243 /// `(n_samples,)`. `> 0` predicts `classes_[1]`.
244 Binary(Array1<F>),
245 /// One-vs-rest scores, shape `(n_samples, n_classes)`. The argmax of each
246 /// row agrees with [`Predict`].
247 Multiclass(Array2<F>),
248}
249
250impl<F: Clone> DecisionScores<F> {
251 /// Number of samples scored (the leading axis length in both variants).
252 #[must_use]
253 pub fn n_samples(&self) -> usize {
254 match self {
255 DecisionScores::Binary(v) => v.len(),
256 DecisionScores::Multiclass(m) => m.nrows(),
257 }
258 }
259
260 /// Borrow the binary 1-D scores, if this is the binary case.
261 #[must_use]
262 pub fn as_binary(&self) -> Option<&Array1<F>> {
263 match self {
264 DecisionScores::Binary(v) => Some(v),
265 DecisionScores::Multiclass(_) => None,
266 }
267 }
268
269 /// Borrow the multiclass `(n_samples, n_classes)` scores, if this is the
270 /// multiclass case.
271 #[must_use]
272 pub fn as_multiclass(&self) -> Option<&Array2<F>> {
273 match self {
274 DecisionScores::Multiclass(m) => Some(m),
275 DecisionScores::Binary(_) => None,
276 }
277 }
278}
279
280/// Linear Support Vector Classifier (liblinear dual CD).
281///
282/// Solves the L2-regularized hinge or squared-hinge objective
283/// `0.5*||w||^2 + C * sum_i L(y_i, w.x_i)` via liblinear's dual coordinate
284/// descent. Supports binary and multiclass (one-vs-rest) classification.
285/// Mirrors `sklearn.svm.LinearSVC`.
286///
287/// # Type Parameters
288///
289/// - `F`: The floating-point type (`f32` or `f64`).
290#[derive(Debug, Clone)]
291pub struct LinearSVC<F> {
292 /// Inverse regularization strength. Larger values allow more
293 /// misclassification. Must be strictly positive.
294 pub c: F,
295 /// Maximum number of dual coordinate descent iterations.
296 pub max_iter: usize,
297 /// Convergence tolerance on the projected-gradient span.
298 pub tol: F,
299 /// Loss function to use.
300 pub loss: LinearSVCLoss,
301 /// Regularizer norm (`l1` / `l2`). Default `l2`. `l1` is only supported with
302 /// `squared_hinge` + `dual=false` (liblinear solver type 5,
303 /// `_get_liblinear_solver_type`, `_base.py:1014`).
304 pub penalty: LinearSVCPenalty,
305 /// Dual / primal selector. Default `Auto`, resolved via
306 /// `_validate_dual_parameter` (`_classes.py:13-29`). Observably immaterial
307 /// for `penalty=l2` (R-DEV-7 dual-invariance), load-bearing for the
308 /// unsupported-combination rejects and the `l1` primal solver.
309 pub dual: DualMode,
310 /// Whether to fit an intercept. When `true`, the design matrix is augmented
311 /// with a synthetic constant column equal to [`intercept_scaling`]; the
312 /// augmented weight is penalized like any feature (liblinear convention).
313 ///
314 /// [`intercept_scaling`]: Self::intercept_scaling
315 pub fit_intercept: bool,
316 /// Value of the synthetic intercept feature when [`fit_intercept`] is
317 /// `true`. Must be strictly positive. `intercept_ = intercept_scaling *
318 /// w_last`.
319 ///
320 /// [`fit_intercept`]: Self::fit_intercept
321 pub intercept_scaling: F,
322 /// Per-class scaling of `C`. Default [`ClassWeight::None`] (all classes
323 /// weighted `1.0`). The effective penalty for class `i` is
324 /// `class_weight[i]·C` (`compute_class_weight`, `_base.py:1179`;
325 /// `weighted_C[i] = C·class_weight[i]`, `linear.cpp:2496-2507`).
326 pub class_weight: ClassWeight<F>,
327 /// Multiclass strategy. Default [`MultiClass::Ovr`] (one-vs-rest).
328 /// [`MultiClass::CrammerSinger`] runs the joint Crammer-Singer solver,
329 /// ignoring penalty/loss/dual (`_base.py:1017`, `_classes.py:239`).
330 pub multi_class: MultiClass,
331}
332
333impl<F: Float> LinearSVC<F> {
334 /// Create a new `LinearSVC` with scikit-learn's default settings.
335 ///
336 /// Defaults (matching `sklearn.svm.LinearSVC`, `_classes.py`):
337 /// `C = 1.0`, `max_iter = 1000`, `tol = 1e-4`, `loss = SquaredHinge`,
338 /// `penalty = L2`, `dual = Auto`, `fit_intercept = true`,
339 /// `intercept_scaling = 1.0`, `class_weight = None`, `multi_class = Ovr`.
340 #[must_use]
341 pub fn new() -> Self {
342 // 1e-4/1.0 are exactly representable in f32/f64; the defensive fallback
343 // for `from(1e-4)` is never taken (no `.unwrap()` in lib code).
344 let one = F::one();
345 Self {
346 c: one,
347 max_iter: 1000,
348 tol: F::from(1e-4).unwrap_or_else(|| {
349 let ten = F::from(10).unwrap_or(one);
350 one / (ten * ten * ten * ten)
351 }),
352 loss: LinearSVCLoss::SquaredHinge,
353 penalty: LinearSVCPenalty::L2,
354 dual: DualMode::Auto,
355 fit_intercept: true,
356 intercept_scaling: one,
357 class_weight: ClassWeight::None,
358 multi_class: MultiClass::Ovr,
359 }
360 }
361
362 /// Set the regularization parameter C.
363 #[must_use]
364 pub fn with_c(mut self, c: F) -> Self {
365 self.c = c;
366 self
367 }
368
369 /// Set the maximum number of iterations.
370 #[must_use]
371 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
372 self.max_iter = max_iter;
373 self
374 }
375
376 /// Set the convergence tolerance.
377 #[must_use]
378 pub fn with_tol(mut self, tol: F) -> Self {
379 self.tol = tol;
380 self
381 }
382
383 /// Set the loss function.
384 #[must_use]
385 pub fn with_loss(mut self, loss: LinearSVCLoss) -> Self {
386 self.loss = loss;
387 self
388 }
389
390 /// Set the penalty (regularizer) norm (sklearn `penalty`). `l1` requires
391 /// `squared_hinge` + `dual=false` (`_base.py:1014`).
392 #[must_use]
393 pub fn with_penalty(mut self, penalty: LinearSVCPenalty) -> Self {
394 self.penalty = penalty;
395 self
396 }
397
398 /// Set the dual / primal selector (sklearn `dual`). `Auto` (default)
399 /// resolves via `_validate_dual_parameter` (`_classes.py:13-29`).
400 #[must_use]
401 pub fn with_dual(mut self, dual: DualMode) -> Self {
402 self.dual = dual;
403 self
404 }
405
406 /// Set whether to fit an intercept (sklearn `fit_intercept`).
407 #[must_use]
408 pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
409 self.fit_intercept = fit_intercept;
410 self
411 }
412
413 /// Set the intercept scaling (sklearn `intercept_scaling`). Must be
414 /// strictly positive when `fit_intercept` is `true`.
415 #[must_use]
416 pub fn with_intercept_scaling(mut self, intercept_scaling: F) -> Self {
417 self.intercept_scaling = intercept_scaling;
418 self
419 }
420
421 /// Set the per-class `C` scaling (sklearn `class_weight`,
422 /// `_classes.py:118-124`). [`ClassWeight::None`] (default) leaves every
423 /// class at `1.0`; [`ClassWeight::Balanced`] uses
424 /// `n_samples / (n_classes · count_c)`; [`ClassWeight::Explicit`] takes a
425 /// `(label, weight)` map (unlisted classes default to `1.0`).
426 #[must_use]
427 pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
428 self.class_weight = class_weight;
429 self
430 }
431
432 /// Set the multiclass strategy (sklearn `multi_class`, `_classes.py:239`).
433 /// [`MultiClass::Ovr`] (default) trains one-vs-rest binary classifiers;
434 /// [`MultiClass::CrammerSinger`] runs the joint Crammer-Singer solver
435 /// (ignoring penalty/loss/dual, `_base.py:1017`).
436 #[must_use]
437 pub fn with_multi_class(mut self, multi_class: MultiClass) -> Self {
438 self.multi_class = multi_class;
439 self
440 }
441}
442
443impl<F: Float> Default for LinearSVC<F> {
444 fn default() -> Self {
445 Self::new()
446 }
447}
448
449/// Fitted Linear Support Vector Classifier.
450///
451/// Stores the learned weight vectors, intercepts, and class labels.
452/// For binary classification a single weight vector is stored; for
453/// multiclass, one per class (one-vs-rest).
454#[derive(Debug, Clone)]
455pub struct FittedLinearSVC<F> {
456 /// Weight vectors: one per binary sub-problem.
457 /// Binary: `[w]`, Multiclass: `[w_0, w_1, ..., w_{k-1}]`.
458 weight_vectors: Vec<Array1<F>>,
459 /// Intercept for each sub-problem.
460 intercepts: Vec<F>,
461 /// Sorted unique class labels.
462 classes: Vec<usize>,
463 /// Whether this is a binary problem.
464 is_binary: bool,
465 /// Number of features.
466 n_features: usize,
467 /// Maximum dual-CD outer-iteration count across sub-problem fits
468 /// (`n_iter_ = n_iter_.max().item()`, `_classes.py:338`).
469 n_iter: usize,
470}
471
472impl<F: Float> FittedLinearSVC<F> {
473 /// Returns the weight vectors (one per binary sub-problem).
474 #[must_use]
475 pub fn weight_vectors(&self) -> &[Array1<F>] {
476 &self.weight_vectors
477 }
478
479 /// Returns the intercepts (one per binary sub-problem).
480 #[must_use]
481 pub fn intercepts(&self) -> &[F] {
482 &self.intercepts
483 }
484
485 /// Number of features seen during fit (`n_features_in_`).
486 ///
487 /// Mirrors sklearn's `n_features_in_`, set by `_validate_data`
488 /// (`sklearn/svm/_classes.py:302`); equals `X.ncols()`.
489 #[must_use]
490 pub fn n_features_in(&self) -> usize {
491 self.n_features
492 }
493
494 /// Maximum number of dual coordinate-descent outer iterations across the
495 /// (binary or one-vs-rest) sub-problem fits.
496 ///
497 /// Mirrors sklearn's `n_iter_ = n_iter_.max().item()`
498 /// (`sklearn/svm/_classes.py:338`). The exact value is shuffle-path
499 /// dependent (sklearn's liblinear shuffles the active index each sweep;
500 /// ferrolearn sweeps natural order), so it is bounded in `[1, max_iter]`
501 /// rather than exact-matching the oracle.
502 #[must_use]
503 pub fn n_iter(&self) -> usize {
504 self.n_iter
505 }
506}
507
508impl<F: Float + ScalarOperand + Send + Sync + 'static> FittedLinearSVC<F> {
509 /// Raw signed distance from the decision boundary. Mirrors sklearn
510 /// `LinearSVC.decision_function`.
511 ///
512 /// Binary: [`DecisionScores::Binary`] of shape `(n_samples,)` containing
513 /// `X @ w + b` for the positive class (`classes_[1]`); sklearn ravels the
514 /// single-column score to 1-D (`linear_model/_base.py:365`).
515 /// Multiclass: [`DecisionScores::Multiclass`] of shape
516 /// `(n_samples, n_classes)` of one-vs-rest scores; the argmax of each row
517 /// agrees with [`Predict`].
518 ///
519 /// # Errors
520 ///
521 /// Returns [`FerroError::ShapeMismatch`] if the number of features
522 /// does not match the fitted model.
523 pub fn decision_function(&self, x: &Array2<F>) -> Result<DecisionScores<F>, FerroError> {
524 let n_features = x.ncols();
525 if n_features != self.n_features {
526 return Err(FerroError::ShapeMismatch {
527 expected: vec![self.n_features],
528 actual: vec![n_features],
529 context: "number of features must match fitted model".into(),
530 });
531 }
532 let n_samples = x.nrows();
533 if self.is_binary {
534 // sklearn collapses the single-column binary score matrix to a 1-D
535 // (n_samples,) array (`linear_model/_base.py:365`).
536 let scores = x.dot(&self.weight_vectors[0]) + self.intercepts[0];
537 Ok(DecisionScores::Binary(scores))
538 } else {
539 let n_classes = self.classes.len();
540 let mut out = Array2::<F>::zeros((n_samples, n_classes));
541 for c in 0..n_classes {
542 for i in 0..n_samples {
543 out[[i, c]] = x.row(i).dot(&self.weight_vectors[c]) + self.intercepts[c];
544 }
545 }
546 Ok(DecisionScores::Multiclass(out))
547 }
548 }
549}
550
551/// Resolved per-fit solver configuration passed to [`solve_binary_dual`]
552/// (groups the dual-CD knobs to keep the solver signature small).
553#[derive(Debug, Clone, Copy)]
554struct SolverConfig<F> {
555 /// Per-sample penalty for the positive (`y_i > 0`) group: `Cp = C·w[+]`
556 /// (`train_one(Cp, Cn)`, `linear.cpp:2543-2571`; `C_[i] = (y_i>0 ? Cp :
557 /// Cn)`, `linear.cpp:843-858`, `:1504-1509`).
558 cp: F,
559 /// Per-sample penalty for the negative (`y_i <= 0`) group: `Cn = C·w[-]`
560 /// (binary) or the base `C` (multiclass OvR; the negative group is the
561 /// unweighted rest, `linear.cpp:2559-2571`).
562 cn: F,
563 /// Maximum dual-CD outer iterations.
564 max_iter: usize,
565 /// Projected-gradient span stopping tolerance.
566 tol: F,
567 /// Hinge / squared-hinge loss selector.
568 loss: LinearSVCLoss,
569 /// Whether the design matrix is augmented with a penalized bias column.
570 fit_intercept: bool,
571 /// Value of the synthetic bias column when `fit_intercept` is set.
572 intercept_scaling: F,
573}
574
575/// Solve a single binary L2-SVM via liblinear's dual coordinate descent.
576///
577/// Minimizes `0.5 * ||w||^2 + C * sum_i L(y_i, w.x_i)` (NO `1/n` averaging,
578/// matching `sklearn/svm/_base.py` `_fit_liblinear`) with `y_i ∈ {-1, +1}`.
579/// The augmented weight vector has length `w_size`; when `fit_intercept` is
580/// set, `w_size = n_features + 1` and the trailing weight multiplies the
581/// synthetic constant column `intercept_scaling` (penalized like any feature).
582///
583/// This is liblinear's `solve_l2r_l1l2_svc` (`linear.cpp:819`): the dual is
584///
585/// ```text
586/// min_alpha 0.5 * alpha^T (Q + diag) alpha - e^T alpha,
587/// s.t. 0 <= alpha_i <= U,
588/// ```
589///
590/// where `Q_ij = y_i y_j x_i.x_j`, `w = sum_i alpha_i y_i x_i`,
591/// `QD[i] = diag + ||x_i||^2`. **hinge** (`L2R_L1LOSS_SVC_DUAL`): `diag = 0`,
592/// `U = C` (`linear.cpp:852-858`). **squared_hinge** (`L2R_L2LOSS_SVC_DUAL`):
593/// `diag = 0.5/C`, `U = +inf` (`linear.cpp:849-850`).
594///
595/// Returns `(w_augmented, n_iter, converged)`.
596fn solve_binary_dual<F: Float + 'static>(
597 x: &Array2<F>,
598 y_signed: &Array1<F>,
599 cfg: &SolverConfig<F>,
600) -> (Vec<F>, usize, bool) {
601 let SolverConfig {
602 cp,
603 cn,
604 max_iter,
605 tol,
606 loss,
607 fit_intercept,
608 intercept_scaling,
609 } = *cfg;
610
611 let (n_samples, n_features) = x.dim();
612 let w_size = if fit_intercept {
613 n_features + 1
614 } else {
615 n_features
616 };
617
618 let inf = F::infinity();
619 let two = F::one() + F::one();
620 let half = F::one() / two;
621 let tiny = F::from(1.0e-12).unwrap_or_else(F::epsilon);
622
623 // Per-sample penalty `C_[i] = (y_i > 0 ? Cp : Cn)` (`linear.cpp:843-858`,
624 // `GETI(i) ≡ i`); `class_weight` makes Cp/Cn differ. Per-sample diag /
625 // upper_bound follow the solver type: squared_hinge → `diag[i] = 0.5/C_[i]`,
626 // `U[i] = +inf`; hinge → `diag[i] = 0`, `U[i] = C_[i]`.
627 let mut diag = vec![F::zero(); n_samples];
628 let mut upper_bound = vec![inf; n_samples];
629 for i in 0..n_samples {
630 let c_i = if y_signed[i] > F::zero() { cp } else { cn };
631 match loss {
632 LinearSVCLoss::Hinge => {
633 diag[i] = F::zero();
634 upper_bound[i] = c_i;
635 }
636 LinearSVCLoss::SquaredHinge => {
637 diag[i] = half / c_i;
638 upper_bound[i] = inf;
639 }
640 }
641 }
642
643 // QD[i] = diag[i] + ||x_i||^2 (including the augmented bias column).
644 let mut qd = vec![F::zero(); n_samples];
645 for (i, qd_i) in qd.iter_mut().enumerate() {
646 let mut acc = diag[i];
647 let row = x.row(i);
648 for &v in row.iter() {
649 acc = acc + v * v;
650 }
651 if fit_intercept {
652 acc = acc + intercept_scaling * intercept_scaling;
653 }
654 *qd_i = acc;
655 }
656
657 let mut alpha = vec![F::zero(); n_samples];
658 let mut w = vec![F::zero(); w_size];
659 // alpha starts at 0 so w starts at 0; nothing to accumulate.
660
661 let mut index: Vec<usize> = (0..n_samples).collect();
662 let mut active_size = n_samples;
663 let mut pgmax_old = inf;
664 let mut pgmin_old = -inf;
665
666 // w . x_i over the (augmented) design matrix.
667 let dot_w_xi = |w: &[F], i: usize| -> F {
668 let mut acc = F::zero();
669 let row = x.row(i);
670 for (j, &v) in row.iter().enumerate() {
671 acc = acc + v * w[j];
672 }
673 if fit_intercept {
674 acc = acc + intercept_scaling * w[n_features];
675 }
676 acc
677 };
678
679 let mut n_iter: usize = 0;
680 let mut converged = false;
681
682 // liblinear shuffles `index` each sweep; the minimizer is unique so order
683 // only affects the path, not the limit. We sweep in natural order for
684 // determinism (no RNG), reaching the same converged optimum.
685 for iter in 0..max_iter {
686 n_iter = iter + 1;
687 let mut pgmax_new = -inf;
688 let mut pgmin_new = inf;
689
690 let mut s = 0;
691 while s < active_size {
692 let i = index[s];
693 let yi = y_signed[i];
694
695 // G = y_i*(w.x_i) - 1 + diag_i*alpha_i (`linear.cpp:909-921`).
696 let g = yi * dot_w_xi(&w, i) - F::one() + diag[i] * alpha[i];
697
698 // Projected gradient + shrinking (`linear.cpp:923-949`).
699 let mut pg = F::zero();
700 if alpha[i] == F::zero() {
701 if g > pgmax_old {
702 active_size -= 1;
703 index.swap(s, active_size);
704 continue; // re-process the swapped-in element at `s`
705 } else if g < F::zero() {
706 pg = g;
707 }
708 } else if alpha[i] == upper_bound[i] {
709 if g < pgmin_old {
710 active_size -= 1;
711 index.swap(s, active_size);
712 continue;
713 } else if g > F::zero() {
714 pg = g;
715 }
716 } else {
717 pg = g;
718 }
719
720 if pg > pgmax_new {
721 pgmax_new = pg;
722 }
723 if pg < pgmin_new {
724 pgmin_new = pg;
725 }
726
727 if pg.abs() > tiny {
728 let alpha_old = alpha[i];
729 // alpha_i <- clamp(alpha_i - G/QD[i], 0, U) (`linear.cpp:957`).
730 let mut new_alpha = alpha[i] - g / qd[i];
731 if new_alpha < F::zero() {
732 new_alpha = F::zero();
733 } else if new_alpha > upper_bound[i] {
734 new_alpha = upper_bound[i];
735 }
736 alpha[i] = new_alpha;
737 let d = (alpha[i] - alpha_old) * yi;
738 if d != F::zero() {
739 let row = x.row(i);
740 for (j, &v) in row.iter().enumerate() {
741 w[j] = w[j] + d * v;
742 }
743 if fit_intercept {
744 w[n_features] = w[n_features] + d * intercept_scaling;
745 }
746 }
747 }
748
749 s += 1;
750 }
751
752 // Stopping: PGmax_new - PGmin_new <= tol on the full set
753 // (`linear.cpp:972-990`). Absolute tol (the dual SVC solver receives
754 // `param->eps` directly, `linear.cpp:2364`).
755 if pgmax_new - pgmin_new <= tol {
756 if active_size == n_samples {
757 converged = true;
758 break;
759 }
760 active_size = n_samples;
761 pgmax_old = inf;
762 pgmin_old = -inf;
763 continue;
764 }
765
766 pgmax_old = pgmax_new;
767 pgmin_old = pgmin_new;
768 if pgmax_old <= F::zero() {
769 pgmax_old = inf;
770 }
771 if pgmin_old >= F::zero() {
772 pgmin_old = -inf;
773 }
774 }
775
776 (w, n_iter, converged)
777}
778
779/// Resolve `(penalty, loss, dual)` to a liblinear solver "magic number" for the
780/// one-vs-rest `multi_class='ovr'` case, mirroring `_get_liblinear_solver_type`
781/// (`sklearn/svm/_base.py:995-1049`). Returns `Err` for the unsupported
782/// combinations sklearn raises `ValueError` on.
783///
784/// The supported (`multi_class='ovr'`) entries of `_solver_type_dict`
785/// (`_base.py:1013-1014`):
786///
787/// ```text
788/// hinge: { l2: { dual=true: 3 } }
789/// squared_hinge: { l1: { dual=false: 5 }, l2: { dual=false: 2, dual=true: 1 } }
790/// ```
791///
792/// (`crammer_singer` = 4 is REQ-6/#623, out of scope; this function assumes the
793/// existing `'ovr'` multi-class.) The `Err` strings mirror sklearn's
794/// `error_string` (`_base.py:1033-1043`).
795fn liblinear_solver_type(
796 penalty: LinearSVCPenalty,
797 loss: LinearSVCLoss,
798 dual: bool,
799) -> Result<u8, FerroError> {
800 match (loss, penalty, dual) {
801 // hinge: { l2: { dual=true: 3 } }
802 (LinearSVCLoss::Hinge, LinearSVCPenalty::L2, true) => Ok(3),
803 (LinearSVCLoss::Hinge, LinearSVCPenalty::L2, false) => Err(FerroError::InvalidParameter {
804 name: "dual".into(),
805 reason: "The combination of penalty='l2' and loss='hinge' are not \
806 supported when dual=false"
807 .into(),
808 }),
809 // hinge + l1: penalty has no entry under `hinge` → combination unsupported.
810 (LinearSVCLoss::Hinge, LinearSVCPenalty::L1, _) => Err(FerroError::InvalidParameter {
811 name: "penalty".into(),
812 reason: "The combination of penalty='l1' and loss='hinge' is not supported".into(),
813 }),
814 // squared_hinge: { l1: { dual=false: 5 } }
815 (LinearSVCLoss::SquaredHinge, LinearSVCPenalty::L1, false) => Ok(5),
816 (LinearSVCLoss::SquaredHinge, LinearSVCPenalty::L1, true) => {
817 Err(FerroError::InvalidParameter {
818 name: "dual".into(),
819 reason: "The combination of penalty='l1' and loss='squared_hinge' are not \
820 supported when dual=true"
821 .into(),
822 })
823 }
824 // squared_hinge: { l2: { dual=false: 2, dual=true: 1 } }
825 (LinearSVCLoss::SquaredHinge, LinearSVCPenalty::L2, false) => Ok(2),
826 (LinearSVCLoss::SquaredHinge, LinearSVCPenalty::L2, true) => Ok(1),
827 }
828}
829
830/// Resolve the [`DualMode`] to a concrete `bool`, mirroring
831/// `_validate_dual_parameter` (`sklearn/svm/_classes.py:13-29`).
832///
833/// For [`DualMode::Auto`]: when `n_samples < n_features` try `dual=true` (fall
834/// back to `false` if that penalty×loss combination has no dual solver); else
835/// (`n_samples >= n_features`) try `dual=false` (fall back to `true` if there is
836/// no primal solver, e.g. `hinge`). Resolution is checked against
837/// [`liblinear_solver_type`] so it is automatically consistent with the
838/// solver-selection matrix.
839fn resolve_dual(
840 dual: DualMode,
841 penalty: LinearSVCPenalty,
842 loss: LinearSVCLoss,
843 n_samples: usize,
844 n_features: usize,
845) -> bool {
846 match dual {
847 DualMode::True => true,
848 DualMode::False => false,
849 DualMode::Auto => {
850 if n_samples < n_features {
851 // Prefer dual=true; fall back to false if unsupported.
852 liblinear_solver_type(penalty, loss, true).is_ok()
853 } else {
854 // Prefer dual=false; fall back to true if no primal solver.
855 liblinear_solver_type(penalty, loss, false).is_err()
856 }
857 }
858 }
859}
860
861/// Solve a single binary L1-regularized L2-loss (squared-hinge) SVM via
862/// liblinear's feature-major coordinate descent, `solve_l1r_l2_svc`
863/// (`sklearn/svm/src/liblinear/linear.cpp:1467`). Minimizes
864///
865/// ```text
866/// ‖w‖₁ + C · Σ_i max(0, 1 − y_i·(w·x_i))²
867/// ```
868///
869/// (the `l1`-penalty objective — a genuinely different, sparse optimum from the
870/// l2 dual). The augmented intercept column (value `intercept_scaling`) is
871/// appended when `fit_intercept`, penalized in `‖w‖₁` like any feature
872/// (`coef_ = w[:n_features]`, `intercept_ = intercept_scaling·w[n_features]`).
873///
874/// State (`linear.cpp:1488-1526`): `b[i] = 1 − y_i·(w·x_i)` (running residual),
875/// `xj_sq[j] = Σ_i C·(y_i·x_ij)²`. Per feature `j`:
876/// `G_loss = −2·Σ_{i: b[i]>0} C·(y_i·x_ij)·b[i]`,
877/// `H = max(2·Σ_{i: b[i]>0} C·(y_i·x_ij)², 1e-12)` (`linear.cpp:1542-1562`).
878/// `Gp = G_loss+1`, `Gn = G_loss−1`. Newton direction with soft-threshold
879/// (`linear.cpp:1589-1595`): `d = −Gp/H` if `Gp < H·w[j]`, `d = −Gn/H` if
880/// `Gn > H·w[j]`, else `d = −w[j]`. Then a backtracking line search
881/// (`sigma=0.01`, ≤20 steps, halving `d`, `linear.cpp:1600-1661`) updating
882/// `b[]`, then `w[j] += d`. Shrinking via `active_size`/`Gmax_old`
883/// (`linear.cpp:1567-1579, 1691-1705`); stop when
884/// `Gnorm1_new ≤ eps·Gnorm1_init` on the full active set (`linear.cpp:1691`).
885///
886/// `C[i] = (y_i > 0 ? Cp : Cn)` per-sample (`class_weight` scales `C` per class,
887/// `linear.cpp:1504-1509`; `Cp = C·w[+]`, `Cn = C·w[-]` binary / base `C`
888/// multiclass, `:2543-2571`). liblinear shuffles
889/// `index` each sweep (`bounded_rand_int`, `linear.cpp:1535`); ferrolearn sweeps
890/// NATURAL ORDER for determinism (no RNG) — the l1 optimum is unique so the
891/// limit is identical (the documented RNG-path boundary, as `solve_binary_dual`).
892/// We use `eps = tol` directly: liblinear scales `primal_solver_tol`
893/// (`linear.cpp:2321,2374`) but the unique optimum is `tol`-invariant at the
894/// limit (the test drives `tol=1e-10` + huge `max_iter`).
895///
896/// Returns `(w_augmented, n_iter, converged)`.
897#[allow(
898 clippy::too_many_lines,
899 reason = "faithful transcription of liblinear solve_l1r_l2_svc (linear.cpp:1467)"
900)]
901fn solve_binary_l1r_l2<F: Float + 'static>(
902 x: &Array2<F>,
903 y_signed: &Array1<F>,
904 cfg: &SolverConfig<F>,
905) -> (Vec<F>, usize, bool) {
906 let SolverConfig {
907 cp,
908 cn,
909 max_iter,
910 tol,
911 fit_intercept,
912 intercept_scaling,
913 ..
914 } = *cfg;
915
916 let (n_samples, n_features) = x.dim();
917
918 // Per-sample penalty `C[i] = (y_i > 0 ? Cp : Cn)` (`solve_l1r_l2_svc`,
919 // `linear.cpp:1504-1509`); `class_weight` makes Cp/Cn differ.
920 let c_of = |i: usize| -> F { if y_signed[i] > F::zero() { cp } else { cn } };
921 let w_size = if fit_intercept {
922 n_features + 1
923 } else {
924 n_features
925 };
926
927 let inf = F::infinity();
928 let two = F::one() + F::one();
929 let sigma = F::from(0.01).unwrap_or_else(|| F::one() / (two * two * two * two * two * two));
930 let tiny = F::from(1.0e-12).unwrap_or_else(F::epsilon);
931 let max_num_linesearch = 20usize;
932 let nl = F::from(n_samples).unwrap_or_else(F::one);
933
934 // `yx(i, j)` = y_i·x_ij over the augmented design matrix (the j-th feature
935 // column entry for sample i). liblinear stores `x->value *= y[ind]`
936 // (`linear.cpp:1520`); we recompute it lazily for determinism / clarity.
937 let yx = |i: usize, j: usize| -> F {
938 let yi = y_signed[i];
939 if j < n_features {
940 yi * x[[i, j]]
941 } else {
942 yi * intercept_scaling
943 }
944 };
945
946 // b[i] = 1 − y_i·(w·x_i). w starts at 0 so b starts at 1 (`linear.cpp:1500`).
947 let mut b = vec![F::one(); n_samples];
948 let mut w = vec![F::zero(); w_size];
949
950 // xj_sq[j] = Σ_i C[i]·(y_i·x_ij)² (`linear.cpp:1523`, per-sample C[i]).
951 let mut xj_sq = vec![F::zero(); w_size];
952 for (j, xj_sq_j) in xj_sq.iter_mut().enumerate() {
953 let mut acc = F::zero();
954 for i in 0..n_samples {
955 let val = yx(i, j);
956 acc = acc + c_of(i) * val * val;
957 }
958 *xj_sq_j = acc;
959 }
960
961 let mut index: Vec<usize> = (0..w_size).collect();
962 let mut active_size = w_size;
963 let mut gmax_old = inf;
964 let mut gnorm1_init = -F::one();
965
966 let mut n_iter: usize = 0;
967 let mut converged = false;
968
969 while n_iter < max_iter {
970 let mut gmax_new = F::zero();
971 let mut gnorm1_new = F::zero();
972
973 // liblinear shuffles `index[0..active_size]` here (`linear.cpp:1533-1537`);
974 // ferrolearn sweeps natural order (no RNG); the unique optimum is
975 // unchanged at the limit.
976
977 let mut s = 0;
978 while s < active_size {
979 let j = index[s];
980
981 // G_loss = −2·Σ_{i: b[i]>0} C·(y_i·x_ij)·b[i];
982 // H = 2·Σ_{i: b[i]>0} C·(y_i·x_ij)² (`linear.cpp:1542-1561`).
983 let mut g_loss = F::zero();
984 let mut h = F::zero();
985 for (i, &bi) in b.iter().enumerate() {
986 if bi > F::zero() {
987 let val = yx(i, j);
988 let tmp = c_of(i) * val;
989 g_loss = g_loss - tmp * bi;
990 h = h + tmp * val;
991 }
992 }
993 g_loss = g_loss * two;
994 let g = g_loss;
995 h = h * two;
996 if h < tiny {
997 h = tiny;
998 }
999
1000 let gp = g + F::one();
1001 let gn = g - F::one();
1002 let wj = w[j];
1003
1004 // Violation + shrinking (`linear.cpp:1564-1587`).
1005 let mut violation = F::zero();
1006 if wj == F::zero() {
1007 if gp < F::zero() {
1008 violation = -gp;
1009 } else if gn > F::zero() {
1010 violation = gn;
1011 } else if gp > gmax_old / nl && gn < -(gmax_old / nl) {
1012 active_size -= 1;
1013 index.swap(s, active_size);
1014 continue; // re-process the swapped-in element at `s`
1015 }
1016 } else if wj > F::zero() {
1017 violation = gp.abs();
1018 } else {
1019 violation = gn.abs();
1020 }
1021
1022 if violation > gmax_new {
1023 gmax_new = violation;
1024 }
1025 gnorm1_new = gnorm1_new + violation;
1026
1027 // Newton direction with soft-threshold (`linear.cpp:1589-1595`).
1028 let mut d = if gp < h * wj {
1029 -gp / h
1030 } else if gn > h * wj {
1031 -gn / h
1032 } else {
1033 -wj
1034 };
1035
1036 if d.abs() < tiny {
1037 s += 1;
1038 continue;
1039 }
1040
1041 // Backtracking line search (`linear.cpp:1600-1661`).
1042 let mut delta = (wj + d).abs() - wj.abs() + g * d;
1043 let mut d_old = F::zero();
1044 let mut num_linesearch = 0usize;
1045 while num_linesearch < max_num_linesearch {
1046 let d_diff = d_old - d;
1047 let mut cond = (wj + d).abs() - wj.abs() - sigma * delta;
1048
1049 let appxcond = xj_sq[j] * d * d + g_loss * d + cond;
1050 if appxcond <= F::zero() {
1051 for (i, bi) in b.iter_mut().enumerate() {
1052 *bi = *bi + d_diff * yx(i, j);
1053 }
1054 break;
1055 }
1056
1057 let mut loss_old = F::zero();
1058 let mut loss_new = F::zero();
1059 if num_linesearch == 0 {
1060 for (i, bi) in b.iter_mut().enumerate() {
1061 if *bi > F::zero() {
1062 loss_old = loss_old + c_of(i) * *bi * *bi;
1063 }
1064 let b_new = *bi + d_diff * yx(i, j);
1065 *bi = b_new;
1066 if b_new > F::zero() {
1067 loss_new = loss_new + c_of(i) * b_new * b_new;
1068 }
1069 }
1070 } else {
1071 for (i, bi) in b.iter_mut().enumerate() {
1072 let b_new = *bi + d_diff * yx(i, j);
1073 *bi = b_new;
1074 if b_new > F::zero() {
1075 loss_new = loss_new + c_of(i) * b_new * b_new;
1076 }
1077 }
1078 }
1079
1080 cond = cond + loss_new - loss_old;
1081 if cond <= F::zero() {
1082 break;
1083 }
1084 d_old = d;
1085 d = d / two;
1086 delta = delta / two;
1087 num_linesearch += 1;
1088 }
1089
1090 w[j] = w[j] + d;
1091
1092 // Recompute b[] if the line search took the maximum steps
1093 // (`linear.cpp:1665-1682`).
1094 if num_linesearch >= max_num_linesearch {
1095 for bi in b.iter_mut() {
1096 *bi = F::one();
1097 }
1098 for (jj, &wjj) in w.iter().enumerate() {
1099 if wjj == F::zero() {
1100 continue;
1101 }
1102 for (i, bi) in b.iter_mut().enumerate() {
1103 *bi = *bi - wjj * yx(i, jj);
1104 }
1105 }
1106 }
1107
1108 s += 1;
1109 }
1110
1111 if n_iter == 0 {
1112 gnorm1_init = gnorm1_new;
1113 }
1114 n_iter += 1;
1115
1116 // Stop when Gnorm1_new ≤ eps·Gnorm1_init on the full active set
1117 // (`linear.cpp:1691-1702`).
1118 if gnorm1_new <= tol * gnorm1_init {
1119 if active_size == w_size {
1120 converged = true;
1121 break;
1122 }
1123 active_size = w_size;
1124 gmax_old = inf;
1125 continue;
1126 }
1127
1128 gmax_old = gmax_new;
1129 }
1130
1131 (w, n_iter, converged)
1132}
1133
1134/// Solve the joint Crammer-Singer multiclass SVM, transcribing liblinear's
1135/// `Solver_MCSVM_CS` (`sklearn/svm/src/liblinear/linear.cpp:493-787`,
1136/// solver type 4, `_base.py:1017`).
1137///
1138/// Minimizes the Crammer-Singer objective over a single joint weight matrix
1139/// `w` flattened as `(w_size × nr_class)` with `w[feature*nr_class + m]`. The
1140/// dual variables `alpha[i*nr_class + m]` satisfy `Σ_m alpha[i,m] = 0`,
1141/// `alpha[i,m] <= C[i]` if `y_i == m` else `alpha[i,m] <= 0`. Per sample,
1142/// `C[i] = W[i] · weighted_C[y_i]` (`linear.cpp:521-522`); here `W[i] = 1` and
1143/// `weighted_C[c] = C · class_weight[c]` (`= C` when `class_weight=None`).
1144///
1145/// `y_class[i]` is the class index (`0..nr_class`, the sorted-`classes`
1146/// position) of sample `i`. When `fit_intercept`, the design matrix is augmented
1147/// with the constant column `intercept_scaling` at feature index `n_features`
1148/// (it IS part of `QD` and `w`, `linear.cpp:512`/`608-618` over the augmented
1149/// row).
1150///
1151/// State / sweep / shrinking (`linear.cpp:576-754`) is transcribed faithfully,
1152/// including per-sample shrinking (`active_size_i`, `alpha_index`) and the
1153/// two-level stopping (`eps_shrink = max(10·eps, 1)`). liblinear shuffles the
1154/// `index` set each sweep (`bounded_rand_int`, `linear.cpp:629`); ferrolearn
1155/// sweeps NATURAL ORDER (no RNG) — the Crammer-Singer optimum is unique, so the
1156/// limit is identical (the documented RNG/shrink-path boundary, as the dual/l1
1157/// solvers). `eps = tol` (the solver receives `param->eps` directly,
1158/// `linear.cpp:2535`).
1159///
1160/// Returns `(w_flat, n_iter, converged)` where `w_flat[feature*nr_class + m]`.
1161#[allow(
1162 clippy::too_many_lines,
1163 clippy::too_many_arguments,
1164 reason = "faithful transcription of liblinear Solver_MCSVM_CS (linear.cpp:493-787); \
1165 the args mirror the solver's ctor + Solve() inputs (prob/nr_class/weighted_C/\
1166 eps/max_iter/bias) and grouping them would obscure the 1:1 transcription"
1167)]
1168fn solve_crammer_singer<F: Float + 'static>(
1169 x: &Array2<F>,
1170 y_class: &[usize],
1171 nr_class: usize,
1172 weighted_c: &[F],
1173 max_iter: usize,
1174 tol: F,
1175 fit_intercept: bool,
1176 intercept_scaling: F,
1177) -> (Vec<F>, usize, bool) {
1178 let (l, n_features) = x.dim();
1179 let w_size = if fit_intercept {
1180 n_features + 1
1181 } else {
1182 n_features
1183 };
1184
1185 let inf = F::infinity();
1186 let ten = F::from(10).unwrap_or_else(|| {
1187 let two = F::one() + F::one();
1188 two + two + two + two + two
1189 });
1190 let tiny = F::from(1.0e-12).unwrap_or_else(F::epsilon);
1191
1192 // `C[i] = W[i] · weighted_C[y_i]`; `W[i] = 1` (`linear.cpp:521-522`).
1193 let c_per_sample: Vec<F> = y_class.iter().map(|&yi| weighted_c[yi]).collect();
1194
1195 // `x_val(i, feat)` over the augmented row (feature index `n_features` is the
1196 // constant intercept_scaling column when fit_intercept).
1197 let x_val = |i: usize, feat: usize| -> F {
1198 if feat < n_features {
1199 x[[i, feat]]
1200 } else {
1201 intercept_scaling
1202 }
1203 };
1204
1205 // alpha[i*nr_class + m], w[feature*nr_class + m] (`linear.cpp:580-602`).
1206 let mut alpha = vec![F::zero(); l * nr_class];
1207 let mut w = vec![F::zero(); w_size * nr_class];
1208
1209 // alpha_index[i*nr_class + m] = m; QD[i] = ||x_i||^2 over the augmented row;
1210 // active_size_i[i] = nr_class; y_index[i] = y_class[i] (`linear.cpp:603-622`).
1211 let mut alpha_index = vec![0usize; l * nr_class];
1212 let mut qd = vec![F::zero(); l];
1213 let mut active_size_i = vec![nr_class; l];
1214 let mut y_index = y_class.to_vec();
1215 let mut index: Vec<usize> = (0..l).collect();
1216 for i in 0..l {
1217 for m in 0..nr_class {
1218 alpha_index[i * nr_class + m] = m;
1219 }
1220 let mut acc = F::zero();
1221 for feat in 0..w_size {
1222 let v = x_val(i, feat);
1223 acc = acc + v * v;
1224 }
1225 qd[i] = acc;
1226 }
1227
1228 // Scratch buffers reused per sample (`B`, `G`, `alpha_new`,
1229 // `linear.cpp:518-519,581`).
1230 let mut b_buf = vec![F::zero(); nr_class];
1231 let mut g_buf = vec![F::zero(); nr_class];
1232 let mut alpha_new = vec![F::zero(); nr_class];
1233
1234 let mut active_size = l;
1235 // eps_shrink = max(10·eps, 1) (`linear.cpp:590`).
1236 let mut eps_shrink = (ten * tol).max(F::one());
1237 let mut start_from_all = true;
1238
1239 let mut iter = 0usize;
1240 let mut converged = false;
1241
1242 while iter < max_iter {
1243 let mut stopping = -inf;
1244
1245 // liblinear shuffles index[0..active_size] here (`linear.cpp:627-631`);
1246 // ferrolearn sweeps natural order (no RNG); the unique optimum is
1247 // unchanged at the limit.
1248
1249 let mut s = 0;
1250 while s < active_size {
1251 let i = index[s];
1252 let ai = qd[i];
1253
1254 if ai > F::zero() {
1255 let asi = active_size_i[i];
1256 // G[m] = (m==y_i ? 0 : 1) + w_{alpha_index[m]} · x_i
1257 // (`linear.cpp:641-653`).
1258 for g in g_buf.iter_mut().take(asi) {
1259 *g = F::one();
1260 }
1261 if y_index[i] < asi {
1262 g_buf[y_index[i]] = F::zero();
1263 }
1264 for feat in 0..w_size {
1265 let xv = x_val(i, feat);
1266 if xv == F::zero() {
1267 continue;
1268 }
1269 let base = feat * nr_class;
1270 for m in 0..asi {
1271 let idx = alpha_index[i * nr_class + m];
1272 g_buf[m] = g_buf[m] + w[base + idx] * xv;
1273 }
1274 }
1275
1276 // minG over {alpha_i[idx]<0} ∪ {y_i if alpha_i[y_i]<C[i]};
1277 // maxG over all active m (`linear.cpp:655-666`).
1278 let mut min_g = inf;
1279 let mut max_g = -inf;
1280 for m in 0..asi {
1281 let idx = alpha_index[i * nr_class + m];
1282 if alpha[i * nr_class + idx] < F::zero() && g_buf[m] < min_g {
1283 min_g = g_buf[m];
1284 }
1285 if g_buf[m] > max_g {
1286 max_g = g_buf[m];
1287 }
1288 }
1289 if y_index[i] < asi
1290 && alpha[i * nr_class + y_class[i]] < c_per_sample[i]
1291 && g_buf[y_index[i]] < min_g
1292 {
1293 min_g = g_buf[y_index[i]];
1294 }
1295
1296 // Per-sample shrinking via be_shrunk (`linear.cpp:668-697`).
1297 let mut m = 0;
1298 while m < active_size_i[i] {
1299 let idx_m = alpha_index[i * nr_class + m];
1300 if cs_be_shrunk(
1301 c_per_sample[i],
1302 m,
1303 y_index[i],
1304 alpha[i * nr_class + idx_m],
1305 g_buf[m],
1306 min_g,
1307 ) {
1308 active_size_i[i] -= 1;
1309 while active_size_i[i] > m {
1310 let asi_top = active_size_i[i];
1311 let idx_top = alpha_index[i * nr_class + asi_top];
1312 if !cs_be_shrunk(
1313 c_per_sample[i],
1314 asi_top,
1315 y_index[i],
1316 alpha[i * nr_class + idx_top],
1317 g_buf[asi_top],
1318 min_g,
1319 ) {
1320 alpha_index.swap(i * nr_class + m, i * nr_class + asi_top);
1321 g_buf.swap(m, asi_top);
1322 if y_index[i] == asi_top {
1323 y_index[i] = m;
1324 } else if y_index[i] == m {
1325 y_index[i] = asi_top;
1326 }
1327 break;
1328 }
1329 active_size_i[i] -= 1;
1330 }
1331 }
1332 m += 1;
1333 }
1334
1335 if active_size_i[i] <= 1 {
1336 active_size -= 1;
1337 index.swap(s, active_size);
1338 continue; // re-process the swapped-in element at `s`
1339 }
1340
1341 if max_g - min_g <= tiny {
1342 s += 1;
1343 continue;
1344 }
1345 stopping = stopping.max(max_g - min_g);
1346
1347 // B[m] = G[m] - Ai·alpha_i[idx] (`linear.cpp:704-705`).
1348 let asi = active_size_i[i];
1349 for m in 0..asi {
1350 let idx = alpha_index[i * nr_class + m];
1351 b_buf[m] = g_buf[m] - ai * alpha[i * nr_class + idx];
1352 }
1353
1354 cs_solve_sub_problem(ai, y_index[i], c_per_sample[i], asi, &b_buf, &mut alpha_new);
1355
1356 // Apply d = alpha_new[m] - alpha_i[idx] and update w
1357 // (`linear.cpp:708-728`).
1358 let mut d_nz: Vec<(usize, F)> = Vec::new();
1359 for m in 0..asi {
1360 let idx = alpha_index[i * nr_class + m];
1361 let d = alpha_new[m] - alpha[i * nr_class + idx];
1362 alpha[i * nr_class + idx] = alpha_new[m];
1363 if d.abs() >= tiny {
1364 d_nz.push((idx, d));
1365 }
1366 }
1367 if !d_nz.is_empty() {
1368 for feat in 0..w_size {
1369 let xv = x_val(i, feat);
1370 if xv == F::zero() {
1371 continue;
1372 }
1373 let base = feat * nr_class;
1374 for &(idx, d) in &d_nz {
1375 w[base + idx] = w[base + idx] + d * xv;
1376 }
1377 }
1378 }
1379 }
1380
1381 s += 1;
1382 }
1383
1384 iter += 1;
1385
1386 // Two-level stopping (`linear.cpp:738-753`).
1387 if stopping < eps_shrink {
1388 if stopping < tol && start_from_all {
1389 converged = true;
1390 break;
1391 }
1392 active_size = l;
1393 for asi in active_size_i.iter_mut() {
1394 *asi = nr_class;
1395 }
1396 eps_shrink = (eps_shrink / (F::one() + F::one())).max(tol);
1397 start_from_all = true;
1398 } else {
1399 start_from_all = false;
1400 }
1401 }
1402
1403 (w, iter, converged)
1404}
1405
1406/// `Solver_MCSVM_CS::be_shrunk` (`linear.cpp:566-574`): shrink class `m` of
1407/// sample `i` when its dual variable is at its bound and the gradient is below
1408/// `minG`. `bound = C[i]` if `m == yi` (the class index), else `0`.
1409fn cs_be_shrunk<F: Float>(c_yi: F, m: usize, yi: usize, alpha_i: F, g_m: F, min_g: F) -> bool {
1410 let bound = if m == yi { c_yi } else { F::zero() };
1411 alpha_i == bound && g_m < min_g
1412}
1413
1414/// `Solver_MCSVM_CS::solve_sub_problem` (`linear.cpp:541-564`): the per-sample
1415/// simplex-projection inner solve. `B[..active_i]` is the gradient offset
1416/// buffer; the result is written to `alpha_new[..active_i]`.
1417///
1418/// Clones `B → D`, adds `Ai·C_yi` to `D[yi]` (when `yi < active_i`), sorts `D`
1419/// DESCENDING, then finds the breakpoint `beta` and projects each coordinate:
1420/// `alpha_new[r] = min(C_yi, (beta-B[r])/Ai)` for `r == yi`, else
1421/// `min(0, (beta-B[r])/Ai)`.
1422fn cs_solve_sub_problem<F: Float + 'static>(
1423 ai: F,
1424 yi: usize,
1425 c_yi: F,
1426 active_i: usize,
1427 b_buf: &[F],
1428 alpha_new: &mut [F],
1429) {
1430 // clone(D, B, active_i); D[yi] += Ai·C_yi (`linear.cpp:546-548`).
1431 let mut d: Vec<F> = b_buf[..active_i].to_vec();
1432 if yi < active_i {
1433 d[yi] = d[yi] + ai * c_yi;
1434 }
1435 // qsort DESCENDING (compare_double returns -1 when a > b, `linear.cpp:532-538`).
1436 d.sort_by(|a, b| match b.partial_cmp(a) {
1437 Some(ord) => ord,
1438 None => core::cmp::Ordering::Equal,
1439 });
1440
1441 // beta = D[0] - Ai·C_yi; for r=1; r<active_i && beta<r·D[r]; r++ { beta+=D[r]; }
1442 // beta /= r (`linear.cpp:551-554`).
1443 let mut beta = d[0] - ai * c_yi;
1444 let mut r = 1usize;
1445 while r < active_i {
1446 let r_f = F::from(r).unwrap_or_else(F::one);
1447 if beta < r_f * d[r] {
1448 beta = beta + d[r];
1449 r += 1;
1450 } else {
1451 break;
1452 }
1453 }
1454 let r_f = F::from(r).unwrap_or_else(F::one);
1455 beta = beta / r_f;
1456
1457 // Project each coordinate (`linear.cpp:556-562`).
1458 for (rr, an) in alpha_new.iter_mut().enumerate().take(active_i) {
1459 let cand = (beta - b_buf[rr]) / ai;
1460 *an = if rr == yi {
1461 c_yi.min(cand)
1462 } else {
1463 F::zero().min(cand)
1464 };
1465 }
1466}
1467
1468impl<F: Float + Send + Sync + ScalarOperand + 'static> Fit<Array2<F>, Array1<usize>>
1469 for LinearSVC<F>
1470{
1471 type Fitted = FittedLinearSVC<F>;
1472 type Error = FerroError;
1473
1474 /// Fit the linear SVC model using liblinear's dual coordinate descent.
1475 ///
1476 /// Minimizes `0.5*||w||^2 + C * sum_i L(y_i, w.x_i)` (no `1/n`). When
1477 /// `fit_intercept` is set, the design matrix is augmented with a constant
1478 /// column equal to `intercept_scaling`, the augmented weight is penalized
1479 /// like any feature, and `intercept_ = intercept_scaling * w_last`
1480 /// (`_base.py:1188-1198,:1240-1245`).
1481 ///
1482 /// # Errors
1483 ///
1484 /// - [`FerroError::ShapeMismatch`] — sample count mismatch.
1485 /// - [`FerroError::InvalidParameter`] — `C` not positive, or
1486 /// `intercept_scaling` not positive when fitting an intercept.
1487 /// - [`FerroError::InsufficientSamples`] — fewer than 2 distinct classes.
1488 fn fit(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<FittedLinearSVC<F>, FerroError> {
1489 let (n_samples, n_features) = x.dim();
1490
1491 if n_samples != y.len() {
1492 return Err(FerroError::ShapeMismatch {
1493 expected: vec![n_samples],
1494 actual: vec![y.len()],
1495 context: "y length must match number of samples in X".into(),
1496 });
1497 }
1498
1499 if self.c <= F::zero() {
1500 return Err(FerroError::InvalidParameter {
1501 name: "C".into(),
1502 reason: "must be positive".into(),
1503 });
1504 }
1505
1506 // `_parameter_constraints["tol"] = Interval(Real, 0.0, None,
1507 // closed="neither")` (`_classes.py:237`) → `tol <= 0` raises.
1508 if self.tol <= F::zero() {
1509 return Err(FerroError::InvalidParameter {
1510 name: "tol".into(),
1511 reason: "must be positive".into(),
1512 });
1513 }
1514
1515 // liblinear raises when intercept_scaling <= 0 with fit_intercept
1516 // (`_base.py:1190-1196`).
1517 if self.fit_intercept && self.intercept_scaling <= F::zero() {
1518 return Err(FerroError::InvalidParameter {
1519 name: "intercept_scaling".into(),
1520 reason: "must be greater than 0 when fit_intercept is true".into(),
1521 });
1522 }
1523
1524 // Non-finite input validation (#2263). sklearn `LinearSVC.fit`
1525 // -> `self._validate_data(X, y, ...)` (`svm/_classes.py:302`, the shared
1526 // liblinear `_fit_liblinear` path; cf. `svm/_base.py:190`) keeps the
1527 // default `force_all_finite=True`, so `check_array` rejects any NaN or
1528 // +/-inf in X with a `ValueError("Input X contains NaN.")` / `"...
1529 // contains infinity ..."` BEFORE the dual coordinate-descent solve. `y`
1530 // is `Array1<usize>` (integer class labels), finite by type, and
1531 // ferrolearn's `LinearSVC::fit` takes no `sample_weight` argument, so X is
1532 // the only runtime check. `.iter().any(|v| !v.is_finite())` rejects both
1533 // NaN and Inf (bounds-safe, no panic, R-CODE-2); the finite path is
1534 // byte-identical.
1535 if x.iter().any(|v| !v.is_finite()) {
1536 return Err(FerroError::InvalidParameter {
1537 name: "X".into(),
1538 reason: "Input X contains NaN or infinity.".into(),
1539 });
1540 }
1541
1542 let y_vec: Vec<usize> = y.to_vec();
1543 let mut classes: Vec<usize> = y_vec.clone();
1544 classes.sort_unstable();
1545 classes.dedup();
1546
1547 if classes.len() < 2 {
1548 return Err(FerroError::InsufficientSamples {
1549 required: 2,
1550 actual: classes.len(),
1551 context: "LinearSVC requires at least 2 distinct classes".into(),
1552 });
1553 }
1554
1555 // `class_weight` scales `C` per class: `weighted_C[i] = C·w[class i]`
1556 // (`compute_class_weight(class_weight, classes=classes_, y=y)`,
1557 // `_base.py:1179`; `linear.cpp:2496-2507`). `weights` is aligned to
1558 // `classes` (sorted unique = LabelEncoder order). Used by BOTH the OvR
1559 // path (below) and the Crammer-Singer joint solver.
1560 let weights = compute_class_weight(&self.class_weight, &classes, &y_vec);
1561
1562 // `multi_class='crammer_singer'` selects liblinear solver type 4
1563 // REGARDLESS of penalty/loss/dual (`_get_liblinear_solver_type` returns
1564 // 4 for crammer_singer, `_base.py:1017,1020-1021`), so penalty/loss/dual
1565 // are IGNORED and the joint Crammer-Singer solver runs.
1566 if self.multi_class == MultiClass::CrammerSinger {
1567 return self.fit_crammer_singer(x, &classes, &y_vec, &weights);
1568 }
1569
1570 // Resolve `dual` (auto → bool via `_validate_dual_parameter`,
1571 // `_classes.py:13-29`) then validate the penalty×loss×dual combination
1572 // against the liblinear solver matrix (`_get_liblinear_solver_type`,
1573 // `_base.py:995-1049`). Unsupported combinations raise (ValueError →
1574 // `InvalidParameter`). The resolved solver type selects the per-
1575 // sub-problem solver below.
1576 let dual = resolve_dual(self.dual, self.penalty, self.loss, n_samples, n_features);
1577 let _solver_type = liblinear_solver_type(self.penalty, self.loss, dual)?;
1578
1579 // Solve one binary sub-problem (positive group penalty `cp`, negative
1580 // group penalty `cn`) and split the augmented weight vector into
1581 // (coef_, intercept_) per `_base.py:1240-1245`. For `penalty=l1`
1582 // use liblinear's L1 coordinate descent (`solve_l1r_l2_svc`, solver
1583 // type 5, `linear.cpp:1467`); for `penalty=l2` keep the dual CD — the
1584 // l2 optimum is dual-invariant (R-DEV-7), so dual=True and dual=False
1585 // reach the same `coef_`/`intercept_`.
1586 let penalty = self.penalty;
1587 let solve_one = |y_signed: &Array1<F>, cp: F, cn: F| -> (Array1<F>, F, usize, bool) {
1588 let cfg = SolverConfig {
1589 cp,
1590 cn,
1591 max_iter: self.max_iter,
1592 tol: self.tol,
1593 loss: self.loss,
1594 fit_intercept: self.fit_intercept,
1595 intercept_scaling: self.intercept_scaling,
1596 };
1597 let (w, n_iter, converged) = match penalty {
1598 LinearSVCPenalty::L1 => solve_binary_l1r_l2(x, y_signed, &cfg),
1599 LinearSVCPenalty::L2 => solve_binary_dual(x, y_signed, &cfg),
1600 };
1601 let coef = Array1::from_iter(w.iter().take(n_features).copied());
1602 let intercept = if self.fit_intercept {
1603 self.intercept_scaling * w[n_features]
1604 } else {
1605 F::zero()
1606 };
1607 (coef, intercept, n_iter, converged)
1608 };
1609
1610 let mut any_unconverged = false;
1611 // n_iter_ = n_iter_.max() across the sub-problem fits (`_classes.py:338`).
1612 let mut max_n_iter: usize = 0;
1613
1614 let fitted = if classes.len() == 2 {
1615 // Binary classification. liblinear's `prob.y` are the LabelEncoder
1616 // indices, mapped `y[i] = +1 if y_ind > 0 else -1`
1617 // (`linear.cpp:861-871`), so the positive class is `classes_[1]`
1618 // and `coef_` is for `classes_[1]` (`_base.py` / `_classes.py:311`).
1619 let y_signed: Array1<F> = y.mapv(|label| {
1620 if label == classes[1] {
1621 F::one()
1622 } else {
1623 -F::one()
1624 }
1625 });
1626
1627 // Binary (`train_one(Cp=weighted_C[1], Cn=weighted_C[0])`,
1628 // `linear.cpp:2543-2551`): Cp = C·w[classes[1]] (the +1 class),
1629 // Cn = C·w[classes[0]] (the −1 class).
1630 let cp = self.c * weights[1];
1631 let cn = self.c * weights[0];
1632 let (coef, intercept, n_iter, converged) = solve_one(&y_signed, cp, cn);
1633 if !converged {
1634 any_unconverged = true;
1635 }
1636 max_n_iter = max_n_iter.max(n_iter);
1637
1638 FittedLinearSVC {
1639 weight_vectors: vec![coef],
1640 intercepts: vec![intercept],
1641 classes,
1642 is_binary: true,
1643 n_features,
1644 n_iter: max_n_iter,
1645 }
1646 } else {
1647 // Multiclass: one-vs-rest. Each class is the positive (+1) class of
1648 // its own binary sub-problem.
1649 let mut weight_vectors = Vec::with_capacity(classes.len());
1650 let mut intercepts = Vec::with_capacity(classes.len());
1651
1652 for (k, &cls) in classes.iter().enumerate() {
1653 let y_signed: Array1<F> =
1654 y.mapv(|label| if label == cls { F::one() } else { -F::one() });
1655 // Multiclass OvR (`train_one(Cp=weighted_C[k], Cn=param->C)`,
1656 // `linear.cpp:2559-2571`): Cp = C·w[class k] (the +1 class),
1657 // Cn = C (the BASE C — the negative rest is UNWEIGHTED).
1658 let cp = self.c * weights[k];
1659 let cn = self.c;
1660 let (coef, intercept, n_iter, converged) = solve_one(&y_signed, cp, cn);
1661 if !converged {
1662 any_unconverged = true;
1663 }
1664 max_n_iter = max_n_iter.max(n_iter);
1665 weight_vectors.push(coef);
1666 intercepts.push(intercept);
1667 }
1668
1669 FittedLinearSVC {
1670 weight_vectors,
1671 intercepts,
1672 classes,
1673 is_binary: false,
1674 n_features,
1675 n_iter: max_n_iter,
1676 }
1677 };
1678
1679 // liblinear warns when any sub-problem reaches `max_iter` without
1680 // satisfying the stopping criterion (`_base.py:1234-1238`). The crate's
1681 // warning channel is `eprintln!` (cf. qda.rs / lda.rs warnings).
1682 if any_unconverged {
1683 eprintln!("Liblinear failed to converge, increase the number of iterations.");
1684 }
1685
1686 Ok(fitted)
1687 }
1688}
1689
1690impl<F: Float + Send + Sync + ScalarOperand + 'static> LinearSVC<F> {
1691 /// Fit via the joint Crammer-Singer multiclass solver (solver type 4,
1692 /// `_base.py:1017`). Runs ONE joint solve over all classes
1693 /// ([`solve_crammer_singer`], `Solver_MCSVM_CS`, `linear.cpp:493-787`),
1694 /// extracts per-class `coef_`/`intercept_` from the flattened
1695 /// `w[feature*nr_class + m]`, and applies the binary collapse
1696 /// (`_classes.py:340-344`).
1697 ///
1698 /// `classes` is the sorted unique label set; `y_vec` the per-sample labels;
1699 /// `weights` the per-class `class_weight` multipliers aligned to `classes`.
1700 fn fit_crammer_singer(
1701 &self,
1702 x: &Array2<F>,
1703 classes: &[usize],
1704 y_vec: &[usize],
1705 weights: &[F],
1706 ) -> Result<FittedLinearSVC<F>, FerroError> {
1707 let (_n_samples, n_features) = x.dim();
1708 let nr_class = classes.len();
1709
1710 // Map each sample's label to its class index (`0..nr_class`, the sorted
1711 // `classes` position — liblinear's LabelEncoder order). `y_class[i]`
1712 // indexes `w[..][m]` and `weighted_C[m]`.
1713 let y_class: Vec<usize> = y_vec
1714 .iter()
1715 .map(|label| classes.iter().position(|c| c == label).unwrap_or(0))
1716 .collect();
1717
1718 // weighted_C[c] = C · class_weight[c] (`linear.cpp:2496-2507`,
1719 // `weighted_C[i] = param->C` then `*= weight`). `weights` already holds
1720 // the per-class multipliers (1.0 when class_weight=None).
1721 let weighted_c: Vec<F> = weights.iter().map(|&wc| self.c * wc).collect();
1722
1723 let (w, n_iter, converged) = solve_crammer_singer(
1724 x,
1725 &y_class,
1726 nr_class,
1727 &weighted_c,
1728 self.max_iter,
1729 self.tol,
1730 self.fit_intercept,
1731 self.intercept_scaling,
1732 );
1733
1734 if !converged {
1735 eprintln!("Liblinear failed to converge, increase the number of iterations.");
1736 }
1737
1738 // Extract coef_[m][feature] = w[feature*nr_class + m] and
1739 // intercept_[m] = intercept_scaling · w[n_features*nr_class + m]
1740 // (the augmented-column weight), per `_base.py:1240-1245`.
1741 let mut weight_vectors: Vec<Array1<F>> = Vec::with_capacity(nr_class);
1742 let mut intercepts: Vec<F> = Vec::with_capacity(nr_class);
1743 for m in 0..nr_class {
1744 let mut row = Array1::<F>::zeros(n_features);
1745 for (feat, r) in row.iter_mut().enumerate() {
1746 *r = w[feat * nr_class + m];
1747 }
1748 weight_vectors.push(row);
1749 let intercept = if self.fit_intercept {
1750 self.intercept_scaling * w[n_features * nr_class + m]
1751 } else {
1752 F::zero()
1753 };
1754 intercepts.push(intercept);
1755 }
1756
1757 // Binary collapse (`_classes.py:340-344`): with 2 classes, the joint
1758 // solve yields two rows; sklearn collapses them to a single weight
1759 // vector `coef_ = row_1 - row_0` and `intercept_ = int_1 - int_0`, so
1760 // the binary sign decision path is used.
1761 if nr_class == 2 {
1762 let collapsed_coef = &weight_vectors[1] - &weight_vectors[0];
1763 let collapsed_intercept = if self.fit_intercept {
1764 intercepts[1] - intercepts[0]
1765 } else {
1766 F::zero()
1767 };
1768 return Ok(FittedLinearSVC {
1769 weight_vectors: vec![collapsed_coef],
1770 intercepts: vec![collapsed_intercept],
1771 classes: classes.to_vec(),
1772 is_binary: true,
1773 n_features,
1774 n_iter,
1775 });
1776 }
1777
1778 Ok(FittedLinearSVC {
1779 weight_vectors,
1780 intercepts,
1781 classes: classes.to_vec(),
1782 is_binary: false,
1783 n_features,
1784 n_iter,
1785 })
1786 }
1787}
1788
1789impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>> for FittedLinearSVC<F> {
1790 type Output = Array1<usize>;
1791 type Error = FerroError;
1792
1793 /// Predict class labels for the given feature matrix.
1794 ///
1795 /// Binary: `sign(X @ w + b)` mapped to class labels (`>= 0 → classes_[1]`).
1796 /// Multiclass: argmax of decision values across one-vs-rest classifiers.
1797 ///
1798 /// # Errors
1799 ///
1800 /// Returns [`FerroError::ShapeMismatch`] if the number of features
1801 /// does not match the fitted model.
1802 fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
1803 let n_features = x.ncols();
1804 if n_features != self.n_features {
1805 return Err(FerroError::ShapeMismatch {
1806 expected: vec![self.n_features],
1807 actual: vec![n_features],
1808 context: "number of features must match fitted model".into(),
1809 });
1810 }
1811
1812 let n_samples = x.nrows();
1813 let mut predictions = Array1::<usize>::zeros(n_samples);
1814
1815 if self.is_binary {
1816 let scores = x.dot(&self.weight_vectors[0]) + self.intercepts[0];
1817 for i in 0..n_samples {
1818 predictions[i] = if scores[i] >= F::zero() {
1819 self.classes[1]
1820 } else {
1821 self.classes[0]
1822 };
1823 }
1824 } else {
1825 // Multiclass: pick class with highest decision value.
1826 for i in 0..n_samples {
1827 let mut best_class = 0;
1828 let mut best_score = F::neg_infinity();
1829 for (c, w) in self.weight_vectors.iter().enumerate() {
1830 let score = x.row(i).dot(w) + self.intercepts[c];
1831 if score > best_score {
1832 best_score = score;
1833 best_class = c;
1834 }
1835 }
1836 predictions[i] = self.classes[best_class];
1837 }
1838 }
1839
1840 Ok(predictions)
1841 }
1842}
1843
1844impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F> for FittedLinearSVC<F> {
1845 /// Returns the coefficient vector of the first (or only) binary sub-problem.
1846 fn coefficients(&self) -> &Array1<F> {
1847 &self.weight_vectors[0]
1848 }
1849
1850 /// Returns the intercept of the first (or only) binary sub-problem.
1851 fn intercept(&self) -> F {
1852 self.intercepts[0]
1853 }
1854}
1855
1856impl<F: Float + Send + Sync + ScalarOperand + 'static> HasClasses for FittedLinearSVC<F> {
1857 fn classes(&self) -> &[usize] {
1858 &self.classes
1859 }
1860
1861 fn n_classes(&self) -> usize {
1862 self.classes.len()
1863 }
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868 use super::*;
1869 use ndarray::array;
1870
1871 #[test]
1872 fn test_default_constructor() {
1873 let m = LinearSVC::<f64>::new();
1874 assert_eq!(m.max_iter, 1000);
1875 assert!(m.c == 1.0);
1876 assert_eq!(m.loss, LinearSVCLoss::SquaredHinge);
1877 assert!(m.fit_intercept);
1878 assert!(m.intercept_scaling == 1.0);
1879 }
1880
1881 #[test]
1882 fn test_builder_setters() {
1883 let m = LinearSVC::<f64>::new()
1884 .with_c(10.0)
1885 .with_max_iter(500)
1886 .with_tol(1e-6)
1887 .with_loss(LinearSVCLoss::Hinge)
1888 .with_fit_intercept(false)
1889 .with_intercept_scaling(2.0);
1890 assert!(m.c == 10.0);
1891 assert_eq!(m.max_iter, 500);
1892 assert_eq!(m.loss, LinearSVCLoss::Hinge);
1893 assert!(!m.fit_intercept);
1894 assert!(m.intercept_scaling == 2.0);
1895 }
1896
1897 #[test]
1898 fn test_binary_classification() {
1899 let x = Array2::from_shape_vec(
1900 (8, 2),
1901 vec![
1902 1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 2.0, 2.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0, 9.0, 9.0,
1903 ],
1904 )
1905 .unwrap();
1906 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
1907
1908 let model = LinearSVC::<f64>::new().with_c(1.0).with_max_iter(5000);
1909 let fitted = model.fit(&x, &y).unwrap();
1910 let preds = fitted.predict(&x).unwrap();
1911
1912 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
1913 assert!(correct >= 6, "expected at least 6 correct, got {correct}");
1914 }
1915
1916 #[test]
1917 fn test_binary_hinge_loss() {
1918 let x = Array2::from_shape_vec(
1919 (6, 2),
1920 vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
1921 )
1922 .unwrap();
1923 let y = array![0, 0, 0, 1, 1, 1];
1924
1925 let model = LinearSVC::<f64>::new()
1926 .with_loss(LinearSVCLoss::Hinge)
1927 .with_max_iter(5000);
1928 let fitted = model.fit(&x, &y).unwrap();
1929 let preds = fitted.predict(&x).unwrap();
1930
1931 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
1932 assert!(correct >= 4, "expected at least 4 correct, got {correct}");
1933 }
1934
1935 #[test]
1936 fn test_multiclass_classification() {
1937 let x = Array2::from_shape_vec(
1938 (9, 2),
1939 vec![
1940 0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 10.0, 0.0, 10.5, 0.0, 10.0, 0.5, 0.0, 10.0, 0.5,
1941 10.0, 0.0, 10.5,
1942 ],
1943 )
1944 .unwrap();
1945 let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
1946
1947 let model = LinearSVC::<f64>::new().with_c(10.0).with_max_iter(5000);
1948 let fitted = model.fit(&x, &y).unwrap();
1949
1950 assert_eq!(fitted.n_classes(), 3);
1951 assert_eq!(fitted.classes(), &[0, 1, 2]);
1952
1953 let preds = fitted.predict(&x).unwrap();
1954 let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
1955 assert!(correct >= 7, "expected at least 7 correct, got {correct}");
1956 }
1957
1958 #[test]
1959 fn test_binary_decision_function_is_1d() {
1960 let x = Array2::from_shape_vec(
1961 (6, 2),
1962 vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
1963 )
1964 .unwrap();
1965 let y = array![0, 0, 0, 1, 1, 1];
1966
1967 let fitted = LinearSVC::<f64>::new()
1968 .with_max_iter(5000)
1969 .fit(&x, &y)
1970 .unwrap();
1971 let df = fitted.decision_function(&x).unwrap();
1972 // sklearn ravels the binary decision_function to (n,) (`_base.py:365`).
1973 let binary = df.as_binary().expect("binary decision is 1-D");
1974 assert_eq!(binary.len(), 6);
1975 assert!(df.as_multiclass().is_none());
1976 assert_eq!(df.n_samples(), 6);
1977 }
1978
1979 #[test]
1980 fn test_multiclass_decision_function_is_2d() {
1981 let x = Array2::from_shape_vec(
1982 (9, 2),
1983 vec![
1984 0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 10.0, 0.0, 10.5, 0.0, 10.0, 0.5, 0.0, 10.0, 0.5,
1985 10.0, 0.0, 10.5,
1986 ],
1987 )
1988 .unwrap();
1989 let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
1990
1991 let fitted = LinearSVC::<f64>::new()
1992 .with_c(10.0)
1993 .with_max_iter(5000)
1994 .fit(&x, &y)
1995 .unwrap();
1996 let df = fitted.decision_function(&x).unwrap();
1997 let scores = df.as_multiclass().expect("multiclass decision is 2-D");
1998 assert_eq!(scores.dim(), (9, 3));
1999 assert!(df.as_binary().is_none());
2000 }
2001
2002 #[test]
2003 fn test_shape_mismatch_fit() {
2004 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2005 let y = array![0, 1]; // Wrong length
2006
2007 let model = LinearSVC::<f64>::new();
2008 assert!(model.fit(&x, &y).is_err());
2009 }
2010
2011 #[test]
2012 fn test_invalid_c() {
2013 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2014 let y = array![0, 0, 1, 1];
2015
2016 let model = LinearSVC::<f64>::new().with_c(0.0);
2017 assert!(model.fit(&x, &y).is_err());
2018
2019 let model_neg = LinearSVC::<f64>::new().with_c(-1.0);
2020 assert!(model_neg.fit(&x, &y).is_err());
2021 }
2022
2023 #[test]
2024 fn test_invalid_intercept_scaling() {
2025 let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
2026 let y = array![0, 0, 1, 1];
2027
2028 // fit_intercept=true + intercept_scaling<=0 is rejected
2029 // (`_base.py:1190-1196`).
2030 let model = LinearSVC::<f64>::new().with_intercept_scaling(0.0);
2031 assert!(model.fit(&x, &y).is_err());
2032
2033 // But with fit_intercept=false, intercept_scaling is ignored.
2034 let model = LinearSVC::<f64>::new()
2035 .with_fit_intercept(false)
2036 .with_intercept_scaling(0.0);
2037 assert!(model.fit(&x, &y).is_ok());
2038 }
2039
2040 #[test]
2041 fn test_fit_intercept_false_zero_intercept() {
2042 let x = Array2::from_shape_vec(
2043 (6, 2),
2044 vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
2045 )
2046 .unwrap();
2047 let y = array![0, 0, 0, 1, 1, 1];
2048
2049 let fitted = LinearSVC::<f64>::new()
2050 .with_fit_intercept(false)
2051 .with_max_iter(5000)
2052 .fit(&x, &y)
2053 .unwrap();
2054 assert!(fitted.intercept() == 0.0);
2055 }
2056
2057 #[test]
2058 fn test_single_class_error() {
2059 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2060 let y = array![0, 0, 0];
2061
2062 let model = LinearSVC::<f64>::new();
2063 assert!(model.fit(&x, &y).is_err());
2064 }
2065
2066 #[test]
2067 fn test_has_coefficients() {
2068 let x = Array2::from_shape_vec(
2069 (6, 2),
2070 vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
2071 )
2072 .unwrap();
2073 let y = array![0, 0, 0, 1, 1, 1];
2074
2075 let model = LinearSVC::<f64>::new().with_max_iter(5000);
2076 let fitted = model.fit(&x, &y).unwrap();
2077 assert_eq!(fitted.coefficients().len(), 2);
2078 }
2079
2080 #[test]
2081 #[allow(
2082 clippy::assertions_on_constants,
2083 reason = "assert!(false) reports the unexpected-Err fit path without a gated panic!/expect"
2084 )]
2085 fn test_l1_penalty_smoke() {
2086 // Smoke check that the `penalty=l1` path (solve_binary_l1r_l2,
2087 // `linear.cpp:1467`, solver type 5, `_base.py:1014`) lands near the live
2088 // sklearn 1.5.2 oracle. The rigorous oracle pin is the critic's to add.
2089 //
2090 // Oracle (live sklearn 1.5.2; values per R-CHAR-3 — NEVER copied from
2091 // ferrolearn):
2092 // python3 -c "import numpy as np; from sklearn.svm import LinearSVC; \
2093 // X=np.array([[1.,1.],[1.,2.],[2.,1.],[2.,2.],[8.,8.],[8.,9.],[9.,8.],[9.,9.]]); \
2094 // y=np.array([0,0,0,0,1,1,1,1]); \
2095 // m=LinearSVC(penalty='l1',loss='squared_hinge',dual=False,C=1.0, \
2096 // fit_intercept=True,max_iter=200000,tol=1e-10).fit(X,y); \
2097 // print(m.coef_.tolist(), m.intercept_.tolist())"
2098 // # coef [[0.1283185834966579, 0.12831858464059265]] int [-1.2079646017762715]
2099 const SK_COEF_0: f64 = 0.1283185834966579;
2100 const SK_COEF_1: f64 = 0.12831858464059265;
2101 const SK_INTERCEPT: f64 = -1.2079646017762715;
2102
2103 let x = array![
2104 [1.0, 1.0],
2105 [1.0, 2.0],
2106 [2.0, 1.0],
2107 [2.0, 2.0],
2108 [8.0, 8.0],
2109 [8.0, 9.0],
2110 [9.0, 8.0],
2111 [9.0, 9.0],
2112 ];
2113 let y = array![0usize, 0, 0, 0, 1, 1, 1, 1];
2114
2115 let result = LinearSVC::<f64>::new()
2116 .with_penalty(LinearSVCPenalty::L1)
2117 .with_loss(LinearSVCLoss::SquaredHinge)
2118 .with_dual(DualMode::False)
2119 .with_c(1.0)
2120 .with_max_iter(200_000)
2121 .with_tol(1e-10)
2122 .fit(&x, &y);
2123
2124 let Ok(fitted) = result else {
2125 assert!(false, "l1 fit must succeed");
2126 return;
2127 };
2128
2129 let coef = fitted.coefficients();
2130 assert!(
2131 (coef[0] - SK_COEF_0).abs() < 1e-2,
2132 "l1 coef[0] {} vs oracle {SK_COEF_0}",
2133 coef[0]
2134 );
2135 assert!(
2136 (coef[1] - SK_COEF_1).abs() < 1e-2,
2137 "l1 coef[1] {} vs oracle {SK_COEF_1}",
2138 coef[1]
2139 );
2140 assert!(
2141 (fitted.intercept() - SK_INTERCEPT).abs() < 1e-2,
2142 "l1 intercept {} vs oracle {SK_INTERCEPT}",
2143 fitted.intercept()
2144 );
2145 }
2146
2147 #[test]
2148 #[allow(
2149 clippy::assertions_on_constants,
2150 reason = "assert!(false) reports the unexpected-Err fit path without a gated panic!/expect"
2151 )]
2152 fn test_class_weight_smoke() {
2153 // Smoke check that `class_weight` (scaling `C` per class —
2154 // `compute_class_weight`, `_base.py:1179`; `weighted_C[i] = C·w[i]`,
2155 // `linear.cpp:2496-2507`) lands near the live sklearn 1.5.2 oracle. The
2156 // rigorous oracle pin is the critic's to add.
2157 //
2158 // Oracle (live sklearn 1.5.2; values per R-CHAR-3 — NEVER copied from
2159 // ferrolearn):
2160 // python3 -c "import numpy as np; from sklearn.svm import LinearSVC; \
2161 // X=np.array([[1.,1.],[1.,2.],[2.,1.],[2.,2.],[1.5,1.5],[2.,1.5],[8.,8.],[9.,9.]]); \
2162 // y=np.array([0,0,0,0,0,0,1,1]); \
2163 // for cw in (None,'balanced',{0:1,1:5}): \
2164 // m=LinearSVC(C=1.0,loss='squared_hinge',dual=True,fit_intercept=True, \
2165 // max_iter=200000,tol=1e-10,class_weight=cw).fit(X,y); \
2166 // print(cw, m.coef_.tolist(), m.intercept_.tolist())"
2167 // # None coef [[0.10056447415875154, 0.15957404219329038]] int [-1.263461307484969]
2168 // # balanced coef [[0.09936888940946959, 0.16666283617002833]] int [-1.2132032194327564]
2169 // # (compute_class_weight('balanced',...) = [0.6667, 2.0])
2170 // # {0:1,1:5} coef [[0.11058720549912869, 0.17164468739390437]] int [-1.2954689964246575]
2171 let x = array![
2172 [1.0, 1.0],
2173 [1.0, 2.0],
2174 [2.0, 1.0],
2175 [2.0, 2.0],
2176 [1.5, 1.5],
2177 [2.0, 1.5],
2178 [8.0, 8.0],
2179 [9.0, 9.0],
2180 ];
2181 let y = array![0usize, 0, 0, 0, 0, 0, 1, 1];
2182
2183 // (class_weight, expected coef_, expected intercept_) from the oracle.
2184 let cases: [(ClassWeight<f64>, [f64; 2], f64); 3] = [
2185 (
2186 ClassWeight::None,
2187 [0.100_564_474_158_751_54, 0.159_574_042_193_290_38],
2188 -1.263_461_307_484_969,
2189 ),
2190 (
2191 ClassWeight::Balanced,
2192 [0.099_368_889_409_469_59, 0.166_662_836_170_028_33],
2193 -1.213_203_219_432_756_4,
2194 ),
2195 (
2196 ClassWeight::Explicit(vec![(0, 1.0), (1, 5.0)]),
2197 [0.110_587_205_499_128_69, 0.171_644_687_393_904_37],
2198 -1.295_468_996_424_657_5,
2199 ),
2200 ];
2201
2202 for (cw, exp_coef, exp_int) in cases {
2203 let result = LinearSVC::<f64>::new()
2204 .with_loss(LinearSVCLoss::SquaredHinge)
2205 .with_dual(DualMode::True)
2206 .with_c(1.0)
2207 .with_class_weight(cw)
2208 .with_max_iter(200_000)
2209 .with_tol(1e-10)
2210 .fit(&x, &y);
2211
2212 let Ok(fitted) = result else {
2213 assert!(false, "class_weight fit must succeed");
2214 return;
2215 };
2216
2217 let coef = fitted.coefficients();
2218 assert!(
2219 (coef[0] - exp_coef[0]).abs() < 1e-2,
2220 "coef[0] {} vs oracle {}",
2221 coef[0],
2222 exp_coef[0]
2223 );
2224 assert!(
2225 (coef[1] - exp_coef[1]).abs() < 1e-2,
2226 "coef[1] {} vs oracle {}",
2227 coef[1],
2228 exp_coef[1]
2229 );
2230 assert!(
2231 (fitted.intercept() - exp_int).abs() < 1e-2,
2232 "intercept {} vs oracle {exp_int}",
2233 fitted.intercept()
2234 );
2235 }
2236 }
2237
2238 #[test]
2239 fn test_unsupported_combinations_rejected() {
2240 let x = array![[1.0, 1.0], [2.0, 2.0], [8.0, 8.0], [9.0, 9.0]];
2241 let y = array![0usize, 0, 1, 1];
2242
2243 // l1 + hinge is unsupported for any dual (`_base.py:1013` has no `l1`
2244 // under `hinge`).
2245 assert!(
2246 LinearSVC::<f64>::new()
2247 .with_penalty(LinearSVCPenalty::L1)
2248 .with_loss(LinearSVCLoss::Hinge)
2249 .fit(&x, &y)
2250 .is_err(),
2251 "l1 + hinge must be rejected"
2252 );
2253
2254 // l2 + hinge + dual=false is unsupported (`hinge: {l2: {dual=true: 3}}`,
2255 // no dual=false entry).
2256 assert!(
2257 LinearSVC::<f64>::new()
2258 .with_penalty(LinearSVCPenalty::L2)
2259 .with_loss(LinearSVCLoss::Hinge)
2260 .with_dual(DualMode::False)
2261 .fit(&x, &y)
2262 .is_err(),
2263 "l2 + hinge + dual=false must be rejected"
2264 );
2265
2266 // l1 + squared_hinge + dual=true is unsupported (`squared_hinge: {l1:
2267 // {dual=false: 5}}`, no dual=true entry).
2268 assert!(
2269 LinearSVC::<f64>::new()
2270 .with_penalty(LinearSVCPenalty::L1)
2271 .with_loss(LinearSVCLoss::SquaredHinge)
2272 .with_dual(DualMode::True)
2273 .fit(&x, &y)
2274 .is_err(),
2275 "l1 + squared_hinge + dual=true must be rejected"
2276 );
2277 }
2278
2279 #[test]
2280 fn test_dual_auto_resolution() {
2281 // n_samples (4) >= n_features (2): auto prefers dual=false. hinge+l2:
2282 // dual=false has no solver, so auto must fall back to dual=true
2283 // (type 3, `_classes.py:22-27`) — the fit succeeds rather than rejecting.
2284 let x = array![[1.0, 1.0], [2.0, 2.0], [8.0, 8.0], [9.0, 9.0]];
2285 let y = array![0usize, 0, 1, 1];
2286
2287 assert!(
2288 LinearSVC::<f64>::new()
2289 .with_loss(LinearSVCLoss::Hinge)
2290 .with_dual(DualMode::Auto)
2291 .fit(&x, &y)
2292 .is_ok(),
2293 "auto must fall back to dual=true for hinge+l2"
2294 );
2295 }
2296
2297 #[test]
2298 #[allow(
2299 clippy::assertions_on_constants,
2300 reason = "assert!(false) reports the unexpected-Err fit path without a gated panic!/expect"
2301 )]
2302 fn test_crammer_singer_smoke() {
2303 // Smoke check that the joint Crammer-Singer solver (solve_crammer_singer,
2304 // Solver_MCSVM_CS, `linear.cpp:493-787`, solver type 4, `_base.py:1017`)
2305 // lands near the live sklearn 1.5.2 oracle for BOTH multiclass and the
2306 // binary collapse (`_classes.py:340-344`). The rigorous oracle pin is the
2307 // critic's to add.
2308 //
2309 // Oracle (live sklearn 1.5.2; values per R-CHAR-3 — NEVER copied from
2310 // ferrolearn):
2311 // python3 -c "import numpy as np; from sklearn.svm import LinearSVC; \
2312 // X=np.array([[0,0],[0.5,0.2],[0.2,0.5],[1,1],[4,4],[4.5,4.2],[4.2,4.5],[5,5],\
2313 // [0,5],[0.5,5.2],[0.2,4.8],[1,6]],dtype=float); \
2314 // y=np.array([0,0,0,0,1,1,1,1,2,2,2,2]); \
2315 // m=LinearSVC(multi_class='crammer_singer',C=1.0,fit_intercept=True, \
2316 // max_iter=200000,tol=1e-10).fit(X,y); \
2317 // print(m.coef_.tolist(), m.intercept_.tolist(), m.predict(X).tolist())"
2318 // # coef [[-0.06761865903618029,-0.24341085269880958],
2319 // # [0.30047599619312043,0.021705426371714007],
2320 // # [-0.23285733712058276,0.22170542636345167]]
2321 // # int [0.9107847137145293,-0.622059023505724,-0.2887256901997209]
2322 // # pred [0,0,0,0,1,1,1,1,2,2,2,2]
2323 let x = array![
2324 [0.0, 0.0],
2325 [0.5, 0.2],
2326 [0.2, 0.5],
2327 [1.0, 1.0],
2328 [4.0, 4.0],
2329 [4.5, 4.2],
2330 [4.2, 4.5],
2331 [5.0, 5.0],
2332 [0.0, 5.0],
2333 [0.5, 5.2],
2334 [0.2, 4.8],
2335 [1.0, 6.0],
2336 ];
2337 let y = array![0usize, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2];
2338
2339 // Per-class oracle coef_/intercept_ (rows aligned to classes_ [0,1,2]).
2340 let exp_coef: [[f64; 2]; 3] = [
2341 [-0.067_618_659_036_180_29, -0.243_410_852_698_809_58],
2342 [0.300_475_996_193_120_43, 0.021_705_426_371_714_007],
2343 [-0.232_857_337_120_582_76, 0.221_705_426_363_451_67],
2344 ];
2345 let exp_int: [f64; 3] = [
2346 0.910_784_713_714_529_3,
2347 -0.622_059_023_505_724,
2348 -0.288_725_690_199_720_9,
2349 ];
2350
2351 let Ok(fitted) = LinearSVC::<f64>::new()
2352 .with_multi_class(MultiClass::CrammerSinger)
2353 .with_c(1.0)
2354 .with_max_iter(200_000)
2355 .with_tol(1e-10)
2356 .fit(&x, &y)
2357 else {
2358 assert!(false, "crammer_singer multiclass fit must succeed");
2359 return;
2360 };
2361
2362 assert!(!fitted.is_binary, "3-class CS must be multiclass");
2363 let rows = fitted.weight_vectors();
2364 let ints = fitted.intercepts();
2365 assert_eq!(rows.len(), 3);
2366 for m in 0..3 {
2367 assert!(
2368 (rows[m][0] - exp_coef[m][0]).abs() < 1e-2,
2369 "CS coef[{m}][0] {} vs oracle {}",
2370 rows[m][0],
2371 exp_coef[m][0]
2372 );
2373 assert!(
2374 (rows[m][1] - exp_coef[m][1]).abs() < 1e-2,
2375 "CS coef[{m}][1] {} vs oracle {}",
2376 rows[m][1],
2377 exp_coef[m][1]
2378 );
2379 assert!(
2380 (ints[m] - exp_int[m]).abs() < 1e-2,
2381 "CS intercept[{m}] {} vs oracle {}",
2382 ints[m],
2383 exp_int[m]
2384 );
2385 }
2386
2387 // predict is all-correct (argmax over the per-class scores).
2388 let Ok(preds) = fitted.predict(&x) else {
2389 assert!(false, "CS multiclass predict must succeed");
2390 return;
2391 };
2392 assert_eq!(
2393 preds.to_vec(),
2394 y.to_vec(),
2395 "CS multiclass predict must be all-correct"
2396 );
2397
2398 // Binary collapse (`_classes.py:340-344`): with 2 classes the joint solve
2399 // collapses to a SINGLE weight vector (`coef_` shape (1,2)) +
2400 // `intercept_ = int_1 - int_0`.
2401 // python3 -c "import numpy as np; from sklearn.svm import LinearSVC; \
2402 // X=np.array([[1,1],[1,2],[2,1],[2,2],[8,8],[8,9],[9,8],[9,9]],dtype=float); \
2403 // y=np.array([0,0,0,0,1,1,1,1]); \
2404 // m=LinearSVC(multi_class='crammer_singer',C=1.0,fit_intercept=True, \
2405 // max_iter=200000,tol=1e-10).fit(X,y); \
2406 // print(m.coef_.tolist(), m.intercept_.tolist())"
2407 // # coef [[0.15503875968992287,0.15503875968992295]] int [-1.4806201550387597]
2408 const BIN_COEF_0: f64 = 0.155_038_759_689_922_87;
2409 const BIN_COEF_1: f64 = 0.155_038_759_689_922_95;
2410 const BIN_INT: f64 = -1.480_620_155_038_759_7;
2411
2412 let xb = array![
2413 [1.0, 1.0],
2414 [1.0, 2.0],
2415 [2.0, 1.0],
2416 [2.0, 2.0],
2417 [8.0, 8.0],
2418 [8.0, 9.0],
2419 [9.0, 8.0],
2420 [9.0, 9.0],
2421 ];
2422 let yb = array![0usize, 0, 0, 0, 1, 1, 1, 1];
2423
2424 let Ok(fitted_b) = LinearSVC::<f64>::new()
2425 .with_multi_class(MultiClass::CrammerSinger)
2426 .with_c(1.0)
2427 .with_max_iter(200_000)
2428 .with_tol(1e-10)
2429 .fit(&xb, &yb)
2430 else {
2431 assert!(false, "crammer_singer binary fit must succeed");
2432 return;
2433 };
2434
2435 assert!(fitted_b.is_binary, "2-class CS must collapse to binary");
2436 let coef = fitted_b.coefficients();
2437 assert_eq!(coef.len(), 2, "binary collapse → single (1,2) weight row");
2438 assert!(
2439 (coef[0] - BIN_COEF_0).abs() < 1e-2,
2440 "CS binary coef[0] {} vs oracle {BIN_COEF_0}",
2441 coef[0]
2442 );
2443 assert!(
2444 (coef[1] - BIN_COEF_1).abs() < 1e-2,
2445 "CS binary coef[1] {} vs oracle {BIN_COEF_1}",
2446 coef[1]
2447 );
2448 assert!(
2449 (fitted_b.intercept() - BIN_INT).abs() < 1e-2,
2450 "CS binary intercept {} vs oracle {BIN_INT}",
2451 fitted_b.intercept()
2452 );
2453
2454 let Ok(preds_b) = fitted_b.predict(&xb) else {
2455 assert!(false, "CS binary predict must succeed");
2456 return;
2457 };
2458 assert_eq!(
2459 preds_b.to_vec(),
2460 yb.to_vec(),
2461 "CS binary predict all-correct"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_predict_feature_mismatch() {
2467 let x = Array2::from_shape_vec(
2468 (6, 2),
2469 vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
2470 )
2471 .unwrap();
2472 let y = array![0, 0, 0, 1, 1, 1];
2473
2474 let fitted = LinearSVC::<f64>::new()
2475 .with_max_iter(5000)
2476 .fit(&x, &y)
2477 .unwrap();
2478
2479 let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
2480 assert!(fitted.predict(&x_bad).is_err());
2481 }
2482}