Skip to main content

anno_eval/eval/
long_tail.rs

1//! Long-tail entity evaluation for NER.
2//!
3//! Measures performance on rare entity types and entities that appear infrequently.
4//! Critical because aggregate F1 masks poor performance on minority classes.
5//!
6//! # Example
7//!
8//! ```rust
9//! use anno_eval::eval::long_tail::{LongTailAnalyzer, EntityFrequency};
10//!
11//! let frequencies = vec![
12//!     EntityFrequency::new("PER", 1000),   // Head: very common
13//!     EntityFrequency::new("ORG", 800),    // Head
14//!     EntityFrequency::new("LOC", 600),    // Mid
15//!     EntityFrequency::new("DATE", 200),   // Mid
16//!     EntityFrequency::new("DISEASE", 50), // Tail: rare
17//!     EntityFrequency::new("GENE", 20),    // Tail
18//! ];
19//!
20//! let analyzer = LongTailAnalyzer::default();
21//! let splits = analyzer.split_by_frequency(&frequencies);
22//!
23//! println!("Head types (top 20%): {:?}", splits.head);
24//! println!("Tail types (bottom 20%): {:?}", splits.tail);
25//! ```
26
27use serde::{Deserialize, Serialize};
28
29// =============================================================================
30// Data Structures
31// =============================================================================
32
33/// Frequency information for an entity type.
34#[derive(Debug, Clone)]
35pub struct EntityFrequency {
36    /// Entity type name
37    pub entity_type: String,
38    /// Number of occurrences in dataset
39    pub count: usize,
40}
41
42impl EntityFrequency {
43    /// Create new frequency entry.
44    pub fn new(entity_type: impl Into<String>, count: usize) -> Self {
45        Self {
46            entity_type: entity_type.into(),
47            count,
48        }
49    }
50}
51
52/// Performance metrics for an entity type.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TypePerformance {
55    /// Entity type name
56    pub entity_type: String,
57    /// Number of gold entities
58    pub count: usize,
59    /// Precision
60    pub precision: f64,
61    /// Recall
62    pub recall: f64,
63    /// F1 score
64    pub f1: f64,
65    /// Frequency bucket (head/mid/tail)
66    pub bucket: FrequencyBucket,
67}
68
69/// Frequency bucket classification.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub enum FrequencyBucket {
72    /// Top 20% by frequency
73    Head,
74    /// Middle 60% by frequency
75    Mid,
76    /// Bottom 20% by frequency
77    Tail,
78}
79
80impl std::fmt::Display for FrequencyBucket {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            FrequencyBucket::Head => write!(f, "Head"),
84            FrequencyBucket::Mid => write!(f, "Mid"),
85            FrequencyBucket::Tail => write!(f, "Tail"),
86        }
87    }
88}
89
90/// Split of entity types by frequency.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct FrequencySplit {
93    /// Head types (most common)
94    pub head: Vec<String>,
95    /// Mid types
96    pub mid: Vec<String>,
97    /// Tail types (least common)
98    pub tail: Vec<String>,
99    /// Percentage of total entities in head bucket
100    pub head_coverage: f64,
101    /// Percentage of total entities in mid bucket
102    pub mid_coverage: f64,
103    /// Percentage of total entities in tail bucket
104    pub tail_coverage: f64,
105}
106
107/// Long-tail analysis results.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct LongTailResults {
110    /// Performance per entity type
111    pub per_type: Vec<TypePerformance>,
112    /// Average F1 for head types
113    pub head_f1: f64,
114    /// Average F1 for mid types
115    pub mid_f1: f64,
116    /// Average F1 for tail types
117    pub tail_f1: f64,
118    /// Gap between head and tail (higher = more disparity)
119    pub head_tail_gap: f64,
120    /// Gini coefficient of performance across types (0=equal, 1=max inequality)
121    pub gini_coefficient: f64,
122    /// Number of types with F1 < 0.5
123    pub struggling_types: usize,
124    /// Number of types with F1 = 0 (completely failed)
125    pub failed_types: usize,
126    /// Insights
127    pub insights: Vec<String>,
128}
129
130// =============================================================================
131// Long-Tail Analyzer
132// =============================================================================
133
134/// Analyzer for long-tail entity performance.
135#[derive(Debug, Clone)]
136pub struct LongTailAnalyzer {
137    /// Percentile for head/tail cutoff (e.g., 0.2 = top/bottom 20%)
138    pub tail_percentile: f64,
139}
140
141impl Default for LongTailAnalyzer {
142    fn default() -> Self {
143        Self {
144            tail_percentile: 0.2,
145        }
146    }
147}
148
149impl LongTailAnalyzer {
150    /// Create analyzer with custom tail percentile.
151    pub fn new(tail_percentile: f64) -> Self {
152        Self {
153            tail_percentile: tail_percentile.clamp(0.05, 0.4),
154        }
155    }
156
157    /// Split entity types by frequency into head/mid/tail buckets.
158    pub fn split_by_frequency(&self, frequencies: &[EntityFrequency]) -> FrequencySplit {
159        if frequencies.is_empty() {
160            return FrequencySplit {
161                head: vec![],
162                mid: vec![],
163                tail: vec![],
164                head_coverage: 0.0,
165                mid_coverage: 0.0,
166                tail_coverage: 0.0,
167            };
168        }
169
170        // Sort by count descending
171        let mut sorted: Vec<_> = frequencies.to_vec();
172        sorted.sort_by_key(|b| std::cmp::Reverse(b.count));
173
174        let total: usize = sorted.iter().map(|f| f.count).sum();
175        let n = sorted.len();
176
177        // Determine cutoffs
178        let head_cutoff = (n as f64 * self.tail_percentile).ceil() as usize;
179        let tail_cutoff = n - head_cutoff;
180
181        let mut head = Vec::new();
182        let mut mid = Vec::new();
183        let mut tail = Vec::new();
184
185        let mut head_count = 0usize;
186        let mut mid_count = 0usize;
187        let mut tail_count = 0usize;
188
189        for (i, f) in sorted.iter().enumerate() {
190            if i < head_cutoff {
191                head.push(f.entity_type.clone());
192                head_count += f.count;
193            } else if i >= tail_cutoff {
194                tail.push(f.entity_type.clone());
195                tail_count += f.count;
196            } else {
197                mid.push(f.entity_type.clone());
198                mid_count += f.count;
199            }
200        }
201
202        let total_f64 = total as f64;
203        FrequencySplit {
204            head,
205            mid,
206            tail,
207            head_coverage: if total > 0 {
208                head_count as f64 / total_f64
209            } else {
210                0.0
211            },
212            mid_coverage: if total > 0 {
213                mid_count as f64 / total_f64
214            } else {
215                0.0
216            },
217            tail_coverage: if total > 0 {
218                tail_count as f64 / total_f64
219            } else {
220                0.0
221            },
222        }
223    }
224
225    /// Classify a single entity type into a bucket.
226    pub fn classify_type(
227        &self,
228        entity_type: &str,
229        frequencies: &[EntityFrequency],
230    ) -> FrequencyBucket {
231        let split = self.split_by_frequency(frequencies);
232
233        if split.head.contains(&entity_type.to_string()) {
234            FrequencyBucket::Head
235        } else if split.tail.contains(&entity_type.to_string()) {
236            FrequencyBucket::Tail
237        } else {
238            FrequencyBucket::Mid
239        }
240    }
241
242    /// Analyze long-tail performance from per-type metrics.
243    pub fn analyze(&self, type_metrics: &[(String, usize, f64, f64, f64)]) -> LongTailResults {
244        // type_metrics: (type_name, count, precision, recall, f1)
245
246        if type_metrics.is_empty() {
247            return LongTailResults {
248                per_type: vec![],
249                head_f1: 0.0,
250                mid_f1: 0.0,
251                tail_f1: 0.0,
252                head_tail_gap: 0.0,
253                gini_coefficient: 0.0,
254                struggling_types: 0,
255                failed_types: 0,
256                insights: vec!["No entity types to analyze".into()],
257            };
258        }
259
260        // Build frequency list
261        let frequencies: Vec<_> = type_metrics
262            .iter()
263            .map(|(name, count, _, _, _)| EntityFrequency::new(name.clone(), *count))
264            .collect();
265
266        let split = self.split_by_frequency(&frequencies);
267
268        // Build per-type performance with bucket assignment
269        let per_type: Vec<_> = type_metrics
270            .iter()
271            .map(|(name, count, prec, rec, f1)| {
272                let bucket = self.classify_type(name, &frequencies);
273                TypePerformance {
274                    entity_type: name.clone(),
275                    count: *count,
276                    precision: *prec,
277                    recall: *rec,
278                    f1: *f1,
279                    bucket,
280                }
281            })
282            .collect();
283
284        // Compute bucket averages
285        let head_types: Vec<_> = per_type
286            .iter()
287            .filter(|t| t.bucket == FrequencyBucket::Head)
288            .collect();
289        let mid_types: Vec<_> = per_type
290            .iter()
291            .filter(|t| t.bucket == FrequencyBucket::Mid)
292            .collect();
293        let tail_types: Vec<_> = per_type
294            .iter()
295            .filter(|t| t.bucket == FrequencyBucket::Tail)
296            .collect();
297
298        let head_f1 = if head_types.is_empty() {
299            0.0
300        } else {
301            head_types.iter().map(|t| t.f1).sum::<f64>() / head_types.len() as f64
302        };
303
304        let mid_f1 = if mid_types.is_empty() {
305            0.0
306        } else {
307            mid_types.iter().map(|t| t.f1).sum::<f64>() / mid_types.len() as f64
308        };
309
310        let tail_f1 = if tail_types.is_empty() {
311            0.0
312        } else {
313            tail_types.iter().map(|t| t.f1).sum::<f64>() / tail_types.len() as f64
314        };
315
316        let head_tail_gap = head_f1 - tail_f1;
317
318        // Compute Gini coefficient of F1 scores
319        let gini_coefficient = compute_gini(&per_type.iter().map(|t| t.f1).collect::<Vec<_>>());
320
321        // Count struggling and failed types
322        let struggling_types = per_type.iter().filter(|t| t.f1 < 0.5).count();
323        let failed_types = per_type.iter().filter(|t| t.f1 < 0.01).count();
324
325        // Generate insights
326        let mut insights = Vec::new();
327
328        if head_tail_gap > 0.3 {
329            insights.push(format!(
330                "Large head-tail gap ({:.0}%): tail types severely underperforming",
331                head_tail_gap * 100.0
332            ));
333        } else if head_tail_gap < 0.1 {
334            insights.push("Low head-tail gap: relatively uniform performance across types".into());
335        }
336
337        if gini_coefficient > 0.4 {
338            insights.push(format!(
339                "High inequality (Gini={:.2}): performance very uneven across types",
340                gini_coefficient
341            ));
342        }
343
344        if failed_types > 0 {
345            insights.push(format!(
346                "{} entity types completely failed (F1=0%)",
347                failed_types
348            ));
349        }
350
351        if !tail_types.is_empty() && tail_f1 < 0.3 {
352            let tail_names: Vec<_> = tail_types.iter().map(|t| t.entity_type.as_str()).collect();
353            insights.push(format!(
354                "Tail types struggling: {:?}",
355                &tail_names[..tail_names.len().min(3)]
356            ));
357        }
358
359        // Coverage insight
360        if split.tail_coverage > 0.0 && split.tail_coverage < 0.1 {
361            insights.push(format!(
362                "Tail types represent only {:.1}% of data - may need upsampling",
363                split.tail_coverage * 100.0
364            ));
365        }
366
367        LongTailResults {
368            per_type,
369            head_f1,
370            mid_f1,
371            tail_f1,
372            head_tail_gap,
373            gini_coefficient,
374            struggling_types,
375            failed_types,
376            insights,
377        }
378    }
379}
380
381/// Compute Gini coefficient for a list of values.
382fn compute_gini(values: &[f64]) -> f64 {
383    if values.is_empty() {
384        return 0.0;
385    }
386
387    let n = values.len() as f64;
388    let mean = values.iter().sum::<f64>() / n;
389
390    if mean < 1e-10 {
391        return 0.0; // All zeros
392    }
393
394    let mut sum_abs_diff = 0.0;
395    for v1 in values {
396        for v2 in values {
397            sum_abs_diff += (v1 - v2).abs();
398        }
399    }
400
401    sum_abs_diff / (2.0 * n * n * mean)
402}
403
404/// Format long-tail results for display.
405pub fn format_long_tail_results(results: &LongTailResults) -> String {
406    let mut output = String::new();
407
408    output.push_str("Long-Tail Analysis:\n");
409    output.push_str(&format!("  Head F1: {:.1}%\n", results.head_f1 * 100.0));
410    output.push_str(&format!("  Mid F1:  {:.1}%\n", results.mid_f1 * 100.0));
411    output.push_str(&format!("  Tail F1: {:.1}%\n", results.tail_f1 * 100.0));
412    output.push_str(&format!(
413        "  Head-Tail Gap: {:.1}%\n",
414        results.head_tail_gap * 100.0
415    ));
416    output.push_str(&format!(
417        "  Gini Coefficient: {:.3}\n",
418        results.gini_coefficient
419    ));
420    output.push_str(&format!(
421        "  Struggling types (F1<50%): {}\n",
422        results.struggling_types
423    ));
424    output.push_str(&format!(
425        "  Failed types (F1=0%): {}\n",
426        results.failed_types
427    ));
428
429    if !results.insights.is_empty() {
430        output.push_str("\nInsights:\n");
431        for insight in &results.insights {
432            output.push_str(&format!("  - {}\n", insight));
433        }
434    }
435
436    output
437}
438
439// =============================================================================
440// Tests
441// =============================================================================
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_frequency_split() {
449        let frequencies = vec![
450            EntityFrequency::new("A", 100),
451            EntityFrequency::new("B", 80),
452            EntityFrequency::new("C", 60),
453            EntityFrequency::new("D", 40),
454            EntityFrequency::new("E", 20),
455        ];
456
457        let analyzer = LongTailAnalyzer::new(0.2);
458        let split = analyzer.split_by_frequency(&frequencies);
459
460        // With 5 types and 20% cutoff, head=1, tail=1, mid=3
461        assert_eq!(split.head.len(), 1);
462        assert!(split.head.contains(&"A".to_string()));
463        assert_eq!(split.tail.len(), 1);
464        assert!(split.tail.contains(&"E".to_string()));
465    }
466
467    #[test]
468    fn test_gini_coefficient() {
469        // Equal values = 0
470        let equal = vec![0.5, 0.5, 0.5, 0.5];
471        assert!(compute_gini(&equal) < 0.01);
472
473        // Unequal values = higher gini
474        let unequal = vec![1.0, 0.0, 0.0, 0.0];
475        assert!(compute_gini(&unequal) > 0.5);
476    }
477
478    #[test]
479    fn test_analyze_long_tail() {
480        let type_metrics = vec![
481            ("PER".to_string(), 100, 0.9, 0.85, 0.87),
482            ("ORG".to_string(), 80, 0.8, 0.75, 0.77),
483            ("LOC".to_string(), 60, 0.7, 0.65, 0.67),
484            ("DATE".to_string(), 40, 0.6, 0.55, 0.57),
485            ("DISEASE".to_string(), 20, 0.3, 0.25, 0.27),
486        ];
487
488        let analyzer = LongTailAnalyzer::new(0.2);
489        let results = analyzer.analyze(&type_metrics);
490
491        // Head (PER) should have highest F1
492        assert!(results.head_f1 > results.tail_f1);
493        // Tail (DISEASE) struggling
494        assert!(results.tail_f1 < 0.5);
495        // Gap should be significant
496        assert!(results.head_tail_gap > 0.3);
497    }
498
499    #[test]
500    fn test_empty_input() {
501        let analyzer = LongTailAnalyzer::default();
502        let results = analyzer.analyze(&[]);
503
504        assert_eq!(results.per_type.len(), 0);
505        assert!(!results.insights.is_empty());
506    }
507
508    #[test]
509    fn test_bucket_assignment() {
510        let frequencies = vec![
511            EntityFrequency::new("A", 100),
512            EntityFrequency::new("B", 50),
513            EntityFrequency::new("C", 10),
514        ];
515
516        let analyzer = LongTailAnalyzer::new(0.33);
517
518        assert_eq!(
519            analyzer.classify_type("A", &frequencies),
520            FrequencyBucket::Head
521        );
522        assert_eq!(
523            analyzer.classify_type("C", &frequencies),
524            FrequencyBucket::Tail
525        );
526    }
527}