Skip to main content

sklears_semi_supervised/
dynamic_graph_learning.rs

1//! Dynamic graph learning for streaming and evolving semi-supervised scenarios
2//!
3//! This module provides advanced dynamic graph learning algorithms that can handle
4//! continuously evolving graph structures, streaming data updates, and online
5//! semi-supervised learning scenarios.
6
7use scirs2_core::ndarray_ext::{s, Array1, Array2, ArrayView1, ArrayView2};
8use sklears_core::error::SklearsError;
9use std::collections::{HashMap, VecDeque};
10
11/// Dynamic graph learning for streaming and continuously evolving scenarios
12#[derive(Clone)]
13pub struct DynamicGraphLearning {
14    /// Learning rate for online updates
15    pub learning_rate: f64,
16    /// Forgetting factor for old connections
17    pub forgetting_factor: f64,
18    /// Number of neighbors for new node integration
19    pub k_neighbors: usize,
20    /// Buffer size for streaming updates
21    pub buffer_size: usize,
22    /// Threshold for edge creation/removal
23    pub edge_threshold: f64,
24    /// Maximum number of nodes to maintain
25    pub max_nodes: Option<usize>,
26    /// Random state for reproducibility
27    pub random_state: Option<u64>,
28    /// Current adjacency matrix
29    adjacency_matrix: Option<Array2<f64>>,
30    /// Node features buffer
31    node_features: Option<Array2<f64>>,
32    /// Update history buffer
33    update_buffer: VecDeque<GraphUpdate>,
34}
35
36/// Represents a graph update operation
37#[derive(Clone, Debug)]
38pub struct GraphUpdate {
39    /// Type of update: "add_node", "remove_node", "update_edge", "update_features"
40    pub update_type: String,
41    /// Node indices involved
42    pub node_indices: Vec<usize>,
43    /// New feature values (for feature updates)
44    pub features: Option<Array1<f64>>,
45    /// Edge weight (for edge updates)
46    pub edge_weight: Option<f64>,
47    /// Timestamp of update
48    pub timestamp: f64,
49}
50
51impl DynamicGraphLearning {
52    /// Create a new dynamic graph learning instance
53    pub fn new() -> Self {
54        Self {
55            learning_rate: 0.01,
56            forgetting_factor: 0.95,
57            k_neighbors: 5,
58            buffer_size: 1000,
59            edge_threshold: 0.1,
60            max_nodes: None,
61            random_state: None,
62            adjacency_matrix: None,
63            node_features: None,
64            update_buffer: VecDeque::new(),
65        }
66    }
67
68    /// Set the learning rate for online updates
69    pub fn learning_rate(mut self, lr: f64) -> Self {
70        self.learning_rate = lr;
71        self
72    }
73
74    /// Set the forgetting factor for old connections
75    pub fn forgetting_factor(mut self, factor: f64) -> Self {
76        self.forgetting_factor = factor;
77        self
78    }
79
80    /// Set the number of neighbors for new node integration
81    pub fn k_neighbors(mut self, k: usize) -> Self {
82        self.k_neighbors = k;
83        self
84    }
85
86    /// Set the buffer size for streaming updates
87    pub fn buffer_size(mut self, size: usize) -> Self {
88        self.buffer_size = size;
89        self
90    }
91
92    /// Set the edge threshold for creation/removal
93    pub fn edge_threshold(mut self, threshold: f64) -> Self {
94        self.edge_threshold = threshold;
95        self
96    }
97
98    /// Set the maximum number of nodes to maintain
99    pub fn max_nodes(mut self, max_nodes: usize) -> Self {
100        self.max_nodes = Some(max_nodes);
101        self
102    }
103
104    /// Set the random state for reproducibility
105    pub fn random_state(mut self, seed: u64) -> Self {
106        self.random_state = Some(seed);
107        self
108    }
109
110    /// Initialize the dynamic graph with initial data
111    pub fn initialize(&mut self, initial_features: ArrayView2<f64>) -> Result<(), SklearsError> {
112        let n_samples = initial_features.nrows();
113
114        if n_samples == 0 {
115            return Err(SklearsError::InvalidInput(
116                "No initial data provided".to_string(),
117            ));
118        }
119
120        // Initialize node features
121        self.node_features = Some(initial_features.to_owned());
122
123        // Initialize adjacency matrix with k-NN graph
124        let mut adjacency = Array2::zeros((n_samples, n_samples));
125
126        for i in 0..n_samples {
127            let mut distances: Vec<(usize, f64)> = Vec::new();
128
129            for j in 0..n_samples {
130                if i != j {
131                    let dist =
132                        self.compute_distance(initial_features.row(i), initial_features.row(j));
133                    distances.push((j, dist));
134                }
135            }
136
137            // Sort by distance and connect to k nearest neighbors
138            distances.sort_by(|a, b| a.1.partial_cmp(&b.1).expect("operation should succeed"));
139            for &(neighbor, dist) in distances.iter().take(self.k_neighbors) {
140                let weight = (-dist).exp(); // Gaussian similarity
141                adjacency[[i, neighbor]] = weight;
142                adjacency[[neighbor, i]] = weight; // Symmetric
143            }
144        }
145
146        self.adjacency_matrix = Some(adjacency);
147        Ok(())
148    }
149
150    /// Add new nodes to the dynamic graph
151    pub fn add_nodes(&mut self, new_features: ArrayView2<f64>) -> Result<(), SklearsError> {
152        if self.node_features.is_none() || self.adjacency_matrix.is_none() {
153            return Err(SklearsError::InvalidInput(
154                "Graph not initialized".to_string(),
155            ));
156        }
157
158        let new_n_nodes = new_features.nrows();
159
160        // Check max nodes constraint and prune if necessary
161        if let Some(max_nodes) = self.max_nodes {
162            let current_n_nodes = self
163                .node_features
164                .as_ref()
165                .expect("operation should succeed")
166                .nrows();
167            let total_nodes = current_n_nodes + new_n_nodes;
168            if total_nodes > max_nodes {
169                self.prune_old_nodes(max_nodes - new_n_nodes)?;
170            }
171        }
172
173        // Get references after potential pruning
174        let current_features = self
175            .node_features
176            .as_ref()
177            .expect("operation should succeed");
178        let current_adjacency = self
179            .adjacency_matrix
180            .as_ref()
181            .expect("operation should succeed");
182
183        let old_n_nodes = current_features.nrows();
184        let total_nodes = old_n_nodes + new_n_nodes;
185
186        // Extend feature matrix
187        let mut extended_features = Array2::zeros((total_nodes, current_features.ncols()));
188        extended_features
189            .slice_mut(s![..old_n_nodes, ..])
190            .assign(current_features);
191        extended_features
192            .slice_mut(s![old_n_nodes.., ..])
193            .assign(&new_features);
194
195        // Extend adjacency matrix
196        let mut extended_adjacency = Array2::zeros((total_nodes, total_nodes));
197        extended_adjacency
198            .slice_mut(s![..old_n_nodes, ..old_n_nodes])
199            .assign(current_adjacency);
200
201        // Connect new nodes to existing nodes
202        for i in old_n_nodes..total_nodes {
203            let mut distances: Vec<(usize, f64)> = Vec::new();
204
205            for j in 0..old_n_nodes {
206                let dist =
207                    self.compute_distance(extended_features.row(i), extended_features.row(j));
208                distances.push((j, dist));
209            }
210
211            // Connect to k nearest existing neighbors
212            distances.sort_by(|a, b| a.1.partial_cmp(&b.1).expect("operation should succeed"));
213            for &(neighbor, dist) in distances.iter().take(self.k_neighbors) {
214                let weight = (-dist).exp();
215                extended_adjacency[[i, neighbor]] = weight;
216                extended_adjacency[[neighbor, i]] = weight;
217            }
218
219            // Connect new nodes to each other
220            for j in (old_n_nodes..total_nodes).filter(|&j| j != i) {
221                let dist =
222                    self.compute_distance(extended_features.row(i), extended_features.row(j));
223                let weight = (-dist).exp();
224                if weight > self.edge_threshold {
225                    extended_adjacency[[i, j]] = weight;
226                    extended_adjacency[[j, i]] = weight;
227                }
228            }
229        }
230
231        self.node_features = Some(extended_features);
232        self.adjacency_matrix = Some(extended_adjacency);
233
234        // Record updates
235        for i in old_n_nodes..total_nodes {
236            self.record_update(GraphUpdate {
237                update_type: "add_node".to_string(),
238                node_indices: vec![i],
239                features: Some(new_features.row(i - old_n_nodes).to_owned()),
240                edge_weight: None,
241                timestamp: self.get_current_time(),
242            });
243        }
244
245        Ok(())
246    }
247
248    /// Update node features dynamically
249    pub fn update_node_features(
250        &mut self,
251        node_idx: usize,
252        new_features: ArrayView1<f64>,
253    ) -> Result<(), SklearsError> {
254        if self.node_features.is_none() {
255            return Err(SklearsError::InvalidInput(
256                "Graph not initialized".to_string(),
257            ));
258        }
259
260        let features = self
261            .node_features
262            .as_mut()
263            .expect("operation should succeed");
264
265        if node_idx >= features.nrows() {
266            return Err(SklearsError::InvalidInput(
267                "Node index out of bounds".to_string(),
268            ));
269        }
270
271        // Apply online learning update
272        let mut current_features = features.row_mut(node_idx);
273        for (i, &new_val) in new_features.iter().enumerate() {
274            current_features[i] =
275                (1.0 - self.learning_rate) * current_features[i] + self.learning_rate * new_val;
276        }
277
278        // Update edges based on new features
279        self.update_edges_for_node(node_idx)?;
280
281        // Record update
282        self.record_update(GraphUpdate {
283            update_type: "update_features".to_string(),
284            node_indices: vec![node_idx],
285            features: Some(new_features.to_owned()),
286            edge_weight: None,
287            timestamp: self.get_current_time(),
288        });
289
290        Ok(())
291    }
292
293    /// Update edges for a specific node after feature change
294    fn update_edges_for_node(&mut self, node_idx: usize) -> Result<(), SklearsError> {
295        if self.node_features.is_none() || self.adjacency_matrix.is_none() {
296            return Ok(());
297        }
298
299        // Create a copy of features to avoid borrowing conflicts
300        let features = self
301            .node_features
302            .as_ref()
303            .expect("operation should succeed")
304            .clone();
305        let n_nodes = features.nrows();
306        let forgetting_factor = self.forgetting_factor;
307        let edge_threshold = self.edge_threshold;
308
309        // Get mutable reference to adjacency matrix
310        let adjacency = self
311            .adjacency_matrix
312            .as_mut()
313            .expect("operation should succeed");
314
315        // Recompute edges for this node
316        for other_idx in 0..n_nodes {
317            if node_idx != other_idx {
318                let dist =
319                    Self::compute_distance_static(features.row(node_idx), features.row(other_idx));
320                let new_weight = (-dist).exp();
321
322                // Apply forgetting factor to existing edge and add new weight
323                let current_weight = adjacency[[node_idx, other_idx]];
324                let updated_weight =
325                    forgetting_factor * current_weight + (1.0 - forgetting_factor) * new_weight;
326
327                // Apply threshold for edge maintenance
328                let final_weight = if updated_weight > edge_threshold {
329                    updated_weight
330                } else {
331                    0.0
332                };
333
334                adjacency[[node_idx, other_idx]] = final_weight;
335                adjacency[[other_idx, node_idx]] = final_weight; // Symmetric
336            }
337        }
338
339        Ok(())
340    }
341
342    /// Prune old nodes to maintain memory constraints
343    fn prune_old_nodes(&mut self, target_nodes: usize) -> Result<(), SklearsError> {
344        if self.node_features.is_none() || self.adjacency_matrix.is_none() {
345            return Ok(());
346        }
347
348        let current_nodes = self
349            .node_features
350            .as_ref()
351            .expect("operation should succeed")
352            .nrows();
353        if current_nodes <= target_nodes {
354            return Ok(());
355        }
356
357        let nodes_to_remove = current_nodes - target_nodes;
358
359        // Simple strategy: remove oldest nodes (first nodes_to_remove nodes)
360        // In practice, you might want more sophisticated strategies based on
361        // node importance, connectivity, or recency of updates
362
363        let features = self
364            .node_features
365            .as_ref()
366            .expect("operation should succeed");
367        let adjacency = self
368            .adjacency_matrix
369            .as_ref()
370            .expect("operation should succeed");
371
372        // Create new matrices without the pruned nodes
373        let new_features = features.slice(s![nodes_to_remove.., ..]).to_owned();
374        let new_adjacency = adjacency
375            .slice(s![nodes_to_remove.., nodes_to_remove..])
376            .to_owned();
377
378        self.node_features = Some(new_features);
379        self.adjacency_matrix = Some(new_adjacency);
380
381        Ok(())
382    }
383
384    /// Get the current adjacency matrix
385    pub fn get_adjacency_matrix(&self) -> Option<&Array2<f64>> {
386        self.adjacency_matrix.as_ref()
387    }
388
389    /// Get the current node features
390    pub fn get_node_features(&self) -> Option<&Array2<f64>> {
391        self.node_features.as_ref()
392    }
393
394    /// Get recent updates from the buffer
395    pub fn get_recent_updates(&self, n_updates: usize) -> Vec<&GraphUpdate> {
396        self.update_buffer.iter().rev().take(n_updates).collect()
397    }
398
399    /// Compute distance between two feature vectors
400    fn compute_distance(&self, feat1: ArrayView1<f64>, feat2: ArrayView1<f64>) -> f64 {
401        Self::compute_distance_static(feat1, feat2)
402    }
403
404    /// Static version of compute_distance to avoid borrowing conflicts
405    fn compute_distance_static(feat1: ArrayView1<f64>, feat2: ArrayView1<f64>) -> f64 {
406        feat1
407            .iter()
408            .zip(feat2.iter())
409            .map(|(&a, &b)| (a - b).powi(2))
410            .sum::<f64>()
411            .sqrt()
412    }
413
414    /// Record a graph update in the buffer
415    fn record_update(&mut self, update: GraphUpdate) {
416        self.update_buffer.push_back(update);
417
418        // Maintain buffer size
419        while self.update_buffer.len() > self.buffer_size {
420            self.update_buffer.pop_front();
421        }
422    }
423
424    /// Get current timestamp (simplified)
425    fn get_current_time(&self) -> f64 {
426        std::time::SystemTime::now()
427            .duration_since(std::time::UNIX_EPOCH)
428            .unwrap_or_default()
429            .as_secs_f64()
430    }
431
432    /// Apply decay to all edges to simulate forgetting
433    pub fn apply_temporal_decay(&mut self) -> Result<(), SklearsError> {
434        if let Some(adjacency) = self.adjacency_matrix.as_mut() {
435            *adjacency *= self.forgetting_factor;
436
437            // Remove edges below threshold
438            adjacency.mapv_inplace(|x| if x < self.edge_threshold { 0.0 } else { x });
439        }
440        Ok(())
441    }
442
443    /// Get graph statistics
444    pub fn get_statistics(&self) -> HashMap<String, f64> {
445        let mut stats = HashMap::new();
446
447        if let Some(adjacency) = &self.adjacency_matrix {
448            let n_nodes = adjacency.nrows() as f64;
449            let total_edges = adjacency.iter().filter(|&&x| x > 0.0).count() as f64 / 2.0; // Undirected
450            let density = if n_nodes > 1.0 {
451                total_edges / (n_nodes * (n_nodes - 1.0) / 2.0)
452            } else {
453                0.0
454            };
455
456            stats.insert("n_nodes".to_string(), n_nodes);
457            stats.insert("n_edges".to_string(), total_edges);
458            stats.insert("density".to_string(), density);
459            stats.insert("avg_degree".to_string(), total_edges * 2.0 / n_nodes);
460        }
461
462        stats.insert("buffer_size".to_string(), self.update_buffer.len() as f64);
463        stats
464    }
465}
466
467impl Default for DynamicGraphLearning {
468    fn default() -> Self {
469        Self::new()
470    }
471}
472
473#[allow(non_snake_case)]
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use scirs2_core::array;
478
479    #[test]
480    fn test_dynamic_graph_initialization() {
481        let mut dgl = DynamicGraphLearning::new().k_neighbors(2);
482
483        let initial_data = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
484
485        let result = dgl.initialize(initial_data.view());
486        assert!(result.is_ok());
487
488        let adjacency = dgl
489            .get_adjacency_matrix()
490            .expect("operation should succeed");
491        assert_eq!(adjacency.dim(), (3, 3));
492
493        // Check that diagonal is zero
494        for i in 0..3 {
495            assert_eq!(adjacency[[i, i]], 0.0);
496        }
497    }
498
499    #[test]
500    fn test_add_nodes() {
501        let mut dgl = DynamicGraphLearning::new().k_neighbors(2);
502
503        let initial_data = array![[1.0, 2.0], [2.0, 3.0]];
504
505        dgl.initialize(initial_data.view())
506            .expect("operation should succeed");
507
508        let new_data = array![[3.0, 4.0], [4.0, 5.0]];
509
510        let result = dgl.add_nodes(new_data.view());
511        assert!(result.is_ok());
512
513        let adjacency = dgl
514            .get_adjacency_matrix()
515            .expect("operation should succeed");
516        assert_eq!(adjacency.dim(), (4, 4));
517
518        let features = dgl.get_node_features().expect("operation should succeed");
519        assert_eq!(features.dim(), (4, 2));
520    }
521
522    #[test]
523    fn test_update_node_features() {
524        let mut dgl = DynamicGraphLearning::new()
525            .k_neighbors(2)
526            .learning_rate(0.5);
527
528        let initial_data = array![[1.0, 2.0], [2.0, 3.0]];
529
530        dgl.initialize(initial_data.view())
531            .expect("operation should succeed");
532
533        let new_features = array![5.0, 6.0];
534        let result = dgl.update_node_features(0, new_features.view());
535        assert!(result.is_ok());
536
537        let features = dgl.get_node_features().expect("operation should succeed");
538        // Features should be updated with learning rate
539        assert!(features[[0, 0]] > 1.0);
540        assert!(features[[0, 1]] > 2.0);
541    }
542
543    #[test]
544    fn test_temporal_decay() {
545        let mut dgl = DynamicGraphLearning::new()
546            .k_neighbors(2)
547            .forgetting_factor(0.5)
548            .edge_threshold(0.1);
549
550        let initial_data = array![[1.0, 2.0], [2.0, 3.0]];
551
552        dgl.initialize(initial_data.view())
553            .expect("operation should succeed");
554
555        let original_adjacency = dgl
556            .get_adjacency_matrix()
557            .expect("operation should succeed")
558            .clone();
559
560        dgl.apply_temporal_decay()
561            .expect("operation should succeed");
562
563        let decayed_adjacency = dgl
564            .get_adjacency_matrix()
565            .expect("operation should succeed");
566
567        // Check that edges have been decayed
568        for i in 0..2 {
569            for j in 0..2 {
570                if i != j && original_adjacency[[i, j]] > 0.0 {
571                    assert!(decayed_adjacency[[i, j]] < original_adjacency[[i, j]]);
572                }
573            }
574        }
575    }
576
577    #[test]
578    fn test_max_nodes_constraint() {
579        let mut dgl = DynamicGraphLearning::new().k_neighbors(2).max_nodes(3);
580
581        let initial_data = array![[1.0, 2.0], [2.0, 3.0]];
582
583        dgl.initialize(initial_data.view())
584            .expect("operation should succeed");
585
586        let new_data = array![[3.0, 4.0], [4.0, 5.0], [5.0, 6.0]];
587
588        let result = dgl.add_nodes(new_data.view());
589        assert!(result.is_ok());
590
591        let adjacency = dgl
592            .get_adjacency_matrix()
593            .expect("operation should succeed");
594        assert_eq!(adjacency.nrows(), 3); // Should be pruned to max_nodes
595    }
596
597    #[test]
598    fn test_graph_statistics() {
599        let mut dgl = DynamicGraphLearning::new().k_neighbors(2);
600
601        let initial_data = array![[1.0, 2.0], [2.0, 3.0], [3.0, 4.0]];
602
603        dgl.initialize(initial_data.view())
604            .expect("operation should succeed");
605
606        let stats = dgl.get_statistics();
607
608        assert!(stats.contains_key("n_nodes"));
609        assert!(stats.contains_key("n_edges"));
610        assert!(stats.contains_key("density"));
611        assert!(stats.contains_key("avg_degree"));
612
613        assert_eq!(stats["n_nodes"], 3.0);
614        assert!(stats["n_edges"] > 0.0);
615    }
616
617    #[test]
618    fn test_update_buffer() {
619        let mut dgl = DynamicGraphLearning::new().buffer_size(2);
620
621        let initial_data = array![[1.0, 2.0], [2.0, 3.0]];
622
623        dgl.initialize(initial_data.view())
624            .expect("operation should succeed");
625
626        let new_features = array![5.0, 6.0];
627        dgl.update_node_features(0, new_features.view())
628            .expect("operation should succeed");
629        dgl.update_node_features(1, new_features.view())
630            .expect("operation should succeed");
631        dgl.update_node_features(0, new_features.view())
632            .expect("operation should succeed");
633
634        let recent_updates = dgl.get_recent_updates(5);
635        assert!(recent_updates.len() <= 2); // Buffer size constraint
636    }
637
638    #[test]
639    fn test_error_cases() {
640        let mut dgl = DynamicGraphLearning::new();
641
642        // Test operations before initialization
643        let new_data = array![[1.0, 2.0]];
644        assert!(dgl.add_nodes(new_data.view()).is_err());
645
646        let new_features = array![5.0, 6.0];
647        assert!(dgl.update_node_features(0, new_features.view()).is_err());
648
649        // Test initialization with empty data
650        let empty_data = Array2::<f64>::zeros((0, 2));
651        assert!(dgl.initialize(empty_data.view()).is_err());
652
653        // Test feature update with invalid index
654        let initial_data = array![[1.0, 2.0]];
655        dgl.initialize(initial_data.view())
656            .expect("operation should succeed");
657        assert!(dgl.update_node_features(10, new_features.view()).is_err());
658    }
659}