use crate::error::{Context, Error};
pub use crate::machine_learning::DistanceCalculationMetric;
use crate::machine_learning::parallel::map_collect;
use crate::machine_learning::spatial::KdTree;
use crate::machine_learning::validation::{preliminary_check, validate_predict_input};
use crate::parallel_gates::SCAN_F64_PARALLEL_MIN_ELEMS;
use crate::{Deserialize, Serialize};
use ahash::AHashSet;
use ndarray::{Array1, Array2, ArrayBase, Data, Ix2};
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
use std::collections::VecDeque;
const DBSCAN_KD_TREE_MAX_DIMS: usize = 8;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DBSCAN {
eps: f64,
min_samples: usize,
metric: DistanceCalculationMetric,
labels: Option<Array1<isize>>,
core_sample_indices: Option<Array1<usize>>,
core_points: Option<Array2<f64>>,
core_point_labels: Option<Array1<isize>>,
}
impl Default for DBSCAN {
fn default() -> Self {
DBSCAN {
eps: 0.5,
min_samples: 5,
metric: DistanceCalculationMetric::Euclidean,
labels: None,
core_sample_indices: None,
core_points: None,
core_point_labels: None,
}
}
}
impl DBSCAN {
pub fn new(eps: f64, min_samples: usize) -> Result<Self, Error> {
if eps <= 0.0 || !eps.is_finite() {
return Err(Error::invalid_parameter(
"eps",
format!("eps must be positive and finite, got {}", eps),
));
}
if min_samples == 0 {
return Err(Error::invalid_parameter(
"min_samples",
"min_samples must be greater than 0",
));
}
Ok(DBSCAN {
eps,
min_samples,
metric: DistanceCalculationMetric::Euclidean,
labels: None,
core_sample_indices: None,
core_points: None,
core_point_labels: None,
})
}
pub fn with_metric(mut self, metric: DistanceCalculationMetric) -> Result<Self, Error> {
if let DistanceCalculationMetric::Minkowski(p) = metric
&& (p < 1.0 || !p.is_finite())
{
return Err(Error::invalid_parameter(
"p",
format!("Minkowski p must be at least 1 and finite, got {}", p),
));
}
self.metric = metric;
Ok(self)
}
get_field!(get_epsilon, eps, f64);
get_field!(get_min_samples, min_samples, usize);
get_field!(get_metric, metric, DistanceCalculationMetric);
get_field_as_ref!(get_labels, labels, Option<&Array1<isize>>);
get_field_as_ref!(
get_core_sample_indices,
core_sample_indices,
Option<&Array1<usize>>
);
fn region_query<S>(
&self,
data: &ArrayBase<S, Ix2>,
p: usize,
tree: Option<&KdTree>,
) -> Result<Vec<usize>, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
if p >= data.nrows() {
return Err(Error::computation(format!(
"Point index {} is out of bounds (max: {})",
p,
data.nrows() - 1
)));
}
let p_row = data.row(p);
let eps = self.eps;
if let Some(tree) = tree {
return Ok(tree.radius_neighbors(p_row, eps));
}
let n_samples = data.nrows();
let scan_work = n_samples.saturating_mul(data.ncols());
let neighbors: Vec<usize> = if scan_work >= SCAN_F64_PARALLEL_MIN_ELEMS {
(0..n_samples)
.into_par_iter()
.filter(|&q| self.metric.within(p_row, data.row(q), eps))
.collect()
} else {
(0..n_samples)
.filter(|&q| self.metric.within(p_row, data.row(q), eps))
.collect()
};
Ok(neighbors)
}
pub fn fit<S>(&mut self, data: &ArrayBase<S, Ix2>) -> Result<&mut Self, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
preliminary_check(data, None)?;
let n_samples = data.nrows();
let tree: Option<KdTree> = if data.ncols() <= DBSCAN_KD_TREE_MAX_DIMS {
Some(KdTree::build(data.view(), self.metric))
} else {
None
};
let tree_ref = tree.as_ref();
let mut labels = Array1::from(vec![-1isize; n_samples]); let mut core_samples = AHashSet::with_capacity(n_samples / 4); let mut cluster_id = 0isize;
#[cfg(feature = "show_progress")]
let pb = {
let progress = crate::create_progress_bar(
n_samples as u64,
"[{elapsed_precise}] {bar:40} {pos}/{len} | Clusters: {msg}",
);
progress.set_message("0 | Core points: 0");
progress
};
for p in 0..n_samples {
#[cfg(feature = "show_progress")]
pb.inc(1);
if labels[p] != -1 {
continue;
}
let neighbors = self
.region_query(data, p, tree_ref)
.context("region query failed")?;
if neighbors.len() < self.min_samples {
labels[p] = -1; continue;
}
labels[p] = cluster_id;
core_samples.insert(p);
let mut seeds: VecDeque<usize> = neighbors.into_iter().collect();
while let Some(q) = seeds.pop_front() {
if labels[q] >= 0 {
continue;
}
labels[q] = cluster_id;
let q_neighbors = self
.region_query(data, q, tree_ref)
.with_context(|| format!("region query failed for point {q}"))?;
if q_neighbors.len() >= self.min_samples {
core_samples.insert(q);
for r in q_neighbors {
if labels[r] < 0 {
seeds.push_back(r);
}
}
}
}
cluster_id += 1;
#[cfg(feature = "show_progress")]
pb.set_message(format!(
"{} | Core points: {}",
cluster_id,
core_samples.len()
));
if cluster_id == isize::MAX {
#[cfg(feature = "show_progress")]
pb.finish_with_message("Error: cluster ID overflow");
return Err(Error::computation("Too many clusters: cluster ID overflow"));
}
}
#[cfg(feature = "show_progress")]
pb.finish_with_message(format!(
"{} | Core points: {} | Noise points: {}",
cluster_id,
core_samples.len(),
labels.iter().filter(|&&x| x == -1).count()
));
let mut core_indices: Vec<usize> = core_samples.into_iter().collect();
core_indices.sort_unstable();
let n_features = data.ncols();
let mut core_points = Array2::<f64>::zeros((core_indices.len(), n_features));
let mut core_point_labels = Array1::<isize>::zeros(core_indices.len());
for (i, &idx) in core_indices.iter().enumerate() {
core_points.row_mut(i).assign(&data.row(idx));
core_point_labels[i] = labels[idx];
}
self.labels = Some(labels);
self.core_sample_indices = Some(Array1::from(core_indices));
self.core_points = Some(core_points);
self.core_point_labels = Some(core_point_labels);
Ok(self)
}
pub fn predict<S>(&self, new_data: &ArrayBase<S, Ix2>) -> Result<Array1<isize>, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
let core_points = self
.core_points
.as_ref()
.ok_or_else(|| Error::not_fitted("DBSCAN"))?;
let core_point_labels = self
.core_point_labels
.as_ref()
.ok_or_else(|| Error::not_fitted("DBSCAN"))?;
if new_data.nrows() == 0 {
return Ok(Array1::from(vec![]));
}
validate_predict_input(new_data, core_points.ncols())?;
let scan_work = new_data
.nrows()
.saturating_mul(core_points.nrows())
.saturating_mul(core_points.ncols());
let predictions = map_collect(
new_data.nrows(),
scan_work >= SCAN_F64_PARALLEL_MIN_ELEMS,
|i| {
let row = new_data.row(i);
let mut min_dist = f64::MAX;
let mut label = -1isize;
for (j, core_row) in core_points.rows().into_iter().enumerate() {
let dist = self.metric.distance(row, core_row);
if dist < min_dist {
min_dist = dist;
label = core_point_labels[j];
}
}
if min_dist <= self.eps { label } else { -1 }
},
);
Ok(Array1::from(predictions))
}
pub fn fit_predict<S>(&mut self, data: &ArrayBase<S, Ix2>) -> Result<Array1<isize>, Error>
where
S: Data<Elem = f64> + Send + Sync,
{
self.fit(data)?;
Ok(self.labels.clone().unwrap())
}
model_save_and_load_methods!(DBSCAN);
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn region_query_out_of_bounds_index_gives_computation() {
let data = array![[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]]; let model = DBSCAN::new(0.5, 2).unwrap();
let err = model.region_query(&data, 3, None).unwrap_err(); match err {
Error::Computation { .. } => {}
other => panic!("expected Computation, got {:?}", other),
}
}
}