Skip to main content

anno_eval/eval/
robustness.rs

1//! Robustness testing for NER models.
2//!
3//! Tests model behavior under various perturbations and distribution shifts.
4//! A robust model should degrade gracefully rather than catastrophically fail.
5//!
6//! # Perturbation Types
7//!
8//! - **Typos**: Character-level noise (swaps, insertions, deletions)
9//! - **Case changes**: UPPER, lower, Title, mIxEd
10//! - **Whitespace**: Extra spaces, tabs, newlines
11//! - **Punctuation**: Missing or extra punctuation
12//! - **Unicode**: Homoglyphs, diacritics, combining characters
13//!
14//! # Research Background
15//!
16//! - Pacific AI (2024): "Robustness Testing of NER Models with LangTest"
17//! - Perturbation-based evaluation reveals model brittleness
18//! - Real-world data contains noise that test sets often lack
19//!
20//! # Example
21//!
22//! ```rust
23//! use anno_eval::eval::robustness::{RobustnessEvaluator, Perturbation};
24//!
25//! let perturber = RobustnessEvaluator::default();
26//! let original = "John Smith works at Google.";
27//!
28//! // Generate perturbed versions
29//! let variants = perturber.generate_variants(original);
30//! for (perturbation_type, text) in variants {
31//!     println!("{:?}: {}", perturbation_type, text);
32//! }
33//! ```
34
35use crate::{Entity, Model};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38
39/// Simple deterministic pseudo-random number generator (xorshift).
40struct SimpleRng {
41    state: u64,
42}
43
44impl SimpleRng {
45    fn new(seed: u64) -> Self {
46        Self { state: seed.max(1) }
47    }
48
49    fn next(&mut self) -> u64 {
50        let mut x = self.state;
51        x ^= x << 13;
52        x ^= x >> 7;
53        x ^= x << 17;
54        self.state = x;
55        x
56    }
57
58    fn gen_f64(&mut self) -> f64 {
59        (self.next() as f64) / (u64::MAX as f64)
60    }
61
62    fn gen_bool(&mut self) -> bool {
63        #[allow(clippy::manual_is_multiple_of)]
64        {
65            self.next() % 2 == 0
66        }
67    }
68
69    fn gen_range(&mut self, max: usize) -> usize {
70        if max == 0 {
71            0
72        } else {
73            (self.next() as usize) % max
74        }
75    }
76}
77
78// =============================================================================
79// Perturbation Types
80// =============================================================================
81
82/// Types of perturbations for robustness testing.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub enum Perturbation {
85    /// No perturbation (baseline)
86    None,
87    /// Character swaps within words
88    TypoSwap,
89    /// Character insertions
90    TypoInsert,
91    /// Character deletions
92    TypoDelete,
93    /// Keyboard-adjacent character substitution
94    TypoKeyboard,
95    /// Convert to UPPERCASE
96    CaseUpper,
97    /// Convert to lowercase
98    CaseLower,
99    /// Convert to Title Case
100    CaseTitle,
101    /// Convert to mIxEd CaSe
102    CaseMixed,
103    /// Add extra whitespace
104    WhitespaceExtra,
105    /// Remove some whitespace
106    WhitespaceRemove,
107    /// Replace spaces with newlines
108    WhitespaceNewline,
109    /// Remove punctuation
110    PunctuationRemove,
111    /// Add extra punctuation
112    PunctuationExtra,
113    /// Unicode homoglyphs (e.g., 'а' vs 'a')
114    UnicodeHomoglyph,
115    /// Add diacritics (e.g., 'e' -> 'é')
116    UnicodeDiacritics,
117    /// Add zero-width characters
118    UnicodeZeroWidth,
119}
120
121// =============================================================================
122// Robustness Results
123// =============================================================================
124
125/// Results of robustness evaluation.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RobustnessResults {
128    /// Baseline F1 (no perturbation)
129    pub baseline_f1: f64,
130    /// F1 score by perturbation type
131    pub by_perturbation: HashMap<String, PerturbationMetrics>,
132    /// Average F1 across all perturbations
133    pub avg_perturbed_f1: f64,
134    /// Robustness score: avg_perturbed_f1 / baseline_f1 (1.0 = perfectly robust)
135    pub robustness_score: f64,
136    /// Worst perturbation type
137    pub worst_perturbation: String,
138    /// Best perturbation type (often "None")
139    pub best_perturbation: String,
140    /// Total examples tested
141    pub total_examples: usize,
142}
143
144/// Metrics for a single perturbation type.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct PerturbationMetrics {
147    /// F1 score under this perturbation
148    pub f1: f64,
149    /// Precision under this perturbation
150    pub precision: f64,
151    /// Recall under this perturbation
152    pub recall: f64,
153    /// Relative change from baseline: (perturbed - baseline) / baseline
154    pub relative_change: f64,
155    /// Number of examples tested
156    pub count: usize,
157}
158
159// =============================================================================
160// Robustness Evaluator
161// =============================================================================
162
163/// Evaluator for model robustness under perturbations.
164#[derive(Debug, Clone)]
165pub struct RobustnessEvaluator {
166    /// Perturbation types to test
167    pub perturbations: Vec<Perturbation>,
168    /// Random seed for reproducibility
169    pub seed: u64,
170    /// Perturbation intensity (0.0-1.0)
171    pub intensity: f64,
172}
173
174impl Default for RobustnessEvaluator {
175    fn default() -> Self {
176        Self {
177            perturbations: vec![
178                Perturbation::None,
179                Perturbation::TypoSwap,
180                Perturbation::TypoDelete,
181                Perturbation::CaseUpper,
182                Perturbation::CaseLower,
183                Perturbation::CaseMixed,
184                Perturbation::WhitespaceExtra,
185                Perturbation::PunctuationRemove,
186                Perturbation::UnicodeHomoglyph,
187            ],
188            seed: 42,
189            intensity: 0.1, // 10% of characters affected
190        }
191    }
192}
193
194impl RobustnessEvaluator {
195    /// Create a new evaluator with custom perturbations.
196    pub fn new(perturbations: Vec<Perturbation>) -> Self {
197        Self {
198            perturbations,
199            ..Default::default()
200        }
201    }
202
203    /// Generate perturbed variants of a text.
204    pub fn generate_variants(&self, text: &str) -> Vec<(Perturbation, String)> {
205        self.perturbations
206            .iter()
207            .map(|&p| (p, self.apply_perturbation(text, p)))
208            .collect()
209    }
210
211    /// Apply a single perturbation to text.
212    pub fn apply_perturbation(&self, text: &str, perturbation: Perturbation) -> String {
213        let mut rng = SimpleRng::new(self.seed ^ (text.len() as u64));
214
215        match perturbation {
216            Perturbation::None => text.to_string(),
217
218            Perturbation::TypoSwap => {
219                let mut chars: Vec<char> = text.chars().collect();
220                let num_swaps = ((chars.len() as f64 * self.intensity) as usize).max(1);
221                for _ in 0..num_swaps {
222                    if chars.len() >= 2 {
223                        let idx = rng.gen_range(chars.len() - 1);
224                        if chars[idx].is_alphabetic() && chars[idx + 1].is_alphabetic() {
225                            chars.swap(idx, idx + 1);
226                        }
227                    }
228                }
229                chars.into_iter().collect()
230            }
231
232            Perturbation::TypoInsert => {
233                let mut result = String::new();
234                let chars: Vec<char> = text.chars().collect();
235                for (i, c) in chars.iter().enumerate() {
236                    result.push(*c);
237                    if rng.gen_f64() < self.intensity && c.is_alphabetic() {
238                        // Insert a random adjacent character
239                        let adjacent = random_adjacent_char(*c, &mut rng);
240                        result.push(adjacent);
241                    }
242                    // Ensure we don't insert too much
243                    if i > 0 && i % 20 == 0 && rng.gen_f64() < 0.1 {
244                        break;
245                    }
246                }
247                result
248            }
249
250            Perturbation::TypoDelete => {
251                let intensity = self.intensity;
252                text.chars()
253                    .filter(|c| !c.is_alphabetic() || rng.gen_f64() > intensity)
254                    .collect()
255            }
256
257            Perturbation::TypoKeyboard => {
258                let intensity = self.intensity;
259                text.chars()
260                    .map(|c| {
261                        if c.is_alphabetic() && rng.gen_f64() < intensity {
262                            keyboard_neighbor(c, &mut rng)
263                        } else {
264                            c
265                        }
266                    })
267                    .collect()
268            }
269
270            Perturbation::CaseUpper => text.to_uppercase(),
271            Perturbation::CaseLower => text.to_lowercase(),
272
273            Perturbation::CaseTitle => text
274                .split_whitespace()
275                .map(|word| {
276                    let mut chars = word.chars();
277                    match chars.next() {
278                        None => String::new(),
279                        Some(first) => first
280                            .to_uppercase()
281                            .chain(chars.flat_map(|c| c.to_lowercase()))
282                            .collect(),
283                    }
284                })
285                .collect::<Vec<_>>()
286                .join(" "),
287
288            Perturbation::CaseMixed => text.chars().enumerate().fold(
289                String::with_capacity(text.len()),
290                |mut out, (i, c)| {
291                    // Unicode-aware: case conversion can expand into multiple chars.
292                    if i % 2 == 0 {
293                        out.extend(c.to_uppercase());
294                    } else {
295                        out.extend(c.to_lowercase());
296                    }
297                    out
298                },
299            ),
300
301            Perturbation::WhitespaceExtra => {
302                let intensity = self.intensity;
303                text.chars()
304                    .flat_map(|c| {
305                        if c == ' ' && rng.gen_f64() < intensity * 3.0 {
306                            vec![' ', ' ']
307                        } else {
308                            vec![c]
309                        }
310                    })
311                    .collect()
312            }
313
314            Perturbation::WhitespaceRemove => {
315                let words: Vec<&str> = text.split_whitespace().collect();
316                let mut result = String::new();
317                for (i, word) in words.iter().enumerate() {
318                    result.push_str(word);
319                    if i < words.len() - 1 && rng.gen_f64() > self.intensity {
320                        result.push(' ');
321                    }
322                }
323                result
324            }
325
326            Perturbation::WhitespaceNewline => {
327                let intensity = self.intensity;
328                text.chars()
329                    .map(|c| {
330                        if c == ' ' && rng.gen_f64() < intensity {
331                            '\n'
332                        } else {
333                            c
334                        }
335                    })
336                    .collect()
337            }
338
339            Perturbation::PunctuationRemove => {
340                text.chars().filter(|c| !c.is_ascii_punctuation()).collect()
341            }
342
343            Perturbation::PunctuationExtra => {
344                let intensity = self.intensity;
345                text.chars()
346                    .flat_map(|c| {
347                        if c.is_ascii_punctuation() && rng.gen_f64() < intensity * 3.0 {
348                            vec![c, c]
349                        } else {
350                            vec![c]
351                        }
352                    })
353                    .collect()
354            }
355
356            Perturbation::UnicodeHomoglyph => {
357                let intensity = self.intensity;
358                text.chars()
359                    .map(|c| {
360                        if rng.gen_f64() < intensity {
361                            homoglyph(c)
362                        } else {
363                            c
364                        }
365                    })
366                    .collect()
367            }
368
369            Perturbation::UnicodeDiacritics => {
370                let intensity = self.intensity;
371                text.chars()
372                    .map(|c| {
373                        if c.is_alphabetic() && rng.gen_f64() < intensity {
374                            add_diacritic(c)
375                        } else {
376                            c
377                        }
378                    })
379                    .collect()
380            }
381
382            Perturbation::UnicodeZeroWidth => {
383                let zwsp = '\u{200B}'; // Zero-width space
384                let intensity = self.intensity;
385                text.chars()
386                    .flat_map(|c| {
387                        if rng.gen_f64() < intensity * 0.5 {
388                            vec![c, zwsp]
389                        } else {
390                            vec![c]
391                        }
392                    })
393                    .collect()
394            }
395        }
396    }
397
398    /// Evaluate model robustness on test cases.
399    pub fn evaluate(
400        &self,
401        model: &dyn Model,
402        test_cases: &[(String, Vec<Entity>)],
403    ) -> RobustnessResults {
404        let mut by_perturbation: HashMap<String, Vec<(f64, f64, f64)>> = HashMap::new();
405
406        for (text, gold_entities) in test_cases {
407            for &perturbation in &self.perturbations {
408                let perturbed = self.apply_perturbation(text, perturbation);
409                let predicted = model.extract_entities(&perturbed, None).unwrap_or_default();
410
411                // Compute metrics (simplified - just count matches)
412                let (precision, recall, f1) =
413                    compute_simple_metrics(&predicted, gold_entities, text, &perturbed);
414
415                by_perturbation
416                    .entry(format!("{:?}", perturbation))
417                    .or_default()
418                    .push((precision, recall, f1));
419            }
420        }
421
422        // Aggregate metrics
423        let mut aggregated: HashMap<String, PerturbationMetrics> = HashMap::new();
424        let baseline_f1 = by_perturbation
425            .get("None")
426            .map(|v| v.iter().map(|(_, _, f1)| f1).sum::<f64>() / v.len() as f64)
427            .unwrap_or(0.0);
428
429        for (name, metrics) in &by_perturbation {
430            let avg_precision =
431                metrics.iter().map(|(p, _, _)| p).sum::<f64>() / metrics.len() as f64;
432            let avg_recall = metrics.iter().map(|(_, r, _)| r).sum::<f64>() / metrics.len() as f64;
433            let avg_f1 = metrics.iter().map(|(_, _, f)| f).sum::<f64>() / metrics.len() as f64;
434            let relative_change = if baseline_f1 > 0.0 {
435                (avg_f1 - baseline_f1) / baseline_f1
436            } else {
437                0.0
438            };
439
440            aggregated.insert(
441                name.clone(),
442                PerturbationMetrics {
443                    f1: avg_f1,
444                    precision: avg_precision,
445                    recall: avg_recall,
446                    relative_change,
447                    count: metrics.len(),
448                },
449            );
450        }
451
452        // Find best/worst
453        let (worst, _) = aggregated
454            .iter()
455            .filter(|(k, _)| k.as_str() != "None")
456            .min_by(|a, b| {
457                a.1.f1
458                    .partial_cmp(&b.1.f1)
459                    .unwrap_or(std::cmp::Ordering::Equal)
460            })
461            .map(|(k, v)| (k.clone(), v.f1))
462            .unwrap_or(("None".to_string(), baseline_f1));
463
464        let (best, _) = aggregated
465            .iter()
466            .max_by(|a, b| {
467                a.1.f1
468                    .partial_cmp(&b.1.f1)
469                    .unwrap_or(std::cmp::Ordering::Equal)
470            })
471            .map(|(k, v)| (k.clone(), v.f1))
472            .unwrap_or(("None".to_string(), baseline_f1));
473
474        // Average F1 across perturbations (excluding baseline)
475        let perturbed_f1s: Vec<f64> = aggregated
476            .iter()
477            .filter(|(k, _)| k.as_str() != "None")
478            .map(|(_, v)| v.f1)
479            .collect();
480        let avg_perturbed_f1 = if perturbed_f1s.is_empty() {
481            baseline_f1
482        } else {
483            perturbed_f1s.iter().sum::<f64>() / perturbed_f1s.len() as f64
484        };
485
486        let robustness_score = if baseline_f1 > 0.0 {
487            avg_perturbed_f1 / baseline_f1
488        } else {
489            0.0
490        };
491
492        RobustnessResults {
493            baseline_f1,
494            by_perturbation: aggregated,
495            avg_perturbed_f1,
496            robustness_score,
497            worst_perturbation: worst,
498            best_perturbation: best,
499            total_examples: test_cases.len(),
500        }
501    }
502}
503
504// =============================================================================
505// Helper Functions
506// =============================================================================
507
508/// Get a random character adjacent on a QWERTY keyboard.
509fn keyboard_neighbor(c: char, rng: &mut SimpleRng) -> char {
510    let keyboard: &[(&[char], &[char])] = &[
511        (&['q'], &['w', 'a']),
512        (&['w'], &['q', 'e', 's']),
513        (&['e'], &['w', 'r', 'd']),
514        (&['r'], &['e', 't', 'f']),
515        (&['t'], &['r', 'y', 'g']),
516        (&['a'], &['q', 's', 'z']),
517        (&['s'], &['a', 'd', 'w', 'x']),
518        (&['d'], &['s', 'f', 'e', 'c']),
519        (&['f'], &['d', 'g', 'r', 'v']),
520        (&['g'], &['f', 'h', 't', 'b']),
521    ];
522
523    // This helper is intentionally ASCII/QWERTY-only.
524    let lower = c.to_ascii_lowercase();
525    for (keys, neighbors) in keyboard {
526        if keys.contains(&lower) && !neighbors.is_empty() {
527            let idx = rng.gen_range(neighbors.len());
528            let neighbor = neighbors[idx];
529            return if c.is_uppercase() {
530                neighbor.to_ascii_uppercase()
531            } else {
532                neighbor
533            };
534        }
535    }
536    c
537}
538
539/// Get a random adjacent character (simple version).
540fn random_adjacent_char(c: char, rng: &mut SimpleRng) -> char {
541    let offset: i32 = if rng.gen_bool() { 1 } else { -1 };
542    char::from_u32((c as i32 + offset) as u32).unwrap_or(c)
543}
544
545/// Get a homoglyph for a character.
546fn homoglyph(c: char) -> char {
547    match c {
548        'a' => 'а', // Cyrillic а
549        'e' => 'е', // Cyrillic е
550        'o' => 'о', // Cyrillic о
551        'p' => 'р', // Cyrillic р
552        'c' => 'с', // Cyrillic с
553        'A' => 'А', // Cyrillic А
554        'E' => 'Е', // Cyrillic Е
555        'O' => 'О', // Cyrillic О
556        'P' => 'Р', // Cyrillic Р
557        'C' => 'С', // Cyrillic С
558        _ => c,
559    }
560}
561
562/// Add a diacritic to a character.
563fn add_diacritic(c: char) -> char {
564    match c {
565        'a' => 'á',
566        'e' => 'é',
567        'i' => 'í',
568        'o' => 'ó',
569        'u' => 'ú',
570        'n' => 'ñ',
571        'A' => 'Á',
572        'E' => 'É',
573        'I' => 'Í',
574        'O' => 'Ó',
575        'U' => 'Ú',
576        'N' => 'Ñ',
577        _ => c,
578    }
579}
580
581/// Compute simple P/R/F1 metrics.
582fn compute_simple_metrics(
583    predicted: &[Entity],
584    gold: &[Entity],
585    _original_text: &str,
586    _perturbed_text: &str,
587) -> (f64, f64, f64) {
588    // Simplified matching: count entities by type
589    let mut correct = 0;
590
591    for pred in predicted {
592        if gold.iter().any(|g| {
593            g.entity_type == pred.entity_type && g.text.to_lowercase() == pred.text.to_lowercase()
594        }) {
595            correct += 1;
596        }
597    }
598
599    let precision = if predicted.is_empty() {
600        0.0
601    } else {
602        correct as f64 / predicted.len() as f64
603    };
604    let recall = if gold.is_empty() {
605        0.0
606    } else {
607        correct as f64 / gold.len() as f64
608    };
609    let f1 = if precision + recall > 0.0 {
610        2.0 * precision * recall / (precision + recall)
611    } else {
612        0.0
613    };
614
615    (precision, recall, f1)
616}
617
618/// Grade robustness score.
619pub fn robustness_grade(score: f64) -> &'static str {
620    if score >= 0.95 {
621        "Excellent robustness"
622    } else if score >= 0.85 {
623        "Good robustness"
624    } else if score >= 0.70 {
625        "Moderate robustness"
626    } else if score >= 0.50 {
627        "Poor robustness"
628    } else {
629        "Very poor robustness"
630    }
631}
632
633// =============================================================================
634// Tests
635// =============================================================================
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn test_typo_swap() {
643        let evaluator = RobustnessEvaluator {
644            intensity: 0.5,
645            ..Default::default()
646        };
647
648        let original = "hello world";
649        let perturbed = evaluator.apply_perturbation(original, Perturbation::TypoSwap);
650
651        // Should be different but similar length
652        assert!(!perturbed.is_empty());
653    }
654
655    #[test]
656    fn test_case_upper() {
657        let evaluator = RobustnessEvaluator::default();
658        let perturbed = evaluator.apply_perturbation("Hello World", Perturbation::CaseUpper);
659        assert_eq!(perturbed, "HELLO WORLD");
660    }
661
662    #[test]
663    fn test_case_lower() {
664        let evaluator = RobustnessEvaluator::default();
665        let perturbed = evaluator.apply_perturbation("Hello World", Perturbation::CaseLower);
666        assert_eq!(perturbed, "hello world");
667    }
668
669    #[test]
670    fn test_punctuation_remove() {
671        let evaluator = RobustnessEvaluator::default();
672        let perturbed =
673            evaluator.apply_perturbation("Hello, World!", Perturbation::PunctuationRemove);
674        assert_eq!(perturbed, "Hello World");
675    }
676
677    #[test]
678    fn test_generate_variants() {
679        let evaluator = RobustnessEvaluator::default();
680        let variants = evaluator.generate_variants("Test text");
681
682        assert!(!variants.is_empty());
683        assert!(variants.iter().any(|(p, _)| *p == Perturbation::None));
684    }
685
686    #[test]
687    fn test_homoglyph() {
688        assert_eq!(homoglyph('a'), 'а'); // Cyrillic а
689        assert_eq!(homoglyph('z'), 'z'); // No homoglyph
690    }
691
692    #[test]
693    fn test_robustness_grades() {
694        assert_eq!(robustness_grade(0.98), "Excellent robustness");
695        assert_eq!(robustness_grade(0.90), "Good robustness");
696        assert_eq!(robustness_grade(0.75), "Moderate robustness");
697        assert_eq!(robustness_grade(0.60), "Poor robustness");
698        assert_eq!(robustness_grade(0.30), "Very poor robustness");
699    }
700}