1use serde::{Deserialize, Serialize};
34use std::collections::{HashMap, VecDeque};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DriftConfig {
43 pub window_size: usize,
45 pub num_windows: usize,
47 pub confidence_drift_threshold: f64,
49 pub distribution_drift_threshold: f64,
51 pub vocab_drift_threshold: f64,
53 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#[derive(Debug, Clone)]
76struct PredictionLog {
77 #[allow(dead_code)]
79 timestamp: u64,
80 confidence: f64,
81 entity_type: String,
82 entity_text: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct DriftWindow {
88 pub window_id: usize,
90 pub mean_confidence: f64,
92 pub std_confidence: f64,
94 pub type_distribution: HashMap<String, f64>,
96 pub count: usize,
98 pub unique_tokens: usize,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct DriftReport {
105 pub drift_detected: bool,
107 pub summary: String,
109 pub confidence_drift: ConfidenceDrift,
111 pub distribution_drift: DistributionDrift,
113 pub vocabulary_drift: VocabularyDrift,
115 pub windows: Vec<DriftWindow>,
117 pub recommendations: Vec<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ConfidenceDrift {
124 pub baseline_mean: f64,
126 pub current_mean: f64,
128 pub drift_amount: f64,
130 pub is_significant: bool,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct DistributionDrift {
137 pub kl_divergence: f64,
139 pub increased_types: Vec<(String, f64)>,
141 pub decreased_types: Vec<(String, f64)>,
143 pub new_types: Vec<String>,
145 pub is_significant: bool,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct VocabularyDrift {
152 pub baseline_vocab_size: usize,
154 pub current_vocab_size: usize,
156 pub new_token_rate: f64,
158 pub is_significant: bool,
160}
161
162#[derive(Debug, Clone)]
168pub struct DriftDetector {
169 config: DriftConfig,
171 predictions: VecDeque<PredictionLog>,
173 baseline_vocab: HashMap<String, usize>,
175 current_vocab: HashMap<String, usize>,
177 baseline_established: bool,
179}
180
181impl DriftDetector {
182 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 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 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 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 if !self.baseline_established && self.predictions.len() >= self.config.min_samples {
224 self.establish_baseline();
225 }
226 }
227
228 fn establish_baseline(&mut self) {
230 self.baseline_vocab = self.current_vocab.clone();
231 self.baseline_established = true;
232 }
233
234 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 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 let windows = self.compute_windows();
278
279 let confidence_drift = self.analyze_confidence_drift(&windows);
281
282 let distribution_drift = self.analyze_distribution_drift(&windows);
284
285 let vocabulary_drift = self.analyze_vocabulary_drift();
287
288 let drift_detected = confidence_drift.is_significant
290 || distribution_drift.is_significant
291 || vocabulary_drift.is_significant;
292
293 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 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 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 let current = &windows[windows.len() - 1].type_distribution;
405
406 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 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#[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 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 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 for i in 0..10 {
601 detector.log_prediction(i as u64, 0.95, "PER", "John");
602 }
603
604 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 for i in 0..5 {
626 detector.log_prediction(i as u64, 0.90, "PER", "John Smith");
627 }
628
629 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}