1use 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#[derive(Debug, Clone)]
49pub struct SparseSVM {
50 pub c: f64,
52 pub loss: String,
54 pub tol: f64,
56 pub max_iter: usize,
58 pub fit_intercept: bool,
60 pub positive: bool,
62 pub selection: String,
64 pub verbose: bool,
66 pub random_state: Option<u64>,
68}
69
70#[derive(Debug, Clone)]
72pub struct TrainedSparseSVM {
73 pub coef_: Array2<f64>,
75 pub intercept_: Array1<f64>,
77 pub classes_: Array1<i32>,
79 pub n_features_in_: usize,
81 pub sparse_features_: Vec<usize>,
83 _params: SparseSVM,
85}
86
87impl Default for SparseSVM {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl SparseSVM {
94 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 pub fn with_c(mut self, c: f64) -> Self {
111 self.c = c;
112 self
113 }
114
115 pub fn with_loss(mut self, loss: &str) -> Self {
117 self.loss = loss.to_string();
118 self
119 }
120
121 pub fn with_tol(mut self, tol: f64) -> Self {
123 self.tol = tol;
124 self
125 }
126
127 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
129 self.max_iter = max_iter;
130 self
131 }
132
133 pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
135 self.fit_intercept = fit_intercept;
136 self
137 }
138
139 pub fn with_positive(mut self, positive: bool) -> Self {
141 self.positive = positive;
142 self
143 }
144
145 pub fn with_selection(mut self, selection: &str) -> Self {
147 self.selection = selection.to_string();
148 self
149 }
150
151 pub fn with_verbose(mut self, verbose: bool) -> Self {
153 self.verbose = verbose;
154 self
155 }
156
157 pub fn with_random_state(mut self, random_state: u64) -> Self {
159 self.random_state = Some(random_state);
160 self
161 }
162
163 fn soft_threshold(&self, value: f64, threshold: f64) -> f64 {
165 if self.positive {
166 if value > threshold {
168 value - threshold
169 } else {
170 0.0
171 }
172 } else {
173 if value > threshold {
175 value - threshold
176 } else if value < -threshold {
177 value + threshold
178 } else {
179 0.0
180 }
181 }
182 }
183
184 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 let y_binary: Array1<f64> = y.map(|&label| if label == 1 { 1.0 } else { -1.0 });
197
198 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 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 for &j in &feature_indices {
220 let old_wj = w[j];
221
222 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 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 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 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 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 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 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 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 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 if n_classes == 2 {
469 let mut w = Array1::zeros(n_features);
470 let mut intercept = 0.0;
471
472 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 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 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 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 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 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 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 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 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 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 pub fn coef(&self) -> &Array2<f64> {
632 &self.coef_
633 }
634
635 pub fn intercept(&self) -> &Array1<f64> {
637 &self.intercept_
638 }
639
640 pub fn classes(&self) -> &Array1<i32> {
642 &self.classes_
643 }
644
645 pub fn selected_features(&self) -> &[usize] {
647 &self.sparse_features_
648 }
649
650 pub fn n_selected_features(&self) -> usize {
652 self.sparse_features_.len()
653 }
654
655 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 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 assert!(trained_model.n_selected_features() > 0);
708 assert!(trained_model.sparsity_ratio() >= 0.0);
709
710 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 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 assert!(trained_model.n_selected_features() <= X.ncols());
733
734 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 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 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 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 let scores = trained_model
804 .decision_function(&X)
805 .expect("decision function should succeed");
806 assert_eq!(scores.dim(), (6, 3)); 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 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 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 let sparse_coef = trained_model.sparse_coef();
847 assert_eq!(sparse_coef.nrows(), 1); assert!(sparse_coef.ncols() <= X.ncols()); assert_eq!(sparse_coef.ncols(), trained_model.n_selected_features());
850 }
851}