Skip to main content

quantrs2_ml/clustering/
core.rs

1//! Core quantum clustering functionality
2
3use crate::dimensionality_reduction::QuantumDistanceMetric;
4use crate::error::{MLError, Result};
5use scirs2_core::ndarray::{Array1, Array2, ArrayView1, Axis};
6
7use super::config::*;
8
9/// Clustering result containing labels and metadata
10#[derive(Debug, Clone)]
11pub struct ClusteringResult {
12    /// Cluster labels for each data point
13    pub labels: Array1<usize>,
14    /// Number of clusters found
15    pub n_clusters: usize,
16    /// Cluster centers (if available)
17    pub cluster_centers: Option<Array2<f64>>,
18    /// Inertia/within-cluster sum of squares (if available)
19    pub inertia: Option<f64>,
20    /// Cluster probabilities (for soft clustering)
21    pub probabilities: Option<Array2<f64>>,
22}
23
24/// Main quantum clusterer
25#[derive(Debug)]
26pub struct QuantumClusterer {
27    config: QuantumClusteringConfig,
28    cluster_centers: Option<Array2<f64>>,
29    labels: Option<Array1<usize>>,
30    // Algorithm-specific configurations
31    pub kmeans_config: Option<QuantumKMeansConfig>,
32    pub dbscan_config: Option<QuantumDBSCANConfig>,
33    pub spectral_config: Option<QuantumSpectralConfig>,
34    pub fuzzy_config: Option<QuantumFuzzyCMeansConfig>,
35    pub gmm_config: Option<QuantumGMMConfig>,
36}
37
38impl QuantumClusterer {
39    /// Create new quantum clusterer
40    pub fn new(config: QuantumClusteringConfig) -> Self {
41        Self {
42            config,
43            cluster_centers: None,
44            labels: None,
45            kmeans_config: None,
46            dbscan_config: None,
47            spectral_config: None,
48            fuzzy_config: None,
49            gmm_config: None,
50        }
51    }
52
53    /// Create quantum K-means clusterer
54    pub fn kmeans(config: QuantumKMeansConfig) -> Self {
55        let mut clusterer = Self::new(QuantumClusteringConfig {
56            algorithm: ClusteringAlgorithm::QuantumKMeans,
57            n_clusters: config.n_clusters,
58            max_iterations: config.max_iterations,
59            tolerance: config.tolerance,
60            num_qubits: 4,
61            random_state: config.seed,
62        });
63        clusterer.kmeans_config = Some(config);
64        clusterer
65    }
66
67    /// Create quantum DBSCAN clusterer
68    pub fn dbscan(config: QuantumDBSCANConfig) -> Self {
69        let mut clusterer = Self::new(QuantumClusteringConfig {
70            algorithm: ClusteringAlgorithm::QuantumDBSCAN,
71            n_clusters: 0, // DBSCAN determines clusters automatically
72            max_iterations: 100,
73            tolerance: 1e-4,
74            num_qubits: 4,
75            random_state: config.seed,
76        });
77        clusterer.dbscan_config = Some(config);
78        clusterer
79    }
80
81    /// Create quantum spectral clusterer
82    pub fn spectral(config: QuantumSpectralConfig) -> Self {
83        let mut clusterer = Self::new(QuantumClusteringConfig {
84            algorithm: ClusteringAlgorithm::QuantumSpectral,
85            n_clusters: config.n_clusters,
86            max_iterations: 100,
87            tolerance: 1e-4,
88            num_qubits: 4,
89            random_state: config.seed,
90        });
91        clusterer.spectral_config = Some(config);
92        clusterer
93    }
94
95    /// Compute squared Euclidean distance between two array views
96    fn squared_dist(&self, a: &ArrayView1<f64>, b: &ArrayView1<f64>) -> f64 {
97        a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum()
98    }
99
100    /// Iterative union-find with path halving (no recursion)
101    fn uf_find(parent: &mut [usize], mut x: usize) -> usize {
102        while parent[x] != x {
103            // Path compression by halving
104            parent[x] = parent[parent[x]];
105            x = parent[x];
106        }
107        x
108    }
109
110    /// Run Lloyd's k-means algorithm with k-means++ initialization.
111    ///
112    /// Returns `(cluster_centers, labels, inertia)`.
113    fn run_kmeans(
114        &self,
115        data: &Array2<f64>,
116        k: usize,
117    ) -> Result<(Array2<f64>, Array1<usize>, f64)> {
118        let n_samples = data.nrows();
119        let n_features = data.ncols();
120        let max_iter = self.config.max_iterations;
121
122        // -----------------------------------------------------------------------
123        // k-means++ initialisation
124        // First center: deterministic – row 0, or seeded via random_state.
125        // Subsequent centers: greedy furthest-point (deterministic, avoids RNG).
126        // -----------------------------------------------------------------------
127        let mut centers = Array2::<f64>::zeros((k, n_features));
128
129        // Choose first center
130        let first_idx = self
131            .config
132            .random_state
133            .map(|s| (s as usize) % n_samples)
134            .unwrap_or(0);
135        centers.row_mut(0).assign(&data.row(first_idx));
136
137        // k-means++ subsequent centers
138        for c in 1..k {
139            // For each sample, compute minimum squared distance to any chosen center so far
140            let mut min_dists_sq = vec![f64::INFINITY; n_samples];
141            for i in 0..n_samples {
142                for prev_c in 0..c {
143                    let d = self.squared_dist(&data.row(i), &centers.row(prev_c));
144                    if d < min_dists_sq[i] {
145                        min_dists_sq[i] = d;
146                    }
147                }
148            }
149            // Greedy deterministic choice: the sample farthest from all current centers
150            let next_idx = min_dists_sq
151                .iter()
152                .enumerate()
153                .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
154                .map(|(i, _)| i)
155                .unwrap_or(c % n_samples);
156            centers.row_mut(c).assign(&data.row(next_idx));
157        }
158
159        // -----------------------------------------------------------------------
160        // Lloyd's iterations
161        // -----------------------------------------------------------------------
162        let mut labels = vec![0usize; n_samples];
163
164        for _iter in 0..max_iter {
165            // ----- Assignment step -----
166            let mut changed = false;
167            for i in 0..n_samples {
168                let mut best_c = 0;
169                let mut best_d = f64::INFINITY;
170                for c in 0..k {
171                    let d = self.squared_dist(&data.row(i), &centers.row(c));
172                    if d < best_d {
173                        best_d = d;
174                        best_c = c;
175                    }
176                }
177                if labels[i] != best_c {
178                    changed = true;
179                    labels[i] = best_c;
180                }
181            }
182
183            // ----- Update step -----
184            let mut new_centers = Array2::<f64>::zeros((k, n_features));
185            let mut counts = vec![0usize; k];
186            for i in 0..n_samples {
187                let c = labels[i];
188                new_centers.row_mut(c).scaled_add(1.0, &data.row(i));
189                counts[c] += 1;
190            }
191            for c in 0..k {
192                if counts[c] > 0 {
193                    new_centers
194                        .row_mut(c)
195                        .mapv_inplace(|v| v / counts[c] as f64);
196                } else {
197                    // Empty cluster: reassign center to a guaranteed occupied data point
198                    new_centers.row_mut(c).assign(&data.row(c % n_samples));
199                }
200            }
201            centers = new_centers;
202
203            if !changed {
204                break;
205            }
206        }
207
208        // -----------------------------------------------------------------------
209        // Compute inertia (within-cluster sum of squared distances)
210        // -----------------------------------------------------------------------
211        let mut inertia = 0.0f64;
212        for i in 0..n_samples {
213            inertia += self.squared_dist(&data.row(i), &centers.row(labels[i]));
214        }
215
216        let labels_arr = Array1::from_iter(labels);
217        Ok((centers, labels_arr, inertia))
218    }
219
220    /// Density-based cluster counting using union-find over the epsilon neighbourhood.
221    ///
222    /// Uses `dbscan_config.eps` and `dbscan_config.min_samples` when available,
223    /// falling back to sensible defaults derived from the data spread.
224    fn fit_dbscan(&self, data: &Array2<f64>) -> Result<usize> {
225        let n = data.nrows();
226
227        let (eps, min_samples) = if let Some(cfg) = &self.dbscan_config {
228            (cfg.eps, cfg.min_samples)
229        } else {
230            // Estimate eps as ~10 % of the bounding-box diagonal
231            let mut max_sq = 0.0f64;
232            for i in 0..n {
233                for j in (i + 1)..n {
234                    let d = self.squared_dist(&data.row(i), &data.row(j));
235                    if d > max_sq {
236                        max_sq = d;
237                    }
238                }
239            }
240            (max_sq.sqrt() * 0.1, 2usize)
241        };
242
243        // Union-find initialisation
244        let mut parent: Vec<usize> = (0..n).collect();
245
246        for i in 0..n {
247            let mut neighbor_count = 0usize;
248            for j in 0..n {
249                if i == j {
250                    continue;
251                }
252                let d = self.squared_dist(&data.row(i), &data.row(j)).sqrt();
253                if d <= eps {
254                    neighbor_count += 1;
255                    // Union i and j
256                    let pi = Self::uf_find(&mut parent, i);
257                    let pj = Self::uf_find(&mut parent, j);
258                    if pi != pj {
259                        parent[pi] = pj;
260                    }
261                }
262            }
263            // Points with fewer than min_samples neighbours remain noise (own root)
264            let _ = neighbor_count;
265        }
266
267        // Count distinct roots – each root represents one cluster
268        let n_clusters = (0..n)
269            .filter(|&i| Self::uf_find(&mut parent, i) == i)
270            .count();
271
272        Ok(n_clusters.max(1))
273    }
274
275    /// Fit the clustering model using Lloyd's k-means with k-means++ initialization.
276    pub fn fit(&mut self, data: &Array2<f64>) -> Result<ClusteringResult> {
277        let n_samples = data.nrows();
278
279        if n_samples == 0 {
280            return Err(MLError::InvalidInput("Empty data".to_string()));
281        }
282
283        // Determine the target number of clusters
284        let n_clusters = match self.config.algorithm {
285            ClusteringAlgorithm::QuantumDBSCAN => {
286                // DBSCAN determines clusters from density
287                let auto_k = self.fit_dbscan(data)?;
288                auto_k
289            }
290            _ => {
291                // Use configured n_clusters, capped to available samples
292                self.config.n_clusters.min(n_samples).max(1)
293            }
294        };
295
296        // Run Lloyd's k-means (with k-means++ init) over the chosen k
297        let (cluster_centers, labels, inertia) = self.run_kmeans(data, n_clusters)?;
298
299        self.cluster_centers = Some(cluster_centers.clone());
300        self.labels = Some(labels.clone());
301
302        Ok(ClusteringResult {
303            labels,
304            n_clusters,
305            cluster_centers: Some(cluster_centers),
306            inertia: Some(inertia),
307            probabilities: None,
308        })
309    }
310
311    /// Predict cluster labels for new data by assigning to the nearest center.
312    pub fn predict(&self, data: &Array2<f64>) -> Result<Array1<usize>> {
313        let centers = self.cluster_centers.as_ref().ok_or_else(|| {
314            MLError::ModelNotTrained("Clusterer must be fitted before predict".to_string())
315        })?;
316
317        let k = centers.nrows();
318        let labels: Vec<usize> = (0..data.nrows())
319            .map(|i| {
320                let mut best_c = 0;
321                let mut best_d = f64::INFINITY;
322                for c in 0..k {
323                    let d = self.squared_dist(&data.row(i), &centers.row(c));
324                    if d < best_d {
325                        best_d = d;
326                        best_c = c;
327                    }
328                }
329                best_c
330            })
331            .collect();
332
333        Ok(Array1::from_iter(labels))
334    }
335
336    /// Predict cluster probabilities (for soft clustering)
337    ///
338    /// Computed as a softmax over the negative squared distance from each
339    /// point to every cluster center, so points closer to a center receive a
340    /// higher assignment probability to that cluster and the probabilities
341    /// genuinely depend on `data` (rather than being uniform placeholders).
342    pub fn predict_proba(&self, data: &Array2<f64>) -> Result<Array2<f64>> {
343        let centers = self.cluster_centers.as_ref().ok_or_else(|| {
344            MLError::ModelNotTrained("Clusterer must be fitted before predict_proba".to_string())
345        })?;
346
347        let n_samples = data.nrows();
348        let k = centers.nrows();
349        let mut probabilities = Array2::<f64>::zeros((n_samples, k));
350
351        for i in 0..n_samples {
352            let row = data.row(i);
353            let neg_distances: Vec<f64> = (0..k)
354                .map(|c| -self.squared_dist(&row, &centers.row(c)))
355                .collect();
356            let max_neg_dist = neg_distances
357                .iter()
358                .cloned()
359                .fold(f64::NEG_INFINITY, f64::max);
360            let exp_vals: Vec<f64> = neg_distances
361                .iter()
362                .map(|&d| (d - max_neg_dist).exp())
363                .collect();
364            let sum_exp: f64 = exp_vals.iter().sum();
365            for c in 0..k {
366                probabilities[[i, c]] = if sum_exp > 0.0 {
367                    exp_vals[c] / sum_exp
368                } else {
369                    1.0 / k as f64
370                };
371            }
372        }
373
374        Ok(probabilities)
375    }
376
377    /// Compute quantum distance between two points
378    pub fn compute_quantum_distance(
379        &self,
380        point1: &Array1<f64>,
381        point2: &Array1<f64>,
382        metric: QuantumDistanceMetric,
383    ) -> Result<f64> {
384        // Placeholder implementation for quantum distance computation
385        match metric {
386            QuantumDistanceMetric::QuantumEuclidean => {
387                let diff = point1 - point2;
388                Ok(diff.dot(&diff).sqrt())
389            }
390            QuantumDistanceMetric::QuantumManhattan => {
391                Ok((point1 - point2).mapv(|x| x.abs()).sum())
392            }
393            QuantumDistanceMetric::QuantumCosine => {
394                let dot_product = point1.dot(point2);
395                let norm1 = point1.dot(point1).sqrt();
396                let norm2 = point2.dot(point2).sqrt();
397                Ok(1.0 - (dot_product / (norm1 * norm2)))
398            }
399            _ => {
400                // For other quantum metrics, return Euclidean as fallback
401                let diff = point1 - point2;
402                Ok(diff.dot(&diff).sqrt())
403            }
404        }
405    }
406
407    /// Fit and predict in one step
408    pub fn fit_predict(&mut self, data: &Array2<f64>) -> Result<Array1<usize>> {
409        let result = self.fit(data)?;
410        Ok(result.labels)
411    }
412
413    /// Get cluster centers
414    pub fn cluster_centers(&self) -> Option<&Array2<f64>> {
415        self.cluster_centers.as_ref()
416    }
417
418    /// Evaluate clustering performance.
419    ///
420    /// All metrics are computed from the actual fitted `cluster_centers` and
421    /// the provided `data` (real silhouette score, Davies-Bouldin index,
422    /// Calinski-Harabasz index and inertia). If `true_labels` are supplied,
423    /// the Adjusted Rand Index and Normalized Mutual Information against those
424    /// ground-truth labels are computed as well; otherwise those two fields
425    /// are `None`, since there is nothing external to compare against.
426    pub fn evaluate(
427        &self,
428        data: &Array2<f64>,
429        true_labels: Option<&Array1<usize>>,
430    ) -> Result<ClusteringMetrics> {
431        let centers = self.cluster_centers.as_ref().ok_or_else(|| {
432            MLError::ModelNotTrained("Clusterer must be fitted before evaluation".to_string())
433        })?;
434
435        if data.nrows() == 0 {
436            return Err(MLError::InvalidInput("Empty data".to_string()));
437        }
438
439        let predicted_labels = self.predict(data)?;
440        let n_samples = data.nrows();
441        let k = centers.nrows();
442
443        let inertia: f64 = (0..n_samples)
444            .map(|i| self.squared_dist(&data.row(i), &centers.row(predicted_labels[i])))
445            .sum();
446
447        let mut cluster_members: Vec<Vec<usize>> = vec![Vec::new(); k];
448        for (i, &label) in predicted_labels.iter().enumerate() {
449            cluster_members[label].push(i);
450        }
451
452        let silhouette_score =
453            Self::compute_silhouette_score(data, &predicted_labels, &cluster_members);
454        let davies_bouldin_index =
455            Self::compute_davies_bouldin_index(data, centers, &cluster_members);
456        let calinski_harabasz_index =
457            Self::compute_calinski_harabasz_index(data, centers, &cluster_members, inertia);
458
459        let (adjusted_rand_index, normalized_mutual_info) = match true_labels {
460            Some(truth) if truth.len() == n_samples => (
461                Some(Self::adjusted_rand_index(&predicted_labels, truth)),
462                Some(Self::normalized_mutual_info(&predicted_labels, truth)),
463            ),
464            _ => (None, None),
465        };
466
467        Ok(ClusteringMetrics {
468            silhouette_score,
469            davies_bouldin_index,
470            calinski_harabasz_index,
471            inertia,
472            adjusted_rand_index,
473            normalized_mutual_info,
474        })
475    }
476
477    /// Mean silhouette coefficient over all points: for each point `i`,
478    /// `s_i = (b_i - a_i) / max(a_i, b_i)`, where `a_i` is the mean distance
479    /// to other points in the same cluster and `b_i` is the smallest mean
480    /// distance to the points of any other cluster.
481    fn compute_silhouette_score(
482        data: &Array2<f64>,
483        labels: &Array1<usize>,
484        cluster_members: &[Vec<usize>],
485    ) -> f64 {
486        let n_samples = data.nrows();
487        let non_empty_clusters = cluster_members.iter().filter(|m| !m.is_empty()).count();
488        if n_samples < 2 || non_empty_clusters < 2 {
489            return 0.0;
490        }
491
492        let mut silhouette_sum = 0.0;
493        let mut counted = 0usize;
494        for i in 0..n_samples {
495            let own_cluster = labels[i];
496            let own_members = &cluster_members[own_cluster];
497            let a_i = if own_members.len() > 1 {
498                own_members
499                    .iter()
500                    .filter(|&&j| j != i)
501                    .map(|&j| euclidean_distance(&data.row(i), &data.row(j)))
502                    .sum::<f64>()
503                    / (own_members.len() - 1) as f64
504            } else {
505                0.0
506            };
507
508            let mut b_i = f64::INFINITY;
509            for (c, members) in cluster_members.iter().enumerate() {
510                if c == own_cluster || members.is_empty() {
511                    continue;
512                }
513                let mean_dist = members
514                    .iter()
515                    .map(|&j| euclidean_distance(&data.row(i), &data.row(j)))
516                    .sum::<f64>()
517                    / members.len() as f64;
518                if mean_dist < b_i {
519                    b_i = mean_dist;
520                }
521            }
522
523            if b_i.is_finite() {
524                let denom = a_i.max(b_i);
525                let s_i = if denom > 0.0 {
526                    (b_i - a_i) / denom
527                } else {
528                    0.0
529                };
530                silhouette_sum += s_i;
531                counted += 1;
532            }
533        }
534
535        if counted > 0 {
536            silhouette_sum / counted as f64
537        } else {
538            0.0
539        }
540    }
541
542    /// Davies-Bouldin index: average, over clusters `i`, of the worst-case
543    /// ratio `(scatter_i + scatter_j) / distance(center_i, center_j)` across
544    /// every other cluster `j`. Lower is better (more separated clusters).
545    fn compute_davies_bouldin_index(
546        data: &Array2<f64>,
547        centers: &Array2<f64>,
548        cluster_members: &[Vec<usize>],
549    ) -> f64 {
550        let k = centers.nrows();
551        if k < 2 {
552            return 0.0;
553        }
554
555        let scatter: Vec<f64> = (0..k)
556            .map(|c| {
557                if cluster_members[c].is_empty() {
558                    0.0
559                } else {
560                    cluster_members[c]
561                        .iter()
562                        .map(|&i| euclidean_distance(&data.row(i), &centers.row(c)))
563                        .sum::<f64>()
564                        / cluster_members[c].len() as f64
565                }
566            })
567            .collect();
568
569        let mut db_sum = 0.0;
570        for i in 0..k {
571            let mut max_ratio = 0.0_f64;
572            for j in 0..k {
573                if i == j {
574                    continue;
575                }
576                let center_distance = euclidean_distance(&centers.row(i), &centers.row(j));
577                if center_distance > 1e-12 {
578                    let ratio = (scatter[i] + scatter[j]) / center_distance;
579                    if ratio > max_ratio {
580                        max_ratio = ratio;
581                    }
582                }
583            }
584            db_sum += max_ratio;
585        }
586
587        db_sum / k as f64
588    }
589
590    /// Calinski-Harabasz index: ratio of between-cluster to within-cluster
591    /// dispersion, scaled by the usual degrees-of-freedom correction.
592    fn compute_calinski_harabasz_index(
593        data: &Array2<f64>,
594        centers: &Array2<f64>,
595        cluster_members: &[Vec<usize>],
596        within_cluster_dispersion: f64,
597    ) -> f64 {
598        let n_samples = data.nrows();
599        let k = centers.nrows();
600        if k < 2 || n_samples <= k || within_cluster_dispersion <= 1e-12 {
601            return 0.0;
602        }
603
604        let overall_mean = data
605            .mean_axis(Axis(0))
606            .unwrap_or_else(|| Array1::zeros(data.ncols()));
607
608        let between_cluster_dispersion: f64 = (0..k)
609            .map(|c| {
610                let n_c = cluster_members[c].len() as f64;
611                if n_c == 0.0 {
612                    0.0
613                } else {
614                    n_c * euclidean_distance(&centers.row(c), &overall_mean.view()).powi(2)
615                }
616            })
617            .sum();
618
619        (between_cluster_dispersion / within_cluster_dispersion)
620            * ((n_samples - k) as f64 / (k - 1) as f64)
621    }
622
623    /// Adjusted Rand Index between predicted and ground-truth labels, computed
624    /// from the pairwise contingency table.
625    fn adjusted_rand_index(predicted: &Array1<usize>, truth: &Array1<usize>) -> f64 {
626        let n = predicted.len();
627        if n == 0 {
628            return 0.0;
629        }
630        let pred_max = predicted.iter().cloned().max().unwrap_or(0) + 1;
631        let true_max = truth.iter().cloned().max().unwrap_or(0) + 1;
632
633        let mut contingency = vec![vec![0usize; true_max]; pred_max];
634        for i in 0..n {
635            contingency[predicted[i]][truth[i]] += 1;
636        }
637
638        let comb2 = |x: usize| -> f64 {
639            if x < 2 {
640                0.0
641            } else {
642                (x * (x - 1)) as f64 / 2.0
643            }
644        };
645
646        let sum_comb_nij: f64 = contingency.iter().flatten().map(|&v| comb2(v)).sum();
647        let row_sums: Vec<usize> = contingency.iter().map(|row| row.iter().sum()).collect();
648        let col_sums: Vec<usize> = (0..true_max)
649            .map(|j| contingency.iter().map(|row| row[j]).sum())
650            .collect();
651
652        let sum_comb_a: f64 = row_sums.iter().map(|&a| comb2(a)).sum();
653        let sum_comb_b: f64 = col_sums.iter().map(|&b| comb2(b)).sum();
654        let comb_n = comb2(n);
655        if comb_n <= 0.0 {
656            return 0.0;
657        }
658
659        let expected_index = (sum_comb_a * sum_comb_b) / comb_n;
660        let max_index = 0.5 * (sum_comb_a + sum_comb_b);
661        let denom = max_index - expected_index;
662
663        if denom.abs() < 1e-12 {
664            0.0
665        } else {
666            (sum_comb_nij - expected_index) / denom
667        }
668    }
669
670    /// Normalized Mutual Information between predicted and ground-truth
671    /// labels, normalized by the geometric mean of the two label entropies so
672    /// the result lies in `[0, 1]`.
673    fn normalized_mutual_info(predicted: &Array1<usize>, truth: &Array1<usize>) -> f64 {
674        let n = predicted.len();
675        if n == 0 {
676            return 0.0;
677        }
678        let pred_max = predicted.iter().cloned().max().unwrap_or(0) + 1;
679        let true_max = truth.iter().cloned().max().unwrap_or(0) + 1;
680
681        let mut contingency = vec![vec![0usize; true_max]; pred_max];
682        for i in 0..n {
683            contingency[predicted[i]][truth[i]] += 1;
684        }
685
686        let row_sums: Vec<usize> = contingency.iter().map(|row| row.iter().sum()).collect();
687        let col_sums: Vec<usize> = (0..true_max)
688            .map(|j| contingency.iter().map(|row| row[j]).sum())
689            .collect();
690
691        let n_f = n as f64;
692        let mutual_information: f64 = contingency
693            .iter()
694            .enumerate()
695            .flat_map(|(i, row)| row.iter().enumerate().map(move |(j, &n_ij)| (i, j, n_ij)))
696            .filter(|&(_, _, n_ij)| n_ij > 0)
697            .map(|(i, j, n_ij)| {
698                let p_ij = n_ij as f64 / n_f;
699                let p_i = row_sums[i] as f64 / n_f;
700                let p_j = col_sums[j] as f64 / n_f;
701                p_ij * (p_ij / (p_i * p_j)).ln()
702            })
703            .sum();
704
705        let entropy = |sums: &[usize]| -> f64 {
706            sums.iter()
707                .filter(|&&s| s > 0)
708                .map(|&s| {
709                    let p = s as f64 / n_f;
710                    -p * p.ln()
711                })
712                .sum::<f64>()
713        };
714
715        let h_pred = entropy(&row_sums);
716        let h_true = entropy(&col_sums);
717
718        if h_pred <= 1e-12 || h_true <= 1e-12 {
719            if mutual_information.abs() < 1e-12 {
720                1.0
721            } else {
722                0.0
723            }
724        } else {
725            (mutual_information / (h_pred * h_true).sqrt()).clamp(0.0, 1.0)
726        }
727    }
728}
729
730/// Euclidean distance between two vectors.
731fn euclidean_distance(a: &ArrayView1<f64>, b: &ArrayView1<f64>) -> f64 {
732    a.iter()
733        .zip(b.iter())
734        .map(|(x, y)| (x - y).powi(2))
735        .sum::<f64>()
736        .sqrt()
737}
738
739/// Clustering evaluation metrics
740#[derive(Debug, Clone)]
741pub struct ClusteringMetrics {
742    /// Silhouette score
743    pub silhouette_score: f64,
744    /// Davies-Bouldin index
745    pub davies_bouldin_index: f64,
746    /// Calinski-Harabasz index
747    pub calinski_harabasz_index: f64,
748    /// Within-cluster sum of squares
749    pub inertia: f64,
750    /// Adjusted Rand Index (if true labels provided)
751    pub adjusted_rand_index: Option<f64>,
752    /// Normalized Mutual Information (if true labels provided)
753    pub normalized_mutual_info: Option<f64>,
754}
755
756/// Helper function to create default quantum K-means clusterer
757pub fn create_default_quantum_kmeans(n_clusters: usize) -> QuantumClusterer {
758    let config = QuantumKMeansConfig {
759        n_clusters,
760        ..Default::default()
761    };
762    QuantumClusterer::kmeans(config)
763}
764
765/// Helper function to create default quantum DBSCAN clusterer
766pub fn create_default_quantum_dbscan(eps: f64, min_samples: usize) -> QuantumClusterer {
767    let config = QuantumDBSCANConfig {
768        eps,
769        min_samples,
770        ..Default::default()
771    };
772    QuantumClusterer::dbscan(config)
773}
774
775#[cfg(test)]
776mod regression_tests {
777    use super::*;
778
779    /// Two well-separated 2-cluster blobs for deterministic assertions.
780    fn two_blob_data() -> Array2<f64> {
781        Array2::from_shape_vec(
782            (8, 2),
783            vec![
784                0.0, 0.0, 0.1, 0.0, 0.0, 0.1, 0.1, 0.1, // cluster A around (0,0)
785                10.0, 10.0, 10.1, 10.0, 10.0, 10.1, 10.1, 10.1, // cluster B around (10,10)
786            ],
787        )
788        .expect("valid shape")
789    }
790
791    #[test]
792    fn predict_proba_reflects_actual_distances_not_uniform() {
793        let mut clusterer = create_default_quantum_kmeans(2);
794        let data = two_blob_data();
795        clusterer.fit(&data).expect("fit should succeed");
796
797        let probabilities = clusterer
798            .predict_proba(&data)
799            .expect("predict_proba should succeed");
800
801        // Every point is far closer to one center than the other, so its
802        // dominant-cluster probability should be near 1.0, not the uniform
803        // 1/2 that the previous placeholder implementation always returned.
804        for i in 0..probabilities.nrows() {
805            let row = probabilities.row(i);
806            let max_prob = row.iter().cloned().fold(f64::MIN, f64::max);
807            assert!(
808                max_prob > 0.9,
809                "expected a confident cluster assignment, got {row:?}"
810            );
811        }
812
813        // Probabilities for each point must sum to 1.
814        for i in 0..probabilities.nrows() {
815            let sum: f64 = probabilities.row(i).iter().sum();
816            assert!((sum - 1.0).abs() < 1e-9, "row {i} does not sum to 1: {sum}");
817        }
818    }
819
820    #[test]
821    fn evaluate_computes_real_metrics_for_well_separated_clusters() {
822        let mut clusterer = create_default_quantum_kmeans(2);
823        let data = two_blob_data();
824        clusterer.fit(&data).expect("fit should succeed");
825
826        let metrics = clusterer
827            .evaluate(&data, None)
828            .expect("evaluate should succeed");
829
830        // Two tight, well-separated blobs should score close to a perfect
831        // silhouette (near 1.0), not the hardcoded placeholder of 0.5.
832        assert!(
833            metrics.silhouette_score > 0.9,
834            "silhouette_score should be near 1.0 for well-separated blobs, got {}",
835            metrics.silhouette_score
836        );
837        // Davies-Bouldin should be small (good separation), not the
838        // hardcoded placeholder of 1.0.
839        assert!(
840            metrics.davies_bouldin_index < 0.1,
841            "davies_bouldin_index should be small for well-separated blobs, got {}",
842            metrics.davies_bouldin_index
843        );
844        // Calinski-Harabasz should be large for well-separated blobs, not
845        // the hardcoded placeholder of 100.0 by coincidence of formula.
846        assert!(
847            metrics.calinski_harabasz_index > 100.0,
848            "calinski_harabasz_index should be large for well-separated blobs, got {}",
849            metrics.calinski_harabasz_index
850        );
851        // Inertia must be strictly positive and reflect within-cluster
852        // scatter, not the hardcoded placeholder of 0.0.
853        assert!(
854            metrics.inertia > 0.0,
855            "inertia should be positive, got {}",
856            metrics.inertia
857        );
858
859        // With ground-truth labels matching the true blob structure exactly,
860        // ARI and NMI should both be (near) 1.0.
861        let truth = Array1::from_vec(vec![0usize, 0, 0, 0, 1, 1, 1, 1]);
862        let metrics_with_truth = clusterer
863            .evaluate(&data, Some(&truth))
864            .expect("evaluate with truth should succeed");
865        let ari = metrics_with_truth
866            .adjusted_rand_index
867            .expect("ARI should be Some when true_labels given");
868        let nmi = metrics_with_truth
869            .normalized_mutual_info
870            .expect("NMI should be Some when true_labels given");
871        assert!(
872            ari > 0.9,
873            "ARI should be near 1.0 for matching truth, got {ari}"
874        );
875        assert!(
876            nmi > 0.9,
877            "NMI should be near 1.0 for matching truth, got {nmi}"
878        );
879    }
880
881    #[test]
882    fn evaluate_without_true_labels_leaves_external_metrics_none() {
883        let mut clusterer = create_default_quantum_kmeans(2);
884        let data = two_blob_data();
885        clusterer.fit(&data).expect("fit should succeed");
886
887        let metrics = clusterer.evaluate(&data, None).expect("evaluate ok");
888        assert!(metrics.adjusted_rand_index.is_none());
889        assert!(metrics.normalized_mutual_info.is_none());
890    }
891}