1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum LinearSVRLoss {
34 EpsilonInsensitive,
36 SquaredEpsilonInsensitive,
38}
39
40#[derive(Debug, Clone)]
49pub struct LinearSVR<F> {
50 pub c: F,
52 pub epsilon: F,
54 pub max_iter: usize,
56 pub tol: F,
58 pub loss: LinearSVRLoss,
60}
61
62impl<F: Float> LinearSVR<F> {
63 #[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 #[must_use]
80 pub fn with_c(mut self, c: F) -> Self {
81 self.c = c;
82 self
83 }
84
85 #[must_use]
87 pub fn with_epsilon(mut self, epsilon: F) -> Self {
88 self.epsilon = epsilon;
89 self
90 }
91
92 #[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 #[must_use]
101 pub fn with_tol(mut self, tol: F) -> Self {
102 self.tol = tol;
103 self
104 }
105
106 #[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#[derive(Debug, Clone)]
124pub struct FittedLinearSVR<F> {
125 coefficients: Array1<F>,
127 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 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 for j in 0..n_features {
191 let mut grad = w[j]; 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 {
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 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
324impl<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 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}