Skip to main content

sklears_semi_supervised/
multi_view_graph.rs

1//! Multi-view graph learning methods for semi-supervised learning
2//!
3//! This module provides advanced graph learning algorithms that can handle
4//! multiple views or modalities of data, enabling more robust semi-supervised
5//! learning on complex, multi-modal datasets.
6
7use scirs2_core::ndarray_ext::{Array2, ArrayView1, ArrayView2};
8use scirs2_core::random::Random;
9use sklears_core::error::SklearsError;
10use std::collections::HashMap;
11
12/// Multi-view graph learning that constructs graphs from multiple data views
13#[derive(Clone)]
14pub struct MultiViewGraphLearning {
15    /// Number of neighbors for k-NN graph construction
16    pub k_neighbors: usize,
17    /// Weights for combining different views
18    pub view_weights: Vec<f64>,
19    /// Method for combining views: "weighted", "union", "intersection", "adaptive"
20    pub combination_method: String,
21    /// Regularization parameter for graph structure learning
22    pub regularization: f64,
23    /// Maximum iterations for optimization
24    pub max_iter: usize,
25    /// Convergence tolerance
26    pub tolerance: f64,
27    /// Random state for reproducibility
28    pub random_state: Option<u64>,
29}
30
31impl MultiViewGraphLearning {
32    /// Create a new multi-view graph learning instance
33    pub fn new() -> Self {
34        Self {
35            k_neighbors: 5,
36            view_weights: vec![],
37            combination_method: "weighted".to_string(),
38            regularization: 0.1,
39            max_iter: 100,
40            tolerance: 1e-6,
41            random_state: None,
42        }
43    }
44
45    /// Set the number of neighbors for k-NN graph construction
46    pub fn k_neighbors(mut self, k: usize) -> Self {
47        self.k_neighbors = k;
48        self
49    }
50
51    /// Set the weights for combining different views
52    pub fn view_weights(mut self, weights: Vec<f64>) -> Self {
53        self.view_weights = weights;
54        self
55    }
56
57    /// Set the method for combining views
58    pub fn combination_method(mut self, method: String) -> Self {
59        self.combination_method = method;
60        self
61    }
62
63    /// Set the regularization parameter
64    pub fn regularization(mut self, reg: f64) -> Self {
65        self.regularization = reg;
66        self
67    }
68
69    /// Set the maximum number of iterations
70    pub fn max_iter(mut self, max_iter: usize) -> Self {
71        self.max_iter = max_iter;
72        self
73    }
74
75    /// Set the convergence tolerance
76    pub fn tolerance(mut self, tol: f64) -> Self {
77        self.tolerance = tol;
78        self
79    }
80
81    /// Set the random state for reproducibility
82    pub fn random_state(mut self, seed: u64) -> Self {
83        self.random_state = Some(seed);
84        self
85    }
86
87    /// Learn a unified graph from multiple views of data
88    pub fn fit(&self, views: &[ArrayView2<f64>]) -> Result<Array2<f64>, SklearsError> {
89        if views.is_empty() {
90            return Err(SklearsError::InvalidInput("No views provided".to_string()));
91        }
92
93        let n_samples = views[0].nrows();
94
95        // Validate that all views have the same number of samples
96        for view in views.iter() {
97            if view.nrows() != n_samples {
98                return Err(SklearsError::ShapeMismatch {
99                    expected: format!("All views should have {} samples", n_samples),
100                    actual: format!("View has {} samples", view.nrows()),
101                });
102            }
103        }
104
105        // Construct graphs for each view
106        let view_graphs = self.construct_view_graphs(views)?;
107
108        // Combine graphs according to the specified method
109        let combined_graph = self.combine_graphs(&view_graphs)?;
110
111        Ok(combined_graph)
112    }
113
114    /// Construct k-NN graphs for each view
115    fn construct_view_graphs(
116        &self,
117        views: &[ArrayView2<f64>],
118    ) -> Result<Vec<Array2<f64>>, SklearsError> {
119        let mut graphs = Vec::new();
120
121        for view in views.iter() {
122            let graph = self.construct_knn_graph(view)?;
123            graphs.push(graph);
124        }
125
126        Ok(graphs)
127    }
128
129    /// Construct a k-NN graph from a single view
130    #[allow(non_snake_case)] // standard ML notation
131    fn construct_knn_graph(&self, X: &ArrayView2<f64>) -> Result<Array2<f64>, SklearsError> {
132        let n_samples = X.nrows();
133        let mut graph = Array2::<f64>::zeros((n_samples, n_samples));
134
135        for i in 0..n_samples {
136            let mut distances: Vec<(f64, usize)> = Vec::new();
137
138            for j in 0..n_samples {
139                if i != j {
140                    let dist = self.euclidean_distance(&X.row(i), &X.row(j));
141                    distances.push((dist, j));
142                }
143            }
144
145            // Sort by distance and take k nearest neighbors
146            distances.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("operation should succeed"));
147
148            for (dist, j) in distances.iter().take(self.k_neighbors.min(distances.len())) {
149                let weight = (-dist.powi(2) / 2.0).exp(); // RBF kernel
150                graph[[i, *j]] = weight;
151            }
152        }
153
154        // Make graph symmetric
155        for i in 0..n_samples {
156            for j in i + 1..n_samples {
157                let avg_weight = (graph[[i, j]] + graph[[j, i]]) / 2.0;
158                graph[[i, j]] = avg_weight;
159                graph[[j, i]] = avg_weight;
160            }
161        }
162
163        Ok(graph)
164    }
165
166    /// Combine multiple view graphs into a unified graph
167    fn combine_graphs(&self, graphs: &[Array2<f64>]) -> Result<Array2<f64>, SklearsError> {
168        if graphs.is_empty() {
169            return Err(SklearsError::InvalidInput(
170                "No graphs to combine".to_string(),
171            ));
172        }
173
174        let n_samples = graphs[0].nrows();
175        let mut combined = Array2::<f64>::zeros((n_samples, n_samples));
176
177        match self.combination_method.as_str() {
178            "weighted" => {
179                let weights = if self.view_weights.is_empty() {
180                    vec![1.0 / graphs.len() as f64; graphs.len()]
181                } else {
182                    self.view_weights.clone()
183                };
184
185                if weights.len() != graphs.len() {
186                    return Err(SklearsError::InvalidInput(
187                        "Number of weights must match number of views".to_string(),
188                    ));
189                }
190
191                for (i, graph) in graphs.iter().enumerate() {
192                    combined += &(graph * weights[i]);
193                }
194            }
195            "union" => {
196                for graph in graphs.iter() {
197                    for i in 0..n_samples {
198                        for j in 0..n_samples {
199                            combined[[i, j]] = combined[[i, j]].max(graph[[i, j]]);
200                        }
201                    }
202                }
203            }
204            "intersection" => {
205                combined = graphs[0].clone();
206                for graph in graphs.iter().skip(1) {
207                    for i in 0..n_samples {
208                        for j in 0..n_samples {
209                            combined[[i, j]] = combined[[i, j]].min(graph[[i, j]]);
210                        }
211                    }
212                }
213            }
214            "adaptive" => {
215                combined = self.adaptive_combination(graphs)?;
216            }
217            _ => {
218                return Err(SklearsError::InvalidInput(format!(
219                    "Unknown combination method: {}",
220                    self.combination_method
221                )));
222            }
223        }
224
225        Ok(combined)
226    }
227
228    /// Adaptive combination that learns optimal weights for views
229    fn adaptive_combination(&self, graphs: &[Array2<f64>]) -> Result<Array2<f64>, SklearsError> {
230        let n_views = graphs.len();
231        let n_samples = graphs[0].nrows();
232
233        // Initialize weights uniformly
234        let mut weights = vec![1.0 / n_views as f64; n_views];
235
236        for _iter in 0..self.max_iter {
237            let old_weights = weights.clone();
238
239            // Compute current combined graph
240            let mut combined = Array2::<f64>::zeros((n_samples, n_samples));
241            for (i, graph) in graphs.iter().enumerate() {
242                combined += &(graph * weights[i]);
243            }
244
245            // Update weights based on agreement with combined graph
246            for i in 0..n_views {
247                let agreement = self.compute_graph_agreement(&graphs[i], &combined);
248                weights[i] = agreement;
249            }
250
251            // Normalize weights
252            let weight_sum: f64 = weights.iter().sum();
253            if weight_sum > 0.0 {
254                for w in weights.iter_mut() {
255                    *w /= weight_sum;
256                }
257            }
258
259            // Check convergence
260            let weight_change: f64 = weights
261                .iter()
262                .zip(old_weights.iter())
263                .map(|(w1, w2)| (w1 - w2).abs())
264                .sum();
265
266            if weight_change < self.tolerance {
267                break;
268            }
269        }
270
271        // Compute final combined graph
272        let mut combined = Array2::<f64>::zeros((n_samples, n_samples));
273        for (i, graph) in graphs.iter().enumerate() {
274            combined += &(graph * weights[i]);
275        }
276
277        Ok(combined)
278    }
279
280    /// Compute agreement between two graphs
281    fn compute_graph_agreement(&self, graph1: &Array2<f64>, graph2: &Array2<f64>) -> f64 {
282        let mut agreement = 0.0;
283        let mut total = 0.0;
284
285        for i in 0..graph1.nrows() {
286            for j in 0..graph1.ncols() {
287                let diff = (graph1[[i, j]] - graph2[[i, j]]).abs();
288                agreement += 1.0 / (1.0 + diff);
289                total += 1.0;
290            }
291        }
292
293        if total > 0.0 {
294            agreement / total
295        } else {
296            0.0
297        }
298    }
299
300    /// Compute Euclidean distance between two vectors
301    fn euclidean_distance(&self, x1: &ArrayView1<f64>, x2: &ArrayView1<f64>) -> f64 {
302        x1.iter()
303            .zip(x2.iter())
304            .map(|(a, b)| (a - b).powi(2))
305            .sum::<f64>()
306            .sqrt()
307    }
308}
309
310impl Default for MultiViewGraphLearning {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316/// Heterogeneous graph learning for mixed data types
317#[derive(Clone)]
318pub struct HeterogeneousGraphLearning {
319    /// Node types in the heterogeneous graph
320    pub node_types: Vec<String>,
321    /// Edge types connecting different node types
322    pub edge_types: Vec<(String, String)>,
323    /// Weights for different edge types
324    pub edge_weights: HashMap<(String, String), f64>,
325    /// Embedding dimensions for each node type
326    pub embedding_dims: HashMap<String, usize>,
327    /// Number of neighbors for each edge type
328    pub k_neighbors: HashMap<(String, String), usize>,
329    /// Random state for reproducibility
330    pub random_state: Option<u64>,
331}
332
333impl HeterogeneousGraphLearning {
334    /// Create a new heterogeneous graph learning instance
335    pub fn new() -> Self {
336        Self {
337            node_types: vec![],
338            edge_types: vec![],
339            edge_weights: HashMap::new(),
340            embedding_dims: HashMap::new(),
341            k_neighbors: HashMap::new(),
342            random_state: None,
343        }
344    }
345
346    /// Set node types
347    pub fn node_types(mut self, types: Vec<String>) -> Self {
348        self.node_types = types;
349        self
350    }
351
352    /// Set edge types
353    pub fn edge_types(mut self, types: Vec<(String, String)>) -> Self {
354        self.edge_types = types;
355        self
356    }
357
358    /// Set weights for edge types
359    pub fn edge_weights(mut self, weights: HashMap<(String, String), f64>) -> Self {
360        self.edge_weights = weights;
361        self
362    }
363
364    /// Set embedding dimensions for node types
365    pub fn embedding_dims(mut self, dims: HashMap<String, usize>) -> Self {
366        self.embedding_dims = dims;
367        self
368    }
369
370    /// Set random state
371    pub fn random_state(mut self, seed: u64) -> Self {
372        self.random_state = Some(seed);
373        self
374    }
375
376    /// Learn embeddings for heterogeneous graph
377    pub fn fit(
378        &self,
379        data: &HashMap<String, ArrayView2<f64>>,
380    ) -> Result<HashMap<String, Array2<f64>>, SklearsError> {
381        if data.is_empty() {
382            return Err(SklearsError::InvalidInput("No data provided".to_string()));
383        }
384
385        let mut embeddings = HashMap::new();
386        let mut rng = if let Some(_seed) = self.random_state {
387            Random::seed(42)
388        } else {
389            Random::seed(42) // Use a default seed instead of from_entropy
390        };
391
392        // Initialize embeddings for each node type
393        for (node_type, node_data) in data.iter() {
394            let embed_dim = self.embedding_dims.get(node_type).unwrap_or(&64);
395            let n_nodes = node_data.nrows();
396
397            // Initialize random embeddings
398            let mut embedding = Array2::<f64>::zeros((n_nodes, *embed_dim));
399            for i in 0..n_nodes {
400                for j in 0..*embed_dim {
401                    embedding[[i, j]] = rng.random_range(-1.0..1.0);
402                }
403            }
404
405            embeddings.insert(node_type.clone(), embedding);
406        }
407
408        // Simple implementation: use input features as embeddings
409        // In practice, this would involve more sophisticated learning
410        for (node_type, node_data) in data.iter() {
411            let features = node_data.to_owned();
412            embeddings.insert(node_type.clone(), features);
413        }
414
415        Ok(embeddings)
416    }
417}
418
419impl Default for HeterogeneousGraphLearning {
420    fn default() -> Self {
421        Self::new()
422    }
423}
424
425/// Temporal graph learning for time-evolving graphs
426#[derive(Clone)]
427pub struct TemporalGraphLearning {
428    /// Window size for temporal analysis
429    pub window_size: usize,
430    /// Decay factor for temporal weighting
431    pub temporal_decay: f64,
432    /// Method for temporal aggregation: "mean", "weighted", "attention"
433    pub aggregation_method: String,
434    /// Number of neighbors for graph construction
435    pub k_neighbors: usize,
436    /// Random state for reproducibility
437    pub random_state: Option<u64>,
438}
439
440impl TemporalGraphLearning {
441    /// Create a new temporal graph learning instance
442    pub fn new() -> Self {
443        Self {
444            window_size: 5,
445            temporal_decay: 0.9,
446            aggregation_method: "weighted".to_string(),
447            k_neighbors: 5,
448            random_state: None,
449        }
450    }
451
452    /// Set window size
453    pub fn window_size(mut self, size: usize) -> Self {
454        self.window_size = size;
455        self
456    }
457
458    /// Set temporal decay factor
459    pub fn temporal_decay(mut self, decay: f64) -> Self {
460        self.temporal_decay = decay;
461        self
462    }
463
464    /// Set aggregation method
465    pub fn aggregation_method(mut self, method: String) -> Self {
466        self.aggregation_method = method;
467        self
468    }
469
470    /// Set number of neighbors
471    pub fn k_neighbors(mut self, k: usize) -> Self {
472        self.k_neighbors = k;
473        self
474    }
475
476    /// Set random state
477    pub fn random_state(mut self, seed: u64) -> Self {
478        self.random_state = Some(seed);
479        self
480    }
481
482    /// Learn from temporal graph snapshots
483    pub fn fit(&self, snapshots: &[ArrayView2<f64>]) -> Result<Array2<f64>, SklearsError> {
484        if snapshots.is_empty() {
485            return Err(SklearsError::InvalidInput(
486                "No snapshots provided".to_string(),
487            ));
488        }
489
490        let n_samples = snapshots[0].nrows();
491
492        // Validate that all snapshots have the same dimensions
493        for snapshot in snapshots.iter() {
494            if snapshot.nrows() != n_samples {
495                return Err(SklearsError::ShapeMismatch {
496                    expected: format!("All snapshots should have {} samples", n_samples),
497                    actual: format!("Snapshot has {} samples", snapshot.nrows()),
498                });
499            }
500        }
501
502        // Construct graphs for each snapshot
503        let graphs = self.construct_temporal_graphs(snapshots)?;
504
505        // Aggregate temporal graphs
506        let aggregated_graph = self.aggregate_temporal_graphs(&graphs)?;
507
508        Ok(aggregated_graph)
509    }
510
511    /// Construct graphs for temporal snapshots
512    fn construct_temporal_graphs(
513        &self,
514        snapshots: &[ArrayView2<f64>],
515    ) -> Result<Vec<Array2<f64>>, SklearsError> {
516        let mut graphs = Vec::new();
517
518        for snapshot in snapshots.iter() {
519            let graph = self.construct_knn_graph(snapshot)?;
520            graphs.push(graph);
521        }
522
523        Ok(graphs)
524    }
525
526    /// Construct k-NN graph from snapshot data
527    #[allow(non_snake_case)] // standard ML notation
528    fn construct_knn_graph(&self, X: &ArrayView2<f64>) -> Result<Array2<f64>, SklearsError> {
529        let n_samples = X.nrows();
530        let mut graph = Array2::<f64>::zeros((n_samples, n_samples));
531
532        for i in 0..n_samples {
533            let mut distances: Vec<(f64, usize)> = Vec::new();
534
535            for j in 0..n_samples {
536                if i != j {
537                    let dist = self.euclidean_distance(&X.row(i), &X.row(j));
538                    distances.push((dist, j));
539                }
540            }
541
542            // Sort by distance and take k nearest neighbors
543            distances.sort_by(|a, b| a.0.partial_cmp(&b.0).expect("operation should succeed"));
544
545            for (dist, j) in distances.iter().take(self.k_neighbors.min(distances.len())) {
546                let weight = (-dist.powi(2) / 2.0).exp(); // RBF kernel
547                graph[[i, *j]] = weight;
548            }
549        }
550
551        // Make graph symmetric
552        for i in 0..n_samples {
553            for j in i + 1..n_samples {
554                let avg_weight = (graph[[i, j]] + graph[[j, i]]) / 2.0;
555                graph[[i, j]] = avg_weight;
556                graph[[j, i]] = avg_weight;
557            }
558        }
559
560        Ok(graph)
561    }
562
563    /// Aggregate temporal graphs based on the aggregation method
564    fn aggregate_temporal_graphs(
565        &self,
566        graphs: &[Array2<f64>],
567    ) -> Result<Array2<f64>, SklearsError> {
568        if graphs.is_empty() {
569            return Err(SklearsError::InvalidInput(
570                "No graphs to aggregate".to_string(),
571            ));
572        }
573
574        let n_samples = graphs[0].nrows();
575        let mut aggregated = Array2::<f64>::zeros((n_samples, n_samples));
576
577        match self.aggregation_method.as_str() {
578            "mean" => {
579                for graph in graphs.iter() {
580                    aggregated += graph;
581                }
582                aggregated /= graphs.len() as f64;
583            }
584            "weighted" => {
585                let total_weight: f64 = (0..graphs.len())
586                    .map(|i| self.temporal_decay.powi(i as i32))
587                    .sum();
588
589                for (i, graph) in graphs.iter().enumerate() {
590                    let weight = self.temporal_decay.powi(i as i32) / total_weight;
591                    aggregated += &(graph * weight);
592                }
593            }
594            "attention" => {
595                // Simple attention mechanism - in practice this would be more sophisticated
596                let weights = self.compute_attention_weights(graphs)?;
597                for (i, graph) in graphs.iter().enumerate() {
598                    aggregated += &(graph * weights[i]);
599                }
600            }
601            _ => {
602                return Err(SklearsError::InvalidInput(format!(
603                    "Unknown aggregation method: {}",
604                    self.aggregation_method
605                )));
606            }
607        }
608
609        Ok(aggregated)
610    }
611
612    /// Compute attention weights for temporal graphs
613    fn compute_attention_weights(&self, graphs: &[Array2<f64>]) -> Result<Vec<f64>, SklearsError> {
614        let n_graphs = graphs.len();
615        let mut weights = vec![1.0 / n_graphs as f64; n_graphs];
616
617        // Simple implementation: weight by graph density
618        let mut densities = Vec::new();
619        for graph in graphs.iter() {
620            let density = graph.iter().filter(|&&x| x > 0.0).count() as f64 / (graph.len() as f64);
621            densities.push(density);
622        }
623
624        let total_density: f64 = densities.iter().sum();
625        if total_density > 0.0 {
626            for (i, density) in densities.iter().enumerate() {
627                weights[i] = density / total_density;
628            }
629        }
630
631        Ok(weights)
632    }
633
634    /// Compute Euclidean distance between two vectors
635    fn euclidean_distance(&self, x1: &ArrayView1<f64>, x2: &ArrayView1<f64>) -> f64 {
636        x1.iter()
637            .zip(x2.iter())
638            .map(|(a, b)| (a - b).powi(2))
639            .sum::<f64>()
640            .sqrt()
641    }
642}
643
644impl Default for TemporalGraphLearning {
645    fn default() -> Self {
646        Self::new()
647    }
648}
649
650#[allow(non_snake_case)]
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use approx::assert_abs_diff_eq;
655    use scirs2_core::array;
656
657    #[test]
658    fn test_multi_view_graph_learning() {
659        let view1 = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
660        let view2 = array![[2.0, 1.0], [3.0, 2.0], [4.0, 3.0]];
661        let views = vec![view1.view(), view2.view()];
662
663        let mvgl = MultiViewGraphLearning::new()
664            .k_neighbors(2)
665            .combination_method("weighted".to_string());
666
667        let result = mvgl.fit(&views);
668        assert!(result.is_ok());
669
670        let graph = result.expect("operation should succeed");
671        assert_eq!(graph.dim(), (3, 3));
672
673        // Check that diagonal is zero (no self-loops)
674        assert_eq!(graph[[0, 0]], 0.0);
675        assert_eq!(graph[[1, 1]], 0.0);
676        assert_eq!(graph[[2, 2]], 0.0);
677
678        // Check symmetry
679        assert_abs_diff_eq!(graph[[0, 1]], graph[[1, 0]], epsilon = 1e-10);
680        assert_abs_diff_eq!(graph[[0, 2]], graph[[2, 0]], epsilon = 1e-10);
681        assert_abs_diff_eq!(graph[[1, 2]], graph[[2, 1]], epsilon = 1e-10);
682    }
683
684    #[test]
685    fn test_multi_view_graph_union() {
686        let view1 = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
687        let view2 = array![[2.0, 1.0], [3.0, 2.0], [4.0, 3.0]];
688        let views = vec![view1.view(), view2.view()];
689
690        let mvgl = MultiViewGraphLearning::new()
691            .k_neighbors(2)
692            .combination_method("union".to_string());
693
694        let result = mvgl.fit(&views);
695        assert!(result.is_ok());
696
697        let graph = result.expect("operation should succeed");
698        assert_eq!(graph.dim(), (3, 3));
699    }
700
701    #[test]
702    fn test_multi_view_graph_adaptive() {
703        let view1 = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
704        let view2 = array![[2.0, 1.0], [3.0, 2.0], [4.0, 3.0]];
705        let views = vec![view1.view(), view2.view()];
706
707        let mvgl = MultiViewGraphLearning::new()
708            .k_neighbors(2)
709            .combination_method("adaptive".to_string())
710            .max_iter(10)
711            .tolerance(1e-4);
712
713        let result = mvgl.fit(&views);
714        assert!(result.is_ok());
715
716        let graph = result.expect("operation should succeed");
717        assert_eq!(graph.dim(), (3, 3));
718    }
719
720    #[test]
721    fn test_heterogeneous_graph_learning() {
722        let type1_data = array![[1.0, 2.0], [2.0, 3.0]];
723        let type2_data = array![[3.0, 4.0], [4.0, 5.0]];
724        let mut data = HashMap::new();
725        data.insert("type1".to_string(), type1_data.view());
726        data.insert("type2".to_string(), type2_data.view());
727
728        let hgl = HeterogeneousGraphLearning::new()
729            .node_types(vec!["type1".to_string(), "type2".to_string()]);
730
731        let result = hgl.fit(&data);
732        assert!(result.is_ok());
733
734        let embeddings = result.expect("operation should succeed");
735        assert!(embeddings.contains_key("type1"));
736        assert!(embeddings.contains_key("type2"));
737        assert_eq!(embeddings["type1"].dim(), (2, 2));
738        assert_eq!(embeddings["type2"].dim(), (2, 2));
739    }
740
741    #[test]
742    fn test_temporal_graph_learning() {
743        let snapshot1 = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
744        let snapshot2 = array![[1.1, 2.1], [2.1, 3.1], [3.1, 4.1]];
745        let snapshot3 = array![[1.2, 2.2], [2.2, 3.2], [3.2, 4.2]];
746        let snapshots = vec![snapshot1.view(), snapshot2.view(), snapshot3.view()];
747
748        let tgl = TemporalGraphLearning::new()
749            .window_size(3)
750            .temporal_decay(0.9)
751            .aggregation_method("weighted".to_string())
752            .k_neighbors(2);
753
754        let result = tgl.fit(&snapshots);
755        assert!(result.is_ok());
756
757        let graph = result.expect("operation should succeed");
758        assert_eq!(graph.dim(), (3, 3));
759
760        // Check that diagonal is zero (no self-loops)
761        assert_eq!(graph[[0, 0]], 0.0);
762        assert_eq!(graph[[1, 1]], 0.0);
763        assert_eq!(graph[[2, 2]], 0.0);
764    }
765
766    #[test]
767    fn test_temporal_graph_attention() {
768        let snapshot1 = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
769        let snapshot2 = array![[1.1, 2.1], [2.1, 3.1], [3.1, 4.1]];
770        let snapshots = vec![snapshot1.view(), snapshot2.view()];
771
772        let tgl = TemporalGraphLearning::new()
773            .aggregation_method("attention".to_string())
774            .k_neighbors(2);
775
776        let result = tgl.fit(&snapshots);
777        assert!(result.is_ok());
778
779        let graph = result.expect("operation should succeed");
780        assert_eq!(graph.dim(), (3, 3));
781    }
782
783    #[test]
784    fn test_multi_view_graph_error_cases() {
785        let mvgl = MultiViewGraphLearning::new();
786
787        // Test with empty views
788        let result = mvgl.fit(&[]);
789        assert!(result.is_err());
790
791        // Test with mismatched dimensions
792        let view1 = array![[1.0, 2.0], [2.0, 3.0]];
793        let view2 = array![[3.0, 4.0], [4.0, 5.0], [5.0, 6.0]];
794        let views = vec![view1.view(), view2.view()];
795
796        let result = mvgl.fit(&views);
797        assert!(result.is_err());
798    }
799
800    #[test]
801    fn test_temporal_graph_error_cases() {
802        let tgl = TemporalGraphLearning::new();
803
804        // Test with empty snapshots
805        let result = tgl.fit(&[]);
806        assert!(result.is_err());
807
808        // Test with mismatched dimensions
809        let snapshot1 = array![[1.0, 2.0], [2.0, 3.0]];
810        let snapshot2 = array![[3.0, 4.0], [4.0, 5.0], [5.0, 6.0]];
811        let snapshots = vec![snapshot1.view(), snapshot2.view()];
812
813        let result = tgl.fit(&snapshots);
814        assert!(result.is_err());
815    }
816}