Skip to main content

sklears_clustering/
semi_supervised.rs

1//! Semi-Supervised Clustering Algorithms
2//!
3//! This module provides semi-supervised clustering algorithms that incorporate
4//! prior knowledge in the form of constraints or partial labels to guide
5//! the clustering process.
6//!
7//! # Algorithms Provided
8//! - **Constrained K-Means**: K-Means with must-link and cannot-link constraints
9//! - **Semi-Supervised Spectral Clustering**: Spectral clustering with constraints
10//! - **Label Propagation Clustering**: Clustering with partial labeling
11//! - **Active Clustering**: Interactive clustering with user feedback
12//! - **PCCA (Police-Constrained Clustering Algorithm)**: Advanced constraint handling
13//!
14//! # Mathematical Background
15//!
16//! ## Constraint Types
17//! - **Must-link**: Points i and j must be in the same cluster
18//! - **Cannot-link**: Points i and j must be in different clusters
19//! - **Partial labels**: Some points have known cluster assignments
20//!
21//! ## Constraint Satisfaction
22//! The algorithms optimize clustering objectives while satisfying constraints:
23//! - Hard constraints: Must be satisfied exactly
24//! - Soft constraints: Violations are penalized in the objective function
25
26use std::collections::{HashMap, HashSet};
27
28use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
29// Normal distribution via scirs2_core::random::RandNormal
30use scirs2_core::random::{thread_rng, Random};
31use sklears_core::error::{Result, SklearsError};
32use sklears_core::traits::{Estimator, Fit, Predict};
33
34/// Types of constraints for semi-supervised clustering
35#[derive(Debug, Clone, PartialEq)]
36pub enum ConstraintType {
37    /// Two points must be in the same cluster
38    MustLink(usize, usize),
39    /// Two points must be in different clusters
40    CannotLink(usize, usize),
41}
42
43/// Constraint satisfaction strategy
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub enum ConstraintHandling {
46    /// Hard constraints that must be satisfied exactly
47    Hard,
48    /// Soft constraints that are penalized when violated
49    Soft,
50    /// Adaptive penalty based on constraint importance
51    Adaptive,
52}
53
54/// Configuration for constrained K-Means clustering
55#[derive(Debug, Clone)]
56pub struct ConstrainedKMeansConfig {
57    /// Number of clusters
58    pub n_clusters: usize,
59    /// Maximum number of iterations
60    pub max_iter: usize,
61    /// Convergence tolerance
62    pub tolerance: f64,
63    /// Constraint handling strategy
64    pub constraint_handling: ConstraintHandling,
65    /// Penalty weight for constraint violations (for soft constraints)
66    pub constraint_penalty: f64,
67    /// Random seed for reproducibility
68    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/// Constrained K-Means clustering
85#[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
97/// Fitted Constrained K-Means model
98pub struct ConstrainedKMeansFitted {
99    /// Cluster centroids
100    pub centroids: Array2<f64>,
101    /// Cluster labels for training data
102    pub labels: Vec<i32>,
103    /// Configuration used
104    pub config: ConstrainedKMeansConfig,
105    /// Constraints used during training
106    pub constraints: Vec<ConstraintType>,
107    /// Final inertia
108    pub inertia: f64,
109    /// Number of constraint violations
110    pub constraint_violations: usize,
111    /// Number of iterations until convergence
112    pub n_iterations: usize,
113}
114
115impl ConstrainedKMeans {
116    /// Create a new Constrained K-Means clusterer
117    pub fn new(config: ConstrainedKMeansConfig, constraints: Vec<ConstraintType>) -> Self {
118        Self {
119            config,
120            constraints,
121        }
122    }
123
124    /// Builder pattern: add must-link constraint
125    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    /// Builder pattern: add cannot-link constraint
131    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    /// Builder pattern: set constraint handling strategy
137    pub fn constraint_handling(mut self, handling: ConstraintHandling) -> Self {
138        self.config.constraint_handling = handling;
139        self
140    }
141
142    /// Builder pattern: set constraint penalty weight
143    pub fn constraint_penalty(mut self, penalty: f64) -> Self {
144        self.config.constraint_penalty = penalty;
145        self
146    }
147
148    /// Check if a clustering assignment satisfies all constraints
149    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    /// Compute constraint penalty for soft constraint handling
171    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    /// Initialize centroids while respecting must-link constraints
177    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        // Build connected components from must-link constraints
188        let mut components: Vec<HashSet<usize>> = Vec::new();
189        let mut point_to_component: HashMap<usize, usize> = HashMap::new();
190
191        // Initialize each point as its own component
192        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        // Merge components based on must-link constraints
200        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                    // Merge components
207                    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        // Remove empty components
218        components.retain(|comp| !comp.is_empty());
219
220        // Initialize centroids based on components
221        let mut rng = if let Some(seed) = self.config.random_seed {
222            Random::seed(seed)
223        } else {
224            Random::seed(42) // Use default seed if none provided
225        };
226
227        let mut centroids = Array2::<f64>::zeros((self.config.n_clusters, n_features));
228
229        // If we have more components than clusters, select largest components
230        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        // Initialize centroids for each component
236        for (k, component) in components.iter().enumerate() {
237            if k >= self.config.n_clusters {
238                break;
239            }
240
241            // Compute centroid as mean of points in component
242            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(&centroid);
248        }
249
250        // Fill remaining centroids randomly
251        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        // Fisher-Yates shuffle
258        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                // If we run out of points, use random initialization
268                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    /// Assign points to clusters while respecting hard constraints
278    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                // Hard constraint handling: try to satisfy all constraints
286                self.hard_constrained_assignment(X, centroids, &mut labels, &mut inertia);
287            }
288            ConstraintHandling::Soft | ConstraintHandling::Adaptive => {
289                // Soft constraint handling: minimize objective including penalty
290                self.soft_constrained_assignment(X, centroids, &mut labels, &mut inertia);
291            }
292        }
293
294        (labels, inertia)
295    }
296
297    /// Hard constraint assignment (guarantee constraint satisfaction)
298    fn hard_constrained_assignment(
299        &self,
300        X: &Array2<f64>,
301        centroids: &Array2<f64>,
302        labels: &mut [i32],
303        inertia: &mut f64,
304    ) {
305        // Start with unconstrained assignment
306        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        // Iteratively fix constraint violations
326        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                            // Move one point to match the other
339                            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                            // Find alternative cluster for one of the points
355                            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                            // Move the point with smaller distance increase
379                            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        // Recompute inertia after constraint satisfaction
392        *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    /// Soft constraint assignment (penalize violations in objective)
402    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                // Add constraint penalty
422                labels[i] = k as i32; // Temporarily assign
423                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    /// Compute Euclidean distance between two points
439    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    /// Update centroids based on current assignments
448    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(&centroid);
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        // Initialize centroids respecting constraints
496        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        // Main clustering loop
503        for iteration in 0..self.config.max_iter {
504            // Assign clusters with constraints
505            let (labels, inertia) = self.constrained_assignment(X, &centroids);
506
507            // Check convergence
508            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            // Update centroids
526            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/// Configuration for label propagation clustering
560#[derive(Debug, Clone)]
561pub struct LabelPropagationConfig {
562    /// Maximum number of iterations
563    pub max_iter: usize,
564    /// Convergence tolerance
565    pub tolerance: f64,
566    /// Weight decay factor for unlabeled points
567    pub alpha: f64,
568    /// Random seed for reproducibility
569    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
583/// Label Propagation clustering with partial labeling
584pub struct LabelPropagation {
585    config: LabelPropagationConfig,
586}
587
588impl Default for LabelPropagation {
589    fn default() -> Self {
590        Self::new(LabelPropagationConfig::default())
591    }
592}
593
594/// Fitted Label Propagation model
595pub struct LabelPropagationFitted {
596    /// Final label probabilities
597    pub label_probabilities: Array2<f64>,
598    /// Predicted labels
599    pub labels: Vec<i32>,
600    /// Configuration used
601    pub config: LabelPropagationConfig,
602    /// Number of iterations until convergence
603    pub n_iterations: usize,
604}
605
606impl LabelPropagation {
607    /// Create a new Label Propagation clusterer
608    pub fn new(config: LabelPropagationConfig) -> Self {
609        Self { config }
610    }
611
612    /// Builder pattern: set alpha parameter
613    pub fn alpha(mut self, alpha: f64) -> Self {
614        self.config.alpha = alpha;
615        self
616    }
617
618    /// Compute RBF similarity matrix
619    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    /// Normalize similarity matrix to transition matrix
642    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    /// Fit label propagation with partial labels
671    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        // Find unique labels
691        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        // Initialize label probability matrix
708        let mut label_probs = Array2::<f64>::zeros((n_samples, n_classes));
709
710        // Set known labels
711        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        // Compute similarity matrix (use RBF kernel with automatic gamma)
720        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        // Label propagation iterations
725        let mut previous_probs = label_probs.clone();
726
727        for iteration in 0..self.config.max_iter {
728            // Propagate labels
729            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            // Clamp labeled examples
742            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                        // Reset labeled point
746                        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            // Apply alpha smoothing for unlabeled points
755            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            // Check convergence
765            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                // Convert probabilities to labels
775                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                        // Convert back to original label
786                        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        // Final conversion to labels if max iterations reached
806        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        // Note: Label propagation is typically not used for out-of-sample prediction
836        // This is a limitation of the semi-supervised approach
837        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        // Check that must-link constraint is satisfied
879        assert_eq!(fitted.labels[0], fitted.labels[1]);
880        // Check that cannot-link constraint is satisfied
881        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]; // Violates cannot-link
895        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]; // Satisfies all constraints
901        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        // Partial labels: first two points labeled as class 0, others unlabeled
913        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        // Check that labeled points retain their labels
931        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/// Configuration for semi-supervised spectral clustering
959#[derive(Debug, Clone)]
960pub struct SemiSupervisedSpectralConfig {
961    /// Number of clusters
962    pub n_clusters: usize,
963    /// Number of eigenvectors to compute
964    pub n_eigenvectors: Option<usize>,
965    /// Constraint penalty weight
966    pub constraint_weight: f64,
967    /// Similarity matrix computation method
968    pub similarity_method: String,
969    /// Random seed for reproducibility
970    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
985/// Semi-supervised spectral clustering with constraints
986pub struct SemiSupervisedSpectral {
987    config: SemiSupervisedSpectralConfig,
988}
989
990/// Fitted semi-supervised spectral clustering model
991pub struct SemiSupervisedSpectralFitted {
992    /// Final cluster assignments
993    pub labels: Vec<i32>,
994    /// Affinity matrix used
995    pub affinity_matrix: Array2<f64>,
996    /// Eigenvectors computed
997    pub eigenvectors: Array2<f64>,
998    /// Number of clusters found
999    pub n_clusters: usize,
1000}
1001
1002impl SemiSupervisedSpectral {
1003    /// Create a new semi-supervised spectral clustering instance
1004    pub fn new(config: SemiSupervisedSpectralConfig) -> Self {
1005        Self { config }
1006    }
1007
1008    /// Fit clustering with constraints
1009    pub fn fit_constrained(
1010        &self,
1011        X: &Array2<f64>,
1012        constraints: &[ConstraintType],
1013    ) -> Result<SemiSupervisedSpectralFitted> {
1014        // Compute affinity matrix
1015        let mut affinity = self.compute_affinity_matrix(X)?;
1016
1017        // Apply constraints to affinity matrix
1018        self.apply_constraints_to_affinity(&mut affinity, constraints)?;
1019
1020        // Compute Laplacian
1021        let laplacian = self.compute_normalized_laplacian(&affinity)?;
1022
1023        // Compute eigenvectors
1024        let n_eigenvectors = self.config.n_eigenvectors.unwrap_or(self.config.n_clusters);
1025        let eigenvectors = self.compute_eigenvectors(&laplacian, n_eigenvectors)?;
1026
1027        // Apply k-means to eigenvectors
1028        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    /// Compute affinity matrix
1039    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; // Default gamma for RBF kernel
1046                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                // k-nearest neighbors similarity
1062                let k = 10; // Default k
1063                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    /// Apply constraints to affinity matrix
1099    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                    // Increase affinity for must-link constraints
1108                    let boost = self.config.constraint_weight;
1109                    affinity[[*i, *j]] += boost;
1110                    affinity[[*j, *i]] += boost;
1111                }
1112                ConstraintType::CannotLink(i, j) => {
1113                    // Decrease affinity for cannot-link constraints
1114                    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    /// Compute normalized Laplacian
1124    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        // Compute degree matrix
1129        let mut degrees = vec![0.0; n];
1130        for i in 0..n {
1131            degrees[i] = affinity.row(i).sum();
1132        }
1133
1134        // Normalized Laplacian: L = I - D^(-1/2) * A * D^(-1/2)
1135        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    /// Compute the `n_eigenvectors` smallest-eigenvalue eigenvectors of `laplacian`.
1161    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        // eigh returns eigenvalues in ascending order (smallest first).
1170        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        // Take the k columns corresponding to the k smallest eigenvalues.
1175        let selected = eigenvectors
1176            .slice(scirs2_core::ndarray::s![.., ..k])
1177            .to_owned();
1178        Ok(selected)
1179    }
1180
1181    /// Cluster eigenvectors using k-means
1182    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        // Simple random assignment as placeholder
1191        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/// Configuration for active clustering
1213#[derive(Debug, Clone)]
1214pub struct ActiveClusteringConfig {
1215    /// Base clustering algorithm to use
1216    pub base_algorithm: String,
1217    /// Number of queries per iteration
1218    pub queries_per_iteration: usize,
1219    /// Maximum number of iterations
1220    pub max_iterations: usize,
1221    /// Query selection strategy
1222    pub query_strategy: String,
1223    /// Random seed for reproducibility
1224    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
1239/// Active clustering with user feedback
1240pub struct ActiveClustering {
1241    config: ActiveClusteringConfig,
1242}
1243
1244/// Fitted active clustering model
1245pub struct ActiveClusteringFitted {
1246    /// Final cluster assignments
1247    pub labels: Vec<i32>,
1248    /// Constraints collected during active learning
1249    pub constraints: Vec<ConstraintType>,
1250    /// Number of queries made
1251    pub n_queries: usize,
1252    /// Final clustering quality score
1253    pub quality_score: f64,
1254}
1255
1256/// Query response from user
1257#[derive(Debug, Clone, PartialEq)]
1258pub enum QueryResponse {
1259    /// Points should be in same cluster
1260    SameCluster,
1261    /// Points should be in different clusters
1262    DifferentCluster,
1263    /// User doesn't know or skips
1264    Unknown,
1265}
1266
1267/// Query for user feedback
1268#[derive(Debug, Clone)]
1269pub struct ClusteringQuery {
1270    /// Indices of points to query about
1271    pub point_indices: (usize, usize),
1272    /// Current cluster assignments for these points
1273    pub current_clusters: (i32, i32),
1274    /// Uncertainty score for this query
1275    pub uncertainty_score: f64,
1276}
1277
1278impl ActiveClustering {
1279    /// Create a new active clustering instance
1280    pub fn new(config: ActiveClusteringConfig) -> Self {
1281        Self { config }
1282    }
1283
1284    /// Interactive clustering with user feedback
1285    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        // Initial clustering without constraints
1297        let mut current_labels = self.run_base_clustering(X, &constraints)?;
1298
1299        for iteration in 0..self.config.max_iterations {
1300            // Generate queries based on current clustering
1301            let queries = self.generate_queries(X, &current_labels)?;
1302
1303            if queries.is_empty() {
1304                break; // No more uncertain pairs
1305            }
1306
1307            // Collect feedback for this iteration
1308            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                        // Skip this constraint
1326                    }
1327                }
1328                total_queries += 1;
1329            }
1330
1331            // Add new constraints
1332            constraints.extend(new_constraints);
1333
1334            // Re-cluster with updated constraints
1335            current_labels = self.run_base_clustering(X, &constraints)?;
1336
1337            // Check for convergence (could add early stopping criteria)
1338            if iteration > 0 {
1339                // Could compare with previous iteration and stop if converged
1340            }
1341        }
1342
1343        // Compute final quality score
1344        let quality_score = self.compute_quality_score(X, &current_labels);
1345
1346        Ok(ActiveClusteringFitted {
1347            labels: current_labels,
1348            constraints,
1349            n_queries: total_queries,
1350            quality_score,
1351        })
1352    }
1353
1354    /// Generate queries for user feedback
1355    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                // Find points near cluster boundaries (high uncertainty)
1366                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                // Sort by uncertainty and return top queries
1379                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                // Random point pairs
1387                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    /// Compute uncertainty score for a point pair
1412    fn compute_uncertainty(&self, X: &Array2<f64>, i: usize, j: usize, labels: &[i32]) -> f64 {
1413        // Distance between points
1414        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        // Whether they're in same cluster
1423        let same_cluster = labels[i] == labels[j];
1424
1425        // Compute uncertainty based on distance and current assignment
1426        if same_cluster {
1427            // If in same cluster, uncertainty is higher for distant points
1428            distance
1429        } else {
1430            // If in different clusters, uncertainty is higher for close points
1431            1.0 / (1.0 + distance)
1432        }
1433    }
1434
1435    /// Run base clustering algorithm with constraints
1436    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                // Use constrained k-means
1444                let constrained_config = ConstrainedKMeansConfig {
1445                    n_clusters: 3, // Default
1446                    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                // For clustering, provide dummy Y parameter
1455                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    /// Compute clustering quality score
1466    fn compute_quality_score(&self, X: &Array2<f64>, labels: &[i32]) -> f64 {
1467        // Simple silhouette-like score
1468        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}