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