Skip to main content

sklears_semi_supervised/
composable_graph.rs

1//! Composable graph construction methods
2//!
3//! This module provides a flexible, composable framework for constructing graphs
4//! used in semi-supervised learning. It allows combining different graph construction
5//! strategies and applying transformations in a pipeline.
6
7use scirs2_core::ndarray_ext::{Array1, Array2, ArrayView2};
8use sklears_core::error::{Result as SklResult, SklearsError};
9use sklears_core::types::Float;
10
11/// Trait for graph construction strategies
12pub trait GraphBuilder: Clone {
13    /// Build a graph from data
14    #[allow(non_snake_case)] // standard ML notation
15    fn build(&self, X: &ArrayView2<Float>) -> SklResult<Array2<f64>>;
16}
17
18/// Trait for graph transformations
19pub trait GraphTransform: Clone {
20    /// Transform a graph
21    fn transform(&self, graph: &Array2<f64>) -> SklResult<Array2<f64>>;
22}
23
24/// K-Nearest Neighbors graph builder
25#[derive(Debug, Clone)]
26pub struct KNNGraphBuilder {
27    n_neighbors: usize,
28    weighted: bool,
29    sigma: f64,
30}
31
32impl KNNGraphBuilder {
33    /// Create a new KNN graph builder
34    pub fn new(n_neighbors: usize) -> Self {
35        Self {
36            n_neighbors,
37            weighted: true,
38            sigma: 1.0,
39        }
40    }
41
42    /// Set whether to use weighted edges
43    pub fn weighted(mut self, weighted: bool) -> Self {
44        self.weighted = weighted;
45        self
46    }
47
48    /// Set the kernel bandwidth
49    pub fn sigma(mut self, sigma: f64) -> Self {
50        self.sigma = sigma;
51        self
52    }
53}
54
55impl GraphBuilder for KNNGraphBuilder {
56    #[allow(non_snake_case)] // standard ML notation
57    fn build(&self, X: &ArrayView2<Float>) -> SklResult<Array2<f64>> {
58        let n_samples = X.nrows();
59        let mut graph = Array2::<f64>::zeros((n_samples, n_samples));
60
61        for i in 0..n_samples {
62            let mut distances: Vec<(usize, f64)> = Vec::new();
63
64            for j in 0..n_samples {
65                if i != j {
66                    let diff = &X.row(i) - &X.row(j);
67                    let dist = diff.mapv(|x| x * x).sum().sqrt();
68                    distances.push((j, dist));
69                }
70            }
71
72            distances.sort_by(|a, b| a.1.partial_cmp(&b.1).expect("operation should succeed"));
73
74            for &(j, dist) in distances.iter().take(self.n_neighbors) {
75                if self.weighted {
76                    let weight = (-dist * dist / (2.0 * self.sigma * self.sigma)).exp();
77                    graph[[i, j]] = weight;
78                } else {
79                    graph[[i, j]] = 1.0;
80                }
81            }
82        }
83
84        Ok(graph)
85    }
86}
87
88/// Epsilon-ball graph builder
89#[derive(Debug, Clone)]
90pub struct EpsilonGraphBuilder {
91    epsilon: f64,
92    weighted: bool,
93    sigma: f64,
94}
95
96impl EpsilonGraphBuilder {
97    /// Create a new epsilon graph builder
98    pub fn new(epsilon: f64) -> Self {
99        Self {
100            epsilon,
101            weighted: true,
102            sigma: 1.0,
103        }
104    }
105
106    /// Set whether to use weighted edges
107    pub fn weighted(mut self, weighted: bool) -> Self {
108        self.weighted = weighted;
109        self
110    }
111
112    /// Set the kernel bandwidth
113    pub fn sigma(mut self, sigma: f64) -> Self {
114        self.sigma = sigma;
115        self
116    }
117}
118
119impl GraphBuilder for EpsilonGraphBuilder {
120    #[allow(non_snake_case)] // standard ML notation
121    fn build(&self, X: &ArrayView2<Float>) -> SklResult<Array2<f64>> {
122        let n_samples = X.nrows();
123        let mut graph = Array2::<f64>::zeros((n_samples, n_samples));
124
125        for i in 0..n_samples {
126            for j in 0..n_samples {
127                if i != j {
128                    let diff = &X.row(i) - &X.row(j);
129                    let dist = diff.mapv(|x| x * x).sum().sqrt();
130
131                    if dist < self.epsilon {
132                        if self.weighted {
133                            let weight = (-dist * dist / (2.0 * self.sigma * self.sigma)).exp();
134                            graph[[i, j]] = weight;
135                        } else {
136                            graph[[i, j]] = 1.0;
137                        }
138                    }
139                }
140            }
141        }
142
143        Ok(graph)
144    }
145}
146
147/// Symmetrize graph transformation
148#[derive(Debug, Clone)]
149pub struct SymmetrizeTransform {
150    method: String,
151}
152
153impl SymmetrizeTransform {
154    /// Create a new symmetrize transform
155    pub fn new(method: String) -> Self {
156        Self { method }
157    }
158}
159
160impl GraphTransform for SymmetrizeTransform {
161    fn transform(&self, graph: &Array2<f64>) -> SklResult<Array2<f64>> {
162        let n = graph.nrows();
163        let mut symmetric = graph.clone();
164
165        match self.method.as_str() {
166            "max" => {
167                for i in 0..n {
168                    for j in (i + 1)..n {
169                        let value = graph[[i, j]].max(graph[[j, i]]);
170                        symmetric[[i, j]] = value;
171                        symmetric[[j, i]] = value;
172                    }
173                }
174            }
175            "average" => {
176                for i in 0..n {
177                    for j in (i + 1)..n {
178                        let value = (graph[[i, j]] + graph[[j, i]]) / 2.0;
179                        symmetric[[i, j]] = value;
180                        symmetric[[j, i]] = value;
181                    }
182                }
183            }
184            _ => {
185                return Err(SklearsError::InvalidInput(format!(
186                    "Unknown symmetrization method: {}",
187                    self.method
188                )));
189            }
190        }
191
192        Ok(symmetric)
193    }
194}
195
196/// Normalize graph transformation
197#[derive(Debug, Clone)]
198pub struct NormalizeTransform {
199    method: String,
200}
201
202impl NormalizeTransform {
203    /// Create a new normalize transform
204    pub fn new(method: String) -> Self {
205        Self { method }
206    }
207}
208
209impl GraphTransform for NormalizeTransform {
210    fn transform(&self, graph: &Array2<f64>) -> SklResult<Array2<f64>> {
211        let n = graph.nrows();
212        let mut normalized = graph.clone();
213
214        match self.method.as_str() {
215            "row" => {
216                for i in 0..n {
217                    let row_sum: f64 = graph.row(i).sum();
218                    if row_sum > 0.0 {
219                        for j in 0..n {
220                            normalized[[i, j]] /= row_sum;
221                        }
222                    }
223                }
224            }
225            "symmetric" => {
226                // D^{-1/2} A D^{-1/2}
227                let mut degrees = Array1::<f64>::zeros(n);
228                for i in 0..n {
229                    degrees[i] = graph.row(i).sum();
230                }
231
232                for i in 0..n {
233                    for j in 0..n {
234                        if degrees[i] > 0.0 && degrees[j] > 0.0 {
235                            normalized[[i, j]] = graph[[i, j]] / (degrees[i] * degrees[j]).sqrt();
236                        }
237                    }
238                }
239            }
240            _ => {
241                return Err(SklearsError::InvalidInput(format!(
242                    "Unknown normalization method: {}",
243                    self.method
244                )));
245            }
246        }
247
248        Ok(normalized)
249    }
250}
251
252/// Sparsify graph transformation
253#[derive(Debug, Clone)]
254pub struct SparsifyTransform {
255    threshold: f64,
256}
257
258impl SparsifyTransform {
259    /// Create a new sparsify transform
260    pub fn new(threshold: f64) -> Self {
261        Self { threshold }
262    }
263}
264
265impl GraphTransform for SparsifyTransform {
266    fn transform(&self, graph: &Array2<f64>) -> SklResult<Array2<f64>> {
267        let mut sparse = graph.clone();
268        let n = graph.nrows();
269
270        for i in 0..n {
271            for j in 0..n {
272                if sparse[[i, j]] < self.threshold {
273                    sparse[[i, j]] = 0.0;
274                }
275            }
276        }
277
278        Ok(sparse)
279    }
280}
281
282/// Composable graph pipeline
283#[derive(Clone)]
284pub struct GraphPipeline {
285    builder: Box<dyn GraphBuilderTrait>,
286    transforms: Vec<Box<dyn GraphTransformTrait>>,
287}
288
289impl std::fmt::Debug for GraphPipeline {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        f.debug_struct("GraphPipeline")
292            .field("builder", &"Box<dyn GraphBuilderTrait>")
293            .field(
294                "transforms",
295                &format!("{} transforms", self.transforms.len()),
296            )
297            .finish()
298    }
299}
300
301// Helper traits with object safety
302trait GraphBuilderTrait {
303    #[allow(non_snake_case)] // standard ML notation
304    fn build_graph(&self, X: &ArrayView2<Float>) -> SklResult<Array2<f64>>;
305    fn clone_box(&self) -> Box<dyn GraphBuilderTrait>;
306}
307
308trait GraphTransformTrait {
309    fn transform_graph(&self, graph: &Array2<f64>) -> SklResult<Array2<f64>>;
310    fn clone_box(&self) -> Box<dyn GraphTransformTrait>;
311}
312
313impl<T: GraphBuilder + 'static> GraphBuilderTrait for T {
314    #[allow(non_snake_case)] // standard ML notation
315    fn build_graph(&self, X: &ArrayView2<Float>) -> SklResult<Array2<f64>> {
316        self.build(X)
317    }
318
319    fn clone_box(&self) -> Box<dyn GraphBuilderTrait> {
320        Box::new(self.clone())
321    }
322}
323
324impl<T: GraphTransform + 'static> GraphTransformTrait for T {
325    fn transform_graph(&self, graph: &Array2<f64>) -> SklResult<Array2<f64>> {
326        self.transform(graph)
327    }
328
329    fn clone_box(&self) -> Box<dyn GraphTransformTrait> {
330        Box::new(self.clone())
331    }
332}
333
334impl Clone for Box<dyn GraphBuilderTrait> {
335    fn clone(&self) -> Self {
336        self.clone_box()
337    }
338}
339
340impl Clone for Box<dyn GraphTransformTrait> {
341    fn clone(&self) -> Self {
342        self.clone_box()
343    }
344}
345
346impl GraphPipeline {
347    /// Create a new graph pipeline
348    pub fn new<B: GraphBuilder + 'static>(builder: B) -> Self {
349        Self {
350            builder: Box::new(builder),
351            transforms: Vec::new(),
352        }
353    }
354
355    /// Add a transformation to the pipeline
356    pub fn add_transform<T: GraphTransform + 'static>(mut self, transform: T) -> Self {
357        self.transforms.push(Box::new(transform));
358        self
359    }
360
361    /// Build the graph with all transformations
362    #[allow(non_snake_case)] // standard ML notation
363    pub fn build(&self, X: &ArrayView2<Float>) -> SklResult<Array2<f64>> {
364        let mut graph = self.builder.build_graph(X)?;
365
366        for transform in &self.transforms {
367            graph = transform.transform_graph(&graph)?;
368        }
369
370        Ok(graph)
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use scirs2_core::array;
378
379    #[test]
380    #[allow(non_snake_case)]
381    fn test_knn_graph_builder() {
382        let X = array![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]];
383        let builder = KNNGraphBuilder::new(2).weighted(true).sigma(1.0);
384
385        let graph = builder.build(&X.view()).expect("operation should succeed");
386
387        assert_eq!(graph.dim(), (4, 4));
388        // Each node should be connected to 2 neighbors
389        for i in 0..4 {
390            let row_nonzero = graph.row(i).iter().filter(|&&x| x > 0.0).count();
391            assert_eq!(row_nonzero, 2);
392        }
393    }
394
395    #[test]
396    #[allow(non_snake_case)]
397    fn test_epsilon_graph_builder() {
398        let X = array![[0.0, 0.0], [1.0, 0.0], [10.0, 0.0]];
399        let builder = EpsilonGraphBuilder::new(2.0).weighted(false);
400
401        let graph = builder.build(&X.view()).expect("operation should succeed");
402
403        assert_eq!(graph.dim(), (3, 3));
404        // Nodes 0 and 1 should be connected (distance 1.0 < 2.0)
405        assert_eq!(graph[[0, 1]], 1.0);
406        assert_eq!(graph[[1, 0]], 1.0);
407        // Nodes 0 and 2 should not be connected (distance 10.0 > 2.0)
408        assert_eq!(graph[[0, 2]], 0.0);
409    }
410
411    #[test]
412    fn test_symmetrize_transform() {
413        let mut graph = Array2::<f64>::zeros((3, 3));
414        graph[[0, 1]] = 1.0;
415        graph[[1, 0]] = 2.0;
416        graph[[1, 2]] = 3.0;
417        graph[[2, 1]] = 4.0;
418
419        let transform = SymmetrizeTransform::new("max".to_string());
420        let symmetric = transform
421            .transform(&graph)
422            .expect("operation should succeed");
423
424        assert_eq!(symmetric[[0, 1]], 2.0);
425        assert_eq!(symmetric[[1, 0]], 2.0);
426        assert_eq!(symmetric[[1, 2]], 4.0);
427        assert_eq!(symmetric[[2, 1]], 4.0);
428    }
429
430    #[test]
431    fn test_normalize_transform() {
432        let mut graph = Array2::<f64>::zeros((3, 3));
433        graph[[0, 1]] = 2.0;
434        graph[[0, 2]] = 2.0;
435        graph[[1, 0]] = 1.0;
436
437        let transform = NormalizeTransform::new("row".to_string());
438        let normalized = transform
439            .transform(&graph)
440            .expect("operation should succeed");
441
442        // Row 0 should sum to 1.0
443        let row_sum: f64 = normalized.row(0).sum();
444        assert!((row_sum - 1.0).abs() < 1e-10);
445    }
446
447    #[test]
448    fn test_sparsify_transform() {
449        let mut graph = Array2::<f64>::zeros((3, 3));
450        graph[[0, 1]] = 0.1;
451        graph[[0, 2]] = 0.5;
452        graph[[1, 2]] = 0.3;
453
454        let transform = SparsifyTransform::new(0.2);
455        let sparse = transform
456            .transform(&graph)
457            .expect("operation should succeed");
458
459        assert_eq!(sparse[[0, 1]], 0.0); // Below threshold
460        assert_eq!(sparse[[0, 2]], 0.5); // Above threshold
461        assert_eq!(sparse[[1, 2]], 0.3); // Above threshold
462    }
463
464    #[test]
465    #[allow(non_snake_case)]
466    fn test_graph_pipeline() {
467        let X = array![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]];
468
469        let pipeline = GraphPipeline::new(KNNGraphBuilder::new(2).weighted(true))
470            .add_transform(SymmetrizeTransform::new("average".to_string()))
471            .add_transform(NormalizeTransform::new("row".to_string()));
472
473        let graph = pipeline.build(&X.view()).expect("operation should succeed");
474
475        assert_eq!(graph.dim(), (4, 4));
476
477        // Check that each row sums to approximately 1.0 (normalized)
478        for i in 0..4 {
479            let row_sum: f64 = graph.row(i).sum();
480            assert!((row_sum - 1.0).abs() < 1e-6 || row_sum == 0.0);
481        }
482
483        // Note: row normalization breaks symmetry, so we don't check for it
484        // Let's check connectivity instead
485        let total_edges: usize = graph.iter().filter(|&&x| x > 0.0).count();
486        assert!(total_edges > 0);
487    }
488
489    #[test]
490    #[allow(non_snake_case)]
491    fn test_symmetric_pipeline() {
492        let X = array![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]];
493
494        let pipeline = GraphPipeline::new(KNNGraphBuilder::new(1).weighted(true))
495            .add_transform(SymmetrizeTransform::new("max".to_string()));
496
497        let graph = pipeline.build(&X.view()).expect("operation should succeed");
498
499        assert_eq!(graph.dim(), (3, 3));
500
501        // Check symmetry (without row normalization)
502        for i in 0..3 {
503            for j in 0..3 {
504                assert!((graph[[i, j]] - graph[[j, i]]).abs() < 1e-10);
505            }
506        }
507    }
508}