Skip to main content

sklears_svm/
multiclass.rs

1//! Multi-class SVM classification using One-vs-Rest and One-vs-One strategies
2
3use 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/// Multi-class strategy for SVM
13#[derive(Debug, Clone, Copy, PartialEq, Default)]
14pub enum MultiClassStrategy {
15    /// One-vs-Rest (OvR) strategy
16    #[default]
17    OneVsRest,
18    /// One-vs-One (OvO) strategy with majority voting
19    OneVsOne,
20    /// One-vs-One (OvO) strategy with decision-based voting
21    OneVsOneDecision,
22    /// Error-Correcting Output Codes (ECOC) strategy
23    Ecoc,
24    /// Hierarchical classification using binary tree
25    HierarchicalTree,
26}
27
28/// Node in a hierarchical classification tree
29#[derive(Debug, Clone)]
30pub enum TreeNode {
31    /// Leaf node containing a class label
32    Leaf(Float),
33    /// Internal node with classifier and children
34    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/// Hierarchical tree for multi-class classification
44#[derive(Debug, Clone)]
45pub struct HierarchicalTree {
46    root: TreeNode,
47}
48
49impl HierarchicalTree {
50    /// Create a new hierarchical tree with the given root
51    pub fn new(root: TreeNode) -> Self {
52        Self { root }
53    }
54
55    /// Predict class for a single sample
56    pub fn predict_sample(&self, x: &Array2<Float>) -> Result<Float> {
57        self.predict_node(&self.root, x)
58    }
59
60    /// Recursively predict using tree nodes
61    #[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    /// Get decision path for a sample (for interpretability)
83    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/// Multi-class Support Vector Classification
118#[derive(Debug, Clone)]
119pub struct MultiClassSVC<State = Untrained> {
120    config: SvcConfig,
121    strategy: MultiClassStrategy,
122    state: PhantomData<State>,
123    // Fitted parameters
124    estimators_: Option<Vec<SVC<Trained>>>,
125    classes_: Option<Array1<Float>>,
126    n_features_in_: Option<usize>,
127    class_pairs_: Option<Vec<(Float, Float)>>, // For OvO strategy
128    codebook_: Option<Array2<Float>>,          // For ECOC strategy (classes x bits)
129    hierarchy_tree_: Option<HierarchicalTree>, // For hierarchical strategy
130}
131
132impl MultiClassSVC<Untrained> {
133    /// Create a new multi-class SVC
134    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    /// Set the regularization parameter C
149    pub fn c(mut self, c: Float) -> Self {
150        self.config.c = c;
151        self
152    }
153
154    /// Set the kernel to linear
155    pub fn linear(mut self) -> Self {
156        self.config.kernel = crate::svc::SvcKernel::Linear;
157        self
158    }
159
160    /// Set the kernel to RBF with optional gamma
161    pub fn rbf(mut self, gamma: Option<Float>) -> Self {
162        self.config.kernel = crate::svc::SvcKernel::Rbf { gamma };
163        self
164    }
165
166    /// Set the kernel to polynomial
167    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    /// Set the tolerance for stopping criterion
177    pub fn tol(mut self, tol: Float) -> Self {
178        self.config.tol = tol;
179        self
180    }
181
182    /// Set the maximum number of iterations
183    pub fn max_iter(mut self, max_iter: usize) -> Self {
184        self.config.max_iter = max_iter;
185        self
186    }
187
188    /// Set the multi-class strategy
189    pub fn strategy(mut self, strategy: MultiClassStrategy) -> Self {
190        self.strategy = strategy;
191        self
192    }
193
194    /// Set One-vs-Rest strategy
195    pub fn one_vs_rest(mut self) -> Self {
196        self.strategy = MultiClassStrategy::OneVsRest;
197        self
198    }
199
200    /// Set One-vs-One strategy
201    pub fn one_vs_one(mut self) -> Self {
202        self.strategy = MultiClassStrategy::OneVsOne;
203        self
204    }
205
206    /// Set One-vs-One strategy with decision-based voting
207    pub fn one_vs_one_decision(mut self) -> Self {
208        self.strategy = MultiClassStrategy::OneVsOneDecision;
209        self
210    }
211
212    /// Set Error-Correcting Output Codes (ECOC) strategy
213    pub fn ecoc(mut self) -> Self {
214        self.strategy = MultiClassStrategy::Ecoc;
215        self
216    }
217
218    /// Set hierarchical tree strategy
219    pub fn hierarchical_tree(mut self) -> Self {
220        self.strategy = MultiClassStrategy::HierarchicalTree;
221        self
222    }
223
224    /// Set balanced class weights
225    pub fn balanced(mut self) -> Self {
226        self.config.class_weight = Some(crate::svc::ClassWeight::Balanced);
227        self
228    }
229
230    /// Find unique classes in the target array
231    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    /// Generate class pairs for One-vs-One strategy
239    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    /// Create binary labels for One-vs-Rest
250    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    /// Extract samples for One-vs-One classification
255    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    /// Generate ECOC codebook using random codes with improved discrimination
283    fn generate_ecoc_codebook(n_classes: usize, n_bits: Option<usize>) -> Array2<Float> {
284        // Use at least ceil(log2(n_classes)) bits, but more for error correction
285        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) // Use more bits for better error correction
288        });
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            // Generate random binary codes with balanced bits
298            for j in 0..n_bits {
299                // Ensure each bit has roughly balanced +1/-1 values
300                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                // Shuffle to randomize assignment
305                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            // Check if codebook is valid (no identical or complement codes)
316            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                    // Require minimum Hamming distance
332                    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                // Fallback to simple random generation if we can't find good codes
352                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    /// Create binary labels for ECOC bit classifier
364    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    /// Build hierarchical tree using clustering-based approach
380    fn build_hierarchical_tree(
381        &self,
382        x: &Array2<Float>,
383        y: &Array1<Float>,
384        classes: &[Float],
385    ) -> Result<TreeNode> {
386        // Base case: single class
387        if classes.len() == 1 {
388            return Ok(TreeNode::Leaf(classes[0]));
389        }
390
391        // Base case: two classes - create binary classifier
392        if classes.len() == 2 {
393            let left_class = classes[0];
394            let right_class = classes[1];
395
396            // Extract samples for binary classification
397            let (x_binary, y_binary, _) = Self::extract_ovo_samples(x, y, left_class, right_class);
398
399            // Create and train binary classifier
400            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        // Recursive case: split classes into two groups
428        let (left_classes, right_classes) = self.split_classes(classes);
429
430        // Create binary labels for the split
431        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        // Filter samples that belong to the current classes
441        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        // Extract relevant samples
455        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        // Train binary classifier for this split
466        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        // Recursively build child trees
485        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    /// Split classes into two roughly equal groups
498    /// For simplicity, we use a round-robin approach
499    /// In practice, you might use clustering or other sophisticated methods
500    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        // Ensure both groups have at least one class
513        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    /// Get the classes
525    pub fn classes(&self) -> &Array1<Float> {
526        self.classes_
527            .as_ref()
528            .expect("MultiClassSVC should be fitted")
529    }
530
531    /// Get the number of estimators
532    pub fn n_estimators(&self) -> usize {
533        self.estimators_
534            .as_ref()
535            .expect("MultiClassSVC should be fitted")
536            .len()
537    }
538
539    /// Get a reference to the binary estimators
540    pub fn estimators(&self) -> &[SVC<Trained>] {
541        self.estimators_
542            .as_ref()
543            .expect("MultiClassSVC should be fitted")
544    }
545
546    /// Compute decision function values for each binary classifier
547    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    /// Compute Hamming distance between predicted code and class codewords
578    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        // Find unique classes
618        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 binary classification, use single SVC
627        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                    // For now, use RBF as sigmoid is not implemented in SVC
643                    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        // Multi-class case
663        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                // Train one binary classifier per class
671                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                            // For now, use RBF as sigmoid is not implemented in SVC
689                            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                // Train one binary classifier for each pair of classes
700                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                            // For now, use RBF as sigmoid is not implemented in SVC
720                            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                // Generate ECOC codebook
733                let generated_codebook = Self::generate_ecoc_codebook(classes.len(), None);
734                let n_bits = generated_codebook.ncols();
735
736                // Train one binary classifier for each bit
737                for bit_idx in 0..n_bits {
738                    let binary_y =
739                        Self::create_ecoc_labels(y, &classes, bit_idx, &generated_codebook);
740
741                    // Skip bits that have only one class (all same values)
742                    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; // Skip this bit as it doesn't provide discriminative information
746                    }
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                            // For now, use RBF as sigmoid is not implemented in SVC
763                            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                // Build hierarchical tree
776                let root = self.build_hierarchical_tree(x, y, &classes.to_vec())?;
777                hierarchy_tree = Some(HierarchicalTree::new(root));
778
779                // For hierarchical tree, we don't need separate estimators since they're embedded in the tree
780                // But we'll keep the estimators empty for consistency with the interface
781            }
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        // Binary classification case
819        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                // Get decision scores from all binary classifiers
828                let decision_scores = self.decision_function(x)?;
829
830                // Predict class with highest decision score
831                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                // Vote-based prediction with improved tie handling
853                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                    // Get votes from each binary classifier
858                    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                        // Find class indices for decision scores
872                        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                        // Accumulate decision scores
882                        if decision_score > 0.0 {
883                            decision_sums[idx_b] += decision_score;
884                        } else {
885                            decision_sums[idx_a] += -decision_score;
886                        }
887
888                        // Count votes
889                        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                    // Find class with most votes, use decision scores for tie-breaking
898                    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                        // Break tie using decision scores
910                        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                // Decision-based prediction
931                for i in 0..n_samples {
932                    let mut decision_sums = vec![0.0; classes.len()];
933
934                    // Accumulate decision scores for each class
935                    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                        // Find class indices
942                        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                        // Positive score favors class_b, negative favors class_a
952                        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                    // Find class with highest decision sum
960                    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                // Predict using ECOC
980                for i in 0..n_samples {
981                    let mut predicted_code = Array1::zeros(n_bits);
982
983                    // Get prediction from each bit classifier
984                    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                    // Find closest codeword using Hamming distance
993                    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        // Create a simple 3-class dataset
1039        let x = array![
1040            [1.0, 1.0], // Class 0
1041            [1.1, 1.1], // Class 0
1042            [5.0, 5.0], // Class 1
1043            [5.1, 5.1], // Class 1
1044            [9.0, 9.0], // Class 2
1045            [9.1, 9.1], // Class 2
1046        ];
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) // Very high tolerance for test speed
1053            .max_iter(10) // Very low iterations for tests
1054            .one_vs_rest()
1055            .fit(&x, &y)
1056            .expect("operation should succeed");
1057
1058        // Check fitted attributes
1059        assert_eq!(svc.classes().len(), 3);
1060        assert_eq!(svc.n_estimators(), 3); // One classifier per class
1061
1062        // Test prediction
1063        let x_test = array![
1064            [1.0, 1.0], // Should be class 0
1065            [5.0, 5.0], // Should be class 1
1066            [9.0, 9.0], // Should be class 2
1067        ];
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        // Create a simple 3-class dataset
1076        let x = array![
1077            [1.0, 1.0], // Class 0
1078            [1.1, 1.1], // Class 0
1079            [5.0, 5.0], // Class 1
1080            [5.1, 5.1], // Class 1
1081            [9.0, 9.0], // Class 2
1082            [9.1, 9.1], // Class 2
1083        ];
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) // Very high tolerance for test speed
1090            .max_iter(10) // Very low iterations for tests
1091            .one_vs_one()
1092            .fit(&x, &y)
1093            .expect("operation should succeed");
1094
1095        // Check fitted attributes
1096        assert_eq!(svc.classes().len(), 3);
1097        assert_eq!(svc.n_estimators(), 3); // One classifier per pair: (0,1), (0,2), (1,2)
1098
1099        // Test prediction
1100        let x_test = array![
1101            [1.0, 1.0], // Should be class 0
1102            [5.0, 5.0], // Should be class 1
1103            [9.0, 9.0], // Should be class 2
1104        ];
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        // Test that binary classification works correctly
1112        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); // Single binary classifier
1123
1124        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); // C(4,2) = 6
1145    }
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        // Create a simple 3-class dataset
1159        let x = array![
1160            [1.0, 1.0], // Class 0
1161            [1.1, 1.1], // Class 0
1162            [5.0, 5.0], // Class 1
1163            [5.1, 5.1], // Class 1
1164            [9.0, 9.0], // Class 2
1165            [9.1, 9.1], // Class 2
1166        ];
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) // Very high tolerance for test speed
1173            .max_iter(10) // Very low iterations for tests
1174            .ecoc()
1175            .fit(&x, &y)
1176            .expect("operation should succeed");
1177
1178        // Check fitted attributes
1179        assert_eq!(svc.classes().len(), 3);
1180        assert!(svc.n_estimators() >= 4); // At least ceil(log2(3)) + 2 = 4 bits
1181
1182        // Test prediction
1183        let x_test = array![
1184            [1.0, 1.0], // Should be class 0
1185            [5.0, 5.0], // Should be class 1
1186            [9.0, 9.0], // Should be class 2
1187        ];
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); // 4 classes
1196        assert_eq!(codebook.ncols(), 6); // 6 bits
1197
1198        // Check that all values are either 1.0 or -1.0
1199        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); // Only second bit differs
1210
1211        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); // All bits differ
1214    }
1215
1216    #[test]
1217    #[ignore = "Slow test: trains multiple SVM classifiers. Run with --ignored flag"]
1218    fn test_multiclass_svc_hierarchical_tree() {
1219        // Create a simple 4-class dataset
1220        let x = array![
1221            [1.0, 1.0],   // Class 0
1222            [1.1, 1.1],   // Class 0
1223            [5.0, 5.0],   // Class 1
1224            [5.1, 5.1],   // Class 1
1225            [9.0, 9.0],   // Class 2
1226            [9.1, 9.1],   // Class 2
1227            [13.0, 13.0], // Class 3
1228            [13.1, 13.1], // Class 3
1229        ];
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) // Very high tolerance for test speed
1236            .max_iter(10) // Very low iterations for tests
1237            .hierarchical_tree()
1238            .fit(&x, &y)
1239            .expect("operation should succeed");
1240
1241        // Check fitted attributes
1242        assert_eq!(svc.classes().len(), 4);
1243        // For hierarchical tree, the estimators may be empty since they're embedded in the tree
1244
1245        // Test prediction
1246        let x_test = array![
1247            [1.0, 1.0],   // Should be class 0
1248            [5.0, 5.0],   // Should be class 1
1249            [9.0, 9.0],   // Should be class 2
1250            [13.0, 13.0], // Should be class 3
1251        ];
1252        let predictions = svc.predict(&x_test).expect("prediction should succeed");
1253        assert_eq!(predictions.len(), 4);
1254
1255        // Check that all predictions are valid classes
1256        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        // Test the decision path functionality
1264        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        // For a leaf node, path should be empty
1272        assert_eq!(path.len(), 0);
1273    }
1274
1275    #[test]
1276    fn test_split_classes_method() {
1277        let svc = MultiClassSVC::new();
1278
1279        // Test even number of classes
1280        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        // Test odd number of classes
1287        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        // Test single class
1294        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()); // At least one should be non-empty
1298    }
1299}