1use crate::svc::{SvcConfig, SVC};
4use scirs2_core::ndarray::{Array1, Array2};
5use sklears_core::{
6 error::{Result, SklearsError},
7 traits::{Fit, Predict, Trained, Untrained},
8 types::Float,
9};
10use std::marker::PhantomData;
11
12#[derive(Debug, Clone, Copy, PartialEq, Default)]
14pub enum MultiClassStrategy {
15 #[default]
17 OneVsRest,
18 OneVsOne,
20 OneVsOneDecision,
22 Ecoc,
24 HierarchicalTree,
26}
27
28#[derive(Debug, Clone)]
30pub enum TreeNode {
31 Leaf(Float),
33 Internal {
35 classifier: Box<SVC<Trained>>,
36 left_classes: Vec<Float>,
37 right_classes: Vec<Float>,
38 left_child: Box<TreeNode>,
39 right_child: Box<TreeNode>,
40 },
41}
42
43#[derive(Debug, Clone)]
45pub struct HierarchicalTree {
46 root: TreeNode,
47}
48
49impl HierarchicalTree {
50 pub fn new(root: TreeNode) -> Self {
52 Self { root }
53 }
54
55 pub fn predict_sample(&self, x: &Array2<Float>) -> Result<Float> {
57 self.predict_node(&self.root, x)
58 }
59
60 #[allow(clippy::only_used_in_recursion)]
62 fn predict_node(&self, node: &TreeNode, x: &Array2<Float>) -> Result<Float> {
63 match node {
64 TreeNode::Leaf(class) => Ok(*class),
65 TreeNode::Internal {
66 classifier,
67 left_classes: _,
68 right_classes: _,
69 left_child,
70 right_child,
71 } => {
72 let decision = classifier.decision_function(x)?[0];
73 if decision <= 0.0 {
74 self.predict_node(left_child, x)
75 } else {
76 self.predict_node(right_child, x)
77 }
78 }
79 }
80 }
81
82 pub fn decision_path(&self, x: &Array2<Float>) -> Result<Vec<Float>> {
84 let mut path = Vec::new();
85 self.collect_decision_path(&self.root, x, &mut path)?;
86 Ok(path)
87 }
88
89 #[allow(clippy::only_used_in_recursion)]
90 fn collect_decision_path(
91 &self,
92 node: &TreeNode,
93 x: &Array2<Float>,
94 path: &mut Vec<Float>,
95 ) -> Result<()> {
96 match node {
97 TreeNode::Leaf(_) => Ok(()),
98 TreeNode::Internal {
99 classifier,
100 left_classes: _,
101 right_classes: _,
102 left_child,
103 right_child,
104 } => {
105 let decision = classifier.decision_function(x)?[0];
106 path.push(decision);
107 if decision <= 0.0 {
108 self.collect_decision_path(left_child, x, path)
109 } else {
110 self.collect_decision_path(right_child, x, path)
111 }
112 }
113 }
114 }
115}
116
117#[derive(Debug, Clone)]
119pub struct MultiClassSVC<State = Untrained> {
120 config: SvcConfig,
121 strategy: MultiClassStrategy,
122 state: PhantomData<State>,
123 estimators_: Option<Vec<SVC<Trained>>>,
125 classes_: Option<Array1<Float>>,
126 n_features_in_: Option<usize>,
127 class_pairs_: Option<Vec<(Float, Float)>>, codebook_: Option<Array2<Float>>, hierarchy_tree_: Option<HierarchicalTree>, }
131
132impl MultiClassSVC<Untrained> {
133 pub fn new() -> Self {
135 Self {
136 config: SvcConfig::default(),
137 strategy: MultiClassStrategy::default(),
138 state: PhantomData,
139 estimators_: None,
140 classes_: None,
141 n_features_in_: None,
142 class_pairs_: None,
143 codebook_: None,
144 hierarchy_tree_: None,
145 }
146 }
147
148 pub fn c(mut self, c: Float) -> Self {
150 self.config.c = c;
151 self
152 }
153
154 pub fn linear(mut self) -> Self {
156 self.config.kernel = crate::svc::SvcKernel::Linear;
157 self
158 }
159
160 pub fn rbf(mut self, gamma: Option<Float>) -> Self {
162 self.config.kernel = crate::svc::SvcKernel::Rbf { gamma };
163 self
164 }
165
166 pub fn poly(mut self, degree: usize, gamma: Option<Float>, coef0: Float) -> Self {
168 self.config.kernel = crate::svc::SvcKernel::Poly {
169 degree,
170 gamma,
171 coef0,
172 };
173 self
174 }
175
176 pub fn tol(mut self, tol: Float) -> Self {
178 self.config.tol = tol;
179 self
180 }
181
182 pub fn max_iter(mut self, max_iter: usize) -> Self {
184 self.config.max_iter = max_iter;
185 self
186 }
187
188 pub fn strategy(mut self, strategy: MultiClassStrategy) -> Self {
190 self.strategy = strategy;
191 self
192 }
193
194 pub fn one_vs_rest(mut self) -> Self {
196 self.strategy = MultiClassStrategy::OneVsRest;
197 self
198 }
199
200 pub fn one_vs_one(mut self) -> Self {
202 self.strategy = MultiClassStrategy::OneVsOne;
203 self
204 }
205
206 pub fn one_vs_one_decision(mut self) -> Self {
208 self.strategy = MultiClassStrategy::OneVsOneDecision;
209 self
210 }
211
212 pub fn ecoc(mut self) -> Self {
214 self.strategy = MultiClassStrategy::Ecoc;
215 self
216 }
217
218 pub fn hierarchical_tree(mut self) -> Self {
220 self.strategy = MultiClassStrategy::HierarchicalTree;
221 self
222 }
223
224 pub fn balanced(mut self) -> Self {
226 self.config.class_weight = Some(crate::svc::ClassWeight::Balanced);
227 self
228 }
229
230 fn find_classes(y: &Array1<Float>) -> Array1<Float> {
232 let mut classes: Vec<Float> = y.iter().cloned().collect();
233 classes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
234 classes.dedup();
235 Array1::from_vec(classes)
236 }
237
238 fn generate_class_pairs(classes: &Array1<Float>) -> Vec<(Float, Float)> {
240 let mut pairs = Vec::new();
241 for (i, &class_a) in classes.iter().enumerate() {
242 for &class_b in classes.iter().skip(i + 1) {
243 pairs.push((class_a, class_b));
244 }
245 }
246 pairs
247 }
248
249 fn create_ovr_labels(y: &Array1<Float>, positive_class: Float) -> Array1<Float> {
251 y.mapv(|label| if label == positive_class { 1.0 } else { 0.0 })
252 }
253
254 fn extract_ovo_samples(
256 x: &Array2<Float>,
257 y: &Array1<Float>,
258 class_a: Float,
259 class_b: Float,
260 ) -> (Array2<Float>, Array1<Float>, Vec<usize>) {
261 let mut indices = Vec::new();
262 let mut labels = Vec::new();
263
264 for (i, &label) in y.iter().enumerate() {
265 if label == class_a || label == class_b {
266 indices.push(i);
267 labels.push(if label == class_a { 0.0 } else { 1.0 });
268 }
269 }
270
271 let n_samples = indices.len();
272 let n_features = x.ncols();
273 let mut x_binary = Array2::zeros((n_samples, n_features));
274
275 for (new_idx, &old_idx) in indices.iter().enumerate() {
276 x_binary.row_mut(new_idx).assign(&x.row(old_idx));
277 }
278
279 (x_binary, Array1::from_vec(labels), indices)
280 }
281
282 fn generate_ecoc_codebook(n_classes: usize, n_bits: Option<usize>) -> Array2<Float> {
284 let n_bits = n_bits.unwrap_or_else(|| {
286 let min_bits = (n_classes as f64).log2().ceil() as usize;
287 (min_bits * 2).max(6) });
289
290 let mut rng = scirs2_core::random::thread_rng();
291 let mut attempts = 0;
292 const MAX_ATTEMPTS: usize = 100;
293
294 loop {
295 let mut codebook = Array2::zeros((n_classes, n_bits));
296
297 for j in 0..n_bits {
299 let mut bit_values: Vec<Float> = (0..n_classes)
301 .map(|i| if i < n_classes / 2 { 1.0 } else { -1.0 })
302 .collect();
303
304 for i in 0..n_classes {
306 let swap_idx = rng.gen_range(0..n_classes);
307 bit_values.swap(i, swap_idx);
308 }
309
310 for i in 0..n_classes {
311 codebook[[i, j]] = bit_values[i];
312 }
313 }
314
315 let mut valid = true;
317 for i in 0..n_classes {
318 for j in (i + 1)..n_classes {
319 let mut similarity = 0;
320 let mut complement_similarity = 0;
321
322 for k in 0..n_bits {
323 if codebook[[i, k]] == codebook[[j, k]] {
324 similarity += 1;
325 }
326 if codebook[[i, k]] == -codebook[[j, k]] {
327 complement_similarity += 1;
328 }
329 }
330
331 let min_distance = (n_bits / 3).max(1);
333 if n_bits - similarity < min_distance
334 || n_bits - complement_similarity < min_distance
335 {
336 valid = false;
337 break;
338 }
339 }
340 if !valid {
341 break;
342 }
343 }
344
345 if valid {
346 return codebook;
347 }
348
349 attempts += 1;
350 if attempts >= MAX_ATTEMPTS {
351 let mut codebook = Array2::zeros((n_classes, n_bits));
353 for i in 0..n_classes {
354 for j in 0..n_bits {
355 codebook[[i, j]] = if rng.random::<bool>() { 1.0 } else { -1.0 };
356 }
357 }
358 return codebook;
359 }
360 }
361 }
362
363 fn create_ecoc_labels(
365 y: &Array1<Float>,
366 classes: &Array1<Float>,
367 bit_idx: usize,
368 codebook: &Array2<Float>,
369 ) -> Array1<Float> {
370 y.mapv(|label| {
371 let class_idx = classes
372 .iter()
373 .position(|&c| c == label)
374 .expect("element not found");
375 codebook[[class_idx, bit_idx]]
376 })
377 }
378
379 fn build_hierarchical_tree(
381 &self,
382 x: &Array2<Float>,
383 y: &Array1<Float>,
384 classes: &[Float],
385 ) -> Result<TreeNode> {
386 if classes.len() == 1 {
388 return Ok(TreeNode::Leaf(classes[0]));
389 }
390
391 if classes.len() == 2 {
393 let left_class = classes[0];
394 let right_class = classes[1];
395
396 let (x_binary, y_binary, _) = Self::extract_ovo_samples(x, y, left_class, right_class);
398
399 let svc = SVC::new()
401 .c(self.config.c)
402 .tol(self.config.tol)
403 .max_iter(self.config.max_iter);
404
405 let fitted_svc = match &self.config.kernel {
406 crate::svc::SvcKernel::Linear => svc.linear(),
407 crate::svc::SvcKernel::Rbf { gamma } => svc.rbf(*gamma),
408 crate::svc::SvcKernel::Poly {
409 degree,
410 gamma,
411 coef0,
412 } => svc.poly(*degree, *gamma, *coef0),
413 crate::svc::SvcKernel::Sigmoid { gamma, coef0: _ } => svc.rbf(*gamma),
414 crate::svc::SvcKernel::Custom(kernel) => svc.kernel(kernel.clone()),
415 }
416 .fit(&x_binary, &y_binary)?;
417
418 return Ok(TreeNode::Internal {
419 classifier: Box::new(fitted_svc),
420 left_classes: vec![left_class],
421 right_classes: vec![right_class],
422 left_child: Box::new(TreeNode::Leaf(left_class)),
423 right_child: Box::new(TreeNode::Leaf(right_class)),
424 });
425 }
426
427 let (left_classes, right_classes) = self.split_classes(classes);
429
430 let mut binary_y = Array1::zeros(y.len());
432 for (i, &label) in y.iter().enumerate() {
433 if left_classes.contains(&label) {
434 binary_y[i] = 0.0;
435 } else if right_classes.contains(&label) {
436 binary_y[i] = 1.0;
437 }
438 }
439
440 let mut relevant_indices = Vec::new();
442 for (i, &label) in y.iter().enumerate() {
443 if classes.contains(&label) {
444 relevant_indices.push(i);
445 }
446 }
447
448 if relevant_indices.is_empty() {
449 return Err(SklearsError::InvalidInput(
450 "No samples found for hierarchical tree node".to_string(),
451 ));
452 }
453
454 let n_relevant = relevant_indices.len();
456 let n_features = x.ncols();
457 let mut x_relevant = Array2::zeros((n_relevant, n_features));
458 let mut y_relevant = Array1::zeros(n_relevant);
459
460 for (new_idx, &old_idx) in relevant_indices.iter().enumerate() {
461 x_relevant.row_mut(new_idx).assign(&x.row(old_idx));
462 y_relevant[new_idx] = binary_y[old_idx];
463 }
464
465 let svc = SVC::new()
467 .c(self.config.c)
468 .tol(self.config.tol)
469 .max_iter(self.config.max_iter);
470
471 let fitted_svc = match &self.config.kernel {
472 crate::svc::SvcKernel::Linear => svc.linear(),
473 crate::svc::SvcKernel::Rbf { gamma } => svc.rbf(*gamma),
474 crate::svc::SvcKernel::Poly {
475 degree,
476 gamma,
477 coef0,
478 } => svc.poly(*degree, *gamma, *coef0),
479 crate::svc::SvcKernel::Sigmoid { gamma, coef0: _ } => svc.rbf(*gamma),
480 crate::svc::SvcKernel::Custom(kernel) => svc.kernel(kernel.clone()),
481 }
482 .fit(&x_relevant, &y_relevant)?;
483
484 let left_child = Box::new(self.build_hierarchical_tree(x, y, &left_classes)?);
486 let right_child = Box::new(self.build_hierarchical_tree(x, y, &right_classes)?);
487
488 Ok(TreeNode::Internal {
489 classifier: Box::new(fitted_svc),
490 left_classes,
491 right_classes,
492 left_child,
493 right_child,
494 })
495 }
496
497 fn split_classes(&self, classes: &[Float]) -> (Vec<Float>, Vec<Float>) {
501 let mut left_classes = Vec::new();
502 let mut right_classes = Vec::new();
503
504 for (i, &class) in classes.iter().enumerate() {
505 if i % 2 == 0 {
506 left_classes.push(class);
507 } else {
508 right_classes.push(class);
509 }
510 }
511
512 if left_classes.is_empty() {
514 left_classes.push(right_classes.pop().expect("empty collection"));
515 } else if right_classes.is_empty() {
516 right_classes.push(left_classes.pop().expect("empty collection"));
517 }
518
519 (left_classes, right_classes)
520 }
521}
522
523impl MultiClassSVC<Trained> {
524 pub fn classes(&self) -> &Array1<Float> {
526 self.classes_
527 .as_ref()
528 .expect("MultiClassSVC should be fitted")
529 }
530
531 pub fn n_estimators(&self) -> usize {
533 self.estimators_
534 .as_ref()
535 .expect("MultiClassSVC should be fitted")
536 .len()
537 }
538
539 pub fn estimators(&self) -> &[SVC<Trained>] {
541 self.estimators_
542 .as_ref()
543 .expect("MultiClassSVC should be fitted")
544 }
545
546 pub fn decision_function(&self, x: &Array2<Float>) -> Result<Array2<Float>> {
548 let (n_samples, n_features) = x.dim();
549
550 if n_features
551 != self
552 .n_features_in_
553 .expect("n_features_in_ not available - model not fitted")
554 {
555 return Err(SklearsError::InvalidInput(format!(
556 "Feature mismatch: expected {} features, got {}",
557 self.n_features_in_
558 .expect("n_features_in_ not available - model not fitted"),
559 n_features
560 )));
561 }
562
563 let estimators = self.estimators();
564 let n_estimators = estimators.len();
565 let mut decision_scores = Array2::zeros((n_samples, n_estimators));
566
567 for (i, estimator) in estimators.iter().enumerate() {
568 let scores = estimator.decision_function(x)?;
569 decision_scores.column_mut(i).assign(&scores);
570 }
571
572 Ok(decision_scores)
573 }
574}
575
576impl<State> MultiClassSVC<State> {
577 fn ecoc_hamming_distance(predicted_code: &Array1<Float>, class_code: &Array1<Float>) -> usize {
579 predicted_code
580 .iter()
581 .zip(class_code.iter())
582 .map(|(&pred, &class)| {
583 if (pred > 0.0 && class > 0.0) || (pred <= 0.0 && class <= 0.0) {
584 0
585 } else {
586 1
587 }
588 })
589 .sum()
590 }
591}
592
593impl Default for MultiClassSVC<Untrained> {
594 fn default() -> Self {
595 Self::new()
596 }
597}
598
599impl Fit<Array2<Float>, Array1<Float>> for MultiClassSVC<Untrained> {
600 type Fitted = MultiClassSVC<Trained>;
601
602 fn fit(self, x: &Array2<Float>, y: &Array1<Float>) -> Result<Self::Fitted> {
603 let (n_samples, n_features) = x.dim();
604
605 if n_samples != y.len() {
606 return Err(SklearsError::InvalidInput(
607 "Number of samples in X and y must match".to_string(),
608 ));
609 }
610
611 if n_samples == 0 {
612 return Err(SklearsError::InvalidInput(
613 "Cannot fit MultiClassSVC on empty dataset".to_string(),
614 ));
615 }
616
617 let classes = Self::find_classes(y);
619
620 if classes.len() < 2 {
621 return Err(SklearsError::InvalidInput(
622 "MultiClassSVC requires at least 2 classes".to_string(),
623 ));
624 }
625
626 if classes.len() == 2 {
628 let svc = SVC::new()
629 .c(self.config.c)
630 .tol(self.config.tol)
631 .max_iter(self.config.max_iter);
632
633 let fitted_svc = match &self.config.kernel {
634 crate::svc::SvcKernel::Linear => svc.linear(),
635 crate::svc::SvcKernel::Rbf { gamma } => svc.rbf(*gamma),
636 crate::svc::SvcKernel::Poly {
637 degree,
638 gamma,
639 coef0,
640 } => svc.poly(*degree, *gamma, *coef0),
641 crate::svc::SvcKernel::Sigmoid { gamma, coef0: _ } => {
642 svc.rbf(*gamma)
644 }
645 crate::svc::SvcKernel::Custom(kernel) => svc.kernel(kernel.clone()),
646 }
647 .fit(x, y)?;
648
649 return Ok(MultiClassSVC {
650 config: self.config,
651 strategy: self.strategy,
652 state: PhantomData,
653 estimators_: Some(vec![fitted_svc]),
654 classes_: Some(classes),
655 n_features_in_: Some(n_features),
656 class_pairs_: None,
657 codebook_: None,
658 hierarchy_tree_: None,
659 });
660 }
661
662 let mut estimators = Vec::new();
664 let mut class_pairs = None;
665 let mut codebook = None;
666 let mut hierarchy_tree = None;
667
668 match self.strategy {
669 MultiClassStrategy::OneVsRest => {
670 for &positive_class in classes.iter() {
672 let binary_y = Self::create_ovr_labels(y, positive_class);
673
674 let svc = SVC::new()
675 .c(self.config.c)
676 .tol(self.config.tol)
677 .max_iter(self.config.max_iter);
678
679 let fitted_svc = match &self.config.kernel {
680 crate::svc::SvcKernel::Linear => svc.linear(),
681 crate::svc::SvcKernel::Rbf { gamma } => svc.rbf(*gamma),
682 crate::svc::SvcKernel::Poly {
683 degree,
684 gamma,
685 coef0,
686 } => svc.poly(*degree, *gamma, *coef0),
687 crate::svc::SvcKernel::Sigmoid { gamma, coef0: _ } => {
688 svc.rbf(*gamma)
690 }
691 crate::svc::SvcKernel::Custom(kernel) => svc.kernel(kernel.clone()),
692 }
693 .fit(x, &binary_y)?;
694
695 estimators.push(fitted_svc);
696 }
697 }
698 MultiClassStrategy::OneVsOne | MultiClassStrategy::OneVsOneDecision => {
699 let pairs = Self::generate_class_pairs(&classes);
701
702 for &(class_a, class_b) in &pairs {
703 let (x_binary, y_binary, _) = Self::extract_ovo_samples(x, y, class_a, class_b);
704
705 let svc = SVC::new()
706 .c(self.config.c)
707 .tol(self.config.tol)
708 .max_iter(self.config.max_iter);
709
710 let fitted_svc = match &self.config.kernel {
711 crate::svc::SvcKernel::Linear => svc.linear(),
712 crate::svc::SvcKernel::Rbf { gamma } => svc.rbf(*gamma),
713 crate::svc::SvcKernel::Poly {
714 degree,
715 gamma,
716 coef0,
717 } => svc.poly(*degree, *gamma, *coef0),
718 crate::svc::SvcKernel::Sigmoid { gamma, coef0: _ } => {
719 svc.rbf(*gamma)
721 }
722 crate::svc::SvcKernel::Custom(kernel) => svc.kernel(kernel.clone()),
723 }
724 .fit(&x_binary, &y_binary)?;
725
726 estimators.push(fitted_svc);
727 }
728
729 class_pairs = Some(pairs);
730 }
731 MultiClassStrategy::Ecoc => {
732 let generated_codebook = Self::generate_ecoc_codebook(classes.len(), None);
734 let n_bits = generated_codebook.ncols();
735
736 for bit_idx in 0..n_bits {
738 let binary_y =
739 Self::create_ecoc_labels(y, &classes, bit_idx, &generated_codebook);
740
741 let has_positive = binary_y.iter().any(|&x| x > 0.0);
743 let has_negative = binary_y.iter().any(|&x| x <= 0.0);
744 if !has_positive || !has_negative {
745 continue; }
747
748 let svc = SVC::new()
749 .c(self.config.c)
750 .tol(self.config.tol)
751 .max_iter(self.config.max_iter);
752
753 let fitted_svc = match &self.config.kernel {
754 crate::svc::SvcKernel::Linear => svc.linear(),
755 crate::svc::SvcKernel::Rbf { gamma } => svc.rbf(*gamma),
756 crate::svc::SvcKernel::Poly {
757 degree,
758 gamma,
759 coef0,
760 } => svc.poly(*degree, *gamma, *coef0),
761 crate::svc::SvcKernel::Sigmoid { gamma, coef0: _ } => {
762 svc.rbf(*gamma)
764 }
765 crate::svc::SvcKernel::Custom(kernel) => svc.kernel(kernel.clone()),
766 }
767 .fit(x, &binary_y)?;
768
769 estimators.push(fitted_svc);
770 }
771
772 codebook = Some(generated_codebook);
773 }
774 MultiClassStrategy::HierarchicalTree => {
775 let root = self.build_hierarchical_tree(x, y, &classes.to_vec())?;
777 hierarchy_tree = Some(HierarchicalTree::new(root));
778
779 }
782 }
783
784 Ok(MultiClassSVC {
785 config: self.config,
786 strategy: self.strategy,
787 state: PhantomData,
788 estimators_: Some(estimators),
789 classes_: Some(classes),
790 n_features_in_: Some(n_features),
791 class_pairs_: class_pairs,
792 codebook_: codebook,
793 hierarchy_tree_: hierarchy_tree,
794 })
795 }
796}
797
798impl Predict<Array2<Float>, Array1<Float>> for MultiClassSVC<Trained> {
799 fn predict(&self, x: &Array2<Float>) -> Result<Array1<Float>> {
800 let (n_samples, n_features) = x.dim();
801
802 if n_features
803 != self
804 .n_features_in_
805 .expect("n_features_in_ not available - model not fitted")
806 {
807 return Err(SklearsError::InvalidInput(format!(
808 "Feature mismatch: expected {} features, got {}",
809 self.n_features_in_
810 .expect("n_features_in_ not available - model not fitted"),
811 n_features
812 )));
813 }
814
815 let classes = self.classes();
816 let estimators = self.estimators();
817
818 if classes.len() == 2 {
820 return estimators[0].predict(x);
821 }
822
823 let mut predictions = Array1::zeros(n_samples);
824
825 match self.strategy {
826 MultiClassStrategy::OneVsRest => {
827 let decision_scores = self.decision_function(x)?;
829
830 for i in 0..n_samples {
832 let mut max_score = Float::NEG_INFINITY;
833 let mut best_class = classes[0];
834
835 for (j, &class) in classes.iter().enumerate() {
836 let score = decision_scores[[i, j]];
837 if score > max_score {
838 max_score = score;
839 best_class = class;
840 }
841 }
842
843 predictions[i] = best_class;
844 }
845 }
846 MultiClassStrategy::OneVsOne => {
847 let pairs = self
848 .class_pairs_
849 .as_ref()
850 .expect("class_pairs_ not available - model not fitted");
851
852 for i in 0..n_samples {
854 let mut votes = vec![0usize; classes.len()];
855 let mut decision_sums = vec![0.0; classes.len()];
856
857 for (j, &(class_a, class_b)) in pairs.iter().enumerate() {
859 let sample_view = x.row(i);
860 let sample = Array2::from_shape_vec((1, n_features), sample_view.to_vec())
861 .expect("array shape mismatch");
862 let prediction = estimators[j].predict(&sample)?;
863 let decision_score = estimators[j].decision_function(&sample)?[0];
864
865 let predicted_class = if prediction[0] == 0.0 {
866 class_a
867 } else {
868 class_b
869 };
870
871 let idx_a = classes
873 .iter()
874 .position(|&c| c == class_a)
875 .expect("element not found");
876 let idx_b = classes
877 .iter()
878 .position(|&c| c == class_b)
879 .expect("element not found");
880
881 if decision_score > 0.0 {
883 decision_sums[idx_b] += decision_score;
884 } else {
885 decision_sums[idx_a] += -decision_score;
886 }
887
888 for (class_idx, &class) in classes.iter().enumerate() {
890 if class == predicted_class {
891 votes[class_idx] += 1;
892 break;
893 }
894 }
895 }
896
897 let max_votes = *votes.iter().max().expect("collection should not be empty");
899 let tied_classes: Vec<usize> = votes
900 .iter()
901 .enumerate()
902 .filter(|&(_, &count)| count == max_votes)
903 .map(|(idx, _)| idx)
904 .collect();
905
906 let best_class_idx = if tied_classes.len() == 1 {
907 tied_classes[0]
908 } else {
909 tied_classes
911 .iter()
912 .max_by(|&&a, &&b| {
913 decision_sums[a]
914 .partial_cmp(&decision_sums[b])
915 .unwrap_or(std::cmp::Ordering::Equal)
916 })
917 .copied()
918 .unwrap_or(0)
919 };
920
921 predictions[i] = classes[best_class_idx];
922 }
923 }
924 MultiClassStrategy::OneVsOneDecision => {
925 let pairs = self
926 .class_pairs_
927 .as_ref()
928 .expect("class_pairs_ not available - model not fitted");
929
930 for i in 0..n_samples {
932 let mut decision_sums = vec![0.0; classes.len()];
933
934 for (j, &(class_a, class_b)) in pairs.iter().enumerate() {
936 let sample_view = x.row(i);
937 let sample = Array2::from_shape_vec((1, n_features), sample_view.to_vec())
938 .expect("array shape mismatch");
939 let decision_score = estimators[j].decision_function(&sample)?[0];
940
941 let idx_a = classes
943 .iter()
944 .position(|&c| c == class_a)
945 .expect("element not found");
946 let idx_b = classes
947 .iter()
948 .position(|&c| c == class_b)
949 .expect("element not found");
950
951 if decision_score > 0.0 {
953 decision_sums[idx_b] += decision_score;
954 } else {
955 decision_sums[idx_a] += -decision_score;
956 }
957 }
958
959 let best_class_idx = decision_sums
961 .iter()
962 .enumerate()
963 .max_by(|&(_, &a), &(_, &b)| {
964 a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal)
965 })
966 .map(|(idx, _)| idx)
967 .unwrap_or(0);
968
969 predictions[i] = classes[best_class_idx];
970 }
971 }
972 MultiClassStrategy::Ecoc => {
973 let codebook = self
974 .codebook_
975 .as_ref()
976 .expect("codebook_ not available - model not fitted");
977 let n_bits = codebook.ncols();
978
979 for i in 0..n_samples {
981 let mut predicted_code = Array1::zeros(n_bits);
982
983 for bit_idx in 0..n_bits {
985 let sample_view = x.row(i);
986 let sample = Array2::from_shape_vec((1, n_features), sample_view.to_vec())
987 .expect("array shape mismatch");
988 let decision_score = estimators[bit_idx].decision_function(&sample)?[0];
989 predicted_code[bit_idx] = if decision_score > 0.0 { 1.0 } else { -1.0 };
990 }
991
992 let mut min_distance = usize::MAX;
994 let mut best_class_idx = 0;
995
996 for (class_idx, &_class) in classes.iter().enumerate() {
997 let class_code = codebook.row(class_idx);
998 let distance =
999 Self::ecoc_hamming_distance(&predicted_code, &class_code.to_owned());
1000
1001 if distance < min_distance {
1002 min_distance = distance;
1003 best_class_idx = class_idx;
1004 }
1005 }
1006
1007 predictions[i] = classes[best_class_idx];
1008 }
1009 }
1010 MultiClassStrategy::HierarchicalTree => {
1011 let tree = self
1012 .hierarchy_tree_
1013 .as_ref()
1014 .expect("hierarchy_tree_ not available - model not fitted");
1015
1016 for i in 0..n_samples {
1017 let sample_view = x.row(i);
1018 let sample = Array2::from_shape_vec((1, n_features), sample_view.to_vec())
1019 .expect("array shape mismatch");
1020 predictions[i] = tree.predict_sample(&sample)?;
1021 }
1022 }
1023 }
1024
1025 Ok(predictions)
1026 }
1027}
1028
1029#[allow(non_snake_case)]
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033 use scirs2_core::ndarray::array;
1034
1035 #[test]
1036 #[ignore = "Slow test: trains multiple SVM classifiers. Run with --ignored flag"]
1037 fn test_multiclass_svc_ovr() {
1038 let x = array![
1040 [1.0, 1.0], [1.1, 1.1], [5.0, 5.0], [5.1, 5.1], [9.0, 9.0], [9.1, 9.1], ];
1047 let y = array![0.0, 0.0, 1.0, 1.0, 2.0, 2.0];
1048
1049 let svc = MultiClassSVC::new()
1050 .linear()
1051 .c(1.0)
1052 .tol(0.1) .max_iter(10) .one_vs_rest()
1055 .fit(&x, &y)
1056 .expect("operation should succeed");
1057
1058 assert_eq!(svc.classes().len(), 3);
1060 assert_eq!(svc.n_estimators(), 3); let x_test = array![
1064 [1.0, 1.0], [5.0, 5.0], [9.0, 9.0], ];
1068 let predictions = svc.predict(&x_test).expect("prediction should succeed");
1069 assert_eq!(predictions.len(), 3);
1070 }
1071
1072 #[test]
1073 #[ignore = "Slow test: trains multiple SVM classifiers. Run with --ignored flag"]
1074 fn test_multiclass_svc_ovo() {
1075 let x = array![
1077 [1.0, 1.0], [1.1, 1.1], [5.0, 5.0], [5.1, 5.1], [9.0, 9.0], [9.1, 9.1], ];
1084 let y = array![0.0, 0.0, 1.0, 1.0, 2.0, 2.0];
1085
1086 let svc = MultiClassSVC::new()
1087 .linear()
1088 .c(1.0)
1089 .tol(0.1) .max_iter(10) .one_vs_one()
1092 .fit(&x, &y)
1093 .expect("operation should succeed");
1094
1095 assert_eq!(svc.classes().len(), 3);
1097 assert_eq!(svc.n_estimators(), 3); let x_test = array![
1101 [1.0, 1.0], [5.0, 5.0], [9.0, 9.0], ];
1105 let predictions = svc.predict(&x_test).expect("prediction should succeed");
1106 assert_eq!(predictions.len(), 3);
1107 }
1108
1109 #[test]
1110 fn test_multiclass_svc_binary_fallback() {
1111 let x = array![[1.0, 1.0], [2.0, 2.0], [-1.0, -1.0], [-2.0, -2.0],];
1113 let y = array![1.0, 1.0, 0.0, 0.0];
1114
1115 let svc = MultiClassSVC::new()
1116 .linear()
1117 .c(1.0)
1118 .fit(&x, &y)
1119 .expect("model fitting should succeed");
1120
1121 assert_eq!(svc.classes().len(), 2);
1122 assert_eq!(svc.n_estimators(), 1); let x_test = array![[1.5, 1.5], [-1.5, -1.5]];
1125 let predictions = svc.predict(&x_test).expect("prediction should succeed");
1126 assert_eq!(predictions.len(), 2);
1127 }
1128
1129 #[test]
1130 fn test_generate_class_pairs() {
1131 let classes = array![0.0, 1.0, 2.0, 3.0];
1132 let pairs = MultiClassSVC::generate_class_pairs(&classes);
1133
1134 let expected = vec![
1135 (0.0, 1.0),
1136 (0.0, 2.0),
1137 (0.0, 3.0),
1138 (1.0, 2.0),
1139 (1.0, 3.0),
1140 (2.0, 3.0),
1141 ];
1142
1143 assert_eq!(pairs, expected);
1144 assert_eq!(pairs.len(), 6); }
1146
1147 #[test]
1148 fn test_create_ovr_labels() {
1149 let y = array![0.0, 1.0, 2.0, 0.0, 1.0, 2.0];
1150 let binary_y = MultiClassSVC::create_ovr_labels(&y, 1.0);
1151 let expected = array![0.0, 1.0, 0.0, 0.0, 1.0, 0.0];
1152 assert_eq!(binary_y, expected);
1153 }
1154
1155 #[test]
1156 #[ignore = "Slow test: trains multiple SVM classifiers. Run with --ignored flag"]
1157 fn test_multiclass_svc_ecoc() {
1158 let x = array![
1160 [1.0, 1.0], [1.1, 1.1], [5.0, 5.0], [5.1, 5.1], [9.0, 9.0], [9.1, 9.1], ];
1167 let y = array![0.0, 0.0, 1.0, 1.0, 2.0, 2.0];
1168
1169 let svc = MultiClassSVC::new()
1170 .linear()
1171 .c(1.0)
1172 .tol(0.1) .max_iter(10) .ecoc()
1175 .fit(&x, &y)
1176 .expect("operation should succeed");
1177
1178 assert_eq!(svc.classes().len(), 3);
1180 assert!(svc.n_estimators() >= 4); let x_test = array![
1184 [1.0, 1.0], [5.0, 5.0], [9.0, 9.0], ];
1188 let predictions = svc.predict(&x_test).expect("prediction should succeed");
1189 assert_eq!(predictions.len(), 3);
1190 }
1191
1192 #[test]
1193 fn test_ecoc_codebook_generation() {
1194 let codebook = MultiClassSVC::generate_ecoc_codebook(4, Some(6));
1195 assert_eq!(codebook.nrows(), 4); assert_eq!(codebook.ncols(), 6); for &val in codebook.iter() {
1200 assert!(val == 1.0 || val == -1.0);
1201 }
1202 }
1203
1204 #[test]
1205 fn test_ecoc_hamming_distance() {
1206 let code1 = array![1.0, -1.0, 1.0, -1.0];
1207 let code2 = array![1.0, 1.0, 1.0, -1.0];
1208 let distance = MultiClassSVC::<Untrained>::ecoc_hamming_distance(&code1, &code2);
1209 assert_eq!(distance, 1); let code3 = array![-1.0, 1.0, -1.0, 1.0];
1212 let distance2 = MultiClassSVC::<Untrained>::ecoc_hamming_distance(&code1, &code3);
1213 assert_eq!(distance2, 4); }
1215
1216 #[test]
1217 #[ignore = "Slow test: trains multiple SVM classifiers. Run with --ignored flag"]
1218 fn test_multiclass_svc_hierarchical_tree() {
1219 let x = array![
1221 [1.0, 1.0], [1.1, 1.1], [5.0, 5.0], [5.1, 5.1], [9.0, 9.0], [9.1, 9.1], [13.0, 13.0], [13.1, 13.1], ];
1230 let y = array![0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0];
1231
1232 let svc = MultiClassSVC::new()
1233 .linear()
1234 .c(1.0)
1235 .tol(0.1) .max_iter(10) .hierarchical_tree()
1238 .fit(&x, &y)
1239 .expect("operation should succeed");
1240
1241 assert_eq!(svc.classes().len(), 4);
1243 let x_test = array![
1247 [1.0, 1.0], [5.0, 5.0], [9.0, 9.0], [13.0, 13.0], ];
1252 let predictions = svc.predict(&x_test).expect("prediction should succeed");
1253 assert_eq!(predictions.len(), 4);
1254
1255 for &pred in predictions.iter() {
1257 assert!((0.0..=3.0).contains(&pred));
1258 }
1259 }
1260
1261 #[test]
1262 fn test_hierarchical_tree_decision_path() {
1263 let tree = HierarchicalTree::new(TreeNode::Leaf(1.0));
1265
1266 let sample = array![[1.0, 2.0]];
1267 let path = tree
1268 .decision_path(&sample)
1269 .expect("decision path should succeed");
1270
1271 assert_eq!(path.len(), 0);
1273 }
1274
1275 #[test]
1276 fn test_split_classes_method() {
1277 let svc = MultiClassSVC::new();
1278
1279 let classes = [0.0, 1.0, 2.0, 3.0];
1281 let (left, right) = svc.split_classes(&classes);
1282 assert_eq!(left.len() + right.len(), 4);
1283 assert!(!left.is_empty());
1284 assert!(!right.is_empty());
1285
1286 let classes = [0.0, 1.0, 2.0];
1288 let (left, right) = svc.split_classes(&classes);
1289 assert_eq!(left.len() + right.len(), 3);
1290 assert!(!left.is_empty());
1291 assert!(!right.is_empty());
1292
1293 let classes = [0.0];
1295 let (left, right) = svc.split_classes(&classes);
1296 assert_eq!(left.len() + right.len(), 1);
1297 assert!(!left.is_empty() || !right.is_empty()); }
1299}