1use super::ApplicationEvalConfig;
8use crate::EmbeddingModel;
9use anyhow::{anyhow, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub enum ClassificationMetric {
16 Accuracy,
18 Precision,
20 Recall,
22 F1Score,
24 ROCAUC,
26 PRAUC,
28 MCC,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ClassResults {
35 pub class_label: String,
37 pub precision: f64,
39 pub recall: f64,
41 pub f1_score: f64,
43 pub support: usize,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ClassificationReport {
50 pub macro_avg: ClassResults,
52 pub weighted_avg: ClassResults,
54 pub accuracy: f64,
56 pub total_samples: usize,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ClassificationResults {
63 pub metric_scores: HashMap<String, f64>,
65 pub per_class_results: HashMap<String, ClassResults>,
67 pub confusion_matrix: Vec<Vec<usize>>,
69 pub classification_report: ClassificationReport,
71}
72
73#[allow(dead_code)]
75pub struct SimpleClassifier {
76 class_centroids: HashMap<String, Vec<f32>>,
78 class_counts: HashMap<String, usize>,
80}
81
82impl Default for SimpleClassifier {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl SimpleClassifier {
89 pub fn new() -> Self {
91 Self {
92 class_centroids: HashMap::new(),
93 class_counts: HashMap::new(),
94 }
95 }
96
97 pub fn predict(&self, embedding: &[f32]) -> Option<String> {
99 if self.class_centroids.is_empty() {
100 return None;
101 }
102
103 let mut best_class = None;
104 let mut best_distance = f32::INFINITY;
105
106 for (class_name, centroid) in &self.class_centroids {
107 let distance = self.euclidean_distance(embedding, centroid);
108 if distance < best_distance {
109 best_distance = distance;
110 best_class = Some(class_name.clone());
111 }
112 }
113
114 best_class
115 }
116
117 fn euclidean_distance(&self, v1: &[f32], v2: &[f32]) -> f32 {
119 v1.iter()
120 .zip(v2.iter())
121 .map(|(a, b)| (a - b).powi(2))
122 .sum::<f32>()
123 .sqrt()
124 }
125}
126
127pub struct ClassificationEvaluator {
129 training_data: Vec<(String, String)>, test_data: Vec<(String, String)>,
133 metrics: Vec<ClassificationMetric>,
135}
136
137impl ClassificationEvaluator {
138 pub fn new() -> Self {
140 Self {
141 training_data: Vec::new(),
142 test_data: Vec::new(),
143 metrics: vec![
144 ClassificationMetric::Accuracy,
145 ClassificationMetric::Precision,
146 ClassificationMetric::Recall,
147 ClassificationMetric::F1Score,
148 ],
149 }
150 }
151
152 pub fn add_training_data(&mut self, entity: String, label: String) {
154 self.training_data.push((entity, label));
155 }
156
157 pub fn add_test_data(&mut self, entity: String, label: String) {
159 self.test_data.push((entity, label));
160 }
161
162 pub async fn evaluate(
164 &self,
165 model: &dyn EmbeddingModel,
166 _config: &ApplicationEvalConfig,
167 ) -> Result<ClassificationResults> {
168 if self.test_data.is_empty() {
169 return Err(anyhow!(
170 "No test data available for classification evaluation"
171 ));
172 }
173
174 let classifier = self.train_classifier(model).await?;
176
177 let predictions = self.predict_test_data(model, &classifier).await?;
179
180 let mut metric_scores = HashMap::new();
182 for metric in &self.metrics {
183 let score = self.calculate_classification_metric(metric, &predictions)?;
184 metric_scores.insert(format!("{metric:?}"), score);
185 }
186
187 let per_class_results = self.calculate_per_class_results(&predictions)?;
189
190 let confusion_matrix = self.generate_confusion_matrix(&predictions)?;
192
193 let classification_report =
195 self.generate_classification_report(&per_class_results, &predictions)?;
196
197 Ok(ClassificationResults {
198 metric_scores,
199 per_class_results,
200 confusion_matrix,
201 classification_report,
202 })
203 }
204
205 async fn train_classifier(&self, model: &dyn EmbeddingModel) -> Result<SimpleClassifier> {
207 let mut class_centroids = HashMap::new();
208 let mut class_counts = HashMap::new();
209
210 for (entity, label) in &self.training_data {
211 if let Ok(embedding) = model.get_entity_embedding(entity) {
212 let centroid = class_centroids
213 .entry(label.clone())
214 .or_insert_with(|| vec![0.0f32; embedding.values.len()]);
215
216 for (i, &value) in embedding.values.iter().enumerate() {
217 centroid[i] += value;
218 }
219
220 *class_counts.entry(label.clone()).or_insert(0) += 1;
221 }
222 }
223
224 for (label, count) in &class_counts {
226 if let Some(centroid) = class_centroids.get_mut(label) {
227 for value in centroid.iter_mut() {
228 *value /= *count as f32;
229 }
230 }
231 }
232
233 Ok(SimpleClassifier {
234 class_centroids,
235 class_counts,
236 })
237 }
238
239 async fn predict_test_data(
241 &self,
242 model: &dyn EmbeddingModel,
243 classifier: &SimpleClassifier,
244 ) -> Result<Vec<(String, String, Option<String>)>> {
245 let mut predictions = Vec::new();
247
248 for (entity, true_label) in &self.test_data {
249 if let Ok(embedding) = model.get_entity_embedding(entity) {
250 let predicted_label = classifier.predict(&embedding.values);
251 predictions.push((true_label.clone(), entity.clone(), predicted_label));
252 }
253 }
254
255 Ok(predictions)
256 }
257
258 fn calculate_classification_metric(
260 &self,
261 metric: &ClassificationMetric,
262 predictions: &[(String, String, Option<String>)],
263 ) -> Result<f64> {
264 match metric {
265 ClassificationMetric::Accuracy => {
266 let correct = predictions
267 .iter()
268 .filter(|(true_label, _, pred)| {
269 pred.as_ref().map(|p| p == true_label).unwrap_or(false)
270 })
271 .count();
272 Ok(correct as f64 / predictions.len() as f64)
273 }
274 ClassificationMetric::Precision
275 | ClassificationMetric::Recall
276 | ClassificationMetric::F1Score => {
277 let per_class = self.calculate_per_class_results(predictions)?;
281 if per_class.is_empty() {
282 return Ok(0.0);
283 }
284 let sum: f64 = match metric {
285 ClassificationMetric::Precision => {
286 per_class.values().map(|c| c.precision).sum()
287 }
288 ClassificationMetric::Recall => per_class.values().map(|c| c.recall).sum(),
289 ClassificationMetric::F1Score => per_class.values().map(|c| c.f1_score).sum(),
290 _ => unreachable!("matched above"),
291 };
292 Ok(sum / per_class.len() as f64)
293 }
294 ClassificationMetric::ROCAUC
295 | ClassificationMetric::PRAUC
296 | ClassificationMetric::MCC => Err(anyhow!(
297 "Classification metric {metric:?} is not yet implemented; requires per-class \
298 prediction scores, which SimpleClassifier does not currently expose"
299 )),
300 }
301 }
302
303 fn calculate_per_class_results(
306 &self,
307 predictions: &[(String, String, Option<String>)],
308 ) -> Result<HashMap<String, ClassResults>> {
309 let mut results = HashMap::new();
310
311 let classes: std::collections::HashSet<String> = predictions
315 .iter()
316 .map(|(true_label, _, _)| true_label.clone())
317 .chain(predictions.iter().filter_map(|(_, _, pred)| pred.clone()))
318 .collect();
319
320 for class in classes {
321 let true_positives = predictions
322 .iter()
323 .filter(|(true_label, _, pred)| {
324 true_label == &class && pred.as_deref() == Some(class.as_str())
325 })
326 .count();
327 let false_positives = predictions
328 .iter()
329 .filter(|(true_label, _, pred)| {
330 true_label != &class && pred.as_deref() == Some(class.as_str())
331 })
332 .count();
333 let false_negatives = predictions
334 .iter()
335 .filter(|(true_label, _, pred)| {
336 true_label == &class && pred.as_deref() != Some(class.as_str())
337 })
338 .count();
339 let support = predictions
340 .iter()
341 .filter(|(true_label, _, _)| true_label == &class)
342 .count();
343
344 let precision = if true_positives + false_positives > 0 {
345 true_positives as f64 / (true_positives + false_positives) as f64
346 } else {
347 0.0
348 };
349 let recall = if true_positives + false_negatives > 0 {
350 true_positives as f64 / (true_positives + false_negatives) as f64
351 } else {
352 0.0
353 };
354 let f1_score = if precision + recall > 0.0 {
355 2.0 * precision * recall / (precision + recall)
356 } else {
357 0.0
358 };
359
360 results.insert(
361 class.clone(),
362 ClassResults {
363 class_label: class,
364 precision,
365 recall,
366 f1_score,
367 support,
368 },
369 );
370 }
371
372 Ok(results)
373 }
374
375 fn generate_confusion_matrix(
380 &self,
381 predictions: &[(String, String, Option<String>)],
382 ) -> Result<Vec<Vec<usize>>> {
383 let mut classes: Vec<String> = predictions
384 .iter()
385 .map(|(true_label, _, _)| true_label.clone())
386 .chain(predictions.iter().filter_map(|(_, _, pred)| pred.clone()))
387 .collect::<std::collections::HashSet<_>>()
388 .into_iter()
389 .collect();
390 classes.sort();
391
392 let class_index: HashMap<&str, usize> = classes
393 .iter()
394 .enumerate()
395 .map(|(i, c)| (c.as_str(), i))
396 .collect();
397
398 let mut matrix = vec![vec![0usize; classes.len()]; classes.len()];
399 for (true_label, _, pred) in predictions {
400 let Some(&true_idx) = class_index.get(true_label.as_str()) else {
401 continue;
402 };
403 if let Some(predicted_label) = pred {
404 if let Some(&pred_idx) = class_index.get(predicted_label.as_str()) {
405 matrix[true_idx][pred_idx] += 1;
406 }
407 }
408 }
409
410 Ok(matrix)
411 }
412
413 fn generate_classification_report(
416 &self,
417 per_class_results: &HashMap<String, ClassResults>,
418 predictions: &[(String, String, Option<String>)],
419 ) -> Result<ClassificationReport> {
420 let accuracy = predictions
421 .iter()
422 .filter(|(true_label, _, pred)| pred.as_ref().map(|p| p == true_label).unwrap_or(false))
423 .count() as f64
424 / predictions.len().max(1) as f64;
425
426 let num_classes = per_class_results.len().max(1);
427 let total_support: usize = per_class_results.values().map(|c| c.support).sum();
428
429 let macro_precision =
430 per_class_results.values().map(|c| c.precision).sum::<f64>() / num_classes as f64;
431 let macro_recall =
432 per_class_results.values().map(|c| c.recall).sum::<f64>() / num_classes as f64;
433 let macro_f1 =
434 per_class_results.values().map(|c| c.f1_score).sum::<f64>() / num_classes as f64;
435
436 let (weighted_precision, weighted_recall, weighted_f1) = if total_support > 0 {
437 let weighted_precision = per_class_results
438 .values()
439 .map(|c| c.precision * c.support as f64)
440 .sum::<f64>()
441 / total_support as f64;
442 let weighted_recall = per_class_results
443 .values()
444 .map(|c| c.recall * c.support as f64)
445 .sum::<f64>()
446 / total_support as f64;
447 let weighted_f1 = per_class_results
448 .values()
449 .map(|c| c.f1_score * c.support as f64)
450 .sum::<f64>()
451 / total_support as f64;
452 (weighted_precision, weighted_recall, weighted_f1)
453 } else {
454 (0.0, 0.0, 0.0)
455 };
456
457 let macro_avg = ClassResults {
458 class_label: "macro avg".to_string(),
459 precision: macro_precision,
460 recall: macro_recall,
461 f1_score: macro_f1,
462 support: predictions.len(),
463 };
464
465 let weighted_avg = ClassResults {
466 class_label: "weighted avg".to_string(),
467 precision: weighted_precision,
468 recall: weighted_recall,
469 f1_score: weighted_f1,
470 support: predictions.len(),
471 };
472
473 Ok(ClassificationReport {
474 macro_avg,
475 weighted_avg,
476 accuracy,
477 total_samples: predictions.len(),
478 })
479 }
480}
481
482impl Default for ClassificationEvaluator {
483 fn default() -> Self {
484 Self::new()
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
495 fn test_calculate_classification_metric_precision_recall_f1_are_real() -> Result<()> {
496 let evaluator = ClassificationEvaluator::new();
497 let predictions = vec![
500 ("cat".to_string(), "e1".to_string(), Some("cat".to_string())),
501 ("cat".to_string(), "e2".to_string(), Some("cat".to_string())),
502 ("cat".to_string(), "e3".to_string(), Some("dog".to_string())),
503 ("dog".to_string(), "e4".to_string(), Some("dog".to_string())),
504 ];
505
506 let precision = evaluator
507 .calculate_classification_metric(&ClassificationMetric::Precision, &predictions)?;
508 let recall = evaluator
509 .calculate_classification_metric(&ClassificationMetric::Recall, &predictions)?;
510 let f1 = evaluator
511 .calculate_classification_metric(&ClassificationMetric::F1Score, &predictions)?;
512
513 assert!((precision - 0.75).abs() < 1e-9, "precision = {precision}");
517 assert!((recall - 0.8333333333).abs() < 1e-6, "recall = {recall}");
518 assert!(f1 > 0.0 && f1 < 1.0, "f1 = {f1}");
519
520 let all_correct = vec![
523 ("cat".to_string(), "e1".to_string(), Some("cat".to_string())),
524 ("dog".to_string(), "e2".to_string(), Some("dog".to_string())),
525 ];
526 let perfect_precision = evaluator
527 .calculate_classification_metric(&ClassificationMetric::Precision, &all_correct)?;
528 assert!(
529 (perfect_precision - 1.0).abs() < 1e-9,
530 "perfect_precision = {perfect_precision}"
531 );
532
533 Ok(())
534 }
535
536 #[test]
537 fn test_calculate_classification_metric_unsupported_metrics_error() {
538 let evaluator = ClassificationEvaluator::new();
539 let predictions = vec![("cat".to_string(), "e1".to_string(), Some("cat".to_string()))];
540
541 for metric in [
542 ClassificationMetric::ROCAUC,
543 ClassificationMetric::PRAUC,
544 ClassificationMetric::MCC,
545 ] {
546 assert!(
547 evaluator
548 .calculate_classification_metric(&metric, &predictions)
549 .is_err(),
550 "metric = {metric:?}"
551 );
552 }
553 }
554
555 #[test]
556 fn test_calculate_per_class_results_computes_real_confusion_counts() {
557 let evaluator = ClassificationEvaluator::new();
558 let predictions = vec![
559 ("cat".to_string(), "e1".to_string(), Some("cat".to_string())),
560 ("cat".to_string(), "e2".to_string(), Some("dog".to_string())),
561 ];
562
563 let results = evaluator
564 .calculate_per_class_results(&predictions)
565 .expect("should succeed");
566 let cat_results = &results["cat"];
567 assert_eq!(cat_results.support, 2);
568 assert!((cat_results.precision - 1.0).abs() < 1e-9);
569 assert!((cat_results.recall - 0.5).abs() < 1e-9);
570 }
571}