Skip to main content

anno_eval/eval/
length_bias.rs

1//! Entity length bias evaluation for Named Entity Recognition.
2//!
3//! Measures performance differences based on entity text length.
4//! Models often exhibit bias toward entity lengths common in training data,
5//! performing worse on very short or very long entities.
6//!
7//! # Research Background
8//!
9//! - Jeong & Kang (2021): "Regularization for Long Named Entity Recognition"
10//!   - Pre-trained language models tend to be biased toward dataset patterns
11//!   - Length statistics of training data directly influence performance
12//!
13//! # Key Metrics
14//!
15//! - **Length Bucket Recognition Rate**: Performance by character/word length
16//! - **Length Parity Gap**: Max difference across length buckets
17//! - **Short Entity Bias**: Performance on 1-2 word entities vs longer
18//!
19//! # Example
20//!
21//! ```rust
22//! use anno_eval::eval::length_bias::{EntityLengthEvaluator, create_length_varied_dataset};
23//!
24//! let examples = create_length_varied_dataset();
25//! let evaluator = EntityLengthEvaluator::default();
26//! // let results = evaluator.evaluate(&model, &examples);
27//! ```
28
29use crate::{EntityType, Model};
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32
33// =============================================================================
34// Length Categories
35// =============================================================================
36
37/// Length bucket for entity classification.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub enum LengthBucket {
40    /// Very short: 1-5 characters (e.g., "NYC", "IBM")
41    VeryShort,
42    /// Short: 6-15 characters (e.g., "John Smith")
43    Short,
44    /// Medium: 16-30 characters (e.g., "University of California")
45    Medium,
46    /// Long: 31-50 characters (e.g., "Massachusetts Institute of Technology")
47    Long,
48    /// Very long: 51+ characters (e.g., compound organization names)
49    VeryLong,
50}
51
52impl LengthBucket {
53    /// Classify a string by its character length.
54    pub fn from_char_length(len: usize) -> Self {
55        match len {
56            0..=5 => LengthBucket::VeryShort,
57            6..=15 => LengthBucket::Short,
58            16..=30 => LengthBucket::Medium,
59            31..=50 => LengthBucket::Long,
60            _ => LengthBucket::VeryLong,
61        }
62    }
63
64    /// Classify a string by its word count.
65    pub fn from_word_count(words: usize) -> Self {
66        match words {
67            0..=1 => LengthBucket::VeryShort,
68            2 => LengthBucket::Short,
69            3..=4 => LengthBucket::Medium,
70            5..=7 => LengthBucket::Long,
71            _ => LengthBucket::VeryLong,
72        }
73    }
74}
75
76/// Word count bucket for finer-grained analysis.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
78pub enum WordCountBucket {
79    /// Single word (e.g., "Microsoft")
80    SingleWord,
81    /// Two words (e.g., "John Smith")
82    TwoWords,
83    /// Three words (e.g., "New York City")
84    ThreeWords,
85    /// Four or more words
86    FourPlusWords,
87}
88
89impl WordCountBucket {
90    /// Classify by word count.
91    pub fn from_count(count: usize) -> Self {
92        match count {
93            0..=1 => WordCountBucket::SingleWord,
94            2 => WordCountBucket::TwoWords,
95            3 => WordCountBucket::ThreeWords,
96            _ => WordCountBucket::FourPlusWords,
97        }
98    }
99}
100
101// =============================================================================
102// Length Test Example
103// =============================================================================
104
105/// An entity example with length metadata.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LengthTestExample {
108    /// The entity text
109    pub entity_text: String,
110    /// Full sentence containing the entity
111    pub sentence: String,
112    /// Expected entity type
113    pub entity_type: EntityType,
114    /// Character length of entity
115    pub char_length: usize,
116    /// Word count of entity
117    pub word_count: usize,
118    /// Character length bucket
119    pub char_bucket: LengthBucket,
120    /// Word count bucket
121    pub word_bucket: WordCountBucket,
122}
123
124impl LengthTestExample {
125    /// Create a new length test example.
126    pub fn new(entity: &str, entity_type: EntityType) -> Self {
127        let sentence = format!("The entity {} was mentioned.", entity);
128        let char_length = entity.chars().count();
129        let word_count = entity.split_whitespace().count();
130
131        Self {
132            entity_text: entity.to_string(),
133            sentence,
134            entity_type,
135            char_length,
136            word_count,
137            char_bucket: LengthBucket::from_char_length(char_length),
138            word_bucket: WordCountBucket::from_count(word_count),
139        }
140    }
141
142    /// Create with a custom sentence.
143    pub fn with_sentence(entity: &str, sentence: &str, entity_type: EntityType) -> Self {
144        let char_length = entity.chars().count();
145        let word_count = entity.split_whitespace().count();
146
147        Self {
148            entity_text: entity.to_string(),
149            sentence: sentence.to_string(),
150            entity_type,
151            char_length,
152            word_count,
153            char_bucket: LengthBucket::from_char_length(char_length),
154            word_bucket: WordCountBucket::from_count(word_count),
155        }
156    }
157}
158
159// =============================================================================
160// Evaluation Results
161// =============================================================================
162
163/// Results of entity length bias evaluation.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct LengthBiasResults {
166    /// Overall recognition rate
167    pub overall_recognition_rate: f64,
168    /// Recognition rate by character length bucket
169    pub by_char_bucket: HashMap<String, f64>,
170    /// Recognition rate by word count bucket
171    pub by_word_bucket: HashMap<String, f64>,
172    /// Recognition rate by entity type
173    pub by_entity_type: HashMap<String, f64>,
174    /// Maximum gap across character length buckets
175    pub char_length_parity_gap: f64,
176    /// Maximum gap across word count buckets
177    pub word_count_parity_gap: f64,
178    /// Short (1-2 words) vs long (4+ words) gap
179    pub short_vs_long_gap: f64,
180    /// Average character length of correctly recognized entities
181    pub avg_recognized_char_length: f64,
182    /// Average character length of missed entities
183    pub avg_missed_char_length: f64,
184    /// Total examples tested
185    pub total_tested: usize,
186}
187
188// =============================================================================
189// Evaluator
190// =============================================================================
191
192/// Evaluator for entity length bias.
193#[derive(Debug, Clone, Default)]
194pub struct EntityLengthEvaluator {
195    /// Include detailed per-example results
196    pub detailed: bool,
197}
198
199impl EntityLengthEvaluator {
200    /// Create a new evaluator.
201    pub fn new(detailed: bool) -> Self {
202        Self { detailed }
203    }
204
205    /// Evaluate NER model for length bias.
206    pub fn evaluate(&self, model: &dyn Model, examples: &[LengthTestExample]) -> LengthBiasResults {
207        let mut by_char_bucket: HashMap<String, (usize, usize)> = HashMap::new();
208        let mut by_word_bucket: HashMap<String, (usize, usize)> = HashMap::new();
209        let mut by_entity_type: HashMap<String, (usize, usize)> = HashMap::new();
210        let mut total_recognized = 0;
211        let mut recognized_char_lengths: Vec<usize> = Vec::new();
212        let mut missed_char_lengths: Vec<usize> = Vec::new();
213
214        for example in examples {
215            // Extract entities
216            let entities = model
217                .extract_entities(&example.sentence, None)
218                .unwrap_or_default();
219
220            // Check if entity was recognized with correct type
221            let recognized = entities.iter().any(|e| {
222                e.entity_type == example.entity_type
223                    && example
224                        .sentence
225                        .get(
226                            anno::offset::TextSpan::from_chars(
227                                &example.sentence,
228                                e.start(),
229                                e.end(),
230                            )
231                            .byte_range(),
232                        )
233                        .map(|s| s.contains(&example.entity_text))
234                        .unwrap_or(false)
235            });
236
237            if recognized {
238                total_recognized += 1;
239                recognized_char_lengths.push(example.char_length);
240            } else {
241                missed_char_lengths.push(example.char_length);
242            }
243
244            // Update char bucket stats
245            let char_key = format!("{:?}", example.char_bucket);
246            let char_entry = by_char_bucket.entry(char_key).or_insert((0, 0));
247            char_entry.1 += 1;
248            if recognized {
249                char_entry.0 += 1;
250            }
251
252            // Update word bucket stats
253            let word_key = format!("{:?}", example.word_bucket);
254            let word_entry = by_word_bucket.entry(word_key).or_insert((0, 0));
255            word_entry.1 += 1;
256            if recognized {
257                word_entry.0 += 1;
258            }
259
260            // Update entity type stats
261            let type_key = format!("{:?}", example.entity_type);
262            let type_entry = by_entity_type.entry(type_key).or_insert((0, 0));
263            type_entry.1 += 1;
264            if recognized {
265                type_entry.0 += 1;
266            }
267        }
268
269        // Convert counts to rates
270        let to_rate = |counts: &HashMap<String, (usize, usize)>| -> HashMap<String, f64> {
271            counts
272                .iter()
273                .map(|(k, (correct, total))| {
274                    let rate = if *total > 0 {
275                        *correct as f64 / *total as f64
276                    } else {
277                        0.0
278                    };
279                    (k.clone(), rate)
280                })
281                .collect()
282        };
283
284        let char_rates = to_rate(&by_char_bucket);
285        let word_rates = to_rate(&by_word_bucket);
286        let type_rates = to_rate(&by_entity_type);
287
288        // Compute parity gaps
289        let char_length_parity_gap = compute_max_gap(&char_rates);
290        let word_count_parity_gap = compute_max_gap(&word_rates);
291
292        // Short vs long gap
293        let short_rate = word_rates
294            .iter()
295            .filter(|(k, _)| k.contains("SingleWord") || k.contains("TwoWords"))
296            .map(|(_, v)| *v)
297            .sum::<f64>()
298            / 2.0;
299        let long_rate = word_rates
300            .get("FourPlusWords")
301            .copied()
302            .unwrap_or(short_rate);
303        let short_vs_long_gap = (short_rate - long_rate).abs();
304
305        // Average lengths
306        let avg_recognized = if recognized_char_lengths.is_empty() {
307            0.0
308        } else {
309            recognized_char_lengths.iter().sum::<usize>() as f64
310                / recognized_char_lengths.len() as f64
311        };
312        let avg_missed = if missed_char_lengths.is_empty() {
313            0.0
314        } else {
315            missed_char_lengths.iter().sum::<usize>() as f64 / missed_char_lengths.len() as f64
316        };
317
318        LengthBiasResults {
319            overall_recognition_rate: if examples.is_empty() {
320                0.0
321            } else {
322                total_recognized as f64 / examples.len() as f64
323            },
324            by_char_bucket: char_rates,
325            by_word_bucket: word_rates,
326            by_entity_type: type_rates,
327            char_length_parity_gap,
328            word_count_parity_gap,
329            short_vs_long_gap,
330            avg_recognized_char_length: avg_recognized,
331            avg_missed_char_length: avg_missed,
332            total_tested: examples.len(),
333        }
334    }
335}
336
337/// Compute maximum gap between any two rates.
338fn compute_max_gap(rates: &HashMap<String, f64>) -> f64 {
339    if rates.len() < 2 {
340        return 0.0;
341    }
342
343    let values: Vec<f64> = rates.values().copied().collect();
344    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
345    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
346
347    max - min
348}
349
350// =============================================================================
351// Length-Varied Dataset
352// =============================================================================
353
354/// Create a dataset with entities of varying lengths.
355pub fn create_length_varied_dataset() -> Vec<LengthTestExample> {
356    vec![
357        // === PERSON entities by length ===
358        // Very short (abbreviations, initials)
359        LengthTestExample::with_sentence(
360            "JFK",
361            "JFK gave a famous speech in Berlin.",
362            EntityType::Person,
363        ),
364        LengthTestExample::with_sentence(
365            "FDR",
366            "FDR led the country through World War II.",
367            EntityType::Person,
368        ),
369        // Short (typical names)
370        LengthTestExample::with_sentence(
371            "John Smith",
372            "John Smith attended the meeting.",
373            EntityType::Person,
374        ),
375        LengthTestExample::with_sentence(
376            "Mary Johnson",
377            "Mary Johnson won the award.",
378            EntityType::Person,
379        ),
380        // Medium (names with middle name or title)
381        LengthTestExample::with_sentence(
382            "Dr. Martin Luther King",
383            "Dr. Martin Luther King delivered a powerful speech.",
384            EntityType::Person,
385        ),
386        LengthTestExample::with_sentence(
387            "William Jefferson Clinton",
388            "William Jefferson Clinton served as president.",
389            EntityType::Person,
390        ),
391        // Long (full names with titles/suffixes)
392        LengthTestExample::with_sentence(
393            "His Royal Highness Prince William",
394            "His Royal Highness Prince William visited the hospital.",
395            EntityType::Person,
396        ),
397        // === ORGANIZATION entities by length ===
398        // Very short
399        LengthTestExample::with_sentence(
400            "IBM",
401            "IBM announced new products.",
402            EntityType::Organization,
403        ),
404        LengthTestExample::with_sentence(
405            "MIT",
406            "MIT published research findings.",
407            EntityType::Organization,
408        ),
409        LengthTestExample::with_sentence(
410            "NASA",
411            "NASA launched a new satellite.",
412            EntityType::Organization,
413        ),
414        // Short
415        LengthTestExample::with_sentence(
416            "Google Inc",
417            "Google Inc acquired the startup.",
418            EntityType::Organization,
419        ),
420        LengthTestExample::with_sentence(
421            "Apple Computer",
422            "Apple Computer revolutionized mobile phones.",
423            EntityType::Organization,
424        ),
425        // Medium
426        LengthTestExample::with_sentence(
427            "University of California",
428            "University of California released the study.",
429            EntityType::Organization,
430        ),
431        LengthTestExample::with_sentence(
432            "World Health Organization",
433            "World Health Organization issued guidelines.",
434            EntityType::Organization,
435        ),
436        // Long
437        LengthTestExample::with_sentence(
438            "Massachusetts Institute of Technology",
439            "Massachusetts Institute of Technology won the competition.",
440            EntityType::Organization,
441        ),
442        LengthTestExample::with_sentence(
443            "International Business Machines Corporation",
444            "International Business Machines Corporation reported earnings.",
445            EntityType::Organization,
446        ),
447        // Very long
448        LengthTestExample::with_sentence(
449            "United States Department of Health and Human Services",
450            "United States Department of Health and Human Services announced the policy.",
451            EntityType::Organization,
452        ),
453        LengthTestExample::with_sentence(
454            "European Organization for Nuclear Research",
455            "European Organization for Nuclear Research discovered the particle.",
456            EntityType::Organization,
457        ),
458        // === LOCATION entities by length ===
459        // Very short
460        LengthTestExample::with_sentence(
461            "NYC",
462            "NYC is known for its skyline.",
463            EntityType::Location,
464        ),
465        LengthTestExample::with_sentence("LA", "LA has beautiful weather.", EntityType::Location),
466        // Short
467        LengthTestExample::with_sentence(
468            "New York",
469            "New York is a bustling city.",
470            EntityType::Location,
471        ),
472        LengthTestExample::with_sentence(
473            "London",
474            "London has many museums.",
475            EntityType::Location,
476        ),
477        // Medium
478        LengthTestExample::with_sentence(
479            "San Francisco Bay Area",
480            "San Francisco Bay Area is a tech hub.",
481            EntityType::Location,
482        ),
483        LengthTestExample::with_sentence(
484            "United Arab Emirates",
485            "United Arab Emirates hosted the conference.",
486            EntityType::Location,
487        ),
488        // Long
489        LengthTestExample::with_sentence(
490            "Democratic Republic of the Congo",
491            "Democratic Republic of the Congo has vast resources.",
492            EntityType::Location,
493        ),
494        LengthTestExample::with_sentence(
495            "Saint Vincent and the Grenadines",
496            "Saint Vincent and the Grenadines is in the Caribbean.",
497            EntityType::Location,
498        ),
499        // Very long
500        LengthTestExample::with_sentence(
501            "Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch",
502            "Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch is a town in Wales.",
503            EntityType::Location,
504        ),
505        // === Additional PERSON examples with titles and suffixes ===
506        LengthTestExample::with_sentence(
507            "Dr. Jane Smith",
508            "Dr. Jane Smith diagnosed the patient.",
509            EntityType::Person,
510        ),
511        LengthTestExample::with_sentence(
512            "Prof. John Doe",
513            "Prof. John Doe published the research.",
514            EntityType::Person,
515        ),
516        LengthTestExample::with_sentence(
517            "Mary-Jane Watson",
518            "Mary-Jane Watson attended the event.",
519            EntityType::Person,
520        ),
521        LengthTestExample::with_sentence(
522            "José María García",
523            "José María García spoke at the conference.",
524            EntityType::Person,
525        ),
526        LengthTestExample::with_sentence(
527            "Robert Williams Jr.",
528            "Robert Williams Jr. inherited the business.",
529            EntityType::Person,
530        ),
531        LengthTestExample::with_sentence(
532            "Elizabeth Taylor III",
533            "Elizabeth Taylor III was the third generation.",
534            EntityType::Person,
535        ),
536        LengthTestExample::with_sentence(
537            "Jean-Pierre Dubois",
538            "Jean-Pierre Dubois visited from France.",
539            EntityType::Person,
540        ),
541        LengthTestExample::with_sentence(
542            "Mary Ann Johnson",
543            "Mary Ann Johnson was the keynote speaker.",
544            EntityType::Person,
545        ),
546        // === Additional ORGANIZATION examples ===
547        LengthTestExample::with_sentence(
548            "AT&T",
549            "AT&T announced the merger.",
550            EntityType::Organization,
551        ),
552        LengthTestExample::with_sentence(
553            "3M",
554            "3M developed new materials.",
555            EntityType::Organization,
556        ),
557        LengthTestExample::with_sentence(
558            "JPMorgan Chase",
559            "JPMorgan Chase reported earnings.",
560            EntityType::Organization,
561        ),
562        LengthTestExample::with_sentence(
563            "Bank of America",
564            "Bank of America opened new branches.",
565            EntityType::Organization,
566        ),
567        LengthTestExample::with_sentence(
568            "General Electric Company",
569            "General Electric Company restructured operations.",
570            EntityType::Organization,
571        ),
572        LengthTestExample::with_sentence(
573            "The Coca-Cola Company",
574            "The Coca-Cola Company launched a new product.",
575            EntityType::Organization,
576        ),
577        LengthTestExample::with_sentence(
578            "Procter & Gamble",
579            "Procter & Gamble acquired the brand.",
580            EntityType::Organization,
581        ),
582        LengthTestExample::with_sentence(
583            "Johnson & Johnson",
584            "Johnson & Johnson developed the vaccine.",
585            EntityType::Organization,
586        ),
587        // === Additional LOCATION examples ===
588        LengthTestExample::with_sentence("UK", "UK announced new policies.", EntityType::Location),
589        LengthTestExample::with_sentence("USA", "USA hosted the summit.", EntityType::Location),
590        LengthTestExample::with_sentence(
591            "Los Angeles",
592            "Los Angeles hosted the Olympics.",
593            EntityType::Location,
594        ),
595        LengthTestExample::with_sentence(
596            "San Diego",
597            "San Diego is a coastal city.",
598            EntityType::Location,
599        ),
600        LengthTestExample::with_sentence(
601            "New York City",
602            "New York City never sleeps.",
603            EntityType::Location,
604        ),
605        LengthTestExample::with_sentence(
606            "Greater London Area",
607            "Greater London Area has millions of residents.",
608            EntityType::Location,
609        ),
610        LengthTestExample::with_sentence(
611            "Republic of South Africa",
612            "Republic of South Africa celebrated independence.",
613            EntityType::Location,
614        ),
615        LengthTestExample::with_sentence(
616            "Federative Republic of Brazil",
617            "Federative Republic of Brazil hosted the World Cup.",
618            EntityType::Location,
619        ),
620        // === DATE examples (for completeness) ===
621        LengthTestExample::with_sentence(
622            "2024",
623            "The year 2024 was significant.",
624            EntityType::Date,
625        ),
626        LengthTestExample::with_sentence(
627            "January 15, 2024",
628            "The meeting was scheduled for January 15, 2024.",
629            EntityType::Date,
630        ),
631        LengthTestExample::with_sentence(
632            "Q1 2024",
633            "Q1 2024 showed strong growth.",
634            EntityType::Date,
635        ),
636        // === MONEY examples ===
637        LengthTestExample::with_sentence("$5", "The item cost $5.", EntityType::Money),
638        LengthTestExample::with_sentence(
639            "$1,234.56",
640            "The total was $1,234.56.",
641            EntityType::Money,
642        ),
643        LengthTestExample::with_sentence(
644            "€1,000,000",
645            "The investment was €1,000,000.",
646            EntityType::Money,
647        ),
648    ]
649}
650
651// =============================================================================
652// Tests
653// =============================================================================
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_length_bucket_classification() {
661        assert_eq!(LengthBucket::from_char_length(3), LengthBucket::VeryShort);
662        assert_eq!(LengthBucket::from_char_length(10), LengthBucket::Short);
663        assert_eq!(LengthBucket::from_char_length(25), LengthBucket::Medium);
664        assert_eq!(LengthBucket::from_char_length(40), LengthBucket::Long);
665        assert_eq!(LengthBucket::from_char_length(60), LengthBucket::VeryLong);
666    }
667
668    #[test]
669    fn test_word_count_bucket() {
670        assert_eq!(WordCountBucket::from_count(1), WordCountBucket::SingleWord);
671        assert_eq!(WordCountBucket::from_count(2), WordCountBucket::TwoWords);
672        assert_eq!(WordCountBucket::from_count(3), WordCountBucket::ThreeWords);
673        assert_eq!(
674            WordCountBucket::from_count(5),
675            WordCountBucket::FourPlusWords
676        );
677    }
678
679    #[test]
680    fn test_create_length_dataset() {
681        let examples = create_length_varied_dataset();
682
683        // Should have examples in all length buckets
684        let char_buckets: std::collections::HashSet<_> = examples
685            .iter()
686            .map(|e| format!("{:?}", e.char_bucket))
687            .collect();
688
689        assert!(
690            char_buckets.contains("VeryShort"),
691            "Should have very short entities"
692        );
693        assert!(char_buckets.contains("Short"), "Should have short entities");
694        assert!(
695            char_buckets.contains("Medium"),
696            "Should have medium entities"
697        );
698        assert!(char_buckets.contains("Long"), "Should have long entities");
699    }
700
701    #[test]
702    fn test_entity_type_coverage() {
703        let examples = create_length_varied_dataset();
704
705        let types: std::collections::HashSet<_> = examples
706            .iter()
707            .map(|e| format!("{:?}", e.entity_type))
708            .collect();
709
710        assert!(types.contains("Person"), "Should have PERSON entities");
711        assert!(
712            types.contains("Organization"),
713            "Should have ORGANIZATION entities"
714        );
715        assert!(types.contains("Location"), "Should have LOCATION entities");
716    }
717
718    #[test]
719    fn test_example_construction() {
720        let example = LengthTestExample::new("John Smith", EntityType::Person);
721
722        assert_eq!(example.entity_text, "John Smith");
723        assert_eq!(example.char_length, 10);
724        assert_eq!(example.word_count, 2);
725        assert_eq!(example.char_bucket, LengthBucket::Short);
726        assert_eq!(example.word_bucket, WordCountBucket::TwoWords);
727    }
728}