Skip to main content

anno_eval/eval/
threshold_analysis.rs

1//! Confidence threshold analysis for NER systems.
2//!
3//! Analyzes how precision, recall, and F1 change at different confidence thresholds.
4//! Useful for:
5//! - Finding optimal operating points
6//! - Understanding precision-recall tradeoffs
7//! - Setting production thresholds
8//!
9//! # Example
10//!
11//! ```rust
12//! use anno_eval::eval::threshold_analysis::{ThresholdAnalyzer, PredictionWithConfidence};
13//!
14//! let predictions = vec![
15//!     PredictionWithConfidence::new("John", "PER", 0.95, true),
16//!     PredictionWithConfidence::new("maybe", "PER", 0.45, false),  // Wrong
17//!     PredictionWithConfidence::new("Google", "ORG", 0.88, true),
18//! ];
19//!
20//! let analyzer = ThresholdAnalyzer::default();
21//! let curve = analyzer.analyze(&predictions);
22//!
23//! println!("Optimal threshold: {:.2} (F1: {:.1}%)",
24//!     curve.optimal_threshold, curve.optimal_f1 * 100.0);
25//! ```
26
27use serde::{Deserialize, Serialize};
28
29// =============================================================================
30// Data Structures
31// =============================================================================
32
33/// A prediction with confidence and correctness label.
34#[derive(Debug, Clone)]
35pub struct PredictionWithConfidence {
36    /// Entity text
37    pub text: String,
38    /// Entity type
39    pub entity_type: String,
40    /// Model confidence (0.0 to 1.0)
41    pub confidence: f64,
42    /// Whether this prediction is correct
43    pub is_correct: bool,
44}
45
46impl PredictionWithConfidence {
47    /// Create a new prediction.
48    pub fn new(
49        text: impl Into<String>,
50        entity_type: impl Into<String>,
51        confidence: f64,
52        is_correct: bool,
53    ) -> Self {
54        Self {
55            text: text.into(),
56            entity_type: entity_type.into(),
57            confidence,
58            is_correct,
59        }
60    }
61}
62
63/// Metrics at a specific threshold.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ThresholdPoint {
66    /// Confidence threshold
67    pub threshold: f64,
68    /// Precision at this threshold
69    pub precision: f64,
70    /// Recall at this threshold (relative to total correct predictions at threshold 0)
71    pub recall: f64,
72    /// F1 at this threshold
73    pub f1: f64,
74    /// Number of predictions retained at this threshold
75    pub num_predictions: usize,
76    /// Number of correct predictions at this threshold
77    pub num_correct: usize,
78}
79
80/// Full threshold analysis results.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ThresholdCurve {
83    /// Points along the threshold curve
84    pub points: Vec<ThresholdPoint>,
85    /// Optimal threshold (maximizes F1)
86    pub optimal_threshold: f64,
87    /// F1 at optimal threshold
88    pub optimal_f1: f64,
89    /// Precision at optimal threshold
90    pub optimal_precision: f64,
91    /// Recall at optimal threshold
92    pub optimal_recall: f64,
93    /// Area under precision-recall curve (approximation)
94    pub auc_pr: f64,
95    /// Total predictions analyzed
96    pub total_predictions: usize,
97    /// Total correct predictions (at threshold 0)
98    pub total_correct: usize,
99    /// High-precision threshold (precision >= 0.95)
100    pub high_precision_threshold: Option<f64>,
101    /// High-recall threshold (recall >= 0.95)
102    pub high_recall_threshold: Option<f64>,
103}
104
105// =============================================================================
106// Threshold Analyzer
107// =============================================================================
108
109/// Analyzer for confidence threshold effects.
110#[derive(Debug, Clone)]
111pub struct ThresholdAnalyzer {
112    /// Number of threshold points to compute
113    pub num_points: usize,
114}
115
116impl Default for ThresholdAnalyzer {
117    fn default() -> Self {
118        Self { num_points: 20 }
119    }
120}
121
122impl ThresholdAnalyzer {
123    /// Create analyzer with custom number of points.
124    pub fn new(num_points: usize) -> Self {
125        Self {
126            num_points: num_points.max(5),
127        }
128    }
129
130    /// Analyze threshold effects on predictions.
131    pub fn analyze(&self, predictions: &[PredictionWithConfidence]) -> ThresholdCurve {
132        if predictions.is_empty() {
133            return ThresholdCurve {
134                points: Vec::new(),
135                optimal_threshold: 0.5,
136                optimal_f1: 0.0,
137                optimal_precision: 0.0,
138                optimal_recall: 0.0,
139                auc_pr: 0.0,
140                total_predictions: 0,
141                total_correct: 0,
142                high_precision_threshold: None,
143                high_recall_threshold: None,
144            };
145        }
146
147        let total_correct = predictions.iter().filter(|p| p.is_correct).count();
148
149        // Compute metrics at each threshold
150        let mut points = Vec::new();
151        let step = 1.0 / self.num_points as f64;
152
153        for i in 0..=self.num_points {
154            let threshold = i as f64 * step;
155            let point = self.compute_point(predictions, threshold, total_correct);
156            points.push(point);
157        }
158
159        // Find optimal threshold
160        let (_optimal_idx, optimal_point) = points
161            .iter()
162            .enumerate()
163            .max_by(|a, b| {
164                a.1.f1
165                    .partial_cmp(&b.1.f1)
166                    .unwrap_or(std::cmp::Ordering::Equal)
167            })
168            .map(|(i, p)| (i, p.clone()))
169            .unwrap_or((0, points[0].clone()));
170
171        // Compute AUC-PR (trapezoidal approximation)
172        let auc_pr = self.compute_auc_pr(&points);
173
174        // Find high-precision threshold
175        let high_precision_threshold = points
176            .iter()
177            .filter(|p| p.precision >= 0.95 && p.num_predictions > 0)
178            .map(|p| p.threshold)
179            .next();
180
181        // Find high-recall threshold (lowest threshold with recall >= 0.95)
182        let high_recall_threshold = points
183            .iter()
184            .rev()
185            .filter(|p| p.recall >= 0.95)
186            .map(|p| p.threshold)
187            .next();
188
189        ThresholdCurve {
190            points,
191            optimal_threshold: optimal_point.threshold,
192            optimal_f1: optimal_point.f1,
193            optimal_precision: optimal_point.precision,
194            optimal_recall: optimal_point.recall,
195            auc_pr,
196            total_predictions: predictions.len(),
197            total_correct,
198            high_precision_threshold,
199            high_recall_threshold,
200        }
201    }
202
203    fn compute_point(
204        &self,
205        predictions: &[PredictionWithConfidence],
206        threshold: f64,
207        total_correct: usize,
208    ) -> ThresholdPoint {
209        let retained: Vec<_> = predictions
210            .iter()
211            .filter(|p| p.confidence >= threshold)
212            .collect();
213
214        let num_predictions = retained.len();
215        let num_correct = retained.iter().filter(|p| p.is_correct).count();
216
217        let precision = if num_predictions == 0 {
218            1.0 // No predictions = no false positives
219        } else {
220            num_correct as f64 / num_predictions as f64
221        };
222
223        let recall = if total_correct == 0 {
224            1.0
225        } else {
226            num_correct as f64 / total_correct as f64
227        };
228
229        let f1 = if precision + recall == 0.0 {
230            0.0
231        } else {
232            2.0 * precision * recall / (precision + recall)
233        };
234
235        ThresholdPoint {
236            threshold,
237            precision,
238            recall,
239            f1,
240            num_predictions,
241            num_correct,
242        }
243    }
244
245    fn compute_auc_pr(&self, points: &[ThresholdPoint]) -> f64 {
246        if points.len() < 2 {
247            return 0.0;
248        }
249
250        // Sort by recall (descending) for proper AUC computation
251        let mut sorted: Vec<_> = points.iter().collect();
252        sorted.sort_by(|a, b| {
253            b.recall
254                .partial_cmp(&a.recall)
255                .unwrap_or(std::cmp::Ordering::Equal)
256        });
257
258        let mut auc = 0.0;
259        for i in 1..sorted.len() {
260            let recall_diff = sorted[i - 1].recall - sorted[i].recall;
261            let avg_precision = (sorted[i - 1].precision + sorted[i].precision) / 2.0;
262            auc += recall_diff * avg_precision;
263        }
264
265        auc
266    }
267}
268
269// =============================================================================
270// Display Helpers
271// =============================================================================
272
273/// Format threshold curve as ASCII table.
274pub fn format_threshold_table(curve: &ThresholdCurve) -> String {
275    let mut output = String::new();
276
277    output.push_str("Threshold   Precision   Recall      F1    Predictions\n");
278    output.push_str("--------------------------------------------------------\n");
279
280    for point in &curve.points {
281        output.push_str(&format!(
282            "   {:.2}       {:5.1}%    {:5.1}%    {:5.1}%      {:4}\n",
283            point.threshold,
284            point.precision * 100.0,
285            point.recall * 100.0,
286            point.f1 * 100.0,
287            point.num_predictions,
288        ));
289    }
290
291    output.push_str("--------------------------------------------------------\n");
292    output.push_str(&format!(
293        "Optimal: threshold={:.2}, F1={:.1}%, P={:.1}%, R={:.1}%\n",
294        curve.optimal_threshold,
295        curve.optimal_f1 * 100.0,
296        curve.optimal_precision * 100.0,
297        curve.optimal_recall * 100.0,
298    ));
299    output.push_str(&format!("AUC-PR: {:.3}\n", curve.auc_pr));
300
301    if let Some(t) = curve.high_precision_threshold {
302        output.push_str(&format!("High-precision (>=95%) threshold: {:.2}\n", t));
303    }
304    if let Some(t) = curve.high_recall_threshold {
305        output.push_str(&format!("High-recall (>=95%) threshold: {:.2}\n", t));
306    }
307
308    output
309}
310
311/// Interpret threshold curve quality.
312pub fn interpret_curve(curve: &ThresholdCurve) -> Vec<String> {
313    let mut insights = Vec::new();
314
315    // AUC-PR interpretation
316    if curve.auc_pr >= 0.9 {
317        insights.push("Excellent calibration (AUC-PR >= 0.9)".into());
318    } else if curve.auc_pr >= 0.7 {
319        insights.push("Good calibration (AUC-PR >= 0.7)".into());
320    } else if curve.auc_pr >= 0.5 {
321        insights.push("Moderate calibration (AUC-PR >= 0.5)".into());
322    } else {
323        insights.push("Poor calibration (AUC-PR < 0.5) - confidence scores unreliable".into());
324    }
325
326    // Optimal threshold interpretation
327    if curve.optimal_threshold < 0.3 {
328        insights.push("Low optimal threshold suggests model is underconfident".into());
329    } else if curve.optimal_threshold > 0.7 {
330        insights.push("High optimal threshold suggests model tends to overpredict".into());
331    }
332
333    // Precision-recall tradeoff
334    if curve.optimal_precision > 0.9 && curve.optimal_recall < 0.7 {
335        insights.push("High precision but low recall - consider lowering threshold".into());
336    } else if curve.optimal_recall > 0.9 && curve.optimal_precision < 0.7 {
337        insights.push("High recall but low precision - consider raising threshold".into());
338    }
339
340    // High-precision availability
341    if curve.high_precision_threshold.is_some() {
342        insights.push("Can achieve 95%+ precision with threshold tuning".into());
343    } else {
344        insights.push("Cannot achieve 95% precision at any threshold".into());
345    }
346
347    insights
348}
349
350// =============================================================================
351// Tests
352// =============================================================================
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_perfect_predictions() {
360        let predictions = vec![
361            PredictionWithConfidence::new("A", "T", 0.9, true),
362            PredictionWithConfidence::new("B", "T", 0.8, true),
363            PredictionWithConfidence::new("C", "T", 0.7, true),
364        ];
365
366        let analyzer = ThresholdAnalyzer::new(10);
367        let curve = analyzer.analyze(&predictions);
368
369        // All correct = perfect precision at all thresholds
370        for point in &curve.points {
371            if point.num_predictions > 0 {
372                assert!((point.precision - 1.0).abs() < 0.01);
373            }
374        }
375    }
376
377    #[test]
378    fn test_confidence_ordering() {
379        let predictions = vec![
380            PredictionWithConfidence::new("High", "T", 0.95, true),
381            PredictionWithConfidence::new("Med", "T", 0.50, false),
382            PredictionWithConfidence::new("Low", "T", 0.20, false),
383        ];
384
385        let analyzer = ThresholdAnalyzer::new(10);
386        let curve = analyzer.analyze(&predictions);
387
388        // High threshold should have better precision
389        let high_point = curve.points.iter().find(|p| p.threshold >= 0.9).unwrap();
390        let low_point = curve.points.iter().find(|p| p.threshold <= 0.1).unwrap();
391
392        assert!(high_point.precision >= low_point.precision);
393    }
394
395    #[test]
396    fn test_empty_predictions() {
397        let predictions: Vec<PredictionWithConfidence> = vec![];
398        let analyzer = ThresholdAnalyzer::default();
399        let curve = analyzer.analyze(&predictions);
400
401        assert_eq!(curve.total_predictions, 0);
402        assert!(curve.points.is_empty());
403    }
404
405    #[test]
406    fn test_optimal_threshold_found() {
407        let predictions = vec![
408            PredictionWithConfidence::new("A", "T", 0.9, true),
409            PredictionWithConfidence::new("B", "T", 0.8, true),
410            PredictionWithConfidence::new("C", "T", 0.3, false),
411            PredictionWithConfidence::new("D", "T", 0.2, false),
412        ];
413
414        let analyzer = ThresholdAnalyzer::new(10);
415        let curve = analyzer.analyze(&predictions);
416
417        // Optimal should be around 0.5 to filter out the low-confidence wrong predictions
418        assert!(curve.optimal_threshold >= 0.3);
419        assert!(curve.optimal_threshold <= 0.9);
420    }
421
422    #[test]
423    fn test_auc_pr_bounds() {
424        let predictions = vec![
425            PredictionWithConfidence::new("A", "T", 0.9, true),
426            PredictionWithConfidence::new("B", "T", 0.5, false),
427        ];
428
429        let analyzer = ThresholdAnalyzer::default();
430        let curve = analyzer.analyze(&predictions);
431
432        assert!(curve.auc_pr >= 0.0);
433        assert!(curve.auc_pr <= 1.0);
434    }
435}