Skip to main content

anno_eval/eval/
drift.rs

1//! Temporal drift detection for production NER monitoring.
2//!
3//! Detects when model performance degrades over time due to:
4//! - Distribution shift in input text
5//! - New entity patterns not in training
6//! - Concept drift (entity meanings change)
7//!
8//! # Key Metrics
9//!
10//! - **Performance Drift**: F1 change over time windows
11//! - **Vocabulary Drift**: New tokens appearing in production
12//! - **Confidence Drift**: Model confidence distribution changes
13//! - **Entity Distribution Drift**: Entity type frequencies changing
14//!
15//! # Example
16//!
17//! ```rust
18//! use anno_eval::eval::drift::{DriftDetector, DriftWindow, DriftConfig};
19//!
20//! let mut detector = DriftDetector::new(DriftConfig::default());
21//!
22//! // Log predictions over time
23//! detector.log_prediction(1000, 0.85, "PER", "John Smith");
24//! detector.log_prediction(1001, 0.90, "ORG", "Google");
25//!
26//! // Check for drift
27//! let report = detector.analyze();
28//! if report.drift_detected {
29//!     println!("Warning: Drift detected! {}", report.summary);
30//! }
31//! ```
32
33use serde::{Deserialize, Serialize};
34use std::collections::{HashMap, VecDeque};
35
36// =============================================================================
37// Configuration
38// =============================================================================
39
40/// Configuration for drift detection.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DriftConfig {
43    /// Window size for computing rolling metrics (in number of predictions)
44    pub window_size: usize,
45    /// Number of windows to compare (current vs baseline)
46    pub num_windows: usize,
47    /// Threshold for confidence drift (change in mean confidence)
48    pub confidence_drift_threshold: f64,
49    /// Threshold for distribution drift (KL divergence)
50    pub distribution_drift_threshold: f64,
51    /// Threshold for vocabulary drift (proportion of new tokens)
52    pub vocab_drift_threshold: f64,
53    /// Minimum samples before drift detection activates
54    pub min_samples: usize,
55}
56
57impl Default for DriftConfig {
58    fn default() -> Self {
59        Self {
60            window_size: 1000,
61            num_windows: 5,
62            confidence_drift_threshold: 0.1,
63            distribution_drift_threshold: 0.5,
64            vocab_drift_threshold: 0.2,
65            min_samples: 500,
66        }
67    }
68}
69
70// =============================================================================
71// Data Structures
72// =============================================================================
73
74/// A single logged prediction.
75#[derive(Debug, Clone)]
76struct PredictionLog {
77    /// Timestamp for future time-based windowing
78    #[allow(dead_code)]
79    timestamp: u64,
80    confidence: f64,
81    entity_type: String,
82    entity_text: String,
83}
84
85/// Metrics for a single time window.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct DriftWindow {
88    /// Window identifier (timestamp or index)
89    pub window_id: usize,
90    /// Mean confidence in this window
91    pub mean_confidence: f64,
92    /// Standard deviation of confidence
93    pub std_confidence: f64,
94    /// Entity type distribution
95    pub type_distribution: HashMap<String, f64>,
96    /// Number of predictions in window
97    pub count: usize,
98    /// Unique tokens seen
99    pub unique_tokens: usize,
100}
101
102/// Drift analysis report.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct DriftReport {
105    /// Whether significant drift was detected
106    pub drift_detected: bool,
107    /// Summary message
108    pub summary: String,
109    /// Confidence drift details
110    pub confidence_drift: ConfidenceDrift,
111    /// Distribution drift details
112    pub distribution_drift: DistributionDrift,
113    /// Vocabulary drift details
114    pub vocabulary_drift: VocabularyDrift,
115    /// Window-by-window metrics
116    pub windows: Vec<DriftWindow>,
117    /// Recommendations
118    pub recommendations: Vec<String>,
119}
120
121/// Confidence drift analysis.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ConfidenceDrift {
124    /// Baseline mean confidence
125    pub baseline_mean: f64,
126    /// Current mean confidence
127    pub current_mean: f64,
128    /// Change in mean confidence
129    pub drift_amount: f64,
130    /// Is drift significant?
131    pub is_significant: bool,
132}
133
134/// Distribution drift analysis.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct DistributionDrift {
137    /// KL divergence from baseline to current
138    pub kl_divergence: f64,
139    /// Types that increased in frequency
140    pub increased_types: Vec<(String, f64)>,
141    /// Types that decreased in frequency
142    pub decreased_types: Vec<(String, f64)>,
143    /// New types not in baseline
144    pub new_types: Vec<String>,
145    /// Is drift significant?
146    pub is_significant: bool,
147}
148
149/// Vocabulary drift analysis.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct VocabularyDrift {
152    /// Baseline vocabulary size
153    pub baseline_vocab_size: usize,
154    /// Current vocabulary size
155    pub current_vocab_size: usize,
156    /// Proportion of new tokens
157    pub new_token_rate: f64,
158    /// Is drift significant?
159    pub is_significant: bool,
160}
161
162// =============================================================================
163// Drift Detector
164// =============================================================================
165
166/// Detector for temporal drift in NER predictions.
167#[derive(Debug, Clone)]
168pub struct DriftDetector {
169    /// Configuration
170    config: DriftConfig,
171    /// Logged predictions (ring buffer)
172    predictions: VecDeque<PredictionLog>,
173    /// Baseline vocabulary
174    baseline_vocab: HashMap<String, usize>,
175    /// Current vocabulary
176    current_vocab: HashMap<String, usize>,
177    /// Whether baseline has been established
178    baseline_established: bool,
179}
180
181impl DriftDetector {
182    /// Create a new drift detector.
183    pub fn new(config: DriftConfig) -> Self {
184        let max_size = config.window_size * config.num_windows;
185        Self {
186            config,
187            predictions: VecDeque::with_capacity(max_size),
188            baseline_vocab: HashMap::new(),
189            current_vocab: HashMap::new(),
190            baseline_established: false,
191        }
192    }
193
194    /// Log a prediction for drift analysis.
195    pub fn log_prediction(
196        &mut self,
197        timestamp: u64,
198        confidence: f64,
199        entity_type: &str,
200        entity_text: &str,
201    ) {
202        let log = PredictionLog {
203            timestamp,
204            confidence,
205            entity_type: entity_type.to_string(),
206            entity_text: entity_text.to_string(),
207        };
208
209        // Track vocabulary
210        for token in entity_text.split_whitespace() {
211            let lower = token.to_lowercase();
212            *self.current_vocab.entry(lower).or_insert(0) += 1;
213        }
214
215        // Add to ring buffer
216        let max_size = self.config.window_size * self.config.num_windows;
217        if self.predictions.len() >= max_size {
218            self.predictions.pop_front();
219        }
220        self.predictions.push_back(log);
221
222        // Establish baseline after minimum samples
223        if !self.baseline_established && self.predictions.len() >= self.config.min_samples {
224            self.establish_baseline();
225        }
226    }
227
228    /// Establish baseline from current predictions.
229    fn establish_baseline(&mut self) {
230        self.baseline_vocab = self.current_vocab.clone();
231        self.baseline_established = true;
232    }
233
234    /// Reset the detector.
235    pub fn reset(&mut self) {
236        self.predictions.clear();
237        self.baseline_vocab.clear();
238        self.current_vocab.clear();
239        self.baseline_established = false;
240    }
241
242    /// Analyze for drift.
243    pub fn analyze(&self) -> DriftReport {
244        if self.predictions.len() < self.config.min_samples {
245            return DriftReport {
246                drift_detected: false,
247                summary: format!(
248                    "Insufficient data: {} predictions (need {})",
249                    self.predictions.len(),
250                    self.config.min_samples
251                ),
252                confidence_drift: ConfidenceDrift {
253                    baseline_mean: 0.0,
254                    current_mean: 0.0,
255                    drift_amount: 0.0,
256                    is_significant: false,
257                },
258                distribution_drift: DistributionDrift {
259                    kl_divergence: 0.0,
260                    increased_types: Vec::new(),
261                    decreased_types: Vec::new(),
262                    new_types: Vec::new(),
263                    is_significant: false,
264                },
265                vocabulary_drift: VocabularyDrift {
266                    baseline_vocab_size: 0,
267                    current_vocab_size: 0,
268                    new_token_rate: 0.0,
269                    is_significant: false,
270                },
271                windows: Vec::new(),
272                recommendations: vec!["Collect more data for drift analysis".into()],
273            };
274        }
275
276        // Split predictions into windows
277        let windows = self.compute_windows();
278
279        // Analyze confidence drift
280        let confidence_drift = self.analyze_confidence_drift(&windows);
281
282        // Analyze distribution drift
283        let distribution_drift = self.analyze_distribution_drift(&windows);
284
285        // Analyze vocabulary drift
286        let vocabulary_drift = self.analyze_vocabulary_drift();
287
288        // Determine if drift detected
289        let drift_detected = confidence_drift.is_significant
290            || distribution_drift.is_significant
291            || vocabulary_drift.is_significant;
292
293        // Generate summary and recommendations
294        let (summary, recommendations) = self.generate_summary_and_recommendations(
295            drift_detected,
296            &confidence_drift,
297            &distribution_drift,
298            &vocabulary_drift,
299        );
300
301        DriftReport {
302            drift_detected,
303            summary,
304            confidence_drift,
305            distribution_drift,
306            vocabulary_drift,
307            windows,
308            recommendations,
309        }
310    }
311
312    fn compute_windows(&self) -> Vec<DriftWindow> {
313        let predictions: Vec<_> = self.predictions.iter().collect();
314        let window_size = self.config.window_size.min(predictions.len());
315
316        if window_size == 0 {
317            return Vec::new();
318        }
319
320        let num_windows = (predictions.len() / window_size).min(self.config.num_windows);
321        let mut windows = Vec::new();
322
323        for i in 0..num_windows {
324            let start = predictions.len() - (num_windows - i) * window_size;
325            let end = start + window_size;
326            let window_preds = &predictions[start..end];
327
328            // Compute metrics
329            let confidences: Vec<f64> = window_preds.iter().map(|p| p.confidence).collect();
330            let mean_conf = confidences.iter().sum::<f64>() / confidences.len() as f64;
331            let std_conf = (confidences
332                .iter()
333                .map(|c| (c - mean_conf).powi(2))
334                .sum::<f64>()
335                / confidences.len() as f64)
336                .sqrt();
337
338            // Type distribution
339            let mut type_counts: HashMap<String, usize> = HashMap::new();
340            let mut unique_tokens = std::collections::HashSet::new();
341
342            for pred in window_preds {
343                *type_counts.entry(pred.entity_type.clone()).or_insert(0) += 1;
344                for token in pred.entity_text.split_whitespace() {
345                    unique_tokens.insert(token.to_lowercase());
346                }
347            }
348
349            let total = window_preds.len() as f64;
350            let type_distribution: HashMap<String, f64> = type_counts
351                .iter()
352                .map(|(t, c)| (t.clone(), *c as f64 / total))
353                .collect();
354
355            windows.push(DriftWindow {
356                window_id: i,
357                mean_confidence: mean_conf,
358                std_confidence: std_conf,
359                type_distribution,
360                count: window_preds.len(),
361                unique_tokens: unique_tokens.len(),
362            });
363        }
364
365        windows
366    }
367
368    fn analyze_confidence_drift(&self, windows: &[DriftWindow]) -> ConfidenceDrift {
369        if windows.len() < 2 {
370            return ConfidenceDrift {
371                baseline_mean: 0.0,
372                current_mean: 0.0,
373                drift_amount: 0.0,
374                is_significant: false,
375            };
376        }
377
378        let baseline_mean = windows[0].mean_confidence;
379        let current_mean = windows.last().map(|w| w.mean_confidence).unwrap_or(0.0);
380        let drift_amount = current_mean - baseline_mean;
381        let is_significant = drift_amount.abs() > self.config.confidence_drift_threshold;
382
383        ConfidenceDrift {
384            baseline_mean,
385            current_mean,
386            drift_amount,
387            is_significant,
388        }
389    }
390
391    fn analyze_distribution_drift(&self, windows: &[DriftWindow]) -> DistributionDrift {
392        if windows.len() < 2 {
393            return DistributionDrift {
394                kl_divergence: 0.0,
395                increased_types: Vec::new(),
396                decreased_types: Vec::new(),
397                new_types: Vec::new(),
398                is_significant: false,
399            };
400        }
401
402        let baseline = &windows[0].type_distribution;
403        // Safety: we checked windows.len() >= 2 above
404        let current = &windows[windows.len() - 1].type_distribution;
405
406        // Compute KL divergence
407        let epsilon = 1e-10;
408        let mut kl_div = 0.0;
409
410        for (typ, p) in current {
411            let q = baseline.get(typ).copied().unwrap_or(epsilon);
412            kl_div += p * (p / q).ln();
413        }
414
415        // Find changed types
416        let mut increased = Vec::new();
417        let mut decreased = Vec::new();
418        let mut new_types = Vec::new();
419
420        for (typ, curr_freq) in current {
421            if let Some(base_freq) = baseline.get(typ) {
422                let change = curr_freq - base_freq;
423                if change > 0.05 {
424                    increased.push((typ.clone(), change));
425                } else if change < -0.05 {
426                    decreased.push((typ.clone(), change));
427                }
428            } else {
429                new_types.push(typ.clone());
430            }
431        }
432
433        increased.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
434        decreased.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
435
436        let is_significant =
437            kl_div > self.config.distribution_drift_threshold || !new_types.is_empty();
438
439        DistributionDrift {
440            kl_divergence: kl_div,
441            increased_types: increased,
442            decreased_types: decreased,
443            new_types,
444            is_significant,
445        }
446    }
447
448    fn analyze_vocabulary_drift(&self) -> VocabularyDrift {
449        if !self.baseline_established {
450            return VocabularyDrift {
451                baseline_vocab_size: 0,
452                current_vocab_size: self.current_vocab.len(),
453                new_token_rate: 0.0,
454                is_significant: false,
455            };
456        }
457
458        let baseline_size = self.baseline_vocab.len();
459        let current_size = self.current_vocab.len();
460
461        let new_tokens: usize = self
462            .current_vocab
463            .keys()
464            .filter(|t| !self.baseline_vocab.contains_key(*t))
465            .count();
466
467        let new_token_rate = if current_size == 0 {
468            0.0
469        } else {
470            new_tokens as f64 / current_size as f64
471        };
472
473        let is_significant = new_token_rate > self.config.vocab_drift_threshold;
474
475        VocabularyDrift {
476            baseline_vocab_size: baseline_size,
477            current_vocab_size: current_size,
478            new_token_rate,
479            is_significant,
480        }
481    }
482
483    fn generate_summary_and_recommendations(
484        &self,
485        drift_detected: bool,
486        confidence: &ConfidenceDrift,
487        distribution: &DistributionDrift,
488        vocabulary: &VocabularyDrift,
489    ) -> (String, Vec<String>) {
490        let mut issues = Vec::new();
491        let mut recommendations = Vec::new();
492
493        if confidence.is_significant {
494            if confidence.drift_amount < 0.0 {
495                issues.push(format!(
496                    "Confidence dropped by {:.1}%",
497                    confidence.drift_amount.abs() * 100.0
498                ));
499                recommendations
500                    .push("Model may be encountering harder examples - consider retraining".into());
501            } else {
502                issues.push(format!(
503                    "Confidence increased by {:.1}%",
504                    confidence.drift_amount * 100.0
505                ));
506                recommendations
507                    .push("Verify model isn't becoming overconfident on new patterns".into());
508            }
509        }
510
511        if distribution.is_significant {
512            issues.push(format!(
513                "Entity type distribution shifted (KL={:.2})",
514                distribution.kl_divergence
515            ));
516            if !distribution.new_types.is_empty() {
517                recommendations.push(format!(
518                    "New entity types detected: {:?} - update training data",
519                    distribution.new_types
520                ));
521            }
522        }
523
524        if vocabulary.is_significant {
525            issues.push(format!(
526                "{:.1}% new vocabulary",
527                vocabulary.new_token_rate * 100.0
528            ));
529            recommendations
530                .push("Significant vocabulary shift - consider domain adaptation".into());
531        }
532
533        let summary = if drift_detected {
534            format!("Drift detected: {}", issues.join("; "))
535        } else {
536            "No significant drift detected".into()
537        };
538
539        if recommendations.is_empty() {
540            recommendations.push("Continue monitoring".into());
541        }
542
543        (summary, recommendations)
544    }
545}
546
547impl Default for DriftDetector {
548    fn default() -> Self {
549        Self::new(DriftConfig::default())
550    }
551}
552
553// =============================================================================
554// Tests
555// =============================================================================
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_insufficient_data() {
563        let detector = DriftDetector::default();
564        let report = detector.analyze();
565
566        assert!(!report.drift_detected);
567        assert!(report.summary.contains("Insufficient"));
568    }
569
570    #[test]
571    fn test_no_drift() {
572        let mut detector = DriftDetector::new(DriftConfig {
573            min_samples: 10,
574            window_size: 5,
575            num_windows: 2,
576            ..Default::default()
577        });
578
579        // Log consistent predictions
580        for i in 0..20 {
581            detector.log_prediction(i as u64, 0.90, "PER", "John Smith");
582        }
583
584        let report = detector.analyze();
585        // With consistent data, drift should be minimal
586        assert!(!report.confidence_drift.is_significant);
587    }
588
589    #[test]
590    fn test_confidence_drift_detection() {
591        let mut detector = DriftDetector::new(DriftConfig {
592            min_samples: 10,
593            window_size: 10,
594            num_windows: 2,
595            confidence_drift_threshold: 0.1,
596            ..Default::default()
597        });
598
599        // First window: high confidence
600        for i in 0..10 {
601            detector.log_prediction(i as u64, 0.95, "PER", "John");
602        }
603
604        // Second window: low confidence
605        for i in 10..20 {
606            detector.log_prediction(i as u64, 0.60, "PER", "John");
607        }
608
609        let report = detector.analyze();
610        assert!(report.confidence_drift.is_significant);
611        assert!(report.confidence_drift.drift_amount < 0.0);
612    }
613
614    #[test]
615    fn test_vocabulary_drift() {
616        let mut detector = DriftDetector::new(DriftConfig {
617            min_samples: 5,
618            window_size: 5,
619            num_windows: 2,
620            vocab_drift_threshold: 0.3,
621            ..Default::default()
622        });
623
624        // Establish baseline with common words
625        for i in 0..5 {
626            detector.log_prediction(i as u64, 0.90, "PER", "John Smith");
627        }
628
629        // Add predictions with completely new vocabulary
630        for i in 5..10 {
631            detector.log_prediction(i as u64, 0.90, "PER", "Xiangjun Chen Zhang Wei");
632        }
633
634        let report = detector.analyze();
635        assert!(report.vocabulary_drift.new_token_rate > 0.0);
636    }
637
638    #[test]
639    fn test_reset() {
640        let mut detector = DriftDetector::default();
641        detector.log_prediction(0, 0.9, "PER", "Test");
642        detector.reset();
643
644        let report = detector.analyze();
645        assert!(report.summary.contains("Insufficient"));
646    }
647}