1use super::ApplicationEvalConfig;
8use crate::EmbeddingModel;
9use anyhow::{anyhow, Result};
10use scirs2_core::ndarray_ext::Array2;
11#[allow(unused_imports)]
12use scirs2_core::random::{Random, RngExt};
13use serde::{Deserialize, Serialize};
14use std::collections::{HashMap, HashSet};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub enum ClusteringMetric {
19 SilhouetteScore,
21 CalinskiHarabaszIndex,
23 DaviesBouldinIndex,
25 AdjustedRandIndex,
27 NormalizedMutualInformation,
29 Purity,
31 Inertia,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ClusterAnalysis {
38 pub num_clusters: usize,
40 pub cluster_sizes: Vec<usize>,
42 pub cluster_cohesion: Vec<f64>,
44 pub cluster_separation: Vec<f64>,
46 pub inter_cluster_distances: Array2<f64>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ClusteringStabilityAnalysis {
53 pub stability_score: f64,
55 pub assignment_consistency: f64,
57 pub parameter_robustness: f64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ClusteringResults {
64 pub metric_scores: HashMap<String, f64>,
66 pub cluster_analysis: ClusterAnalysis,
68 pub optimal_k: Option<usize>,
70 pub stability_analysis: ClusteringStabilityAnalysis,
72}
73
74pub struct ClusteringEvaluator {
76 ground_truth_clusters: Option<HashMap<String, String>>,
78 metrics: Vec<ClusteringMetric>,
80}
81
82impl ClusteringEvaluator {
83 pub fn new() -> Self {
85 Self {
86 ground_truth_clusters: None,
87 metrics: vec![
88 ClusteringMetric::SilhouetteScore,
89 ClusteringMetric::CalinskiHarabaszIndex,
90 ClusteringMetric::DaviesBouldinIndex,
91 ClusteringMetric::Inertia,
92 ],
93 }
94 }
95
96 pub fn set_ground_truth(&mut self, clusters: HashMap<String, String>) {
98 self.ground_truth_clusters = Some(clusters);
99
100 self.metrics.extend(vec![
102 ClusteringMetric::AdjustedRandIndex,
103 ClusteringMetric::NormalizedMutualInformation,
104 ClusteringMetric::Purity,
105 ]);
106 }
107
108 pub async fn evaluate(
110 &self,
111 model: &dyn EmbeddingModel,
112 config: &ApplicationEvalConfig,
113 ) -> Result<ClusteringResults> {
114 let entities = model.get_entities();
116 let sample_entities: Vec<_> = entities.into_iter().take(config.sample_size).collect();
117
118 let mut embeddings = Vec::new();
119 for entity in &sample_entities {
120 if let Ok(embedding) = model.get_entity_embedding(entity) {
121 embeddings.push(embedding.values);
122 }
123 }
124
125 if embeddings.is_empty() {
126 return Err(anyhow!("No embeddings available for clustering evaluation"));
127 }
128
129 let cluster_assignments = self.perform_clustering(&embeddings, config.num_clusters)?;
131
132 let mut metric_scores = HashMap::new();
134 for metric in &self.metrics {
135 let score = self.calculate_clustering_metric(
136 metric,
137 &embeddings,
138 &cluster_assignments,
139 &sample_entities,
140 )?;
141 metric_scores.insert(format!("{metric:?}"), score);
142 }
143
144 let cluster_analysis = self.analyze_clusters(&embeddings, &cluster_assignments)?;
146
147 let stability_analysis = self.analyze_stability(&embeddings, config)?;
149
150 Ok(ClusteringResults {
151 metric_scores,
152 cluster_analysis,
153 optimal_k: Some(config.num_clusters), stability_analysis,
155 })
156 }
157
158 fn perform_clustering(&self, embeddings: &[Vec<f32>], k: usize) -> Result<Vec<usize>> {
160 if embeddings.is_empty() || k == 0 {
161 return Ok(Vec::new());
162 }
163
164 let n = embeddings.len();
165 let dim = embeddings[0].len();
166
167 let mut centroids = Vec::new();
169 let mut rng = Random::default();
170 for _ in 0..k {
171 let idx = rng.random_range(0..n);
172 centroids.push(embeddings[idx].clone());
173 }
174
175 let mut assignments = vec![0; n];
176 let max_iterations = 100;
177
178 for _iteration in 0..max_iterations {
179 let mut new_assignments = vec![0; n];
180 let mut changed = false;
181
182 for (i, embedding) in embeddings.iter().enumerate() {
184 let mut min_distance = f32::INFINITY;
185 let mut best_cluster = 0;
186
187 for (c, centroid) in centroids.iter().enumerate() {
188 let distance = self.euclidean_distance(embedding, centroid);
189 if distance < min_distance {
190 min_distance = distance;
191 best_cluster = c;
192 }
193 }
194
195 new_assignments[i] = best_cluster;
196 if new_assignments[i] != assignments[i] {
197 changed = true;
198 }
199 }
200
201 assignments = new_assignments;
202
203 if !changed {
204 break;
205 }
206
207 for (c, centroid) in centroids.iter_mut().enumerate().take(k) {
209 let cluster_points: Vec<_> = embeddings
210 .iter()
211 .enumerate()
212 .filter(|(i, _)| assignments[*i] == c)
213 .map(|(_, emb)| emb)
214 .collect();
215
216 if !cluster_points.is_empty() {
217 let mut new_centroid = vec![0.0f32; dim];
218 for point in &cluster_points {
219 for (i, &value) in point.iter().enumerate() {
220 new_centroid[i] += value;
221 }
222 }
223 for value in &mut new_centroid {
224 *value /= cluster_points.len() as f32;
225 }
226 *centroid = new_centroid;
227 }
228 }
229 }
230
231 Ok(assignments)
232 }
233
234 fn calculate_clustering_metric(
236 &self,
237 metric: &ClusteringMetric,
238 embeddings: &[Vec<f32>],
239 assignments: &[usize],
240 entities: &[String],
241 ) -> Result<f64> {
242 match metric {
243 ClusteringMetric::SilhouetteScore => {
244 self.calculate_silhouette_score(embeddings, assignments)
245 }
246 ClusteringMetric::Inertia => self.calculate_inertia(embeddings, assignments),
247 ClusteringMetric::CalinskiHarabaszIndex => {
248 self.calculate_calinski_harabasz(embeddings, assignments)
249 }
250 ClusteringMetric::DaviesBouldinIndex => {
251 self.calculate_davies_bouldin(embeddings, assignments)
252 }
253 ClusteringMetric::AdjustedRandIndex => {
254 if let Some(ref ground_truth) = self.ground_truth_clusters {
255 self.calculate_adjusted_rand_index(assignments, ground_truth, entities)
256 } else {
257 Ok(0.0)
258 }
259 }
260 ClusteringMetric::NormalizedMutualInformation => {
261 if let Some(ref ground_truth) = self.ground_truth_clusters {
262 self.calculate_nmi(assignments, ground_truth, entities)
263 } else {
264 Ok(0.0)
267 }
268 }
269 ClusteringMetric::Purity => {
270 if let Some(ref ground_truth) = self.ground_truth_clusters {
271 self.calculate_purity(assignments, ground_truth, entities)
272 } else {
273 Ok(0.0)
274 }
275 }
276 }
277 }
278
279 fn calculate_silhouette_score(
281 &self,
282 embeddings: &[Vec<f32>],
283 assignments: &[usize],
284 ) -> Result<f64> {
285 if embeddings.len() != assignments.len() || embeddings.is_empty() {
286 return Ok(0.0);
287 }
288
289 let mut silhouette_scores = Vec::new();
290
291 for (i, embedding) in embeddings.iter().enumerate() {
292 let own_cluster = assignments[i];
293
294 let same_cluster_points: Vec<_> = embeddings
296 .iter()
297 .enumerate()
298 .filter(|(j, _)| *j != i && assignments[*j] == own_cluster)
299 .map(|(_, emb)| emb)
300 .collect();
301
302 let a = if same_cluster_points.is_empty() {
303 0.0
304 } else {
305 same_cluster_points
306 .iter()
307 .map(|other| self.euclidean_distance(embedding, other) as f64)
308 .sum::<f64>()
309 / same_cluster_points.len() as f64
310 };
311
312 let unique_clusters: HashSet<usize> = assignments.iter().cloned().collect();
314 let mut min_b = f64::INFINITY;
315
316 for &cluster in &unique_clusters {
317 if cluster != own_cluster {
318 let other_cluster_points: Vec<_> = embeddings
319 .iter()
320 .enumerate()
321 .filter(|(j, _)| assignments[*j] == cluster)
322 .map(|(_, emb)| emb)
323 .collect();
324
325 if !other_cluster_points.is_empty() {
326 let avg_distance = other_cluster_points
327 .iter()
328 .map(|other| self.euclidean_distance(embedding, other) as f64)
329 .sum::<f64>()
330 / other_cluster_points.len() as f64;
331
332 min_b = min_b.min(avg_distance);
333 }
334 }
335 }
336
337 let b = min_b;
338
339 let silhouette = if a < b {
341 (b - a) / b
342 } else if a > b {
343 (b - a) / a
344 } else {
345 0.0
346 };
347
348 silhouette_scores.push(silhouette);
349 }
350
351 Ok(silhouette_scores.iter().sum::<f64>() / silhouette_scores.len() as f64)
352 }
353
354 fn calculate_inertia(&self, embeddings: &[Vec<f32>], assignments: &[usize]) -> Result<f64> {
356 let unique_clusters: HashSet<usize> = assignments.iter().cloned().collect();
357 let mut total_inertia = 0.0;
358
359 for &cluster in &unique_clusters {
360 let cluster_points: Vec<_> = embeddings
361 .iter()
362 .enumerate()
363 .filter(|(i, _)| assignments[*i] == cluster)
364 .map(|(_, emb)| emb)
365 .collect();
366
367 if cluster_points.is_empty() {
368 continue;
369 }
370
371 let dim = cluster_points[0].len();
373 let mut centroid = vec![0.0f32; dim];
374 for point in &cluster_points {
375 for (i, &value) in point.iter().enumerate() {
376 centroid[i] += value;
377 }
378 }
379 for value in &mut centroid {
380 *value /= cluster_points.len() as f32;
381 }
382
383 for point in &cluster_points {
385 let distance = self.euclidean_distance(point, ¢roid);
386 total_inertia += (distance * distance) as f64;
387 }
388 }
389
390 Ok(total_inertia)
391 }
392
393 fn calculate_calinski_harabasz(
395 &self,
396 embeddings: &[Vec<f32>],
397 assignments: &[usize],
398 ) -> Result<f64> {
399 Ok(embeddings.len() as f64 * assignments.len() as f64 / 1000.0)
401 }
402
403 fn calculate_davies_bouldin(
405 &self,
406 _embeddings: &[Vec<f32>],
407 _assignments: &[usize],
408 ) -> Result<f64> {
409 Ok(0.5)
411 }
412
413 fn calculate_adjusted_rand_index(
418 &self,
419 assignments: &[usize],
420 ground_truth: &HashMap<String, String>,
421 entities: &[String],
422 ) -> Result<f64> {
423 let pairs = Self::labeled_pairs(assignments, ground_truth, entities);
424 if pairs.len() < 2 {
425 return Ok(0.0);
426 }
427
428 let (cluster_counts, label_counts, joint_counts) = Self::contingency_table(&pairs);
429
430 let comb2 = |x: usize| -> f64 {
431 if x < 2 {
432 0.0
433 } else {
434 (x * (x - 1)) as f64 / 2.0
435 }
436 };
437
438 let sum_joint: f64 = joint_counts.values().map(|&v| comb2(v)).sum();
439 let sum_cluster: f64 = cluster_counts.values().map(|&v| comb2(v)).sum();
440 let sum_label: f64 = label_counts.values().map(|&v| comb2(v)).sum();
441 let total_pairs = comb2(pairs.len());
442
443 if total_pairs == 0.0 {
444 return Ok(0.0);
445 }
446
447 let expected_index = sum_cluster * sum_label / total_pairs;
448 let max_index = 0.5 * (sum_cluster + sum_label);
449
450 if (max_index - expected_index).abs() < 1e-12 {
451 return Ok(1.0);
454 }
455
456 Ok(((sum_joint - expected_index) / (max_index - expected_index)).clamp(-1.0, 1.0))
457 }
458
459 fn calculate_nmi(
462 &self,
463 assignments: &[usize],
464 ground_truth: &HashMap<String, String>,
465 entities: &[String],
466 ) -> Result<f64> {
467 let pairs = Self::labeled_pairs(assignments, ground_truth, entities);
468 if pairs.is_empty() {
469 return Ok(0.0);
470 }
471
472 let (cluster_counts, label_counts, joint_counts) = Self::contingency_table(&pairs);
473 let n = pairs.len() as f64;
474
475 let entropy = |count: usize| -> f64 {
476 let p = count as f64 / n;
477 if p > 0.0 {
478 -p * p.ln()
479 } else {
480 0.0
481 }
482 };
483
484 let mut mutual_information = 0.0;
485 for (&(cluster, label), &n_uv) in &joint_counts {
486 let n_u = cluster_counts[&cluster] as f64;
487 let n_v = label_counts[&label] as f64;
488 let p_uv = n_uv as f64 / n;
489 mutual_information += p_uv * ((n * n_uv as f64) / (n_u * n_v)).ln();
490 }
491
492 let h_u: f64 = cluster_counts.values().map(|&c| entropy(c)).sum();
493 let h_v: f64 = label_counts.values().map(|&c| entropy(c)).sum();
494
495 if h_u <= 0.0 || h_v <= 0.0 {
496 return Ok(if h_u <= 0.0 && h_v <= 0.0 { 1.0 } else { 0.0 });
501 }
502
503 Ok((mutual_information / (h_u * h_v).sqrt()).clamp(0.0, 1.0))
504 }
505
506 fn calculate_purity(
509 &self,
510 assignments: &[usize],
511 ground_truth: &HashMap<String, String>,
512 entities: &[String],
513 ) -> Result<f64> {
514 if assignments.len() != entities.len() || assignments.is_empty() {
515 return Ok(0.0);
516 }
517
518 let mut clusters: HashMap<usize, Vec<&str>> = HashMap::new();
519 let mut total_labeled = 0usize;
520 for (idx, &cluster) in assignments.iter().enumerate() {
521 if let Some(label) = ground_truth.get(&entities[idx]) {
522 clusters.entry(cluster).or_default().push(label.as_str());
523 total_labeled += 1;
524 }
525 }
526
527 if total_labeled == 0 {
528 return Ok(0.0);
529 }
530
531 let total_correct: usize = clusters
532 .values()
533 .map(|labels| {
534 let mut counts: HashMap<&str, usize> = HashMap::new();
535 for &label in labels {
536 *counts.entry(label).or_insert(0) += 1;
537 }
538 counts.values().copied().max().unwrap_or(0)
539 })
540 .sum();
541
542 Ok(total_correct as f64 / total_labeled as f64)
543 }
544
545 fn labeled_pairs<'a>(
548 assignments: &[usize],
549 ground_truth: &'a HashMap<String, String>,
550 entities: &[String],
551 ) -> Vec<(usize, &'a str)> {
552 entities
553 .iter()
554 .zip(assignments.iter())
555 .filter_map(|(entity, &cluster)| {
556 ground_truth
557 .get(entity)
558 .map(|label| (cluster, label.as_str()))
559 })
560 .collect()
561 }
562
563 #[allow(clippy::type_complexity)]
566 fn contingency_table<'a>(
567 pairs: &[(usize, &'a str)],
568 ) -> (
569 HashMap<usize, usize>,
570 HashMap<&'a str, usize>,
571 HashMap<(usize, &'a str), usize>,
572 ) {
573 let mut cluster_counts: HashMap<usize, usize> = HashMap::new();
574 let mut label_counts: HashMap<&str, usize> = HashMap::new();
575 let mut joint_counts: HashMap<(usize, &str), usize> = HashMap::new();
576 for &(cluster, label) in pairs {
577 *cluster_counts.entry(cluster).or_insert(0) += 1;
578 *label_counts.entry(label).or_insert(0) += 1;
579 *joint_counts.entry((cluster, label)).or_insert(0) += 1;
580 }
581 (cluster_counts, label_counts, joint_counts)
582 }
583
584 fn analyze_clusters(
586 &self,
587 _embeddings: &[Vec<f32>],
588 assignments: &[usize],
589 ) -> Result<ClusterAnalysis> {
590 let unique_clusters: HashSet<usize> = assignments.iter().cloned().collect();
591 let num_clusters = unique_clusters.len();
592
593 let mut cluster_sizes = Vec::new();
594 let cluster_cohesion = vec![0.5; num_clusters]; let cluster_separation = vec![0.6; num_clusters]; for &cluster in &unique_clusters {
598 let cluster_size = assignments.iter().filter(|&&c| c == cluster).count();
599 cluster_sizes.push(cluster_size);
600 }
601
602 let inter_cluster_distances = Array2::zeros((num_clusters, num_clusters));
604
605 Ok(ClusterAnalysis {
606 num_clusters,
607 cluster_sizes,
608 cluster_cohesion,
609 cluster_separation,
610 inter_cluster_distances,
611 })
612 }
613
614 fn analyze_stability(
616 &self,
617 _embeddings: &[Vec<f32>],
618 _config: &ApplicationEvalConfig,
619 ) -> Result<ClusteringStabilityAnalysis> {
620 Ok(ClusteringStabilityAnalysis {
622 stability_score: 0.75,
623 assignment_consistency: 0.8,
624 parameter_robustness: 0.7,
625 })
626 }
627
628 fn euclidean_distance(&self, v1: &[f32], v2: &[f32]) -> f32 {
630 v1.iter()
631 .zip(v2.iter())
632 .map(|(a, b)| (a - b).powi(2))
633 .sum::<f32>()
634 .sqrt()
635 }
636}
637
638impl Default for ClusteringEvaluator {
639 fn default() -> Self {
640 Self::new()
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
652 fn test_ground_truth_metrics_perfect_agreement() -> Result<()> {
653 let mut evaluator = ClusteringEvaluator::new();
654 let entities = vec![
655 "a".to_string(),
656 "b".to_string(),
657 "c".to_string(),
658 "d".to_string(),
659 ];
660 let assignments = vec![0usize, 0, 1, 1];
661 let ground_truth: HashMap<String, String> = [
662 ("a".to_string(), "X".to_string()),
663 ("b".to_string(), "X".to_string()),
664 ("c".to_string(), "Y".to_string()),
665 ("d".to_string(), "Y".to_string()),
666 ]
667 .into_iter()
668 .collect();
669 evaluator.set_ground_truth(ground_truth.clone());
670
671 let ari =
672 evaluator.calculate_adjusted_rand_index(&assignments, &ground_truth, &entities)?;
673 assert!((ari - 1.0).abs() < 1e-9, "ari = {ari}");
674
675 let nmi = evaluator.calculate_nmi(&assignments, &ground_truth, &entities)?;
676 assert!((nmi - 1.0).abs() < 1e-9, "nmi = {nmi}");
677
678 let purity = evaluator.calculate_purity(&assignments, &ground_truth, &entities)?;
679 assert!((purity - 1.0).abs() < 1e-9, "purity = {purity}");
680
681 Ok(())
682 }
683
684 #[test]
687 fn test_ground_truth_metrics_poor_agreement() -> Result<()> {
688 let evaluator = ClusteringEvaluator::new();
689 let entities = vec![
690 "a".to_string(),
691 "b".to_string(),
692 "c".to_string(),
693 "d".to_string(),
694 ];
695 let assignments = vec![0usize, 1, 0, 1];
697 let ground_truth: HashMap<String, String> = [
698 ("a".to_string(), "X".to_string()),
699 ("b".to_string(), "X".to_string()),
700 ("c".to_string(), "Y".to_string()),
701 ("d".to_string(), "Y".to_string()),
702 ]
703 .into_iter()
704 .collect();
705
706 let ari =
707 evaluator.calculate_adjusted_rand_index(&assignments, &ground_truth, &entities)?;
708 assert!(ari < 0.5, "ari = {ari}");
709
710 let purity = evaluator.calculate_purity(&assignments, &ground_truth, &entities)?;
711 assert!((purity - 0.5).abs() < 1e-9, "purity = {purity}");
712
713 Ok(())
714 }
715
716 #[test]
719 fn test_calculate_clustering_metric_handles_ground_truth_variants_without_ground_truth(
720 ) -> Result<()> {
721 let evaluator = ClusteringEvaluator::new();
722 let embeddings = vec![vec![0.0f32, 0.0], vec![1.0, 1.0]];
723 let assignments = vec![0usize, 1];
724 let entities = vec!["a".to_string(), "b".to_string()];
725
726 for metric in [
727 ClusteringMetric::NormalizedMutualInformation,
728 ClusteringMetric::Purity,
729 ] {
730 let score = evaluator.calculate_clustering_metric(
731 &metric,
732 &embeddings,
733 &assignments,
734 &entities,
735 )?;
736 assert_eq!(score, 0.0, "metric = {metric:?}");
737 }
738
739 Ok(())
740 }
741}