Skip to main content

oxirs_embed/application_tasks/
classification.rs

1//! Classification evaluation module
2//!
3//! This module provides comprehensive evaluation for classification tasks using
4//! embedding models, including accuracy, precision, recall, F1-score, and
5//! confusion matrix analysis.
6
7use super::ApplicationEvalConfig;
8use crate::EmbeddingModel;
9use anyhow::{anyhow, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Classification evaluation metrics
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub enum ClassificationMetric {
16    /// Accuracy
17    Accuracy,
18    /// Precision (macro-averaged)
19    Precision,
20    /// Recall (macro-averaged)
21    Recall,
22    /// F1 Score (macro-averaged)
23    F1Score,
24    /// ROC AUC (for binary classification)
25    ROCAUC,
26    /// Precision-Recall AUC
27    PRAUC,
28    /// Matthews Correlation Coefficient
29    MCC,
30}
31
32/// Per-class classification results
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ClassResults {
35    /// Class label
36    pub class_label: String,
37    /// Precision
38    pub precision: f64,
39    /// Recall
40    pub recall: f64,
41    /// F1 score
42    pub f1_score: f64,
43    /// Support (number of instances)
44    pub support: usize,
45}
46
47/// Classification report
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ClassificationReport {
50    /// Macro-averaged metrics
51    pub macro_avg: ClassResults,
52    /// Weighted-averaged metrics
53    pub weighted_avg: ClassResults,
54    /// Overall accuracy
55    pub accuracy: f64,
56    /// Total number of samples
57    pub total_samples: usize,
58}
59
60/// Classification evaluation results
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ClassificationResults {
63    /// Metric scores
64    pub metric_scores: HashMap<String, f64>,
65    /// Per-class results
66    pub per_class_results: HashMap<String, ClassResults>,
67    /// Confusion matrix
68    pub confusion_matrix: Vec<Vec<usize>>,
69    /// Classification report
70    pub classification_report: ClassificationReport,
71}
72
73/// Simple classifier for evaluation
74#[allow(dead_code)]
75pub struct SimpleClassifier {
76    /// Class centroids
77    class_centroids: HashMap<String, Vec<f32>>,
78    /// Class counts
79    class_counts: HashMap<String, usize>,
80}
81
82impl Default for SimpleClassifier {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl SimpleClassifier {
89    /// Create a new simple classifier
90    pub fn new() -> Self {
91        Self {
92            class_centroids: HashMap::new(),
93            class_counts: HashMap::new(),
94        }
95    }
96
97    /// Predict class for an embedding
98    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    /// Calculate euclidean distance
118    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
127/// Classification evaluator
128pub struct ClassificationEvaluator {
129    /// Training data with labels
130    training_data: Vec<(String, String)>, // (entity, label)
131    /// Test data with labels
132    test_data: Vec<(String, String)>,
133    /// Classification metrics
134    metrics: Vec<ClassificationMetric>,
135}
136
137impl ClassificationEvaluator {
138    /// Create a new classification evaluator
139    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    /// Add training data
153    pub fn add_training_data(&mut self, entity: String, label: String) {
154        self.training_data.push((entity, label));
155    }
156
157    /// Add test data
158    pub fn add_test_data(&mut self, entity: String, label: String) {
159        self.test_data.push((entity, label));
160    }
161
162    /// Evaluate classification performance
163    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        // Train a simple classifier on embeddings
175        let classifier = self.train_classifier(model).await?;
176
177        // Predict on test data
178        let predictions = self.predict_test_data(model, &classifier).await?;
179
180        // Calculate metrics
181        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        // Generate per-class results
188        let per_class_results = self.calculate_per_class_results(&predictions)?;
189
190        // Generate confusion matrix
191        let confusion_matrix = self.generate_confusion_matrix(&predictions)?;
192
193        // Generate classification report
194        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    /// Train a simple classifier
206    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        // Average the centroids
225        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    /// Predict on test data
240    async fn predict_test_data(
241        &self,
242        model: &dyn EmbeddingModel,
243        classifier: &SimpleClassifier,
244    ) -> Result<Vec<(String, String, Option<String>)>> {
245        // (true_label, entity, predicted_label)
246        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    /// Calculate classification metric
259    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                // Macro-averaged over the real per-class precision/recall/F1
278                // computed from the confusion matrix (true/false positives and
279                // false negatives per class).
280                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    /// Calculate per-class results from real true-positive/false-positive/
304    /// false-negative counts derived from the predictions.
305    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        // Consider every class that appears either as ground truth or as a
312        // prediction, so classes the classifier over/under-predicts are not
313        // silently dropped from the report.
314        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    /// Generate a real multi-class confusion matrix, indexed by a
376    /// deterministic (sorted) ordering over every class seen as ground truth
377    /// or prediction. `matrix[i][j]` is the count of true-class-`i` samples
378    /// predicted as class `j`.
379    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    /// Generate classification report with macro- and support-weighted
414    /// averages computed from the real per-class results.
415    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    /// Regression test for the P1 finding: Precision/Recall/F1Score must be
493    /// computed from the real confusion matrix, not hardcoded 0.75/0.73/0.74.
494    #[test]
495    fn test_calculate_classification_metric_precision_recall_f1_are_real() -> Result<()> {
496        let evaluator = ClassificationEvaluator::new();
497        // 2 correct "cat" predictions, 1 correct "dog", 1 "cat" mispredicted
498        // as "dog".
499        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        // cat: TP=2, FP=0, FN=1 -> precision=1.0, recall=2/3
514        // dog: TP=1, FP=1, FN=0 -> precision=0.5, recall=1.0
515        // macro precision = (1.0 + 0.5) / 2 = 0.75; macro recall = (2/3 + 1.0) / 2 ≈ 0.8333
516        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        // These must NOT be the old hardcoded constants for a *different*
521        // confusion matrix (sanity: values change with the data).
522        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}