Skip to main content

sklears_multioutput/
ranking.rs

1//! Label ranking and threshold optimization algorithms
2
3// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
4use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
5use scirs2_core::random::thread_rng;
6use sklears_core::{
7    error::{Result as SklResult, SklearsError},
8    traits::{Estimator, Fit, Predict, Untrained},
9    types::Float,
10};
11
12/// Independent Label Prediction with threshold optimization
13///
14/// This approach treats multi-label classification as independent binary classification
15/// problems, with sophisticated threshold optimization strategies.
16#[derive(Debug, Clone)]
17pub struct IndependentLabelPrediction<S = Untrained> {
18    state: S,
19    threshold_strategy: ThresholdStrategy,
20    optimize_thresholds: bool,
21    class_weight: Option<String>, // "balanced" or None
22    random_state: Option<u64>,
23}
24
25/// Threshold strategy for label prediction
26#[derive(Debug, Clone)]
27pub enum ThresholdStrategy {
28    /// Fixed
29    Fixed(Float), // Use fixed threshold for all labels
30    /// PerLabel
31    PerLabel(Vec<Float>), // Use different threshold for each label
32    /// Optimal
33    Optimal, // Learn optimal thresholds from validation data
34    /// FScore
35    FScore, // Optimize F-score threshold for each label
36}
37
38/// Trained state for Independent Label Prediction
39#[derive(Debug, Clone)]
40pub struct IndependentLabelPredictionTrained {
41    binary_classifiers: Vec<BinaryClassifierModel>,
42    thresholds: Vec<Float>,
43    n_labels: usize,
44}
45
46/// Simple binary classifier model
47#[derive(Debug, Clone)]
48pub struct BinaryClassifierModel {
49    weights: Array1<Float>,
50    bias: Float,
51    feature_means: Array1<Float>,
52    feature_stds: Array1<Float>,
53}
54
55impl IndependentLabelPrediction<Untrained> {
56    /// Create a new IndependentLabelPrediction instance
57    pub fn new() -> Self {
58        Self {
59            state: Untrained,
60            threshold_strategy: ThresholdStrategy::Fixed(0.5),
61            optimize_thresholds: false,
62            class_weight: None,
63            random_state: None,
64        }
65    }
66
67    /// Set the threshold strategy
68    pub fn threshold_strategy(mut self, strategy: ThresholdStrategy) -> Self {
69        self.threshold_strategy = strategy;
70        self
71    }
72
73    /// Set whether to optimize thresholds
74    pub fn optimize_thresholds(mut self, optimize: bool) -> Self {
75        self.optimize_thresholds = optimize;
76        self
77    }
78
79    /// Set class weight strategy
80    pub fn class_weight(mut self, weight: Option<String>) -> Self {
81        self.class_weight = weight;
82        self
83    }
84
85    /// Set random state
86    pub fn random_state(mut self, random_state: Option<u64>) -> Self {
87        self.random_state = random_state;
88        self
89    }
90}
91
92impl Default for IndependentLabelPrediction<Untrained> {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl Estimator for IndependentLabelPrediction<Untrained> {
99    type Config = ();
100    type Error = SklearsError;
101    type Float = Float;
102
103    fn config(&self) -> &Self::Config {
104        &()
105    }
106}
107
108impl Fit<ArrayView2<'_, Float>, ArrayView2<'_, i32>> for IndependentLabelPrediction<Untrained> {
109    type Fitted = IndependentLabelPrediction<IndependentLabelPredictionTrained>;
110
111    fn fit(self, x: &ArrayView2<'_, Float>, y: &ArrayView2<'_, i32>) -> SklResult<Self::Fitted> {
112        let (n_samples, _n_features) = x.dim();
113        let (y_samples, n_labels) = y.dim();
114
115        if n_samples != y_samples {
116            return Err(SklearsError::InvalidInput(
117                "Number of samples in X and y must match".to_string(),
118            ));
119        }
120
121        if n_samples < 2 {
122            return Err(SklearsError::InvalidInput(
123                "Need at least 2 samples for training".to_string(),
124            ));
125        }
126
127        // Initialize random number generator
128        let mut rng = thread_rng();
129
130        // Train binary classifiers for each label
131        let mut binary_classifiers = Vec::new();
132        for label_idx in 0..n_labels {
133            let label_column = y.column(label_idx);
134            let classifier = self.train_binary_classifier(x, &label_column, &mut rng)?;
135            binary_classifiers.push(classifier);
136        }
137
138        // Determine thresholds
139        let thresholds = match &self.threshold_strategy {
140            ThresholdStrategy::Fixed(threshold) => vec![*threshold; n_labels],
141            ThresholdStrategy::PerLabel(thresholds) => {
142                if thresholds.len() != n_labels {
143                    return Err(SklearsError::InvalidInput(
144                        "Number of thresholds must match number of labels".to_string(),
145                    ));
146                }
147                thresholds.clone()
148            }
149            ThresholdStrategy::Optimal => {
150                self.optimize_thresholds_for_accuracy(x, y, &binary_classifiers)?
151            }
152            ThresholdStrategy::FScore => {
153                self.optimize_thresholds_for_fscore(x, y, &binary_classifiers)?
154            }
155        };
156
157        Ok(IndependentLabelPrediction {
158            state: IndependentLabelPredictionTrained {
159                binary_classifiers,
160                thresholds,
161                n_labels,
162            },
163            threshold_strategy: self.threshold_strategy,
164            optimize_thresholds: self.optimize_thresholds,
165            class_weight: self.class_weight,
166            random_state: self.random_state,
167        })
168    }
169}
170
171impl IndependentLabelPrediction<Untrained> {
172    fn train_binary_classifier(
173        &self,
174        x: &ArrayView2<'_, Float>,
175        y_label: &ArrayView1<'_, i32>,
176        _rng: &mut scirs2_core::random::CoreRandom,
177    ) -> SklResult<BinaryClassifierModel> {
178        let (n_samples, n_features) = x.dim();
179
180        // Compute feature statistics for normalization
181        let feature_means = x
182            .mean_axis(Axis(0))
183            .expect("array should have elements for mean computation");
184        let feature_stds = x
185            .mapv(|val| val * val)
186            .mean_axis(Axis(0))
187            .expect("array should have elements for mean computation")
188            - &feature_means.mapv(|mean| mean * mean);
189        let feature_stds = feature_stds.mapv(|var| (var.max(1e-10)).sqrt());
190
191        // Normalize features
192        let mut x_normalized = x.to_owned();
193        for mut row in x_normalized.rows_mut().into_iter() {
194            row -= &feature_means;
195            row /= &feature_stds;
196        }
197
198        // Compute class weights if requested
199        let class_weights = if self.class_weight.as_deref() == Some("balanced") {
200            let pos_count = y_label.iter().filter(|&&y| y == 1).count();
201            let neg_count = n_samples - pos_count;
202
203            if pos_count == 0 || neg_count == 0 {
204                (1.0, 1.0)
205            } else {
206                let pos_weight = n_samples as Float / (2.0 * pos_count as Float);
207                let neg_weight = n_samples as Float / (2.0 * neg_count as Float);
208                (neg_weight, pos_weight)
209            }
210        } else {
211            (1.0, 1.0)
212        };
213
214        // Simple logistic regression using gradient descent
215        let mut weights = Array1::<Float>::zeros(n_features);
216        let mut bias = 0.0;
217
218        let learning_rate = 0.01;
219        let max_iter = 1000;
220        let tolerance = 1e-6;
221
222        for iteration in 0..max_iter {
223            let mut weight_gradient = Array1::<Float>::zeros(n_features);
224            let mut bias_gradient = 0.0;
225            let mut total_loss = 0.0;
226
227            for sample_idx in 0..n_samples {
228                let x_sample = x_normalized.row(sample_idx);
229                let y_true = y_label[sample_idx] as Float;
230
231                // Forward pass
232                let logits = x_sample.dot(&weights) + bias;
233                let prediction = 1.0 / (1.0 + (-logits).exp());
234
235                // Compute loss with class weights
236                let sample_weight = if y_true > 0.5 {
237                    class_weights.1
238                } else {
239                    class_weights.0
240                };
241                let loss = -sample_weight
242                    * (y_true * prediction.ln() + (1.0 - y_true) * (1.0 - prediction).ln());
243                total_loss += loss;
244
245                // Backward pass
246                let error = sample_weight * (prediction - y_true);
247                weight_gradient += &(x_sample.to_owned() * error);
248                bias_gradient += error;
249            }
250
251            // Update parameters
252            weights -= &(weight_gradient * (learning_rate / n_samples as Float));
253            bias -= bias_gradient * (learning_rate / n_samples as Float);
254
255            // Check convergence
256            if iteration > 10 {
257                let avg_loss = total_loss / n_samples as Float;
258                if avg_loss < tolerance {
259                    break;
260                }
261            }
262        }
263
264        Ok(BinaryClassifierModel {
265            weights,
266            bias,
267            feature_means,
268            feature_stds,
269        })
270    }
271
272    fn optimize_thresholds_for_accuracy(
273        &self,
274        x: &ArrayView2<'_, Float>,
275        y: &ArrayView2<'_, i32>,
276        classifiers: &[BinaryClassifierModel],
277    ) -> SklResult<Vec<Float>> {
278        let n_labels = y.ncols();
279        let mut thresholds = Vec::new();
280
281        for (label_idx, classifier) in classifiers.iter().enumerate().take(n_labels) {
282            let y_true = y.column(label_idx);
283            let y_scores = self.predict_probabilities_single_label(x, classifier)?;
284
285            let mut best_threshold = 0.5;
286            let mut best_accuracy = 0.0;
287
288            // Grid search for best threshold
289            for threshold_int in 1..100 {
290                let threshold = threshold_int as Float / 100.0;
291
292                let mut correct = 0;
293                for sample_idx in 0..x.nrows() {
294                    let predicted = if y_scores[sample_idx] >= threshold {
295                        1
296                    } else {
297                        0
298                    };
299                    if predicted == y_true[sample_idx] {
300                        correct += 1;
301                    }
302                }
303
304                let accuracy = correct as Float / x.nrows() as Float;
305                if accuracy > best_accuracy {
306                    best_accuracy = accuracy;
307                    best_threshold = threshold;
308                }
309            }
310
311            thresholds.push(best_threshold);
312        }
313
314        Ok(thresholds)
315    }
316
317    fn optimize_thresholds_for_fscore(
318        &self,
319        x: &ArrayView2<'_, Float>,
320        y: &ArrayView2<'_, i32>,
321        classifiers: &[BinaryClassifierModel],
322    ) -> SklResult<Vec<Float>> {
323        let n_labels = y.ncols();
324        let mut thresholds = Vec::new();
325
326        for (label_idx, classifier) in classifiers.iter().enumerate().take(n_labels) {
327            let y_true = y.column(label_idx);
328            let y_scores = self.predict_probabilities_single_label(x, classifier)?;
329
330            let mut best_threshold = 0.5;
331            let mut best_fscore = 0.0;
332
333            // Grid search for best F-score threshold
334            for threshold_int in 1..100 {
335                let threshold = threshold_int as Float / 100.0;
336
337                let mut tp = 0;
338                let mut fp = 0;
339                let mut fn_count = 0;
340
341                for sample_idx in 0..x.nrows() {
342                    let predicted = if y_scores[sample_idx] >= threshold {
343                        1
344                    } else {
345                        0
346                    };
347                    let actual = y_true[sample_idx];
348
349                    match (actual, predicted) {
350                        (1, 1) => tp += 1,
351                        (0, 1) => fp += 1,
352                        (1, 0) => fn_count += 1,
353                        _ => {}
354                    }
355                }
356
357                let precision = if tp + fp > 0 {
358                    tp as Float / (tp + fp) as Float
359                } else {
360                    0.0
361                };
362                let recall = if tp + fn_count > 0 {
363                    tp as Float / (tp + fn_count) as Float
364                } else {
365                    0.0
366                };
367                let fscore = if precision + recall > 0.0 {
368                    2.0 * precision * recall / (precision + recall)
369                } else {
370                    0.0
371                };
372
373                if fscore > best_fscore {
374                    best_fscore = fscore;
375                    best_threshold = threshold;
376                }
377            }
378
379            thresholds.push(best_threshold);
380        }
381
382        Ok(thresholds)
383    }
384
385    fn predict_probabilities_single_label(
386        &self,
387        x: &ArrayView2<'_, Float>,
388        classifier: &BinaryClassifierModel,
389    ) -> SklResult<Array1<Float>> {
390        let n_samples = x.nrows();
391        let mut probabilities = Array1::<Float>::zeros(n_samples);
392
393        for sample_idx in 0..n_samples {
394            let x_sample = x.row(sample_idx);
395
396            // Normalize features
397            let x_normalized =
398                (&x_sample.to_owned() - &classifier.feature_means) / &classifier.feature_stds;
399
400            // Compute logits and probability
401            let logits = x_normalized.dot(&classifier.weights) + classifier.bias;
402            let probability = 1.0 / (1.0 + (-logits).exp());
403
404            probabilities[sample_idx] = probability;
405        }
406
407        Ok(probabilities)
408    }
409}
410
411impl Predict<ArrayView2<'_, Float>, Array2<i32>>
412    for IndependentLabelPrediction<IndependentLabelPredictionTrained>
413{
414    fn predict(&self, x: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
415        let (n_samples, _n_features) = x.dim();
416        let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_labels));
417
418        for label_idx in 0..self.state.n_labels {
419            let classifier = &self.state.binary_classifiers[label_idx];
420            let threshold = self.state.thresholds[label_idx];
421
422            for sample_idx in 0..n_samples {
423                let x_sample = x.row(sample_idx);
424
425                // Normalize features
426                let x_normalized =
427                    (&x_sample.to_owned() - &classifier.feature_means) / &classifier.feature_stds;
428
429                // Compute probability
430                let logits = x_normalized.dot(&classifier.weights) + classifier.bias;
431                let probability = 1.0 / (1.0 + (-logits).exp());
432
433                // Apply threshold
434                predictions[[sample_idx, label_idx]] = if probability >= threshold { 1 } else { 0 };
435            }
436        }
437
438        Ok(predictions)
439    }
440}
441
442impl IndependentLabelPrediction<IndependentLabelPredictionTrained> {
443    /// Predict probabilities for each label
444    pub fn predict_proba(&self, x: &ArrayView2<'_, Float>) -> SklResult<Array2<Float>> {
445        let (n_samples, _) = x.dim();
446        let mut probabilities = Array2::<Float>::zeros((n_samples, self.state.n_labels));
447
448        for label_idx in 0..self.state.n_labels {
449            let classifier = &self.state.binary_classifiers[label_idx];
450
451            for sample_idx in 0..n_samples {
452                let x_sample = x.row(sample_idx);
453
454                // Normalize features
455                let x_normalized =
456                    (&x_sample.to_owned() - &classifier.feature_means) / &classifier.feature_stds;
457
458                // Compute probability
459                let logits = x_normalized.dot(&classifier.weights) + classifier.bias;
460                let probability = 1.0 / (1.0 + (-logits).exp());
461
462                probabilities[[sample_idx, label_idx]] = probability;
463            }
464        }
465
466        Ok(probabilities)
467    }
468
469    /// Get the learned thresholds for each label
470    pub fn thresholds(&self) -> &[Float] {
471        &self.state.thresholds
472    }
473
474    /// Get the feature importance scores for each label
475    pub fn feature_importances(&self) -> Vec<Array1<Float>> {
476        self.state
477            .binary_classifiers
478            .iter()
479            .map(|classifier| classifier.weights.mapv(|w| w.abs()))
480            .collect()
481    }
482}