Skip to main content

anno_eval/eval/
calibration.rs

1//! Confidence calibration metrics for NER evaluation.
2//!
3//! Measures whether a model's confidence scores align with actual correctness.
4//! A well-calibrated model should have high confidence for correct predictions
5//! and low confidence for incorrect ones.
6//!
7//! # ⚠️ Important: Confidence Score Semantics
8//!
9//! Calibration metrics are only meaningful for **probabilistically calibrated**
10//! confidence scores (i.e., scores that approximate P(correct|prediction)).
11//!
12//! | Backend | `ExtractionMethod` | Calibrated? | Notes |
13//! |---------|-------------------|-------------|-------|
14//! | BertNEROnnx, GLiNEROnnx | `Neural` | ✓ Yes | Softmax probabilities |
15//! | RegexNER | `Pattern` | ✗ No | Hardcoded values (e.g., 0.95) |
16//! | HeuristicNER | `Heuristic` | ✗ No | Rule-based scores |
17//! | StackedNER | Mixed | Partial | Depends on entity type |
18//!
19//! **Running calibration analysis on RegexNER or HeuristicNER produces
20//! meaningless results.** Use `ExtractionMethod::is_calibrated()` to check.
21//!
22//! # Research Background
23//!
24//! Calibration is critical for production NER systems where:
25//! - Downstream systems need reliable confidence thresholds
26//! - Human review should focus on low-confidence predictions
27//! - False confidence is worse than admitted uncertainty
28//!
29//! See: Guo et al. (2017) "On Calibration of Modern Neural Networks" (arXiv:1706.04599)
30//!
31//! # Key Metrics
32//!
33//! - **Expected Calibration Error (ECE)**: Weighted average of per-bin calibration error
34//! - **Maximum Calibration Error (MCE)**: Worst-case calibration in any bin
35//! - **Brier Score**: Mean squared error of probabilistic predictions
36//! - **Confidence Gap**: Difference between avg confidence on correct vs incorrect
37//!
38//! # Example
39//!
40//! ```rust
41//! use anno_eval::eval::calibration::{CalibrationEvaluator, CalibrationResults};
42//!
43//! // Only use with probabilistic confidence scores (e.g., from neural models)
44//! let predictions = vec![
45//!     (0.95, true),   // High confidence, correct
46//!     (0.80, true),   // Medium confidence, correct
47//!     (0.60, false),  // Low confidence, incorrect (good!)
48//!     (0.90, false),  // High confidence, incorrect (bad!)
49//! ];
50//!
51//! let results = CalibrationEvaluator::compute(&predictions);
52//! println!("ECE: {:.3}", results.ece);
53//! ```
54
55use serde::{Deserialize, Serialize};
56use std::collections::HashMap;
57
58// =============================================================================
59// Calibration Results
60// =============================================================================
61
62/// Results of calibration evaluation.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CalibrationResults {
65    /// Expected Calibration Error (lower is better, 0 = perfect)
66    pub ece: f64,
67    /// Maximum Calibration Error (lower is better)
68    pub mce: f64,
69    /// Brier Score (lower is better, 0 = perfect)
70    pub brier_score: f64,
71    /// Average confidence of correct predictions
72    pub avg_confidence_correct: f64,
73    /// Average confidence of incorrect predictions
74    pub avg_confidence_incorrect: f64,
75    /// Confidence gap (correct - incorrect, higher is better)
76    pub confidence_gap: f64,
77    /// Reliability diagram data (bin_midpoint -> (avg_confidence, accuracy, count))
78    pub reliability_bins: Vec<ReliabilityBin>,
79    /// Total predictions evaluated
80    pub total_predictions: usize,
81    /// Accuracy at different confidence thresholds
82    pub threshold_accuracy: HashMap<String, ThresholdMetrics>,
83}
84
85/// A single bin in the reliability diagram.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ReliabilityBin {
88    /// Bin range (e.g., 0.0-0.1)
89    pub range: (f64, f64),
90    /// Average confidence in this bin
91    pub avg_confidence: f64,
92    /// Accuracy (fraction correct) in this bin
93    pub accuracy: f64,
94    /// Number of predictions in this bin
95    pub count: usize,
96    /// Calibration error for this bin: |accuracy - avg_confidence|
97    pub calibration_error: f64,
98}
99
100/// Metrics at a specific confidence threshold.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ThresholdMetrics {
103    /// Accuracy of predictions above this threshold
104    pub accuracy: f64,
105    /// Coverage (fraction of predictions above threshold)
106    pub coverage: f64,
107    /// Count of predictions above threshold
108    pub count: usize,
109}
110
111// =============================================================================
112// Calibration Evaluator
113// =============================================================================
114
115/// Evaluator for confidence calibration.
116#[derive(Debug, Clone)]
117pub struct CalibrationEvaluator {
118    /// Number of bins for reliability diagram
119    pub num_bins: usize,
120    /// Confidence thresholds to evaluate
121    pub thresholds: Vec<f64>,
122}
123
124impl Default for CalibrationEvaluator {
125    fn default() -> Self {
126        Self {
127            num_bins: 10,
128            thresholds: vec![0.5, 0.7, 0.8, 0.9, 0.95],
129        }
130    }
131}
132
133impl CalibrationEvaluator {
134    /// Create a new evaluator with custom bins.
135    pub fn new(num_bins: usize) -> Self {
136        Self {
137            num_bins,
138            ..Default::default()
139        }
140    }
141
142    /// Compute calibration metrics from (confidence, correct) pairs.
143    pub fn compute(predictions: &[(f64, bool)]) -> CalibrationResults {
144        Self::default().evaluate(predictions)
145    }
146
147    /// Evaluate calibration on predictions.
148    ///
149    /// Each prediction is a tuple of (confidence_score, is_correct).
150    ///
151    /// Computes:
152    /// - **ECE**: `Σ(n_i / N) × |acc_i - conf_i|` where bins partition [0, 1]
153    /// - **Brier Score**: `(1/N) × Σ(conf_i - target_i)²` where target is 1 if correct, 0 otherwise
154    ///
155    /// Reference: Guo et al. (2017) "On Calibration of Modern Neural Networks" (arXiv:1706.04599)
156    pub fn evaluate(&self, predictions: &[(f64, bool)]) -> CalibrationResults {
157        if predictions.is_empty() {
158            return CalibrationResults {
159                ece: 0.0,
160                mce: 0.0,
161                brier_score: 0.0,
162                avg_confidence_correct: 0.0,
163                avg_confidence_incorrect: 0.0,
164                confidence_gap: 0.0,
165                reliability_bins: Vec::new(),
166                total_predictions: 0,
167                threshold_accuracy: HashMap::new(),
168            };
169        }
170
171        // Build reliability bins
172        let bin_width = 1.0 / self.num_bins as f64;
173        let mut bins: Vec<Vec<(f64, bool)>> = vec![Vec::new(); self.num_bins];
174
175        for &(conf, correct) in predictions {
176            let bin_idx = ((conf * self.num_bins as f64) as usize).min(self.num_bins - 1);
177            bins[bin_idx].push((conf, correct));
178        }
179
180        // Compute per-bin metrics
181        let mut reliability_bins = Vec::new();
182        let mut ece_sum = 0.0;
183        let mut mce: f64 = 0.0;
184
185        for (i, bin) in bins.iter().enumerate() {
186            if bin.is_empty() {
187                continue;
188            }
189
190            let range_start = i as f64 * bin_width;
191            let range_end = (i + 1) as f64 * bin_width;
192
193            let avg_confidence = bin.iter().map(|(c, _)| c).sum::<f64>() / bin.len() as f64;
194            let accuracy =
195                bin.iter().filter(|(_, correct)| *correct).count() as f64 / bin.len() as f64;
196            let calibration_error = (accuracy - avg_confidence).abs();
197
198            // Weighted contribution to ECE
199            let weight = bin.len() as f64 / predictions.len() as f64;
200            ece_sum += weight * calibration_error;
201            mce = mce.max(calibration_error);
202
203            reliability_bins.push(ReliabilityBin {
204                range: (range_start, range_end),
205                avg_confidence,
206                accuracy,
207                count: bin.len(),
208                calibration_error,
209            });
210        }
211
212        // Compute Brier score
213        let brier_score = predictions
214            .iter()
215            .map(|(conf, correct)| {
216                let target = if *correct { 1.0 } else { 0.0 };
217                (conf - target).powi(2)
218            })
219            .sum::<f64>()
220            / predictions.len() as f64;
221
222        // Compute confidence statistics
223        let correct_confs: Vec<f64> = predictions
224            .iter()
225            .filter(|(_, c)| *c)
226            .map(|(conf, _)| *conf)
227            .collect();
228        let incorrect_confs: Vec<f64> = predictions
229            .iter()
230            .filter(|(_, c)| !*c)
231            .map(|(conf, _)| *conf)
232            .collect();
233
234        let avg_confidence_correct = if correct_confs.is_empty() {
235            0.0
236        } else {
237            correct_confs.iter().sum::<f64>() / correct_confs.len() as f64
238        };
239
240        let avg_confidence_incorrect = if incorrect_confs.is_empty() {
241            0.0
242        } else {
243            incorrect_confs.iter().sum::<f64>() / incorrect_confs.len() as f64
244        };
245
246        // Compute threshold metrics
247        let mut threshold_accuracy = HashMap::new();
248        for &threshold in &self.thresholds {
249            let above: Vec<_> = predictions
250                .iter()
251                .filter(|(c, _)| *c >= threshold)
252                .collect();
253
254            if above.is_empty() {
255                threshold_accuracy.insert(
256                    format!("{:.2}", threshold),
257                    ThresholdMetrics {
258                        accuracy: 0.0,
259                        coverage: 0.0,
260                        count: 0,
261                    },
262                );
263            } else {
264                let acc = above.iter().filter(|(_, correct)| *correct).count() as f64
265                    / above.len() as f64;
266                let cov = above.len() as f64 / predictions.len() as f64;
267                threshold_accuracy.insert(
268                    format!("{:.2}", threshold),
269                    ThresholdMetrics {
270                        accuracy: acc,
271                        coverage: cov,
272                        count: above.len(),
273                    },
274                );
275            }
276        }
277
278        CalibrationResults {
279            ece: ece_sum,
280            mce,
281            brier_score,
282            avg_confidence_correct,
283            avg_confidence_incorrect,
284            confidence_gap: avg_confidence_correct - avg_confidence_incorrect,
285            reliability_bins,
286            total_predictions: predictions.len(),
287            threshold_accuracy,
288        }
289    }
290}
291
292// =============================================================================
293// Helper Functions
294// =============================================================================
295
296/// Check if a model is well-calibrated.
297///
298/// Rules of thumb:
299/// - ECE < 0.05: Well calibrated
300/// - ECE 0.05-0.10: Moderately calibrated
301/// - ECE > 0.10: Poorly calibrated
302pub fn calibration_grade(ece: f64) -> &'static str {
303    if ece < 0.05 {
304        "Well calibrated"
305    } else if ece < 0.10 {
306        "Moderately calibrated"
307    } else if ece < 0.15 {
308        "Poorly calibrated"
309    } else {
310        "Very poorly calibrated"
311    }
312}
313
314/// Check if confidence gap is healthy.
315///
316/// A healthy model should have higher confidence for correct predictions.
317/// Gap > 0.2 suggests good confidence discrimination.
318pub fn confidence_gap_grade(gap: f64) -> &'static str {
319    if gap > 0.3 {
320        "Excellent discrimination"
321    } else if gap > 0.2 {
322        "Good discrimination"
323    } else if gap > 0.1 {
324        "Moderate discrimination"
325    } else if gap > 0.0 {
326        "Weak discrimination"
327    } else {
328        "No discrimination (or reversed)"
329    }
330}
331
332// =============================================================================
333// Tests
334// =============================================================================
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_perfect_calibration() {
342        // Perfect calibration: confidence equals accuracy
343        let predictions = vec![
344            (0.9, true),
345            (0.9, true),
346            (0.9, true),
347            (0.9, true),
348            (0.9, true),
349            (0.9, true),
350            (0.9, true),
351            (0.9, true),
352            (0.9, true),
353            (0.9, false), // 9/10 correct at confidence 0.9
354        ];
355
356        let results = CalibrationEvaluator::compute(&predictions);
357
358        // ECE should be very low for perfect calibration
359        assert!(
360            results.ece < 0.1,
361            "ECE should be low for well-calibrated predictions"
362        );
363    }
364
365    #[test]
366    fn test_overconfident_model() {
367        // Overconfident: high confidence but low accuracy
368        let predictions = vec![
369            (0.95, false),
370            (0.95, false),
371            (0.95, false),
372            (0.95, true),
373            (0.95, false), // 1/5 correct at confidence 0.95
374        ];
375
376        let results = CalibrationEvaluator::compute(&predictions);
377
378        // ECE should be high for overconfident model
379        assert!(
380            results.ece > 0.5,
381            "ECE should be high for overconfident predictions"
382        );
383    }
384
385    #[test]
386    fn test_confidence_gap() {
387        let predictions = vec![
388            (0.95, true),
389            (0.90, true),
390            (0.85, true),
391            (0.30, false),
392            (0.25, false),
393            (0.20, false),
394        ];
395
396        let results = CalibrationEvaluator::compute(&predictions);
397
398        assert!(
399            results.avg_confidence_correct > 0.8,
400            "Correct predictions should have high confidence"
401        );
402        assert!(
403            results.avg_confidence_incorrect < 0.4,
404            "Incorrect predictions should have low confidence"
405        );
406        assert!(
407            results.confidence_gap > 0.4,
408            "Should have large confidence gap"
409        );
410    }
411
412    #[test]
413    fn test_threshold_metrics() {
414        let predictions = vec![
415            (0.95, true),
416            (0.85, true),
417            (0.75, false),
418            (0.65, true),
419            (0.55, false),
420        ];
421
422        let results = CalibrationEvaluator::compute(&predictions);
423
424        // At 0.80 threshold, only 2 predictions (0.95, 0.85), both correct
425        let t80 = results.threshold_accuracy.get("0.80").unwrap();
426        assert!((t80.accuracy - 1.0).abs() < 0.01, "Should be 100% at 0.80");
427        assert!((t80.coverage - 0.4).abs() < 0.01, "Coverage should be 40%");
428    }
429
430    #[test]
431    fn test_empty_predictions() {
432        let results = CalibrationEvaluator::compute(&[]);
433        assert_eq!(results.total_predictions, 0);
434        assert_eq!(results.ece, 0.0);
435    }
436
437    #[test]
438    fn test_calibration_grades() {
439        assert_eq!(calibration_grade(0.03), "Well calibrated");
440        assert_eq!(calibration_grade(0.07), "Moderately calibrated");
441        assert_eq!(calibration_grade(0.12), "Poorly calibrated");
442        assert_eq!(calibration_grade(0.25), "Very poorly calibrated");
443    }
444
445    #[test]
446    fn test_entropy_single_source() {
447        // Single source = zero entropy
448        let scores = vec![0.9];
449        let entropy = confidence_entropy(&scores);
450        assert!(
451            (entropy - 0.0).abs() < 0.001,
452            "Single source should have 0 entropy"
453        );
454    }
455
456    #[test]
457    fn test_entropy_agreement() {
458        // Sources agree = low entropy
459        let scores = vec![0.9, 0.88, 0.92];
460        let entropy = confidence_entropy(&scores);
461        assert!(
462            entropy < 0.5,
463            "Agreeing sources should have low entropy: {}",
464            entropy
465        );
466    }
467
468    #[test]
469    fn test_entropy_conflict() {
470        // Sources disagree = high entropy
471        let scores = vec![0.95, 0.05, 0.5, 0.8, 0.2];
472        let entropy = confidence_entropy(&scores);
473        assert!(
474            entropy > 0.5,
475            "Conflicting sources should have high entropy: {}",
476            entropy
477        );
478    }
479
480    #[test]
481    fn test_entropy_filter() {
482        let candidates = [
483            ("Apple Inc.", vec![0.9, 0.88, 0.92]), // Agreement
484            ("Apple", vec![0.95, 0.05, 0.5]),      // Conflict
485            ("Microsoft", vec![0.85, 0.87]),       // Agreement
486        ];
487
488        let filter = EntropyFilter::new(0.6);
489        let filtered: Vec<_> = candidates
490            .iter()
491            .filter(|(_, scores)| filter.should_keep(scores))
492            .map(|(name, _)| *name)
493            .collect();
494
495        assert!(filtered.contains(&"Apple Inc."));
496        assert!(filtered.contains(&"Microsoft"));
497        assert!(
498            !filtered.contains(&"Apple"),
499            "Conflicting 'Apple' should be filtered"
500        );
501    }
502}
503
504// =============================================================================
505// Entropy-Based Conflict Detection (TruthfulRAG-style)
506// =============================================================================
507
508/// Compute disagreement metric for confidence scores from multiple sources.
509///
510/// # TruthfulRAG Research Background
511///
512/// When multiple sources provide confidence scores for the same entity/fact,
513/// high disagreement indicates conflict. TruthfulRAG (EMNLP 2024) uses this
514/// to identify facts that need verification:
515///
516/// - **Low disagreement**: Sources agree → likely reliable
517/// - **High disagreement**: Sources disagree → needs human review or rejection
518///
519/// # Formula
520///
521/// Uses normalized standard deviation of scores:
522/// ```text
523/// disagreement = std_dev(scores) / 0.5
524/// ```
525/// where 0.5 is the maximum possible std dev for scores in \[0,1\].
526/// This maps to \[0, 1\] where 0 = perfect agreement, 1 = maximum disagreement.
527///
528/// # Example
529///
530/// ```rust
531/// use anno_eval::eval::calibration::confidence_entropy;
532///
533/// // Sources agree (low disagreement)
534/// let scores = vec![0.9, 0.88, 0.92];
535/// assert!(confidence_entropy(&scores) < 0.3);
536///
537/// // Sources disagree (high disagreement)
538/// let scores = vec![0.95, 0.05, 0.5];
539/// assert!(confidence_entropy(&scores) > 0.5);
540/// ```
541#[must_use]
542pub fn confidence_entropy(scores: &[f64]) -> f64 {
543    if scores.len() <= 1 {
544        return 0.0; // Single source = no disagreement
545    }
546
547    // Compute standard deviation
548    let mean = scores.iter().sum::<f64>() / scores.len() as f64;
549    // Use sample variance (Bessel's correction: n-1) for unbiased estimate
550    let n = scores.len() as f64;
551    let variance = if n > 1.0 {
552        scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / (n - 1.0)
553    } else {
554        0.0
555    };
556    let std_dev = variance.sqrt();
557
558    // Normalize by maximum possible std dev for [0,1] scores
559    // Max std dev is 0.5 (when half are 0 and half are 1)
560    (std_dev / 0.5).min(1.0)
561}
562
563/// Compute variance of confidence scores (simpler alternative to entropy).
564///
565/// High variance indicates disagreement between sources.
566#[must_use]
567pub fn confidence_variance(scores: &[f64]) -> f64 {
568    if scores.len() <= 1 {
569        return 0.0;
570    }
571
572    let mean = scores.iter().sum::<f64>() / scores.len() as f64;
573    scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / scores.len() as f64
574}
575
576/// Filter for rejecting high-entropy (conflicting) entity extractions.
577///
578/// # Usage in RAG Systems
579///
580/// When multiple retrieval passes or models extract the same entity with
581/// different confidences, use this filter to:
582///
583/// 1. Accept entities where sources agree (low entropy)
584/// 2. Flag/reject entities where sources disagree (high entropy)
585///
586/// # Example
587///
588/// ```rust
589/// use anno_eval::eval::calibration::EntropyFilter;
590///
591/// let filter = EntropyFilter::new(0.6);  // Reject if entropy > 0.6
592///
593/// // Multiple models extracted "Apple" with these confidences:
594/// let apple_scores = vec![0.95, 0.05, 0.5];  // Disagreement
595/// assert!(!filter.should_keep(&apple_scores), "Should reject conflicting extractions");
596///
597/// let microsoft_scores = vec![0.9, 0.88, 0.92];  // Agreement
598/// assert!(filter.should_keep(&microsoft_scores), "Should keep agreeing extractions");
599/// ```
600#[derive(Debug, Clone)]
601pub struct EntropyFilter {
602    /// Maximum allowed entropy (0.0-1.0)
603    pub max_entropy: f64,
604    /// Minimum number of sources required
605    pub min_sources: usize,
606    /// Use variance instead of entropy (faster, simpler)
607    pub use_variance: bool,
608    /// Maximum variance threshold (if use_variance=true)
609    pub max_variance: f64,
610}
611
612impl Default for EntropyFilter {
613    fn default() -> Self {
614        Self {
615            max_entropy: 0.7, // Moderate threshold
616            min_sources: 2,   // Need at least 2 sources
617            use_variance: false,
618            max_variance: 0.1, // ~0.3 std dev
619        }
620    }
621}
622
623impl EntropyFilter {
624    /// Create with specific entropy threshold.
625    #[must_use]
626    pub fn new(max_entropy: f64) -> Self {
627        Self {
628            max_entropy,
629            ..Default::default()
630        }
631    }
632
633    /// Create a strict filter (low threshold = high agreement required).
634    #[must_use]
635    pub fn strict() -> Self {
636        Self {
637            max_entropy: 0.4,
638            min_sources: 3,
639            ..Default::default()
640        }
641    }
642
643    /// Create a permissive filter (high threshold = accepts more disagreement).
644    #[must_use]
645    pub fn permissive() -> Self {
646        Self {
647            max_entropy: 0.85,
648            min_sources: 2,
649            ..Default::default()
650        }
651    }
652
653    /// Check if scores indicate sufficient agreement to keep the extraction.
654    #[must_use]
655    pub fn should_keep(&self, scores: &[f64]) -> bool {
656        if scores.len() < self.min_sources {
657            return true; // Not enough sources to judge
658        }
659
660        if self.use_variance {
661            confidence_variance(scores) <= self.max_variance
662        } else {
663            confidence_entropy(scores) <= self.max_entropy
664        }
665    }
666
667    /// Compute the entropy/variance for logging/debugging.
668    #[must_use]
669    pub fn compute_score(&self, scores: &[f64]) -> f64 {
670        if self.use_variance {
671            confidence_variance(scores)
672        } else {
673            confidence_entropy(scores)
674        }
675    }
676
677    /// Grade the level of agreement.
678    #[must_use]
679    pub fn agreement_grade(&self, scores: &[f64]) -> &'static str {
680        let score = self.compute_score(scores);
681        if self.use_variance {
682            if score < 0.02 {
683                "Strong agreement"
684            } else if score < 0.05 {
685                "Good agreement"
686            } else if score < 0.1 {
687                "Moderate agreement"
688            } else {
689                "Disagreement"
690            }
691        } else if score < 0.3 {
692            "Strong agreement"
693        } else if score < 0.5 {
694            "Good agreement"
695        } else if score < 0.7 {
696            "Moderate agreement"
697        } else {
698            "Disagreement"
699        }
700    }
701}