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(µsoft_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}