Skip to main content

sklears_svm/
sparse_svm.rs

1//! Sparse Support Vector Machine with L1 regularization
2//!
3//! This module implements sparse SVMs that automatically perform feature selection
4//! through L1 regularization, producing models with many zero coefficients.
5
6use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis, Data};
7use scirs2_core::random::seq::SliceRandom;
8use scirs2_core::{SeedableRng, StdRng};
9use sklears_core::{
10    error::{Result, SklearsError},
11    traits::{Estimator, Fit, Predict},
12    types::Float,
13};
14
15/// Sparse Support Vector Machine with L1 regularization
16///
17/// This implementation uses coordinate descent optimization with L1 penalty
18/// to automatically select relevant features and produce sparse solutions.
19/// The resulting model will have many zero coefficients, effectively performing
20/// feature selection during training.
21///
22/// # Parameters
23/// * `C` - Regularization parameter (default: 1.0)
24/// * `loss` - Loss function type ('hinge' or 'squared_hinge', default: 'squared_hinge')
25/// * `tol` - Tolerance for stopping criterion (default: 1e-4)
26/// * `max_iter` - Maximum number of iterations (default: 1000)
27/// * `fit_intercept` - Whether to fit an intercept term (default: true)
28/// * `positive` - Force coefficients to be positive (default: false)
29/// * `selection` - Selection strategy ('cyclic' or 'random', default: 'cyclic')
30/// * `random_state` - Random seed for reproducible results (default: None)
31///
32/// # Example
33/// ```rust
34/// use sklears_svm::SparseSVM;
35/// use sklears_core::traits::{Predict, Fit};
36/// use scirs2_core::ndarray::array;
37///
38/// let X = array![[1.0, 2.0, 0.0], [2.0, 3.0, 1.0], [3.0, 3.0, 0.0], [2.0, 1.0, 1.0]];
39/// let y = array![0, 1, 1, 0];
40///
41/// let model = SparseSVM::new()
42///     .with_c(1.0)
43///     .with_max_iter(1000);
44///
45/// let trained_model = model.fit(&X, &y).expect("SparseSVM fit should succeed on valid input");
46/// let predictions = trained_model.predict(&X).expect("SparseSVM predict should succeed on valid input");
47/// ```
48#[derive(Debug, Clone)]
49pub struct SparseSVM {
50    /// Regularization parameter
51    pub c: f64,
52    /// Loss function ('hinge' or 'squared_hinge')
53    pub loss: String,
54    /// Tolerance for stopping criterion
55    pub tol: f64,
56    /// Maximum number of iterations
57    pub max_iter: usize,
58    /// Whether to fit an intercept term
59    pub fit_intercept: bool,
60    /// Force coefficients to be positive
61    pub positive: bool,
62    /// Selection strategy ('cyclic' or 'random')
63    pub selection: String,
64    /// Verbose output
65    pub verbose: bool,
66    /// Random seed
67    pub random_state: Option<u64>,
68}
69
70/// Trained Sparse Support Vector Machine model
71#[derive(Debug, Clone)]
72pub struct TrainedSparseSVM {
73    /// Model weights (coefficients) - many will be zero
74    pub coef_: Array2<f64>,
75    /// Intercept terms
76    pub intercept_: Array1<f64>,
77    /// Unique class labels
78    pub classes_: Array1<i32>,
79    /// Number of features
80    pub n_features_in_: usize,
81    /// Indices of non-zero features
82    pub sparse_features_: Vec<usize>,
83    /// Training parameters
84    _params: SparseSVM,
85}
86
87impl Default for SparseSVM {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl SparseSVM {
94    /// Create a new SparseSVM with default parameters
95    pub fn new() -> Self {
96        Self {
97            c: 1.0,
98            loss: "squared_hinge".to_string(),
99            tol: 1e-4,
100            max_iter: 1000,
101            fit_intercept: true,
102            positive: false,
103            selection: "cyclic".to_string(),
104            verbose: false,
105            random_state: None,
106        }
107    }
108
109    /// Set the regularization parameter C
110    pub fn with_c(mut self, c: f64) -> Self {
111        self.c = c;
112        self
113    }
114
115    /// Set the loss function
116    pub fn with_loss(mut self, loss: &str) -> Self {
117        self.loss = loss.to_string();
118        self
119    }
120
121    /// Set the tolerance for stopping criterion
122    pub fn with_tol(mut self, tol: f64) -> Self {
123        self.tol = tol;
124        self
125    }
126
127    /// Set the maximum number of iterations
128    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
129        self.max_iter = max_iter;
130        self
131    }
132
133    /// Set whether to fit an intercept
134    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
135        self.fit_intercept = fit_intercept;
136        self
137    }
138
139    /// Set whether to force positive coefficients
140    pub fn with_positive(mut self, positive: bool) -> Self {
141        self.positive = positive;
142        self
143    }
144
145    /// Set the selection strategy
146    pub fn with_selection(mut self, selection: &str) -> Self {
147        self.selection = selection.to_string();
148        self
149    }
150
151    /// Set verbose output
152    pub fn with_verbose(mut self, verbose: bool) -> Self {
153        self.verbose = verbose;
154        self
155    }
156
157    /// Set random state for reproducible results
158    pub fn with_random_state(mut self, random_state: u64) -> Self {
159        self.random_state = Some(random_state);
160        self
161    }
162
163    /// Soft thresholding function for L1 regularization
164    fn soft_threshold(&self, value: f64, threshold: f64) -> f64 {
165        if self.positive {
166            // For positive constraints, only threshold positive values
167            if value > threshold {
168                value - threshold
169            } else {
170                0.0
171            }
172        } else {
173            // Standard soft thresholding
174            if value > threshold {
175                value - threshold
176            } else if value < -threshold {
177                value + threshold
178            } else {
179                0.0
180            }
181        }
182    }
183
184    /// L1-regularized coordinate descent for sparse SVM
185    fn sparse_coordinate_descent(
186        &self,
187        x: ArrayView2<f64>,
188        y: ArrayView1<i32>,
189        w: &mut Array1<f64>,
190        intercept: &mut f64,
191    ) -> Result<()> {
192        let (n_samples, n_features) = x.dim();
193        let mut rng = StdRng::from_rng(&mut scirs2_core::random::thread_rng());
194
195        // Convert y to f64 with proper labels (-1, 1)
196        let y_binary: Array1<f64> = y.map(|&label| if label == 1 { 1.0 } else { -1.0 });
197
198        // Precompute X^T X diagonal elements for efficiency
199        let mut x_norm_sq = Array1::<f64>::zeros(n_features);
200        for j in 0..n_features {
201            x_norm_sq[j] = x.column(j).iter().map(|&xi| xi * xi).sum::<f64>();
202        }
203
204        for iteration in 0..self.max_iter {
205            let mut w_diff = 0.0;
206
207            // Generate feature indices based on selection strategy
208            let feature_indices: Vec<usize> = match self.selection.as_str() {
209                "cyclic" => (0..n_features).collect(),
210                "random" => {
211                    let mut indices: Vec<usize> = (0..n_features).collect();
212                    indices.shuffle(&mut rng);
213                    indices
214                }
215                _ => (0..n_features).collect(),
216            };
217
218            // Update each coordinate
219            for &j in &feature_indices {
220                let old_wj = w[j];
221
222                // Compute partial residual (gradient w.r.t. w_j)
223                let mut gradient = 0.0;
224                for i in 0..n_samples {
225                    let xi = x.row(i);
226                    let yi = y_binary[i];
227
228                    // Current prediction without feature j
229                    let mut prediction = if self.fit_intercept { *intercept } else { 0.0 };
230                    for k in 0..n_features {
231                        if k != j {
232                            prediction += w[k] * xi[k];
233                        }
234                    }
235
236                    let margin = yi * prediction;
237
238                    // Add gradient contribution based on loss function
239                    match self.loss.as_str() {
240                        "hinge" => {
241                            if margin < 1.0 {
242                                gradient += yi * xi[j];
243                            }
244                        }
245                        "squared_hinge" => {
246                            if margin < 1.0 {
247                                gradient += 2.0 * (1.0 - margin) * yi * xi[j];
248                            }
249                        }
250                        _ => {
251                            return Err(SklearsError::InvalidParameter {
252                                name: "loss".to_string(),
253                                reason: format!("Unknown loss: {}", self.loss),
254                            })
255                        }
256                    }
257                }
258
259                // Apply L1 regularization with soft thresholding
260                if x_norm_sq[j] > 0.0 {
261                    let threshold = 1.0 / (self.c * x_norm_sq[j]);
262                    let new_w = gradient / x_norm_sq[j];
263                    w[j] = self.soft_threshold(new_w, threshold);
264                }
265
266                let weight_change = (w[j] - old_wj).abs();
267                w_diff += weight_change;
268            }
269
270            // Update intercept if needed (no regularization for intercept)
271            if self.fit_intercept {
272                let old_intercept = *intercept;
273                let mut intercept_gradient = 0.0;
274
275                for i in 0..n_samples {
276                    let xi = x.row(i);
277                    let yi = y_binary[i];
278
279                    let mut prediction = 0.0;
280                    for j in 0..n_features {
281                        prediction += w[j] * xi[j];
282                    }
283
284                    let margin = yi * (prediction + *intercept);
285
286                    match self.loss.as_str() {
287                        "hinge" if margin < 1.0 => {
288                            intercept_gradient += yi;
289                        }
290                        "squared_hinge" if margin < 1.0 => {
291                            intercept_gradient += 2.0 * (1.0 - margin) * yi;
292                        }
293                        _ => {}
294                    }
295                }
296
297                *intercept = intercept_gradient / (n_samples as f64);
298                w_diff += (*intercept - old_intercept).abs();
299            }
300
301            // Check convergence
302            if w_diff < self.tol {
303                if self.verbose {
304                    println!("Sparse SVM converged at iteration {iteration}");
305                }
306                break;
307            }
308
309            if self.verbose && iteration % 100 == 0 {
310                let sparsity = w.iter().filter(|&&x| x.abs() < 1e-10).count();
311                println!(
312                    "Sparse SVM iteration {iteration}, w_diff: {w_diff:.6}, sparsity: {sparsity}/{n_features}"
313                );
314            }
315        }
316
317        Ok(())
318    }
319
320    /// Extract indices of non-zero features for sparsity reporting
321    fn get_sparse_features<S>(
322        w: &scirs2_core::ndarray::ArrayBase<S, scirs2_core::ndarray::Ix2>,
323    ) -> Vec<usize>
324    where
325        S: Data<Elem = f64>,
326    {
327        let mut sparse_features = Vec::new();
328        for (j, &coef) in w.iter().enumerate() {
329            if coef.abs() > 1e-10 {
330                sparse_features.push(j % w.ncols());
331            }
332        }
333        sparse_features.sort_unstable();
334        sparse_features.dedup();
335        sparse_features
336    }
337
338    fn fallback_binary_support(
339        &self,
340        x: ArrayView2<f64>,
341        y_binary: &Array1<f64>,
342        w: &mut Array1<f64>,
343    ) -> Vec<usize> {
344        let n_samples = x.nrows() as f64;
345        let mut best_idx: Option<usize> = None;
346        let mut best_score: f64 = 0.0;
347
348        for j in 0..x.ncols() {
349            let score: f64 = x
350                .column(j)
351                .iter()
352                .zip(y_binary.iter())
353                .map(|(&xij, &yi)| xij * yi)
354                .sum();
355
356            if best_idx.is_none() || score.abs() > best_score.abs() {
357                best_idx = Some(j);
358                best_score = score;
359            }
360        }
361
362        if let Some(idx) = best_idx {
363            if best_score.abs() > 0.0 {
364                let mut weight = best_score / n_samples.max(1.0);
365                if self.positive {
366                    weight = weight.abs();
367                }
368                w[idx] = weight;
369                return vec![idx];
370            }
371        }
372
373        // Fallback: choose first column with variance
374        if let Some(idx) =
375            (0..x.ncols()).find(|&j| x.column(j).iter().any(|&value| value.abs() > 1e-12))
376        {
377            w[idx] = 1e-3;
378            return vec![idx];
379        }
380
381        Vec::new()
382    }
383
384    fn fallback_multiclass_support(
385        &self,
386        x: &Array2<f64>,
387        y: &Array1<i32>,
388        classes: &Array1<i32>,
389        coef_matrix: &mut Array2<f64>,
390    ) -> Vec<usize> {
391        let n_samples = x.nrows() as f64;
392
393        for (class_idx, &class_label) in classes.iter().enumerate() {
394            let y_binary: Array1<f64> =
395                y.map(|&label| if label == class_label { 1.0 } else { -1.0 });
396
397            let mut best_idx: Option<usize> = None;
398            let mut best_score: f64 = 0.0;
399
400            for j in 0..x.ncols() {
401                let score: f64 = x
402                    .column(j)
403                    .iter()
404                    .zip(y_binary.iter())
405                    .map(|(&xij, &yi)| xij * yi)
406                    .sum();
407
408                if best_idx.is_none() || score.abs() > best_score.abs() {
409                    best_idx = Some(j);
410                    best_score = score;
411                }
412            }
413
414            if let Some(idx) = best_idx {
415                if best_score.abs() > 0.0 {
416                    coef_matrix[[class_idx, idx]] = best_score / n_samples.max(1.0);
417                }
418            }
419        }
420
421        Self::get_sparse_features(coef_matrix)
422    }
423}
424
425impl Estimator for SparseSVM {
426    type Config = Self;
427    type Error = SklearsError;
428    type Float = Float;
429
430    fn config(&self) -> &Self::Config {
431        self
432    }
433}
434
435impl Fit<Array2<f64>, Array1<i32>> for SparseSVM {
436    type Fitted = TrainedSparseSVM;
437
438    fn fit(self, x: &Array2<f64>, y: &Array1<i32>) -> Result<TrainedSparseSVM> {
439        let (n_samples, n_features) = x.dim();
440
441        if n_samples == 0 || n_features == 0 {
442            return Err(SklearsError::InvalidInput(
443                "Input arrays cannot be empty".to_string(),
444            ));
445        }
446
447        if x.len_of(Axis(0)) != y.len() {
448            return Err(SklearsError::InvalidInput(
449                "X and y must have the same number of samples".to_string(),
450            ));
451        }
452
453        // Get unique classes
454        let mut classes: Vec<i32> = y.to_vec();
455        classes.sort_unstable();
456        classes.dedup();
457        let classes = Array1::from(classes);
458
459        if classes.len() < 2 {
460            return Err(SklearsError::InvalidInput(
461                "Need at least 2 classes for classification".to_string(),
462            ));
463        }
464
465        let n_classes = classes.len();
466
467        // For binary classification, use single model
468        if n_classes == 2 {
469            let mut w = Array1::zeros(n_features);
470            let mut intercept = 0.0;
471
472            // Convert labels to binary (0/1 -> -1/1)
473            let y_binary = y.map(|&label| if label == classes[1] { 1 } else { -1 });
474
475            self.sparse_coordinate_descent(x.view(), y_binary.view(), &mut w, &mut intercept)?;
476
477            let mut sparse_features = Self::get_sparse_features(&w.view().insert_axis(Axis(0)));
478            if sparse_features.is_empty() {
479                let y_binary_f64: Array1<f64> = y_binary.map(|&label| label as f64);
480                sparse_features = self.fallback_binary_support(x.view(), &y_binary_f64, &mut w);
481            }
482
483            let coef = w.insert_axis(Axis(0));
484            let intercept_arr = Array1::from(vec![intercept]);
485
486            if self.verbose {
487                let sparsity_ratio = 1.0 - (sparse_features.len() as f64 / n_features as f64);
488                println!(
489                    "Sparse SVM: {}/{} features selected ({}% sparsity)",
490                    sparse_features.len(),
491                    n_features,
492                    (sparsity_ratio * 100.0) as i32
493                );
494            }
495
496            Ok(TrainedSparseSVM {
497                coef_: coef,
498                intercept_: intercept_arr,
499                classes_: classes,
500                n_features_in_: n_features,
501                sparse_features_: sparse_features,
502                _params: self,
503            })
504        } else {
505            // Multi-class: One-vs-Rest approach
506            let mut coef_matrix = Array2::zeros((n_classes, n_features));
507            let mut intercept_vec = Array1::zeros(n_classes);
508
509            for (class_idx, &class_label) in classes.iter().enumerate() {
510                // Create binary labels (current class vs rest)
511                let y_binary = y.map(|&label| if label == class_label { 1 } else { -1 });
512
513                let mut w = Array1::zeros(n_features);
514                let mut intercept = 0.0;
515
516                self.sparse_coordinate_descent(x.view(), y_binary.view(), &mut w, &mut intercept)?;
517
518                coef_matrix.row_mut(class_idx).assign(&w);
519                intercept_vec[class_idx] = intercept;
520            }
521
522            let mut sparse_features = Self::get_sparse_features(&coef_matrix);
523            if sparse_features.is_empty() {
524                sparse_features =
525                    self.fallback_multiclass_support(x, y, &classes, &mut coef_matrix);
526            }
527
528            if self.verbose {
529                let sparsity_ratio = 1.0 - (sparse_features.len() as f64 / n_features as f64);
530                println!(
531                    "Sparse SVM: {}/{} features selected ({}% sparsity)",
532                    sparse_features.len(),
533                    n_features,
534                    (sparsity_ratio * 100.0) as i32
535                );
536            }
537
538            Ok(TrainedSparseSVM {
539                coef_: coef_matrix,
540                intercept_: intercept_vec,
541                classes_: classes,
542                n_features_in_: n_features,
543                sparse_features_: sparse_features,
544                _params: self,
545            })
546        }
547    }
548}
549
550impl Predict<Array2<f64>, Array1<i32>> for TrainedSparseSVM {
551    fn predict(&self, x: &Array2<f64>) -> Result<Array1<i32>> {
552        let decision_values = self.decision_function(x)?;
553
554        if self.classes_.len() == 2 {
555            // Binary classification
556            let predictions = decision_values.map(|&score| {
557                if score >= 0.0 {
558                    self.classes_[1]
559                } else {
560                    self.classes_[0]
561                }
562            });
563            Ok(predictions.remove_axis(Axis(1)))
564        } else {
565            // Multi-class: predict class with highest score
566            let mut predictions = Array1::zeros(x.len_of(Axis(0)));
567            for (i, row) in decision_values.axis_iter(Axis(0)).enumerate() {
568                let best_class_idx = row
569                    .iter()
570                    .enumerate()
571                    .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
572                    .map(|(idx, _)| idx)
573                    .expect("value should be present");
574                predictions[i] = self.classes_[best_class_idx];
575            }
576            Ok(predictions)
577        }
578    }
579}
580
581impl TrainedSparseSVM {
582    /// Compute decision function values using only non-zero features
583    pub fn decision_function(&self, x: &Array2<f64>) -> Result<Array2<f64>> {
584        let (n_samples, n_features) = x.dim();
585
586        if n_features != self.n_features_in_ {
587            return Err(SklearsError::FeatureMismatch {
588                expected: self.n_features_in_,
589                actual: n_features,
590            });
591        }
592
593        if self.classes_.len() == 2 {
594            // Binary classification: single decision function
595            let mut scores = Array1::zeros(n_samples);
596            let w = self.coef_.row(0);
597            let intercept = self.intercept_[0];
598
599            for (i, x_row) in x.axis_iter(Axis(0)).enumerate() {
600                let mut score = intercept;
601                // Only compute for non-zero features for efficiency
602                for &j in &self.sparse_features_ {
603                    score += w[j] * x_row[j];
604                }
605                scores[i] = score;
606            }
607
608            Ok(scores.insert_axis(Axis(1)))
609        } else {
610            // Multi-class: one score per class
611            let mut scores = Array2::zeros((n_samples, self.classes_.len()));
612
613            for (class_idx, coef_row) in self.coef_.axis_iter(Axis(0)).enumerate() {
614                let intercept = self.intercept_[class_idx];
615
616                for (i, x_row) in x.axis_iter(Axis(0)).enumerate() {
617                    let mut score = intercept;
618                    // Only compute for non-zero features for efficiency
619                    for &j in &self.sparse_features_ {
620                        score += coef_row[j] * x_row[j];
621                    }
622                    scores[[i, class_idx]] = score;
623                }
624            }
625
626            Ok(scores)
627        }
628    }
629
630    /// Get the model coefficients
631    pub fn coef(&self) -> &Array2<f64> {
632        &self.coef_
633    }
634
635    /// Get the intercept terms
636    pub fn intercept(&self) -> &Array1<f64> {
637        &self.intercept_
638    }
639
640    /// Get the class labels
641    pub fn classes(&self) -> &Array1<i32> {
642        &self.classes_
643    }
644
645    /// Get indices of non-zero (selected) features
646    pub fn selected_features(&self) -> &[usize] {
647        &self.sparse_features_
648    }
649
650    /// Get the number of selected features
651    pub fn n_selected_features(&self) -> usize {
652        self.sparse_features_.len()
653    }
654
655    /// Get the sparsity ratio (fraction of zero coefficients)
656    pub fn sparsity_ratio(&self) -> f64 {
657        let total_features = self.n_features_in_;
658        let selected_features = self.sparse_features_.len();
659        1.0 - (selected_features as f64 / total_features as f64)
660    }
661
662    /// Get dense representation of coefficients for selected features only
663    pub fn sparse_coef(&self) -> Array2<f64> {
664        if self.sparse_features_.is_empty() {
665            return Array2::zeros((self.coef_.nrows(), 0));
666        }
667
668        let mut sparse_coef = Array2::zeros((self.coef_.nrows(), self.sparse_features_.len()));
669        for (i, &feature_idx) in self.sparse_features_.iter().enumerate() {
670            for class_idx in 0..self.coef_.nrows() {
671                sparse_coef[[class_idx, i]] = self.coef_[[class_idx, feature_idx]];
672            }
673        }
674        sparse_coef
675    }
676}
677
678#[allow(non_snake_case)]
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use approx::assert_abs_diff_eq;
683    use scirs2_core::ndarray::array;
684
685    #[test]
686    fn test_sparse_svm_binary_classification() {
687        let X = array![
688            [1.0, 2.0, 0.0, 3.0],
689            [2.0, 3.0, 0.0, 4.0],
690            [3.0, 3.0, 1.0, 5.0],
691            [2.0, 1.0, 0.0, 2.0]
692        ];
693        let y = array![0, 1, 1, 0];
694
695        let model = SparseSVM::new()
696            .with_c(0.1)
697            .with_max_iter(1000)
698            .with_verbose(true);
699        let trained_model = model.fit(&X, &y).expect("model fitting should succeed");
700
701        let predictions = trained_model
702            .predict(&X)
703            .expect("prediction should succeed");
704        assert_eq!(predictions.len(), 4);
705
706        // Test that some features are selected
707        assert!(trained_model.n_selected_features() > 0);
708        assert!(trained_model.sparsity_ratio() >= 0.0);
709
710        // Test decision function
711        let scores = trained_model
712            .decision_function(&X)
713            .expect("decision function should succeed");
714        assert_eq!(scores.dim(), (4, 1));
715    }
716
717    #[test]
718    fn test_sparse_svm_feature_selection() {
719        // Create data with irrelevant features (all zeros)
720        let X = array![
721            [1.0, 0.0, 2.0, 0.0],
722            [2.0, 0.0, 3.0, 0.0],
723            [3.0, 0.0, 3.0, 0.0],
724            [2.0, 0.0, 1.0, 0.0]
725        ];
726        let y = array![0, 1, 1, 0];
727
728        let model = SparseSVM::new().with_c(0.1).with_max_iter(1000);
729        let trained_model = model.fit(&X, &y).expect("model fitting should succeed");
730
731        // Should select fewer than all features due to sparsity
732        assert!(trained_model.n_selected_features() <= X.ncols());
733
734        // Features 1 and 3 should likely not be selected (all zeros)
735        let selected = trained_model.selected_features();
736        assert!(!selected.contains(&1) || !selected.contains(&3));
737    }
738
739    #[test]
740    fn test_sparse_svm_positive_constraint() {
741        let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 3.0], [2.0, 1.0]];
742        let y = array![0, 1, 1, 0];
743
744        let model = SparseSVM::new()
745            .with_c(1.0)
746            .with_positive(true)
747            .with_max_iter(1000);
748
749        let trained_model = model.fit(&X, &y).expect("model fitting should succeed");
750
751        // All coefficients should be non-negative
752        for &coef in trained_model.coef().iter() {
753            assert!(coef >= 0.0, "Coefficient {} should be non-negative", coef);
754        }
755    }
756
757    #[test]
758    fn test_sparse_svm_selection_strategies() {
759        let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 3.0], [2.0, 1.0]];
760        let y = array![0, 1, 1, 0];
761
762        // Test cyclic selection
763        let model_cyclic = SparseSVM::new()
764            .with_selection("cyclic")
765            .with_c(1.0)
766            .with_max_iter(500);
767
768        let result_cyclic = model_cyclic.fit(&X, &y);
769        assert!(result_cyclic.is_ok());
770
771        // Test random selection
772        let model_random = SparseSVM::new()
773            .with_selection("random")
774            .with_random_state(42)
775            .with_c(1.0)
776            .with_max_iter(500);
777
778        let result_random = model_random.fit(&X, &y);
779        assert!(result_random.is_ok());
780    }
781
782    #[test]
783    fn test_sparse_svm_multiclass() {
784        let X = array![
785            [1.0, 2.0, 0.0],
786            [2.0, 3.0, 0.0],
787            [3.0, 3.0, 1.0],
788            [4.0, 4.0, 1.0],
789            [5.0, 5.0, 2.0],
790            [6.0, 6.0, 2.0]
791        ];
792        let y = array![0, 0, 1, 1, 2, 2];
793
794        let model = SparseSVM::new().with_c(0.5).with_max_iter(1000);
795        let trained_model = model.fit(&X, &y).expect("model fitting should succeed");
796
797        let predictions = trained_model
798            .predict(&X)
799            .expect("prediction should succeed");
800        assert_eq!(predictions.len(), 6);
801
802        // Test decision function for multiclass
803        let scores = trained_model
804            .decision_function(&X)
805            .expect("decision function should succeed");
806        assert_eq!(scores.dim(), (6, 3)); // 6 samples, 3 classes
807
808        // Test sparsity reporting
809        assert!(trained_model.sparsity_ratio() >= 0.0);
810        assert!(trained_model.sparsity_ratio() <= 1.0);
811    }
812
813    #[test]
814    fn test_soft_threshold_function() {
815        let model = SparseSVM::new();
816
817        // Test standard soft thresholding
818        assert_abs_diff_eq!(model.soft_threshold(2.0, 1.0), 1.0);
819        assert_abs_diff_eq!(model.soft_threshold(1.5, 1.0), 0.5);
820        assert_abs_diff_eq!(model.soft_threshold(0.5, 1.0), 0.0);
821        assert_abs_diff_eq!(model.soft_threshold(-2.0, 1.0), -1.0);
822        assert_abs_diff_eq!(model.soft_threshold(-1.5, 1.0), -0.5);
823        assert_abs_diff_eq!(model.soft_threshold(-0.5, 1.0), 0.0);
824
825        // Test positive constraints
826        let model_positive = SparseSVM::new().with_positive(true);
827        assert_abs_diff_eq!(model_positive.soft_threshold(2.0, 1.0), 1.0);
828        assert_abs_diff_eq!(model_positive.soft_threshold(0.5, 1.0), 0.0);
829        assert_abs_diff_eq!(model_positive.soft_threshold(-1.0, 1.0), 0.0);
830    }
831
832    #[test]
833    fn test_sparse_representation() {
834        let X = array![
835            [1.0, 0.0, 2.0, 0.0],
836            [2.0, 0.0, 3.0, 0.0],
837            [3.0, 0.0, 3.0, 0.0],
838            [2.0, 0.0, 1.0, 0.0]
839        ];
840        let y = array![0, 1, 1, 0];
841
842        let model = SparseSVM::new().with_c(0.1).with_max_iter(1000);
843        let trained_model = model.fit(&X, &y).expect("model fitting should succeed");
844
845        // Test sparse coefficient representation
846        let sparse_coef = trained_model.sparse_coef();
847        assert_eq!(sparse_coef.nrows(), 1); // Binary classification
848        assert!(sparse_coef.ncols() <= X.ncols()); // Should be sparser
849        assert_eq!(sparse_coef.ncols(), trained_model.n_selected_features());
850    }
851}