Skip to main content

oxigdal_analytics/clustering/
optics.rs

1//! OPTICS Clustering Algorithm
2//!
3//! Ordering Points To Identify the Clustering Structure (OPTICS).
4//! A density-based clustering algorithm that addresses one of DBSCAN's
5//! major weaknesses: the inability to detect meaningful clusters when
6//! the data set contains regions of widely varying density.
7//!
8//! Reference: Ankerst, M., Breunig, M. M., Kriegel, H.-P., & Sander, J.
9//! (1999). "OPTICS: Ordering Points to Identify the Clustering Structure."
10//! Proceedings of the 1999 ACM SIGMOD International Conference on
11//! Management of Data, pp. 49–60.
12//!
13//! Unlike DBSCAN, OPTICS does not produce a clustering of the data set
14//! explicitly. Instead, it creates an augmented ordering of the database
15//! representing its density-based clustering structure — the
16//! "reachability plot". Clusters can be extracted from this plot using:
17//!   - The ξ (xi) steep-area method (variable density, default).
18//!   - A DBSCAN-equivalent extraction with a chosen radius ε.
19
20use crate::error::{AnalyticsError, Result};
21use rstar::{AABB, PointDistance, RTree, RTreeObject};
22use std::cmp::Ordering;
23use std::collections::BinaryHeap;
24
25// ── Public Types ────────────────────────────────────────────────────────────
26
27/// A 2-dimensional point used for OPTICS input.
28///
29/// This mirrors `oxigdal_noalloc::Point2D` but is intentionally local so
30/// that the analytics crate avoids pulling extra workspace dependencies.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct Point2D {
33    /// X coordinate.
34    pub x: f64,
35    /// Y coordinate.
36    pub y: f64,
37}
38
39impl Point2D {
40    /// Creates a new [`Point2D`].
41    #[must_use]
42    #[inline]
43    pub const fn new(x: f64, y: f64) -> Self {
44        Self { x, y }
45    }
46}
47
48/// Options controlling the OPTICS algorithm.
49#[derive(Debug, Clone, Copy)]
50pub struct OpticsOptions {
51    /// Minimum number of neighbours required for a point to be a "core"
52    /// point. Smaller values produce noisier clusterings.
53    pub min_samples: usize,
54    /// Maximum neighbourhood radius. Setting `f64::INFINITY` (the default)
55    /// means no upper bound is enforced.
56    pub max_eps: f64,
57    /// Steepness threshold used for ξ-extraction (Ankerst 1999 §4).
58    /// Typical values are in `[0.01, 0.10]`.
59    pub xi: f64,
60}
61
62impl Default for OpticsOptions {
63    fn default() -> Self {
64        Self {
65            min_samples: 5,
66            max_eps: f64::INFINITY,
67            xi: 0.05,
68        }
69    }
70}
71
72/// A single cluster discovered in the reachability ordering.
73///
74/// The cluster spans positions `[start, end]` (inclusive) of the
75/// `ordering` vector in [`OpticsResult`].
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct OpticsCluster {
78    /// First position in the reachability ordering.
79    pub start: usize,
80    /// Last position in the reachability ordering (inclusive).
81    pub end: usize,
82    /// Steepness used during ξ-extraction (0.0 for DBSCAN-compat
83    /// extraction).
84    pub xi_steepness: f64,
85}
86
87/// Full output of an OPTICS run.
88#[derive(Debug, Clone)]
89pub struct OpticsResult {
90    /// Visit order — `ordering[i]` is the original index of the point at
91    /// position `i` in the reachability plot.
92    pub ordering: Vec<usize>,
93    /// Per-position reachability distance (`f64::INFINITY` for points
94    /// whose reachability is undefined, typically the first member of a
95    /// connected component).
96    pub reachability: Vec<f64>,
97    /// Per-position core distance (`f64::INFINITY` if the point at this
98    /// position is not a core point).
99    pub core_distances: Vec<f64>,
100    /// Clusters extracted with the default ξ value supplied in
101    /// [`OpticsOptions`]. Use [`extract_xi_clusters`] or
102    /// [`extract_dbscan_clusters`] for alternative extractions over the
103    /// same reachability plot.
104    pub clusters: Vec<OpticsCluster>,
105}
106
107/// OPTICS clusterer object that pairs options with a fit method.
108#[derive(Debug, Clone, Copy)]
109pub struct OpticsClusterer {
110    options: OpticsOptions,
111}
112
113impl OpticsClusterer {
114    /// Construct a new clusterer with the given options.
115    #[must_use]
116    pub fn new(options: OpticsOptions) -> Self {
117        Self { options }
118    }
119
120    /// Run the OPTICS algorithm on the supplied points.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error when `min_samples == 0` or `max_eps <= 0`.
125    pub fn fit(&self, points: &[Point2D]) -> Result<OpticsResult> {
126        optics(points, &self.options)
127    }
128
129    /// Access the options used by this clusterer.
130    #[must_use]
131    pub fn options(&self) -> &OpticsOptions {
132        &self.options
133    }
134}
135
136// ── R-tree adapter ─────────────────────────────────────────────────────────
137
138#[derive(Debug, Clone, Copy)]
139struct IndexedPoint {
140    index: usize,
141    x: f64,
142    y: f64,
143}
144
145impl RTreeObject for IndexedPoint {
146    type Envelope = AABB<[f64; 2]>;
147
148    fn envelope(&self) -> Self::Envelope {
149        AABB::from_point([self.x, self.y])
150    }
151}
152
153impl PointDistance for IndexedPoint {
154    fn distance_2(&self, p: &[f64; 2]) -> f64 {
155        let dx = self.x - p[0];
156        let dy = self.y - p[1];
157        dx * dx + dy * dy
158    }
159}
160
161// ── Priority queue entry ───────────────────────────────────────────────────
162
163/// Entry of the OPTICS priority queue. The natural ordering is the
164/// reverse of the reachability so that [`BinaryHeap`] (a max-heap)
165/// behaves like a min-heap on reachability distance. `total_cmp` is
166/// used so that NaN never sneaks into the comparison — although in
167/// practice all values pushed here are finite.
168#[derive(Debug, Clone, Copy)]
169struct HeapEntry {
170    reachability: f64,
171    index: usize,
172}
173
174impl PartialEq for HeapEntry {
175    fn eq(&self, other: &Self) -> bool {
176        self.reachability.total_cmp(&other.reachability) == Ordering::Equal
177            && self.index == other.index
178    }
179}
180
181impl Eq for HeapEntry {}
182
183impl PartialOrd for HeapEntry {
184    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
185        Some(self.cmp(other))
186    }
187}
188
189impl Ord for HeapEntry {
190    fn cmp(&self, other: &Self) -> Ordering {
191        // Reverse so BinaryHeap pops smallest reachability first.
192        other
193            .reachability
194            .total_cmp(&self.reachability)
195            .then_with(|| other.index.cmp(&self.index))
196    }
197}
198
199// ── Free functions ─────────────────────────────────────────────────────────
200
201/// Run the OPTICS algorithm on `points` using the supplied `options`.
202///
203/// The returned [`OpticsResult`] contains the reachability plot from
204/// which clusters at any density level can be extracted; the field
205/// `clusters` is populated with the ξ extraction at `options.xi`.
206///
207/// # Errors
208///
209/// Returns an error when `min_samples == 0` or `max_eps <= 0`.
210pub fn optics(points: &[Point2D], options: &OpticsOptions) -> Result<OpticsResult> {
211    if options.min_samples == 0 {
212        return Err(AnalyticsError::invalid_parameter(
213            "min_samples",
214            "must be positive",
215        ));
216    }
217    if options.max_eps <= 0.0 || options.max_eps.is_nan() {
218        return Err(AnalyticsError::invalid_parameter(
219            "max_eps",
220            "must be positive (or f64::INFINITY)",
221        ));
222    }
223    if !(0.0..1.0).contains(&options.xi) {
224        return Err(AnalyticsError::invalid_parameter(
225            "xi",
226            "must lie in [0, 1)",
227        ));
228    }
229
230    let n = points.len();
231    if n == 0 {
232        return Ok(OpticsResult {
233            ordering: Vec::new(),
234            reachability: Vec::new(),
235            core_distances: Vec::new(),
236            clusters: Vec::new(),
237        });
238    }
239
240    // Build the R-tree.
241    let indexed: Vec<IndexedPoint> = points
242        .iter()
243        .enumerate()
244        .map(|(i, p)| IndexedPoint {
245            index: i,
246            x: p.x,
247            y: p.y,
248        })
249        .collect();
250    let tree = RTree::bulk_load(indexed);
251
252    // Working state. `reach_by_idx` and `core_by_idx` are indexed by the
253    // ORIGINAL point index (not by ordering position) until we finalise
254    // the result; this matches the textbook OPTICS pseudocode.
255    let mut processed: Vec<bool> = vec![false; n];
256    let mut reach_by_idx: Vec<f64> = vec![f64::INFINITY; n];
257    let mut core_by_idx: Vec<f64> = vec![f64::INFINITY; n];
258
259    let mut ordering: Vec<usize> = Vec::with_capacity(n);
260    let mut reachability: Vec<f64> = Vec::with_capacity(n);
261    let mut core_distances: Vec<f64> = Vec::with_capacity(n);
262
263    for start in 0..n {
264        if processed[start] {
265            continue;
266        }
267
268        // Compute neighbours and core distance for the seed.
269        let seed_neighbours = neighbours_within(&tree, points[start], options.max_eps);
270        let seed_core = core_distance(&seed_neighbours, options.min_samples);
271        core_by_idx[start] = seed_core;
272
273        // Emit the seed point with its current reachability
274        // (typically INFINITY for the first point of a new component).
275        processed[start] = true;
276        ordering.push(start);
277        reachability.push(reach_by_idx[start]);
278        core_distances.push(seed_core);
279
280        if !seed_core.is_finite() {
281            // Seed is not a core point — move on without expanding.
282            continue;
283        }
284
285        // Seed the priority queue from the seed's neighbourhood.
286        let mut queue: BinaryHeap<HeapEntry> = BinaryHeap::new();
287        update_seeds(
288            &seed_neighbours,
289            start,
290            seed_core,
291            &mut reach_by_idx,
292            &processed,
293            &mut queue,
294        );
295
296        // Expand.
297        while let Some(HeapEntry {
298            index: q,
299            reachability: r_q,
300        }) = queue.pop()
301        {
302            if processed[q] {
303                // The same point can be queued more than once when a
304                // shorter reachability is discovered; stale entries are
305                // dropped here.
306                continue;
307            }
308            if (reach_by_idx[q] - r_q).abs() > 0.0 && reach_by_idx[q] < r_q {
309                // A better reachability was found after this entry was
310                // pushed; the canonical entry will be popped later.
311                continue;
312            }
313
314            let q_neighbours = neighbours_within(&tree, points[q], options.max_eps);
315            let q_core = core_distance(&q_neighbours, options.min_samples);
316            core_by_idx[q] = q_core;
317
318            processed[q] = true;
319            ordering.push(q);
320            reachability.push(reach_by_idx[q]);
321            core_distances.push(q_core);
322
323            if q_core.is_finite() {
324                update_seeds(
325                    &q_neighbours,
326                    q,
327                    q_core,
328                    &mut reach_by_idx,
329                    &processed,
330                    &mut queue,
331                );
332            }
333        }
334    }
335
336    let mut result = OpticsResult {
337        ordering,
338        reachability,
339        core_distances,
340        clusters: Vec::new(),
341    };
342    result.clusters = extract_xi_clusters(&result, options.xi);
343    Ok(result)
344}
345
346/// Update reachability of `centre`'s neighbours and push improved
347/// entries into the priority queue.
348fn update_seeds(
349    neighbours: &[NeighbourInfo],
350    centre: usize,
351    centre_core: f64,
352    reach_by_idx: &mut [f64],
353    processed: &[bool],
354    queue: &mut BinaryHeap<HeapEntry>,
355) {
356    for nb in neighbours {
357        if nb.index == centre || processed[nb.index] {
358            continue;
359        }
360        let new_reach = centre_core.max(nb.distance);
361        if new_reach < reach_by_idx[nb.index] {
362            reach_by_idx[nb.index] = new_reach;
363            queue.push(HeapEntry {
364                reachability: new_reach,
365                index: nb.index,
366            });
367        }
368    }
369}
370
371#[derive(Debug, Clone, Copy)]
372struct NeighbourInfo {
373    index: usize,
374    /// Euclidean distance (not squared) from the query point.
375    distance: f64,
376}
377
378/// Collect neighbours of `point` within `max_eps` (inclusive), including
379/// the point itself if it is in the tree.
380fn neighbours_within(
381    tree: &RTree<IndexedPoint>,
382    point: Point2D,
383    max_eps: f64,
384) -> Vec<NeighbourInfo> {
385    let mut out: Vec<NeighbourInfo> = Vec::new();
386    let query = [point.x, point.y];
387
388    if max_eps.is_finite() {
389        let radius_sq = max_eps * max_eps;
390        for candidate in tree.locate_within_distance(query, radius_sq) {
391            let dx = candidate.x - query[0];
392            let dy = candidate.y - query[1];
393            let dist = (dx * dx + dy * dy).sqrt();
394            if dist <= max_eps {
395                out.push(NeighbourInfo {
396                    index: candidate.index,
397                    distance: dist,
398                });
399            }
400        }
401    } else {
402        // `locate_within_distance` requires a finite radius, so for
403        // unbounded queries we walk the iterator of nearest neighbours.
404        for candidate in tree.nearest_neighbor_iter(query) {
405            let dx = candidate.x - query[0];
406            let dy = candidate.y - query[1];
407            let dist = (dx * dx + dy * dy).sqrt();
408            out.push(NeighbourInfo {
409                index: candidate.index,
410                distance: dist,
411            });
412        }
413    }
414    out
415}
416
417/// Core distance = distance to the `min_samples`-th nearest neighbour
418/// (where the centre point itself counts as one). Returns
419/// `f64::INFINITY` when fewer than `min_samples` neighbours are
420/// available.
421fn core_distance(neighbours: &[NeighbourInfo], min_samples: usize) -> f64 {
422    if neighbours.len() < min_samples {
423        return f64::INFINITY;
424    }
425    let mut sorted: Vec<f64> = neighbours.iter().map(|n| n.distance).collect();
426    sorted.sort_by(|a, b| a.total_cmp(b));
427    // 1-indexed: the `min_samples`-th smallest distance.
428    sorted[min_samples - 1]
429}
430
431// ── Cluster extraction ─────────────────────────────────────────────────────
432
433/// Extract clusters from the reachability plot via the ξ steep-area
434/// method (Ankerst 1999 §4).
435///
436/// A *steep-down area* at position `i` is where the curve drops by at
437/// least a factor of `(1 - ξ)`, formally `reach[i+1] <= reach[i] * (1 - ξ)`
438/// with `reach[i] >= reach[i+1]`. A *steep-up area* is the symmetric
439/// case where the curve rises by at least the same factor:
440/// `reach[i] <= reach[i+1] * (1 - ξ)` with `reach[i] <= reach[i+1]`.
441///
442/// Each steep-down area is paired with the next compatible steep-up
443/// area to form a cluster: the cluster spans `[start_of_down,
444/// end_of_up]`. The resulting span must contain at least three points
445/// to be retained.
446#[must_use]
447pub fn extract_xi_clusters(result: &OpticsResult, xi: f64) -> Vec<OpticsCluster> {
448    let reach = &result.reachability;
449    let n = reach.len();
450    if n < 3 || !(0.0..1.0).contains(&xi) {
451        return Vec::new();
452    }
453    let factor = 1.0 - xi;
454
455    // Identify steep-down and steep-up runs. A run extends as long as
456    // each adjacent pair satisfies the inequality (and points in between
457    // do not violate monotonicity beyond what ξ allows). We keep the
458    // runs as `(start, end)` inclusive position pairs.
459    let mut downs: Vec<(usize, usize)> = Vec::new();
460    let mut ups: Vec<(usize, usize)> = Vec::new();
461
462    let is_steep_down =
463        |a: f64, b: f64| -> bool { a.is_finite() && b.is_finite() && a >= b && b <= a * factor };
464    let is_steep_up =
465        |a: f64, b: f64| -> bool { a.is_finite() && b.is_finite() && a <= b && a <= b * factor };
466
467    let mut i = 0;
468    while i + 1 < n {
469        let r0 = reach[i];
470        let r1 = reach[i + 1];
471        if is_steep_down(r0, r1) {
472            let s = i;
473            let mut j = i + 1;
474            while j + 1 < n {
475                let a = reach[j];
476                let b = reach[j + 1];
477                if !is_steep_down(a, b) {
478                    break;
479                }
480                j += 1;
481            }
482            downs.push((s, j));
483            i = j + 1;
484        } else if is_steep_up(r0, r1) {
485            let s = i;
486            let mut j = i + 1;
487            while j + 1 < n {
488                let a = reach[j];
489                let b = reach[j + 1];
490                if !is_steep_up(a, b) {
491                    break;
492                }
493                j += 1;
494            }
495            ups.push((s, j));
496            i = j + 1;
497        } else {
498            i += 1;
499        }
500    }
501
502    // Pair each steep-down with the next compatible steep-up that
503    // begins at or after the end of the down area.
504    let mut clusters: Vec<OpticsCluster> = Vec::new();
505    for &(d_start, d_end) in &downs {
506        if let Some(&(_u_start, u_end)) = ups.iter().find(|&&(u_s, _)| u_s >= d_end) {
507            let cluster_start = d_start;
508            let cluster_end = u_end;
509            let span = cluster_end.saturating_sub(cluster_start).saturating_add(1);
510            if span < 3 {
511                continue;
512            }
513
514            let r_start = reach[cluster_start];
515            let r_end = reach[cluster_end];
516            let steepness = if r_start.is_finite() && r_end.is_finite() {
517                let max_r = r_start.max(r_end).max(f64::EPSILON);
518                (r_start - r_end).abs() / max_r
519            } else {
520                xi
521            };
522
523            clusters.push(OpticsCluster {
524                start: cluster_start,
525                end: cluster_end,
526                xi_steepness: steepness,
527            });
528        }
529    }
530
531    clusters
532}
533
534/// Extract clusters using the DBSCAN-equivalent rule: a cluster spans a
535/// maximal contiguous slice of the reachability plot in which every
536/// entry is ≤ `eps`. Reachability values that exceed `eps` (including
537/// `f64::INFINITY`) act as separators.
538#[must_use]
539pub fn extract_dbscan_clusters(result: &OpticsResult, eps: f64) -> Vec<OpticsCluster> {
540    if !(eps > 0.0 && eps.is_finite()) {
541        return Vec::new();
542    }
543    let reach = &result.reachability;
544    let n = reach.len();
545    if n == 0 {
546        return Vec::new();
547    }
548
549    let mut clusters: Vec<OpticsCluster> = Vec::new();
550    let mut start: Option<usize> = None;
551    for i in 0..n {
552        if reach[i] <= eps {
553            if start.is_none() {
554                start = Some(i);
555            }
556        } else if let Some(s) = start.take()
557            && i > s
558        {
559            clusters.push(OpticsCluster {
560                start: s,
561                end: i - 1,
562                xi_steepness: 0.0,
563            });
564        }
565    }
566    if let Some(s) = start {
567        clusters.push(OpticsCluster {
568            start: s,
569            end: n - 1,
570            xi_steepness: 0.0,
571        });
572    }
573    clusters
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn options_default_values() {
582        let opts = OpticsOptions::default();
583        assert_eq!(opts.min_samples, 5);
584        assert!((opts.xi - 0.05).abs() < 1e-12);
585        assert!(opts.max_eps.is_infinite());
586    }
587
588    #[test]
589    fn empty_input_returns_empty_result() {
590        let res = optics(&[], &OpticsOptions::default()).expect("empty");
591        assert!(res.ordering.is_empty());
592        assert!(res.reachability.is_empty());
593        assert!(res.core_distances.is_empty());
594        assert!(res.clusters.is_empty());
595    }
596
597    #[test]
598    fn invalid_min_samples_zero_fails() {
599        let opts = OpticsOptions {
600            min_samples: 0,
601            ..OpticsOptions::default()
602        };
603        let pts = [Point2D::new(0.0, 0.0)];
604        assert!(optics(&pts, &opts).is_err());
605    }
606}