Skip to main content

stats_claw/algorithms/clustering/
dbscan.rs

1//! DBSCAN density-based clustering (Ester, Kriegel, Sander & Xu 1996).
2//!
3//! A point is a *core* point when at least `min_samples` points (itself included,
4//! matching `scikit-learn`) lie within distance `eps`. Clusters grow by breadth-
5//! first expansion from core points through their `eps`-neighbourhoods; points
6//! reachable from no core point are labelled [`NOISE`]. The set of core-point
7//! indices is the identifiable scalar the equivalence suite compares exactly.
8
9use crate::algorithms::clustering::NOISE;
10use crate::algorithms::euclidean_sq;
11
12/// Outcome of a DBSCAN run.
13#[derive(Debug, Clone)]
14pub struct DbscanResult {
15    /// Cluster id per input point, in input order; noise points carry [`NOISE`].
16    pub labels: Vec<usize>,
17    /// Indices (into the input) of the points that qualified as core points.
18    pub core_samples: Vec<usize>,
19}
20
21/// Clusters `data` by density, returning per-point labels and the core-sample set.
22///
23/// Deterministic: the result depends only on `data`, `eps`, and `min_samples`
24/// (no RNG), so repeated runs are identical. Empty input yields an empty result.
25///
26/// # Arguments
27///
28/// * `data` — observations; each inner slice is one point of equal dimension.
29/// * `eps` — neighbourhood radius. Two points are neighbours when their Euclidean
30///   distance is `≤ eps`.
31/// * `min_samples` — minimum neighbourhood size (self-inclusive) for a core point.
32///
33/// # Returns
34///
35/// A [`DbscanResult`] whose `labels` use contiguous ids `0, 1, …` for clusters and
36/// [`NOISE`] for outliers, and whose `core_samples` lists the core-point indices.
37///
38/// # Examples
39///
40/// ```
41/// use stats_claw::algorithms::clustering::{dbscan, NOISE};
42///
43/// // Two dense triples plus one far outlier.
44/// let data = vec![
45///     vec![0.0], vec![0.1], vec![0.2],
46///     vec![5.0], vec![5.1], vec![5.2],
47///     vec![100.0],
48/// ];
49/// let r = dbscan(&data, 0.5, 3);
50/// assert_eq!(r.labels.last(), Some(&NOISE), "outlier not flagged");
51/// ```
52#[must_use]
53pub fn dbscan(data: &[Vec<f64>], eps: f64, min_samples: usize) -> DbscanResult {
54    let n = data.len();
55    let eps_sq = eps * eps;
56    let neighbours: Vec<Vec<usize>> = (0..n).map(|i| region_query(data, i, eps_sq)).collect();
57    let is_core: Vec<bool> = neighbours
58        .iter()
59        .map(|nb| nb.len() >= min_samples)
60        .collect();
61
62    let mut labels = vec![UNVISITED; n];
63    let mut next_cluster = 0_usize;
64    for seed in 0..n {
65        if labels.get(seed).copied() != Some(UNVISITED) || !core_at(&is_core, seed) {
66            continue;
67        }
68        expand_cluster(seed, next_cluster, &neighbours, &is_core, &mut labels);
69        next_cluster += 1;
70    }
71
72    let labels: Vec<usize> = labels
73        .into_iter()
74        .map(|l| if l == UNVISITED { NOISE } else { l })
75        .collect();
76    let core_samples = (0..n).filter(|&i| core_at(&is_core, i)).collect();
77    DbscanResult {
78        labels,
79        core_samples,
80    }
81}
82
83/// Internal marker for a point not yet assigned to a cluster or to noise.
84const UNVISITED: usize = usize::MAX - 1;
85
86/// Returns the indices of every point within squared distance `eps_sq` of point
87/// `centre` (the point itself included, per the `scikit-learn` convention).
88fn region_query(data: &[Vec<f64>], centre: usize, eps_sq: f64) -> Vec<usize> {
89    let Some(origin) = data.get(centre) else {
90        return Vec::new();
91    };
92    data.iter()
93        .enumerate()
94        .filter(|(_, p)| euclidean_sq(origin, p) <= eps_sq)
95        .map(|(i, _)| i)
96        .collect()
97}
98
99/// Reads the core flag for index `i`, defaulting to `false` out of range.
100fn core_at(is_core: &[bool], i: usize) -> bool {
101    is_core.get(i).copied().unwrap_or(false)
102}
103
104/// Grows cluster `cluster` from core point `seed` by breadth-first expansion over
105/// `eps`-neighbourhoods, absorbing reachable points (border points join but do not
106/// extend the frontier).
107fn expand_cluster(
108    seed: usize,
109    cluster: usize,
110    neighbours: &[Vec<usize>],
111    is_core: &[bool],
112    labels: &mut [usize],
113) {
114    let mut queue = std::collections::VecDeque::new();
115    queue.push_back(seed);
116    if let Some(slot) = labels.get_mut(seed) {
117        *slot = cluster;
118    }
119    while let Some(current) = queue.pop_front() {
120        if !core_at(is_core, current) {
121            continue;
122        }
123        let Some(reachable) = neighbours.get(current) else {
124            continue;
125        };
126        for &point in reachable {
127            if labels.get(point).copied() == Some(UNVISITED) {
128                if let Some(slot) = labels.get_mut(point) {
129                    *slot = cluster;
130                }
131                queue.push_back(point);
132            }
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn flags_isolated_point_as_noise() {
143        let data = vec![
144            vec![0.0],
145            vec![0.1],
146            vec![0.2],
147            vec![5.0],
148            vec![5.1],
149            vec![5.2],
150            vec![100.0],
151        ];
152        let r = dbscan(&data, 0.5, 3);
153        assert_eq!(r.labels.last(), Some(&NOISE), "outlier not noise");
154        assert_eq!(r.labels.first(), r.labels.get(2), "dense triple split");
155    }
156
157    #[test]
158    fn core_samples_exclude_noise() {
159        let data = vec![vec![0.0], vec![0.1], vec![0.2], vec![100.0]];
160        let r = dbscan(&data, 0.5, 3);
161        assert!(!r.core_samples.contains(&3), "outlier marked core");
162        assert_eq!(r.core_samples.len(), 3, "core count");
163    }
164
165    #[test]
166    fn deterministic_for_fixed_inputs() {
167        let data = vec![vec![0.0], vec![0.1], vec![5.0], vec![5.1]];
168        assert_eq!(dbscan(&data, 0.5, 2).labels, dbscan(&data, 0.5, 2).labels);
169    }
170}