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::{ApplySortedUpdates, NNDescent, NNDescentQuery};
24use ann_search_rs::prelude::AnnSearchFloat;
25use faer::MatRef;
26use std::time::Instant;
27
28#[cfg(feature = "gpu")]
29use ann_search_rs::gpu::nndescent_gpu::NNDescentGpu;
30#[cfg(feature = "gpu")]
31use ann_search_rs::gpu::traits_gpu::AnnSearchGpuFloat;
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 + AnnSearchGpuFloat,
492    R: Runtime,
493    NNDescentGpu<T, R>: NNDescentQuery<T>,
494{
495    let start_all = Instant::now();
496    let verbosity = parse_verbosity_level(verbose);
497
498    // 1. kNN graph (GPU)
499    let (knn_indices, knn_dist) = match precomputed_knn {
500        Some((indices, distances)) => {
501            if verbosity.normal_verbosity() {
502                println!("Using precomputed kNN graph...");
503            }
504            (indices, distances)
505        }
506        None => {
507            // For the nndescent_gpu path, the CAGRA graph degree (`params.k`)
508            // and NNDescent build degree (`params.k_build`) are independent
509            // of the query k. Left at their crate defaults (30 and ~45) they
510            // sit below `n_neighbours` whenever `n_neighbours > 30`, so the
511            // beam search walks a graph too small to serve the query well.
512            // Backfill from `n_neighbours` here when the user hasn't set them.
513            let k = evoc_params.n_neighbours;
514            let scaled_params: NearestNeighbourParamsGpuEvoc<T>;
515            let nn_params = if nn_params.k.is_none() || nn_params.k_build.is_none() {
516                scaled_params = NearestNeighbourParamsGpuEvoc {
517                    k: nn_params.k.or(Some(k)),
518                    k_build: nn_params.k_build.or(Some(2 * k)),
519                    ..nn_params.clone()
520                };
521                &scaled_params
522            } else {
523                nn_params
524            };
525
526            if verbosity.normal_verbosity() {
527                println!("Running GPU nearest neighbour search using {}...", ann_type);
528            }
529            let start_knn = Instant::now();
530            let result = run_ann_search_gpu::<T, R>(
531                data,
532                evoc_params.n_neighbours,
533                ann_type,
534                nn_params,
535                device,
536                seed,
537                verbose,
538            )?;
539            if verbosity.normal_verbosity() {
540                println!("GPU kNN search done in {:.2?}.", start_knn.elapsed());
541            }
542            result
543        }
544    };
545
546    // 2. Fuzzy simplicial set
547    if verbosity.normal_verbosity() {
548        println!("Constructing fuzzy simplicial set...");
549    }
550    let start_graph = Instant::now();
551    let effective_k = evoc_params.neighbour_scale * T::from(evoc_params.n_neighbours).unwrap();
552    let graph =
553        build_fuzzy_simplicial_set(&knn_indices, &knn_dist, effective_k, evoc_params.symmetrise);
554    let adj = coo_to_adjacency_list(&graph);
555    if verbosity.normal_verbosity() {
556        println!(
557            "... fuzzy simplicial set done in {:.2?}.",
558            start_graph.elapsed()
559        );
560    }
561
562    // 3. Embedding dimensionality
563    let dim = evoc_params
564        .embedding_dim
565        .unwrap_or_else(|| (evoc_params.n_neighbours / 4).clamp(4, 16));
566
567    // 4. Label propagation initialisation
568    let start_init = Instant::now();
569    let n = data.nrows();
570    let d = data.ncols();
571    let data_vecs: Vec<Vec<T>> = (0..n)
572        .map(|i| (0..d).map(|j| data[(i, j)]).collect())
573        .collect();
574
575    if verbosity.normal_verbosity() {
576        println!("Computing label propagation initialisation...");
577    }
578    let initial_embedding = crate::graph::label_prop::label_propagation_init(
579        &graph,
580        dim,
581        Some(&data_vecs),
582        seed as u64,
583        verbose,
584    );
585    if verbosity.normal_verbosity() {
586        println!(" ... label prop init done in {:.2?}.", start_init.elapsed());
587    }
588
589    // 5. Node embedding
590    if verbosity.normal_verbosity() {
591        println!(
592            "Computing {}-d node embedding ({} epochs)...",
593            dim, evoc_params.n_epochs
594        );
595    }
596    let start_embed = Instant::now();
597    let embed_params = EvocEmbeddingParams {
598        n_epochs: evoc_params.n_epochs,
599        noise_level: evoc_params.noise_level,
600        initial_alpha: T::from(0.1).unwrap(),
601        ..EvocEmbeddingParams::default()
602    };
603
604    let embedding = evoc_embedding(
605        &adj,
606        dim,
607        &embed_params,
608        Some(&initial_embedding),
609        seed as u64,
610        verbose,
611    );
612    if verbosity.normal_verbosity() {
613        println!(" ... embedding done in {:.2?}.", start_embed.elapsed());
614    }
615
616    // 6. Clustering
617    if verbosity.normal_verbosity() {
618        println!("Running density-based clustering...");
619    }
620    let start_cluster = Instant::now();
621
622    let (cluster_layers, membership_strengths, persistence_scores) =
623        if let Some(target_k) = evoc_params.approx_n_clusters {
624            let (labels, strengths) =
625                search_for_n_clusters(&embedding, evoc_params.min_samples, target_k);
626            (vec![labels], vec![strengths], vec![0.0])
627        } else {
628            build_cluster_layers(
629                &embedding,
630                evoc_params.min_samples,
631                evoc_params.base_min_cluster_size,
632                evoc_params.min_similarity_threshold,
633                evoc_params.max_layers,
634            )
635        };
636
637    if verbosity.normal_verbosity() {
638        let n_layers = cluster_layers.len();
639        println!(
640            "Clustering done in {:.2?}: {} layer(s).",
641            start_cluster.elapsed(),
642            n_layers,
643        );
644        println!("EVoC (GPU) total: {:.2?}.", start_all.elapsed());
645    }
646
647    Ok(EvocResult {
648        cluster_layers,
649        membership_strengths,
650        persistence_scores,
651        nn_indices: knn_indices,
652        nn_distances: knn_dist,
653    })
654}