Skip to main content

bixverse_methods/graph/
graph_label_propagations.rs

1//! Label propagation algorithms over kNN graphs specifically (but with
2//! extension to other domains)
3
4use rayon::prelude::*;
5use rustc_hash::FxHashMap;
6use std::collections::VecDeque;
7
8use crate::prelude::BixverseFloat;
9use bixverse_core::assert_same_len;
10
11/////////////
12// Helpers //
13/////////////
14
15/// Which strategy to use for symmetrisation and the weights
16#[derive(Debug, Clone, Copy, Default)]
17pub enum SymmetryWeightStrategy {
18    /// Average weight between two nodes will be taken forward
19    #[default]
20    Average,
21    /// Minimum weight between two nodes will be taken forward
22    Min,
23    /// Maximum weight between two nodes will be taken forward
24    Max,
25}
26
27/// Parse the symmetry strategy string
28///
29/// ### Params
30///
31/// * `s` - String to parse.
32///
33/// ### Returns
34///
35/// The Option of the `SymmetryWeightStrategy` to apply
36pub fn parse_symmetry_strategy(s: &str) -> Option<SymmetryWeightStrategy> {
37    match s.to_lowercase().as_str() {
38        "min" => Some(SymmetryWeightStrategy::Min),
39        "max" => Some(SymmetryWeightStrategy::Max),
40        "average" | "avg" => Some(SymmetryWeightStrategy::Average),
41        _ => None,
42    }
43}
44
45///////////////////////////
46// KNN label propagation //
47///////////////////////////
48
49/// Structure to store KNN graphs and do label propagation
50#[derive(Debug, Clone)]
51pub struct KnnLabPropGraph<T> {
52    /// Stores the offsets for node i's neighbours
53    pub offsets: Vec<usize>,
54    /// Flat array with neighbour indices
55    pub neighbours: Vec<usize>,
56    /// Normalised edge weights
57    pub weights: Vec<T>,
58}
59
60impl<T> KnnLabPropGraph<T>
61where
62    T: BixverseFloat,
63{
64    //////////////
65    // Builders //
66    //////////////
67
68    /// Generate the KnnLabelPropGraph
69    ///
70    /// ### Params
71    ///
72    /// * `edges` - edge list in form of [node_1, node_2, node_3, ...] which
73    ///   indicates alternating pairs (node_1, node_2), etc in terms of edges.
74    /// * `n_nodes` - Number of nodes in the graph
75    /// * `symmetrise` - Shall the graph be made symmetric.
76    ///
77    /// ### Returns
78    ///
79    /// Self with the data stored in the structure.
80    pub fn from_edge_list(edges: &[usize], n_nodes: usize, symmetrise: bool) -> Self {
81        let mut adj: Vec<Vec<(usize, T)>> = vec![vec![]; n_nodes];
82
83        for chunk in edges.chunks(2) {
84            let (u, v) = (chunk[0], chunk[1]);
85            adj[u].push((v, T::one()));
86            if symmetrise {
87                adj[v].push((u, T::one()));
88            }
89        }
90
91        for neighbours in &mut adj {
92            let sum = T::from_usize(neighbours.len()).unwrap();
93            for (_, w) in neighbours.iter_mut() {
94                *w /= sum;
95            }
96        }
97
98        Self::build_csr(adj)
99    }
100
101    /// Generate KnnLabPropGraph from node pairs
102    ///
103    /// ### Params
104    ///
105    /// * `from` - Index of the from node
106    /// * `to` - Index of the to node
107    /// * `n_nodes` - Number of nodes in the graph
108    /// * `symmetrise` - Shall the graph we symmetrised
109    ///
110    /// ### Returns
111    ///
112    /// Initialised class
113    pub fn from_node_pairs(from: &[usize], to: &[usize], n_nodes: usize, symmetrise: bool) -> Self {
114        assert_eq!(from.len(), to.len(), "from and to must have equal length");
115
116        let mut adj: Vec<Vec<(usize, T)>> = vec![vec![]; n_nodes];
117
118        for (&u, &v) in from.iter().zip(to.iter()) {
119            adj[u].push((v, T::one()));
120            if symmetrise {
121                adj[v].push((u, T::one()));
122            }
123        }
124
125        for neighbours in &mut adj {
126            let sum = T::from_usize(neighbours.len()).unwrap();
127            for (_, w) in neighbours.iter_mut() {
128                *w /= sum;
129            }
130        }
131
132        Self::build_csr(adj)
133    }
134
135    /// Build the KnnLabPropGraph from node pairs (with weights)
136    ///
137    /// ### Params
138    ///
139    /// * `from` - Index of the from node
140    /// * `to` - Index of the to node
141    /// * `weights` - The weights between the nodes
142    /// * `n_nodes` - Number of nodes
143    /// * `symmetrise` - Symmetrisation strategy
144    ///
145    /// ### Returns
146    ///
147    /// Initialised self
148    pub fn from_weighted_node_pairs(
149        from: &[usize],
150        to: &[usize],
151        weights: &[T],
152        n_nodes: usize,
153        symmetrise: Option<SymmetryWeightStrategy>,
154    ) -> Self {
155        assert_same_len!(from, to, weights);
156
157        let mut adj: Vec<FxHashMap<usize, T>> = vec![FxHashMap::default(); n_nodes];
158
159        for (i, (&u, &v)) in from.iter().zip(to.iter()).enumerate() {
160            adj[u].insert(v, weights[i]);
161            if let Some(ref strategy) = symmetrise {
162                adj[v]
163                    .entry(u)
164                    .and_modify(|w: &mut T| {
165                        *w = match strategy {
166                            SymmetryWeightStrategy::Min => (*w).min(weights[i]),
167                            SymmetryWeightStrategy::Max => (*w).max(weights[i]),
168                            SymmetryWeightStrategy::Average => {
169                                (*w + weights[i]) / T::from_f64(2.0).unwrap()
170                            }
171                        };
172                    })
173                    .or_insert(weights[i]);
174            }
175        }
176
177        let mut adj_vec: Vec<Vec<(usize, T)>> = adj
178            .into_iter()
179            .map(|map| map.into_iter().collect())
180            .collect();
181
182        for neighbours in &mut adj_vec {
183            let sum = neighbours
184                .iter()
185                .map(|(_, w)| *w)
186                .fold(T::zero(), |a, b| a + b);
187            for (_, w) in neighbours.iter_mut() {
188                *w /= sum;
189            }
190        }
191
192        Self::build_csr(adj_vec)
193    }
194
195    /// Generate the KnnLabelPropGraph with weights
196    ///
197    /// ### Params
198    ///
199    /// * `edges` - edge list in form of [node_1, node_2, node_3, ...] which
200    ///   indicates alternating pairs (node_1, node_2), etc in terms of edges.
201    /// * `weights` - the weights between the two nodes in the graph.
202    /// * `n_nodes` - Number of nodes in the graph
203    /// * `symmetrise` - Which symmetry strategy to use. If None, no
204    ///   symmetrisation is performed.
205    ///
206    /// ### Returns
207    ///
208    /// Self with the data stored in the structure.
209    pub fn from_weighted_edge_list(
210        edges: &[usize],
211        weights: &[T],
212        n_nodes: usize,
213        symmetrise: Option<SymmetryWeightStrategy>,
214    ) -> Self {
215        assert_eq!(
216            edges.len() / 2,
217            weights.len(),
218            "Weight count must match edge count"
219        );
220
221        let mut adj: Vec<FxHashMap<usize, T>> = vec![FxHashMap::default(); n_nodes];
222
223        for (i, chunk) in edges.chunks(2).enumerate() {
224            let (u, v) = (chunk[0], chunk[1]);
225            adj[u].insert(v, weights[i]);
226            if let Some(ref strategy) = symmetrise {
227                adj[v]
228                    .entry(u)
229                    .and_modify(|w: &mut T| {
230                        // to avoid rust analyser complains
231                        *w = match strategy {
232                            SymmetryWeightStrategy::Min => (*w).min(weights[i]),
233                            SymmetryWeightStrategy::Max => (*w).max(weights[i]),
234                            SymmetryWeightStrategy::Average => {
235                                (*w + weights[i]) / T::from_f64(2.0).unwrap()
236                            }
237                        };
238                    })
239                    .or_insert(weights[i]);
240            }
241        }
242
243        let mut adj_vec: Vec<Vec<(usize, T)>> = adj
244            .into_iter()
245            .map(|map| map.into_iter().collect())
246            .collect();
247
248        for neighbours in &mut adj_vec {
249            let sum = neighbours
250                .iter()
251                .map(|(_, w)| *w)
252                .fold(T::zero(), |a, b| a + b);
253            for (_, w) in neighbours.iter_mut() {
254                *w /= sum;
255            }
256        }
257
258        Self::build_csr(adj_vec)
259    }
260
261    /// Label spreading algorithm
262    ///
263    /// Spreads labels (one-hot encoded categorical data) over the graph. The
264    /// input needs to be of structure:
265    ///
266    /// class 1 -> `[1.0, 0.0, 0.0, 0.0]`
267    ///
268    /// class 2 -> `[0.0, 1.0, 0.0, 0.0]`
269    ///
270    /// unlabelled -> `[0.0, 0.0, 0.0, 0.0]`
271    ///
272    /// Labelled nodes are anchored to their original labels and act as sources,
273    /// while unlabelled nodes receive purely propagated distributions.
274    ///
275    /// ### Params
276    ///
277    /// * `labels` - One-hot encoded group membership. All zeroes == unlabelled.
278    /// * `mask` - Boolean slice where `true` indicates an unlabelled node.
279    /// * `alpha` - Anchoring strength for labelled nodes, typically 0.9 to 0.95.
280    ///   Higher values anchor more strongly to the original label; lower values
281    ///   allow more influence from neighbours.
282    /// * `iterations` - Maximum number of spreading iterations.
283    /// * `tolerance` - Convergence threshold. Stops early if the maximum change
284    ///   across all nodes and classes falls below this value.
285    /// * `max_hops` - If `Some(k)`, restricts spreading to nodes within `k` hops
286    ///   of any labelled node. Nodes beyond this limit are never updated and
287    ///   remain as all-zeroes. If `None`, spreading is unrestricted.
288    ///
289    /// ### Returns
290    ///
291    /// A `Vec<Vec<T>>` of length `n_nodes` where each entry is the probability
292    /// distribution over classes for that node.
293    pub fn label_spreading(
294        &self,
295        labels: &[Vec<T>],
296        mask: &[bool],
297        alpha: T,
298        iterations: usize,
299        tolerance: T,
300        max_hops: Option<usize>,
301    ) -> Vec<Vec<T>> {
302        let num_classes = labels[0].len();
303        if num_classes == 0 {
304            return labels.to_vec();
305        }
306        assert!(
307            labels.iter().all(|l| l.len() == num_classes),
308            "every label vector must have the same number of classes"
309        );
310
311        // Flat row-major n x num_classes buffers
312        let labels_flat: Vec<T> = labels.iter().flatten().copied().collect();
313        let mut y = labels_flat.clone();
314        let mut y_new = vec![T::zero(); y.len()];
315
316        let distances = max_hops.map(|_| self.compute_hop_distances(mask));
317
318        for _ in 0..iterations {
319            y_new
320                .par_chunks_mut(num_classes)
321                .enumerate()
322                .for_each(|(node, y_dist)| {
323                    if let (Some(h), Some(dists)) = (max_hops, &distances)
324                        && dists[node] > h
325                    {
326                        return;
327                    }
328
329                    let start = self.offsets[node];
330                    let end = self.offsets[node + 1];
331                    y_dist.fill(T::zero());
332                    for (&nb, &w) in self.neighbours[start..end]
333                        .iter()
334                        .zip(&self.weights[start..end])
335                    {
336                        let neighbor_dist = &y[nb * num_classes..(nb + 1) * num_classes];
337                        for (out, &v) in y_dist.iter_mut().zip(neighbor_dist) {
338                            *out += w * v;
339                        }
340                    }
341                });
342
343            let max_change = y
344                .par_chunks_mut(num_classes)
345                .zip(y_new.par_chunks(num_classes))
346                .zip(labels_flat.par_chunks(num_classes))
347                .enumerate()
348                .map(|(i, ((y_dist, new_row), label_row))| {
349                    if let (Some(h), Some(dists)) = (max_hops, &distances)
350                        && dists[i] > h
351                    {
352                        return T::zero();
353                    }
354
355                    let mut max_diff = T::zero();
356                    if mask[i] {
357                        for (old, &new_val) in y_dist.iter_mut().zip(new_row) {
358                            max_diff = max_diff.max((new_val - *old).abs());
359                            *old = new_val;
360                        }
361                    } else {
362                        for ((old, &propagated), &label) in
363                            y_dist.iter_mut().zip(new_row).zip(label_row)
364                        {
365                            let new_val = alpha * label + (T::one() - alpha) * propagated;
366                            max_diff = max_diff.max((new_val - *old).abs());
367                            *old = new_val;
368                        }
369                    }
370                    max_diff
371                })
372                .reduce(|| T::zero(), T::max);
373
374            if max_change < tolerance {
375                break;
376            }
377        }
378
379        y.chunks(num_classes).map(|row| row.to_vec()).collect()
380    }
381
382    /// Internal helper to generate the CSR representation
383    ///
384    /// ### Params
385    ///
386    /// * `build_csr` - The adjacency of the graph
387    ///
388    /// ### Returns
389    ///
390    /// Self.
391    fn build_csr(adj: Vec<Vec<(usize, T)>>) -> Self {
392        let mut offsets = vec![0];
393        let mut neighbours = Vec::new();
394        let mut weights = Vec::new();
395
396        for node_neighbours in adj {
397            for (neighbour, weight) in node_neighbours {
398                neighbours.push(neighbour);
399                weights.push(weight);
400            }
401            offsets.push(neighbours.len());
402        }
403
404        Self {
405            offsets,
406            neighbours,
407            weights,
408        }
409    }
410
411    /// Compute hop distances
412    ///
413    /// ### Params
414    ///
415    /// * `mask` -
416    ///
417    /// ### Return
418    ///
419    ///
420    fn compute_hop_distances(&self, mask: &[bool]) -> Vec<usize> {
421        let n = mask.len();
422        let mut distances = vec![usize::MAX; n];
423        let mut queue = VecDeque::new();
424
425        for (i, &is_unlabelled) in mask.iter().enumerate() {
426            if !is_unlabelled {
427                distances[i] = 0;
428                queue.push_back(i);
429            }
430        }
431
432        while let Some(node) = queue.pop_front() {
433            let start = self.offsets[node];
434            let end = self.offsets[node + 1];
435
436            for i in start..end {
437                let neighbour = self.neighbours[i];
438                if distances[neighbour] == usize::MAX {
439                    distances[neighbour] = distances[node] + 1;
440                    queue.push_back(neighbour);
441                }
442            }
443        }
444
445        distances
446    }
447}
448
449///////////
450// Tests //
451///////////
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    type Graph = KnnLabPropGraph<f64>;
458
459    // 0 -- 1 -- 2
460    fn simple_chain() -> Graph {
461        let edges = vec![0, 1, 1, 2];
462        Graph::from_edge_list(&edges, 3, true)
463    }
464
465    /// An edge list builds both directions, so the CSR offsets are symmetric.
466    #[test]
467    fn test_csr_structure_symmetric() {
468        let g = simple_chain();
469        // Node 0: [1], Node 1: [0, 2], Node 2: [1]
470        assert_eq!(g.offsets, vec![0, 1, 3, 4]);
471        assert_eq!(g.neighbours.len(), 4);
472    }
473
474    /// Each node's outgoing weights sum to one, so propagation is a convex mix.
475    #[test]
476    fn test_weights_normalised() {
477        let g = simple_chain();
478        // Node 1 has two neighbours, each weight should be 0.5
479        let start = g.offsets[1];
480        let end = g.offsets[2];
481        let sum: f64 = g.weights[start..end].iter().sum();
482        assert!((sum - 1.0).abs() < 1e-9);
483    }
484
485    /// Seed nodes keep their own class dominant after spreading.
486    #[test]
487    fn test_label_spreading_labelled_nodes_anchored() {
488        let g = simple_chain();
489        let labels = vec![vec![1.0_f64, 0.0], vec![0.0, 0.0], vec![0.0, 1.0]];
490        let mask = vec![false, true, false];
491        let result = g.label_spreading(&labels, &mask, 0.9, 100, 1e-6, None);
492
493        // Labelled nodes are soft-anchored, not hard-clamped, so just verify dominance
494        assert!(
495            result[0][0] > result[0][1],
496            "node 0 should still favour class 0"
497        );
498        assert!(
499            result[2][1] > result[2][0],
500            "node 2 should still favour class 1"
501        );
502    }
503
504    /// An unlabelled node equidistant from two seeds settles between them.
505    #[test]
506    fn test_label_spreading_unlabelled_receives_distribution() {
507        let g = simple_chain();
508        let labels = vec![vec![1.0_f64, 0.0], vec![0.0, 0.0], vec![0.0, 1.0]];
509        let mask = vec![false, true, false];
510        let result = g.label_spreading(&labels, &mask, 0.9, 100, 1e-6, None);
511
512        // Node 1 sits between class 0 and class 1 so should end up roughly 0.5/0.5
513        assert!((result[1][0] - 0.5).abs() < 0.05);
514        assert!((result[1][1] - 0.5).abs() < 0.05);
515    }
516
517    /// `max_hops` is a hard cutoff: nodes beyond it stay at zero mass.
518    #[test]
519    fn test_max_hops_restricts_propagation() {
520        // 0 -- 1 -- 2 -- 3 -- 4
521        // Node 0 labelled, rest unlabelled, max_hops = 2
522        let edges = vec![0, 1, 1, 2, 2, 3, 3, 4];
523        let g = Graph::from_edge_list(&edges, 5, true);
524        let labels = vec![
525            vec![1.0_f64, 0.0],
526            vec![0.0, 0.0],
527            vec![0.0, 0.0],
528            vec![0.0, 0.0],
529            vec![0.0, 0.0],
530        ];
531        let mask = vec![false, true, true, true, true];
532        let result = g.label_spreading(&labels, &mask, 0.9, 100, 1e-6, Some(2));
533
534        // Node 4 is 4 hops away, should remain all-zero
535        assert!((result[4][0]).abs() < 1e-9);
536        assert!((result[4][1]).abs() < 1e-9);
537    }
538
539    /// Hop distances are measured from the seed set, which sits at zero.
540    #[test]
541    fn test_hop_distances() {
542        let g = simple_chain();
543        let mask = vec![false, true, false];
544        let dists = g.compute_hop_distances(&mask);
545        assert_eq!(dists[0], 0);
546        assert_eq!(dists[1], 1);
547        assert_eq!(dists[2], 0);
548    }
549
550    /// Strategy parsing is case-insensitive and rejects unknown names.
551    #[test]
552    fn test_parse_symmetry_strategy() {
553        assert!(matches!(
554            parse_symmetry_strategy("min"),
555            Some(SymmetryWeightStrategy::Min)
556        ));
557        assert!(matches!(
558            parse_symmetry_strategy("MAX"),
559            Some(SymmetryWeightStrategy::Max)
560        ));
561        assert!(matches!(
562            parse_symmetry_strategy("avg"),
563            Some(SymmetryWeightStrategy::Average)
564        ));
565        assert!(parse_symmetry_strategy("nonsense").is_none());
566    }
567
568    /// The two constructors are the same graph in different input shapes.
569    #[test]
570    fn test_node_pairs_matches_edge_list() {
571        let from = vec![0, 1];
572        let to = vec![1, 2];
573        let g_pairs = Graph::from_node_pairs(&from, &to, 3, true);
574
575        let edges = vec![0, 1, 1, 2];
576        let g_edge = Graph::from_edge_list(&edges, 3, true);
577
578        assert_eq!(g_pairs.offsets, g_edge.offsets);
579        assert_eq!(g_pairs.neighbours, g_edge.neighbours);
580        for (a, b) in g_pairs.weights.iter().zip(g_edge.weights.iter()) {
581            assert!((a - b).abs() < 1e-9);
582        }
583    }
584
585    /// Same for the weighted constructors, including the symmetry strategy.
586    #[test]
587    fn test_weighted_node_pairs_matches_weighted_edge_list() {
588        let from = vec![0, 1];
589        let to = vec![1, 2];
590        let weights = vec![0.6_f64, 0.4];
591
592        let g_pairs = Graph::from_weighted_node_pairs(
593            &from,
594            &to,
595            &weights,
596            3,
597            Some(SymmetryWeightStrategy::Min),
598        );
599        let edges = vec![0, 1, 1, 2];
600        let g_edge =
601            Graph::from_weighted_edge_list(&edges, &weights, 3, Some(SymmetryWeightStrategy::Min));
602
603        assert_eq!(g_pairs.offsets, g_edge.offsets);
604        assert_eq!(g_pairs.neighbours, g_edge.neighbours);
605        for (a, b) in g_pairs.weights.iter().zip(g_edge.weights.iter()) {
606            assert!((a - b).abs() < 1e-9);
607        }
608    }
609}