Skip to main content

sioul_learn/
svm.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A linear SVM: L2-regularized, squared hinge loss, solved in the dual by
5//! coordinate descent with shrinking (Hsieh, Chang, Lin, Keerthi &
6//! Sundararajan, "A dual coordinate descent method for large-scale linear
7//! SVM", ICML 2008: liblinear's solver for `-s 1`).
8//!
9//! The primal it solves, with `x̃ = (x, bias)` and `w̃ = (w, w_b)`:
10//! `min ½‖w̃‖² + Σ Cᵢ max(0, 1 − yᵢ w̃·x̃ᵢ)²`, where `Cᵢ` is `C` times the
11//! weight of the message's class (ham weighs more: calling ham spam costs
12//! more). The bias is a feature of its own and is regularized, as liblinear's
13//! `-B`. Rows are dense `f32` (the standardized message vector and header
14//! features); every sum is `f64`.
15
16use crate::Rng;
17
18/// Labels: spam is +1, ham −1.
19pub const SPAM: i8 = 1;
20pub const HAM: i8 = -1;
21
22/// The rows to learn from.
23#[derive(Debug, Clone, Copy)]
24pub struct Problem<'a> {
25    /// `y.len()` rows of `dim` values each, one after the other.
26    pub x: &'a [f32],
27    pub dim: usize,
28    /// `SPAM` or `HAM`, one per row.
29    pub y: &'a [i8],
30}
31
32impl Problem<'_> {
33    pub fn rows(&self) -> usize {
34        self.y.len()
35    }
36
37    pub fn row(&self, i: usize) -> &[f32] {
38        &self.x[i * self.dim..(i + 1) * self.dim]
39    }
40}
41
42/// How the SVM learns.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct Params {
45    /// The cost of a margin error: lower is smoother (and steadier when labels are noisy).
46    pub c: f64,
47    /// `C` is multiplied by this for ham (5: calling ham spam costs five times more).
48    pub ham_weight: f64,
49    /// … and by this for spam.
50    pub spam_weight: f64,
51    /// The bias feature's value (liblinear's `-B`); 0 for no bias.
52    pub bias: f64,
53    /// Stops when the projected gradient's spread falls under this (liblinear: 0.1).
54    pub eps: f64,
55    /// Passes over the data, at most.
56    pub max_passes: usize,
57    /// For the order rows are visited in: the same seed, the same model.
58    pub seed: u64,
59}
60
61impl Default for Params {
62    fn default() -> Self {
63        Params { c: 1.0, ham_weight: 5.0, spam_weight: 1.0, bias: 1.0, eps: 0.01, max_passes: 1000, seed: 1 }
64    }
65}
66
67/// A learned linear SVM: `decision(x) = w·x + b`, positive for spam.
68#[derive(Debug, Clone, PartialEq)]
69pub struct Model {
70    pub w: Vec<f64>,
71    /// The bias feature's weight times its value.
72    pub b: f64,
73    /// Passes made over the data.
74    pub passes: usize,
75    /// The stopping rule was met before `max_passes`.
76    pub converged: bool,
77}
78
79impl Model {
80    pub fn decision(&self, row: &[f32]) -> f64 {
81        dot(&self.w, row) + self.b
82    }
83}
84
85/// Learning was stopped from outside.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct Cancelled;
88
89/// Learns `problem`'s SVM. `cancelled` is asked once per pass.
90pub fn train(problem: &Problem, params: &Params, cancelled: &dyn Fn() -> bool) -> Result<Model, Cancelled> {
91    let n = problem.rows();
92    let dim = problem.dim;
93    let mut w = vec![0.0f64; dim];
94    let mut w_bias = 0.0f64;
95    let mut alpha = vec![0.0f64; n];
96    // ½ / Cᵢ: the squared hinge puts it on the dual's diagonal; no upper bound on αᵢ.
97    let diag = |i: usize| {
98        let weight = if problem.y[i] == SPAM { params.spam_weight } else { params.ham_weight };
99        0.5 / (params.c * weight)
100    };
101    let qd: Vec<f64> = (0..n)
102        .map(|i| {
103            let row = problem.row(i);
104            row.iter().map(|&v| f64::from(v) * f64::from(v)).sum::<f64>() + params.bias * params.bias + diag(i)
105        })
106        .collect();
107    let mut index: Vec<usize> = (0..n).collect();
108    let mut active = n;
109    let mut rng = Rng::new(params.seed);
110    // Shrinking needs only the upper end: αᵢ has no upper bound with the squared hinge.
111    let mut pg_max_old = f64::INFINITY;
112    let mut passes = 0;
113    let mut converged = false;
114    while passes < params.max_passes {
115        if cancelled() {
116            return Err(Cancelled);
117        }
118        let (mut pg_max, mut pg_min) = (f64::NEG_INFINITY, f64::INFINITY);
119        for i in 0..active {
120            let j = i + rng.below(active - i);
121            index.swap(i, j);
122        }
123        let mut s = 0;
124        while s < active {
125            let i = index[s];
126            let yi = f64::from(problem.y[i]);
127            let row = problem.row(i);
128            let g = yi * (dot(&w, row) + w_bias * params.bias) - 1.0 + alpha[i] * diag(i);
129            let mut pg = 0.0;
130            if alpha[i] == 0.0 {
131                if g > pg_max_old {
132                    // Shrunk: at its bound and pushing against it; looked at again at the end.
133                    active -= 1;
134                    index.swap(s, active);
135                    continue;
136                } else if g < 0.0 {
137                    pg = g;
138                }
139            } else {
140                pg = g;
141            }
142            pg_max = pg_max.max(pg);
143            pg_min = pg_min.min(pg);
144            if pg.abs() > 1e-12 {
145                let old = alpha[i];
146                alpha[i] = (old - g / qd[i]).max(0.0);
147                let step = (alpha[i] - old) * yi;
148                for (wk, &xk) in w.iter_mut().zip(row) {
149                    *wk += step * f64::from(xk);
150                }
151                w_bias += step * params.bias;
152            }
153            s += 1;
154        }
155        passes += 1;
156        if pg_max - pg_min <= params.eps {
157            if active == n {
158                converged = true;
159                break;
160            }
161            // Optimal on what is left: every row again, to be sure.
162            active = n;
163            pg_max_old = f64::INFINITY;
164            continue;
165        }
166        pg_max_old = if pg_max <= 0.0 { f64::INFINITY } else { pg_max };
167    }
168    Ok(Model { w, b: w_bias * params.bias, passes, converged })
169}
170
171fn dot(w: &[f64], row: &[f32]) -> f64 {
172    w.iter().zip(row).map(|(&a, &b)| a * f64::from(b)).sum()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn exact() -> Params {
180        Params { eps: 1e-10, max_passes: 100_000, ..Params::default() }
181    }
182
183    /// Solved by hand: two points, no bias, `min ½w² + Σ Cᵢ(1 − w)²`.
184    #[test]
185    fn two_points_without_bias() {
186        let x = [1.0f32, -1.0];
187        let y = [SPAM, HAM];
188        let problem = Problem { x: &x, dim: 1, y: &y };
189        // Equal weights: ½w² + 2(1 − w)² is least at w = 4/5.
190        let even = Params { bias: 0.0, ham_weight: 1.0, ..exact() };
191        let model = train(&problem, &even, &|| false).unwrap();
192        assert!((model.w[0] - 0.8).abs() < 1e-9, "{model:?}");
193        assert_eq!(model.b, 0.0);
194        // Ham weighs 5: ½w² + (1 − w)² + 5(1 − w)² is least at w = 12/13.
195        let weighted = Params { bias: 0.0, ..exact() };
196        let model = train(&problem, &weighted, &|| false).unwrap();
197        assert!((model.w[0] - 12.0 / 13.0).abs() < 1e-9, "{model:?}");
198        assert!(model.converged);
199    }
200
201    /// Solved by hand, with the regularized bias: x = 2 is spam, x = 0 ham,
202    /// C = 1 for both; then x = 3, spam beyond the margin, changes nothing.
203    /// ½(w² + b²) + (1 − 2w − b)² + (1 + b)²: w = 20/29, b = −16/29.
204    #[test]
205    fn three_points_with_bias() {
206        let even = Params { ham_weight: 1.0, ..exact() };
207        for (x, y) in [(vec![2.0f32, 0.0], vec![SPAM, HAM]), (vec![2.0f32, 0.0, 3.0], vec![SPAM, HAM, SPAM])] {
208            let problem = Problem { x: &x, dim: 1, y: &y };
209            let model = train(&problem, &even, &|| false).unwrap();
210            assert!((model.w[0] - 20.0 / 29.0).abs() < 1e-9, "{model:?}");
211            assert!((model.b + 16.0 / 29.0).abs() < 1e-9, "{model:?}");
212            assert!((model.decision(&[2.0]) - 24.0 / 29.0).abs() < 1e-9);
213            assert!(model.decision(&[3.0]) > 1.0, "beyond the margin: no weight of its own");
214        }
215    }
216
217    /// Separable points in two dimensions: every one on its side.
218    #[test]
219    fn separates_what_can_be() {
220        let mut rng = Rng::new(7);
221        let (mut x, mut y) = (Vec::new(), Vec::new());
222        for i in 0..400 {
223            let spam = i % 2 == 0;
224            let centre = if spam { 2.0 } else { -2.0 };
225            x.push((centre + rng.unit() - 0.5) as f32);
226            x.push((rng.unit() * 4.0 - 2.0) as f32);
227            y.push(if spam { SPAM } else { HAM });
228        }
229        let problem = Problem { x: &x, dim: 2, y: &y };
230        let model = train(&problem, &Params::default(), &|| false).unwrap();
231        for i in 0..problem.rows() {
232            assert_eq!(model.decision(problem.row(i)) > 0.0, y[i] == SPAM, "row {i}");
233        }
234        // The same seed, the same model.
235        assert_eq!(train(&problem, &Params::default(), &|| false).unwrap(), model);
236    }
237
238    #[test]
239    fn stops_when_asked() {
240        let x = [1.0f32, -1.0];
241        let y = [SPAM, HAM];
242        let problem = Problem { x: &x, dim: 1, y: &y };
243        assert_eq!(train(&problem, &Params::default(), &|| true), Err(Cancelled));
244    }
245}