1use std::collections::{HashMap, HashSet};
27
28use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
29use scirs2_core::random::{thread_rng, Random};
31use sklears_core::error::{Result, SklearsError};
32use sklears_core::traits::{Estimator, Fit, Predict};
33
34#[derive(Debug, Clone, PartialEq)]
36pub enum ConstraintType {
37 MustLink(usize, usize),
39 CannotLink(usize, usize),
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
45pub enum ConstraintHandling {
46 Hard,
48 Soft,
50 Adaptive,
52}
53
54#[derive(Debug, Clone)]
56pub struct ConstrainedKMeansConfig {
57 pub n_clusters: usize,
59 pub max_iter: usize,
61 pub tolerance: f64,
63 pub constraint_handling: ConstraintHandling,
65 pub constraint_penalty: f64,
67 pub random_seed: Option<u64>,
69}
70
71impl Default for ConstrainedKMeansConfig {
72 fn default() -> Self {
73 Self {
74 n_clusters: 2,
75 max_iter: 300,
76 tolerance: 1e-4,
77 constraint_handling: ConstraintHandling::Soft,
78 constraint_penalty: 1.0,
79 random_seed: None,
80 }
81 }
82}
83
84#[derive(Clone)]
86pub struct ConstrainedKMeans {
87 config: ConstrainedKMeansConfig,
88 constraints: Vec<ConstraintType>,
89}
90
91impl Default for ConstrainedKMeans {
92 fn default() -> Self {
93 Self::new(ConstrainedKMeansConfig::default(), Vec::new())
94 }
95}
96
97pub struct ConstrainedKMeansFitted {
99 pub centroids: Array2<f64>,
101 pub labels: Vec<i32>,
103 pub config: ConstrainedKMeansConfig,
105 pub constraints: Vec<ConstraintType>,
107 pub inertia: f64,
109 pub constraint_violations: usize,
111 pub n_iterations: usize,
113}
114
115impl ConstrainedKMeans {
116 pub fn new(config: ConstrainedKMeansConfig, constraints: Vec<ConstraintType>) -> Self {
118 Self {
119 config,
120 constraints,
121 }
122 }
123
124 pub fn add_must_link(mut self, i: usize, j: usize) -> Self {
126 self.constraints.push(ConstraintType::MustLink(i, j));
127 self
128 }
129
130 pub fn add_cannot_link(mut self, i: usize, j: usize) -> Self {
132 self.constraints.push(ConstraintType::CannotLink(i, j));
133 self
134 }
135
136 pub fn constraint_handling(mut self, handling: ConstraintHandling) -> Self {
138 self.config.constraint_handling = handling;
139 self
140 }
141
142 pub fn constraint_penalty(mut self, penalty: f64) -> Self {
144 self.config.constraint_penalty = penalty;
145 self
146 }
147
148 fn check_constraints(&self, labels: &[i32]) -> (bool, usize) {
150 let mut violations = 0;
151
152 for constraint in &self.constraints {
153 match constraint {
154 ConstraintType::MustLink(i, j) => {
155 if labels[*i] != labels[*j] {
156 violations += 1;
157 }
158 }
159 ConstraintType::CannotLink(i, j) => {
160 if labels[*i] == labels[*j] {
161 violations += 1;
162 }
163 }
164 }
165 }
166
167 (violations == 0, violations)
168 }
169
170 fn compute_constraint_penalty(&self, labels: &[i32]) -> f64 {
172 let (_, violations) = self.check_constraints(labels);
173 violations as f64 * self.config.constraint_penalty
174 }
175
176 fn initialize_centroids_constrained(&self, X: &Array2<f64>) -> Result<Array2<f64>> {
178 let n_samples = X.nrows();
179 let n_features = X.ncols();
180
181 if n_samples < self.config.n_clusters {
182 return Err(SklearsError::InvalidInput(
183 "Number of samples must be >= number of clusters".to_string(),
184 ));
185 }
186
187 let mut components: Vec<HashSet<usize>> = Vec::new();
189 let mut point_to_component: HashMap<usize, usize> = HashMap::new();
190
191 for i in 0..n_samples {
193 let mut component = HashSet::new();
194 component.insert(i);
195 point_to_component.insert(i, components.len());
196 components.push(component);
197 }
198
199 for constraint in &self.constraints {
201 if let ConstraintType::MustLink(i, j) = constraint {
202 let comp_i = point_to_component[i];
203 let comp_j = point_to_component[j];
204
205 if comp_i != comp_j {
206 let comp_j_points: Vec<_> = components[comp_j].iter().cloned().collect();
208 for point in comp_j_points {
209 components[comp_i].insert(point);
210 point_to_component.insert(point, comp_i);
211 }
212 components[comp_j].clear();
213 }
214 }
215 }
216
217 components.retain(|comp| !comp.is_empty());
219
220 let mut rng = if let Some(seed) = self.config.random_seed {
222 Random::seed(seed)
223 } else {
224 Random::seed(42) };
226
227 let mut centroids = Array2::<f64>::zeros((self.config.n_clusters, n_features));
228
229 if components.len() > self.config.n_clusters {
231 components.sort_by_key(|comp| std::cmp::Reverse(comp.len()));
232 components.truncate(self.config.n_clusters);
233 }
234
235 for (k, component) in components.iter().enumerate() {
237 if k >= self.config.n_clusters {
238 break;
239 }
240
241 let mut centroid = Array1::<f64>::zeros(n_features);
243 for &point_idx in component {
244 centroid = centroid + X.row(point_idx);
245 }
246 centroid /= component.len() as f64;
247 centroids.row_mut(k).assign(¢roid);
248 }
249
250 let used_points: HashSet<usize> = components.iter().flatten().cloned().collect();
252 let remaining_points: Vec<usize> = (0..n_samples)
253 .filter(|i| !used_points.contains(i))
254 .collect();
255
256 let mut remaining_points = remaining_points;
257 for i in (1..remaining_points.len()).rev() {
259 let j = rng.gen_range(0..i + 1);
260 remaining_points.swap(i, j);
261 }
262
263 for k in components.len()..self.config.n_clusters {
264 if let Some(&point_idx) = remaining_points.get(k - components.len()) {
265 centroids.row_mut(k).assign(&X.row(point_idx));
266 } else {
267 for j in 0..n_features {
269 centroids[[k, j]] = rng.random_range(-1.0..1.0);
270 }
271 }
272 }
273
274 Ok(centroids)
275 }
276
277 fn constrained_assignment(&self, X: &Array2<f64>, centroids: &Array2<f64>) -> (Vec<i32>, f64) {
279 let n_samples = X.nrows();
280 let mut labels = vec![0i32; n_samples];
281 let mut inertia = 0.0;
282
283 match self.config.constraint_handling {
284 ConstraintHandling::Hard => {
285 self.hard_constrained_assignment(X, centroids, &mut labels, &mut inertia);
287 }
288 ConstraintHandling::Soft | ConstraintHandling::Adaptive => {
289 self.soft_constrained_assignment(X, centroids, &mut labels, &mut inertia);
291 }
292 }
293
294 (labels, inertia)
295 }
296
297 fn hard_constrained_assignment(
299 &self,
300 X: &Array2<f64>,
301 centroids: &Array2<f64>,
302 labels: &mut [i32],
303 inertia: &mut f64,
304 ) {
305 for i in 0..X.nrows() {
307 let point = X.row(i);
308 let mut best_cluster = 0;
309 let mut min_distance = f64::INFINITY;
310
311 for k in 0..self.config.n_clusters {
312 let centroid = centroids.row(k);
313 let distance = self.euclidean_distance(point, centroid);
314
315 if distance < min_distance {
316 min_distance = distance;
317 best_cluster = k as i32;
318 }
319 }
320
321 labels[i] = best_cluster;
322 *inertia += min_distance * min_distance;
323 }
324
325 let mut changed = true;
327 let max_iterations = 100;
328 let mut iteration = 0;
329
330 while changed && iteration < max_iterations {
331 changed = false;
332 iteration += 1;
333
334 for constraint in &self.constraints {
335 match constraint {
336 ConstraintType::MustLink(i, j) => {
337 if labels[*i] != labels[*j] {
338 let dist_i_to_j = self
340 .euclidean_distance(X.row(*i), centroids.row(labels[*j] as usize));
341 let dist_j_to_i = self
342 .euclidean_distance(X.row(*j), centroids.row(labels[*i] as usize));
343
344 if dist_i_to_j < dist_j_to_i {
345 labels[*i] = labels[*j];
346 } else {
347 labels[*j] = labels[*i];
348 }
349 changed = true;
350 }
351 }
352 ConstraintType::CannotLink(i, j) => {
353 if labels[*i] == labels[*j] {
354 let mut best_i_cluster = labels[*i];
356 let mut best_i_distance = f64::INFINITY;
357 let mut best_j_cluster = labels[*j];
358 let mut best_j_distance = f64::INFINITY;
359
360 for k in 0..self.config.n_clusters {
361 if k as i32 == labels[*i] {
362 continue;
363 }
364
365 let dist_i = self.euclidean_distance(X.row(*i), centroids.row(k));
366 let dist_j = self.euclidean_distance(X.row(*j), centroids.row(k));
367
368 if dist_i < best_i_distance {
369 best_i_distance = dist_i;
370 best_i_cluster = k as i32;
371 }
372 if dist_j < best_j_distance {
373 best_j_distance = dist_j;
374 best_j_cluster = k as i32;
375 }
376 }
377
378 if best_i_distance < best_j_distance {
380 labels[*i] = best_i_cluster;
381 } else {
382 labels[*j] = best_j_cluster;
383 }
384 changed = true;
385 }
386 }
387 }
388 }
389 }
390
391 *inertia = 0.0;
393 for i in 0..X.nrows() {
394 let point = X.row(i);
395 let centroid = centroids.row(labels[i] as usize);
396 let distance = self.euclidean_distance(point, centroid);
397 *inertia += distance * distance;
398 }
399 }
400
401 fn soft_constrained_assignment(
403 &self,
404 X: &Array2<f64>,
405 centroids: &Array2<f64>,
406 labels: &mut [i32],
407 inertia: &mut f64,
408 ) {
409 *inertia = 0.0;
410
411 for i in 0..X.nrows() {
412 let point = X.row(i);
413 let mut best_cluster = 0;
414 let mut min_cost = f64::INFINITY;
415
416 for k in 0..self.config.n_clusters {
417 let centroid = centroids.row(k);
418 let distance = self.euclidean_distance(point, centroid);
419 let mut cost = distance * distance;
420
421 labels[i] = k as i32; cost += self.compute_constraint_penalty(labels);
424
425 if cost < min_cost {
426 min_cost = cost;
427 best_cluster = k as i32;
428 }
429 }
430
431 labels[i] = best_cluster;
432 let centroid = centroids.row(best_cluster as usize);
433 let distance = self.euclidean_distance(point, centroid);
434 *inertia += distance * distance;
435 }
436 }
437
438 fn euclidean_distance(&self, a: ArrayView1<f64>, b: ArrayView1<f64>) -> f64 {
440 a.iter()
441 .zip(b.iter())
442 .map(|(x, y)| (x - y).powi(2))
443 .sum::<f64>()
444 .sqrt()
445 }
446
447 fn update_centroids(&self, X: &Array2<f64>, labels: &[i32]) -> Array2<f64> {
449 let n_features = X.ncols();
450 let mut new_centroids = Array2::<f64>::zeros((self.config.n_clusters, n_features));
451
452 for k in 0..self.config.n_clusters {
453 let cluster_points: Vec<_> = labels
454 .iter()
455 .enumerate()
456 .filter(|(_, &label)| label == k as i32)
457 .map(|(i, _)| i)
458 .collect();
459
460 if !cluster_points.is_empty() {
461 let mut centroid = Array1::<f64>::zeros(n_features);
462 for &point_idx in &cluster_points {
463 centroid = centroid + X.row(point_idx);
464 }
465 centroid /= cluster_points.len() as f64;
466 new_centroids.row_mut(k).assign(¢roid);
467 }
468 }
469
470 new_centroids
471 }
472}
473
474impl Estimator for ConstrainedKMeans {
475 type Config = ConstrainedKMeansConfig;
476 type Error = SklearsError;
477 type Float = f64;
478
479 fn config(&self) -> &Self::Config {
480 &self.config
481 }
482}
483
484impl Fit<Array2<f64>, Array1<f64>> for ConstrainedKMeans {
485 type Fitted = ConstrainedKMeansFitted;
486
487 fn fit(self, X: &Array2<f64>, _y: &Array1<f64>) -> Result<Self::Fitted> {
488 let config = self.clone();
489 if X.is_empty() || X.nrows() == 0 {
490 return Err(SklearsError::InvalidInput(
491 "Input data is empty".to_string(),
492 ));
493 }
494
495 let mut centroids = self.initialize_centroids_constrained(X)?;
497 let mut previous_inertia = f64::INFINITY;
498
499 let mut final_labels = Vec::new();
500 let mut final_inertia = 0.0;
501
502 for iteration in 0..self.config.max_iter {
504 let (labels, inertia) = self.constrained_assignment(X, ¢roids);
506
507 if (previous_inertia - inertia).abs() < self.config.tolerance {
509 final_labels = labels.clone();
510 final_inertia = inertia;
511
512 let (_, constraint_violations) = self.check_constraints(&labels);
513
514 return Ok(ConstrainedKMeansFitted {
515 centroids,
516 labels: final_labels,
517 config: config.config.clone(),
518 constraints: config.constraints.clone(),
519 inertia: final_inertia,
520 constraint_violations,
521 n_iterations: iteration + 1,
522 });
523 }
524
525 centroids = self.update_centroids(X, &labels);
527
528 previous_inertia = inertia;
529 final_labels = labels;
530 final_inertia = inertia;
531 }
532
533 let (_, constraint_violations) = self.check_constraints(&final_labels);
534
535 Ok(ConstrainedKMeansFitted {
536 centroids,
537 labels: final_labels,
538 config: self.config.clone(),
539 constraints: self.constraints.clone(),
540 inertia: final_inertia,
541 constraint_violations,
542 n_iterations: self.config.max_iter,
543 })
544 }
545}
546
547impl Predict<Array2<f64>, Vec<i32>> for ConstrainedKMeansFitted {
548 fn predict(&self, X: &Array2<f64>) -> Result<Vec<i32>> {
549 if X.is_empty() {
550 return Ok(vec![]);
551 }
552
553 let clusterer = ConstrainedKMeans::new(self.config.clone(), self.constraints.clone());
554 let (labels, _) = clusterer.constrained_assignment(X, &self.centroids);
555 Ok(labels)
556 }
557}
558
559#[derive(Debug, Clone)]
561pub struct LabelPropagationConfig {
562 pub max_iter: usize,
564 pub tolerance: f64,
566 pub alpha: f64,
568 pub random_seed: Option<u64>,
570}
571
572impl Default for LabelPropagationConfig {
573 fn default() -> Self {
574 Self {
575 max_iter: 1000,
576 tolerance: 1e-6,
577 alpha: 0.2,
578 random_seed: None,
579 }
580 }
581}
582
583pub struct LabelPropagation {
585 config: LabelPropagationConfig,
586}
587
588impl Default for LabelPropagation {
589 fn default() -> Self {
590 Self::new(LabelPropagationConfig::default())
591 }
592}
593
594pub struct LabelPropagationFitted {
596 pub label_probabilities: Array2<f64>,
598 pub labels: Vec<i32>,
600 pub config: LabelPropagationConfig,
602 pub n_iterations: usize,
604}
605
606impl LabelPropagation {
607 pub fn new(config: LabelPropagationConfig) -> Self {
609 Self { config }
610 }
611
612 pub fn alpha(mut self, alpha: f64) -> Self {
614 self.config.alpha = alpha;
615 self
616 }
617
618 fn compute_similarity_matrix(&self, X: &Array2<f64>, gamma: f64) -> Array2<f64> {
620 let n_samples = X.nrows();
621 let mut similarity = Array2::<f64>::zeros((n_samples, n_samples));
622
623 for i in 0..n_samples {
624 for j in i..n_samples {
625 let dist_sq = X
626 .row(i)
627 .iter()
628 .zip(X.row(j).iter())
629 .map(|(a, b)| (a - b).powi(2))
630 .sum::<f64>();
631
632 let sim = (-gamma * dist_sq).exp();
633 similarity[[i, j]] = sim;
634 similarity[[j, i]] = sim;
635 }
636 }
637
638 similarity
639 }
640
641 fn normalize_transition_matrix(&self, similarity: &Array2<f64>) -> Array2<f64> {
643 let n_samples = similarity.nrows();
644 let mut transition = similarity.clone();
645
646 for i in 0..n_samples {
647 let row_sum: f64 = similarity.row(i).sum();
648 if row_sum > 0.0 {
649 for j in 0..n_samples {
650 transition[[i, j]] /= row_sum;
651 }
652 }
653 }
654
655 transition
656 }
657}
658
659impl Estimator for LabelPropagation {
660 type Config = LabelPropagationConfig;
661 type Error = SklearsError;
662 type Float = f64;
663
664 fn config(&self) -> &Self::Config {
665 &self.config
666 }
667}
668
669impl LabelPropagation {
670 pub fn fit_partial(
672 &self,
673 X: &Array2<f64>,
674 partial_labels: &[Option<i32>],
675 ) -> Result<LabelPropagationFitted> {
676 if X.is_empty() || X.nrows() == 0 {
677 return Err(SklearsError::InvalidInput(
678 "Input data is empty".to_string(),
679 ));
680 }
681
682 if X.nrows() != partial_labels.len() {
683 return Err(SklearsError::InvalidInput(
684 "Data and labels length mismatch".to_string(),
685 ));
686 }
687
688 let n_samples = X.nrows();
689
690 let unique_labels: HashSet<i32> =
692 partial_labels.iter().filter_map(|&label| label).collect();
693
694 if unique_labels.is_empty() {
695 return Err(SklearsError::InvalidInput(
696 "No labeled samples provided".to_string(),
697 ));
698 }
699
700 let n_classes = unique_labels.len();
701 let label_to_idx: HashMap<i32, usize> = unique_labels
702 .iter()
703 .enumerate()
704 .map(|(i, &label)| (label, i))
705 .collect();
706
707 let mut label_probs = Array2::<f64>::zeros((n_samples, n_classes));
709
710 for (i, &label) in partial_labels.iter().enumerate() {
712 if let Some(l) = label {
713 if let Some(&class_idx) = label_to_idx.get(&l) {
714 label_probs[[i, class_idx]] = 1.0;
715 }
716 }
717 }
718
719 let gamma = 1.0 / (X.ncols() as f64 * X.var(0.0));
721 let similarity = self.compute_similarity_matrix(X, gamma);
722 let transition = self.normalize_transition_matrix(&similarity);
723
724 let mut previous_probs = label_probs.clone();
726
727 for iteration in 0..self.config.max_iter {
728 let mut new_probs = Array2::<f64>::zeros((n_samples, n_classes));
730
731 for i in 0..n_samples {
732 for j in 0..n_classes {
733 let propagated: f64 = (0..n_samples)
734 .map(|k| transition[[i, k]] * label_probs[[k, j]])
735 .sum();
736
737 new_probs[[i, j]] = propagated;
738 }
739 }
740
741 for (i, &label) in partial_labels.iter().enumerate() {
743 if let Some(l) = label {
744 if let Some(&class_idx) = label_to_idx.get(&l) {
745 for j in 0..n_classes {
747 new_probs[[i, j]] = 0.0;
748 }
749 new_probs[[i, class_idx]] = 1.0;
750 }
751 }
752 }
753
754 for i in 0..n_samples {
756 if partial_labels[i].is_none() {
757 for j in 0..n_classes {
758 new_probs[[i, j]] = self.config.alpha * new_probs[[i, j]]
759 + (1.0 - self.config.alpha) * previous_probs[[i, j]];
760 }
761 }
762 }
763
764 let change: f64 = new_probs
766 .iter()
767 .zip(label_probs.iter())
768 .map(|(new, old)| (new - old).abs())
769 .sum();
770
771 label_probs = new_probs;
772
773 if change < self.config.tolerance {
774 let labels: Vec<i32> = (0..n_samples)
776 .map(|i| {
777 let max_prob_idx = (0..n_classes)
778 .max_by(|&a, &b| {
779 label_probs[[i, a]]
780 .partial_cmp(&label_probs[[i, b]])
781 .unwrap_or(std::cmp::Ordering::Equal)
782 })
783 .unwrap_or(0);
784
785 label_to_idx
787 .iter()
788 .find(|(_, &idx)| idx == max_prob_idx)
789 .map(|(&original_label, _)| original_label)
790 .unwrap_or(0)
791 })
792 .collect();
793
794 return Ok(LabelPropagationFitted {
795 label_probabilities: label_probs,
796 labels,
797 config: self.config.clone(),
798 n_iterations: iteration + 1,
799 });
800 }
801
802 previous_probs = label_probs.clone();
803 }
804
805 let labels: Vec<i32> = (0..n_samples)
807 .map(|i| {
808 let max_prob_idx = (0..n_classes)
809 .max_by(|&a, &b| {
810 label_probs[[i, a]]
811 .partial_cmp(&label_probs[[i, b]])
812 .unwrap_or(std::cmp::Ordering::Equal)
813 })
814 .unwrap_or(0);
815
816 label_to_idx
817 .iter()
818 .find(|(_, &idx)| idx == max_prob_idx)
819 .map(|(&original_label, _)| original_label)
820 .unwrap_or(0)
821 })
822 .collect();
823
824 Ok(LabelPropagationFitted {
825 label_probabilities: label_probs,
826 labels,
827 config: self.config.clone(),
828 n_iterations: self.config.max_iter,
829 })
830 }
831}
832
833impl Predict<Array2<f64>, Vec<i32>> for LabelPropagationFitted {
834 fn predict(&self, _X: &Array2<f64>) -> Result<Vec<i32>> {
835 Err(SklearsError::InvalidInput(
838 "Label propagation does not support out-of-sample prediction".to_string(),
839 ))
840 }
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 #[test]
848 fn test_constrained_kmeans_basic() {
849 let X = Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 1.1, 1.1, 5.0, 5.0, 5.1, 5.1])
850 .expect("operation should succeed");
851
852 let config = ConstrainedKMeansConfig {
853 n_clusters: 2,
854 max_iter: 100,
855 tolerance: 1e-4,
856 constraint_handling: ConstraintHandling::Soft,
857 constraint_penalty: 1.0,
858 random_seed: Some(42),
859 };
860
861 let clusterer = ConstrainedKMeans::new(
862 config,
863 vec![
864 ConstraintType::MustLink(0, 1),
865 ConstraintType::CannotLink(0, 2),
866 ],
867 );
868
869 let dummy_y = Array1::<f64>::zeros(X.nrows());
870 let fitted = clusterer
871 .fit(&X, &dummy_y)
872 .expect("operation should succeed");
873
874 assert_eq!(fitted.labels.len(), 4);
875 assert!(fitted.n_iterations <= 100);
876 assert!(fitted.inertia >= 0.0);
877
878 assert_eq!(fitted.labels[0], fitted.labels[1]);
880 assert_ne!(fitted.labels[0], fitted.labels[2]);
882 }
883
884 #[test]
885 fn test_constraint_checking() {
886 let clusterer = ConstrainedKMeans::new(
887 ConstrainedKMeansConfig::default(),
888 vec![
889 ConstraintType::MustLink(0, 1),
890 ConstraintType::CannotLink(2, 3),
891 ],
892 );
893
894 let labels = vec![0, 0, 1, 1]; let (satisfied, violations) = clusterer.check_constraints(&labels);
896
897 assert!(!satisfied);
898 assert_eq!(violations, 1);
899
900 let labels = vec![0, 0, 1, 2]; let (satisfied, violations) = clusterer.check_constraints(&labels);
902
903 assert!(satisfied);
904 assert_eq!(violations, 0);
905 }
906
907 #[test]
908 fn test_label_propagation_basic() {
909 let X = Array2::from_shape_vec((4, 2), vec![1.0, 1.0, 1.1, 1.1, 5.0, 5.0, 5.1, 5.1])
910 .expect("operation should succeed");
911
912 let partial_labels = vec![Some(0), Some(0), None, None];
914
915 let config = LabelPropagationConfig {
916 max_iter: 100,
917 tolerance: 1e-6,
918 alpha: 0.2,
919 random_seed: Some(42),
920 };
921
922 let clusterer = LabelPropagation::new(config);
923 let fitted = clusterer
924 .fit_partial(&X, &partial_labels)
925 .expect("operation should succeed");
926
927 assert_eq!(fitted.labels.len(), 4);
928 assert!(fitted.n_iterations <= 100);
929
930 assert_eq!(fitted.labels[0], 0);
932 assert_eq!(fitted.labels[1], 0);
933 }
934
935 #[test]
936 fn test_constraint_types() {
937 let must_link = ConstraintType::MustLink(0, 1);
938 let cannot_link = ConstraintType::CannotLink(2, 3);
939
940 match must_link {
941 ConstraintType::MustLink(i, j) => {
942 assert_eq!(i, 0);
943 assert_eq!(j, 1);
944 }
945 _ => panic!("Wrong constraint type"),
946 }
947
948 match cannot_link {
949 ConstraintType::CannotLink(i, j) => {
950 assert_eq!(i, 2);
951 assert_eq!(j, 3);
952 }
953 _ => panic!("Wrong constraint type"),
954 }
955 }
956}
957
958#[derive(Debug, Clone)]
960pub struct SemiSupervisedSpectralConfig {
961 pub n_clusters: usize,
963 pub n_eigenvectors: Option<usize>,
965 pub constraint_weight: f64,
967 pub similarity_method: String,
969 pub random_seed: Option<u64>,
971}
972
973impl Default for SemiSupervisedSpectralConfig {
974 fn default() -> Self {
975 Self {
976 n_clusters: 2,
977 n_eigenvectors: None,
978 constraint_weight: 1.0,
979 similarity_method: "rbf".to_string(),
980 random_seed: None,
981 }
982 }
983}
984
985pub struct SemiSupervisedSpectral {
987 config: SemiSupervisedSpectralConfig,
988}
989
990pub struct SemiSupervisedSpectralFitted {
992 pub labels: Vec<i32>,
994 pub affinity_matrix: Array2<f64>,
996 pub eigenvectors: Array2<f64>,
998 pub n_clusters: usize,
1000}
1001
1002impl SemiSupervisedSpectral {
1003 pub fn new(config: SemiSupervisedSpectralConfig) -> Self {
1005 Self { config }
1006 }
1007
1008 pub fn fit_constrained(
1010 &self,
1011 X: &Array2<f64>,
1012 constraints: &[ConstraintType],
1013 ) -> Result<SemiSupervisedSpectralFitted> {
1014 let mut affinity = self.compute_affinity_matrix(X)?;
1016
1017 self.apply_constraints_to_affinity(&mut affinity, constraints)?;
1019
1020 let laplacian = self.compute_normalized_laplacian(&affinity)?;
1022
1023 let n_eigenvectors = self.config.n_eigenvectors.unwrap_or(self.config.n_clusters);
1025 let eigenvectors = self.compute_eigenvectors(&laplacian, n_eigenvectors)?;
1026
1027 let labels = self.cluster_eigenvectors(&eigenvectors)?;
1029
1030 Ok(SemiSupervisedSpectralFitted {
1031 labels,
1032 affinity_matrix: affinity,
1033 eigenvectors,
1034 n_clusters: self.config.n_clusters,
1035 })
1036 }
1037
1038 fn compute_affinity_matrix(&self, X: &Array2<f64>) -> Result<Array2<f64>> {
1040 let n_samples = X.nrows();
1041 let mut affinity = Array2::zeros((n_samples, n_samples));
1042
1043 match self.config.similarity_method.as_str() {
1044 "rbf" => {
1045 let gamma = 1.0; for i in 0..n_samples {
1047 for j in i..n_samples {
1048 let distance_sq: f64 = X
1049 .row(i)
1050 .iter()
1051 .zip(X.row(j).iter())
1052 .map(|(a, b)| (a - b).powi(2))
1053 .sum();
1054 let similarity = (-gamma * distance_sq).exp();
1055 affinity[[i, j]] = similarity;
1056 affinity[[j, i]] = similarity;
1057 }
1058 }
1059 }
1060 "knn" => {
1061 let k = 10; for i in 0..n_samples {
1064 let mut distances: Vec<(usize, f64)> = (0..n_samples)
1065 .map(|j| {
1066 let dist: f64 = X
1067 .row(i)
1068 .iter()
1069 .zip(X.row(j).iter())
1070 .map(|(a, b)| (a - b).powi(2))
1071 .sum::<f64>()
1072 .sqrt();
1073 (j, dist)
1074 })
1075 .collect();
1076
1077 distances
1078 .sort_by(|a, b| a.1.partial_cmp(&b.1).expect("operation should succeed"));
1079
1080 for (neighbor_idx, _) in distances.iter().take(k + 1) {
1081 if *neighbor_idx != i {
1082 affinity[[i, *neighbor_idx]] = 1.0;
1083 affinity[[*neighbor_idx, i]] = 1.0;
1084 }
1085 }
1086 }
1087 }
1088 _ => {
1089 return Err(SklearsError::InvalidInput(
1090 "Invalid similarity method. Use 'rbf' or 'knn'".to_string(),
1091 ));
1092 }
1093 }
1094
1095 Ok(affinity)
1096 }
1097
1098 fn apply_constraints_to_affinity(
1100 &self,
1101 affinity: &mut Array2<f64>,
1102 constraints: &[ConstraintType],
1103 ) -> Result<()> {
1104 for constraint in constraints {
1105 match constraint {
1106 ConstraintType::MustLink(i, j) => {
1107 let boost = self.config.constraint_weight;
1109 affinity[[*i, *j]] += boost;
1110 affinity[[*j, *i]] += boost;
1111 }
1112 ConstraintType::CannotLink(i, j) => {
1113 let penalty = self.config.constraint_weight;
1115 affinity[[*i, *j]] = (affinity[[*i, *j]] - penalty).max(0.0);
1116 affinity[[*j, *i]] = (affinity[[*j, *i]] - penalty).max(0.0);
1117 }
1118 }
1119 }
1120 Ok(())
1121 }
1122
1123 fn compute_normalized_laplacian(&self, affinity: &Array2<f64>) -> Result<Array2<f64>> {
1125 let n = affinity.nrows();
1126 let mut laplacian = Array2::zeros((n, n));
1127
1128 let mut degrees = vec![0.0; n];
1130 for i in 0..n {
1131 degrees[i] = affinity.row(i).sum();
1132 }
1133
1134 for i in 0..n {
1136 laplacian[[i, i]] = 1.0;
1137 let sqrt_deg_i = if degrees[i] > 0.0 {
1138 degrees[i].sqrt()
1139 } else {
1140 0.0
1141 };
1142
1143 for j in 0..n {
1144 if i != j && affinity[[i, j]] > 0.0 {
1145 let sqrt_deg_j = if degrees[j] > 0.0 {
1146 degrees[j].sqrt()
1147 } else {
1148 0.0
1149 };
1150 if sqrt_deg_i > 0.0 && sqrt_deg_j > 0.0 {
1151 laplacian[[i, j]] = -affinity[[i, j]] / (sqrt_deg_i * sqrt_deg_j);
1152 }
1153 }
1154 }
1155 }
1156
1157 Ok(laplacian)
1158 }
1159
1160 fn compute_eigenvectors(
1162 &self,
1163 laplacian: &Array2<f64>,
1164 n_eigenvectors: usize,
1165 ) -> Result<Array2<f64>> {
1166 let n = laplacian.nrows();
1167 let k = n_eigenvectors.min(n);
1168
1169 let (_eigenvalues, eigenvectors) =
1171 scirs2_linalg::compat::eigh(laplacian, scirs2_linalg::compat::UPLO::Lower)
1172 .map_err(|e| SklearsError::NumericalError(e.to_string()))?;
1173
1174 let selected = eigenvectors
1176 .slice(scirs2_core::ndarray::s![.., ..k])
1177 .to_owned();
1178 Ok(selected)
1179 }
1180
1181 fn cluster_eigenvectors(&self, eigenvectors: &Array2<f64>) -> Result<Vec<i32>> {
1183 let n_points = eigenvectors.nrows();
1184 let n_clusters = self.config.n_clusters;
1185
1186 if n_clusters >= n_points {
1187 return Ok((0..n_points).map(|i| i as i32).collect());
1188 }
1189
1190 let mut rng = thread_rng();
1192
1193 let mut clusters = Vec::new();
1194 for _ in 0..n_points {
1195 clusters.push(rng.gen_range(0..n_clusters) as i32);
1196 }
1197
1198 Ok(clusters)
1199 }
1200}
1201
1202impl Estimator for SemiSupervisedSpectral {
1203 type Config = SemiSupervisedSpectralConfig;
1204 type Error = SklearsError;
1205 type Float = f64;
1206
1207 fn config(&self) -> &Self::Config {
1208 &self.config
1209 }
1210}
1211
1212#[derive(Debug, Clone)]
1214pub struct ActiveClusteringConfig {
1215 pub base_algorithm: String,
1217 pub queries_per_iteration: usize,
1219 pub max_iterations: usize,
1221 pub query_strategy: String,
1223 pub random_seed: Option<u64>,
1225}
1226
1227impl Default for ActiveClusteringConfig {
1228 fn default() -> Self {
1229 Self {
1230 base_algorithm: "kmeans".to_string(),
1231 queries_per_iteration: 5,
1232 max_iterations: 10,
1233 query_strategy: "uncertainty".to_string(),
1234 random_seed: None,
1235 }
1236 }
1237}
1238
1239pub struct ActiveClustering {
1241 config: ActiveClusteringConfig,
1242}
1243
1244pub struct ActiveClusteringFitted {
1246 pub labels: Vec<i32>,
1248 pub constraints: Vec<ConstraintType>,
1250 pub n_queries: usize,
1252 pub quality_score: f64,
1254}
1255
1256#[derive(Debug, Clone, PartialEq)]
1258pub enum QueryResponse {
1259 SameCluster,
1261 DifferentCluster,
1263 Unknown,
1265}
1266
1267#[derive(Debug, Clone)]
1269pub struct ClusteringQuery {
1270 pub point_indices: (usize, usize),
1272 pub current_clusters: (i32, i32),
1274 pub uncertainty_score: f64,
1276}
1277
1278impl ActiveClustering {
1279 pub fn new(config: ActiveClusteringConfig) -> Self {
1281 Self { config }
1282 }
1283
1284 pub fn fit_interactive<F>(
1286 &self,
1287 X: &Array2<f64>,
1288 mut feedback_fn: F,
1289 ) -> Result<ActiveClusteringFitted>
1290 where
1291 F: FnMut(&ClusteringQuery) -> QueryResponse,
1292 {
1293 let mut constraints = Vec::new();
1294 let mut total_queries = 0;
1295
1296 let mut current_labels = self.run_base_clustering(X, &constraints)?;
1298
1299 for iteration in 0..self.config.max_iterations {
1300 let queries = self.generate_queries(X, ¤t_labels)?;
1302
1303 if queries.is_empty() {
1304 break; }
1306
1307 let mut new_constraints = Vec::new();
1309 for query in queries.iter().take(self.config.queries_per_iteration) {
1310 let response = feedback_fn(query);
1311 match response {
1312 QueryResponse::SameCluster => {
1313 new_constraints.push(ConstraintType::MustLink(
1314 query.point_indices.0,
1315 query.point_indices.1,
1316 ));
1317 }
1318 QueryResponse::DifferentCluster => {
1319 new_constraints.push(ConstraintType::CannotLink(
1320 query.point_indices.0,
1321 query.point_indices.1,
1322 ));
1323 }
1324 QueryResponse::Unknown => {
1325 }
1327 }
1328 total_queries += 1;
1329 }
1330
1331 constraints.extend(new_constraints);
1333
1334 current_labels = self.run_base_clustering(X, &constraints)?;
1336
1337 if iteration > 0 {
1339 }
1341 }
1342
1343 let quality_score = self.compute_quality_score(X, ¤t_labels);
1345
1346 Ok(ActiveClusteringFitted {
1347 labels: current_labels,
1348 constraints,
1349 n_queries: total_queries,
1350 quality_score,
1351 })
1352 }
1353
1354 fn generate_queries(
1356 &self,
1357 X: &Array2<f64>,
1358 current_labels: &[i32],
1359 ) -> Result<Vec<ClusteringQuery>> {
1360 let n_samples = X.nrows();
1361 let mut queries = Vec::new();
1362
1363 match self.config.query_strategy.as_str() {
1364 "uncertainty" => {
1365 for i in 0..n_samples {
1367 for j in (i + 1)..n_samples {
1368 let uncertainty = self.compute_uncertainty(X, i, j, current_labels);
1369
1370 queries.push(ClusteringQuery {
1371 point_indices: (i, j),
1372 current_clusters: (current_labels[i], current_labels[j]),
1373 uncertainty_score: uncertainty,
1374 });
1375 }
1376 }
1377
1378 queries.sort_by(|a, b| {
1380 b.uncertainty_score
1381 .partial_cmp(&a.uncertainty_score)
1382 .expect("operation should succeed")
1383 });
1384 }
1385 "random" => {
1386 let mut rng = thread_rng();
1388
1389 for _ in 0..(self.config.queries_per_iteration * 3) {
1390 let i = rng.gen_range(0..n_samples);
1391 let j = rng.gen_range(0..n_samples);
1392 if i != j {
1393 queries.push(ClusteringQuery {
1394 point_indices: (i, j),
1395 current_clusters: (current_labels[i], current_labels[j]),
1396 uncertainty_score: rng.random_range(0.0..1.0),
1397 });
1398 }
1399 }
1400 }
1401 _ => {
1402 return Err(SklearsError::InvalidInput(
1403 "Invalid query strategy. Use 'uncertainty' or 'random'".to_string(),
1404 ));
1405 }
1406 }
1407
1408 Ok(queries)
1409 }
1410
1411 fn compute_uncertainty(&self, X: &Array2<f64>, i: usize, j: usize, labels: &[i32]) -> f64 {
1413 let distance: f64 = X
1415 .row(i)
1416 .iter()
1417 .zip(X.row(j).iter())
1418 .map(|(a, b)| (a - b).powi(2))
1419 .sum::<f64>()
1420 .sqrt();
1421
1422 let same_cluster = labels[i] == labels[j];
1424
1425 if same_cluster {
1427 distance
1429 } else {
1430 1.0 / (1.0 + distance)
1432 }
1433 }
1434
1435 fn run_base_clustering(
1437 &self,
1438 X: &Array2<f64>,
1439 constraints: &[ConstraintType],
1440 ) -> Result<Vec<i32>> {
1441 match self.config.base_algorithm.as_str() {
1442 "kmeans" => {
1443 let constrained_config = ConstrainedKMeansConfig {
1445 n_clusters: 3, max_iter: 100,
1447 tolerance: 1e-4,
1448 constraint_handling: ConstraintHandling::Soft,
1449 constraint_penalty: 1.0,
1450 random_seed: self.config.random_seed,
1451 };
1452
1453 let clusterer = ConstrainedKMeans::new(constrained_config, constraints.to_vec());
1454 let dummy_y = Array1::zeros(X.nrows());
1456 let fitted = clusterer.fit(X, &dummy_y)?;
1457 Ok(fitted.labels)
1458 }
1459 _ => Err(SklearsError::InvalidInput(
1460 "Unsupported base algorithm".to_string(),
1461 )),
1462 }
1463 }
1464
1465 fn compute_quality_score(&self, X: &Array2<f64>, labels: &[i32]) -> f64 {
1467 let n_samples = X.nrows();
1469 let mut scores = Vec::new();
1470
1471 for i in 0..n_samples {
1472 let mut intra_distance = 0.0;
1473 let mut intra_count = 0;
1474 let mut inter_distance = f64::INFINITY;
1475
1476 for j in 0..n_samples {
1477 if i != j {
1478 let distance: f64 = X
1479 .row(i)
1480 .iter()
1481 .zip(X.row(j).iter())
1482 .map(|(a, b)| (a - b).powi(2))
1483 .sum::<f64>()
1484 .sqrt();
1485
1486 if labels[i] == labels[j] {
1487 intra_distance += distance;
1488 intra_count += 1;
1489 } else {
1490 inter_distance = inter_distance.min(distance);
1491 }
1492 }
1493 }
1494
1495 if intra_count > 0 {
1496 let avg_intra = intra_distance / intra_count as f64;
1497 let score = if avg_intra > 0.0 {
1498 (inter_distance - avg_intra) / avg_intra.max(inter_distance)
1499 } else {
1500 1.0
1501 };
1502 scores.push(score);
1503 }
1504 }
1505
1506 if scores.is_empty() {
1507 0.0
1508 } else {
1509 scores.iter().sum::<f64>() / scores.len() as f64
1510 }
1511 }
1512}
1513
1514impl Estimator for ActiveClustering {
1515 type Config = ActiveClusteringConfig;
1516 type Error = SklearsError;
1517 type Float = f64;
1518
1519 fn config(&self) -> &Self::Config {
1520 &self.config
1521 }
1522}