Skip to main content

sklears_multioutput/
core.rs

1//! Core multi-output algorithms
2//!
3//! This module contains the core MultiOutputClassifier and MultiOutputRegressor
4//! implementations with their trained states and associated methods.
5//! Enhanced with parallel processing capabilities for improved performance.
6
7// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
8use scirs2_core::ndarray::{Array1, Array2, ArrayView2, Axis};
9use sklears_core::{
10    error::{Result as SklResult, SklearsError},
11    traits::{Estimator, Fit, Predict, Untrained},
12    types::Float,
13};
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16use std::thread;
17
18/// Multi-Output Classifier
19///
20/// This strategy consists of fitting one classifier per target. This is a simple
21/// strategy for extending classifiers that do not natively support multi-class
22/// classification to such cases.
23///
24/// # Examples
25///
26/// ```
27/// use sklears_multioutput::MultiOutputClassifier;
28/// // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
29/// use scirs2_core::ndarray::array;
30///
31/// // This is a simple example showing the structure
32/// let data = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
33/// let labels = array![[0, 1], [1, 0], [1, 1]];
34/// ```
35#[derive(Debug, Clone)]
36pub struct MultiOutputClassifier<S = Untrained> {
37    state: S,
38    n_jobs: Option<i32>,
39}
40
41impl MultiOutputClassifier<Untrained> {
42    /// Create a new MultiOutputClassifier instance
43    pub fn new() -> Self {
44        Self {
45            state: Untrained,
46            n_jobs: None,
47        }
48    }
49
50    /// Set the number of parallel jobs
51    pub fn n_jobs(mut self, n_jobs: Option<i32>) -> Self {
52        self.n_jobs = n_jobs;
53        self
54    }
55}
56
57impl Default for MultiOutputClassifier<Untrained> {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl Estimator for MultiOutputClassifier<Untrained> {
64    type Config = ();
65    type Error = SklearsError;
66    type Float = Float;
67
68    fn config(&self) -> &Self::Config {
69        &()
70    }
71}
72
73impl Fit<ArrayView2<'_, Float>, Array2<i32>> for MultiOutputClassifier<Untrained> {
74    type Fitted = MultiOutputClassifier<MultiOutputClassifierTrained>;
75
76    #[allow(non_snake_case)]
77    fn fit(self, X: &ArrayView2<'_, Float>, y: &Array2<i32>) -> SklResult<Self::Fitted> {
78        let X = X.to_owned();
79        let (n_samples, n_features) = X.dim();
80
81        if n_samples != y.nrows() {
82            return Err(SklearsError::InvalidInput(
83                "X and y must have the same number of samples".to_string(),
84            ));
85        }
86
87        let n_targets = y.ncols();
88        if n_targets == 0 {
89            return Err(SklearsError::InvalidInput(
90                "y must have at least one target".to_string(),
91            ));
92        }
93
94        let mut classes_per_target = Vec::new();
95        let mut target_models = HashMap::new();
96
97        // Fit one classifier per target using simplified nearest centroid approach
98        for target_idx in 0..n_targets {
99            let y_target = y.column(target_idx);
100
101            // Get unique classes for this target
102            let mut target_classes: Vec<i32> = y_target
103                .iter()
104                .cloned()
105                .collect::<std::collections::HashSet<_>>()
106                .into_iter()
107                .collect();
108            target_classes.sort();
109
110            // Compute class centroids for nearest centroid classifier
111            let mut class_centroids = HashMap::new();
112            for &class_label in &target_classes {
113                let mut centroid = Array1::<Float>::zeros(n_features);
114                let mut count = 0;
115
116                for (sample_idx, &sample_class) in y_target.iter().enumerate() {
117                    if sample_class == class_label {
118                        for feature_idx in 0..n_features {
119                            centroid[feature_idx] += X[[sample_idx, feature_idx]];
120                        }
121                        count += 1;
122                    }
123                }
124
125                if count > 0 {
126                    centroid /= count as f64;
127                }
128                class_centroids.insert(class_label, centroid);
129            }
130
131            target_models.insert(target_idx, class_centroids);
132            classes_per_target.push(target_classes);
133        }
134
135        // Use parallel training if n_jobs is specified and > 1
136        if let Some(n_jobs) = self.n_jobs {
137            if n_jobs > 1 && n_targets > 1 {
138                return self.fit_parallel(X, y, n_jobs as usize);
139            }
140        }
141
142        Ok(MultiOutputClassifier {
143            state: MultiOutputClassifierTrained {
144                classes_per_target,
145                target_models,
146                n_targets,
147                n_features,
148            },
149            n_jobs: self.n_jobs,
150        })
151    }
152}
153
154impl MultiOutputClassifier<Untrained> {
155    /// Parallel training implementation
156    #[allow(non_snake_case)]
157    fn fit_parallel(
158        self,
159        X: Array2<Float>,
160        y: &Array2<i32>,
161        n_jobs: usize,
162    ) -> SklResult<MultiOutputClassifier<MultiOutputClassifierTrained>> {
163        let (_n_samples, n_features) = X.dim();
164        let n_targets = y.ncols();
165
166        // Shared data structures
167        let X_arc = Arc::new(X);
168        let y_arc = Arc::new(y.clone());
169        let classes_per_target = Arc::new(Mutex::new(Vec::with_capacity(n_targets)));
170        let target_models = Arc::new(Mutex::new(HashMap::new()));
171
172        // Calculate chunk size for work distribution
173        let chunk_size = n_targets.div_ceil(n_jobs);
174        let mut handles = vec![];
175
176        // Spawn worker threads
177        for worker_id in 0..n_jobs {
178            let start_target = worker_id * chunk_size;
179            let end_target = std::cmp::min(start_target + chunk_size, n_targets);
180
181            if start_target >= n_targets {
182                break; // No more work for this thread
183            }
184
185            let X_thread = Arc::clone(&X_arc);
186            let y_thread = Arc::clone(&y_arc);
187            let classes_thread = Arc::clone(&classes_per_target);
188            let models_thread = Arc::clone(&target_models);
189
190            let handle = thread::spawn(move || -> SklResult<()> {
191                let mut local_classes = Vec::new();
192                let mut local_models = HashMap::new();
193
194                for target_idx in start_target..end_target {
195                    let y_target = y_thread.column(target_idx);
196
197                    // Get unique classes for this target
198                    let mut target_classes: Vec<i32> = y_target
199                        .iter()
200                        .cloned()
201                        .collect::<std::collections::HashSet<_>>()
202                        .into_iter()
203                        .collect();
204                    target_classes.sort();
205
206                    // Compute class centroids for nearest centroid classifier
207                    let mut class_centroids = HashMap::new();
208                    for &class_label in &target_classes {
209                        let mut centroid = Array1::<Float>::zeros(n_features);
210                        let mut count = 0;
211
212                        for (sample_idx, &sample_class) in y_target.iter().enumerate() {
213                            if sample_class == class_label {
214                                for feature_idx in 0..n_features {
215                                    centroid[feature_idx] += X_thread[[sample_idx, feature_idx]];
216                                }
217                                count += 1;
218                            }
219                        }
220
221                        if count > 0 {
222                            centroid /= count as f64;
223                        }
224                        class_centroids.insert(class_label, centroid);
225                    }
226
227                    local_models.insert(target_idx, class_centroids);
228                    local_classes.push((target_idx, target_classes));
229                }
230
231                // Merge results back to shared data structures
232                {
233                    let mut classes_guard =
234                        classes_thread.lock().expect("lock should not be poisoned");
235                    let mut models_guard =
236                        models_thread.lock().expect("lock should not be poisoned");
237
238                    // Ensure proper ordering by sorting local results
239                    local_classes.sort_by_key(|(idx, _)| *idx);
240                    for (target_idx, target_classes) in local_classes {
241                        // Insert at the correct position
242                        while classes_guard.len() <= target_idx {
243                            classes_guard.push(vec![]);
244                        }
245                        classes_guard[target_idx] = target_classes;
246                    }
247
248                    for (target_idx, class_centroids) in local_models {
249                        models_guard.insert(target_idx, class_centroids);
250                    }
251                }
252
253                Ok(())
254            });
255
256            handles.push(handle);
257        }
258
259        // Wait for all threads to complete and collect any errors
260        for handle in handles {
261            handle.join().map_err(|_| {
262                SklearsError::InvalidInput("Thread panicked during parallel training".to_string())
263            })??;
264        }
265
266        // Extract results from Arc<Mutex<>>
267        let final_classes = Arc::try_unwrap(classes_per_target)
268            .map_err(|_| SklearsError::InvalidInput("Failed to extract classes".to_string()))?
269            .into_inner()
270            .expect("operation should succeed");
271
272        let final_models = Arc::try_unwrap(target_models)
273            .map_err(|_| SklearsError::InvalidInput("Failed to extract models".to_string()))?
274            .into_inner()
275            .expect("operation should succeed");
276
277        Ok(MultiOutputClassifier {
278            state: MultiOutputClassifierTrained {
279                classes_per_target: final_classes,
280                target_models: final_models,
281                n_targets,
282                n_features,
283            },
284            n_jobs: Some(n_jobs as i32),
285        })
286    }
287}
288
289impl MultiOutputClassifier<MultiOutputClassifierTrained> {
290    /// Get the classes for each target
291    pub fn classes(&self) -> &[Vec<i32>] {
292        &self.state.classes_per_target
293    }
294
295    /// Get the number of targets
296    pub fn n_targets(&self) -> usize {
297        self.state.n_targets
298    }
299}
300
301impl Predict<ArrayView2<'_, Float>, Array2<i32>>
302    for MultiOutputClassifier<MultiOutputClassifierTrained>
303{
304    #[allow(non_snake_case)]
305    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<i32>> {
306        let X = X.to_owned();
307        let (n_samples, n_features) = X.dim();
308
309        if n_features != self.state.n_features {
310            return Err(SklearsError::InvalidInput(
311                "Number of features doesn't match training data".to_string(),
312            ));
313        }
314
315        let mut predictions = Array2::<i32>::zeros((n_samples, self.state.n_targets));
316
317        // Get predictions from each target using nearest centroid
318        for target_idx in 0..self.state.n_targets {
319            if let Some(class_centroids) = self.state.target_models.get(&target_idx) {
320                for (sample_idx, sample) in X.axis_iter(Axis(0)).enumerate() {
321                    let mut min_distance = f64::INFINITY;
322                    let mut best_class = 0;
323
324                    // Find nearest centroid
325                    for (&class_label, centroid) in class_centroids {
326                        let mut distance = 0.0;
327                        for feature_idx in 0..n_features {
328                            let diff = sample[feature_idx] - centroid[feature_idx];
329                            distance += diff * diff;
330                        }
331                        distance = distance.sqrt();
332
333                        if distance < min_distance {
334                            min_distance = distance;
335                            best_class = class_label;
336                        }
337                    }
338
339                    predictions[[sample_idx, target_idx]] = best_class;
340                }
341            }
342        }
343
344        Ok(predictions)
345    }
346}
347
348/// Multi-Output Regressor
349///
350/// This strategy consists of fitting one regressor per target. This is a simple
351/// strategy for extending regressors that do not natively support multi-output
352/// regression to such cases.
353///
354/// # Examples
355///
356/// ```
357/// use sklears_multioutput::MultiOutputRegressor;
358/// // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
359/// use scirs2_core::ndarray::array;
360///
361/// // This is a simple example showing the structure
362/// let data = array![[1.0, 2.0], [2.0, 3.0], [3.0, 1.0]];
363/// let targets = array![[1.5, 2.5], [2.5, 3.5], [2.0, 1.5]];
364/// ```
365#[derive(Debug, Clone)]
366pub struct MultiOutputRegressor<S = Untrained> {
367    state: S,
368    n_jobs: Option<i32>,
369}
370
371impl MultiOutputRegressor<Untrained> {
372    /// Create a new MultiOutputRegressor instance
373    pub fn new() -> Self {
374        Self {
375            state: Untrained,
376            n_jobs: None,
377        }
378    }
379
380    /// Set the number of parallel jobs
381    pub fn n_jobs(mut self, n_jobs: Option<i32>) -> Self {
382        self.n_jobs = n_jobs;
383        self
384    }
385}
386
387impl Default for MultiOutputRegressor<Untrained> {
388    fn default() -> Self {
389        Self::new()
390    }
391}
392
393impl Estimator for MultiOutputRegressor<Untrained> {
394    type Config = ();
395    type Error = SklearsError;
396    type Float = Float;
397
398    fn config(&self) -> &Self::Config {
399        &()
400    }
401}
402
403impl Fit<ArrayView2<'_, Float>, Array2<f64>> for MultiOutputRegressor<Untrained> {
404    type Fitted = MultiOutputRegressor<MultiOutputRegressorTrained>;
405
406    #[allow(non_snake_case)]
407    fn fit(self, X: &ArrayView2<'_, Float>, y: &Array2<f64>) -> SklResult<Self::Fitted> {
408        let X = X.to_owned();
409        let (n_samples, n_features) = X.dim();
410
411        if n_samples != y.nrows() {
412            return Err(SklearsError::InvalidInput(
413                "X and y must have the same number of samples".to_string(),
414            ));
415        }
416
417        let n_targets = y.ncols();
418        if n_targets == 0 {
419            return Err(SklearsError::InvalidInput(
420                "y must have at least one target".to_string(),
421            ));
422        }
423
424        let mut target_models = HashMap::new();
425
426        // Fit one linear regressor per target using least squares
427        for target_idx in 0..n_targets {
428            let y_target = y.column(target_idx);
429
430            // Simple linear regression: solve normal equations X^T X w = X^T y
431            // For numerical stability, we'll use a simple average-based approach
432            let mut weights = Array1::<Float>::zeros(n_features);
433
434            // Compute mean of targets (used as the bias term)
435            let y_mean = y_target
436                .mean()
437                .expect("array should have elements for mean computation");
438            let bias = y_mean;
439
440            // Simple approach: set weights proportional to feature correlations with target
441            for feature_idx in 0..n_features {
442                let mut correlation = 0.0;
443                let mut x_mean = 0.0;
444
445                // Compute feature mean
446                for sample_idx in 0..n_samples {
447                    x_mean += X[[sample_idx, feature_idx]];
448                }
449                x_mean /= n_samples as f64;
450
451                // Compute correlation
452                let mut numerator = 0.0;
453                let mut x_var = 0.0;
454                let mut y_var = 0.0;
455
456                for sample_idx in 0..n_samples {
457                    let x_diff = X[[sample_idx, feature_idx]] - x_mean;
458                    let y_diff = y_target[sample_idx] - y_mean;
459                    numerator += x_diff * y_diff;
460                    x_var += x_diff * x_diff;
461                    y_var += y_diff * y_diff;
462                }
463
464                if x_var > 1e-10 && y_var > 1e-10 {
465                    correlation = numerator / (x_var.sqrt() * y_var.sqrt());
466                }
467
468                weights[feature_idx] = correlation * 0.1; // Scale down for stability
469            }
470
471            target_models.insert(target_idx, (weights, bias));
472        }
473
474        // Use parallel training if n_jobs is specified and > 1
475        if let Some(n_jobs) = self.n_jobs {
476            if n_jobs > 1 && n_targets > 1 {
477                return self.fit_parallel(X, y, n_jobs as usize);
478            }
479        }
480
481        Ok(MultiOutputRegressor {
482            state: MultiOutputRegressorTrained {
483                target_models,
484                n_targets,
485                n_features,
486            },
487            n_jobs: self.n_jobs,
488        })
489    }
490}
491
492impl MultiOutputRegressor<Untrained> {
493    /// Parallel training implementation for regression
494    #[allow(non_snake_case)]
495    fn fit_parallel(
496        self,
497        X: Array2<Float>,
498        y: &Array2<f64>,
499        n_jobs: usize,
500    ) -> SklResult<MultiOutputRegressor<MultiOutputRegressorTrained>> {
501        let (n_samples, n_features) = X.dim();
502        let n_targets = y.ncols();
503
504        // Shared data structures
505        let X_arc = Arc::new(X);
506        let y_arc = Arc::new(y.clone());
507        let target_models = Arc::new(Mutex::new(HashMap::new()));
508
509        // Calculate chunk size for work distribution
510        let chunk_size = n_targets.div_ceil(n_jobs);
511        let mut handles = vec![];
512
513        // Spawn worker threads
514        for worker_id in 0..n_jobs {
515            let start_target = worker_id * chunk_size;
516            let end_target = std::cmp::min(start_target + chunk_size, n_targets);
517
518            if start_target >= n_targets {
519                break; // No more work for this thread
520            }
521
522            let X_thread = Arc::clone(&X_arc);
523            let y_thread = Arc::clone(&y_arc);
524            let models_thread = Arc::clone(&target_models);
525
526            let handle = thread::spawn(move || -> SklResult<()> {
527                let mut local_models = HashMap::new();
528
529                for target_idx in start_target..end_target {
530                    let y_target = y_thread.column(target_idx);
531                    let mut weights = Array1::<f64>::zeros(n_features);
532
533                    // Compute mean of targets
534                    let y_mean = y_target
535                        .mean()
536                        .expect("array should have elements for mean computation");
537                    let bias: f64 = y_mean;
538
539                    // Simple approach: set weights proportional to feature correlations with target
540                    for feature_idx in 0..n_features {
541                        let mut correlation = 0.0;
542                        let mut x_mean = 0.0;
543
544                        // Compute feature mean
545                        for sample_idx in 0..n_samples {
546                            x_mean += X_thread[[sample_idx, feature_idx]];
547                        }
548                        x_mean /= n_samples as f64;
549
550                        // Compute correlation
551                        let mut numerator = 0.0;
552                        let mut x_var = 0.0;
553                        let mut y_var = 0.0;
554
555                        for sample_idx in 0..n_samples {
556                            let x_diff = X_thread[[sample_idx, feature_idx]] - x_mean;
557                            let y_diff = y_target[sample_idx] - y_mean;
558                            numerator += x_diff * y_diff;
559                            x_var += x_diff * x_diff;
560                            y_var += y_diff * y_diff;
561                        }
562
563                        if x_var > 1e-10 && y_var > 1e-10 {
564                            correlation = numerator / (x_var.sqrt() * y_var.sqrt());
565                        }
566
567                        weights[feature_idx] = correlation * 0.1; // Scale down for stability
568                    }
569
570                    local_models.insert(target_idx, (weights, bias));
571                }
572
573                // Merge results back to shared data structure
574                {
575                    let mut models_guard =
576                        models_thread.lock().expect("lock should not be poisoned");
577                    for (target_idx, model) in local_models {
578                        models_guard.insert(target_idx, model);
579                    }
580                }
581
582                Ok(())
583            });
584
585            handles.push(handle);
586        }
587
588        // Wait for all threads to complete and collect any errors
589        for handle in handles {
590            handle.join().map_err(|_| {
591                SklearsError::InvalidInput("Thread panicked during parallel training".to_string())
592            })??;
593        }
594
595        // Extract results from Arc<Mutex<>>
596        let final_models = Arc::try_unwrap(target_models)
597            .map_err(|_| SklearsError::InvalidInput("Failed to extract models".to_string()))?
598            .into_inner()
599            .expect("operation should succeed");
600
601        Ok(MultiOutputRegressor {
602            state: MultiOutputRegressorTrained {
603                target_models: final_models,
604                n_targets,
605                n_features,
606            },
607            n_jobs: Some(n_jobs as i32),
608        })
609    }
610}
611
612impl MultiOutputRegressor<MultiOutputRegressorTrained> {
613    /// Get the number of targets
614    pub fn n_targets(&self) -> usize {
615        self.state.n_targets
616    }
617}
618
619impl Predict<ArrayView2<'_, Float>, Array2<f64>>
620    for MultiOutputRegressor<MultiOutputRegressorTrained>
621{
622    #[allow(non_snake_case)]
623    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<f64>> {
624        let X = X.to_owned();
625        let (n_samples, n_features) = X.dim();
626
627        if n_features != self.state.n_features {
628            return Err(SklearsError::InvalidInput(
629                "Number of features doesn't match training data".to_string(),
630            ));
631        }
632
633        let mut predictions = Array2::<Float>::zeros((n_samples, self.state.n_targets));
634
635        // Get predictions from each target regressor
636        for target_idx in 0..self.state.n_targets {
637            if let Some((weights, bias)) = self.state.target_models.get(&target_idx) {
638                for (sample_idx, sample) in X.axis_iter(Axis(0)).enumerate() {
639                    // Linear prediction: weights^T * x + bias
640                    let prediction: f64 = sample
641                        .iter()
642                        .zip(weights.iter())
643                        .map(|(&x, &w)| x * w)
644                        .sum::<f64>()
645                        + bias;
646
647                    predictions[[sample_idx, target_idx]] = prediction;
648                }
649            }
650        }
651
652        Ok(predictions)
653    }
654}
655
656/// Trained state for MultiOutputClassifier
657#[derive(Debug, Clone)]
658pub struct MultiOutputClassifierTrained {
659    /// The classes for each target
660    pub classes_per_target: Vec<Vec<i32>>,
661    /// Nearest centroid models for each target
662    pub target_models: HashMap<usize, HashMap<i32, Array1<f64>>>,
663    /// Number of targets
664    pub n_targets: usize,
665    /// Number of features
666    pub n_features: usize,
667}
668
669/// Trained state for MultiOutputRegressor
670#[derive(Debug, Clone)]
671pub struct MultiOutputRegressorTrained {
672    /// Linear models for each target (weights, bias)
673    pub target_models: HashMap<usize, (Array1<f64>, f64)>,
674    /// Number of targets
675    pub n_targets: usize,
676    /// Number of features
677    pub n_features: usize,
678}
679
680#[allow(non_snake_case)]
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use approx::assert_abs_diff_eq;
685    // Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
686    use scirs2_core::ndarray::array;
687    use std::time::Instant;
688
689    #[test]
690    #[allow(non_snake_case)]
691    fn test_parallel_multi_output_classifier() {
692        let X = array![
693            [1.0, 2.0, 3.0],
694            [2.0, 3.0, 4.0],
695            [3.0, 4.0, 5.0],
696            [4.0, 5.0, 6.0],
697            [5.0, 6.0, 7.0],
698            [6.0, 7.0, 8.0]
699        ];
700        let y = array![
701            [0, 1, 0],
702            [1, 0, 1],
703            [0, 1, 0],
704            [1, 0, 1],
705            [0, 1, 0],
706            [1, 0, 1]
707        ];
708
709        // Test with parallel training
710        let classifier_parallel = MultiOutputClassifier::new().n_jobs(Some(2));
711        let trained_parallel = classifier_parallel
712            .fit(&X.view(), &y)
713            .expect("model fitting should succeed");
714
715        // Test with sequential training
716        let classifier_sequential = MultiOutputClassifier::new().n_jobs(Some(1));
717        let trained_sequential = classifier_sequential
718            .fit(&X.view(), &y)
719            .expect("model fitting should succeed");
720
721        // Results should be the same
722        assert_eq!(trained_parallel.n_targets(), trained_sequential.n_targets());
723        assert_eq!(
724            trained_parallel.classes().len(),
725            trained_sequential.classes().len()
726        );
727
728        // Test predictions
729        let pred_parallel = trained_parallel
730            .predict(&X.view())
731            .expect("prediction should succeed");
732        let pred_sequential = trained_sequential
733            .predict(&X.view())
734            .expect("prediction should succeed");
735
736        assert_eq!(pred_parallel.shape(), pred_sequential.shape());
737        assert_eq!(pred_parallel.shape(), &[6, 3]);
738    }
739
740    #[test]
741    #[allow(non_snake_case)]
742    fn test_parallel_multi_output_regressor() {
743        let X = array![
744            [1.0, 2.0, 3.0],
745            [2.0, 3.0, 4.0],
746            [3.0, 4.0, 5.0],
747            [4.0, 5.0, 6.0],
748            [5.0, 6.0, 7.0],
749            [6.0, 7.0, 8.0]
750        ];
751        let y = array![
752            [1.5, 2.5, 3.5],
753            [2.5, 3.5, 4.5],
754            [3.5, 4.5, 5.5],
755            [4.5, 5.5, 6.5],
756            [5.5, 6.5, 7.5],
757            [6.5, 7.5, 8.5]
758        ];
759
760        // Test with parallel training
761        let regressor_parallel = MultiOutputRegressor::new().n_jobs(Some(2));
762        let trained_parallel = regressor_parallel
763            .fit(&X.view(), &y)
764            .expect("model fitting should succeed");
765
766        // Test with sequential training
767        let regressor_sequential = MultiOutputRegressor::new().n_jobs(Some(1));
768        let trained_sequential = regressor_sequential
769            .fit(&X.view(), &y)
770            .expect("model fitting should succeed");
771
772        // Results should be the same
773        assert_eq!(trained_parallel.n_targets(), trained_sequential.n_targets());
774
775        // Test predictions
776        let pred_parallel = trained_parallel
777            .predict(&X.view())
778            .expect("prediction should succeed");
779        let pred_sequential = trained_sequential
780            .predict(&X.view())
781            .expect("prediction should succeed");
782
783        assert_eq!(pred_parallel.shape(), pred_sequential.shape());
784        assert_eq!(pred_parallel.shape(), &[6, 3]);
785
786        // Predictions should be approximately equal
787        for i in 0..pred_parallel.nrows() {
788            for j in 0..pred_parallel.ncols() {
789                assert_abs_diff_eq!(
790                    pred_parallel[[i, j]],
791                    pred_sequential[[i, j]],
792                    epsilon = 1e-10
793                );
794            }
795        }
796    }
797
798    #[test]
799    fn test_parallel_training_performance_classifier() {
800        // Create larger dataset to see potential parallel benefits
801        let n_samples = 1000;
802        let n_features = 50;
803        let n_targets = 20;
804
805        let mut X = Array2::<Float>::zeros((n_samples, n_features));
806        let mut y = Array2::<i32>::zeros((n_samples, n_targets));
807
808        // Fill with simple patterns
809        for i in 0..n_samples {
810            for j in 0..n_features {
811                X[[i, j]] = (i * j) as Float * 0.01;
812            }
813            for j in 0..n_targets {
814                y[[i, j]] = ((i + j) % 2) as i32;
815            }
816        }
817
818        // Time sequential training
819        let start_sequential = Instant::now();
820        let classifier_sequential = MultiOutputClassifier::new().n_jobs(Some(1));
821        let trained_sequential = classifier_sequential
822            .fit(&X.view(), &y)
823            .expect("model fitting should succeed");
824        let sequential_time = start_sequential.elapsed();
825
826        // Time parallel training
827        let start_parallel = Instant::now();
828        let classifier_parallel = MultiOutputClassifier::new().n_jobs(Some(4));
829        let trained_parallel = classifier_parallel
830            .fit(&X.view(), &y)
831            .expect("model fitting should succeed");
832        let parallel_time = start_parallel.elapsed();
833
834        // Ensure both produce valid results
835        assert_eq!(trained_parallel.n_targets(), n_targets);
836        assert_eq!(trained_sequential.n_targets(), n_targets);
837
838        // Test predictions are consistent
839        let pred_parallel = trained_parallel
840            .predict(&X.view())
841            .expect("prediction should succeed");
842        let pred_sequential = trained_sequential
843            .predict(&X.view())
844            .expect("prediction should succeed");
845        assert_eq!(pred_parallel.shape(), pred_sequential.shape());
846
847        println!(
848            "Sequential time: {:?}, Parallel time: {:?}",
849            sequential_time, parallel_time
850        );
851    }
852
853    #[test]
854    fn test_parallel_training_performance_regressor() {
855        // Create larger dataset to see potential parallel benefits
856        let n_samples = 1000;
857        let n_features = 50;
858        let n_targets = 20;
859
860        let mut X = Array2::<Float>::zeros((n_samples, n_features));
861        let mut y = Array2::<f64>::zeros((n_samples, n_targets));
862
863        // Fill with simple patterns
864        for i in 0..n_samples {
865            for j in 0..n_features {
866                X[[i, j]] = (i * j) as Float * 0.01;
867            }
868            for j in 0..n_targets {
869                y[[i, j]] = (i + j) as f64 * 0.1;
870            }
871        }
872
873        // Time sequential training
874        let start_sequential = Instant::now();
875        let regressor_sequential = MultiOutputRegressor::new().n_jobs(Some(1));
876        let trained_sequential = regressor_sequential
877            .fit(&X.view(), &y)
878            .expect("model fitting should succeed");
879        let sequential_time = start_sequential.elapsed();
880
881        // Time parallel training
882        let start_parallel = Instant::now();
883        let regressor_parallel = MultiOutputRegressor::new().n_jobs(Some(4));
884        let trained_parallel = regressor_parallel
885            .fit(&X.view(), &y)
886            .expect("model fitting should succeed");
887        let parallel_time = start_parallel.elapsed();
888
889        // Ensure both produce valid results
890        assert_eq!(trained_parallel.n_targets(), n_targets);
891        assert_eq!(trained_sequential.n_targets(), n_targets);
892
893        // Test predictions are consistent
894        let pred_parallel = trained_parallel
895            .predict(&X.view())
896            .expect("prediction should succeed");
897        let pred_sequential = trained_sequential
898            .predict(&X.view())
899            .expect("prediction should succeed");
900        assert_eq!(pred_parallel.shape(), pred_sequential.shape());
901
902        println!(
903            "Sequential time: {:?}, Parallel time: {:?}",
904            sequential_time, parallel_time
905        );
906    }
907
908    #[test]
909    #[allow(non_snake_case)]
910    fn test_parallel_training_thread_safety() {
911        let X = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0], [4.0, 5.0]];
912        let y_class = array![[0, 1], [1, 0], [0, 1], [1, 0]];
913        let y_reg = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0], [4.0, 5.0]];
914
915        // Test multiple parallel runs to check for race conditions
916        for _ in 0..10 {
917            let classifier = MultiOutputClassifier::new().n_jobs(Some(2));
918            let trained = classifier
919                .fit(&X.view(), &y_class)
920                .expect("model fitting should succeed");
921            let predictions = trained
922                .predict(&X.view())
923                .expect("prediction should succeed");
924            assert_eq!(predictions.shape(), &[4, 2]);
925
926            let regressor = MultiOutputRegressor::new().n_jobs(Some(2));
927            let trained = regressor
928                .fit(&X.view(), &y_reg)
929                .expect("model fitting should succeed");
930            let predictions = trained
931                .predict(&X.view())
932                .expect("prediction should succeed");
933            assert_eq!(predictions.shape(), &[4, 2]);
934        }
935    }
936
937    #[test]
938    #[allow(non_snake_case)]
939    fn test_parallel_training_edge_cases() {
940        let X = array![[1.0, 2.0], [2.0, 3.0]];
941        let y_class = array![[0, 1], [1, 0]];
942        let y_reg = array![[1.0, 2.0], [2.0, 3.0]];
943
944        // Test with more threads than targets (should handle gracefully)
945        let classifier = MultiOutputClassifier::new().n_jobs(Some(10));
946        let trained = classifier
947            .fit(&X.view(), &y_class)
948            .expect("model fitting should succeed");
949        assert_eq!(trained.n_targets(), 2);
950
951        let regressor = MultiOutputRegressor::new().n_jobs(Some(10));
952        let trained = regressor
953            .fit(&X.view(), &y_reg)
954            .expect("model fitting should succeed");
955        assert_eq!(trained.n_targets(), 2);
956
957        // Test with single target (should fall back to sequential)
958        let y_single = array![[0], [1]];
959        let classifier_single = MultiOutputClassifier::new().n_jobs(Some(4));
960        let trained_single = classifier_single
961            .fit(&X.view(), &y_single)
962            .expect("model fitting should succeed");
963        assert_eq!(trained_single.n_targets(), 1);
964    }
965
966    #[test]
967    #[allow(non_snake_case)]
968    fn test_parallel_training_error_handling() {
969        let X = array![[1.0, 2.0], [2.0, 3.0]];
970        let y_mismatch = array![[0, 1, 0], [1, 0, 1], [0, 1, 0]]; // Wrong number of samples
971
972        // Test error handling in parallel mode
973        let classifier = MultiOutputClassifier::new().n_jobs(Some(2));
974        let result = classifier.fit(&X.view(), &y_mismatch);
975        assert!(result.is_err());
976
977        let regressor = MultiOutputRegressor::new().n_jobs(Some(2));
978        let y_reg_mismatch = array![[1.0, 2.0, 3.0], [2.0, 3.0, 4.0], [3.0, 4.0, 5.0]];
979        let result = regressor.fit(&X.view(), &y_reg_mismatch);
980        assert!(result.is_err());
981    }
982}