1use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone)]
35pub struct PredictionWithConfidence {
36 pub text: String,
38 pub entity_type: String,
40 pub confidence: f64,
42 pub is_correct: bool,
44}
45
46impl PredictionWithConfidence {
47 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#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct ThresholdPoint {
66 pub threshold: f64,
68 pub precision: f64,
70 pub recall: f64,
72 pub f1: f64,
74 pub num_predictions: usize,
76 pub num_correct: usize,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ThresholdCurve {
83 pub points: Vec<ThresholdPoint>,
85 pub optimal_threshold: f64,
87 pub optimal_f1: f64,
89 pub optimal_precision: f64,
91 pub optimal_recall: f64,
93 pub auc_pr: f64,
95 pub total_predictions: usize,
97 pub total_correct: usize,
99 pub high_precision_threshold: Option<f64>,
101 pub high_recall_threshold: Option<f64>,
103}
104
105#[derive(Debug, Clone)]
111pub struct ThresholdAnalyzer {
112 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 pub fn new(num_points: usize) -> Self {
125 Self {
126 num_points: num_points.max(5),
127 }
128 }
129
130 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 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 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 let auc_pr = self.compute_auc_pr(&points);
173
174 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 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 } 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 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
269pub 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
311pub fn interpret_curve(curve: &ThresholdCurve) -> Vec<String> {
313 let mut insights = Vec::new();
314
315 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 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 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 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#[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 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 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 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}