Skip to main content

anno_eval/eval/
ood_detection.rs

1//! Out-of-Distribution (OOD) detection for NER systems.
2//!
3//! Detects when models encounter entity patterns not seen during training,
4//! enabling graceful degradation rather than confident incorrect predictions.
5//!
6//! # Research Background
7//!
8//! - Models often produce confident predictions on OOD inputs
9//! - "Confident uncertainty" is dangerous in production
10//! - OOD detection enables fallback strategies (human review, abstention)
11//!
12//! # Key Concepts
13//!
14//! - **Vocabulary OOD**: Entity surface forms not in training vocabulary
15//! - **Distribution OOD**: Entity patterns statistically different from training
16//! - **Confidence-based OOD**: Low model confidence as OOD signal
17//!
18//! # Example
19//!
20//! ```rust
21//! use anno_eval::eval::ood_detection::{OODDetector, OODConfig};
22//!
23//! let detector = OODDetector::new(OODConfig::default());
24//!
25//! // Build vocabulary from training entities
26//! let training_entities = vec!["John Smith", "Google", "New York"];
27//! let detector = detector.fit(&training_entities);
28//!
29//! // Check if test entities are OOD
30//! let is_ood = detector.is_ood("Xiangjun Chen");
31//! ```
32
33use serde::{Deserialize, Serialize};
34use std::collections::{HashMap, HashSet};
35
36// =============================================================================
37// Configuration
38// =============================================================================
39
40/// Configuration for OOD detection.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct OODConfig {
43    /// Confidence threshold below which predictions are flagged as OOD
44    pub confidence_threshold: f64,
45    /// Minimum character n-gram frequency to be considered in-distribution
46    pub min_ngram_frequency: usize,
47    /// N-gram size for vocabulary coverage
48    pub ngram_size: usize,
49    /// Whether to use subword tokenization for vocabulary
50    pub use_subwords: bool,
51    /// Threshold for vocabulary coverage (0.0-1.0)
52    pub vocab_coverage_threshold: f64,
53}
54
55impl Default for OODConfig {
56    fn default() -> Self {
57        Self {
58            confidence_threshold: 0.5,
59            min_ngram_frequency: 1,
60            ngram_size: 3,
61            use_subwords: true,
62            vocab_coverage_threshold: 0.5,
63        }
64    }
65}
66
67// =============================================================================
68// OOD Detection Results
69// =============================================================================
70
71/// Results of OOD analysis on a dataset.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct OODAnalysisResults {
74    /// Total entities analyzed
75    pub total_entities: usize,
76    /// Number flagged as OOD
77    pub ood_count: usize,
78    /// OOD rate (ood_count / total_entities)
79    pub ood_rate: f64,
80    /// OOD breakdown by detection method
81    pub by_method: HashMap<String, usize>,
82    /// Average confidence of OOD entities
83    pub avg_ood_confidence: f64,
84    /// Average confidence of in-distribution entities
85    pub avg_id_confidence: f64,
86    /// Vocabulary coverage statistics
87    pub vocab_stats: VocabCoverageStats,
88    /// Sample OOD entities for inspection
89    pub sample_ood_entities: Vec<String>,
90}
91
92/// Vocabulary coverage statistics.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct VocabCoverageStats {
95    /// Training vocabulary size (unique n-grams)
96    pub train_vocab_size: usize,
97    /// Test vocabulary size (unique n-grams)
98    pub test_vocab_size: usize,
99    /// N-grams in test but not in train
100    pub unseen_ngrams: usize,
101    /// Coverage ratio (seen / total test ngrams)
102    pub coverage_ratio: f64,
103}
104
105/// OOD status for a single entity.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct OODStatus {
108    /// Entity text
109    pub text: String,
110    /// Whether entity is OOD
111    pub is_ood: bool,
112    /// OOD detection methods that flagged this entity
113    pub flagged_by: Vec<String>,
114    /// Vocabulary coverage score (0.0 = all unseen, 1.0 = all seen)
115    pub vocab_coverage: f64,
116    /// Model confidence (if available)
117    pub confidence: Option<f64>,
118}
119
120// =============================================================================
121// OOD Detector
122// =============================================================================
123
124/// Detector for out-of-distribution entities.
125#[derive(Debug, Clone)]
126pub struct OODDetector {
127    /// Configuration
128    config: OODConfig,
129    /// Training vocabulary (character n-grams)
130    train_ngrams: HashSet<String>,
131    /// N-gram frequencies in training data
132    ngram_frequencies: HashMap<String, usize>,
133    /// Known entity surface forms
134    known_entities: HashSet<String>,
135    /// Entity type distributions from training
136    type_distributions: HashMap<String, usize>,
137}
138
139impl OODDetector {
140    /// Create a new OOD detector with given configuration.
141    pub fn new(config: OODConfig) -> Self {
142        Self {
143            config,
144            train_ngrams: HashSet::new(),
145            ngram_frequencies: HashMap::new(),
146            known_entities: HashSet::new(),
147            type_distributions: HashMap::new(),
148        }
149    }
150
151    /// Fit the detector on training entity texts.
152    pub fn fit(mut self, training_entities: &[impl AsRef<str>]) -> Self {
153        for entity in training_entities {
154            let text = entity.as_ref();
155            self.known_entities.insert(text.to_lowercase());
156
157            // Extract and count n-grams
158            for ngram in self.extract_ngrams(text) {
159                self.train_ngrams.insert(ngram.clone());
160                *self.ngram_frequencies.entry(ngram).or_insert(0) += 1;
161            }
162        }
163        self
164    }
165
166    /// Fit with entity types for distribution tracking.
167    pub fn fit_with_types(mut self, training_data: &[(impl AsRef<str>, impl AsRef<str>)]) -> Self {
168        for (entity, entity_type) in training_data {
169            let text = entity.as_ref();
170            let etype = entity_type.as_ref();
171
172            self.known_entities.insert(text.to_lowercase());
173            *self
174                .type_distributions
175                .entry(etype.to_string())
176                .or_insert(0) += 1;
177
178            for ngram in self.extract_ngrams(text) {
179                self.train_ngrams.insert(ngram.clone());
180                *self.ngram_frequencies.entry(ngram).or_insert(0) += 1;
181            }
182        }
183        self
184    }
185
186    /// Check if a single entity is OOD.
187    pub fn is_ood(&self, entity_text: &str) -> bool {
188        self.check_ood(entity_text, None).is_ood
189    }
190
191    /// Check OOD status with detailed information.
192    pub fn check_ood(&self, entity_text: &str, confidence: Option<f64>) -> OODStatus {
193        let mut flagged_by = Vec::new();
194
195        // Check vocabulary coverage
196        let vocab_coverage = self.compute_vocab_coverage(entity_text);
197        if vocab_coverage < self.config.vocab_coverage_threshold {
198            flagged_by.push("low_vocab_coverage".to_string());
199        }
200
201        // Check exact match
202        if !self.known_entities.contains(&entity_text.to_lowercase()) {
203            // Only flag if also has low vocab coverage (unknown but similar = OK)
204            if vocab_coverage < 0.8 {
205                flagged_by.push("unseen_entity".to_string());
206            }
207        }
208
209        // Check confidence threshold
210        if let Some(conf) = confidence {
211            if conf < self.config.confidence_threshold {
212                flagged_by.push("low_confidence".to_string());
213            }
214        }
215
216        // Check for unusual character patterns
217        if self.has_unusual_characters(entity_text) {
218            flagged_by.push("unusual_characters".to_string());
219        }
220
221        OODStatus {
222            text: entity_text.to_string(),
223            is_ood: !flagged_by.is_empty(),
224            flagged_by,
225            vocab_coverage,
226            confidence,
227        }
228    }
229
230    /// Analyze OOD statistics for a test dataset.
231    pub fn analyze(&self, test_entities: &[(impl AsRef<str>, Option<f64>)]) -> OODAnalysisResults {
232        let mut ood_count = 0;
233        let mut by_method: HashMap<String, usize> = HashMap::new();
234        let mut ood_confidences = Vec::new();
235        let mut id_confidences = Vec::new();
236        let mut sample_ood = Vec::new();
237
238        let mut test_ngrams = HashSet::new();
239
240        for (entity, confidence) in test_entities {
241            let text = entity.as_ref();
242            let status = self.check_ood(text, *confidence);
243
244            // Collect test n-grams
245            for ngram in self.extract_ngrams(text) {
246                test_ngrams.insert(ngram);
247            }
248
249            if status.is_ood {
250                ood_count += 1;
251                for method in &status.flagged_by {
252                    *by_method.entry(method.clone()).or_insert(0) += 1;
253                }
254                if let Some(conf) = confidence {
255                    ood_confidences.push(*conf);
256                }
257                if sample_ood.len() < 10 {
258                    sample_ood.push(text.to_string());
259                }
260            } else if let Some(conf) = confidence {
261                id_confidences.push(*conf);
262            }
263        }
264
265        // Compute vocab stats
266        let unseen: usize = test_ngrams
267            .iter()
268            .filter(|ng| !self.train_ngrams.contains(*ng))
269            .count();
270
271        let coverage_ratio = if test_ngrams.is_empty() {
272            1.0
273        } else {
274            1.0 - (unseen as f64 / test_ngrams.len() as f64)
275        };
276
277        OODAnalysisResults {
278            total_entities: test_entities.len(),
279            ood_count,
280            ood_rate: if test_entities.is_empty() {
281                0.0
282            } else {
283                ood_count as f64 / test_entities.len() as f64
284            },
285            by_method,
286            avg_ood_confidence: if ood_confidences.is_empty() {
287                0.0
288            } else {
289                ood_confidences.iter().sum::<f64>() / ood_confidences.len() as f64
290            },
291            avg_id_confidence: if id_confidences.is_empty() {
292                0.0
293            } else {
294                id_confidences.iter().sum::<f64>() / id_confidences.len() as f64
295            },
296            vocab_stats: VocabCoverageStats {
297                train_vocab_size: self.train_ngrams.len(),
298                test_vocab_size: test_ngrams.len(),
299                unseen_ngrams: unseen,
300                coverage_ratio,
301            },
302            sample_ood_entities: sample_ood,
303        }
304    }
305
306    // --- Internal helpers ---
307
308    fn extract_ngrams(&self, text: &str) -> Vec<String> {
309        let chars: Vec<char> = text.to_lowercase().chars().collect();
310        let n = self.config.ngram_size;
311
312        if chars.len() < n {
313            return vec![chars.iter().collect()];
314        }
315
316        (0..=chars.len() - n)
317            .map(|i| chars[i..i + n].iter().collect())
318            .collect()
319    }
320
321    fn compute_vocab_coverage(&self, text: &str) -> f64 {
322        let ngrams = self.extract_ngrams(text);
323        if ngrams.is_empty() {
324            return 1.0;
325        }
326
327        let seen = ngrams
328            .iter()
329            .filter(|ng| self.train_ngrams.contains(*ng))
330            .count();
331
332        seen as f64 / ngrams.len() as f64
333    }
334
335    fn has_unusual_characters(&self, text: &str) -> bool {
336        // Check for characters that might indicate non-standard text
337        let unusual_count = text
338            .chars()
339            .filter(|c| {
340                // Flag zero-width chars, unusual Unicode, etc.
341                matches!(c, '\u{200B}'..='\u{200F}' | '\u{FEFF}' | '\u{2060}')
342            })
343            .count();
344
345        unusual_count > 0
346    }
347}
348
349impl Default for OODDetector {
350    fn default() -> Self {
351        Self::new(OODConfig::default())
352    }
353}
354
355// =============================================================================
356// Utility Functions
357// =============================================================================
358
359/// Grade OOD rate for interpretability.
360pub fn ood_rate_grade(rate: f64) -> &'static str {
361    if rate < 0.05 {
362        "Very low OOD (well-covered domain)"
363    } else if rate < 0.15 {
364        "Low OOD (mostly covered)"
365    } else if rate < 0.30 {
366        "Moderate OOD (some gaps)"
367    } else if rate < 0.50 {
368        "High OOD (significant gaps)"
369    } else {
370        "Very high OOD (major domain shift)"
371    }
372}
373
374// =============================================================================
375// Tests
376// =============================================================================
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn test_basic_ood_detection() {
384        let training = vec!["John Smith", "Jane Doe", "Google", "Microsoft"];
385        let detector = OODDetector::default().fit(&training);
386
387        // Known entity should not be OOD
388        assert!(!detector.is_ood("John Smith"));
389
390        // Similar entity should have high coverage
391        let status = detector.check_ood("John Doe", None);
392        assert!(status.vocab_coverage > 0.5);
393    }
394
395    #[test]
396    fn test_unusual_characters() {
397        let detector = OODDetector::default();
398
399        // Normal text
400        let status = detector.check_ood("John Smith", None);
401        assert!(!status
402            .flagged_by
403            .contains(&"unusual_characters".to_string()));
404
405        // Text with zero-width space
406        let status = detector.check_ood("John\u{200B}Smith", None);
407        assert!(status
408            .flagged_by
409            .contains(&"unusual_characters".to_string()));
410    }
411
412    #[test]
413    fn test_vocab_coverage() {
414        let training = vec!["apple", "banana", "orange"];
415        let detector = OODDetector::default().fit(&training);
416
417        // Similar text should have good coverage
418        let status = detector.check_ood("apple", None);
419        assert!(status.vocab_coverage > 0.9);
420
421        // Very different text should have low coverage
422        let status = detector.check_ood("xyz123", None);
423        assert!(status.vocab_coverage < 0.5);
424    }
425
426    #[test]
427    fn test_analyze_dataset() {
428        let training = vec!["John Smith", "Jane Doe"];
429        let detector = OODDetector::default().fit(&training);
430
431        let test_data: Vec<(&str, Option<f64>)> = vec![
432            ("John Smith", Some(0.9)),    // In-distribution
433            ("Xiangjun Chen", Some(0.3)), // OOD
434        ];
435
436        let results = detector.analyze(&test_data);
437        assert_eq!(results.total_entities, 2);
438        assert!(results.ood_count >= 1);
439    }
440
441    #[test]
442    fn test_confidence_threshold() {
443        let detector = OODDetector::new(OODConfig {
444            confidence_threshold: 0.7,
445            ..Default::default()
446        });
447
448        // Low confidence should flag OOD
449        let status = detector.check_ood("test", Some(0.5));
450        assert!(status.flagged_by.contains(&"low_confidence".to_string()));
451
452        // High confidence should not flag
453        let status = detector.check_ood("test", Some(0.9));
454        assert!(!status.flagged_by.contains(&"low_confidence".to_string()));
455    }
456
457    #[test]
458    fn test_ood_rate_grades() {
459        assert_eq!(ood_rate_grade(0.02), "Very low OOD (well-covered domain)");
460        assert_eq!(ood_rate_grade(0.10), "Low OOD (mostly covered)");
461        assert_eq!(ood_rate_grade(0.25), "Moderate OOD (some gaps)");
462        assert_eq!(ood_rate_grade(0.40), "High OOD (significant gaps)");
463        assert_eq!(ood_rate_grade(0.60), "Very high OOD (major domain shift)");
464    }
465}