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 prelude;
19pub mod utils;
20
21use ann_search_rs::cpu::hnsw::{HnswIndex, HnswState};
22use ann_search_rs::cpu::nndescent::{ApplySortedUpdates, NNDescent, NNDescentQuery};
23use ann_search_rs::prelude::AnnSearchFloat;
24use faer::MatRef;
25use manifolds_rs::PreComputedKnn;
26use manifolds_rs::data::nearest_neighbours::*;
27use std::time::Instant;
28
29#[cfg(feature = "gpu")]
30use ann_search_rs::gpu::nndescent_gpu::NNDescentGpu;
31#[cfg(feature = "gpu")]
32use ann_search_rs::gpu::traits_gpu::AnnSearchGpuFloat;
33#[cfg(feature = "gpu")]
34use cubecl::prelude::*;
35#[cfg(feature = "gpu")]
36use manifolds_rs::data::nearest_neighbours_gpu::*;
37
38use crate::clustering::condensed_tree::*;
39use crate::clustering::linkage::mst_to_linkage_tree;
40use crate::clustering::mst::build_mst;
41use crate::clustering::persistence::build_cluster_layers;
42use crate::graph::embedding::*;
43use crate::graph::fuzzy_graph::*;
44use crate::graph::label_prop::label_propagation_init;
45use crate::prelude::*;
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.
189/// * `seed` — random seed for reproducibility.
190/// * `verbose` - If `0` -> silent or `1` for normal verbosity, `2` for detailed
191///   verbosity.
192///
193/// ### Returns
194///
195/// An [`EvocResult`] containing cluster layers, membership strengths,
196/// persistence scores, and the kNN graph.
197pub fn evoc<T>(
198    data: MatRef<T>,
199    ann_type: String,
200    precomputed_knn: PreComputedKnn<T>,
201    evoc_params: &EvocParams<T>,
202    nn_params: &NearestNeighbourParams<T>,
203    seed: usize,
204    verbose: usize,
205) -> Result<EvocResult<T>, EvocErrors>
206where
207    T: EvocFloat + AnnSearchFloat,
208    NNDescent<T>: ApplySortedUpdates<T> + NNDescentQuery<T>,
209    HnswIndex<T>: HnswState<T>,
210{
211    let verbosity = parse_verbosity_level(verbose);
212
213    let start_all = Instant::now();
214
215    // 1. kNN graph
216    let (knn_indices, knn_dist) = match precomputed_knn {
217        Some((indices, distances)) => {
218            if verbosity.normal_verbosity() {
219                println!("Using precomputed kNN graph...");
220            }
221            (indices, distances)
222        }
223        None => {
224            if verbosity.normal_verbosity() {
225                println!(
226                    "Running approximate nearest neighbour search using {}...",
227                    ann_type
228                );
229            }
230            let start_knn = Instant::now();
231            let result = run_ann_search(
232                data,
233                evoc_params.n_neighbours,
234                ann_type,
235                nn_params,
236                seed,
237                verbose,
238            )?;
239            if verbosity.normal_verbosity() {
240                println!("kNN search done in {:.2?}.", start_knn.elapsed());
241            }
242            result
243        }
244    };
245
246    // 2. Fuzzy simplicial set
247    if verbosity.normal_verbosity() {
248        println!("Constructing fuzzy simplicial set...");
249    }
250    let start_graph = Instant::now();
251    let effective_k = evoc_params.neighbour_scale * T::from(evoc_params.n_neighbours).unwrap();
252    let graph =
253        build_fuzzy_simplicial_set(&knn_indices, &knn_dist, effective_k, evoc_params.symmetrise);
254    let adj = coo_to_adjacency_list(&graph);
255    if verbosity.normal_verbosity() {
256        println!(
257            "... fuzzy simplicial set done in {:.2?}.",
258            start_graph.elapsed()
259        );
260    }
261
262    // 3. Embedding dimensionality
263    // original code did 4 to 15 -> went up one for SIMD
264    let dim = evoc_params
265        .embedding_dim
266        .unwrap_or_else(|| (evoc_params.n_neighbours / 4).clamp(4, 16));
267
268    // 4. Label propagation initialisation
269    let start_init = Instant::now();
270    let n = data.nrows();
271    let d = data.ncols();
272    let data_vecs: Vec<Vec<T>> = (0..n)
273        .map(|i| (0..d).map(|j| data[(i, j)]).collect())
274        .collect();
275
276    if verbosity.normal_verbosity() {
277        println!("Computing label propagation initialisation...");
278    }
279    let initial_embedding =
280        label_propagation_init(&graph, dim, Some(&data_vecs), seed as u64, verbose);
281    if verbosity.normal_verbosity() {
282        println!("Label prop init done in {:.2?}.", start_init.elapsed());
283    }
284
285    // 5. Node embedding
286    if verbosity.normal_verbosity() {
287        println!(
288            "Computing {}-d node embedding ({} epochs)...",
289            dim, evoc_params.n_epochs
290        );
291    }
292    let start_embed = Instant::now();
293    let embed_params = EvocEmbeddingParams {
294        n_epochs: evoc_params.n_epochs,
295        noise_level: evoc_params.noise_level,
296        initial_alpha: T::from(0.1).unwrap(),
297        ..EvocEmbeddingParams::default()
298    };
299
300    let embedding = evoc_embedding(
301        &adj,
302        dim,
303        &embed_params,
304        Some(&initial_embedding),
305        seed as u64,
306        verbose,
307    );
308    if verbosity.normal_verbosity() {
309        println!(" ... embedding done in {:.2?}.", start_embed.elapsed());
310    }
311
312    // 6. Clustering
313    if verbosity.normal_verbosity() {
314        println!("Running density-based clustering...");
315    }
316    let start_cluster = Instant::now();
317
318    let (cluster_layers, membership_strengths, persistence_scores) =
319        if let Some(target_k) = evoc_params.approx_n_clusters {
320            let (labels, strengths) =
321                search_for_n_clusters(&embedding, evoc_params.min_samples, target_k);
322            (vec![labels], vec![strengths], vec![0.0])
323        } else {
324            build_cluster_layers(
325                &embedding,
326                evoc_params.min_samples,
327                evoc_params.base_min_cluster_size,
328                evoc_params.min_similarity_threshold,
329                evoc_params.max_layers,
330            )
331        };
332
333    if verbosity.normal_verbosity() {
334        let n_layers = cluster_layers.len();
335        println!(
336            "Clustering done in {:.2?}: {} layer(s).",
337            start_cluster.elapsed(),
338            n_layers,
339        );
340        println!("EVoC total: {:.2?}.", start_all.elapsed());
341    }
342
343    Ok(EvocResult {
344        cluster_layers,
345        membership_strengths,
346        persistence_scores,
347        nn_indices: knn_indices,
348        nn_distances: knn_dist,
349    })
350}
351
352/// Binary-searches over `min_cluster_size` to find approximately `target_k`
353/// clusters.
354///
355/// Builds the MST and linkage tree once from `embedding`, then re-condenses
356/// the tree at different `min_cluster_size` thresholds until the number of
357/// extracted leaves is close to `target_k`.
358///
359/// When the search converges, both boundary values (`lo` and `hi`) are
360/// evaluated and the one whose cluster count is closer to `target_k` is
361/// returned. Ties are broken in favour of whichever boundary assigns more
362/// points to non-noise clusters.
363///
364/// ### Params
365///
366/// * `embedding` — low-dimensional node positions, one `Vec<T>` per point.
367/// * `min_samples` — passed to [`build_mst`] for core-distance estimation.
368/// * `target_k` — desired number of clusters.
369///
370/// ### Returns
371///
372/// A `(labels, membership_strengths)` pair for the selected clustering.
373/// Labels follow the same convention as [`EvocResult::cluster_layers`]:
374/// `-1` is noise, non-negative integers are cluster IDs.
375pub fn search_for_n_clusters<T>(
376    embedding: &[Vec<T>],
377    min_samples: usize,
378    target_k: usize,
379) -> (Vec<i64>, Vec<T>)
380where
381    T: EvocFloat,
382{
383    let n = embedding.len();
384    if n == 0 {
385        return (Vec::new(), Vec::new());
386    }
387
388    let mut mst = build_mst(embedding, min_samples);
389    let linkage = mst_to_linkage_tree(&mut mst, n);
390
391    let mut lo = 2usize;
392    let mut hi = n / 2;
393
394    while hi - lo > 1 {
395        let mid = (lo + hi) / 2;
396        if mid == lo || mid == hi {
397            break;
398        }
399
400        let ct_mid = condense_tree(&linkage, n, mid);
401        let leaves_mid = extract_leaves(&ct_mid);
402        let mid_k = leaves_mid.len();
403
404        if mid_k < target_k {
405            // Need more clusters -> smaller min_cluster_size
406            hi = mid;
407        } else {
408            // Have enough or too many -> larger min_cluster_size
409            lo = mid;
410        }
411    }
412
413    // Pick whichever bound is closer to target
414    let ct_lo = condense_tree(&linkage, n, lo);
415    let leaves_lo = extract_leaves(&ct_lo);
416    let labels_lo = get_cluster_label_vector(&ct_lo, &leaves_lo, n);
417    let lo_k = leaves_lo.len();
418
419    let ct_hi = condense_tree(&linkage, n, hi);
420    let leaves_hi = extract_leaves(&ct_hi);
421    let labels_hi = get_cluster_label_vector(&ct_hi, &leaves_hi, n);
422    let hi_k = leaves_hi.len();
423
424    let lo_diff = (lo_k as isize - target_k as isize).unsigned_abs();
425    let hi_diff = (hi_k as isize - target_k as isize).unsigned_abs();
426
427    if lo_diff < hi_diff {
428        let strengths = get_point_membership_strengths(&ct_lo, &leaves_lo, &labels_lo);
429        (labels_lo, strengths)
430    } else if hi_diff < lo_diff {
431        let strengths = get_point_membership_strengths(&ct_hi, &leaves_hi, &labels_hi);
432        (labels_hi, strengths)
433    } else {
434        // Tie: prefer whichever has more non-noise points (matches Python)
435        let lo_assigned = labels_lo.iter().filter(|&&l| l >= 0).count();
436        let hi_assigned = labels_hi.iter().filter(|&&l| l >= 0).count();
437        if lo_assigned >= hi_assigned {
438            let strengths = get_point_membership_strengths(&ct_lo, &leaves_lo, &labels_lo);
439            (labels_lo, strengths)
440        } else {
441            let strengths = get_point_membership_strengths(&ct_hi, &leaves_hi, &labels_hi);
442            (labels_hi, strengths)
443        }
444    }
445}
446
447/////////////////
448// GPU version //
449/////////////////
450
451/// Run EVoC clustering with GPU-accelerated kNN search.
452///
453/// Identical to [`evoc`] except the kNN graph is constructed on the GPU via
454/// `manifolds-rs`'s GPU ANN backends. Fuzzy graph construction, node
455/// embedding, MST and persistence analysis all remain on the CPU.
456///
457/// ### Params
458///
459/// * `data` — input matrix with shape `(n_points, n_features)`.
460/// * `ann_type` — GPU ANN backend: `"exhaustive_gpu"`, `"ivf_gpu"` or
461///   `"nndescent_gpu"`.
462/// * `precomputed_knn` — pre-built `(indices, distances)` pair; pass `None`
463///   to build the graph on the GPU.
464/// * `evoc_params` — clustering hyperparameters; see [`EvocParams`].
465/// * `nn_params` — GPU nearest-neighbour search parameters.
466/// * `device` — GPU device.
467/// * `seed` — random seed for reproducibility.
468/// * `verbose` - If `0` -> silent or `1` for normal verbosity, `2` for detailed
469///   verbosity.
470///
471/// ### Returns
472///
473/// An [`EvocResult`] containing cluster layers, membership strengths,
474/// persistence scores, and the kNN graph.
475#[allow(clippy::too_many_arguments)]
476#[cfg(feature = "gpu")]
477pub fn evoc_gpu<T, R>(
478    data: MatRef<T>,
479    ann_type: String,
480    precomputed_knn: PreComputedKnn<T>,
481    evoc_params: &EvocParams<T>,
482    nn_params: &NearestNeighbourParamsGpu<T>,
483    device: R::Device,
484    seed: usize,
485    verbose: usize,
486) -> Result<EvocResult<T>, EvocErrors>
487where
488    T: EvocFloat + AnnSearchFloat + AnnSearchGpuFloat,
489    R: Runtime,
490    NNDescentGpu<T, R>: NNDescentQuery<T>,
491{
492    let start_all = Instant::now();
493    let verbosity = parse_verbosity_level(verbose);
494
495    // 1. kNN graph (GPU)
496    let (knn_indices, knn_dist) = match precomputed_knn {
497        Some((indices, distances)) => {
498            if verbosity.normal_verbosity() {
499                println!("Using precomputed kNN graph...");
500            }
501            (indices, distances)
502        }
503        None => {
504            if verbosity.normal_verbosity() {
505                println!("Running GPU nearest neighbour search using {}...", ann_type);
506            }
507            let start_knn = Instant::now();
508            let result = run_ann_search_gpu::<T, R>(
509                data,
510                evoc_params.n_neighbours,
511                ann_type,
512                nn_params,
513                device,
514                seed,
515                verbose,
516            )?;
517            if verbosity.normal_verbosity() {
518                println!("GPU kNN search done in {:.2?}.", start_knn.elapsed());
519            }
520            result
521        }
522    };
523
524    // 2. Fuzzy simplicial set
525    if verbosity.normal_verbosity() {
526        println!("Constructing fuzzy simplicial set...");
527    }
528    let start_graph = Instant::now();
529    let effective_k = evoc_params.neighbour_scale * T::from(evoc_params.n_neighbours).unwrap();
530    let graph =
531        build_fuzzy_simplicial_set(&knn_indices, &knn_dist, effective_k, evoc_params.symmetrise);
532    let adj = coo_to_adjacency_list(&graph);
533    if verbosity.normal_verbosity() {
534        println!(
535            "... fuzzy simplicial set done in {:.2?}.",
536            start_graph.elapsed()
537        );
538    }
539
540    // 3. Embedding dimensionality
541    let dim = evoc_params
542        .embedding_dim
543        .unwrap_or_else(|| (evoc_params.n_neighbours / 4).clamp(4, 16));
544
545    // 4. Label propagation initialisation
546    let start_init = Instant::now();
547    let n = data.nrows();
548    let d = data.ncols();
549    let data_vecs: Vec<Vec<T>> = (0..n)
550        .map(|i| (0..d).map(|j| data[(i, j)]).collect())
551        .collect();
552
553    if verbosity.normal_verbosity() {
554        println!("Computing label propagation initialisation...");
555    }
556    let initial_embedding = crate::graph::label_prop::label_propagation_init(
557        &graph,
558        dim,
559        Some(&data_vecs),
560        seed as u64,
561        verbose,
562    );
563    if verbosity.normal_verbosity() {
564        println!(" ... label prop init done in {:.2?}.", start_init.elapsed());
565    }
566
567    // 5. Node embedding
568    if verbosity.normal_verbosity() {
569        println!(
570            "Computing {}-d node embedding ({} epochs)...",
571            dim, evoc_params.n_epochs
572        );
573    }
574    let start_embed = Instant::now();
575    let embed_params = EvocEmbeddingParams {
576        n_epochs: evoc_params.n_epochs,
577        noise_level: evoc_params.noise_level,
578        initial_alpha: T::from(0.1).unwrap(),
579        ..EvocEmbeddingParams::default()
580    };
581
582    let embedding = evoc_embedding(
583        &adj,
584        dim,
585        &embed_params,
586        Some(&initial_embedding),
587        seed as u64,
588        verbose,
589    );
590    if verbosity.normal_verbosity() {
591        println!(" ... embedding done in {:.2?}.", start_embed.elapsed());
592    }
593
594    // 6. Clustering
595    if verbosity.normal_verbosity() {
596        println!("Running density-based clustering...");
597    }
598    let start_cluster = Instant::now();
599
600    let (cluster_layers, membership_strengths, persistence_scores) =
601        if let Some(target_k) = evoc_params.approx_n_clusters {
602            let (labels, strengths) =
603                search_for_n_clusters(&embedding, evoc_params.min_samples, target_k);
604            (vec![labels], vec![strengths], vec![0.0])
605        } else {
606            build_cluster_layers(
607                &embedding,
608                evoc_params.min_samples,
609                evoc_params.base_min_cluster_size,
610                evoc_params.min_similarity_threshold,
611                evoc_params.max_layers,
612            )
613        };
614
615    if verbosity.normal_verbosity() {
616        let n_layers = cluster_layers.len();
617        println!(
618            "Clustering done in {:.2?}: {} layer(s).",
619            start_cluster.elapsed(),
620            n_layers,
621        );
622        println!("EVoC (GPU) total: {:.2?}.", start_all.elapsed());
623    }
624
625    Ok(EvocResult {
626        cluster_layers,
627        membership_strengths,
628        persistence_scores,
629        nn_indices: knn_indices,
630        nn_distances: knn_dist,
631    })
632}