1use crate::linear::common::{core_array2_to_py, pyarray_to_core_array2, PyValueError};
7use numpy::{PyArray1, PyArray2, PyReadonlyArray2};
8use pyo3::prelude::*;
9use scirs2_core::ndarray::{Array1, Array2};
10use sklears_clustering::kmeans::KMeansFitted;
11use sklears_clustering::{KMeans, KMeansConfig, KMeansInit, DBSCAN};
12use sklears_core::traits::{Fit, Predict, Trained};
13
14#[pyclass(name = "KMeans")]
20pub struct PyKMeans {
21 n_clusters: usize,
22 max_iter: usize,
23 tol: f64,
24 random_state: Option<u64>,
25 fitted: Option<KMeansFitted>,
26}
27
28impl PyKMeans {
29 fn config(&self) -> KMeansConfig {
30 KMeansConfig {
31 n_clusters: self.n_clusters,
32 init: KMeansInit::KMeansPlusPlus,
33 max_iter: self.max_iter,
34 tolerance: self.tol,
35 random_seed: self.random_state,
36 }
37 }
38
39 pub fn fit_core(&mut self, x: &Array2<f64>) -> PyResult<()> {
54 let dummy_y = Array1::<f64>::zeros(x.nrows());
55 let fitted = KMeans::new(self.config())
56 .fit(x, &dummy_y)
57 .map_err(|e| PyValueError::new_err(format!("Failed to fit KMeans: {e}")))?;
58 self.fitted = Some(fitted);
59 Ok(())
60 }
61
62 pub fn predict_core(&self, x: &Array2<f64>) -> PyResult<Vec<i32>> {
66 let fitted = self
67 .fitted
68 .as_ref()
69 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
70 fitted
71 .predict(x)
72 .map_err(|e| PyValueError::new_err(format!("Prediction failed: {e}")))
73 }
74}
75
76#[pymethods]
77impl PyKMeans {
78 #[new]
83 #[pyo3(signature = (n_clusters=8, max_iter=300, tol=1e-4, random_state=None))]
84 pub fn new(n_clusters: usize, max_iter: usize, tol: f64, random_state: Option<u64>) -> Self {
85 Self {
86 n_clusters,
87 max_iter,
88 tol,
89 random_state,
90 fitted: None,
91 }
92 }
93
94 fn fit(&mut self, x: PyReadonlyArray2<f64>) -> PyResult<()> {
96 let x_arr = pyarray_to_core_array2(x)?;
97 self.fit_core(&x_arr)
98 }
99
100 fn predict(&self, py: Python<'_>, x: PyReadonlyArray2<f64>) -> PyResult<Py<PyArray1<i32>>> {
102 let x_arr = pyarray_to_core_array2(x)?;
103 let labels = self.predict_core(&x_arr)?;
104 Ok(PyArray1::from_vec(py, labels).unbind())
105 }
106
107 fn fit_predict(
110 &mut self,
111 py: Python<'_>,
112 x: PyReadonlyArray2<f64>,
113 ) -> PyResult<Py<PyArray1<i32>>> {
114 let x_arr = pyarray_to_core_array2(x)?;
115 self.fit_core(&x_arr)?;
116 let fitted = self
117 .fitted
118 .as_ref()
119 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
120 Ok(PyArray1::from_vec(py, fitted.labels.clone()).unbind())
121 }
122
123 #[getter]
125 fn labels_(&self, py: Python<'_>) -> PyResult<Py<PyArray1<i32>>> {
126 let fitted = self
127 .fitted
128 .as_ref()
129 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
130 Ok(PyArray1::from_vec(py, fitted.labels.clone()).unbind())
131 }
132
133 #[getter]
135 fn cluster_centers_(&self, py: Python<'_>) -> PyResult<Py<PyArray2<f64>>> {
136 let fitted = self
137 .fitted
138 .as_ref()
139 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
140 core_array2_to_py(py, &fitted.centroids)
141 }
142
143 #[getter]
146 fn inertia_(&self) -> PyResult<f64> {
147 let fitted = self
148 .fitted
149 .as_ref()
150 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
151 Ok(fitted.inertia)
152 }
153
154 #[getter]
157 fn n_iter_(&self) -> PyResult<usize> {
158 let fitted = self
159 .fitted
160 .as_ref()
161 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit() first."))?;
162 Ok(fitted.n_iterations)
163 }
164
165 fn __repr__(&self) -> String {
166 format!(
167 "KMeans(n_clusters={}, fitted={})",
168 self.n_clusters,
169 self.fitted.is_some()
170 )
171 }
172}
173
174#[pyclass(name = "DBSCAN")]
181pub struct PyDBSCAN {
182 eps: f64,
183 min_samples: usize,
184 fitted: Option<DBSCAN<Trained>>,
185}
186
187impl PyDBSCAN {
188 pub fn fit_predict_core(&mut self, x: &Array2<f64>) -> PyResult<Vec<i32>> {
193 let model = DBSCAN::new().eps(self.eps).min_samples(self.min_samples);
194 let fitted = model
195 .fit(x, &())
196 .map_err(|e| PyValueError::new_err(format!("Failed to fit DBSCAN: {e}")))?;
197 let labels = fitted.labels().to_vec();
198 self.fitted = Some(fitted);
199 Ok(labels)
200 }
201
202 fn labels_core(&self) -> PyResult<Vec<i32>> {
205 let fitted = self
206 .fitted
207 .as_ref()
208 .ok_or_else(|| PyValueError::new_err("Model not fitted. Call fit_predict() first."))?;
209 Ok(fitted.labels().to_vec())
210 }
211}
212
213#[pymethods]
214impl PyDBSCAN {
215 #[new]
220 #[pyo3(signature = (eps=0.5, min_samples=5))]
221 pub fn new(eps: f64, min_samples: usize) -> Self {
222 Self {
223 eps,
224 min_samples,
225 fitted: None,
226 }
227 }
228
229 fn fit_predict(
232 &mut self,
233 py: Python<'_>,
234 x: PyReadonlyArray2<f64>,
235 ) -> PyResult<Py<PyArray1<i32>>> {
236 let x_arr = pyarray_to_core_array2(x)?;
237 let labels = self.fit_predict_core(&x_arr)?;
238 Ok(PyArray1::from_vec(py, labels).unbind())
239 }
240
241 #[getter]
244 fn labels_(&self, py: Python<'_>) -> PyResult<Py<PyArray1<i32>>> {
245 let labels = self.labels_core()?;
246 Ok(PyArray1::from_vec(py, labels).unbind())
247 }
248
249 fn __repr__(&self) -> String {
250 format!(
251 "DBSCAN(eps={}, min_samples={}, fitted={})",
252 self.eps,
253 self.min_samples,
254 self.fitted.is_some()
255 )
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn two_blob_data() -> Array2<f64> {
267 Array2::from_shape_vec(
268 (6, 2),
269 vec![
270 0.0, 0.0, 0.1, 0.1, 0.2, -0.1, 10.0, 10.0, 10.1, 10.1, 9.9, 10.2,
271 ],
272 )
273 .expect("shape matches data length")
274 }
275
276 #[test]
277 fn kmeans_fit_predict_round_trip_finds_two_clusters() {
278 let mut model = PyKMeans::new(2, 300, 1e-4, Some(42));
279 let x = two_blob_data();
280
281 model
282 .fit_core(&x)
283 .expect("fit should succeed on well-separated data");
284 let labels = model
285 .predict_core(&x)
286 .expect("predict should succeed after fit");
287
288 assert_eq!(labels.len(), 6);
289
290 let distinct: std::collections::HashSet<i32> = labels.iter().copied().collect();
291 assert_eq!(
292 distinct.len(),
293 2,
294 "expected exactly 2 distinct cluster labels, got {distinct:?}"
295 );
296
297 assert_eq!(labels[0], labels[1]);
301 assert_eq!(labels[1], labels[2]);
302 assert_eq!(labels[3], labels[4]);
303 assert_eq!(labels[4], labels[5]);
304 assert_ne!(labels[0], labels[3]);
305 }
306
307 #[test]
308 fn kmeans_fitted_attributes_work_post_fit() {
309 let mut model = PyKMeans::new(2, 300, 1e-4, Some(42));
310 let x = two_blob_data();
311 model.fit_core(&x).expect("fit should succeed");
312
313 let fitted = model.fitted.as_ref().expect("model was just fitted");
314 assert_eq!(fitted.labels.len(), 6);
315 assert_eq!(fitted.centroids.nrows(), 2);
316 assert_eq!(fitted.centroids.ncols(), 2);
317 assert!(fitted.inertia >= 0.0);
318 assert!(fitted.n_iterations >= 1);
319 }
320
321 #[test]
322 fn kmeans_predict_before_fit_errors_instead_of_panicking() {
323 let model = PyKMeans::new(2, 300, 1e-4, Some(42));
324 let x = two_blob_data();
325 assert!(model.predict_core(&x).is_err());
326 }
327
328 #[test]
329 fn dbscan_fit_predict_finds_more_than_one_group() {
330 let mut model = PyDBSCAN::new(1.0, 2);
331 let x = two_blob_data();
332
333 let labels = model
334 .fit_predict_core(&x)
335 .expect("fit_predict should succeed on well-separated data");
336
337 assert_eq!(labels.len(), 6);
338 assert!(
339 !labels.iter().all(|&l| l == 0),
340 "labels must not all be 0 (this is what the old stub always returned): {labels:?}"
341 );
342
343 let distinct: std::collections::HashSet<i32> = labels.iter().copied().collect();
344 assert!(
345 distinct.len() >= 2,
346 "expected at least 2 distinct labels/groups, got {distinct:?}"
347 );
348 }
349
350 #[test]
351 fn dbscan_labels_getter_errors_before_fit_and_works_after() {
352 let mut model = PyDBSCAN::new(1.0, 2);
353 assert!(
354 model.labels_core().is_err(),
355 "labels_ must error before fit_predict, not panic"
356 );
357
358 let x = two_blob_data();
359 model
360 .fit_predict_core(&x)
361 .expect("fit_predict should succeed");
362
363 let labels = model.labels_core().expect("labels_ should work after fit");
364 assert_eq!(labels.len(), 6);
365 }
366}