Skip to main content

sklears_neighbors/
multi_view_learning.rs

1//! Multi-View Learning for Neighbor-Based Methods
2//!
3//! This module implements neighbor algorithms that work with multiple views or
4//! representations of the same data. Multi-view learning is useful when data can be
5//! represented in multiple ways (e.g., images with color, texture, and shape features).
6//!
7//! # Key Concepts
8//!
9//! - **View**: A specific representation or feature set of the data
10//! - **Consensus**: Agreement across multiple views on neighbor relationships
11//! - **View-Specific Learning**: Adapting distance metrics per view
12//! - **Cross-View Analysis**: Analyzing consistency of neighbor patterns across views
13//!
14//! # Examples
15//!
16//! ```rust
17//! use sklears_neighbors::multi_view_learning::{MultiViewKNeighborsClassifier, ViewConfig};
18//! use sklears_neighbors::Distance;
19//! use sklears_core::traits::Fit;
20//! use scirs2_core::ndarray::Array2;
21//!
22//! // Create two views of data
23//! let view1 = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 2.0, 3.0, 3.0, 1.0, 4.0, 2.0]).expect("shape matches data length");
24//! let view2 = Array2::from_shape_vec((4, 2), vec![5.0, 6.0, 6.0, 7.0, 7.0, 5.0, 8.0, 6.0]).expect("shape matches data length");
25//! let views = vec![view1, view2];
26//! # let y = scirs2_core::ndarray::Array1::from_vec(vec![0, 0, 1, 1]);
27//! # let configs = vec![ViewConfig::new(Distance::Euclidean, 1.0); 2];
28//! # let classifier = MultiViewKNeighborsClassifier::new(3, configs);
29//! # let _fitted = classifier.fit(&views, &y);
30//! ```
31
32use crate::distance::Distance;
33use crate::NeighborsError;
34use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
35use sklears_core::error::SklearsError;
36use sklears_core::traits::{Fit, Predict, PredictProba};
37use sklears_core::types::Float;
38
39/// Configuration for a single view
40#[derive(Debug, Clone)]
41pub struct ViewConfig {
42    /// Distance metric for this view
43    pub distance: Distance,
44    /// Weight assigned to this view (higher = more important)
45    pub weight: Float,
46    /// Whether to learn an adaptive distance metric for this view
47    pub learn_metric: bool,
48}
49
50impl ViewConfig {
51    /// Create a new view configuration
52    pub fn new(distance: Distance, weight: Float) -> Self {
53        Self {
54            distance,
55            weight,
56            learn_metric: false,
57        }
58    }
59
60    /// Enable metric learning for this view
61    pub fn with_metric_learning(mut self) -> Self {
62        self.learn_metric = true;
63        self
64    }
65}
66
67/// Fusion strategy for combining predictions from multiple views
68#[derive(Debug, Clone, Copy)]
69pub enum FusionStrategy {
70    /// Weight predictions by view weights
71    WeightedAverage,
72    /// Multiply probabilities (product of experts)
73    Product,
74    /// Use majority voting
75    MajorityVoting,
76    /// Stack predictions and learn optimal combination
77    Stacking,
78}
79
80/// Multi-View K-Nearest Neighbors Classifier
81///
82/// Learns from multiple views/representations of the data and combines
83/// predictions using configurable fusion strategies.
84#[derive(Debug, Clone)]
85pub struct MultiViewKNeighborsClassifier<S> {
86    k: usize,
87    view_configs: Vec<ViewConfig>,
88    fusion_strategy: FusionStrategy,
89    consensus_threshold: Float,
90    state: S,
91}
92
93/// Untrained state
94#[derive(Debug, Clone)]
95pub struct Untrained;
96
97/// Trained state for multi-view classifier
98#[derive(Debug, Clone)]
99pub struct Trained {
100    /// Training data for each view
101    x_train_views: Vec<Array2<Float>>,
102    /// Training labels
103    y_train: Array1<i32>,
104    /// Unique class labels
105    classes: Array1<i32>,
106    /// Learned view weights (may be updated during training)
107    view_weights: Array1<Float>,
108}
109
110impl MultiViewKNeighborsClassifier<Untrained> {
111    /// Create a new multi-view KNN classifier
112    ///
113    /// # Arguments
114    ///
115    /// * `k` - Number of neighbors to use
116    /// * `view_configs` - Configuration for each view
117    pub fn new(k: usize, view_configs: Vec<ViewConfig>) -> Self {
118        Self {
119            k,
120            view_configs,
121            fusion_strategy: FusionStrategy::WeightedAverage,
122            consensus_threshold: 0.5,
123            state: Untrained,
124        }
125    }
126
127    /// Set the fusion strategy for combining view predictions
128    pub fn with_fusion_strategy(mut self, strategy: FusionStrategy) -> Self {
129        self.fusion_strategy = strategy;
130        self
131    }
132
133    /// Set the consensus threshold for neighbor agreement across views
134    pub fn with_consensus_threshold(mut self, threshold: Float) -> Self {
135        self.consensus_threshold = threshold;
136        self
137    }
138}
139
140impl Fit<Vec<Array2<Float>>, Array1<i32>, MultiViewKNeighborsClassifier<Trained>>
141    for MultiViewKNeighborsClassifier<Untrained>
142{
143    type Fitted = MultiViewKNeighborsClassifier<Trained>;
144
145    fn fit(
146        self,
147        x_views: &Vec<Array2<Float>>,
148        y: &Array1<i32>,
149    ) -> Result<Self::Fitted, SklearsError> {
150        // Validation
151        if x_views.is_empty() {
152            return Err(NeighborsError::InvalidInput("No views provided".to_string()).into());
153        }
154
155        if x_views.len() != self.view_configs.len() {
156            return Err(NeighborsError::InvalidInput(format!(
157                "Number of views ({}) doesn't match number of view configs ({})",
158                x_views.len(),
159                self.view_configs.len()
160            ))
161            .into());
162        }
163
164        let n_samples = x_views[0].nrows();
165
166        // Check all views have same number of samples
167        for view in x_views.iter() {
168            if view.nrows() != n_samples {
169                return Err(NeighborsError::ShapeMismatch {
170                    expected: vec![n_samples],
171                    actual: vec![view.nrows()],
172                }
173                .into());
174            }
175        }
176
177        if y.len() != n_samples {
178            return Err(NeighborsError::ShapeMismatch {
179                expected: vec![n_samples],
180                actual: vec![y.len()],
181            }
182            .into());
183        }
184
185        if self.k >= n_samples {
186            return Err(NeighborsError::InvalidInput(format!(
187                "k={} should be less than n_samples={}",
188                self.k, n_samples
189            ))
190            .into());
191        }
192
193        // Extract unique classes
194        let mut unique_classes: Vec<i32> = y.iter().cloned().collect();
195        unique_classes.sort_unstable();
196        unique_classes.dedup();
197        let classes = Array1::from_vec(unique_classes);
198
199        // Initialize view weights from configs
200        let view_weights = Array1::from_vec(
201            self.view_configs
202                .iter()
203                .map(|config| config.weight)
204                .collect(),
205        );
206
207        Ok(MultiViewKNeighborsClassifier {
208            k: self.k,
209            view_configs: self.view_configs,
210            fusion_strategy: self.fusion_strategy,
211            consensus_threshold: self.consensus_threshold,
212            state: Trained {
213                x_train_views: x_views.clone(),
214                y_train: y.clone(),
215                classes,
216                view_weights,
217            },
218        })
219    }
220}
221
222impl Predict<Vec<Array2<Float>>, Array1<i32>> for MultiViewKNeighborsClassifier<Trained> {
223    fn predict(&self, x_views: &Vec<Array2<Float>>) -> Result<Array1<i32>, SklearsError> {
224        if x_views.len() != self.state.x_train_views.len() {
225            return Err(NeighborsError::InvalidInput(format!(
226                "Expected {} views, got {}",
227                self.state.x_train_views.len(),
228                x_views.len()
229            ))
230            .into());
231        }
232
233        let n_samples = x_views[0].nrows();
234        let mut predictions = Vec::with_capacity(n_samples);
235
236        for i in 0..n_samples {
237            let pred = self.predict_single(x_views, i)?;
238            predictions.push(pred);
239        }
240
241        Ok(Array1::from_vec(predictions))
242    }
243}
244
245impl PredictProba<Vec<Array2<Float>>, Array2<Float>> for MultiViewKNeighborsClassifier<Trained> {
246    fn predict_proba(&self, x_views: &Vec<Array2<Float>>) -> Result<Array2<Float>, SklearsError> {
247        if x_views.len() != self.state.x_train_views.len() {
248            return Err(NeighborsError::InvalidInput(format!(
249                "Expected {} views, got {}",
250                self.state.x_train_views.len(),
251                x_views.len()
252            ))
253            .into());
254        }
255
256        let n_samples = x_views[0].nrows();
257        let n_classes = self.state.classes.len();
258        let mut proba_matrix = Array2::zeros((n_samples, n_classes));
259
260        for i in 0..n_samples {
261            let proba = self.predict_proba_single(x_views, i)?;
262            proba_matrix.row_mut(i).assign(&proba);
263        }
264
265        Ok(proba_matrix)
266    }
267}
268
269impl MultiViewKNeighborsClassifier<Trained> {
270    /// Predict class for a single sample across all views
271    fn predict_single(
272        &self,
273        x_views: &[Array2<Float>],
274        sample_idx: usize,
275    ) -> Result<i32, SklearsError> {
276        let proba = self.predict_proba_single(x_views, sample_idx)?;
277        let class_idx = proba
278            .iter()
279            .enumerate()
280            .max_by(|(_, a), (_, b)| a.partial_cmp(b).expect("operation should succeed"))
281            .map(|(idx, _)| idx)
282            .unwrap_or(0);
283        Ok(self.state.classes[class_idx])
284    }
285
286    /// Predict class probabilities for a single sample
287    fn predict_proba_single(
288        &self,
289        x_views: &[Array2<Float>],
290        sample_idx: usize,
291    ) -> Result<Array1<Float>, SklearsError> {
292        let n_classes = self.state.classes.len();
293
294        match self.fusion_strategy {
295            FusionStrategy::WeightedAverage => {
296                let mut combined_proba = Array1::zeros(n_classes);
297                let mut total_weight = 0.0;
298
299                for (view_idx, view) in x_views.iter().enumerate() {
300                    let query = view.row(sample_idx);
301                    let proba = self.predict_proba_for_view(view_idx, &query)?;
302                    let weight = self.state.view_weights[view_idx];
303                    combined_proba += &(proba * weight);
304                    total_weight += weight;
305                }
306
307                if total_weight > 0.0 {
308                    combined_proba /= total_weight;
309                }
310
311                Ok(combined_proba)
312            }
313            FusionStrategy::Product => {
314                let mut combined_proba = Array1::ones(n_classes);
315
316                for (view_idx, view) in x_views.iter().enumerate() {
317                    let query = view.row(sample_idx);
318                    let proba = self.predict_proba_for_view(view_idx, &query)?;
319                    combined_proba *= &proba;
320                }
321
322                // Normalize
323                let sum: Float = combined_proba.sum();
324                if sum > 0.0 {
325                    combined_proba /= sum;
326                }
327
328                Ok(combined_proba)
329            }
330            FusionStrategy::MajorityVoting => {
331                let mut votes = Array1::zeros(n_classes);
332
333                for (view_idx, view) in x_views.iter().enumerate() {
334                    let query = view.row(sample_idx);
335                    let proba = self.predict_proba_for_view(view_idx, &query)?;
336                    let class_idx = proba
337                        .iter()
338                        .enumerate()
339                        .max_by(|(_, a), (_, b)| {
340                            a.partial_cmp(b).expect("operation should succeed")
341                        })
342                        .map(|(idx, _)| idx)
343                        .unwrap_or(0);
344                    votes[class_idx] += 1.0;
345                }
346
347                // Normalize votes to probabilities
348                let total: Float = votes.sum();
349                if total > 0.0 {
350                    votes /= total;
351                }
352
353                Ok(votes)
354            }
355            FusionStrategy::Stacking => {
356                // Simple stacking: average probabilities from all views
357                // In a full implementation, this would use a meta-learner
358                let mut combined_proba = Array1::zeros(n_classes);
359
360                for (view_idx, view) in x_views.iter().enumerate() {
361                    let query = view.row(sample_idx);
362                    let proba = self.predict_proba_for_view(view_idx, &query)?;
363                    combined_proba += &proba;
364                }
365
366                combined_proba /= x_views.len() as Float;
367                Ok(combined_proba)
368            }
369        }
370    }
371
372    /// Predict probabilities for a single view
373    fn predict_proba_for_view(
374        &self,
375        view_idx: usize,
376        query: &ArrayView1<Float>,
377    ) -> Result<Array1<Float>, SklearsError> {
378        let distance = &self.view_configs[view_idx].distance;
379        let train_view = &self.state.x_train_views[view_idx];
380
381        // Find k nearest neighbors in this view
382        let mut distances_indices: Vec<(Float, usize)> = train_view
383            .rows()
384            .into_iter()
385            .enumerate()
386            .map(|(i, row)| (distance.calculate(query, &row), i))
387            .collect();
388
389        distances_indices.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("operation should succeed"));
390        let neighbor_indices: Vec<usize> = distances_indices
391            .into_iter()
392            .take(self.k)
393            .map(|(_, idx)| idx)
394            .collect();
395
396        // Count classes among neighbors
397        let mut class_counts = Array1::zeros(self.state.classes.len());
398        for &idx in &neighbor_indices {
399            let label = self.state.y_train[idx];
400            if let Some(class_idx) = self.state.classes.iter().position(|&c| c == label) {
401                class_counts[class_idx] += 1.0;
402            }
403        }
404
405        // Normalize to probabilities
406        let total: Float = class_counts.sum();
407        if total > 0.0 {
408            class_counts /= total;
409        }
410
411        Ok(class_counts)
412    }
413
414    /// Analyze consensus among views for neighbor relationships
415    pub fn analyze_consensus(
416        &self,
417        x_views: &[Array2<Float>],
418    ) -> Result<ConsensusAnalysis, SklearsError> {
419        if x_views.len() != self.state.x_train_views.len() {
420            return Err(NeighborsError::InvalidInput(format!(
421                "Expected {} views, got {}",
422                self.state.x_train_views.len(),
423                x_views.len()
424            ))
425            .into());
426        }
427
428        let n_samples = x_views[0].nrows();
429        let mut consensus_scores = Vec::with_capacity(n_samples);
430        let mut view_agreements = Array2::zeros((x_views.len(), x_views.len()));
431
432        for i in 0..n_samples {
433            // Get neighbors from each view
434            let mut view_neighbors = Vec::new();
435            for (view_idx, view) in x_views.iter().enumerate() {
436                let query = view.row(i);
437                let neighbors = self.get_neighbors_for_view(view_idx, &query)?;
438                view_neighbors.push(neighbors);
439            }
440
441            // Calculate consensus score (Jaccard similarity of neighbor sets)
442            let mut consensus_sum = 0.0;
443            let mut count = 0;
444            for j in 0..view_neighbors.len() {
445                for k in (j + 1)..view_neighbors.len() {
446                    let intersection: usize = view_neighbors[j]
447                        .iter()
448                        .filter(|&n| view_neighbors[k].contains(n))
449                        .count();
450                    let union = view_neighbors[j].len() + view_neighbors[k].len() - intersection;
451                    let jaccard = if union > 0 {
452                        intersection as Float / union as Float
453                    } else {
454                        0.0
455                    };
456                    consensus_sum += jaccard;
457                    view_agreements[[j, k]] += jaccard;
458                    view_agreements[[k, j]] += jaccard;
459                    count += 1;
460                }
461            }
462
463            let consensus = if count > 0 {
464                consensus_sum / count as Float
465            } else {
466                0.0
467            };
468            consensus_scores.push(consensus);
469        }
470
471        // Normalize view agreements
472        if n_samples > 0 {
473            view_agreements /= n_samples as Float;
474        }
475
476        let mean_consensus =
477            consensus_scores.iter().sum::<Float>() / consensus_scores.len() as Float;
478
479        Ok(ConsensusAnalysis {
480            sample_consensus_scores: Array1::from_vec(consensus_scores),
481            mean_consensus,
482            view_agreement_matrix: view_agreements,
483        })
484    }
485
486    /// Get neighbors for a single view
487    fn get_neighbors_for_view(
488        &self,
489        view_idx: usize,
490        query: &ArrayView1<Float>,
491    ) -> Result<Vec<usize>, SklearsError> {
492        let distance = &self.view_configs[view_idx].distance;
493        let train_view = &self.state.x_train_views[view_idx];
494
495        let mut distances_indices: Vec<(Float, usize)> = train_view
496            .rows()
497            .into_iter()
498            .enumerate()
499            .map(|(i, row)| (distance.calculate(query, &row), i))
500            .collect();
501
502        distances_indices.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("operation should succeed"));
503        Ok(distances_indices
504            .into_iter()
505            .take(self.k)
506            .map(|(_, idx)| idx)
507            .collect())
508    }
509}
510
511/// Result of consensus analysis across views
512#[derive(Debug, Clone)]
513pub struct ConsensusAnalysis {
514    /// Consensus score for each sample (0-1, higher = more agreement)
515    pub sample_consensus_scores: Array1<Float>,
516    /// Mean consensus across all samples
517    pub mean_consensus: Float,
518    /// Agreement matrix between views (n_views x n_views)
519    pub view_agreement_matrix: Array2<Float>,
520}
521
522/// Multi-View K-Nearest Neighbors Regressor
523#[derive(Debug, Clone)]
524pub struct MultiViewKNeighborsRegressor<S> {
525    k: usize,
526    view_configs: Vec<ViewConfig>,
527    fusion_strategy: RegressionFusionStrategy,
528    state: S,
529}
530
531/// Fusion strategy for regression
532#[derive(Debug, Clone, Copy)]
533pub enum RegressionFusionStrategy {
534    /// Weight predictions by view weights
535    WeightedAverage,
536    /// Use median prediction
537    Median,
538    /// Stack predictions and learn optimal combination
539    Stacking,
540}
541
542/// Trained state for regressor
543#[derive(Debug, Clone)]
544pub struct RegressorTrained {
545    x_train_views: Vec<Array2<Float>>,
546    y_train: Array1<Float>,
547    view_weights: Array1<Float>,
548}
549
550impl MultiViewKNeighborsRegressor<Untrained> {
551    /// Create a new multi-view KNN regressor
552    pub fn new(k: usize, view_configs: Vec<ViewConfig>) -> Self {
553        Self {
554            k,
555            view_configs,
556            fusion_strategy: RegressionFusionStrategy::WeightedAverage,
557            state: Untrained,
558        }
559    }
560
561    /// Set the fusion strategy
562    pub fn with_fusion_strategy(mut self, strategy: RegressionFusionStrategy) -> Self {
563        self.fusion_strategy = strategy;
564        self
565    }
566}
567
568impl Fit<Vec<Array2<Float>>, Array1<Float>, MultiViewKNeighborsRegressor<RegressorTrained>>
569    for MultiViewKNeighborsRegressor<Untrained>
570{
571    type Fitted = MultiViewKNeighborsRegressor<RegressorTrained>;
572
573    fn fit(
574        self,
575        x_views: &Vec<Array2<Float>>,
576        y: &Array1<Float>,
577    ) -> Result<Self::Fitted, SklearsError> {
578        if x_views.is_empty() {
579            return Err(NeighborsError::InvalidInput("No views provided".to_string()).into());
580        }
581
582        if x_views.len() != self.view_configs.len() {
583            return Err(NeighborsError::InvalidInput(format!(
584                "Number of views ({}) doesn't match number of view configs ({})",
585                x_views.len(),
586                self.view_configs.len()
587            ))
588            .into());
589        }
590
591        let n_samples = x_views[0].nrows();
592
593        for view in x_views.iter() {
594            if view.nrows() != n_samples {
595                return Err(NeighborsError::ShapeMismatch {
596                    expected: vec![n_samples],
597                    actual: vec![view.nrows()],
598                }
599                .into());
600            }
601        }
602
603        if y.len() != n_samples {
604            return Err(NeighborsError::ShapeMismatch {
605                expected: vec![n_samples],
606                actual: vec![y.len()],
607            }
608            .into());
609        }
610
611        let view_weights = Array1::from_vec(
612            self.view_configs
613                .iter()
614                .map(|config| config.weight)
615                .collect(),
616        );
617
618        Ok(MultiViewKNeighborsRegressor {
619            k: self.k,
620            view_configs: self.view_configs,
621            fusion_strategy: self.fusion_strategy,
622            state: RegressorTrained {
623                x_train_views: x_views.clone(),
624                y_train: y.clone(),
625                view_weights,
626            },
627        })
628    }
629}
630
631impl Predict<Vec<Array2<Float>>, Array1<Float>> for MultiViewKNeighborsRegressor<RegressorTrained> {
632    fn predict(&self, x_views: &Vec<Array2<Float>>) -> Result<Array1<Float>, SklearsError> {
633        if x_views.len() != self.state.x_train_views.len() {
634            return Err(NeighborsError::InvalidInput(format!(
635                "Expected {} views, got {}",
636                self.state.x_train_views.len(),
637                x_views.len()
638            ))
639            .into());
640        }
641
642        let n_samples = x_views[0].nrows();
643        let mut predictions = Vec::with_capacity(n_samples);
644
645        for i in 0..n_samples {
646            let pred = self.predict_single(x_views, i)?;
647            predictions.push(pred);
648        }
649
650        Ok(Array1::from_vec(predictions))
651    }
652}
653
654impl MultiViewKNeighborsRegressor<RegressorTrained> {
655    fn predict_single(
656        &self,
657        x_views: &[Array2<Float>],
658        sample_idx: usize,
659    ) -> Result<Float, SklearsError> {
660        match self.fusion_strategy {
661            RegressionFusionStrategy::WeightedAverage => {
662                let mut weighted_sum = 0.0;
663                let mut total_weight = 0.0;
664
665                for (view_idx, view) in x_views.iter().enumerate() {
666                    let query = view.row(sample_idx);
667                    let pred = self.predict_for_view(view_idx, &query)?;
668                    let weight = self.state.view_weights[view_idx];
669                    weighted_sum += pred * weight;
670                    total_weight += weight;
671                }
672
673                Ok(if total_weight > 0.0 {
674                    weighted_sum / total_weight
675                } else {
676                    0.0
677                })
678            }
679            RegressionFusionStrategy::Median => {
680                let mut predictions = Vec::new();
681                for (view_idx, view) in x_views.iter().enumerate() {
682                    let query = view.row(sample_idx);
683                    let pred = self.predict_for_view(view_idx, &query)?;
684                    predictions.push(pred);
685                }
686                predictions.sort_by(|a, b| a.partial_cmp(b).expect("operation should succeed"));
687                Ok(predictions[predictions.len() / 2])
688            }
689            RegressionFusionStrategy::Stacking => {
690                // Simple stacking: average predictions
691                let mut sum = 0.0;
692                for (view_idx, view) in x_views.iter().enumerate() {
693                    let query = view.row(sample_idx);
694                    let pred = self.predict_for_view(view_idx, &query)?;
695                    sum += pred;
696                }
697                Ok(sum / (x_views.len() as Float))
698            }
699        }
700    }
701
702    fn predict_for_view(
703        &self,
704        view_idx: usize,
705        query: &ArrayView1<Float>,
706    ) -> Result<Float, SklearsError> {
707        let distance = &self.view_configs[view_idx].distance;
708        let train_view = &self.state.x_train_views[view_idx];
709
710        let mut distances_indices: Vec<(Float, usize)> = train_view
711            .rows()
712            .into_iter()
713            .enumerate()
714            .map(|(i, row)| (distance.calculate(query, &row), i))
715            .collect();
716
717        distances_indices.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("operation should succeed"));
718        let neighbor_values: Vec<Float> = distances_indices
719            .into_iter()
720            .take(self.k)
721            .map(|(_, idx)| self.state.y_train[idx])
722            .collect();
723
724        Ok(neighbor_values.iter().sum::<Float>() / neighbor_values.len() as Float)
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use scirs2_core::ndarray::array;
732
733    #[test]
734    fn test_multi_view_classifier_basic() {
735        // Create two views of the same data
736        let view1 = Array2::from_shape_vec(
737            (6, 2),
738            vec![
739                -1.0, -1.0, -0.5, -1.0, 0.0, 0.0, 1.0, 1.0, 0.5, 1.0, 1.5, 1.5,
740            ],
741        )
742        .expect("operation should succeed");
743        let view2 = Array2::from_shape_vec(
744            (6, 2),
745            vec![
746                1.0, 1.0, 1.5, 1.0, 0.0, 0.0, -1.0, -1.0, -0.5, -1.0, -1.5, -1.5,
747            ],
748        )
749        .expect("operation should succeed");
750        let views = vec![view1, view2];
751        let y = array![0, 0, 1, 1, 1, 1];
752
753        let configs = vec![
754            ViewConfig::new(Distance::Euclidean, 1.0),
755            ViewConfig::new(Distance::Euclidean, 1.0),
756        ];
757
758        let classifier = MultiViewKNeighborsClassifier::new(3, configs);
759        let fitted = classifier
760            .fit(&views, &y)
761            .expect("operation should succeed");
762
763        let test_view1 = Array2::from_shape_vec((2, 2), vec![-0.8, -0.8, 0.8, 0.8])
764            .expect("operation should succeed");
765        let test_view2 = Array2::from_shape_vec((2, 2), vec![0.8, 0.8, -0.8, -0.8])
766            .expect("operation should succeed");
767        let test_views = vec![test_view1, test_view2];
768
769        let predictions = fitted
770            .predict(&test_views)
771            .expect("operation should succeed");
772        assert_eq!(predictions.len(), 2);
773    }
774
775    #[test]
776    fn test_multi_view_regressor() {
777        let view1 = Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 2.0, 2.0])
778            .expect("operation should succeed");
779        let view2 = Array2::from_shape_vec((4, 2), vec![5.0, 5.0, 6.0, 5.0, 5.0, 6.0, 6.0, 6.0])
780            .expect("operation should succeed");
781        let views = vec![view1, view2];
782        let y = array![2.0, 3.0, 3.0, 4.0];
783
784        let configs = vec![
785            ViewConfig::new(Distance::Euclidean, 1.0),
786            ViewConfig::new(Distance::Euclidean, 1.0),
787        ];
788
789        let regressor = MultiViewKNeighborsRegressor::new(2, configs);
790        let fitted = regressor.fit(&views, &y).expect("operation should succeed");
791
792        let test_view1 =
793            Array2::from_shape_vec((1, 2), vec![1.5, 1.5]).expect("operation should succeed");
794        let test_view2 =
795            Array2::from_shape_vec((1, 2), vec![5.5, 5.5]).expect("operation should succeed");
796        let test_views = vec![test_view1, test_view2];
797
798        let predictions = fitted
799            .predict(&test_views)
800            .expect("operation should succeed");
801        assert_eq!(predictions.len(), 1);
802        assert!(predictions[0] > 2.0 && predictions[0] < 4.0);
803    }
804
805    #[test]
806    fn test_consensus_analysis() {
807        let view1 = Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 2.0, 2.0])
808            .expect("operation should succeed");
809        let view2 = Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 2.0, 1.0, 1.0, 2.0, 2.0, 2.0])
810            .expect("operation should succeed");
811        let views_train = vec![view1, view2];
812        let y = array![0, 0, 1, 1];
813
814        let configs = vec![
815            ViewConfig::new(Distance::Euclidean, 1.0),
816            ViewConfig::new(Distance::Euclidean, 1.0),
817        ];
818
819        let classifier = MultiViewKNeighborsClassifier::new(2, configs);
820        let fitted = classifier
821            .fit(&views_train, &y)
822            .expect("operation should succeed");
823
824        let consensus = fitted
825            .analyze_consensus(&views_train)
826            .expect("operation should succeed");
827        assert_eq!(consensus.sample_consensus_scores.len(), 4);
828        assert!(consensus.mean_consensus >= 0.0 && consensus.mean_consensus <= 1.0);
829    }
830
831    #[test]
832    fn test_fusion_strategies() {
833        let view1 = Array2::from_shape_vec(
834            (6, 2),
835            vec![
836                -1.0, -1.0, -0.5, -1.0, 0.0, 0.0, 1.0, 1.0, 0.5, 1.0, 1.5, 1.5,
837            ],
838        )
839        .expect("operation should succeed");
840        let view2 = Array2::from_shape_vec(
841            (6, 2),
842            vec![
843                1.0, 1.0, 1.5, 1.0, 0.0, 0.0, -1.0, -1.0, -0.5, -1.0, -1.5, -1.5,
844            ],
845        )
846        .expect("operation should succeed");
847        let views = vec![view1, view2];
848        let y = array![0, 0, 1, 1, 1, 1];
849
850        let strategies = vec![
851            FusionStrategy::WeightedAverage,
852            FusionStrategy::Product,
853            FusionStrategy::MajorityVoting,
854            FusionStrategy::Stacking,
855        ];
856
857        for strategy in strategies {
858            let configs = vec![
859                ViewConfig::new(Distance::Euclidean, 1.0),
860                ViewConfig::new(Distance::Euclidean, 1.0),
861            ];
862            let classifier =
863                MultiViewKNeighborsClassifier::new(3, configs).with_fusion_strategy(strategy);
864            let fitted = classifier
865                .fit(&views, &y)
866                .expect("operation should succeed");
867
868            let test_view1 =
869                Array2::from_shape_vec((1, 2), vec![-0.8, -0.8]).expect("operation should succeed");
870            let test_view2 =
871                Array2::from_shape_vec((1, 2), vec![0.8, 0.8]).expect("operation should succeed");
872            let test_views = vec![test_view1, test_view2];
873
874            let predictions = fitted
875                .predict(&test_views)
876                .expect("operation should succeed");
877            assert_eq!(predictions.len(), 1);
878        }
879    }
880
881    #[test]
882    fn test_view_config() {
883        let config = ViewConfig::new(Distance::Euclidean, 1.5).with_metric_learning();
884        assert_eq!(config.weight, 1.5);
885        assert!(config.learn_metric);
886    }
887
888    #[test]
889    fn test_error_handling() {
890        // Mismatched number of views
891        let view1 = Array2::from_shape_vec((4, 2), vec![1.0; 8]).expect("operation should succeed");
892        let views = vec![view1];
893        let y = array![0, 0, 1, 1];
894        let configs = vec![
895            ViewConfig::new(Distance::Euclidean, 1.0),
896            ViewConfig::new(Distance::Euclidean, 1.0),
897        ];
898
899        let classifier = MultiViewKNeighborsClassifier::new(2, configs);
900        assert!(classifier.fit(&views, &y).is_err());
901
902        // Empty views
903        let empty_views: Vec<Array2<Float>> = vec![];
904        let configs = vec![ViewConfig::new(Distance::Euclidean, 1.0)];
905        let classifier = MultiViewKNeighborsClassifier::new(2, configs);
906        assert!(classifier.fit(&empty_views, &y).is_err());
907    }
908}