Skip to main content

sklears_svm/
ls_svm.rs

1//! Least Squares Support Vector Machines (LS-SVM)
2//!
3//! This module implements Least Squares Support Vector Machines, which use equality
4//! constraints instead of inequality constraints, leading to solving a linear system
5//! instead of a quadratic programming problem. This makes LS-SVM computationally
6//! more efficient for many problems.
7
8use crate::kernels::{Kernel, KernelType};
9use scirs2_core::ndarray::{Array1, Array2};
10use scirs2_linalg::compat::ArrayLinalgExt;
11// Removed SVD import - using ArrayLinalgExt for both solve and svd methods
12use sklears_core::{
13    error::{Result, SklearsError},
14    traits::{Fit, Predict, Trained, Untrained},
15    types::Float,
16};
17use std::marker::PhantomData;
18
19/// Configuration for Least Squares SVM
20#[derive(Debug, Clone)]
21pub struct LSVMConfig {
22    /// Regularization parameter (gamma)
23    pub gamma: Float,
24    /// Kernel function to use
25    pub kernel: KernelType,
26    /// Whether to fit an intercept
27    pub fit_intercept: bool,
28    /// Tolerance for numerical stability
29    pub tol: Float,
30    /// Whether to use regularized kernel matrix (add regularization to diagonal)
31    pub regularized_kernel: bool,
32}
33
34impl Default for LSVMConfig {
35    fn default() -> Self {
36        Self {
37            gamma: 1.0,
38            kernel: KernelType::Rbf { gamma: 1.0 },
39            fit_intercept: true,
40            tol: 1e-12,
41            regularized_kernel: true,
42        }
43    }
44}
45
46/// Least Squares Support Vector Machine for classification and regression
47#[derive(Debug)]
48pub struct LSSVM<State = Untrained> {
49    config: LSVMConfig,
50    state: PhantomData<State>,
51    // Fitted attributes
52    support_vectors_: Option<Array2<Float>>,
53    alpha_: Option<Array1<Float>>,
54    intercept_: Option<Float>,
55    training_labels_: Option<Array1<Float>>,
56    n_features_in_: Option<usize>,
57}
58
59impl LSSVM<Untrained> {
60    /// Create a new LS-SVM
61    pub fn new() -> Self {
62        Self {
63            config: LSVMConfig::default(),
64            state: PhantomData,
65            support_vectors_: None,
66            alpha_: None,
67            intercept_: None,
68            training_labels_: None,
69            n_features_in_: None,
70        }
71    }
72
73    /// Set the regularization parameter gamma
74    pub fn gamma(mut self, gamma: Float) -> Self {
75        self.config.gamma = gamma;
76        self
77    }
78
79    /// Set the kernel type
80    pub fn kernel(mut self, kernel: KernelType) -> Self {
81        self.config.kernel = kernel;
82        self
83    }
84
85    /// Set whether to fit an intercept
86    pub fn fit_intercept(mut self, fit_intercept: bool) -> Self {
87        self.config.fit_intercept = fit_intercept;
88        self
89    }
90
91    /// Set the tolerance for numerical stability
92    pub fn tol(mut self, tol: Float) -> Self {
93        self.config.tol = tol;
94        self
95    }
96
97    /// Set whether to use regularized kernel matrix
98    pub fn regularized_kernel(mut self, regularized: bool) -> Self {
99        self.config.regularized_kernel = regularized;
100        self
101    }
102}
103
104impl Default for LSSVM<Untrained> {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl Fit<Array2<Float>, Array1<Float>> for LSSVM<Untrained> {
111    type Fitted = LSSVM<Trained>;
112
113    fn fit(self, x: &Array2<Float>, y: &Array1<Float>) -> Result<Self::Fitted> {
114        let n_samples = x.nrows();
115        let n_features = x.ncols();
116
117        if n_samples == 0 {
118            return Err(SklearsError::InvalidInput("Empty dataset".to_string()));
119        }
120
121        if n_samples != y.len() {
122            return Err(SklearsError::InvalidInput(
123                "Shape mismatch: X and y must have the same number of samples".to_string(),
124            ));
125        }
126
127        // Create kernel instance
128        let kernel = match &self.config.kernel {
129            KernelType::Linear => Box::new(crate::kernels::LinearKernel) as Box<dyn Kernel>,
130            KernelType::Rbf { gamma } => {
131                Box::new(crate::kernels::RbfKernel::new(*gamma)) as Box<dyn Kernel>
132            }
133            _ => Box::new(crate::kernels::RbfKernel::new(1.0)) as Box<dyn Kernel>, // Default fallback
134        };
135
136        // Compute kernel matrix
137        let mut k_matrix = Array2::zeros((n_samples, n_samples));
138        for i in 0..n_samples {
139            for j in 0..n_samples {
140                let k_val = kernel.compute(x.row(i), x.row(j));
141                k_matrix[[i, j]] = k_val;
142            }
143        }
144
145        // Add regularization to diagonal if requested
146        if self.config.regularized_kernel {
147            for i in 0..n_samples {
148                k_matrix[[i, i]] += 1.0 / self.config.gamma;
149            }
150        }
151
152        // Solve the LS-SVM system
153        let (alpha, intercept) = if self.config.fit_intercept {
154            self.solve_with_intercept(&k_matrix, y)?
155        } else {
156            let alpha = self.solve_without_intercept(&k_matrix, y)?;
157            (alpha, 0.0)
158        };
159
160        Ok(LSSVM {
161            config: self.config,
162            state: PhantomData,
163            support_vectors_: Some(x.clone()),
164            alpha_: Some(alpha),
165            intercept_: Some(intercept),
166            training_labels_: Some(y.clone()),
167            n_features_in_: Some(n_features),
168        })
169    }
170}
171
172impl LSSVM<Untrained> {
173    /// Solve LS-SVM system with intercept
174    fn solve_with_intercept(
175        &self,
176        k_matrix: &Array2<Float>,
177        y: &Array1<Float>,
178    ) -> Result<(Array1<Float>, Float)> {
179        let n = k_matrix.nrows();
180
181        // Create augmented system: [K + I/gamma  1; 1^T  0] [alpha; b] = [y; 0]
182        let mut a_matrix = Array2::zeros((n + 1, n + 1));
183        let mut b_vector = Array1::zeros(n + 1);
184
185        // Fill K matrix part
186        for i in 0..n {
187            for j in 0..n {
188                a_matrix[[i, j]] = k_matrix[[i, j]];
189            }
190        }
191
192        // Add regularization to diagonal
193        if !self.config.regularized_kernel {
194            for i in 0..n {
195                a_matrix[[i, i]] += 1.0 / self.config.gamma;
196            }
197        }
198
199        // Add ones vector for intercept
200        for i in 0..n {
201            a_matrix[[i, n]] = 1.0;
202            a_matrix[[n, i]] = 1.0;
203        }
204
205        // Fill b vector
206        for i in 0..n {
207            b_vector[i] = y[i];
208        }
209
210        // Solve the linear system using scirs2-linalg
211        let solution = a_matrix.solve(&b_vector).map_err(|e| {
212            SklearsError::NumericalError(format!("Failed to solve LS-SVM linear system: {}", e))
213        })?;
214
215        let alpha = solution.slice(scirs2_core::ndarray::s![..n]).to_owned();
216        let intercept = solution[n];
217
218        Ok((alpha, intercept))
219    }
220
221    /// Solve LS-SVM system without intercept
222    fn solve_without_intercept(
223        &self,
224        k_matrix: &Array2<Float>,
225        y: &Array1<Float>,
226    ) -> Result<Array1<Float>> {
227        let n = k_matrix.nrows();
228        let mut a_matrix = k_matrix.clone();
229
230        // Add regularization to diagonal if not already done
231        if !self.config.regularized_kernel {
232            for i in 0..n {
233                a_matrix[[i, i]] += 1.0 / self.config.gamma;
234            }
235        }
236
237        // Solve the linear system using scirs2-linalg
238        let alpha = a_matrix.solve(y).map_err(|e| {
239            SklearsError::NumericalError(format!("Failed to solve LS-SVM linear system: {}", e))
240        })?;
241
242        Ok(alpha)
243    }
244}
245
246impl Predict<Array2<Float>, Array1<Float>> for LSSVM<Trained> {
247    fn predict(&self, x: &Array2<Float>) -> Result<Array1<Float>> {
248        if x.ncols()
249            != self
250                .n_features_in_
251                .expect("n_features_in_ not available - model not fitted")
252        {
253            return Err(SklearsError::InvalidInput(
254                "Feature mismatch: X has different number of features than training data"
255                    .to_string(),
256            ));
257        }
258
259        let support_vectors = self
260            .support_vectors_
261            .as_ref()
262            .expect("support_vectors_ not available - model not fitted");
263        let alpha = self
264            .alpha_
265            .as_ref()
266            .expect("alpha_ not available - model not fitted");
267        let intercept = self
268            .intercept_
269            .expect("intercept_ not available - model not fitted");
270        let kernel = match &self.config.kernel {
271            KernelType::Linear => Box::new(crate::kernels::LinearKernel) as Box<dyn Kernel>,
272            KernelType::Rbf { gamma } => {
273                Box::new(crate::kernels::RbfKernel::new(*gamma)) as Box<dyn Kernel>
274            }
275            _ => Box::new(crate::kernels::RbfKernel::new(1.0)) as Box<dyn Kernel>, // Default fallback
276        };
277
278        let mut predictions = Array1::zeros(x.nrows());
279
280        for i in 0..x.nrows() {
281            let mut prediction = if self.config.fit_intercept {
282                intercept
283            } else {
284                0.0
285            };
286
287            for j in 0..support_vectors.nrows() {
288                let k_val = kernel.compute(x.row(i), support_vectors.row(j));
289                prediction += alpha[j] * k_val;
290            }
291
292            predictions[i] = prediction;
293        }
294
295        Ok(predictions)
296    }
297}
298
299impl LSSVM<Trained> {
300    /// Get the support vectors (training data for LS-SVM)
301    pub fn support_vectors(&self) -> &Array2<Float> {
302        self.support_vectors_
303            .as_ref()
304            .expect("support_vectors_ not available - model not fitted")
305    }
306
307    /// Get the alpha coefficients
308    pub fn alpha(&self) -> &Array1<Float> {
309        self.alpha_
310            .as_ref()
311            .expect("alpha_ not available - model not fitted")
312    }
313
314    /// Get the intercept term
315    pub fn intercept(&self) -> Float {
316        self.intercept_
317            .expect("intercept_ not available - model not fitted")
318    }
319
320    /// Get the training labels
321    pub fn training_labels(&self) -> &Array1<Float> {
322        self.training_labels_
323            .as_ref()
324            .expect("training_labels_ not available - model not fitted")
325    }
326
327    /// Get the number of features
328    pub fn n_features_in(&self) -> usize {
329        self.n_features_in_
330            .expect("n_features_in_ not available - model not fitted")
331    }
332
333    /// Compute the decision function values
334    pub fn decision_function(&self, x: &Array2<Float>) -> Result<Array1<Float>> {
335        self.predict(x)
336    }
337
338    /// Get the kernel matrix for the training data
339    pub fn kernel_matrix(&self) -> Result<Array2<Float>> {
340        let support_vectors = self.support_vectors();
341        let n_samples = support_vectors.nrows();
342        let kernel = match &self.config.kernel {
343            KernelType::Linear => Box::new(crate::kernels::LinearKernel) as Box<dyn Kernel>,
344            KernelType::Rbf { gamma } => {
345                Box::new(crate::kernels::RbfKernel::new(*gamma)) as Box<dyn Kernel>
346            }
347            _ => Box::new(crate::kernels::RbfKernel::new(1.0)) as Box<dyn Kernel>, // Default fallback
348        };
349
350        let mut k_matrix = Array2::zeros((n_samples, n_samples));
351        for i in 0..n_samples {
352            for j in 0..n_samples {
353                let k_val = kernel.compute(support_vectors.row(i), support_vectors.row(j));
354                k_matrix[[i, j]] = k_val;
355            }
356        }
357
358        Ok(k_matrix)
359    }
360}
361
362/// LS-SVM Classifier wrapper for binary classification
363#[derive(Debug)]
364pub struct LSVMClassifier<State = Untrained> {
365    lssvm: LSSVM<State>,
366}
367
368impl LSVMClassifier<Untrained> {
369    /// Create a new LS-SVM classifier
370    pub fn new() -> Self {
371        Self {
372            lssvm: LSSVM::new(),
373        }
374    }
375
376    /// Set the regularization parameter gamma
377    pub fn gamma(mut self, gamma: Float) -> Self {
378        self.lssvm = self.lssvm.gamma(gamma);
379        self
380    }
381
382    /// Set the kernel type
383    pub fn kernel(mut self, kernel: KernelType) -> Self {
384        self.lssvm = self.lssvm.kernel(kernel);
385        self
386    }
387
388    /// Set whether to fit an intercept
389    pub fn fit_intercept(mut self, fit_intercept: bool) -> Self {
390        self.lssvm = self.lssvm.fit_intercept(fit_intercept);
391        self
392    }
393}
394
395impl Default for LSVMClassifier<Untrained> {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401impl Fit<Array2<Float>, Array1<i32>> for LSVMClassifier<Untrained> {
402    type Fitted = LSVMClassifier<Trained>;
403
404    fn fit(self, x: &Array2<Float>, y: &Array1<i32>) -> Result<Self::Fitted> {
405        // Convert labels to {-1, +1}
406        let unique_classes: Vec<i32> = {
407            let mut classes = y.to_vec();
408            classes.sort_unstable();
409            classes.dedup();
410            classes
411        };
412
413        if unique_classes.len() != 2 {
414            return Err(SklearsError::InvalidInput(
415                "LS-SVM classifier currently supports only binary classification".to_string(),
416            ));
417        }
418
419        let y_float = Array1::from_vec(
420            y.iter()
421                .map(|&label| {
422                    if label == unique_classes[0] {
423                        -1.0
424                    } else {
425                        1.0
426                    }
427                })
428                .collect(),
429        );
430
431        let fitted_lssvm = self.lssvm.fit(x, &y_float)?;
432
433        Ok(LSVMClassifier {
434            lssvm: fitted_lssvm,
435        })
436    }
437}
438
439impl Predict<Array2<Float>, Array1<i32>> for LSVMClassifier<Trained> {
440    fn predict(&self, x: &Array2<Float>) -> Result<Array1<i32>> {
441        let decision_values = self.lssvm.predict(x)?;
442
443        let predictions = Array1::from_vec(
444            decision_values
445                .iter()
446                .map(|&val| if val >= 0.0 { 1 } else { 0 })
447                .collect(),
448        );
449
450        Ok(predictions)
451    }
452}
453
454impl LSVMClassifier<Trained> {
455    /// Get the decision function values
456    pub fn decision_function(&self, x: &Array2<Float>) -> Result<Array1<Float>> {
457        self.lssvm.decision_function(x)
458    }
459
460    /// Get the underlying LS-SVM model
461    pub fn lssvm(&self) -> &LSSVM<Trained> {
462        &self.lssvm
463    }
464}
465
466#[allow(non_snake_case)]
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use scirs2_core::ndarray::array;
471
472    #[test]
473    fn test_lssvm_creation() {
474        let lssvm = LSSVM::new()
475            .gamma(2.0)
476            .kernel(KernelType::Linear)
477            .fit_intercept(false)
478            .tol(1e-10)
479            .regularized_kernel(false);
480
481        assert_eq!(lssvm.config.gamma, 2.0);
482        assert_eq!(lssvm.config.kernel, KernelType::Linear);
483        assert!(!lssvm.config.fit_intercept);
484        assert_eq!(lssvm.config.tol, 1e-10);
485        assert!(!lssvm.config.regularized_kernel);
486    }
487
488    #[test]
489    fn test_lssvm_classifier_creation() {
490        let classifier = LSVMClassifier::new()
491            .gamma(1.5)
492            .kernel(KernelType::Rbf { gamma: 0.5 })
493            .fit_intercept(true);
494
495        assert_eq!(classifier.lssvm.config.gamma, 1.5);
496        assert_eq!(
497            classifier.lssvm.config.kernel,
498            KernelType::Rbf { gamma: 0.5 }
499        );
500        assert!(classifier.lssvm.config.fit_intercept);
501    }
502
503    #[test]
504    #[ignore = "Slow test: trains LS-SVM. Run with --ignored flag"]
505    fn test_lssvm_regression() {
506        let x = array![[1.0], [2.0], [3.0], [4.0], [5.0],];
507        let y = array![2.0, 4.0, 6.0, 8.0, 10.0]; // y = 2*x
508
509        let lssvm = LSSVM::new()
510            .gamma(1.0)
511            .kernel(KernelType::Linear)
512            .fit_intercept(true);
513
514        let fitted_model = lssvm.fit(&x, &y).expect("model fitting should succeed");
515
516        assert_eq!(fitted_model.n_features_in(), 1);
517
518        let predictions = fitted_model.predict(&x).expect("prediction should succeed");
519        assert_eq!(predictions.len(), 5);
520
521        // Predictions should be close to the linear relationship
522        for (i, &pred) in predictions.iter().enumerate() {
523            let expected = 2.0 * (i + 1) as Float;
524            assert!(
525                (pred - expected).abs() < 1.0,
526                "Prediction {} should be close to {}",
527                pred,
528                expected
529            );
530        }
531    }
532
533    #[test]
534    #[ignore = "Slow test: trains LS-SVM classifier. Run with --ignored flag"]
535    fn test_lssvm_binary_classification() {
536        let x = array![
537            [1.0, 2.0],
538            [2.0, 3.0],
539            [3.0, 4.0],
540            [6.0, 7.0],
541            [7.0, 8.0],
542            [8.0, 9.0],
543        ];
544        let y = array![0, 0, 0, 1, 1, 1];
545
546        let classifier = LSVMClassifier::new()
547            .gamma(1.0)
548            .kernel(KernelType::Linear)
549            .fit_intercept(true);
550
551        let fitted_model = classifier
552            .fit(&x, &y)
553            .expect("model fitting should succeed");
554
555        let predictions = fitted_model.predict(&x).expect("prediction should succeed");
556        assert_eq!(predictions.len(), 6);
557
558        // Check that predictions are valid class labels
559        for &pred in predictions.iter() {
560            assert!(pred == 0 || pred == 1);
561        }
562
563        let decision_values = fitted_model
564            .decision_function(&x)
565            .expect("decision function should succeed");
566        assert_eq!(decision_values.len(), 6);
567
568        // Decision values should be finite
569        for &val in decision_values.iter() {
570            assert!(val.is_finite());
571        }
572    }
573
574    #[test]
575    fn test_lssvm_shape_mismatch() {
576        let x = array![[1.0, 2.0], [3.0, 4.0]];
577        let y = array![1.0]; // Wrong length
578
579        let lssvm = LSSVM::new();
580        let result = lssvm.fit(&x, &y);
581
582        assert!(result.is_err());
583        assert!(result.unwrap_err().to_string().contains("Shape mismatch"));
584    }
585
586    #[test]
587    fn test_lssvm_classifier_multiclass_error() {
588        let x = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
589        let y = array![0, 1, 2]; // Three classes
590
591        let classifier = LSVMClassifier::new();
592        let result = classifier.fit(&x, &y);
593
594        assert!(result.is_err());
595        assert!(result
596            .unwrap_err()
597            .to_string()
598            .contains("binary classification"));
599    }
600
601    #[test]
602    fn test_lssvm_feature_mismatch() {
603        let x_train = array![[1.0, 2.0], [3.0, 4.0]];
604        let y_train = array![1.0, 2.0];
605        let x_test = array![[1.0, 2.0, 3.0]]; // Wrong number of features
606
607        let lssvm = LSSVM::new();
608        let fitted_model = lssvm
609            .fit(&x_train, &y_train)
610            .expect("model fitting should succeed");
611        let result = fitted_model.predict(&x_test);
612
613        assert!(result.is_err());
614        assert!(result.unwrap_err().to_string().contains("Feature mismatch"));
615    }
616}