Skip to main content

bixverse_methods/graph/
knn_graph.rs

1//! kNN graph construction: one [`KnnSymmetrisation`] choice turns a directed
2//! kNN into an undirected weighted [`SparseGraph`].
3//!
4//! Every variant takes the neighbours row-major (`k` per node, self excluded,
5//! sorted by distance) and builds the symmetric CSR directly, rows sorted by
6//! column, without going through an edge-pair list.
7
8use num_traits::Float;
9use rayon::prelude::*;
10use std::iter::Sum;
11use std::time::Instant;
12
13use crate::graph::graph_structures::UnionFind;
14use crate::prelude::*;
15use bixverse_core::core::math::sparse::{CompressedSparseData2, CompressedSparseFormat};
16
17///////////
18// Const //
19///////////
20
21/// Components below this size after pruning get reconnected by the sNN
22/// rescue.
23const MIN_COMPONENT_SIZE: usize = 10;
24
25/// Rows per rayon task in the sNN pass. Each task owns one `n`-long score
26/// buffer, so this bounds how often that buffer is allocated rather than how
27/// the work is balanced.
28const SNN_ROW_CHUNK: usize = 2048;
29
30/// umap-learn's `SMOOTH_K_TOLERANCE`: binary-search tolerance and the
31/// interpolation cut-off for fractional `local_connectivity`.
32const SMOOTH_K_TOLERANCE: f64 = 1e-5;
33
34/// umap-learn's `MIN_K_DIST_SCALE`: floor on sigma as a fraction of the mean
35/// neighbour distance.
36const MIN_K_DIST_SCALE: f64 = 1e-3;
37
38/// Binary-search iterations for the UMAP bandwidth, umap-learn's `n_iter`.
39const SMOOTH_K_ITERS: usize = 64;
40
41/// Default sNN prune. For the Jaccard weight a threshold `p` keeps pairs that
42/// share at least a fraction `2p / (1 + p)` of their self-inclusive
43/// neighbourhoods, independent of `k`. `1/16` asks for about 12%, in line with
44/// Seurat's `1/15`, and sits off the discrete weight `1/15` takes at `k = 15`,
45/// where float rounding would decide whether a 2-shared edge survives.
46pub const SNN_PRUNE_DEFAULT: f64 = 1.0 / 16.0;
47
48/// Floor on the rank sNN weight before scaling, as in bluster.
49const SNN_RANK_FLOOR: f64 = 1e-6;
50
51///////////
52// Enums //
53///////////
54
55/// Similarity used to weight sNN edges.
56#[derive(Clone, Copy, Debug, Default, PartialEq)]
57pub enum SnnSimilarity {
58    /// Jaccard index of the self-inclusive neighbour sets
59    #[default]
60    Jaccard,
61    /// bluster's rank weight: `k - r / 2`, `r` the smallest combined rank of
62    /// a shared neighbour, scaled to `(0, 1]`
63    Rank,
64}
65
66/// How a directed kNN becomes an undirected graph.
67#[derive(Clone, Copy, Debug, PartialEq)]
68pub enum KnnSymmetrisation {
69    /// kNN union. Mutual pairs weigh 2 and one-sided pairs 1, unless `binary`
70    /// sets every weight to 1.
71    Knn {
72        /// Set every weight to 1
73        binary: bool,
74    },
75    /// Shared nearest neighbours: an edge between every pair of nodes that
76    /// shares any neighbour (bluster / Seurat).
77    Snn {
78        /// Edge weight
79        similarity: SnnSimilarity,
80        /// Edges below this weight are dropped
81        prune: f64,
82        /// Reconnect components that pruning leaves below
83        /// [`MIN_COMPONENT_SIZE`] nodes
84        rescue: bool,
85    },
86    /// UMAP fuzzy simplicial set (the scanpy default). Needs distances.
87    Umap {
88        /// Number of neighbours assumed at distance zero, umap-learn's
89        /// `local_connectivity`
90        local_connectivity: f64,
91        /// Fuzzy union (1) versus fuzzy intersection (0), umap-learn's
92        /// `set_op_mix_ratio`
93        mix_ratio: f64,
94    },
95}
96
97/// Defaults to the bluster sNN with Jaccard weights at a prune of
98/// [`SNN_PRUNE_DEFAULT`]
99impl Default for KnnSymmetrisation {
100    fn default() -> Self {
101        KnnSymmetrisation::Snn {
102            similarity: SnnSimilarity::Jaccard,
103            prune: SNN_PRUNE_DEFAULT,
104            rescue: true,
105        }
106    }
107}
108
109/// Parse an sNN similarity name.
110///
111/// ### Params
112///
113/// * `s` - `"jaccard"` or `"rank"`, case-insensitive
114///
115/// ### Returns
116///
117/// The [`SnnSimilarity`], or `None` for an unknown name
118pub fn parse_snn_similarity(s: &str) -> Option<SnnSimilarity> {
119    match s.to_lowercase().as_str() {
120        "jaccard" => Some(SnnSimilarity::Jaccard),
121        "rank" => Some(SnnSimilarity::Rank),
122        _ => None,
123    }
124}
125
126/////////////
127// Helpers //
128/////////////
129
130/// Cast an `f64` constant or count into the working float type.
131///
132/// ### Params
133///
134/// * `x` - The value
135///
136/// ### Returns
137///
138/// `x` as `T`
139#[inline(always)]
140fn t<T: BixverseFloat>(x: f64) -> T {
141    T::from_f64(x).expect("f64 fits every float type")
142}
143
144/// Check the shape and range of a row-major kNN.
145///
146/// ### Params
147///
148/// * `idx` - Neighbour indices, row-major, `k` per node
149/// * `n_dist` - Length of the distances, if given
150/// * `k` - Neighbours per node
151/// * `n` - Number of nodes
152///
153/// ### Returns
154///
155/// `Ok(())` or the first violation found
156fn check_knn(idx: &[u32], n_dist: Option<usize>, k: usize, n: usize) -> Result<(), MethodsErrors> {
157    if idx.len() != n * k {
158        return Err(MethodsErrors::GraphLengthMismatch {
159            name: "idx",
160            expected: n * k,
161            got: idx.len(),
162        });
163    }
164    if let Some(got) = n_dist
165        && got != n * k
166    {
167        return Err(MethodsErrors::GraphLengthMismatch {
168            name: "dist",
169            expected: n * k,
170            got,
171        });
172    }
173    if let Some(&bad) = idx.par_iter().find_any(|&&j| j as usize >= n) {
174        return Err(MethodsErrors::GraphIndexOutOfRange {
175            name: "idx",
176            index: bad as usize,
177            n_nodes: n,
178        });
179    }
180    Ok(())
181}
182
183/// Wrap a symmetric, row-sorted CSR into an undirected [`SparseGraph`].
184///
185/// ### Params
186///
187/// * `indptr` - Row offsets, `n + 1`
188/// * `indices` - Column indices
189/// * `data` - Edge weights
190/// * `n` - Number of nodes
191///
192/// ### Returns
193///
194/// The undirected graph
195fn csr_to_graph<T>(indptr: Vec<usize>, indices: Vec<u32>, data: Vec<T>, n: usize) -> SparseGraph<T>
196where
197    T: BixverseFloat + Sum,
198{
199    let adjacency = CompressedSparseData2 {
200        data,
201        indices,
202        indptr: indptr.into_iter().map(|p| p as u32).collect(),
203        cs_type: CompressedSparseFormat::Csr,
204        data_2: None,
205        shape: (n, n),
206    };
207    SparseGraph::new(n, adjacency, false)
208}
209
210/// Exclusive prefix sum of per-row lengths into CSR offsets.
211///
212/// ### Params
213///
214/// * `lens` - Entries per row
215///
216/// ### Returns
217///
218/// Offsets of length `lens.len() + 1`
219fn offsets(lens: &[usize]) -> Vec<usize> {
220    let mut ptr = Vec::with_capacity(lens.len() + 1);
221    ptr.push(0);
222    let mut acc = 0;
223    for &l in lens {
224        acc += l;
225        ptr.push(acc);
226    }
227    ptr
228}
229
230/// Hand out one disjoint mutable slice per CSR row.
231///
232/// ### Params
233///
234/// * `data` - The flat CSR array
235/// * `ptr` - Row offsets into it
236///
237/// ### Returns
238///
239/// One slice per row, in row order
240fn row_slices_mut<'a, T>(mut data: &'a mut [T], ptr: &[usize]) -> Vec<&'a mut [T]> {
241    let mut out = Vec::with_capacity(ptr.len().saturating_sub(1));
242    for w in ptr.windows(2) {
243        let (row, rest) = data.split_at_mut(w[1] - w[0]);
244        out.push(row);
245        data = rest;
246    }
247    out
248}
249
250/// Flatten sorted per-row adjacency lists into a graph.
251///
252/// ### Params
253///
254/// * `rows` - Per-node `(column, weight)` lists, sorted by column, already
255///   symmetric
256/// * `n` - Number of nodes
257///
258/// ### Returns
259///
260/// The undirected graph
261fn lists_to_graph<T>(rows: &[Vec<(u32, T)>], n: usize) -> SparseGraph<T>
262where
263    T: BixverseFloat + Send + Sync + Sum,
264{
265    let lens: Vec<usize> = rows.iter().map(|r| r.len()).collect();
266    let ptr = offsets(&lens);
267    let mut indices = vec![0u32; ptr[n]];
268    let mut data = vec![T::zero(); ptr[n]];
269    row_slices_mut(&mut indices, &ptr)
270        .into_par_iter()
271        .zip(row_slices_mut(&mut data, &ptr))
272        .zip(rows.par_iter())
273        .for_each(|((ix, dx), row)| {
274            for (e, &(c, v)) in row.iter().enumerate() {
275                ix[e] = c;
276                dx[e] = v;
277            }
278        });
279    csr_to_graph(ptr, indices, data, n)
280}
281
282/// Symmetrise a weighted directed kNN.
283///
284/// Row `i` of the output is the sorted union of `i`'s out-neighbours and
285/// in-neighbours, each weighted `combine(w_ij, w_ji)` with a missing direction
286/// passed as zero. Results equal to zero are dropped, as scipy's
287/// `eliminate_zeros` does. Self entries are skipped.
288///
289/// ### Params
290///
291/// * `idx` - Neighbour indices, row-major, `k` per node
292/// * `w` - Directed weight per kNN entry, same layout
293/// * `k` - Neighbours per node
294/// * `n` - Number of nodes
295/// * `combine` - Applied to `(w_ij, w_ji)`
296///
297/// ### Returns
298///
299/// The undirected graph
300fn symmetrise<T, F>(idx: &[u32], w: &[T], k: usize, n: usize, combine: F) -> SparseGraph<T>
301where
302    T: BixverseFloat + Send + Sync + Sum,
303    F: Fn(T, T) -> T + Sync,
304{
305    // out rows sorted by column; self entries removed
306    let out: Vec<Vec<(u32, T)>> = (0..n)
307        .into_par_iter()
308        .map(|i| {
309            let mut row: Vec<(u32, T)> = idx[i * k..(i + 1) * k]
310                .iter()
311                .zip(&w[i * k..(i + 1) * k])
312                .filter(|&(&j, _)| j as usize != i)
313                .map(|(&j, &wt)| (j, wt))
314                .collect();
315            row.sort_unstable_by_key(|e| e.0);
316            row
317        })
318        .collect();
319
320    // in rows via counting sort; filling sources in ascending order keeps
321    // every in row sorted by column
322    let mut in_len = vec![0usize; n];
323    for row in &out {
324        for &(j, _) in row {
325            in_len[j as usize] += 1;
326        }
327    }
328    let in_ptr = offsets(&in_len);
329    let mut in_entries = vec![(0u32, T::zero()); in_ptr[n]];
330    let mut cursor = in_ptr.clone();
331    for (i, row) in out.iter().enumerate() {
332        for &(j, wt) in row {
333            in_entries[cursor[j as usize]] = (i as u32, wt);
334            cursor[j as usize] += 1;
335        }
336    }
337
338    // merge each out row with its in row
339    let merged: Vec<Vec<(u32, T)>> = (0..n)
340        .into_par_iter()
341        .map(|i| {
342            let a = &out[i];
343            let b = &in_entries[in_ptr[i]..in_ptr[i + 1]];
344            let mut row = Vec::with_capacity(a.len() + b.len());
345            let (mut p, mut q) = (0, 0);
346            while p < a.len() || q < b.len() {
347                let (col, wij, wji) = match (a.get(p), b.get(q)) {
348                    (Some(&(ca, wa)), Some(&(cb, wb))) if ca == cb => {
349                        p += 1;
350                        q += 1;
351                        (ca, wa, wb)
352                    }
353                    (Some(&(ca, wa)), Some(&(cb, _))) if ca < cb => {
354                        p += 1;
355                        (ca, wa, T::zero())
356                    }
357                    (Some(&(ca, wa)), None) => {
358                        p += 1;
359                        (ca, wa, T::zero())
360                    }
361                    (_, Some(&(cb, wb))) => {
362                        q += 1;
363                        (cb, T::zero(), wb)
364                    }
365                    (None, None) => unreachable!(),
366                };
367                let v = combine(wij, wji);
368                if v != T::zero() {
369                    row.push((col, v));
370                }
371            }
372            row
373        })
374        .collect();
375
376    lists_to_graph(&merged, n)
377}
378
379/////////
380// kNN //
381/////////
382
383/// kNN union.
384///
385/// ### Params
386///
387/// * `idx` - Neighbour indices, row-major, `k` per node
388/// * `k` - Neighbours per node
389/// * `n` - Number of nodes
390/// * `binary` - Set every weight to 1 instead of counting the directions
391///
392/// ### Returns
393///
394/// The undirected graph
395fn knn_union<T>(idx: &[u32], k: usize, n: usize, binary: bool) -> SparseGraph<T>
396where
397    T: BixverseFloat + Send + Sync + Sum,
398{
399    let ones = vec![T::one(); idx.len()];
400    if binary {
401        symmetrise(idx, &ones, k, n, |_, _| T::one())
402    } else {
403        symmetrise(idx, &ones, k, n, |a, b| a + b)
404    }
405}
406
407/////////
408// sNN //
409/////////
410
411/// sNN weight from the shared-member count and the smallest combined rank.
412///
413/// ### Params
414///
415/// * `count` - Shared members of the two self-inclusive neighbour sets
416/// * `min_rank` - Smallest `rank_a + rank_b` over the shared members
417/// * `k` - Neighbours per node
418/// * `similarity` - Which weight
419///
420/// ### Returns
421///
422/// The edge weight
423#[inline]
424fn snn_weight<T: BixverseFloat>(
425    count: u32,
426    min_rank: u32,
427    k: usize,
428    similarity: SnnSimilarity,
429) -> T {
430    let kf: T = t(k as f64);
431    match similarity {
432        SnnSimilarity::Jaccard => {
433            let c: T = t(count as f64);
434            c / (t::<T>(2.0) * (kf + T::one()) - c)
435        }
436        SnnSimilarity::Rank => {
437            let raw = kf - t::<T>(min_rank as f64) / t::<T>(2.0);
438            let floor: T = t(SNN_RANK_FLOOR);
439            (if raw > floor { raw } else { floor }) / kf
440        }
441    }
442}
443
444/// sNN weight between two nodes, built from their kNN rows on the fly.
445///
446/// Only meant for the handful of pairs the component rescue looks at.
447///
448/// ### Params
449///
450/// * `idx` - Neighbour indices, row-major, `k` per node
451/// * `k` - Neighbours per node
452/// * `a` - First node
453/// * `b` - Second node
454/// * `similarity` - Which weight
455///
456/// ### Returns
457///
458/// The weight the full sNN pass would assign the pair
459fn pair_snn_weight<T: BixverseFloat>(
460    idx: &[u32],
461    k: usize,
462    a: usize,
463    b: usize,
464    similarity: SnnSimilarity,
465) -> T {
466    let ranked = |c: usize| -> Vec<(u32, u32)> {
467        let mut v: Vec<(u32, u32)> = std::iter::once((c as u32, 0))
468            .chain(
469                idx[c * k..(c + 1) * k]
470                    .iter()
471                    .enumerate()
472                    .map(|(r, &nb)| (nb, r as u32 + 1)),
473            )
474            .collect();
475        v.sort_unstable();
476        v
477    };
478    let (sa, sb) = (ranked(a), ranked(b));
479    let (mut pa, mut pb) = (0, 0);
480    let mut count = 0u32;
481    let mut min_rank = 2 * k as u32;
482    while pa < sa.len() && pb < sb.len() {
483        match sa[pa].0.cmp(&sb[pb].0) {
484            std::cmp::Ordering::Equal => {
485                count += 1;
486                min_rank = min_rank.min(sa[pa].1 + sb[pb].1);
487                pa += 1;
488                pb += 1;
489            }
490            std::cmp::Ordering::Less => pa += 1,
491            std::cmp::Ordering::Greater => pb += 1,
492        }
493    }
494    snn_weight(count, min_rank, k, similarity)
495}
496
497/// Reconnect the small components that pruning cut off.
498///
499/// Leiden turns every disconnected component into its own cluster, and on
500/// large data sets pruning leaves hundreds of them, mostly pairs of nodes that
501/// are each other's strongest neighbour. Every component below
502/// [`MIN_COMPONENT_SIZE`] gets its strongest kNN edge to a node outside it,
503/// repeated until none is left or the remaining ones have no kNN edge leaving
504/// them. A kNN pair always shares at least the partner itself, so the edge
505/// exists in the unpruned sNN.
506///
507/// ### Params
508///
509/// * `idx` - Neighbour indices, row-major, `k` per node
510/// * `k` - Neighbours per node
511/// * `n` - Number of nodes
512/// * `similarity` - Which weight
513/// * `lower` - Per-node lists of `(smaller neighbour, weight)`; rescue edges
514///   are inserted, keeping each list sorted
515///
516/// ### Returns
517///
518/// Number of rescue edges added
519fn rescue_small_components<T>(
520    idx: &[u32],
521    k: usize,
522    n: usize,
523    similarity: SnnSimilarity,
524    lower: &mut [Vec<(u32, T)>],
525) -> usize
526where
527    T: BixverseFloat + Send + Sync,
528{
529    if k == 0 {
530        return 0;
531    }
532    let mut uf = UnionFind::new(n);
533    for (j, row) in lower.iter().enumerate() {
534        for &(i, _) in row {
535            uf.union(i, j as u32);
536        }
537    }
538
539    let mut n_rescued = 0;
540    loop {
541        let roots: Vec<u32> = (0..n as u32).map(|i| uf.find(i)).collect();
542        let mut size = vec![0usize; n];
543        for &r in &roots {
544            size[r as usize] += 1;
545        }
546
547        // (root, weight, node, partner); ties go to the closer neighbour
548        let mut candidates: Vec<(u32, T, usize, usize)> = (0..n)
549            .into_par_iter()
550            .filter(|&i| size[roots[i] as usize] < MIN_COMPONENT_SIZE)
551            .filter_map(|i| {
552                idx[i * k..(i + 1) * k]
553                    .iter()
554                    .map(|&nb| nb as usize)
555                    .filter(|&nb| roots[nb] != roots[i])
556                    .map(|nb| (pair_snn_weight::<T>(idx, k, i, nb, similarity), nb))
557                    .fold(None, |acc: Option<(T, usize)>, cur| match acc {
558                        Some(a) if a.0 >= cur.0 => Some(a),
559                        _ => Some(cur),
560                    })
561                    .map(|(w, nb)| (roots[i], w, i, nb))
562            })
563            .collect();
564        candidates.sort_unstable_by(|a, b| {
565            a.0.cmp(&b.0)
566                .then(b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
567                .then(a.2.cmp(&b.2))
568        });
569        candidates.dedup_by_key(|c| c.0);
570
571        let before = n_rescued;
572        for (_, w, i, nb) in candidates {
573            // two components can pick each other in the same round
574            if uf.union(i as u32, nb as u32) {
575                let (lo, hi) = (i.min(nb), i.max(nb));
576                let row = &mut lower[hi];
577                let pos = row.partition_point(|e| (e.0 as usize) < lo);
578                row.insert(pos, (lo as u32, w));
579                n_rescued += 1;
580            }
581        }
582        if n_rescued == before {
583            break;
584        }
585    }
586    n_rescued
587}
588
589/// Full sNN graph.
590///
591/// For each node `j`, every node `i < j` that shares a member of the
592/// self-inclusive neighbour sets is found through the reverse kNN index, and
593/// the shared count (Jaccard) or the smallest combined rank (Rank) is
594/// accumulated in a per-task `n`-long buffer. The lower-triangle lists that
595/// come out are sorted, so the symmetric CSR is assembled in one scatter with
596/// no per-row sort.
597///
598/// ### Params
599///
600/// * `idx` - Neighbour indices, row-major, `k` per node
601/// * `k` - Neighbours per node
602/// * `n` - Number of nodes
603/// * `similarity` - Which weight
604/// * `prune` - Edges below this weight are dropped
605/// * `rescue` - Reconnect small components afterwards
606///
607/// ### Returns
608///
609/// `(graph, number of rescue edges)`
610fn snn_full<T>(
611    idx: &[u32],
612    k: usize,
613    n: usize,
614    similarity: SnnSimilarity,
615    prune: T,
616    rescue: bool,
617) -> (SparseGraph<T>, usize)
618where
619    T: BixverseFloat + Send + Sync + Sum,
620{
621    // reverse index: for each node v, the (i, rank) with v in N+(i), filled
622    // in i order with i's own entry ahead of its neighbour entries
623    let mut rev_len = vec![1usize; n];
624    for &nb in idx {
625        rev_len[nb as usize] += 1;
626    }
627    let rev_ptr = offsets(&rev_len);
628    let mut rev = vec![(0u32, 0u32); rev_ptr[n]];
629    let mut cursor = rev_ptr.clone();
630    for i in 0..n {
631        rev[cursor[i]] = (i as u32, 0);
632        cursor[i] += 1;
633        for (r, &nb) in idx[i * k..(i + 1) * k].iter().enumerate() {
634            let nb = nb as usize;
635            rev[cursor[nb]] = (i as u32, r as u32 + 1);
636            cursor[nb] += 1;
637        }
638    }
639
640    // u32::MAX marks an untouched slot in both modes
641    let mut lower: Vec<Vec<(u32, T)>> = vec![Vec::new(); n];
642    lower
643        .par_chunks_mut(SNN_ROW_CHUNK)
644        .enumerate()
645        .for_each(|(c, chunk)| {
646            let mut scores = vec![u32::MAX; n];
647            let mut added: Vec<u32> = Vec::with_capacity(256);
648            for (off, out) in chunk.iter_mut().enumerate() {
649                let j = c * SNN_ROW_CHUNK + off;
650                added.clear();
651                for r in 0..=k {
652                    let v = if r == 0 {
653                        j
654                    } else {
655                        idx[j * k + r - 1] as usize
656                    };
657                    for &(o, o_rank) in &rev[rev_ptr[v]..rev_ptr[v + 1]] {
658                        if (o as usize) >= j {
659                            continue;
660                        }
661                        let s = &mut scores[o as usize];
662                        match similarity {
663                            SnnSimilarity::Jaccard => {
664                                if *s == u32::MAX {
665                                    *s = 1;
666                                    added.push(o);
667                                } else {
668                                    *s += 1;
669                                }
670                            }
671                            SnnSimilarity::Rank => {
672                                let comb = r as u32 + o_rank;
673                                if *s == u32::MAX {
674                                    *s = comb;
675                                    added.push(o);
676                                } else if comb < *s {
677                                    *s = comb;
678                                }
679                            }
680                        }
681                    }
682                }
683                added.sort_unstable();
684                out.extend(added.iter().filter_map(|&o| {
685                    let s = scores[o as usize];
686                    let w: T = match similarity {
687                        SnnSimilarity::Jaccard => snn_weight(s, 0, k, similarity),
688                        SnnSimilarity::Rank => snn_weight(0, s, k, similarity),
689                    };
690                    (w >= prune).then_some((o, w))
691                }));
692                for &o in &added {
693                    scores[o as usize] = u32::MAX;
694                }
695            }
696        });
697
698    let n_rescued = if rescue {
699        rescue_small_components(idx, k, n, similarity, &mut lower)
700    } else {
701        0
702    };
703
704    (lower_to_graph(&lower, n), n_rescued)
705}
706
707/// Mirror sorted lower-triangle lists into a symmetric CSR.
708///
709/// Row `r` holds its own lower list followed by the `j > r` that listed it,
710/// in ascending `j`, so every row comes out sorted.
711///
712/// ### Params
713///
714/// * `lower` - Per-node lists of `(smaller neighbour, weight)`, each sorted
715/// * `n` - Number of nodes
716///
717/// ### Returns
718///
719/// The undirected graph, rows sorted by column
720fn lower_to_graph<T>(lower: &[Vec<(u32, T)>], n: usize) -> SparseGraph<T>
721where
722    T: BixverseFloat + Send + Sync + Sum,
723{
724    let mut lens: Vec<usize> = lower.iter().map(|r| r.len()).collect();
725    for row in lower {
726        for &(i, _) in row {
727            lens[i as usize] += 1;
728        }
729    }
730    let ptr = offsets(&lens);
731    let mut indices = vec![0u32; ptr[n]];
732    let mut data = vec![T::zero(); ptr[n]];
733
734    // own lower entries first, in parallel
735    let mut cursor: Vec<usize> = (0..n).map(|r| ptr[r] + lower[r].len()).collect();
736    row_slices_mut(&mut indices, &ptr)
737        .into_par_iter()
738        .zip(row_slices_mut(&mut data, &ptr))
739        .zip(lower.par_iter())
740        .for_each(|((ix, dx), row)| {
741            for (e, &(c, v)) in row.iter().enumerate() {
742                ix[e] = c;
743                dx[e] = v;
744            }
745        });
746    // then the upper half, ascending j keeps each row sorted
747    for (j, row) in lower.iter().enumerate() {
748        for &(i, v) in row {
749            let p = &mut cursor[i as usize];
750            indices[*p] = j as u32;
751            data[*p] = v;
752            *p += 1;
753        }
754    }
755    csr_to_graph(ptr, indices, data, n)
756}
757
758//////////
759// UMAP //
760//////////
761
762/// Per-node UMAP bandwidth and local-connectivity distance.
763///
764/// Mirrors umap-learn's `smooth_knn_dist`, whose neighbour rows include the
765/// point itself at distance zero. Here self is excluded, so the target is
766/// `log2(k + 1)` and the means divide by `k + 1` to match.
767///
768/// ### Params
769///
770/// * `dist` - Distances, row-major, `k` per node, ascending
771/// * `k` - Neighbours per node
772/// * `n` - Number of nodes
773/// * `local_connectivity` - Neighbours assumed at distance zero
774///
775/// ### Returns
776///
777/// `(sigmas, rhos)`
778fn smooth_knn_dist<T>(dist: &[T], k: usize, n: usize, local_connectivity: T) -> (Vec<T>, Vec<T>)
779where
780    T: BixverseFloat + Send + Sync + Sum,
781{
782    let target: T = t::<T>((k + 1) as f64).log2();
783    let tol: T = t(SMOOTH_K_TOLERANCE);
784    let scale: T = t(MIN_K_DIST_SCALE);
785    let two: T = t(2.0);
786    let kp1: T = t((k + 1) as f64);
787    let mean_all: T = t(dist
788        .par_iter()
789        .map(|d| d.to_f64().unwrap_or(0.0))
790        .sum::<f64>()
791        / (n * (k + 1)) as f64);
792    let index = Float::floor(local_connectivity).to_usize().unwrap_or(0);
793    let interp: T = local_connectivity - t::<T>(index as f64);
794
795    (0..n)
796        .into_par_iter()
797        .map(|i| {
798            let row = &dist[i * k..(i + 1) * k];
799            let mut rho = T::zero();
800            let non_zero: Vec<T> = row.iter().copied().filter(|&d| d > T::zero()).collect();
801            if t::<T>(non_zero.len() as f64) >= local_connectivity {
802                if index > 0 {
803                    rho = non_zero[index - 1];
804                    if interp > tol {
805                        rho += interp * (non_zero[index] - non_zero[index - 1]);
806                    }
807                } else {
808                    rho = interp * non_zero[0];
809                }
810            } else if let Some(m) = non_zero
811                .iter()
812                .copied()
813                .reduce(|a, b| if a > b { a } else { b })
814            {
815                rho = m;
816            }
817
818            let (mut lo, mut hi, mut mid) = (T::zero(), T::max_value(), T::one());
819            for _ in 0..SMOOTH_K_ITERS {
820                let psum: T = row
821                    .iter()
822                    .map(|&d| {
823                        let d = d - rho;
824                        if d > T::zero() {
825                            (-(d / mid)).exp()
826                        } else {
827                            T::one()
828                        }
829                    })
830                    .sum();
831                if (psum - target).abs() < tol {
832                    break;
833                }
834                if psum > target {
835                    hi = mid;
836                    mid = (lo + hi) / two;
837                } else {
838                    lo = mid;
839                    if hi >= T::max_value() {
840                        mid *= two;
841                    } else {
842                        mid = (lo + hi) / two;
843                    }
844                }
845            }
846
847            let floor = if rho > T::zero() {
848                scale * row.iter().copied().sum::<T>() / kp1
849            } else {
850                scale * mean_all
851            };
852            (mid.max(floor), rho)
853        })
854        .unzip()
855}
856
857/// UMAP fuzzy simplicial set.
858///
859/// Membership `exp(-(d - rho) / sigma)`, or 1 within `rho`, then
860/// `mix * (A + A^T - A o A^T) + (1 - mix) * A o A^T`. With
861/// `local_connectivity >= 1` every node keeps a near-1 edge to its nearest
862/// neighbour, so there are no singletons.
863///
864/// ### Params
865///
866/// * `idx` - Neighbour indices, row-major, `k` per node
867/// * `dist` - Distances, same layout, ascending
868/// * `k` - Neighbours per node
869/// * `n` - Number of nodes
870/// * `local_connectivity` - Neighbours assumed at distance zero
871/// * `mix_ratio` - Union versus intersection
872///
873/// ### Returns
874///
875/// The undirected graph
876///
877/// ### References
878///
879/// McInnes, Healy & Melville, arXiv:1802.03426, 2018
880fn umap_graph<T>(
881    idx: &[u32],
882    dist: &[T],
883    k: usize,
884    n: usize,
885    local_connectivity: T,
886    mix_ratio: T,
887) -> SparseGraph<T>
888where
889    T: BixverseFloat + Send + Sync + Sum,
890{
891    let (sigmas, rhos) = smooth_knn_dist(dist, k, n, local_connectivity);
892    let mut w = vec![T::zero(); n * k];
893    if k > 0 {
894        w.par_chunks_mut(k).enumerate().for_each(|(i, row)| {
895            for (e, slot) in row.iter_mut().enumerate() {
896                let d = dist[i * k + e] - rhos[i];
897                *slot = if d <= T::zero() || sigmas[i] == T::zero() {
898                    T::one()
899                } else {
900                    (-(d / sigmas[i])).exp()
901                };
902            }
903        });
904    }
905    symmetrise(idx, &w, k, n, |a, b| {
906        let prod = a * b;
907        mix_ratio * (a + b - prod) + (T::one() - mix_ratio) * prod
908    })
909}
910
911//////////
912// Main //
913//////////
914
915/// Build an undirected weighted graph from a kNN.
916///
917/// ### Params
918///
919/// * `idx` - Neighbour indices, row-major, `k` per node, self excluded
920/// * `dist` - Optional distances, same layout, ascending per row. Required
921///   for [`KnnSymmetrisation::Umap`]
922/// * `k` - Neighbours per node
923/// * `n` - Number of nodes
924/// * `sym` - The [`KnnSymmetrisation`]
925/// * `verbose` - `0` silent, `1` timings, `2` detailed
926///
927/// ### Returns
928///
929/// The undirected graph with a symmetric, row-sorted CSR
930pub fn build_knn_graph<T>(
931    idx: &[u32],
932    dist: Option<&[T]>,
933    k: usize,
934    n: usize,
935    sym: &KnnSymmetrisation,
936    verbose: usize,
937) -> Result<SparseGraph<T>, MethodsErrors>
938where
939    T: BixverseFloat + Send + Sync + Sum,
940{
941    check_knn(idx, dist.map(|d| d.len()), k, n)?;
942    let verbosity = parse_verbosity_level(verbose);
943    let start = Instant::now();
944
945    let graph = match *sym {
946        KnnSymmetrisation::Knn { binary } => knn_union(idx, k, n, binary),
947        KnnSymmetrisation::Snn {
948            similarity,
949            prune,
950            rescue,
951        } => {
952            let (g, n_rescued) = snn_full(idx, k, n, similarity, t(prune), rescue);
953            if verbosity.normal_verbosity() && rescue {
954                println!("Added {n_rescued} edges to reconnect components that pruning cut off.");
955            }
956            g
957        }
958        KnnSymmetrisation::Umap {
959            local_connectivity,
960            mix_ratio,
961        } => {
962            let d = dist.ok_or(MethodsErrors::KnnGraphNeedsDistances)?;
963            umap_graph(idx, d, k, n, t(local_connectivity), t(mix_ratio))
964        }
965    };
966
967    if verbosity.normal_verbosity() {
968        println!("Built the {sym:?} graph: {:.2?}", start.elapsed());
969    }
970    Ok(graph)
971}
972
973/// Flatten a `Vec<Vec<usize>>` kNN into the row-major `u32` layout that
974/// [`build_knn_graph`] takes.
975///
976/// ### Params
977///
978/// * `knn` - Neighbour indices per node, all rows of length `k`
979///
980/// ### Returns
981///
982/// `(idx, k)`
983pub fn flatten_knn(knn: &[Vec<usize>]) -> (Vec<u32>, usize) {
984    let k = knn.first().map_or(0, |r| r.len());
985    let mut idx = Vec::with_capacity(knn.len() * k);
986    for row in knn {
987        idx.extend(row.iter().map(|&j| j as u32));
988    }
989    (idx, k)
990}
991
992///////////
993// Tests //
994///////////
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999    use approx::assert_relative_eq;
1000
1001    // 4 nodes, k = 2, row-major:
1002    //   0 -> [1, 2], 1 -> [0, 2], 2 -> [0, 1], 3 -> [0, 1]
1003    fn small_knn() -> (Vec<u32>, usize, usize) {
1004        (vec![1, 2, 0, 2, 0, 1, 0, 1], 2, 4)
1005    }
1006
1007    fn weight(g: &SparseGraph<f32>, a: usize, b: usize) -> Option<f32> {
1008        let (nb, w) = g.get_neighbours_raw(a);
1009        nb.iter().position(|&x| x as usize == b).map(|p| w[p])
1010    }
1011
1012    fn all_weights(g: &SparseGraph<f32>) -> Vec<f32> {
1013        (0..g.get_node_number())
1014            .flat_map(|i| g.get_neighbours_raw(i).1.to_vec())
1015            .collect()
1016    }
1017
1018    fn assert_symmetric_sorted(g: &SparseGraph<f32>) {
1019        for u in 0..g.get_node_number() {
1020            let (nb, w) = g.get_neighbours_raw(u);
1021            assert!(nb.windows(2).all(|p| p[0] < p[1]), "row {u} not sorted");
1022            for (&v, &wv) in nb.iter().zip(w) {
1023                assert_ne!(v as usize, u, "self loop at {u}");
1024                assert_eq!(weight(g, v as usize, u), Some(wv));
1025            }
1026        }
1027    }
1028
1029    /// Seeded random kNN with distinct neighbours, self excluded, and
1030    /// ascending distances.
1031    fn random_knn(n: usize, k: usize, seed: u64) -> (Vec<u32>, Vec<f32>) {
1032        let mut s = seed;
1033        let mut next = || {
1034            s ^= s << 13;
1035            s ^= s >> 7;
1036            s ^= s << 17;
1037            s
1038        };
1039        let mut idx = Vec::with_capacity(n * k);
1040        let mut dist = Vec::with_capacity(n * k);
1041        for i in 0..n {
1042            let mut row: Vec<u32> = Vec::with_capacity(k);
1043            while row.len() < k {
1044                // local neighbourhoods so the sNN is not empty
1045                let off = (next() % 40) as i64 - 20;
1046                let j = (i as i64 + off).rem_euclid(n as i64) as u32;
1047                if j as usize != i && !row.contains(&j) {
1048                    row.push(j);
1049                }
1050            }
1051            let mut d: Vec<f32> = (0..k)
1052                .map(|_| (next() % 1000) as f32 / 100.0 + 0.01)
1053                .collect();
1054            d.sort_by(f32::total_cmp);
1055            idx.extend(row);
1056            dist.extend(d);
1057        }
1058        (idx, dist)
1059    }
1060
1061    /// Mutual pairs weigh 2, one-sided pairs 1; binary flattens both to 1.
1062    #[test]
1063    fn test_knn_union_weights() {
1064        let (idx, k, n) = small_knn();
1065        let g = build_knn_graph(
1066            &idx,
1067            None,
1068            k,
1069            n,
1070            &KnnSymmetrisation::Knn { binary: false },
1071            0,
1072        )
1073        .unwrap();
1074        assert_symmetric_sorted(&g);
1075        assert_eq!(weight(&g, 0, 1), Some(2.0));
1076        assert_eq!(weight(&g, 0, 3), Some(1.0));
1077        assert_eq!(weight(&g, 2, 3), None);
1078        let gb = build_knn_graph(
1079            &idx,
1080            None,
1081            k,
1082            n,
1083            &KnnSymmetrisation::Knn { binary: true },
1084            0,
1085        )
1086        .unwrap();
1087        assert_eq!(weight(&gb, 0, 1), Some(1.0));
1088        assert_eq!(gb.get_node_degree(0), 3);
1089    }
1090
1091    /// Hand-checked Jaccard weights: (0,1) share {0,1,2}, (0,3) share {0,1}.
1092    #[test]
1093    fn test_snn_jaccard_known_weights() {
1094        let (idx, k, n) = small_knn();
1095        let sym = KnnSymmetrisation::Snn {
1096            similarity: SnnSimilarity::Jaccard,
1097            prune: 0.0,
1098            rescue: true,
1099        };
1100        let g = build_knn_graph::<f32>(&idx, None, k, n, &sym, 0).unwrap();
1101        assert_symmetric_sorted(&g);
1102        assert_eq!((0..n).map(|i| g.get_node_degree(i)).sum::<usize>(), 12);
1103        assert_relative_eq!(weight(&g, 0, 1).unwrap(), 1.0);
1104        assert_relative_eq!(weight(&g, 0, 3).unwrap(), 0.5);
1105        // (2,3): {0,1,2} vs {0,1,3} -> 2 / (6 - 2)
1106        assert_relative_eq!(weight(&g, 2, 3).unwrap(), 0.5);
1107    }
1108
1109    /// Hand-checked rank weights: (0,1) share 0 at ranks 0 + 1, so
1110    /// `(2 - 1/2) / 2`.
1111    #[test]
1112    fn test_snn_rank_known_weights() {
1113        let (idx, k, n) = small_knn();
1114        let sym = KnnSymmetrisation::Snn {
1115            similarity: SnnSimilarity::Rank,
1116            prune: 0.0,
1117            rescue: false,
1118        };
1119        let g = build_knn_graph::<f32>(&idx, None, k, n, &sym, 0).unwrap();
1120        assert_relative_eq!(weight(&g, 0, 1).unwrap(), 0.75);
1121        // (2,3): best shared is 0 at ranks 1 + 1 -> (2 - 1) / 2
1122        assert_relative_eq!(weight(&g, 2, 3).unwrap(), 0.5);
1123        assert!(all_weights(&g).iter().all(|&w| w > 0.0 && w <= 1.0));
1124    }
1125
1126    /// Pruning at 0.6 would isolate node 3; the rescue gives it back its
1127    /// strongest kNN edge, which ties at 0.5 and goes to the closer neighbour.
1128    #[test]
1129    fn test_snn_prune_and_rescue() {
1130        let (idx, k, n) = small_knn();
1131        let mk = |rescue| KnnSymmetrisation::Snn {
1132            similarity: SnnSimilarity::Jaccard,
1133            prune: 0.6,
1134            rescue,
1135        };
1136        let g = build_knn_graph::<f32>(&idx, None, k, n, &mk(true), 0).unwrap();
1137        assert_symmetric_sorted(&g);
1138        assert_eq!(weight(&g, 0, 3), Some(0.5));
1139        assert_eq!(g.get_node_degree(3), 1);
1140        let g0 = build_knn_graph::<f32>(&idx, None, k, n, &mk(false), 0).unwrap();
1141        assert_eq!(g0.get_node_degree(3), 0);
1142    }
1143
1144    /// The sNN weights equal the pairwise definition on a random kNN.
1145    #[test]
1146    fn test_snn_matches_pairwise() {
1147        let (n, k) = (300, 8);
1148        let (idx, _) = random_knn(n, k, 7);
1149        for similarity in [SnnSimilarity::Jaccard, SnnSimilarity::Rank] {
1150            let sym = KnnSymmetrisation::Snn {
1151                similarity,
1152                prune: 0.0,
1153                rescue: false,
1154            };
1155            let g = build_knn_graph::<f32>(&idx, None, k, n, &sym, 0).unwrap();
1156            assert_symmetric_sorted(&g);
1157            for u in 0..n {
1158                let (nb, w) = g.get_neighbours_raw(u);
1159                for (&v, &wv) in nb.iter().zip(w) {
1160                    assert_eq!(
1161                        wv,
1162                        pair_snn_weight::<f32>(&idx, k, u, v as usize, similarity)
1163                    );
1164                }
1165            }
1166        }
1167    }
1168
1169    /// Hand-checked UMAP memberships on two nodes pointing at each other:
1170    /// sigma is irrelevant at k = 1 because the only neighbour sits at rho.
1171    #[test]
1172    fn test_umap_two_nodes() {
1173        let idx = vec![1u32, 0];
1174        let dist = vec![0.5f32, 0.5];
1175        let sym = KnnSymmetrisation::Umap {
1176            local_connectivity: 1.0,
1177            mix_ratio: 1.0,
1178        };
1179        let g = build_knn_graph(&idx, Some(&dist), 1, 2, &sym, 0).unwrap();
1180        assert_eq!(weight(&g, 0, 1), Some(1.0));
1181    }
1182
1183    /// Every node keeps its nearest neighbour at weight 1, so nothing is
1184    /// isolated, and the union is symmetric and in (0, 1].
1185    #[test]
1186    fn test_umap_no_singletons() {
1187        let (n, k) = (500, 10);
1188        let (idx, dist) = random_knn(n, k, 11);
1189        let sym = KnnSymmetrisation::Umap {
1190            local_connectivity: 1.0,
1191            mix_ratio: 1.0,
1192        };
1193        let g = build_knn_graph(&idx, Some(&dist), k, n, &sym, 0).unwrap();
1194        assert_symmetric_sorted(&g);
1195        for i in 0..n {
1196            assert!(g.get_node_degree(i) >= 1);
1197            // a + b - ab rounds below 1 in f32, as in umap-learn
1198            assert_relative_eq!(
1199                weight(&g, i, idx[i * k] as usize).unwrap(),
1200                1.0,
1201                epsilon = 1e-6
1202            );
1203        }
1204        assert!(all_weights(&g).iter().all(|&w| w > 0.0 && w <= 1.0));
1205    }
1206
1207    /// The fuzzy union of a one-sided edge keeps its membership; the
1208    /// intersection (`mix_ratio = 0`) drops it.
1209    #[test]
1210    fn test_umap_mix_ratio() {
1211        let (idx, k, n) = small_knn();
1212        let dist = vec![1.0f32, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0];
1213        let mk = |mix_ratio| KnnSymmetrisation::Umap {
1214            local_connectivity: 1.0,
1215            mix_ratio,
1216        };
1217        let gu = build_knn_graph(&idx, Some(&dist), k, n, &mk(1.0), 0).unwrap();
1218        let gi = build_knn_graph(&idx, Some(&dist), k, n, &mk(0.0), 0).unwrap();
1219        // 3 -> 0 is one-sided
1220        assert_eq!(weight(&gu, 0, 3), Some(1.0));
1221        assert_eq!(weight(&gi, 0, 3), None);
1222        assert!(gi.get_node_degree(0) < gu.get_node_degree(0));
1223    }
1224
1225    /// Umap without distances, a short index and an out-of-range index are
1226    /// errors, not panics.
1227    #[test]
1228    fn test_build_knn_graph_errors() {
1229        let (idx, k, n) = small_knn();
1230        let umap = KnnSymmetrisation::Umap {
1231            local_connectivity: 1.0,
1232            mix_ratio: 1.0,
1233        };
1234        assert!(matches!(
1235            build_knn_graph::<f32>(&idx, None, k, n, &umap, 0),
1236            Err(MethodsErrors::KnnGraphNeedsDistances)
1237        ));
1238        assert!(matches!(
1239            build_knn_graph::<f32>(&idx[..7], None, k, n, &KnnSymmetrisation::default(), 0),
1240            Err(MethodsErrors::GraphLengthMismatch { .. })
1241        ));
1242        let mut bad = idx.clone();
1243        bad[0] = 9;
1244        assert!(matches!(
1245            build_knn_graph::<f32>(&bad, None, k, n, &KnnSymmetrisation::default(), 0),
1246            Err(MethodsErrors::GraphIndexOutOfRange { .. })
1247        ));
1248    }
1249
1250    /// The parser is case-insensitive and refuses names it does not know.
1251    #[test]
1252    fn test_parse_snn_similarity() {
1253        assert_eq!(
1254            parse_snn_similarity("JACCARD"),
1255            Some(SnnSimilarity::Jaccard)
1256        );
1257        assert_eq!(parse_snn_similarity("rank"), Some(SnnSimilarity::Rank));
1258        assert_eq!(parse_snn_similarity("nope"), None);
1259    }
1260}