oxigdal_analytics/clustering/
optics.rs1use crate::error::{AnalyticsError, Result};
21use rstar::{AABB, PointDistance, RTree, RTreeObject};
22use std::cmp::Ordering;
23use std::collections::BinaryHeap;
24
25#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct Point2D {
33 pub x: f64,
35 pub y: f64,
37}
38
39impl Point2D {
40 #[must_use]
42 #[inline]
43 pub const fn new(x: f64, y: f64) -> Self {
44 Self { x, y }
45 }
46}
47
48#[derive(Debug, Clone, Copy)]
50pub struct OpticsOptions {
51 pub min_samples: usize,
54 pub max_eps: f64,
57 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#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct OpticsCluster {
78 pub start: usize,
80 pub end: usize,
82 pub xi_steepness: f64,
85}
86
87#[derive(Debug, Clone)]
89pub struct OpticsResult {
90 pub ordering: Vec<usize>,
93 pub reachability: Vec<f64>,
97 pub core_distances: Vec<f64>,
100 pub clusters: Vec<OpticsCluster>,
105}
106
107#[derive(Debug, Clone, Copy)]
109pub struct OpticsClusterer {
110 options: OpticsOptions,
111}
112
113impl OpticsClusterer {
114 #[must_use]
116 pub fn new(options: OpticsOptions) -> Self {
117 Self { options }
118 }
119
120 pub fn fit(&self, points: &[Point2D]) -> Result<OpticsResult> {
126 optics(points, &self.options)
127 }
128
129 #[must_use]
131 pub fn options(&self) -> &OpticsOptions {
132 &self.options
133 }
134}
135
136#[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#[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 other
193 .reachability
194 .total_cmp(&self.reachability)
195 .then_with(|| other.index.cmp(&self.index))
196 }
197}
198
199pub 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 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 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 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 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 continue;
283 }
284
285 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 while let Some(HeapEntry {
298 index: q,
299 reachability: r_q,
300 }) = queue.pop()
301 {
302 if processed[q] {
303 continue;
307 }
308 if (reach_by_idx[q] - r_q).abs() > 0.0 && reach_by_idx[q] < r_q {
309 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
346fn 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 distance: f64,
376}
377
378fn 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 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
417fn 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 sorted[min_samples - 1]
429}
430
431#[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 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 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#[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 if i > s {
558 clusters.push(OpticsCluster {
559 start: s,
560 end: i - 1,
561 xi_steepness: 0.0,
562 });
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}