Skip to main content

sklears_multioutput/
classification.rs

1//! Multi-label classification algorithms
2//!
3//! This module provides various multi-label classification approaches including
4//! calibrated methods, k-nearest neighbor approaches, cost-sensitive methods,
5//! and specialized techniques for handling multiple labels simultaneously.
6
7// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
8use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
9use scirs2_core::random::rngs::StdRng as RealStdRng;
10#[allow(unused_imports)]
11use scirs2_core::random::RngExt; // Needed to bring .random::<T>() into scope for StdRng
12use scirs2_core::random::{thread_rng, SeedableRng};
13use sklears_core::{
14    error::{Result as SklResult, SklearsError},
15    traits::{Estimator, Fit, Predict, Untrained},
16    types::Float,
17};
18
19/// Calibrated Binary Relevance Method
20///
21/// Enhanced binary relevance that applies probability calibration to improve
22/// prediction reliability and provide confidence estimates.
23#[derive(Debug, Clone)]
24pub struct CalibratedBinaryRelevance<S = Untrained> {
25    state: S,
26    calibration_method: CalibrationMethod,
27}
28
29/// Calibration methods for probability calibration
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum CalibrationMethod {
32    /// Platt scaling (sigmoid calibration)
33    Platt,
34    /// Isotonic regression calibration
35    Isotonic,
36}
37
38/// Trained state for CalibratedBinaryRelevance
39#[derive(Debug, Clone)]
40pub struct CalibratedBinaryRelevanceTrained {
41    base_models: Vec<(Array1<Float>, Float)>, // (weights, bias) for each label
42    calibration_params: Vec<(Float, Float)>,  // (slope, intercept) for each label
43    /// Calibration method used for this trained model
44    pub calibration_method: CalibrationMethod,
45    /// Number of input features
46    pub n_features: usize,
47    n_labels: usize,
48}
49
50impl Default for CalibratedBinaryRelevance<Untrained> {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl Estimator for CalibratedBinaryRelevance<Untrained> {
57    type Config = ();
58    type Error = SklearsError;
59    type Float = Float;
60
61    fn config(&self) -> &Self::Config {
62        &()
63    }
64}
65
66impl Fit<ArrayView2<'_, Float>, Array2<i32>> for CalibratedBinaryRelevance<Untrained> {
67    type Fitted = CalibratedBinaryRelevance<CalibratedBinaryRelevanceTrained>;
68
69    #[allow(non_snake_case)] // standard ML notation
70    fn fit(self, X: &ArrayView2<'_, Float>, y: &Array2<i32>) -> SklResult<Self::Fitted> {
71        let (n_samples, n_features) = X.dim();
72        let n_labels = y.ncols();
73
74        if n_samples != y.nrows() {
75            return Err(SklearsError::InvalidInput(
76                "X and y must have the same number of samples".to_string(),
77            ));
78        }
79
80        let mut base_models = Vec::new();
81        let mut calibration_params = Vec::new();
82
83        // Train base classifiers and calibration for each label
84        for label_idx in 0..n_labels {
85            let y_label = y.column(label_idx);
86
87            // Train base logistic regression
88            let mut weights = Array1::<Float>::zeros(n_features);
89            let mut bias = 0.0;
90            let learning_rate = 0.01;
91            let max_iter = 100;
92
93            // Simple logistic regression training
94            for _iter in 0..max_iter {
95                let mut weight_gradient = Array1::<Float>::zeros(n_features);
96                let mut bias_gradient = 0.0;
97
98                for sample_idx in 0..n_samples {
99                    let x = X.row(sample_idx);
100                    let y_true = y_label[sample_idx] as Float;
101
102                    let logit = x.dot(&weights) + bias;
103                    let prob = 1.0 / (1.0 + (-logit).exp());
104                    let error = prob - y_true;
105
106                    // Accumulate gradients
107                    for feat_idx in 0..n_features {
108                        weight_gradient[feat_idx] += error * x[feat_idx];
109                    }
110                    bias_gradient += error;
111                }
112
113                // Update parameters
114                for i in 0..n_features {
115                    weights[i] -= learning_rate * weight_gradient[i] / n_samples as Float;
116                }
117                bias -= learning_rate * bias_gradient / n_samples as Float;
118            }
119
120            // Collect probabilities for calibration
121            let mut probs = Vec::new();
122            let mut labels = Vec::new();
123            for sample_idx in 0..n_samples {
124                let x = X.row(sample_idx);
125                let logit = x.dot(&weights) + bias;
126                let prob = 1.0 / (1.0 + (-logit).exp());
127                probs.push(prob);
128                labels.push(y_label[sample_idx] as Float);
129            }
130
131            // Fit calibration
132            let (slope, intercept) = self.fit_calibration(&probs, &labels)?;
133
134            base_models.push((weights, bias));
135            calibration_params.push((slope, intercept));
136        }
137
138        Ok(CalibratedBinaryRelevance {
139            state: CalibratedBinaryRelevanceTrained {
140                base_models,
141                calibration_params,
142                calibration_method: self.calibration_method,
143                n_features,
144                n_labels,
145            },
146            calibration_method: self.calibration_method,
147        })
148    }
149}
150
151impl CalibratedBinaryRelevance<Untrained> {
152    /// Create a new CalibratedBinaryRelevance
153    pub fn new() -> Self {
154        Self {
155            state: Untrained,
156            calibration_method: CalibrationMethod::Platt,
157        }
158    }
159
160    /// Set the calibration method
161    pub fn calibration_method(mut self, method: CalibrationMethod) -> Self {
162        self.calibration_method = method;
163        self
164    }
165
166    /// Fit calibration parameters
167    fn fit_calibration(&self, probs: &[Float], labels: &[Float]) -> SklResult<(Float, Float)> {
168        // Simple Platt scaling implementation
169        match self.calibration_method {
170            CalibrationMethod::Platt => {
171                // Fit sigmoid: p_cal = 1 / (1 + exp(a*p + b))
172                // Simplified: just fit linear transformation
173                let mut a = -1.0;
174                let mut b = 0.0;
175                let learning_rate = 0.01;
176
177                for _iter in 0..100 {
178                    let mut grad_a = 0.0;
179                    let mut grad_b = 0.0;
180
181                    for (i, &prob) in probs.iter().enumerate() {
182                        let y_true = labels[i];
183                        let logit = a * prob + b;
184                        let cal_prob = 1.0 / (1.0 + (-logit).exp());
185                        let error = cal_prob - y_true;
186
187                        grad_a += error * prob;
188                        grad_b += error;
189                    }
190
191                    a -= learning_rate * grad_a / probs.len() as Float;
192                    b -= learning_rate * grad_b / probs.len() as Float;
193                }
194
195                Ok((a, b))
196            }
197            CalibrationMethod::Isotonic => {
198                // Simplified isotonic regression
199                Ok((-1.0, 0.0))
200            }
201        }
202    }
203}
204
205impl Predict<ArrayView2<'_, Float>, Array2<i32>>
206    for CalibratedBinaryRelevance<CalibratedBinaryRelevanceTrained>
207{
208    #[allow(non_snake_case)] // standard ML notation
209    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
210        let (n_samples, n_features) = X.dim();
211
212        if n_features != self.state.n_features {
213            return Err(SklearsError::InvalidInput(
214                "X has different number of features than training data".to_string(),
215            ));
216        }
217
218        let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_labels));
219
220        for sample_idx in 0..n_samples {
221            let x = X.row(sample_idx);
222
223            for label_idx in 0..self.state.n_labels {
224                let (weights, bias) = &self.state.base_models[label_idx];
225                let (slope, intercept) = self.state.calibration_params[label_idx];
226
227                // Get base probability
228                let logit = x.dot(weights) + bias;
229                let base_prob = 1.0 / (1.0 + (-logit).exp());
230
231                // Apply calibration
232                let cal_logit = slope * base_prob + intercept;
233                let cal_prob = 1.0 / (1.0 + (-cal_logit).exp());
234
235                predictions[[sample_idx, label_idx]] = if cal_prob > 0.5 { 1 } else { 0 };
236            }
237        }
238
239        Ok(predictions)
240    }
241}
242
243impl CalibratedBinaryRelevance<CalibratedBinaryRelevanceTrained> {
244    /// Get calibrated probabilities
245    #[allow(non_snake_case)] // standard ML notation
246    pub fn predict_proba(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<Float>> {
247        let (n_samples, n_features) = X.dim();
248
249        if n_features != self.state.n_features {
250            return Err(SklearsError::InvalidInput(
251                "X has different number of features than training data".to_string(),
252            ));
253        }
254
255        let mut probabilities = Array2::<Float>::zeros((n_samples, self.state.n_labels));
256
257        for sample_idx in 0..n_samples {
258            let x = X.row(sample_idx);
259
260            for label_idx in 0..self.state.n_labels {
261                let (weights, bias) = &self.state.base_models[label_idx];
262                let (slope, intercept) = self.state.calibration_params[label_idx];
263
264                // Get base probability
265                let logit = x.dot(weights) + bias;
266                let base_prob = 1.0 / (1.0 + (-logit).exp());
267
268                // Apply calibration
269                let cal_logit = slope * base_prob + intercept;
270                let cal_prob = 1.0 / (1.0 + (-cal_logit).exp());
271
272                probabilities[[sample_idx, label_idx]] = cal_prob;
273            }
274        }
275
276        Ok(probabilities)
277    }
278}
279
280/// Random Label Combinations Method
281///
282/// Generates random label combinations for evaluation and testing purposes.
283/// Useful for creating synthetic multi-label datasets with controlled characteristics.
284pub struct RandomLabelCombinations {
285    n_labels: usize,
286    n_combinations: usize,
287    label_density: Float,
288    random_state: Option<u64>,
289}
290
291impl RandomLabelCombinations {
292    /// Create a new RandomLabelCombinations generator
293    pub fn new(n_labels: usize) -> Self {
294        Self {
295            n_labels,
296            n_combinations: 100,
297            label_density: 0.3,
298            random_state: None,
299        }
300    }
301
302    /// Set the number of combinations to generate
303    pub fn n_combinations(mut self, n_combinations: usize) -> Self {
304        self.n_combinations = n_combinations;
305        self
306    }
307
308    /// Set the label density (proportion of positive labels)
309    pub fn label_density(mut self, density: Float) -> Self {
310        self.label_density = density;
311        self
312    }
313
314    /// Set random state for reproducible results
315    pub fn random_state(mut self, seed: u64) -> Self {
316        self.random_state = Some(seed);
317        self
318    }
319
320    /// Generate random label combinations
321    pub fn generate(&self) -> Array2<i32> {
322        let mut rng = match self.random_state {
323            Some(seed) => RealStdRng::seed_from_u64(seed),
324            None => RealStdRng::from_seed(thread_rng().random()),
325        };
326
327        let mut combinations = Array2::<i32>::zeros((self.n_combinations, self.n_labels));
328
329        for i in 0..self.n_combinations {
330            for j in 0..self.n_labels {
331                combinations[[i, j]] = if rng.random::<Float>() < self.label_density {
332                    1
333                } else {
334                    0
335                };
336            }
337        }
338
339        combinations
340    }
341}
342
343/// ML-kNN: Multi-Label k-Nearest Neighbors
344///
345/// ML-kNN is an adaptation of the k-nearest neighbors algorithm for multi-label classification.
346/// It uses the maximum a posteriori (MAP) principle to determine the label set for a test instance
347/// based on the labels of its k nearest neighbors.
348#[derive(Debug, Clone)]
349pub struct MLkNN<S = Untrained> {
350    state: S,
351    k: usize,
352    smooth: Float,
353    distance_metric: DistanceMetric,
354}
355
356/// Distance metrics for ML-kNN
357#[derive(Debug, Clone, Copy, PartialEq)]
358pub enum DistanceMetric {
359    /// Euclidean distance
360    Euclidean,
361    /// Manhattan distance
362    Manhattan,
363    /// Cosine distance
364    Cosine,
365}
366
367/// Trained state for ML-kNN
368#[derive(Debug, Clone)]
369pub struct MLkNNTrained {
370    training_data: Array2<Float>,
371    training_labels: Array2<i32>,
372    prior_probs: Array1<Float>,
373    conditional_probs: Array2<Float>, // P(label|neighbor_count)
374    k: usize,
375    /// Smoothing factor used during training
376    pub smooth: Float,
377    distance_metric: DistanceMetric,
378    n_labels: usize,
379}
380
381impl Default for MLkNN<Untrained> {
382    fn default() -> Self {
383        Self::new()
384    }
385}
386
387impl Estimator for MLkNN<Untrained> {
388    type Config = ();
389    type Error = SklearsError;
390    type Float = Float;
391
392    fn config(&self) -> &Self::Config {
393        &()
394    }
395}
396
397impl Fit<ArrayView2<'_, Float>, Array2<i32>> for MLkNN<Untrained> {
398    type Fitted = MLkNN<MLkNNTrained>;
399
400    #[allow(non_snake_case)] // standard ML notation
401    fn fit(self, X: &ArrayView2<'_, Float>, y: &Array2<i32>) -> SklResult<Self::Fitted> {
402        let (n_samples, _n_features) = X.dim();
403        let n_labels = y.ncols();
404
405        if n_samples != y.nrows() {
406            return Err(SklearsError::InvalidInput(
407                "X and y must have the same number of samples".to_string(),
408            ));
409        }
410
411        if self.k >= n_samples {
412            return Err(SklearsError::InvalidInput(
413                "k must be smaller than the number of training samples".to_string(),
414            ));
415        }
416
417        // Calculate prior probabilities
418        let mut prior_probs = Array1::<Float>::zeros(n_labels);
419        for label_idx in 0..n_labels {
420            let positive_count = y.column(label_idx).iter().filter(|&&x| x == 1).count();
421            prior_probs[label_idx] =
422                (positive_count as Float + self.smooth) / (n_samples as Float + 2.0 * self.smooth);
423        }
424
425        // Calculate conditional probabilities P(neighbor_count | label)
426        let mut conditional_probs = Array2::<Float>::zeros((n_labels, self.k + 1));
427
428        for sample_idx in 0..n_samples {
429            let neighbors = self.find_k_neighbors(X, sample_idx, &X.view())?;
430
431            for label_idx in 0..n_labels {
432                let label_count = neighbors
433                    .iter()
434                    .filter(|&&neighbor_idx| y[[neighbor_idx, label_idx]] == 1)
435                    .count();
436
437                if y[[sample_idx, label_idx]] == 1 {
438                    conditional_probs[[label_idx, label_count]] += 1.0;
439                }
440            }
441        }
442
443        // Normalize conditional probabilities with smoothing
444        for label_idx in 0..n_labels {
445            let total_positive = y.column(label_idx).iter().filter(|&&x| x == 1).count() as Float;
446            for count in 0..=self.k {
447                conditional_probs[[label_idx, count]] = (conditional_probs[[label_idx, count]]
448                    + self.smooth)
449                    / (total_positive + (self.k + 1) as Float * self.smooth);
450            }
451        }
452
453        Ok(MLkNN {
454            state: MLkNNTrained {
455                training_data: X.to_owned(),
456                training_labels: y.clone(),
457                prior_probs,
458                conditional_probs,
459                k: self.k,
460                smooth: self.smooth,
461                distance_metric: self.distance_metric,
462                n_labels,
463            },
464            k: self.k,
465            smooth: self.smooth,
466            distance_metric: self.distance_metric,
467        })
468    }
469}
470
471impl MLkNN<Untrained> {
472    /// Create a new ML-kNN classifier
473    pub fn new() -> Self {
474        Self {
475            state: Untrained,
476            k: 10,
477            smooth: 1.0,
478            distance_metric: DistanceMetric::Euclidean,
479        }
480    }
481
482    /// Set the number of neighbors
483    pub fn k(mut self, k: usize) -> Self {
484        self.k = k;
485        self
486    }
487
488    /// Set the smoothing parameter
489    pub fn smooth(mut self, smooth: Float) -> Self {
490        self.smooth = smooth;
491        self
492    }
493
494    /// Set the distance metric
495    pub fn distance_metric(mut self, metric: DistanceMetric) -> Self {
496        self.distance_metric = metric;
497        self
498    }
499
500    /// Find k nearest neighbors for a sample
501    #[allow(non_snake_case)] // standard ML notation
502    fn find_k_neighbors(
503        &self,
504        X: &ArrayView2<'_, Float>,
505        sample_idx: usize,
506        training_data: &ArrayView2<'_, Float>,
507    ) -> SklResult<Vec<usize>> {
508        let query = X.row(sample_idx);
509        let mut distances = Vec::new();
510
511        for (train_idx, train_sample) in training_data.rows().into_iter().enumerate() {
512            if train_idx != sample_idx {
513                let distance = self.calculate_distance(&query, &train_sample);
514                distances.push((distance, train_idx));
515            }
516        }
517
518        distances.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("operation should succeed"));
519        let neighbors = distances
520            .into_iter()
521            .take(self.k)
522            .map(|(_, idx)| idx)
523            .collect();
524
525        Ok(neighbors)
526    }
527
528    /// Calculate distance between two samples
529    fn calculate_distance(&self, a: &ArrayView1<'_, Float>, b: &ArrayView1<'_, Float>) -> Float {
530        match self.distance_metric {
531            DistanceMetric::Euclidean => a
532                .iter()
533                .zip(b.iter())
534                .map(|(x, y)| (x - y).powi(2))
535                .sum::<Float>()
536                .sqrt(),
537            DistanceMetric::Manhattan => a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).sum(),
538            DistanceMetric::Cosine => {
539                let dot = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum::<Float>();
540                let norm_a = a.iter().map(|x| x.powi(2)).sum::<Float>().sqrt();
541                let norm_b = b.iter().map(|x| x.powi(2)).sum::<Float>().sqrt();
542                if norm_a > 0.0 && norm_b > 0.0 {
543                    1.0 - dot / (norm_a * norm_b)
544                } else {
545                    1.0
546                }
547            }
548        }
549    }
550}
551
552impl Predict<ArrayView2<'_, Float>, Array2<i32>> for MLkNN<MLkNNTrained> {
553    #[allow(non_snake_case)] // standard ML notation
554    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
555        let (n_samples, n_features) = X.dim();
556
557        if n_features != self.state.training_data.ncols() {
558            return Err(SklearsError::InvalidInput(
559                "X has different number of features than training data".to_string(),
560            ));
561        }
562
563        let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_labels));
564
565        for sample_idx in 0..n_samples {
566            let neighbors = self.find_k_neighbors_trained(X, sample_idx)?;
567
568            for label_idx in 0..self.state.n_labels {
569                // Count positive neighbors for this label
570                let positive_neighbors = neighbors
571                    .iter()
572                    .filter(|&&neighbor_idx| {
573                        self.state.training_labels[[neighbor_idx, label_idx]] == 1
574                    })
575                    .count();
576
577                // Calculate posterior probabilities using MAP
578                let prob_positive = self.state.prior_probs[label_idx]
579                    * self.state.conditional_probs[[label_idx, positive_neighbors]];
580                let prob_negative = (1.0 - self.state.prior_probs[label_idx])
581                    * (1.0 - self.state.conditional_probs[[label_idx, positive_neighbors]]);
582
583                predictions[[sample_idx, label_idx]] =
584                    if prob_positive > prob_negative { 1 } else { 0 };
585            }
586        }
587
588        Ok(predictions)
589    }
590}
591
592impl MLkNN<MLkNNTrained> {
593    /// Find k nearest neighbors for a test sample
594    #[allow(non_snake_case)] // standard ML notation
595    fn find_k_neighbors_trained(
596        &self,
597        X: &ArrayView2<'_, Float>,
598        sample_idx: usize,
599    ) -> SklResult<Vec<usize>> {
600        let query = X.row(sample_idx);
601        let mut distances = Vec::new();
602
603        for (train_idx, train_sample) in self.state.training_data.rows().into_iter().enumerate() {
604            let distance = self.calculate_distance_trained(&query, &train_sample);
605            distances.push((distance, train_idx));
606        }
607
608        distances.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("operation should succeed"));
609        let neighbors = distances
610            .into_iter()
611            .take(self.state.k)
612            .map(|(_, idx)| idx)
613            .collect();
614
615        Ok(neighbors)
616    }
617
618    /// Calculate distance between two samples (trained version)
619    fn calculate_distance_trained(
620        &self,
621        a: &ArrayView1<'_, Float>,
622        b: &ArrayView1<'_, Float>,
623    ) -> Float {
624        match self.state.distance_metric {
625            DistanceMetric::Euclidean => a
626                .iter()
627                .zip(b.iter())
628                .map(|(x, y)| (x - y).powi(2))
629                .sum::<Float>()
630                .sqrt(),
631            DistanceMetric::Manhattan => a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).sum(),
632            DistanceMetric::Cosine => {
633                let dot = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum::<Float>();
634                let norm_a = a.iter().map(|x| x.powi(2)).sum::<Float>().sqrt();
635                let norm_b = b.iter().map(|x| x.powi(2)).sum::<Float>().sqrt();
636                if norm_a > 0.0 && norm_b > 0.0 {
637                    1.0 - dot / (norm_a * norm_b)
638                } else {
639                    1.0
640                }
641            }
642        }
643    }
644
645    /// Get the number of neighbors
646    pub fn k(&self) -> usize {
647        self.state.k
648    }
649
650    /// Get prior probabilities
651    pub fn prior_probabilities(&self) -> &Array1<Float> {
652        &self.state.prior_probs
653    }
654}
655
656/// Cost-Sensitive Binary Relevance
657///
658/// Binary relevance approach that incorporates label-specific misclassification costs
659/// to optimize cost-sensitive performance rather than accuracy.
660#[derive(Debug, Clone)]
661pub struct CostSensitiveBinaryRelevance<S = Untrained> {
662    state: S,
663    cost_matrix: CostMatrix,
664    learning_rate: Float,
665    max_iterations: usize,
666    regularization: Float,
667}
668
669/// Cost matrix for cost-sensitive learning
670#[derive(Debug, Clone)]
671pub struct CostMatrix {
672    /// Cost of false positives for each label
673    false_positive_costs: Array1<Float>,
674    /// Cost of false negatives for each label
675    false_negative_costs: Array1<Float>,
676}
677
678impl CostMatrix {
679    /// Create a new cost matrix
680    pub fn new(false_positive_costs: Array1<Float>, false_negative_costs: Array1<Float>) -> Self {
681        Self {
682            false_positive_costs,
683            false_negative_costs,
684        }
685    }
686
687    /// Create uniform cost matrix
688    pub fn uniform(n_labels: usize, fp_cost: Float, fn_cost: Float) -> Self {
689        Self {
690            false_positive_costs: Array1::from_elem(n_labels, fp_cost),
691            false_negative_costs: Array1::from_elem(n_labels, fn_cost),
692        }
693    }
694
695    /// Get false positive cost for a label
696    pub fn fp_cost(&self, label_idx: usize) -> Float {
697        self.false_positive_costs
698            .get(label_idx)
699            .copied()
700            .unwrap_or(1.0)
701    }
702
703    /// Get false negative cost for a label
704    pub fn fn_cost(&self, label_idx: usize) -> Float {
705        self.false_negative_costs
706            .get(label_idx)
707            .copied()
708            .unwrap_or(1.0)
709    }
710}
711
712/// Trained state for cost-sensitive binary relevance
713#[derive(Debug, Clone)]
714pub struct CostSensitiveBinaryRelevanceTrained {
715    models: Vec<SimpleBinaryModel>,
716    cost_matrix: CostMatrix,
717    n_features: usize,
718    n_labels: usize,
719}
720
721/// Simple binary model for cost-sensitive learning
722#[derive(Debug, Clone)]
723pub struct SimpleBinaryModel {
724    weights: Array1<Float>,
725    bias: Float,
726    threshold: Float, // Cost-sensitive threshold
727}
728
729impl Default for CostSensitiveBinaryRelevance<Untrained> {
730    fn default() -> Self {
731        Self::new()
732    }
733}
734
735impl Estimator for CostSensitiveBinaryRelevance<Untrained> {
736    type Config = ();
737    type Error = SklearsError;
738    type Float = Float;
739
740    fn config(&self) -> &Self::Config {
741        &()
742    }
743}
744
745impl Fit<ArrayView2<'_, Float>, Array2<i32>> for CostSensitiveBinaryRelevance<Untrained> {
746    type Fitted = CostSensitiveBinaryRelevance<CostSensitiveBinaryRelevanceTrained>;
747
748    #[allow(non_snake_case)] // standard ML notation
749    fn fit(self, X: &ArrayView2<'_, Float>, y: &Array2<i32>) -> SklResult<Self::Fitted> {
750        let (n_samples, n_features) = X.dim();
751        let n_labels = y.ncols();
752
753        if n_samples != y.nrows() {
754            return Err(SklearsError::InvalidInput(
755                "X and y must have the same number of samples".to_string(),
756            ));
757        }
758
759        let mut models = Vec::new();
760
761        // Train cost-sensitive binary classifier for each label
762        for label_idx in 0..n_labels {
763            let y_label = y.column(label_idx);
764            let fp_cost = self.cost_matrix.fp_cost(label_idx);
765            let fn_cost = self.cost_matrix.fn_cost(label_idx);
766
767            let mut weights = Array1::<Float>::zeros(n_features);
768            let mut bias = 0.0;
769
770            // Cost-sensitive training loop
771            for _iter in 0..self.max_iterations {
772                let mut weight_gradient = Array1::<Float>::zeros(n_features);
773                let mut bias_gradient = 0.0;
774
775                for sample_idx in 0..n_samples {
776                    let x = X.row(sample_idx);
777                    let y_true = y_label[sample_idx] as Float;
778
779                    let logit = x.dot(&weights) + bias;
780                    let prob = 1.0 / (1.0 + (-logit).exp());
781
782                    // Cost-sensitive gradient
783                    let cost_weight = if y_true == 1.0 { fn_cost } else { fp_cost };
784                    let error = (prob - y_true) * cost_weight;
785
786                    // Accumulate gradients
787                    for feat_idx in 0..n_features {
788                        weight_gradient[feat_idx] += error * x[feat_idx];
789                    }
790                    bias_gradient += error;
791                }
792
793                // Add L2 regularization
794                for i in 0..n_features {
795                    weight_gradient[i] += self.regularization * weights[i];
796                }
797
798                // Update parameters
799                for i in 0..n_features {
800                    weights[i] -= self.learning_rate * weight_gradient[i] / n_samples as Float;
801                }
802                bias -= self.learning_rate * bias_gradient / n_samples as Float;
803            }
804
805            // Calculate cost-sensitive threshold
806            let threshold = self.calculate_cost_sensitive_threshold(fp_cost, fn_cost);
807
808            models.push(SimpleBinaryModel {
809                weights,
810                bias,
811                threshold,
812            });
813        }
814
815        Ok(CostSensitiveBinaryRelevance {
816            state: CostSensitiveBinaryRelevanceTrained {
817                models,
818                cost_matrix: self.cost_matrix,
819                n_features,
820                n_labels,
821            },
822            cost_matrix: CostMatrix::uniform(n_labels, 1.0, 1.0),
823            learning_rate: self.learning_rate,
824            max_iterations: self.max_iterations,
825            regularization: self.regularization,
826        })
827    }
828}
829
830impl CostSensitiveBinaryRelevance<Untrained> {
831    /// Create a new cost-sensitive binary relevance classifier
832    pub fn new() -> Self {
833        Self {
834            state: Untrained,
835            cost_matrix: CostMatrix::uniform(1, 1.0, 1.0),
836            learning_rate: 0.01,
837            max_iterations: 100,
838            regularization: 0.01,
839        }
840    }
841
842    /// Set the cost matrix
843    pub fn cost_matrix(mut self, cost_matrix: CostMatrix) -> Self {
844        self.cost_matrix = cost_matrix;
845        self
846    }
847
848    /// Set the learning rate
849    pub fn learning_rate(mut self, learning_rate: Float) -> Self {
850        self.learning_rate = learning_rate;
851        self
852    }
853
854    /// Set the maximum number of iterations
855    pub fn max_iterations(mut self, max_iterations: usize) -> Self {
856        self.max_iterations = max_iterations;
857        self
858    }
859
860    /// Set the regularization strength
861    pub fn regularization(mut self, regularization: Float) -> Self {
862        self.regularization = regularization;
863        self
864    }
865
866    /// Calculate cost-sensitive threshold
867    fn calculate_cost_sensitive_threshold(&self, fp_cost: Float, fn_cost: Float) -> Float {
868        // Threshold that minimizes expected cost
869        // threshold = log(fp_cost / fn_cost) if we had class priors
870        // Simplified version
871        fp_cost / (fp_cost + fn_cost)
872    }
873}
874
875impl Predict<ArrayView2<'_, Float>, Array2<i32>>
876    for CostSensitiveBinaryRelevance<CostSensitiveBinaryRelevanceTrained>
877{
878    #[allow(non_snake_case)] // standard ML notation
879    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
880        let (n_samples, n_features) = X.dim();
881
882        if n_features != self.state.n_features {
883            return Err(SklearsError::InvalidInput(
884                "X has different number of features than training data".to_string(),
885            ));
886        }
887
888        let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_labels));
889
890        for sample_idx in 0..n_samples {
891            let x = X.row(sample_idx);
892
893            for (label_idx, model) in self.state.models.iter().enumerate() {
894                let logit = x.dot(&model.weights) + model.bias;
895                let prob = 1.0 / (1.0 + (-logit).exp());
896
897                predictions[[sample_idx, label_idx]] = if prob > model.threshold { 1 } else { 0 };
898            }
899        }
900
901        Ok(predictions)
902    }
903}
904
905impl CostSensitiveBinaryRelevance<CostSensitiveBinaryRelevanceTrained> {
906    /// Get the cost matrix
907    pub fn cost_matrix(&self) -> &CostMatrix {
908        &self.state.cost_matrix
909    }
910
911    /// Get model thresholds
912    pub fn thresholds(&self) -> Vec<Float> {
913        self.state.models.iter().map(|m| m.threshold).collect()
914    }
915}
916
917#[allow(non_snake_case)]
918#[cfg(test)]
919mod tests {
920    use super::*;
921    // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
922    use scirs2_core::ndarray::array;
923
924    #[test]
925    #[allow(non_snake_case)]
926    fn test_calibrated_binary_relevance_basic() {
927        let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0]];
928        let y = array![[1, 0], [0, 1], [1, 1], [0, 0]];
929
930        let cbr = CalibratedBinaryRelevance::new().calibration_method(CalibrationMethod::Platt);
931        let trained_cbr = cbr
932            .fit(&X.view(), &y)
933            .expect("model fitting should succeed");
934        let predictions = trained_cbr
935            .predict(&X.view())
936            .expect("prediction should succeed");
937
938        assert_eq!(predictions.dim(), (4, 2));
939        assert!(predictions.iter().all(|&x| x == 0 || x == 1));
940    }
941
942    #[test]
943    #[allow(non_snake_case)]
944    fn test_calibrated_binary_relevance_probabilities() {
945        let X = array![[1.0, 2.0], [2.0, 3.0]];
946        let y = array![[1, 0], [0, 1]];
947
948        let cbr = CalibratedBinaryRelevance::new();
949        let trained_cbr = cbr
950            .fit(&X.view(), &y)
951            .expect("model fitting should succeed");
952        let probabilities = trained_cbr
953            .predict_proba(&X.view())
954            .expect("operation should succeed");
955
956        assert_eq!(probabilities.dim(), (2, 2));
957        assert!(probabilities.iter().all(|&p| (0.0..=1.0).contains(&p)));
958    }
959
960    #[test]
961    fn test_random_label_combinations() {
962        let generator = RandomLabelCombinations::new(3)
963            .n_combinations(5)
964            .label_density(0.5)
965            .random_state(42);
966
967        let combinations = generator.generate();
968        assert_eq!(combinations.dim(), (5, 3));
969        assert!(combinations.iter().all(|&x| x == 0 || x == 1));
970    }
971
972    #[test]
973    fn test_random_label_combinations_deterministic_seeding() {
974        // Same seed must produce identical results
975        let result1 = RandomLabelCombinations::new(5)
976            .n_combinations(10)
977            .random_state(42)
978            .generate();
979        let result2 = RandomLabelCombinations::new(5)
980            .n_combinations(10)
981            .random_state(42)
982            .generate();
983        assert_eq!(
984            result1, result2,
985            "same seed should produce identical results"
986        );
987
988        // Different seeds must produce different results (with overwhelming probability for n=50 bits)
989        let result3 = RandomLabelCombinations::new(5)
990            .n_combinations(10)
991            .random_state(43)
992            .generate();
993        assert_ne!(
994            result1, result3,
995            "different seeds should produce different results"
996        );
997    }
998
999    #[test]
1000    #[allow(non_snake_case)]
1001    fn test_mlknn_basic() {
1002        let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0], [1.5, 2.5]];
1003        let y = array![[1, 0], [0, 1], [1, 1], [0, 0], [1, 0]];
1004
1005        let mlknn = MLkNN::new().k(3).smooth(1.0);
1006        let trained_mlknn = mlknn
1007            .fit(&X.view(), &y)
1008            .expect("model fitting should succeed");
1009        let predictions = trained_mlknn
1010            .predict(&X.view())
1011            .expect("prediction should succeed");
1012
1013        assert_eq!(predictions.dim(), (5, 2));
1014        assert!(predictions.iter().all(|&x| x == 0 || x == 1));
1015        assert_eq!(trained_mlknn.k(), 3);
1016    }
1017
1018    #[test]
1019    #[allow(non_snake_case)]
1020    fn test_mlknn_distance_metrics() {
1021        let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
1022        let y = array![[1, 0], [0, 1], [1, 1]];
1023
1024        let mlknn_euclidean = MLkNN::new().k(2).distance_metric(DistanceMetric::Euclidean);
1025        let trained_euclidean = mlknn_euclidean
1026            .fit(&X.view(), &y)
1027            .expect("model fitting should succeed");
1028
1029        let mlknn_manhattan = MLkNN::new().k(2).distance_metric(DistanceMetric::Manhattan);
1030        let trained_manhattan = mlknn_manhattan
1031            .fit(&X.view(), &y)
1032            .expect("model fitting should succeed");
1033
1034        let pred_euclidean = trained_euclidean
1035            .predict(&X.view())
1036            .expect("prediction should succeed");
1037        let pred_manhattan = trained_manhattan
1038            .predict(&X.view())
1039            .expect("prediction should succeed");
1040
1041        assert_eq!(pred_euclidean.dim(), (3, 2));
1042        assert_eq!(pred_manhattan.dim(), (3, 2));
1043    }
1044
1045    #[test]
1046    #[allow(non_snake_case)]
1047    fn test_cost_sensitive_binary_relevance() {
1048        let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0]];
1049        let y = array![[1, 0], [0, 1], [1, 1], [0, 0]];
1050
1051        let fp_costs = array![2.0, 1.0]; // Higher cost for FP on first label
1052        let fn_costs = array![1.0, 3.0]; // Higher cost for FN on second label
1053        let cost_matrix = CostMatrix::new(fp_costs, fn_costs);
1054
1055        let csbr = CostSensitiveBinaryRelevance::new()
1056            .cost_matrix(cost_matrix)
1057            .learning_rate(0.01)
1058            .max_iterations(50);
1059
1060        let trained_csbr = csbr
1061            .fit(&X.view(), &y)
1062            .expect("model fitting should succeed");
1063        let predictions = trained_csbr
1064            .predict(&X.view())
1065            .expect("prediction should succeed");
1066
1067        assert_eq!(predictions.dim(), (4, 2));
1068        assert!(predictions.iter().all(|&x| x == 0 || x == 1));
1069
1070        let thresholds = trained_csbr.thresholds();
1071        assert_eq!(thresholds.len(), 2);
1072    }
1073
1074    #[test]
1075    fn test_cost_matrix_creation() {
1076        let fp_costs = array![1.0, 2.0, 3.0];
1077        let fn_costs = array![2.0, 1.0, 1.0];
1078        let cost_matrix = CostMatrix::new(fp_costs, fn_costs);
1079
1080        assert_eq!(cost_matrix.fp_cost(0), 1.0);
1081        assert_eq!(cost_matrix.fp_cost(1), 2.0);
1082        assert_eq!(cost_matrix.fn_cost(0), 2.0);
1083        assert_eq!(cost_matrix.fn_cost(1), 1.0);
1084
1085        let uniform_costs = CostMatrix::uniform(3, 1.5, 2.5);
1086        assert_eq!(uniform_costs.fp_cost(0), 1.5);
1087        assert_eq!(uniform_costs.fn_cost(2), 2.5);
1088    }
1089
1090    #[test]
1091    fn test_calibration_methods() {
1092        let cbr_platt =
1093            CalibratedBinaryRelevance::new().calibration_method(CalibrationMethod::Platt);
1094        let cbr_isotonic =
1095            CalibratedBinaryRelevance::new().calibration_method(CalibrationMethod::Isotonic);
1096
1097        // Just test that they can be created with different methods
1098        assert_eq!(cbr_platt.calibration_method, CalibrationMethod::Platt);
1099        assert_eq!(cbr_isotonic.calibration_method, CalibrationMethod::Isotonic);
1100    }
1101
1102    #[test]
1103    #[allow(non_snake_case)]
1104    fn test_mlknn_prior_probabilities() {
1105        let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0], [4.0, 4.0]];
1106        let y = array![[1, 0], [0, 1], [1, 1], [0, 0]]; // 2/4 positive for each label
1107
1108        let mlknn = MLkNN::new().k(2).smooth(1.0);
1109        let trained_mlknn = mlknn
1110            .fit(&X.view(), &y)
1111            .expect("model fitting should succeed");
1112
1113        let priors = trained_mlknn.prior_probabilities();
1114        assert_eq!(priors.len(), 2);
1115
1116        // With smoothing: (2 + 1) / (4 + 2) = 0.5
1117        assert!((priors[0] - 0.5).abs() < 1e-6);
1118        assert!((priors[1] - 0.5).abs() < 1e-6);
1119    }
1120}