Skip to main content

anno_eval/eval/
temporal_bias.rs

1//! Temporal bias evaluation for Named Entity Recognition.
2//!
3//! Measures performance differences on names popular in different time periods.
4//! Models trained primarily on contemporary data may struggle with historical names,
5//! and vice versa.
6//!
7//! # Research Background
8//!
9//! - U.S. Social Security Administration baby name data (1880-present)
10//! - Temporal distribution shift in training data
11//! - "Ethel" (peaked 1900s) vs "Jayden" (peaked 2000s)
12//!
13//! # Key Metrics
14//!
15//! - **Decade Recognition Rate**: Recognition accuracy per decade of name popularity
16//! - **Temporal Parity Gap**: Max difference across decades
17//! - **Historical-Modern Gap**: Difference between pre-1950 and post-2000 names
18//!
19//! # Example
20//!
21//! ```rust
22//! use anno_eval::eval::temporal_bias::{TemporalBiasEvaluator, create_temporal_name_dataset};
23//!
24//! let names = create_temporal_name_dataset();
25//! let evaluator = TemporalBiasEvaluator::default();
26//! // let results = evaluator.evaluate(&RegexNER::new(), &names);
27//! ```
28
29use crate::{EntityType, Model};
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32
33// =============================================================================
34// Temporal Categories
35// =============================================================================
36
37/// Decade when a name was most popular.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
39pub enum Decade {
40    /// Pre-1900 (Victorian era names)
41    Pre1900,
42    /// 1900-1909
43    D1900s,
44    /// 1910-1919
45    D1910s,
46    /// 1920-1929
47    D1920s,
48    /// 1930-1939
49    D1930s,
50    /// 1940-1949
51    D1940s,
52    /// 1950-1959
53    D1950s,
54    /// 1960-1969
55    D1960s,
56    /// 1970-1979
57    D1970s,
58    /// 1980-1989
59    D1980s,
60    /// 1990-1999
61    D1990s,
62    /// 2000-2009
63    D2000s,
64    /// 2010-2019
65    D2010s,
66    /// 2020-present
67    D2020s,
68}
69
70impl Decade {
71    /// Returns whether this is a historical (pre-1950) decade.
72    pub fn is_historical(&self) -> bool {
73        matches!(
74            self,
75            Decade::Pre1900
76                | Decade::D1900s
77                | Decade::D1910s
78                | Decade::D1920s
79                | Decade::D1930s
80                | Decade::D1940s
81        )
82    }
83
84    /// Returns whether this is a modern (post-2000) decade.
85    pub fn is_modern(&self) -> bool {
86        matches!(self, Decade::D2000s | Decade::D2010s | Decade::D2020s)
87    }
88
89    /// Returns approximate midpoint year of the decade.
90    pub fn midpoint_year(&self) -> u16 {
91        match self {
92            Decade::Pre1900 => 1890,
93            Decade::D1900s => 1905,
94            Decade::D1910s => 1915,
95            Decade::D1920s => 1925,
96            Decade::D1930s => 1935,
97            Decade::D1940s => 1945,
98            Decade::D1950s => 1955,
99            Decade::D1960s => 1965,
100            Decade::D1970s => 1975,
101            Decade::D1980s => 1985,
102            Decade::D1990s => 1995,
103            Decade::D2000s => 2005,
104            Decade::D2010s => 2015,
105            Decade::D2020s => 2022,
106        }
107    }
108}
109
110// =============================================================================
111// Temporal Name Example
112// =============================================================================
113
114/// A name example with temporal metadata.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct TemporalNameExample {
117    /// First name
118    pub first_name: String,
119    /// Last name
120    pub last_name: String,
121    /// Full name
122    pub full_name: String,
123    /// Decade of peak popularity
124    pub peak_decade: Decade,
125    /// Gender associated with name
126    pub gender: TemporalGender,
127    /// Whether this is a "classic" name (consistent popularity) vs trendy
128    pub is_classic: bool,
129}
130
131/// Gender for temporal name analysis.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
133pub enum TemporalGender {
134    /// Traditionally masculine names
135    Masculine,
136    /// Traditionally feminine names
137    Feminine,
138    /// Gender-neutral names
139    Neutral,
140}
141
142impl TemporalNameExample {
143    /// Create a new temporal name example.
144    pub fn new(
145        first_name: &str,
146        last_name: &str,
147        peak_decade: Decade,
148        gender: TemporalGender,
149        is_classic: bool,
150    ) -> Self {
151        Self {
152            first_name: first_name.to_string(),
153            last_name: last_name.to_string(),
154            full_name: format!("{} {}", first_name, last_name),
155            peak_decade,
156            gender,
157            is_classic,
158        }
159    }
160}
161
162// =============================================================================
163// Evaluation Results
164// =============================================================================
165
166/// Results of temporal bias evaluation.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct TemporalBiasResults {
169    /// Overall recognition rate
170    pub overall_recognition_rate: f64,
171    /// Recognition rate by decade
172    pub by_decade: HashMap<String, f64>,
173    /// Recognition rate for historical (pre-1950) names
174    pub historical_rate: f64,
175    /// Recognition rate for modern (post-2000) names
176    pub modern_rate: f64,
177    /// Gap between historical and modern: |historical - modern|
178    pub historical_modern_gap: f64,
179    /// Maximum gap between any two decades
180    pub temporal_parity_gap: f64,
181    /// Recognition rate by gender
182    pub by_gender: HashMap<String, f64>,
183    /// Recognition rate for classic names (consistent popularity across decades)
184    pub classic_rate: f64,
185    /// Recognition rate for trendy names (peaked in specific decade)
186    pub trendy_rate: f64,
187    /// Total names tested
188    pub total_tested: usize,
189}
190
191// =============================================================================
192// Evaluator
193// =============================================================================
194
195/// Evaluator for temporal bias in NER systems.
196#[derive(Debug, Clone, Default)]
197pub struct TemporalBiasEvaluator {
198    /// Include detailed per-name results
199    pub detailed: bool,
200}
201
202impl TemporalBiasEvaluator {
203    /// Create a new evaluator.
204    pub fn new(detailed: bool) -> Self {
205        Self { detailed }
206    }
207
208    /// Evaluate NER model for temporal bias.
209    pub fn evaluate(
210        &self,
211        model: &dyn Model,
212        names: &[TemporalNameExample],
213    ) -> TemporalBiasResults {
214        let mut by_decade: HashMap<String, (usize, usize)> = HashMap::new();
215        let mut by_gender: HashMap<String, (usize, usize)> = HashMap::new();
216        let mut historical_count = (0usize, 0usize);
217        let mut modern_count = (0usize, 0usize);
218        let mut classic_count = (0usize, 0usize);
219        let mut trendy_count = (0usize, 0usize);
220        let mut total_recognized = 0;
221
222        for name in names {
223            // Create test sentence with realistic context
224            let text = create_realistic_temporal_sentence(&name.full_name);
225
226            // Extract entities
227            let entities = model.extract_entities(&text, None).unwrap_or_default();
228
229            // Check if name was recognized as PERSON
230            let recognized = entities.iter().any(|e| {
231                e.entity_type == EntityType::Person
232                    && e.extract_text(&text).contains(&name.first_name)
233            });
234
235            if recognized {
236                total_recognized += 1;
237            }
238
239            // Update decade stats
240            let decade_key = format!("{:?}", name.peak_decade);
241            let decade_entry = by_decade.entry(decade_key).or_insert((0, 0));
242            decade_entry.1 += 1;
243            if recognized {
244                decade_entry.0 += 1;
245            }
246
247            // Update historical/modern stats
248            if name.peak_decade.is_historical() {
249                historical_count.1 += 1;
250                if recognized {
251                    historical_count.0 += 1;
252                }
253            }
254            if name.peak_decade.is_modern() {
255                modern_count.1 += 1;
256                if recognized {
257                    modern_count.0 += 1;
258                }
259            }
260
261            // Update gender stats
262            let gender_key = format!("{:?}", name.gender);
263            let gender_entry = by_gender.entry(gender_key).or_insert((0, 0));
264            gender_entry.1 += 1;
265            if recognized {
266                gender_entry.0 += 1;
267            }
268
269            // Update classic/trendy stats
270            if name.is_classic {
271                classic_count.1 += 1;
272                if recognized {
273                    classic_count.0 += 1;
274                }
275            } else {
276                trendy_count.1 += 1;
277                if recognized {
278                    trendy_count.0 += 1;
279                }
280            }
281        }
282
283        // Convert counts to rates
284        let to_rate = |counts: &HashMap<String, (usize, usize)>| -> HashMap<String, f64> {
285            counts
286                .iter()
287                .map(|(k, (correct, total))| {
288                    let rate = if *total > 0 {
289                        *correct as f64 / *total as f64
290                    } else {
291                        0.0
292                    };
293                    (k.clone(), rate)
294                })
295                .collect()
296        };
297
298        let count_to_rate = |c: (usize, usize)| -> f64 {
299            if c.1 > 0 {
300                c.0 as f64 / c.1 as f64
301            } else {
302                0.0
303            }
304        };
305
306        let decade_rates = to_rate(&by_decade);
307        let gender_rates = to_rate(&by_gender);
308        let historical_rate = count_to_rate(historical_count);
309        let modern_rate = count_to_rate(modern_count);
310        let classic_rate = count_to_rate(classic_count);
311        let trendy_rate = count_to_rate(trendy_count);
312
313        // Compute parity gap
314        let temporal_parity_gap = compute_max_gap(&decade_rates);
315        let historical_modern_gap = (historical_rate - modern_rate).abs();
316
317        TemporalBiasResults {
318            overall_recognition_rate: if names.is_empty() {
319                0.0
320            } else {
321                total_recognized as f64 / names.len() as f64
322            },
323            by_decade: decade_rates,
324            historical_rate,
325            modern_rate,
326            historical_modern_gap,
327            temporal_parity_gap,
328            by_gender: gender_rates,
329            classic_rate,
330            trendy_rate,
331            total_tested: names.len(),
332        }
333    }
334}
335
336/// Compute maximum gap between any two rates.
337fn compute_max_gap(rates: &HashMap<String, f64>) -> f64 {
338    if rates.len() < 2 {
339        return 0.0;
340    }
341
342    let values: Vec<f64> = rates.values().copied().collect();
343    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
344    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
345
346    max - min
347}
348
349// =============================================================================
350// Realistic Sentence Contexts
351// =============================================================================
352
353/// Create a realistic sentence context for a temporal name.
354fn create_realistic_temporal_sentence(name: &str) -> String {
355    use std::collections::hash_map::DefaultHasher;
356    use std::hash::{Hash, Hasher};
357    let mut hasher = DefaultHasher::new();
358    name.hash(&mut hasher);
359    let hash = hasher.finish();
360
361    let templates = [
362        format!("{} was featured in the historical archives.", name),
363        format!("The biography of {} was published last year.", name),
364        format!("{} made significant contributions to the field.", name),
365        format!("Records show that {} attended the event in 1950.", name),
366        format!("{} was recognized for lifetime achievements.", name),
367        format!("The family of {} established a scholarship fund.", name),
368        format!("{} served as president of the organization.", name),
369        format!("Historical documents mention {} in several contexts.", name),
370        format!("{} was known for innovative research methods.", name),
371        format!(
372            "The legacy of {} continues to inspire new generations.",
373            name
374        ),
375    ];
376
377    templates[hash as usize % templates.len()].clone()
378}
379
380// =============================================================================
381// Temporal Name Dataset
382// =============================================================================
383
384/// Create a dataset of names popular in different decades.
385///
386/// Based on U.S. Social Security Administration baby name data.
387/// Names are selected to represent peak popularity in each decade.
388pub fn create_temporal_name_dataset() -> Vec<TemporalNameExample> {
389    let mut names = Vec::new();
390
391    // Generic last names to pair with first names
392    let last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones"];
393
394    // Pre-1900 (Victorian era)
395    let pre1900 = [
396        ("Gertrude", TemporalGender::Feminine),
397        ("Clarence", TemporalGender::Masculine),
398        ("Mildred", TemporalGender::Feminine),
399        ("Herbert", TemporalGender::Masculine),
400        ("Bertha", TemporalGender::Feminine),
401        ("Agnes", TemporalGender::Feminine),
402        ("Albert", TemporalGender::Masculine),
403        ("Florence", TemporalGender::Feminine),
404        ("Walter", TemporalGender::Masculine),
405        ("Edith", TemporalGender::Feminine),
406    ];
407
408    // 1900s
409    let d1900s = [
410        ("Ethel", TemporalGender::Feminine),
411        ("Harold", TemporalGender::Masculine),
412        ("Pearl", TemporalGender::Feminine),
413        ("Clarence", TemporalGender::Masculine),
414        ("Minnie", TemporalGender::Feminine),
415        ("Alice", TemporalGender::Feminine),
416        ("Raymond", TemporalGender::Masculine),
417        ("Ruth", TemporalGender::Feminine),
418        ("Frank", TemporalGender::Masculine),
419        ("Helen", TemporalGender::Feminine),
420    ];
421
422    // 1910s
423    let d1910s = [
424        ("Dorothy", TemporalGender::Feminine),
425        ("Earl", TemporalGender::Masculine),
426        ("Gladys", TemporalGender::Feminine),
427        ("Howard", TemporalGender::Masculine),
428        ("Thelma", TemporalGender::Feminine),
429    ];
430
431    // 1920s
432    let d1920s = [
433        ("Betty", TemporalGender::Feminine),
434        ("Donald", TemporalGender::Masculine),
435        ("Doris", TemporalGender::Feminine),
436        ("Raymond", TemporalGender::Masculine),
437        ("Shirley", TemporalGender::Feminine),
438    ];
439
440    // 1930s
441    let d1930s = [
442        ("Barbara", TemporalGender::Feminine),
443        ("Robert", TemporalGender::Masculine),
444        ("Patricia", TemporalGender::Feminine),
445        ("Richard", TemporalGender::Masculine),
446        ("Carol", TemporalGender::Feminine),
447    ];
448
449    // 1940s
450    let d1940s = [
451        ("Linda", TemporalGender::Feminine),
452        ("Gary", TemporalGender::Masculine),
453        ("Sandra", TemporalGender::Feminine),
454        ("Larry", TemporalGender::Masculine),
455        ("Sharon", TemporalGender::Feminine),
456    ];
457
458    // 1950s
459    let d1950s = [
460        ("Deborah", TemporalGender::Feminine),
461        ("Dennis", TemporalGender::Masculine),
462        ("Debra", TemporalGender::Feminine),
463        ("Timothy", TemporalGender::Masculine),
464        ("Pamela", TemporalGender::Feminine),
465    ];
466
467    // 1960s
468    let d1960s = [
469        ("Lisa", TemporalGender::Feminine),
470        ("Mark", TemporalGender::Masculine),
471        ("Kimberly", TemporalGender::Feminine),
472        ("Kevin", TemporalGender::Masculine),
473        ("Michelle", TemporalGender::Feminine),
474    ];
475
476    // 1970s
477    let d1970s = [
478        ("Jennifer", TemporalGender::Feminine),
479        ("Jason", TemporalGender::Masculine),
480        ("Amy", TemporalGender::Feminine),
481        ("Brian", TemporalGender::Masculine),
482        ("Heather", TemporalGender::Feminine),
483    ];
484
485    // 1980s
486    let d1980s = [
487        ("Jessica", TemporalGender::Feminine),
488        ("Michael", TemporalGender::Masculine),
489        ("Amanda", TemporalGender::Feminine),
490        ("Christopher", TemporalGender::Masculine),
491        ("Ashley", TemporalGender::Feminine),
492    ];
493
494    // 1990s
495    let d1990s = [
496        ("Brittany", TemporalGender::Feminine),
497        ("Tyler", TemporalGender::Masculine),
498        ("Taylor", TemporalGender::Neutral),
499        ("Brandon", TemporalGender::Masculine),
500        ("Megan", TemporalGender::Feminine),
501    ];
502
503    // 2000s
504    let d2000s = [
505        ("Madison", TemporalGender::Feminine),
506        ("Aiden", TemporalGender::Masculine),
507        ("Emma", TemporalGender::Feminine),
508        ("Ethan", TemporalGender::Masculine),
509        ("Chloe", TemporalGender::Feminine),
510    ];
511
512    // 2010s
513    let d2010s = [
514        ("Sophia", TemporalGender::Feminine),
515        ("Liam", TemporalGender::Masculine),
516        ("Olivia", TemporalGender::Feminine),
517        ("Noah", TemporalGender::Masculine),
518        ("Ava", TemporalGender::Feminine),
519    ];
520
521    // 2020s
522    let d2020s = [
523        ("Luna", TemporalGender::Feminine),
524        ("Ezra", TemporalGender::Masculine),
525        ("Charlotte", TemporalGender::Feminine),
526        ("Oliver", TemporalGender::Masculine),
527        ("Amelia", TemporalGender::Feminine),
528        ("Mia", TemporalGender::Feminine),
529        ("Liam", TemporalGender::Masculine),
530        ("Harper", TemporalGender::Neutral),
531        ("Mason", TemporalGender::Masculine),
532        ("Evelyn", TemporalGender::Feminine),
533    ];
534
535    // Classic names (popular across many decades)
536    let classics = [
537        ("James", TemporalGender::Masculine, true),
538        ("Elizabeth", TemporalGender::Feminine, true),
539        ("William", TemporalGender::Masculine, true),
540        ("Mary", TemporalGender::Feminine, true),
541        ("John", TemporalGender::Masculine, true),
542        ("Sarah", TemporalGender::Feminine, true),
543        ("Robert", TemporalGender::Masculine, true),
544        ("Anna", TemporalGender::Feminine, true),
545        ("Michael", TemporalGender::Masculine, true),
546        ("Emily", TemporalGender::Feminine, true),
547    ];
548
549    // Helper to add names from a decade
550    let add_decade = |names: &mut Vec<TemporalNameExample>,
551                      decade_names: &[(&str, TemporalGender)],
552                      decade: Decade,
553                      last_names: &[&str]| {
554        for (i, (first, gender)) in decade_names.iter().enumerate() {
555            let last = last_names[i % last_names.len()];
556            names.push(TemporalNameExample::new(
557                first, last, decade, *gender, false,
558            ));
559        }
560    };
561
562    add_decade(&mut names, &pre1900, Decade::Pre1900, &last_names);
563    add_decade(&mut names, &d1900s, Decade::D1900s, &last_names);
564    add_decade(&mut names, &d1910s, Decade::D1910s, &last_names);
565    add_decade(&mut names, &d1920s, Decade::D1920s, &last_names);
566    add_decade(&mut names, &d1930s, Decade::D1930s, &last_names);
567    add_decade(&mut names, &d1940s, Decade::D1940s, &last_names);
568    add_decade(&mut names, &d1950s, Decade::D1950s, &last_names);
569    add_decade(&mut names, &d1960s, Decade::D1960s, &last_names);
570    add_decade(&mut names, &d1970s, Decade::D1970s, &last_names);
571    add_decade(&mut names, &d1980s, Decade::D1980s, &last_names);
572    add_decade(&mut names, &d1990s, Decade::D1990s, &last_names);
573    add_decade(&mut names, &d2000s, Decade::D2000s, &last_names);
574    add_decade(&mut names, &d2010s, Decade::D2010s, &last_names);
575    add_decade(&mut names, &d2020s, Decade::D2020s, &last_names);
576
577    // Add classic names (spread across different "peak" decades but marked as classic)
578    for (i, (first, gender, _is_classic)) in classics.iter().enumerate() {
579        let last = last_names[i % last_names.len()];
580        // Classic names get D1950s as nominal decade but marked as classic
581        names.push(TemporalNameExample::new(
582            first,
583            last,
584            Decade::D1950s,
585            *gender,
586            true,
587        ));
588    }
589
590    names
591}
592
593// =============================================================================
594// Tests
595// =============================================================================
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_create_temporal_dataset() {
603        let names = create_temporal_name_dataset();
604
605        // Should have names from multiple decades
606        let decades: std::collections::HashSet<_> = names
607            .iter()
608            .map(|n| format!("{:?}", n.peak_decade))
609            .collect();
610
611        assert!(decades.len() >= 10, "Should cover at least 10 decades");
612        assert!(
613            decades.contains("Pre1900"),
614            "Should have pre-1900 (Victorian) names"
615        );
616        assert!(decades.contains("D2020s"), "Should have 2020s names");
617    }
618
619    #[test]
620    fn test_historical_vs_modern() {
621        let names = create_temporal_name_dataset();
622
623        let historical = names
624            .iter()
625            .filter(|n| n.peak_decade.is_historical())
626            .count();
627        let modern = names.iter().filter(|n| n.peak_decade.is_modern()).count();
628
629        assert!(historical > 0, "Should have historical names");
630        assert!(modern > 0, "Should have modern names");
631    }
632
633    #[test]
634    fn test_classic_names_marked() {
635        let names = create_temporal_name_dataset();
636
637        let classics: Vec<_> = names.iter().filter(|n| n.is_classic).collect();
638
639        assert!(!classics.is_empty(), "Should have classic names");
640        assert!(
641            classics.iter().any(|n| n.first_name == "James"),
642            "James should be a classic"
643        );
644        assert!(
645            classics.iter().any(|n| n.first_name == "Elizabeth"),
646            "Elizabeth should be a classic"
647        );
648    }
649
650    #[test]
651    fn test_decade_ordering() {
652        assert!(Decade::Pre1900 < Decade::D1900s);
653        assert!(Decade::D1900s < Decade::D2020s);
654        assert!(Decade::D1980s.midpoint_year() == 1985);
655    }
656
657    #[test]
658    fn test_gender_distribution() {
659        let names = create_temporal_name_dataset();
660
661        let masculine = names
662            .iter()
663            .filter(|n| n.gender == TemporalGender::Masculine)
664            .count();
665        let feminine = names
666            .iter()
667            .filter(|n| n.gender == TemporalGender::Feminine)
668            .count();
669
670        // Should have reasonable gender distribution
671        assert!(masculine > 20, "Should have substantial masculine names");
672        assert!(feminine > 20, "Should have substantial feminine names");
673    }
674}