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