Initial commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
//! DBSCAN clustering implementation
|
||||
//!
|
||||
//! DBSCAN (Density-Based Spatial Clustering of Applications with Noise)
|
||||
//! groups data points that are closely packed while marking outliers
|
||||
//! as noise in low-density regions.
|
||||
|
||||
use crate::error::Result;
|
||||
use rtx_tensor::Tensor;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// DBSCAN clustering algorithm
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DBSCAN {
|
||||
/// Maximum distance between points in the same neighborhood
|
||||
eps: f32,
|
||||
/// Minimum number of points required to form a dense region
|
||||
min_samples: usize,
|
||||
/// Fitted cluster labels
|
||||
labels: Option<Tensor>,
|
||||
/// Core sample indices
|
||||
core_sample_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Point classification in DBSCAN
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum PointType {
|
||||
Noise = -1,
|
||||
Unclassified = 0,
|
||||
Core = 1,
|
||||
Border = 2,
|
||||
}
|
||||
|
||||
impl DBSCAN {
|
||||
/// Create new DBSCAN clusterer
|
||||
pub fn new(eps: f32, min_samples: usize) -> Self {
|
||||
Self {
|
||||
eps,
|
||||
min_samples,
|
||||
labels: None,
|
||||
core_sample_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set epsilon (maximum distance between points)
|
||||
pub fn eps(mut self, eps: f32) -> Self {
|
||||
self.eps = eps;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set minimum samples for core points
|
||||
pub fn min_samples(mut self, min_samples: usize) -> Self {
|
||||
self.min_samples = min_samples;
|
||||
self
|
||||
}
|
||||
|
||||
/// Fit DBSCAN to data
|
||||
pub fn fit(&mut self, x: &Tensor) -> Result<&mut Self> {
|
||||
// Validate input
|
||||
if x.shape().ndim() != 2 {
|
||||
return Err(crate::error::MLError::invalid_input(
|
||||
"X must be 2-dimensional".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let n_samples = x.shape().dims()[0];
|
||||
let n_features = x.shape().dims()[1];
|
||||
|
||||
if n_samples == 0 {
|
||||
return Err(crate::error::MLError::invalid_input(
|
||||
"X cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Get data as CPU tensor for computation
|
||||
let x_data = x.to_cpu()?;
|
||||
|
||||
// Initialize labels and point types
|
||||
let mut labels = vec![-1i32; n_samples]; // -1 = noise/unassigned
|
||||
let mut point_types = vec![PointType::Unclassified; n_samples];
|
||||
let mut core_samples = Vec::new();
|
||||
|
||||
// Step 1: Identify core points
|
||||
for i in 0..n_samples {
|
||||
let neighbors = self.region_query(&x_data, i, n_samples, n_features);
|
||||
if neighbors.len() >= self.min_samples {
|
||||
point_types[i] = PointType::Core;
|
||||
core_samples.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Form clusters starting from core points
|
||||
let mut cluster_id = 0;
|
||||
|
||||
for &core_idx in &core_samples {
|
||||
if labels[core_idx] != -1 {
|
||||
continue; // Already assigned to a cluster
|
||||
}
|
||||
|
||||
// Start new cluster
|
||||
let mut cluster_stack = vec![core_idx];
|
||||
labels[core_idx] = cluster_id;
|
||||
|
||||
while let Some(current_idx) = cluster_stack.pop() {
|
||||
if point_types[current_idx] == PointType::Core {
|
||||
let neighbors = self.region_query(&x_data, current_idx, n_samples, n_features);
|
||||
|
||||
for &neighbor_idx in &neighbors {
|
||||
if labels[neighbor_idx] == -1 {
|
||||
// Unassigned point - add to cluster
|
||||
labels[neighbor_idx] = cluster_id;
|
||||
|
||||
if point_types[neighbor_idx] == PointType::Core {
|
||||
cluster_stack.push(neighbor_idx);
|
||||
} else {
|
||||
point_types[neighbor_idx] = PointType::Border;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cluster_id += 1;
|
||||
}
|
||||
|
||||
// Convert labels to f32 for tensor compatibility
|
||||
let labels_f32: Vec<f32> = labels.iter().map(|&l| l as f32).collect();
|
||||
|
||||
self.labels = Some(Tensor::from_data(labels_f32, vec![n_samples], x.device())?);
|
||||
self.core_sample_indices = core_samples;
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Predict cluster labels (DBSCAN doesn't support prediction on new data)
|
||||
/// This method fits the algorithm to the provided data
|
||||
pub fn predict(&self, x: &Tensor) -> Result<Tensor> {
|
||||
if self.labels.is_none() {
|
||||
return Err(crate::error::MLError::not_fitted(
|
||||
"Model has not been fitted yet".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// DBSCAN doesn't naturally support prediction on new data
|
||||
// For compatibility, we return the fitted labels if x matches training data
|
||||
let fitted_labels = self.labels.as_ref().unwrap();
|
||||
|
||||
if x.shape().dims()[0] != fitted_labels.shape().dims()[0] {
|
||||
return Err(crate::error::MLError::invalid_input(
|
||||
"DBSCAN doesn't support prediction on new data. Use fit() instead.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(fitted_labels.clone())
|
||||
}
|
||||
|
||||
/// Find all points within epsilon distance of a given point
|
||||
fn region_query(
|
||||
&self,
|
||||
x_data: &[f32],
|
||||
point_idx: usize,
|
||||
n_samples: usize,
|
||||
n_features: usize,
|
||||
) -> Vec<usize> {
|
||||
let mut neighbors = Vec::new();
|
||||
|
||||
for i in 0..n_samples {
|
||||
let distance = self.euclidean_distance(
|
||||
&x_data[point_idx * n_features..(point_idx + 1) * n_features],
|
||||
&x_data[i * n_features..(i + 1) * n_features],
|
||||
);
|
||||
|
||||
if distance <= self.eps {
|
||||
neighbors.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
neighbors
|
||||
}
|
||||
|
||||
/// Compute Euclidean distance between two points
|
||||
fn euclidean_distance(&self, point1: &[f32], point2: &[f32]) -> f32 {
|
||||
point1
|
||||
.iter()
|
||||
.zip(point2.iter())
|
||||
.map(|(&x1, &x2)| (x1 - x2).powi(2))
|
||||
.sum::<f32>()
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
/// Get fitted labels
|
||||
pub fn labels(&self) -> Option<&Tensor> {
|
||||
self.labels.as_ref()
|
||||
}
|
||||
|
||||
/// Get core sample indices
|
||||
pub fn core_sample_indices(&self) -> &[usize] {
|
||||
&self.core_sample_indices
|
||||
}
|
||||
|
||||
/// Get number of clusters found (excluding noise)
|
||||
pub fn n_clusters(&self) -> usize {
|
||||
if let Some(labels) = &self.labels {
|
||||
let labels_data = labels.to_cpu().unwrap_or_default();
|
||||
let unique_labels: HashSet<i32> = labels_data
|
||||
.iter()
|
||||
.map(|&l| l as i32)
|
||||
.filter(|&l| l >= 0) // Exclude noise (-1)
|
||||
.collect();
|
||||
unique_labels.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Get parameters
|
||||
pub fn params(&self) -> (f32, usize) {
|
||||
(self.eps, self.min_samples)
|
||||
}
|
||||
|
||||
/// Check if model is fitted
|
||||
pub fn is_fitted(&self) -> bool {
|
||||
self.labels.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DBSCAN {
|
||||
fn default() -> Self {
|
||||
Self::new(0.5, 5)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user