1use rayon::prelude::*;
5use rustc_hash::FxHashMap;
6use std::collections::VecDeque;
7
8use crate::prelude::BixverseFloat;
9use bixverse_core::assert_same_len;
10
11#[derive(Debug, Clone, Copy, Default)]
17pub enum SymmetryWeightStrategy {
18 #[default]
20 Average,
21 Min,
23 Max,
25}
26
27pub 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#[derive(Debug, Clone)]
51pub struct KnnLabPropGraph<T> {
52 pub offsets: Vec<usize>,
54 pub neighbours: Vec<usize>,
56 pub weights: Vec<T>,
58}
59
60impl<T> KnnLabPropGraph<T>
61where
62 T: BixverseFloat,
63{
64 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 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 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 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 *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 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 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 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 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#[cfg(test)]
454mod tests {
455 use super::*;
456
457 type Graph = KnnLabPropGraph<f64>;
458
459 fn simple_chain() -> Graph {
461 let edges = vec![0, 1, 1, 2];
462 Graph::from_edge_list(&edges, 3, true)
463 }
464
465 #[test]
467 fn test_csr_structure_symmetric() {
468 let g = simple_chain();
469 assert_eq!(g.offsets, vec![0, 1, 3, 4]);
471 assert_eq!(g.neighbours.len(), 4);
472 }
473
474 #[test]
476 fn test_weights_normalised() {
477 let g = simple_chain();
478 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 #[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 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 #[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 assert!((result[1][0] - 0.5).abs() < 0.05);
514 assert!((result[1][1] - 0.5).abs() < 0.05);
515 }
516
517 #[test]
519 fn test_max_hops_restricts_propagation() {
520 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 assert!((result[4][0]).abs() < 1e-9);
536 assert!((result[4][1]).abs() < 1e-9);
537 }
538
539 #[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 #[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 #[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 #[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}