Skip to main content

anno_eval/eval/
few_shot.rs

1//! Few-shot learning evaluation for NER.
2//!
3//! Measures how well models can recognize entities with minimal examples.
4//! Critical for practitioners who need to quickly adapt to new domains.
5//!
6//! # Example
7//!
8//! ```rust
9//! use anno_eval::eval::few_shot::{FewShotEvaluator, FewShotTask, SupportExample};
10//!
11//! // Create support set (few examples per entity type)
12//! let task = FewShotTask {
13//!     entity_type: "DISEASE".into(),
14//!     support: vec![
15//!         SupportExample::new("Patient has diabetes", "diabetes", 12, 20),
16//!         SupportExample::new("Diagnosed with cancer", "cancer", 15, 21),
17//!     ],
18//!     query_texts: vec![
19//!         "The patient presented with pneumonia".into(),
20//!         "History of hypertension noted".into(),
21//!     ],
22//! };
23//!
24//! let evaluator = FewShotEvaluator::default();
25//! // In practice, you'd run a model and evaluate its predictions
26//! ```
27
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30
31/// Entity annotation: (entity_text, start_offset, end_offset)
32pub type EntityAnnotation = (String, usize, usize);
33
34/// Annotated example: (full_text, list of entity annotations)
35pub type AnnotatedText = (String, Vec<EntityAnnotation>);
36
37// =============================================================================
38// Data Structures
39// =============================================================================
40
41/// A single example in the support set.
42#[derive(Debug, Clone)]
43pub struct SupportExample {
44    /// Full text containing the entity
45    pub text: String,
46    /// Entity text
47    pub entity_text: String,
48    /// Start offset
49    pub start: usize,
50    /// End offset
51    pub end: usize,
52}
53
54impl SupportExample {
55    /// Create a new support example.
56    pub fn new(
57        text: impl Into<String>,
58        entity_text: impl Into<String>,
59        start: usize,
60        end: usize,
61    ) -> Self {
62        Self {
63            text: text.into(),
64            entity_text: entity_text.into(),
65            start,
66            end,
67        }
68    }
69}
70
71/// A few-shot learning task for a single entity type.
72#[derive(Debug, Clone)]
73pub struct FewShotTask {
74    /// The entity type to recognize
75    pub entity_type: String,
76    /// Support set: K examples showing the entity type
77    pub support: Vec<SupportExample>,
78    /// Query texts to evaluate
79    pub query_texts: Vec<String>,
80}
81
82/// Gold annotation for a query in few-shot task.
83#[derive(Debug, Clone)]
84pub struct FewShotGold {
85    /// Text being annotated
86    pub text: String,
87    /// Entity spans in the text
88    pub entities: Vec<(String, usize, usize)>, // (entity_text, start, end)
89}
90
91/// Model prediction for few-shot evaluation.
92#[derive(Debug, Clone)]
93pub struct FewShotPrediction {
94    /// Text being annotated
95    pub text: String,
96    /// Predicted entity spans
97    pub predicted: Vec<(String, usize, usize, f64)>, // (entity_text, start, end, confidence)
98}
99
100/// Results for a single few-shot task.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct FewShotTaskResults {
103    /// Entity type being evaluated
104    pub entity_type: String,
105    /// Number of examples in support set (K)
106    pub k: usize,
107    /// Precision on query set
108    pub precision: f64,
109    /// Recall on query set
110    pub recall: f64,
111    /// F1 score
112    pub f1: f64,
113    /// Number of gold entities in query set
114    pub num_gold: usize,
115    /// Number of predicted entities
116    pub num_predicted: usize,
117    /// Number of correct predictions
118    pub num_correct: usize,
119}
120
121/// Overall few-shot evaluation results.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct FewShotResults {
124    /// Results per entity type
125    pub per_type: HashMap<String, FewShotTaskResults>,
126    /// Macro-averaged F1 across types
127    pub macro_f1: f64,
128    /// Micro-averaged F1 (total correct / total predicted)
129    pub micro_f1: f64,
130    /// K values tested
131    pub k_values: Vec<usize>,
132    /// Performance by K (average F1 for each K)
133    pub performance_by_k: Vec<(usize, f64)>,
134    /// Types that failed (F1 < 0.1)
135    pub failed_types: Vec<String>,
136    /// Insights and recommendations
137    pub insights: Vec<String>,
138}
139
140// =============================================================================
141// Few-Shot Evaluator
142// =============================================================================
143
144/// Evaluator for few-shot NER learning.
145#[derive(Debug, Clone)]
146pub struct FewShotEvaluator {
147    /// Minimum K values to test
148    pub k_values: Vec<usize>,
149    /// Minimum F1 to consider "successful"
150    pub success_threshold: f64,
151}
152
153impl Default for FewShotEvaluator {
154    fn default() -> Self {
155        Self {
156            k_values: vec![1, 2, 5, 10],
157            success_threshold: 0.5,
158        }
159    }
160}
161
162impl FewShotEvaluator {
163    /// Create evaluator with custom K values.
164    pub fn new(k_values: Vec<usize>) -> Self {
165        Self {
166            k_values,
167            success_threshold: 0.5,
168        }
169    }
170
171    /// Evaluate few-shot predictions against gold annotations.
172    pub fn evaluate(
173        &self,
174        entity_type: &str,
175        k: usize,
176        predictions: &[FewShotPrediction],
177        gold: &[FewShotGold],
178    ) -> FewShotTaskResults {
179        assert_eq!(
180            predictions.len(),
181            gold.len(),
182            "Predictions and gold must have same length"
183        );
184
185        let mut total_correct = 0;
186        let mut total_predicted = 0;
187        let mut total_gold = 0;
188
189        for (pred, g) in predictions.iter().zip(gold.iter()) {
190            total_gold += g.entities.len();
191            total_predicted += pred.predicted.len();
192
193            // Count matches (exact span match)
194            for (g_text, g_start, g_end) in &g.entities {
195                for (p_text, p_start, p_end, _conf) in &pred.predicted {
196                    if g_start == p_start && g_end == p_end {
197                        total_correct += 1;
198                        break;
199                    }
200                    // Also allow text match if spans differ slightly
201                    if g_text.to_lowercase() == p_text.to_lowercase() {
202                        total_correct += 1;
203                        break;
204                    }
205                }
206            }
207        }
208
209        // Standard behavior: precision = 0.0 when no predictions (matches seqeval)
210        let precision = if total_predicted == 0 {
211            0.0
212        } else {
213            total_correct as f64 / total_predicted as f64
214        };
215
216        // Standard behavior: recall = 0.0 when no gold (matches seqeval)
217        let recall = if total_gold == 0 {
218            0.0
219        } else {
220            total_correct as f64 / total_gold as f64
221        };
222
223        let f1 = if precision + recall == 0.0 {
224            0.0
225        } else {
226            2.0 * precision * recall / (precision + recall)
227        };
228
229        FewShotTaskResults {
230            entity_type: entity_type.to_string(),
231            k,
232            precision,
233            recall,
234            f1,
235            num_gold: total_gold,
236            num_predicted: total_predicted,
237            num_correct: total_correct,
238        }
239    }
240
241    /// Aggregate results across multiple entity types.
242    pub fn aggregate(&self, results: Vec<FewShotTaskResults>) -> FewShotResults {
243        let mut per_type: HashMap<String, FewShotTaskResults> = HashMap::new();
244        let mut by_k: HashMap<usize, Vec<f64>> = HashMap::new();
245
246        for r in &results {
247            per_type.insert(r.entity_type.clone(), r.clone());
248            by_k.entry(r.k).or_default().push(r.f1);
249        }
250
251        // Compute macro F1
252        let macro_f1 = if results.is_empty() {
253            0.0
254        } else {
255            results.iter().map(|r| r.f1).sum::<f64>() / results.len() as f64
256        };
257
258        // Compute micro F1
259        let total_correct: usize = results.iter().map(|r| r.num_correct).sum();
260        let total_predicted: usize = results.iter().map(|r| r.num_predicted).sum();
261        let total_gold: usize = results.iter().map(|r| r.num_gold).sum();
262
263        // Standard behavior: precision = 0.0 when no predictions (matches seqeval)
264        let micro_precision = if total_predicted == 0 {
265            0.0
266        } else {
267            total_correct as f64 / total_predicted as f64
268        };
269        // Standard behavior: recall = 0.0 when no gold (matches seqeval)
270        let micro_recall = if total_gold == 0 {
271            0.0
272        } else {
273            total_correct as f64 / total_gold as f64
274        };
275        let micro_f1 = if micro_precision + micro_recall == 0.0 {
276            0.0
277        } else {
278            2.0 * micro_precision * micro_recall / (micro_precision + micro_recall)
279        };
280
281        // Performance by K
282        let mut performance_by_k: Vec<_> = by_k
283            .iter()
284            .map(|(k, scores)| (*k, scores.iter().sum::<f64>() / scores.len() as f64))
285            .collect();
286        performance_by_k.sort_by_key(|(k, _)| *k);
287
288        // Find failed types
289        let failed_types: Vec<_> = results
290            .iter()
291            .filter(|r| r.f1 < self.success_threshold)
292            .map(|r| r.entity_type.clone())
293            .collect();
294
295        // Generate insights
296        let mut insights = Vec::new();
297
298        if !performance_by_k.is_empty() {
299            let min_k_f1 = performance_by_k.first().map(|(_, f1)| *f1).unwrap_or(0.0);
300            let max_k_f1 = performance_by_k.last().map(|(_, f1)| *f1).unwrap_or(0.0);
301            let improvement = max_k_f1 - min_k_f1;
302
303            if improvement > 0.2 {
304                insights.push(format!(
305                    "Strong learning: +{:.0}% F1 from K=1 to K={}",
306                    improvement * 100.0,
307                    performance_by_k.last().map(|(k, _)| *k).unwrap_or(10)
308                ));
309            } else if improvement < 0.05 {
310                insights.push(
311                    "Minimal improvement with more examples - may need different approach".into(),
312                );
313            }
314        }
315
316        if !failed_types.is_empty() {
317            insights.push(format!(
318                "Struggling with {} entity types: {:?}",
319                failed_types.len(),
320                &failed_types[..failed_types.len().min(3)]
321            ));
322        }
323
324        if macro_f1 < 0.3 {
325            insights.push(
326                "Low overall few-shot performance - consider pre-training on related data".into(),
327            );
328        }
329
330        FewShotResults {
331            per_type,
332            macro_f1,
333            micro_f1,
334            k_values: self.k_values.clone(),
335            performance_by_k,
336            failed_types,
337            insights,
338        }
339    }
340}
341
342// =============================================================================
343// Simulation Utilities
344// =============================================================================
345
346/// Create a simulated few-shot task from existing annotated data.
347///
348/// Takes a dataset and creates K support examples + M query examples.
349pub fn simulate_few_shot_task(
350    entity_type: &str,
351    all_examples: &[AnnotatedText],
352    k: usize,
353    max_queries: usize,
354) -> Option<(FewShotTask, Vec<FewShotGold>)> {
355    // Filter examples containing this entity type
356    let mut matching: Vec<_> = all_examples
357        .iter()
358        .filter(|(_, entities)| !entities.is_empty())
359        .cloned()
360        .collect();
361
362    if matching.len() < k + 1 {
363        return None; // Not enough examples
364    }
365
366    // Split into support (first K) and query (rest)
367    let support: Vec<_> = matching
368        .drain(..k)
369        .filter_map(|(text, entities)| {
370            let (entity_text, start, end) = entities.first()?;
371            Some(SupportExample::new(text, entity_text.clone(), *start, *end))
372        })
373        .collect();
374
375    let query_count = matching.len().min(max_queries);
376    let queries: Vec<_> = matching[..query_count].to_vec();
377
378    let task = FewShotTask {
379        entity_type: entity_type.to_string(),
380        support,
381        query_texts: queries.iter().map(|(t, _)| t.clone()).collect(),
382    };
383
384    let gold: Vec<_> = queries
385        .iter()
386        .map(|(text, entities)| FewShotGold {
387            text: text.clone(),
388            entities: entities.clone(),
389        })
390        .collect();
391
392    Some((task, gold))
393}
394
395// =============================================================================
396// Tests
397// =============================================================================
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn test_perfect_predictions() {
405        let evaluator = FewShotEvaluator::default();
406
407        let predictions = vec![FewShotPrediction {
408            text: "Has diabetes".into(),
409            predicted: vec![("diabetes".into(), 4, 12, 0.95)],
410        }];
411
412        let gold = vec![FewShotGold {
413            text: "Has diabetes".into(),
414            entities: vec![("diabetes".into(), 4, 12)],
415        }];
416
417        let results = evaluator.evaluate("DISEASE", 2, &predictions, &gold);
418        assert!((results.f1 - 1.0).abs() < 0.01);
419        assert_eq!(results.num_correct, 1);
420    }
421
422    #[test]
423    fn test_no_predictions() {
424        let evaluator = FewShotEvaluator::default();
425
426        let predictions = vec![FewShotPrediction {
427            text: "Has diabetes".into(),
428            predicted: vec![],
429        }];
430
431        let gold = vec![FewShotGold {
432            text: "Has diabetes".into(),
433            entities: vec![("diabetes".into(), 4, 12)],
434        }];
435
436        let results = evaluator.evaluate("DISEASE", 2, &predictions, &gold);
437        assert!((results.recall).abs() < 0.01);
438        assert_eq!(results.num_correct, 0);
439    }
440
441    #[test]
442    fn test_aggregate_results() {
443        let evaluator = FewShotEvaluator::default();
444
445        let results = vec![
446            FewShotTaskResults {
447                entity_type: "PER".into(),
448                k: 2,
449                precision: 0.8,
450                recall: 0.7,
451                f1: 0.75,
452                num_gold: 10,
453                num_predicted: 8,
454                num_correct: 7,
455            },
456            FewShotTaskResults {
457                entity_type: "ORG".into(),
458                k: 2,
459                precision: 0.6,
460                recall: 0.5,
461                f1: 0.55,
462                num_gold: 10,
463                num_predicted: 9,
464                num_correct: 5,
465            },
466        ];
467
468        let aggregated = evaluator.aggregate(results);
469        assert!((aggregated.macro_f1 - 0.65).abs() < 0.01);
470        assert_eq!(aggregated.per_type.len(), 2);
471    }
472
473    #[test]
474    fn test_failed_types_detection() {
475        let evaluator = FewShotEvaluator::default();
476
477        let results = vec![
478            FewShotTaskResults {
479                entity_type: "EASY".into(),
480                k: 5,
481                precision: 0.9,
482                recall: 0.85,
483                f1: 0.87,
484                num_gold: 10,
485                num_predicted: 10,
486                num_correct: 9,
487            },
488            FewShotTaskResults {
489                entity_type: "HARD".into(),
490                k: 5,
491                precision: 0.2,
492                recall: 0.1,
493                f1: 0.13,
494                num_gold: 10,
495                num_predicted: 5,
496                num_correct: 1,
497            },
498        ];
499
500        let aggregated = evaluator.aggregate(results);
501        assert!(aggregated.failed_types.contains(&"HARD".to_string()));
502        assert!(!aggregated.failed_types.contains(&"EASY".to_string()));
503    }
504}