Skip to main content

ferrolearn_linear/
linear_svr.rs

1//! Linear Support Vector Regressor.
2//!
3//! This module provides [`LinearSVR`], an optimized linear SVR that operates
4//! directly in the primal space without kernel overhead. It uses coordinate
5//! descent on the L2-regularized epsilon-insensitive or squared
6//! epsilon-insensitive loss.
7//!
8//! # Examples
9//!
10//! ```
11//! use ferrolearn_linear::linear_svr::LinearSVR;
12//! use ferrolearn_core::{Fit, Predict};
13//! use ndarray::{array, Array1, Array2};
14//!
15//! let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
16//! let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
17//!
18//! let model = LinearSVR::<f64>::new();
19//! let fitted = model.fit(&x, &y).unwrap();
20//! let preds = fitted.predict(&x).unwrap();
21//! assert_eq!(preds.len(), 5);
22//! ```
23
24use ferrolearn_core::error::FerroError;
25use ferrolearn_core::introspection::HasCoefficients;
26use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
27use ferrolearn_core::traits::{Fit, Predict};
28use ndarray::{Array1, Array2, ScalarOperand};
29use num_traits::Float;
30
31/// Loss function for [`LinearSVR`].
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum LinearSVRLoss {
34    /// Epsilon-insensitive loss: `max(0, |y - f(x)| - epsilon)`.
35    EpsilonInsensitive,
36    /// Squared epsilon-insensitive loss: `max(0, |y - f(x)| - epsilon)^2`.
37    SquaredEpsilonInsensitive,
38}
39
40/// Linear Support Vector Regressor (primal formulation).
41///
42/// Solves the L2-regularized epsilon-insensitive or squared
43/// epsilon-insensitive loss via coordinate descent in the primal.
44///
45/// # Type Parameters
46///
47/// - `F`: The floating-point type (`f32` or `f64`).
48#[derive(Debug, Clone)]
49pub struct LinearSVR<F> {
50    /// Inverse regularization strength.
51    pub c: F,
52    /// Width of the epsilon-insensitive tube.
53    pub epsilon: F,
54    /// Maximum number of coordinate descent iterations.
55    pub max_iter: usize,
56    /// Convergence tolerance on the change in weight vector.
57    pub tol: F,
58    /// Loss function to use.
59    pub loss: LinearSVRLoss,
60}
61
62impl<F: Float> LinearSVR<F> {
63    /// Create a new `LinearSVR` with default settings.
64    ///
65    /// Defaults: `C = 1.0`, `epsilon = 0.1`, `max_iter = 1000`,
66    /// `tol = 1e-4`, `loss = EpsilonInsensitive`.
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            c: F::one(),
71            epsilon: F::from(0.1).unwrap(),
72            max_iter: 1000,
73            tol: F::from(1e-4).unwrap(),
74            loss: LinearSVRLoss::EpsilonInsensitive,
75        }
76    }
77
78    /// Set the regularization parameter C.
79    #[must_use]
80    pub fn with_c(mut self, c: F) -> Self {
81        self.c = c;
82        self
83    }
84
85    /// Set the epsilon tube width.
86    #[must_use]
87    pub fn with_epsilon(mut self, epsilon: F) -> Self {
88        self.epsilon = epsilon;
89        self
90    }
91
92    /// Set the maximum number of iterations.
93    #[must_use]
94    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
95        self.max_iter = max_iter;
96        self
97    }
98
99    /// Set the convergence tolerance.
100    #[must_use]
101    pub fn with_tol(mut self, tol: F) -> Self {
102        self.tol = tol;
103        self
104    }
105
106    /// Set the loss function.
107    #[must_use]
108    pub fn with_loss(mut self, loss: LinearSVRLoss) -> Self {
109        self.loss = loss;
110        self
111    }
112}
113
114impl<F: Float> Default for LinearSVR<F> {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120/// Fitted Linear Support Vector Regressor.
121///
122/// Stores the learned coefficients and intercept.
123#[derive(Debug, Clone)]
124pub struct FittedLinearSVR<F> {
125    /// Learned coefficient vector (one per feature).
126    coefficients: Array1<F>,
127    /// Learned intercept (bias) term.
128    intercept: F,
129}
130
131impl<F: Float + Send + Sync + ScalarOperand + 'static> Fit<Array2<F>, Array1<F>>
132    for LinearSVR<F>
133{
134    type Fitted = FittedLinearSVR<F>;
135    type Error = FerroError;
136
137    /// Fit the linear SVR model using coordinate descent.
138    ///
139    /// # Errors
140    ///
141    /// - [`FerroError::ShapeMismatch`] — sample count mismatch.
142    /// - [`FerroError::InvalidParameter`] — `C` not positive or epsilon negative.
143    /// - [`FerroError::InsufficientSamples`] — no samples provided.
144    fn fit(
145        &self,
146        x: &Array2<F>,
147        y: &Array1<F>,
148    ) -> Result<FittedLinearSVR<F>, FerroError> {
149        let (n_samples, n_features) = x.dim();
150
151        if n_samples != y.len() {
152            return Err(FerroError::ShapeMismatch {
153                expected: vec![n_samples],
154                actual: vec![y.len()],
155                context: "y length must match number of samples in X".into(),
156            });
157        }
158
159        if n_samples == 0 {
160            return Err(FerroError::InsufficientSamples {
161                required: 1,
162                actual: 0,
163                context: "LinearSVR requires at least one sample".into(),
164            });
165        }
166
167        if self.c <= F::zero() {
168            return Err(FerroError::InvalidParameter {
169                name: "C".into(),
170                reason: "must be positive".into(),
171            });
172        }
173
174        if self.epsilon < F::zero() {
175            return Err(FerroError::InvalidParameter {
176                name: "epsilon".into(),
177                reason: "must be non-negative".into(),
178            });
179        }
180
181        let n_f = F::from(n_samples).unwrap();
182        let mut w = Array1::<F>::zeros(n_features);
183        let mut b = F::zero();
184        let step = F::from(0.01).unwrap();
185
186        for _iter in 0..self.max_iter {
187            let mut max_change = F::zero();
188
189            // Update each weight coordinate.
190            for j in 0..n_features {
191                let mut grad = w[j]; // regularization gradient
192                for i in 0..n_samples {
193                    let pred = x.row(i).dot(&w) + b;
194                    let residual = y[i] - pred;
195                    let abs_residual = residual.abs();
196
197                    if abs_residual > self.epsilon {
198                        match self.loss {
199                            LinearSVRLoss::EpsilonInsensitive => {
200                                let sign = if residual > F::zero() {
201                                    F::one()
202                                } else {
203                                    -F::one()
204                                };
205                                grad = grad - self.c / n_f * sign * x[[i, j]];
206                            }
207                            LinearSVRLoss::SquaredEpsilonInsensitive => {
208                                let two = F::from(2.0).unwrap();
209                                let sign = if residual > F::zero() {
210                                    F::one()
211                                } else {
212                                    -F::one()
213                                };
214                                grad = grad
215                                    - two * self.c / n_f
216                                        * (abs_residual - self.epsilon)
217                                        * sign
218                                        * x[[i, j]];
219                            }
220                        }
221                    }
222                }
223
224                let new_w = w[j] - step * grad;
225                let change = (new_w - w[j]).abs();
226                if change > max_change {
227                    max_change = change;
228                }
229                w[j] = new_w;
230            }
231
232            // Update intercept.
233            {
234                let mut grad_b = F::zero();
235                for i in 0..n_samples {
236                    let pred = x.row(i).dot(&w) + b;
237                    let residual = y[i] - pred;
238                    let abs_residual = residual.abs();
239
240                    if abs_residual > self.epsilon {
241                        match self.loss {
242                            LinearSVRLoss::EpsilonInsensitive => {
243                                let sign = if residual > F::zero() {
244                                    F::one()
245                                } else {
246                                    -F::one()
247                                };
248                                grad_b = grad_b - self.c / n_f * sign;
249                            }
250                            LinearSVRLoss::SquaredEpsilonInsensitive => {
251                                let two = F::from(2.0).unwrap();
252                                let sign = if residual > F::zero() {
253                                    F::one()
254                                } else {
255                                    -F::one()
256                                };
257                                grad_b = grad_b
258                                    - two * self.c / n_f * (abs_residual - self.epsilon) * sign;
259                            }
260                        }
261                    }
262                }
263                let new_b = b - step * grad_b;
264                let change = (new_b - b).abs();
265                if change > max_change {
266                    max_change = change;
267                }
268                b = new_b;
269            }
270
271            if max_change < self.tol {
272                break;
273            }
274        }
275
276        Ok(FittedLinearSVR {
277            coefficients: w,
278            intercept: b,
279        })
280    }
281}
282
283impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
284    for FittedLinearSVR<F>
285{
286    type Output = Array1<F>;
287    type Error = FerroError;
288
289    /// Predict target values for the given feature matrix.
290    ///
291    /// Computes `X @ coefficients + intercept`.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`FerroError::ShapeMismatch`] if the number of features
296    /// does not match the fitted model.
297    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
298        let n_features = x.ncols();
299        if n_features != self.coefficients.len() {
300            return Err(FerroError::ShapeMismatch {
301                expected: vec![self.coefficients.len()],
302                actual: vec![n_features],
303                context: "number of features must match fitted model".into(),
304            });
305        }
306
307        let preds = x.dot(&self.coefficients) + self.intercept;
308        Ok(preds)
309    }
310}
311
312impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
313    for FittedLinearSVR<F>
314{
315    fn coefficients(&self) -> &Array1<F> {
316        &self.coefficients
317    }
318
319    fn intercept(&self) -> F {
320        self.intercept
321    }
322}
323
324// Pipeline integration.
325impl<F> PipelineEstimator<F> for LinearSVR<F>
326where
327    F: Float + ScalarOperand + Send + Sync + 'static,
328{
329    fn fit_pipeline(
330        &self,
331        x: &Array2<F>,
332        y: &Array1<F>,
333    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
334        let fitted = self.fit(x, y)?;
335        Ok(Box::new(fitted))
336    }
337}
338
339impl<F> FittedPipelineEstimator<F> for FittedLinearSVR<F>
340where
341    F: Float + ScalarOperand + Send + Sync + 'static,
342{
343    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
344        self.predict(x)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use approx::assert_relative_eq;
352    use ndarray::array;
353
354    #[test]
355    fn test_default_constructor() {
356        let m = LinearSVR::<f64>::new();
357        assert_eq!(m.max_iter, 1000);
358        assert!(m.c == 1.0);
359        assert_relative_eq!(m.epsilon, 0.1);
360        assert_eq!(m.loss, LinearSVRLoss::EpsilonInsensitive);
361    }
362
363    #[test]
364    fn test_builder_setters() {
365        let m = LinearSVR::<f64>::new()
366            .with_c(10.0)
367            .with_epsilon(0.5)
368            .with_max_iter(500)
369            .with_tol(1e-6)
370            .with_loss(LinearSVRLoss::SquaredEpsilonInsensitive);
371        assert!(m.c == 10.0);
372        assert_relative_eq!(m.epsilon, 0.5);
373        assert_eq!(m.max_iter, 500);
374        assert_eq!(m.loss, LinearSVRLoss::SquaredEpsilonInsensitive);
375    }
376
377    #[test]
378    fn test_fits_linear_data() {
379        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
380        let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
381
382        let model = LinearSVR::<f64>::new()
383            .with_c(10.0)
384            .with_epsilon(0.0)
385            .with_max_iter(10000);
386        let fitted = model.fit(&x, &y).unwrap();
387        let preds = fitted.predict(&x).unwrap();
388
389        // Should roughly recover y = 2x.
390        for (p, &t) in preds.iter().zip(y.iter()) {
391            assert!((p - t).abs() < 3.0, "prediction {p} too far from target {t}");
392        }
393    }
394
395    #[test]
396    fn test_squared_epsilon_insensitive() {
397        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
398        let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
399
400        let model = LinearSVR::<f64>::new()
401            .with_c(10.0)
402            .with_epsilon(0.0)
403            .with_loss(LinearSVRLoss::SquaredEpsilonInsensitive)
404            .with_max_iter(10000);
405        let fitted = model.fit(&x, &y).unwrap();
406        let preds = fitted.predict(&x).unwrap();
407        assert_eq!(preds.len(), 5);
408    }
409
410    #[test]
411    fn test_shape_mismatch() {
412        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
413        let y = array![1.0, 2.0];
414
415        let model = LinearSVR::<f64>::new();
416        assert!(model.fit(&x, &y).is_err());
417    }
418
419    #[test]
420    fn test_invalid_c() {
421        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
422        let y = array![1.0, 2.0, 3.0];
423
424        let model = LinearSVR::<f64>::new().with_c(0.0);
425        assert!(model.fit(&x, &y).is_err());
426    }
427
428    #[test]
429    fn test_negative_epsilon() {
430        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
431        let y = array![1.0, 2.0, 3.0];
432
433        let model = LinearSVR::<f64>::new().with_epsilon(-0.1);
434        assert!(model.fit(&x, &y).is_err());
435    }
436
437    #[test]
438    fn test_predict_feature_mismatch() {
439        let x = Array2::from_shape_vec((3, 2), vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0]).unwrap();
440        let y = array![1.0, 2.0, 3.0];
441
442        let fitted = LinearSVR::<f64>::new()
443            .with_max_iter(5000)
444            .fit(&x, &y)
445            .unwrap();
446
447        let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
448        assert!(fitted.predict(&x_bad).is_err());
449    }
450
451    #[test]
452    fn test_has_coefficients() {
453        let x = Array2::from_shape_vec((4, 2), vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0, 4.0, 0.0])
454            .unwrap();
455        let y = array![1.0, 2.0, 3.0, 4.0];
456
457        let fitted = LinearSVR::<f64>::new()
458            .with_max_iter(5000)
459            .fit(&x, &y)
460            .unwrap();
461        assert_eq!(fitted.coefficients().len(), 2);
462    }
463
464    #[test]
465    fn test_pipeline_integration() {
466        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
467        let y = array![3.0, 5.0, 7.0, 9.0];
468
469        let model = LinearSVR::<f64>::new().with_max_iter(5000);
470        let fitted_pipe = model.fit_pipeline(&x, &y).unwrap();
471        let preds = fitted_pipe.predict_pipeline(&x).unwrap();
472        assert_eq!(preds.len(), 4);
473    }
474}