Skip to main content

evoc_rs/
lib.rs

1//! EVoC - Embedding Vector Oriented Clustering
2//!
3//! Efficient clustering of high-dimensional embedding vectors (CLIP, sentence
4//! transformers, etc.) by combining a UMAP-like node embedding with
5//! HDBSCAN-style density-based clustering and multi-layer persistence
6//! analysis. This is the Rust version/port which allows for different
7//! approximate nearest neighbour search algorithms (for details, see
8//! [ann-search-rs](https://crates.io/crates/ann-search-rs)).
9//! This code is based on the original code from Leland McInnes, see the Python
10//! implementation: [evoc](https://github.com/TutteInstitute/evoc)
11
12#![allow(clippy::needless_range_loop)]
13#![warn(missing_docs)]
14
15pub mod clustering;
16pub mod errors;
17pub mod graph;
18pub mod nearest_neighbours;
19pub mod prelude;
20pub mod utils;
21
22use ann_search_rs::cpu::hnsw::{HnswIndex, HnswState};
23use ann_search_rs::cpu::nndescent::{NNDescent, NNDescentQuery};
24use ann_search_rs::prelude::AnnSearchFloat;
25use ann_search_rs::utils::nndescent_utils::ApplySortedUpdates;
26use std::time::Instant;
27
28#[cfg(feature = "gpu")]
29use cubecl_utils_rs::CubeclFloat;
30
31#[cfg(feature = "gpu")]
32use cubecl::prelude::*;
33
34use crate::clustering::condensed_tree::*;
35use crate::clustering::linkage::mst_to_linkage_tree;
36use crate::clustering::mst::build_mst;
37use crate::clustering::persistence::build_cluster_layers;
38use crate::graph::embedding::*;
39use crate::graph::fuzzy_graph::*;
40use crate::graph::label_prop::label_propagation_init;
41use crate::nearest_neighbours::nearest_neighbour_cpu::*;
42use crate::prelude::*;
43
44#[cfg(feature = "gpu")]
45use crate::nearest_neighbours::nearest_neighbour_gpu::*;
46
47/// Version of this crate.
48///
49/// Exposed so a dependent can report which version of the numerics it was
50/// built against. The Python bindings version independently of this crate and
51/// vendor its source through a path dependency, so their own version number
52/// says nothing about what is inside the wheel; this does.
53pub const VERSION: &str = env!("CARGO_PKG_VERSION");
54
55////////////
56// Params //
57////////////
58
59/// Parameters for EVoC clustering.
60#[derive(Clone, Debug)]
61pub struct EvocParams<T> {
62    /// Number of nearest neighbours for graph construction.
63    pub n_neighbours: usize,
64    /// Noise level for the embedding gradient (0.0 = aggressive, 1.0 =
65    /// conservative).
66    pub noise_level: T,
67    /// Number of embedding optimisation epochs.
68    pub n_epochs: usize,
69    /// Embedding dimensionality. If `None`, defaults to
70    /// `min(max(n_neighbours / 4, 4), 16)`.
71    pub embedding_dim: Option<usize>,
72    /// Multiplier on effective neighbours for fuzzy graph construction.
73    pub neighbour_scale: T,
74    /// Whether to symmetrise the fuzzy graph.
75    pub symmetrise: bool,
76    /// Minimum samples for core distance in MST density estimation.
77    pub min_samples: usize,
78    /// Base minimum cluster size for the finest layer.
79    pub base_min_cluster_size: usize,
80    /// If set, binary-search for approximately this many clusters (single layer
81    /// output).
82    pub approx_n_clusters: Option<usize>,
83    /// Jaccard similarity threshold for filtering redundant layers.
84    pub min_similarity_threshold: f64,
85    /// Maximum number of cluster layers to return.
86    pub max_layers: usize,
87}
88
89/// Default implementation
90impl<T: EvocFloat> Default for EvocParams<T> {
91    fn default() -> Self {
92        Self {
93            n_neighbours: 15,
94            noise_level: T::from(0.5).unwrap(),
95            n_epochs: 50,
96            embedding_dim: None,
97            neighbour_scale: T::one(),
98            symmetrise: true,
99            min_samples: 5,
100            base_min_cluster_size: 5,
101            approx_n_clusters: None,
102            min_similarity_threshold: 0.2,
103            max_layers: 10,
104        }
105    }
106}
107
108/////////////
109// Results //
110/////////////
111
112/// Result of EVoC clustering.
113pub struct EvocResult<T> {
114    /// Cluster labels per layer, sorted finest (most clusters) first.
115    /// -1 indicates noise.
116    pub cluster_layers: Vec<Vec<i64>>,
117    /// Membership strengths per layer, in [0, 1].
118    pub membership_strengths: Vec<Vec<T>>,
119    /// Persistence score per layer (higher = more stable).
120    pub persistence_scores: Vec<f64>,
121    /// k-NN indices (excluding self).
122    pub nn_indices: Vec<Vec<usize>>,
123    /// k-NN distances (excluding self).
124    pub nn_distances: Vec<Vec<T>>,
125}
126
127impl<T: EvocFloat> EvocResult<T> {
128    /// Returns labels from the layer with the highest persistence score.
129    ///
130    /// Falls back to the only available layer when there is just one.
131    pub fn best_labels(&self) -> &[i64] {
132        if self.cluster_layers.len() <= 1 {
133            &self.cluster_layers[0]
134        } else {
135            let best = self
136                .persistence_scores
137                .iter()
138                .enumerate()
139                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
140                .map(|(i, _)| i)
141                .unwrap_or(0);
142            &self.cluster_layers[best]
143        }
144    }
145
146    /// Returns membership strengths corresponding to
147    /// [`EvocResult::best_labels`].
148    pub fn best_strengths(&self) -> &[T] {
149        if self.membership_strengths.len() <= 1 {
150            &self.membership_strengths[0]
151        } else {
152            let best = self
153                .persistence_scores
154                .iter()
155                .enumerate()
156                .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
157                .map(|(i, _)| i)
158                .unwrap_or(0);
159            &self.membership_strengths[best]
160        }
161    }
162
163    /// Number of clusters in the best layer, excluding noise points.
164    pub fn n_clusters(&self) -> usize {
165        let labels = self.best_labels();
166        (labels.iter().copied().reduce(i64::max).unwrap_or(-1) + 1).max(0) as usize
167    }
168}
169
170//////////
171// Main //
172//////////
173
174/// Run EVoC clustering on high-dimensional embedding data.
175///
176/// 1. **kNN graph** — builds an approximate nearest-neighbour graph via the
177///    selected ANN backend (`ann_type`), or uses `precomputed_knn` if
178///    provided.
179/// 2. **Fuzzy simplicial set** — smooths and optionally symmetrises the kNN
180///    graph into a weighted undirected graph.
181/// 3. **Node embedding** — optimises a low-dimensional layout using the EVoC
182///    gradient (a modified UMAP repulsion term controlled by `noise_level`).
183/// 4. **MST** — builds a minimum spanning tree over the embedding using
184///    mutual reachability distances.
185/// 5. **Cluster layers** — extracts a hierarchy of clusterings from the MST
186///    via persistence analysis, or searches for a fixed number of clusters if
187///    [`EvocParams::approx_n_clusters`] is set.
188///
189/// ### Params
190///
191/// * `data` — input data as samples x features. Accepts a faer matrix, an
192///   ndarray 2-D array (with the `ndarray` feature) or a row-major
193///   `(&[T], n_samples, n_features)` tuple. See [`EvocMatrix`].
194/// * `ann_type` — ANN backend identifier (e.g. `"nndescent"`, `"hnsw"`).
195/// * `precomputed_knn` — pre-built `(indices, distances)` pair; pass `None`
196///   to build the graph from `data`.
197/// * `evoc_params` — clustering hyperparameters; see [`EvocParams`].
198/// * `nn_params` — hyperparameters forwarded to the ANN backend, see
199///   [`NearestNeighbourParamsEvoc`]
200/// * `seed` — random seed for reproducibility.
201/// * `verbose` - If `0` -> silent or `1` for normal verbosity, `2` for detailed
202///   verbosity.
203///
204/// ### Returns
205///
206/// An [`EvocResult`] containing cluster layers, membership strengths,
207/// persistence scores, and the kNN graph.
208pub fn evoc<T>(
209    data: impl EvocMatrix<T>,
210    ann_type: String,
211    precomputed_knn: PreComputedKnn<T>,
212    evoc_params: &EvocParams<T>,
213    nn_params: &NearestNeighbourParamsEvoc<T>,
214    seed: usize,
215    verbose: usize,
216) -> Result<EvocResult<T>, EvocErrors>
217where
218    T: EvocFloat + AnnSearchFloat,
219    NNDescent<T>: ApplySortedUpdates<T> + NNDescentQuery<T>,
220    HnswIndex<T>: HnswState<T>,
221{
222    let data_input = data.to_mat_input();
223    let data = data_input.as_mat_ref();
224    let verbosity = parse_verbosity_level(verbose);
225
226    let start_all = Instant::now();
227
228    // 1. kNN graph
229    let (knn_indices, knn_dist) = match precomputed_knn {
230        Some((indices, distances)) => {
231            if verbosity.normal_verbosity() {
232                println!("Using precomputed kNN graph...");
233            }
234            (indices, distances)
235        }
236        None => {
237            if verbosity.normal_verbosity() {
238                println!(
239                    "Running approximate nearest neighbour search using {}...",
240                    ann_type
241                );
242            }
243            let start_knn = Instant::now();
244            let result = run_ann_search(
245                data,
246                evoc_params.n_neighbours,
247                ann_type,
248                nn_params,
249                seed,
250                verbose,
251            )?;
252            if verbosity.normal_verbosity() {
253                println!("kNN search done in {:.2?}.", start_knn.elapsed());
254            }
255            result
256        }
257    };
258
259    // 2. Fuzzy simplicial set
260    if verbosity.normal_verbosity() {
261        println!("Constructing fuzzy simplicial set...");
262    }
263    let start_graph = Instant::now();
264    let effective_k = evoc_params.neighbour_scale * T::from(evoc_params.n_neighbours).unwrap();
265    let graph =
266        build_fuzzy_simplicial_set(&knn_indices, &knn_dist, effective_k, evoc_params.symmetrise);
267    let adj = coo_to_adjacency_list(&graph);
268    if verbosity.normal_verbosity() {
269        println!(
270            "... fuzzy simplicial set done in {:.2?}.",
271            start_graph.elapsed()
272        );
273    }
274
275    // 3. Embedding dimensionality
276    // original code did 4 to 15 -> went up one for SIMD
277    let dim = evoc_params
278        .embedding_dim
279        .unwrap_or_else(|| (evoc_params.n_neighbours / 4).clamp(4, 16));
280
281    // 4. Label propagation initialisation
282    let start_init = Instant::now();
283    let n = data.nrows();
284    let d = data.ncols();
285    let data_vecs: Vec<Vec<T>> = (0..n)
286        .map(|i| (0..d).map(|j| data[(i, j)]).collect())
287        .collect();
288
289    if verbosity.normal_verbosity() {
290        println!("Computing label propagation initialisation...");
291    }
292    let initial_embedding =
293        label_propagation_init(&graph, dim, Some(&data_vecs), seed as u64, verbose);
294    if verbosity.normal_verbosity() {
295        println!("Label prop init done in {:.2?}.", start_init.elapsed());
296    }
297
298    // 5. Node embedding
299    if verbosity.normal_verbosity() {
300        println!(
301            "Computing {}-d node embedding ({} epochs)...",
302            dim, evoc_params.n_epochs
303        );
304    }
305    let start_embed = Instant::now();
306    let embed_params = EvocEmbeddingParams {
307        n_epochs: evoc_params.n_epochs,
308        noise_level: evoc_params.noise_level,
309        initial_alpha: T::from(0.1).unwrap(),
310        ..EvocEmbeddingParams::default()
311    };
312
313    let embedding = evoc_embedding(
314        &adj,
315        dim,
316        &embed_params,
317        Some(&initial_embedding),
318        seed as u64,
319        verbose,
320    );
321    if verbosity.normal_verbosity() {
322        println!(" ... embedding done in {:.2?}.", start_embed.elapsed());
323    }
324
325    // 6. Clustering
326    if verbosity.normal_verbosity() {
327        println!("Running density-based clustering...");
328    }
329    let start_cluster = Instant::now();
330
331    let (cluster_layers, membership_strengths, persistence_scores) =
332        if let Some(target_k) = evoc_params.approx_n_clusters {
333            let (labels, strengths) =
334                search_for_n_clusters(&embedding, evoc_params.min_samples, target_k);
335            (vec![labels], vec![strengths], vec![0.0])
336        } else {
337            build_cluster_layers(
338                &embedding,
339                evoc_params.min_samples,
340                evoc_params.base_min_cluster_size,
341                evoc_params.min_similarity_threshold,
342                evoc_params.max_layers,
343            )
344        };
345
346    if verbosity.normal_verbosity() {
347        let n_layers = cluster_layers.len();
348        println!(
349            "Clustering done in {:.2?}: {} layer(s).",
350            start_cluster.elapsed(),
351            n_layers,
352        );
353        println!("EVoC total: {:.2?}.", start_all.elapsed());
354    }
355
356    Ok(EvocResult {
357        cluster_layers,
358        membership_strengths,
359        persistence_scores,
360        nn_indices: knn_indices,
361        nn_distances: knn_dist,
362    })
363}
364
365/// Binary-searches over `min_cluster_size` to find approximately `target_k`
366/// clusters.
367///
368/// Builds the MST and linkage tree once from `embedding`, then re-condenses
369/// the tree at different `min_cluster_size` thresholds until the number of
370/// extracted leaves is close to `target_k`.
371///
372/// When the search converges, both boundary values (`lo` and `hi`) are
373/// evaluated and the one whose cluster count is closer to `target_k` is
374/// returned. Ties are broken in favour of whichever boundary assigns more
375/// points to non-noise clusters.
376///
377/// ### Params
378///
379/// * `embedding` — low-dimensional node positions, one `Vec<T>` per point.
380/// * `min_samples` — passed to [`build_mst`] for core-distance estimation.
381/// * `target_k` — desired number of clusters.
382///
383/// ### Returns
384///
385/// A `(labels, membership_strengths)` pair for the selected clustering.
386/// Labels follow the same convention as [`EvocResult::cluster_layers`]:
387/// `-1` is noise, non-negative integers are cluster IDs.
388pub fn search_for_n_clusters<T>(
389    embedding: &[Vec<T>],
390    min_samples: usize,
391    target_k: usize,
392) -> (Vec<i64>, Vec<T>)
393where
394    T: EvocFloat,
395{
396    let n = embedding.len();
397    if n == 0 {
398        return (Vec::new(), Vec::new());
399    }
400
401    let mut mst = build_mst(embedding, min_samples);
402    let linkage = mst_to_linkage_tree(&mut mst, n);
403
404    let mut lo = 2usize;
405    let mut hi = n / 2;
406
407    while hi - lo > 1 {
408        let mid = (lo + hi) / 2;
409        if mid == lo || mid == hi {
410            break;
411        }
412
413        let ct_mid = condense_tree(&linkage, n, mid);
414        let leaves_mid = extract_leaves(&ct_mid);
415        let mid_k = leaves_mid.len();
416
417        if mid_k < target_k {
418            // Need more clusters -> smaller min_cluster_size
419            hi = mid;
420        } else {
421            // Have enough or too many -> larger min_cluster_size
422            lo = mid;
423        }
424    }
425
426    // Pick whichever bound is closer to target
427    let ct_lo = condense_tree(&linkage, n, lo);
428    let leaves_lo = extract_leaves(&ct_lo);
429    let labels_lo = get_cluster_label_vector(&ct_lo, &leaves_lo, n);
430    let lo_k = leaves_lo.len();
431
432    let ct_hi = condense_tree(&linkage, n, hi);
433    let leaves_hi = extract_leaves(&ct_hi);
434    let labels_hi = get_cluster_label_vector(&ct_hi, &leaves_hi, n);
435    let hi_k = leaves_hi.len();
436
437    let lo_diff = (lo_k as isize - target_k as isize).unsigned_abs();
438    let hi_diff = (hi_k as isize - target_k as isize).unsigned_abs();
439
440    if lo_diff < hi_diff {
441        let strengths = get_point_membership_strengths(&ct_lo, &leaves_lo, &labels_lo);
442        (labels_lo, strengths)
443    } else if hi_diff < lo_diff {
444        let strengths = get_point_membership_strengths(&ct_hi, &leaves_hi, &labels_hi);
445        (labels_hi, strengths)
446    } else {
447        // Tie: prefer whichever has more non-noise points (matches Python)
448        let lo_assigned = labels_lo.iter().filter(|&&l| l >= 0).count();
449        let hi_assigned = labels_hi.iter().filter(|&&l| l >= 0).count();
450        if lo_assigned >= hi_assigned {
451            let strengths = get_point_membership_strengths(&ct_lo, &leaves_lo, &labels_lo);
452            (labels_lo, strengths)
453        } else {
454            let strengths = get_point_membership_strengths(&ct_hi, &leaves_hi, &labels_hi);
455            (labels_hi, strengths)
456        }
457    }
458}
459
460/////////////////
461// GPU version //
462/////////////////
463
464/// Run EVoC clustering with GPU-accelerated kNN search.
465///
466/// Identical to [`evoc`] except the kNN graph is constructed on the GPU via
467/// `manifolds-rs`'s GPU ANN backends. Fuzzy graph construction, node
468/// embedding, MST and persistence analysis all remain on the CPU.
469///
470/// ### Params
471///
472/// * `data` — input data as samples x features. Accepts a faer matrix, an
473///   ndarray 2-D array (with the `ndarray` feature) or a row-major
474///   `(&[T], n_samples, n_features)` tuple. See [`EvocMatrix`].
475/// * `ann_type` — GPU ANN backend: `"exhaustive_gpu"`, `"ivf_gpu"` or
476///   `"nndescent_gpu"`.
477/// * `precomputed_knn` — pre-built `(indices, distances)` pair; pass `None`
478///   to build the graph on the GPU.
479/// * `evoc_params` — clustering hyperparameters; see [`EvocParams`].
480/// * `nn_params` — GPU nearest-neighbour search parameters, see
481///   [`NearestNeighbourParamsGpuEvoc`].
482/// * `device` — GPU device.
483/// * `seed` — random seed for reproducibility.
484/// * `verbose` - If `0` -> silent or `1` for normal verbosity, `2` for detailed
485///   verbosity.
486///
487/// ### Returns
488///
489/// An [`EvocResult`] containing cluster layers, membership strengths,
490/// persistence scores, and the kNN graph.
491#[allow(clippy::too_many_arguments)]
492#[cfg(feature = "gpu")]
493pub fn evoc_gpu<T, R>(
494    data: impl EvocMatrix<T>,
495    ann_type: String,
496    precomputed_knn: PreComputedKnn<T>,
497    evoc_params: &EvocParams<T>,
498    nn_params: &NearestNeighbourParamsGpuEvoc<T>,
499    device: R::Device,
500    seed: usize,
501    verbose: usize,
502) -> Result<EvocResult<T>, EvocErrors>
503where
504    T: EvocFloat + AnnSearchFloat + CubeclFloat,
505    R: Runtime,
506{
507    let data_input = data.to_mat_input();
508    let data = data_input.as_mat_ref();
509    let start_all = Instant::now();
510    let verbosity = parse_verbosity_level(verbose);
511
512    // 1. kNN graph (GPU)
513    let (knn_indices, knn_dist) = match precomputed_knn {
514        Some((indices, distances)) => {
515            if verbosity.normal_verbosity() {
516                println!("Using precomputed kNN graph...");
517            }
518            (indices, distances)
519        }
520        None => {
521            // For the nndescent_gpu path, the CAGRA graph degree (`params.k`)
522            // and NNDescent build degree (`params.k_build`) are independent
523            // of the query k. Left at their crate defaults (30 and ~45) they
524            // sit below `n_neighbours` whenever `n_neighbours > 30`, so the
525            // beam search walks a graph too small to serve the query well.
526            // Backfill from `n_neighbours` here when the user hasn't set them.
527            let k = evoc_params.n_neighbours;
528            let scaled_params: NearestNeighbourParamsGpuEvoc<T>;
529            let nn_params = if nn_params.k.is_none() || nn_params.k_build.is_none() {
530                scaled_params = NearestNeighbourParamsGpuEvoc {
531                    k: nn_params.k.or(Some(k)),
532                    k_build: nn_params.k_build.or(Some(2 * k)),
533                    ..nn_params.clone()
534                };
535                &scaled_params
536            } else {
537                nn_params
538            };
539
540            if verbosity.normal_verbosity() {
541                println!("Running GPU nearest neighbour search using {}...", ann_type);
542            }
543            let start_knn = Instant::now();
544            let result = run_ann_search_gpu::<T, R>(
545                data,
546                evoc_params.n_neighbours,
547                ann_type,
548                nn_params,
549                device,
550                seed,
551                verbose,
552            )?;
553            if verbosity.normal_verbosity() {
554                println!("GPU kNN search done in {:.2?}.", start_knn.elapsed());
555            }
556            result
557        }
558    };
559
560    // 2. Fuzzy simplicial set
561    if verbosity.normal_verbosity() {
562        println!("Constructing fuzzy simplicial set...");
563    }
564    let start_graph = Instant::now();
565    let effective_k = evoc_params.neighbour_scale * T::from(evoc_params.n_neighbours).unwrap();
566    let graph =
567        build_fuzzy_simplicial_set(&knn_indices, &knn_dist, effective_k, evoc_params.symmetrise);
568    let adj = coo_to_adjacency_list(&graph);
569    if verbosity.normal_verbosity() {
570        println!(
571            "... fuzzy simplicial set done in {:.2?}.",
572            start_graph.elapsed()
573        );
574    }
575
576    // 3. Embedding dimensionality
577    let dim = evoc_params
578        .embedding_dim
579        .unwrap_or_else(|| (evoc_params.n_neighbours / 4).clamp(4, 16));
580
581    // 4. Label propagation initialisation
582    let start_init = Instant::now();
583    let n = data.nrows();
584    let d = data.ncols();
585    let data_vecs: Vec<Vec<T>> = (0..n)
586        .map(|i| (0..d).map(|j| data[(i, j)]).collect())
587        .collect();
588
589    if verbosity.normal_verbosity() {
590        println!("Computing label propagation initialisation...");
591    }
592    let initial_embedding = crate::graph::label_prop::label_propagation_init(
593        &graph,
594        dim,
595        Some(&data_vecs),
596        seed as u64,
597        verbose,
598    );
599    if verbosity.normal_verbosity() {
600        println!(" ... label prop init done in {:.2?}.", start_init.elapsed());
601    }
602
603    // 5. Node embedding
604    if verbosity.normal_verbosity() {
605        println!(
606            "Computing {}-d node embedding ({} epochs)...",
607            dim, evoc_params.n_epochs
608        );
609    }
610    let start_embed = Instant::now();
611    let embed_params = EvocEmbeddingParams {
612        n_epochs: evoc_params.n_epochs,
613        noise_level: evoc_params.noise_level,
614        initial_alpha: T::from(0.1).unwrap(),
615        ..EvocEmbeddingParams::default()
616    };
617
618    let embedding = evoc_embedding(
619        &adj,
620        dim,
621        &embed_params,
622        Some(&initial_embedding),
623        seed as u64,
624        verbose,
625    );
626    if verbosity.normal_verbosity() {
627        println!(" ... embedding done in {:.2?}.", start_embed.elapsed());
628    }
629
630    // 6. Clustering
631    if verbosity.normal_verbosity() {
632        println!("Running density-based clustering...");
633    }
634    let start_cluster = Instant::now();
635
636    let (cluster_layers, membership_strengths, persistence_scores) =
637        if let Some(target_k) = evoc_params.approx_n_clusters {
638            let (labels, strengths) =
639                search_for_n_clusters(&embedding, evoc_params.min_samples, target_k);
640            (vec![labels], vec![strengths], vec![0.0])
641        } else {
642            build_cluster_layers(
643                &embedding,
644                evoc_params.min_samples,
645                evoc_params.base_min_cluster_size,
646                evoc_params.min_similarity_threshold,
647                evoc_params.max_layers,
648            )
649        };
650
651    if verbosity.normal_verbosity() {
652        let n_layers = cluster_layers.len();
653        println!(
654            "Clustering done in {:.2?}: {} layer(s).",
655            start_cluster.elapsed(),
656            n_layers,
657        );
658        println!("EVoC (GPU) total: {:.2?}.", start_all.elapsed());
659    }
660
661    Ok(EvocResult {
662        cluster_layers,
663        membership_strengths,
664        persistence_scores,
665        nn_indices: knn_indices,
666        nn_distances: knn_dist,
667    })
668}