1use super::drift_detection::{
24 DistributionComparator, DistributionComparison, DriftTestResult, ModelBasedDetector,
25 ModelDriftResult, StatisticalTest,
26};
27use super::optimizer::StreamingDataPoint;
28use super::statistics as stats;
29
30use scirs2_core::numeric::Float;
31use std::collections::{HashMap, VecDeque};
32
33fn to_f64<A: Float>(value: A) -> Result<f64, String> {
36 value
37 .to_f64()
38 .ok_or_else(|| "value cannot be represented as f64".to_string())
39}
40
41fn from_f64<A: Float>(value: f64) -> Result<A, String> {
43 A::from(value).ok_or_else(|| format!("{value} cannot be represented in the element type"))
44}
45
46fn finite_f64<A: Float>(values: &[A]) -> Vec<f64> {
48 values
49 .iter()
50 .filter_map(|v| v.to_f64())
51 .filter(|v| v.is_finite())
52 .collect()
53}
54
55const MAX_DETECTOR_WINDOW: usize = 4096;
57
58const HISTOGRAM_BINS: usize = 16;
60
61const HISTOGRAM_SMOOTHING: f64 = 0.5;
63
64pub struct AdwinTest<A: Float + Send + Sync> {
84 delta: f64,
86 significance_level: f64,
88 window: VecDeque<A>,
90 min_sub_window: usize,
92}
93
94impl<A: Float + Send + Sync> AdwinTest<A> {
95 pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
98 if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
99 return Err(format!(
100 "ADWIN delta must lie strictly in (0, 1), got {sensitivity}"
101 ));
102 }
103 Ok(Self {
104 delta: sensitivity,
105 significance_level,
106 window: VecDeque::with_capacity(256),
107 min_sub_window: 5,
108 })
109 }
110}
111
112impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
113 for AdwinTest<A>
114{
115 fn test_for_drift(
116 &mut self,
117 _reference: &[A],
118 current: &[A],
119 ) -> Result<DriftTestResult<A>, String> {
120 if current.is_empty() {
121 return Err("ADWIN: empty observation batch".to_string());
122 }
123
124 for &value in current {
125 if self.window.len() >= MAX_DETECTOR_WINDOW {
126 self.window.pop_front();
127 }
128 self.window.push_back(value);
129 }
130
131 let observations: Vec<f64> = finite_f64(self.window.make_contiguous());
132 let n = observations.len();
133 if n < 2 * self.min_sub_window {
134 return insignificant_result(0.0, HashMap::new());
135 }
136
137 let (min, max) = stats::finite_range(&observations)
138 .ok_or_else(|| "ADWIN: window contains no finite observations".to_string())?;
139 let range = (max - min).max(f64::MIN_POSITIVE);
141
142 let mut prefix = Vec::with_capacity(n + 1);
144 prefix.push(0.0_f64);
145 for &value in &observations {
146 let last = prefix[prefix.len() - 1];
147 prefix.push(last + value);
148 }
149 let total = prefix[n];
150
151 let ln_term = (4.0 / self.delta).ln();
152 let mut best_excess = f64::NEG_INFINITY;
153 let mut best_diff = 0.0_f64;
154 let mut best_split = 0usize;
155 let mut best_eps = 0.0_f64;
156 let mut best_m = 0.0_f64;
157
158 let first_split = self.min_sub_window;
159 let last_split = n - self.min_sub_window;
160 for (offset, &prefix_sum) in prefix[first_split..=last_split].iter().enumerate() {
161 let split = first_split + offset;
162 let n0 = split as f64;
163 let n1 = (n - split) as f64;
164 let mean0 = prefix_sum / n0;
165 let mean1 = (total - prefix_sum) / n1;
166 let diff = (mean0 - mean1).abs();
167
168 let harmonic_m = 1.0 / (1.0 / n0 + 1.0 / n1);
169 let eps_cut = range * (ln_term / (2.0 * harmonic_m)).sqrt();
170 let excess = diff - eps_cut;
171
172 if excess > best_excess {
173 best_excess = excess;
174 best_diff = diff;
175 best_split = split;
176 best_eps = eps_cut;
177 best_m = harmonic_m;
178 }
179 }
180
181 let normalised = best_diff / range;
185 let p_value = (4.0 * (-2.0 * best_m * normalised * normalised).exp()).clamp(0.0, 1.0);
186
187 let cut_found = best_excess > 0.0;
188 if cut_found {
189 for _ in 0..best_split {
191 self.window.pop_front();
192 }
193 }
194
195 let mut metadata = HashMap::new();
196 metadata.insert("split_point".to_string(), from_f64::<A>(best_split as f64)?);
197 metadata.insert("window_size".to_string(), from_f64::<A>(n as f64)?);
198 metadata.insert("epsilon_cut".to_string(), from_f64::<A>(best_eps)?);
199 metadata.insert("observed_range".to_string(), from_f64::<A>(range)?);
200
201 Ok(DriftTestResult {
202 drift_detected: cut_found || p_value < self.significance_level,
203 p_value: from_f64(p_value)?,
204 test_statistic: from_f64(best_diff)?,
205 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
206 metadata,
207 })
208 }
209
210 fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
211 let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
215 let scale = (1.0 + 0.1 * feedback).clamp(0.5, 2.0);
216 self.delta = (self.delta * scale).clamp(1e-8, 0.5);
217 Ok(())
218 }
219
220 fn reset(&mut self) {
221 self.window.clear();
222 }
223}
224
225#[derive(Debug, Clone, Copy)]
241struct ReferenceBaseline {
242 mean: f64,
243 std_dev: f64,
244}
245
246impl ReferenceBaseline {
247 fn from_sample<A: Float>(reference: &[A]) -> Result<Self, String> {
248 let sample = finite_f64(reference);
249 if sample.len() < 2 {
250 return Err(
251 "error-stream baseline requires at least two reference observations".to_string(),
252 );
253 }
254 let mean = stats::mean(&sample).ok_or_else(|| "reference mean is undefined".to_string())?;
255 let std_dev = stats::sample_std_dev(&sample)
256 .ok_or_else(|| "reference standard deviation is undefined".to_string())?;
257 Ok(Self {
258 mean,
259 std_dev: std_dev.max(f64::MIN_POSITIVE),
260 })
261 }
262
263 fn is_error(&self, value: f64) -> bool {
264 (value - self.mean).abs() > self.std_dev
265 }
266}
267
268pub struct DdmTest<A: Float + Send + Sync> {
282 significance_level: f64,
283 warning_level: f64,
284 drift_level: f64,
285 min_instances: usize,
286 instances: usize,
287 errors: usize,
288 p_min: f64,
289 s_min: f64,
290 warning_active: bool,
291 _marker: std::marker::PhantomData<A>,
292}
293
294impl<A: Float + Send + Sync> DdmTest<A> {
295 pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
299 if !(sensitivity.is_finite() && sensitivity > 0.0) {
300 return Err(format!(
301 "DDM sensitivity must be positive, got {sensitivity}"
302 ));
303 }
304 let scale = (0.5 + sensitivity).clamp(0.5, 1.5);
308 Ok(Self {
309 significance_level,
310 warning_level: 2.0 * scale,
311 drift_level: 3.0 * scale,
312 min_instances: 30,
313 instances: 0,
314 errors: 0,
315 p_min: f64::INFINITY,
316 s_min: f64::INFINITY,
317 warning_active: false,
318 _marker: std::marker::PhantomData,
319 })
320 }
321
322 pub fn is_warning(&self) -> bool {
325 self.warning_active
326 }
327}
328
329impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for DdmTest<A> {
330 fn test_for_drift(
331 &mut self,
332 reference: &[A],
333 current: &[A],
334 ) -> Result<DriftTestResult<A>, String> {
335 if current.is_empty() {
336 return Err("DDM: empty observation batch".to_string());
337 }
338 let baseline = ReferenceBaseline::from_sample(reference)?;
339
340 for value in finite_f64(current) {
341 self.instances += 1;
342 if baseline.is_error(value) {
343 self.errors += 1;
344 }
345
346 let n = self.instances as f64;
347 let p = self.errors as f64 / n;
348 let s = (p * (1.0 - p) / n).sqrt();
349
350 if p + s < self.p_min + self.s_min {
351 self.p_min = p;
352 self.s_min = s;
353 }
354 }
355
356 let n = self.instances as f64;
357 let p = self.errors as f64 / n;
358 let s = (p * (1.0 - p) / n).sqrt();
359
360 let s_min = if self.s_min.is_finite() && self.s_min > 0.0 {
362 self.s_min
363 } else {
364 (p * (1.0 - p) / n).sqrt().max(f64::MIN_POSITIVE)
365 };
366 let p_min = if self.p_min.is_finite() {
367 self.p_min
368 } else {
369 p
370 };
371
372 let z = ((p + s) - p_min) / s_min;
373 let p_value = stats::standard_normal_sf(z)?;
374
375 let enough_data = self.instances >= self.min_instances;
376 self.warning_active = enough_data && z >= self.warning_level && z < self.drift_level;
377 let drift_detected =
378 enough_data && (z >= self.drift_level || p_value < self.significance_level);
379
380 if drift_detected {
381 self.instances = 0;
384 self.errors = 0;
385 self.p_min = f64::INFINITY;
386 self.s_min = f64::INFINITY;
387 self.warning_active = false;
388 }
389
390 let mut metadata = HashMap::new();
391 metadata.insert("error_rate".to_string(), from_f64::<A>(p)?);
392 metadata.insert("p_min".to_string(), from_f64::<A>(p_min)?);
393 metadata.insert("s_min".to_string(), from_f64::<A>(s_min)?);
394 metadata.insert(
395 "warning_level".to_string(),
396 from_f64::<A>(self.warning_level)?,
397 );
398
399 Ok(DriftTestResult {
400 drift_detected,
401 p_value: from_f64(p_value)?,
402 test_statistic: from_f64(z)?,
403 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
404 metadata,
405 })
406 }
407
408 fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
409 let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
410 self.drift_level = (self.drift_level - 0.2 * feedback).clamp(1.5, 6.0);
412 self.warning_level = self.warning_level.min(self.drift_level - 0.25).max(1.0);
413 Ok(())
414 }
415
416 fn reset(&mut self) {
417 self.instances = 0;
418 self.errors = 0;
419 self.p_min = f64::INFINITY;
420 self.s_min = f64::INFINITY;
421 self.warning_active = false;
422 }
423}
424
425pub struct EddmTest<A: Float + Send + Sync> {
438 significance_level: f64,
439 warning_ratio: f64,
440 drift_ratio: f64,
441 min_errors: usize,
442 since_last_error: usize,
444 error_count: usize,
446 mean_distance: f64,
447 m2_distance: f64,
448 max_criterion: f64,
449 warning_active: bool,
450 _marker: std::marker::PhantomData<A>,
451}
452
453impl<A: Float + Send + Sync> EddmTest<A> {
454 pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
457 if !(sensitivity.is_finite() && sensitivity > 0.0) {
458 return Err(format!(
459 "EDDM sensitivity must be positive, got {sensitivity}"
460 ));
461 }
462 let shift = (sensitivity * 0.1).clamp(0.0, 0.05);
465 Ok(Self {
466 significance_level,
467 warning_ratio: 0.95 + shift,
468 drift_ratio: 0.90 + shift,
469 min_errors: 30,
470 since_last_error: 0,
471 error_count: 0,
472 mean_distance: 0.0,
473 m2_distance: 0.0,
474 max_criterion: 0.0,
475 warning_active: false,
476 _marker: std::marker::PhantomData,
477 })
478 }
479
480 pub fn is_warning(&self) -> bool {
482 self.warning_active
483 }
484
485 fn criterion(&self) -> f64 {
486 let variance = if self.error_count > 1 {
487 self.m2_distance / (self.error_count - 1) as f64
488 } else {
489 0.0
490 };
491 self.mean_distance + 2.0 * variance.max(0.0).sqrt()
492 }
493}
494
495impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for EddmTest<A> {
496 fn test_for_drift(
497 &mut self,
498 reference: &[A],
499 current: &[A],
500 ) -> Result<DriftTestResult<A>, String> {
501 if current.is_empty() {
502 return Err("EDDM: empty observation batch".to_string());
503 }
504 let baseline = ReferenceBaseline::from_sample(reference)?;
505
506 for value in finite_f64(current) {
507 self.since_last_error += 1;
508 if !baseline.is_error(value) {
509 continue;
510 }
511
512 let distance = self.since_last_error as f64;
514 self.since_last_error = 0;
515 self.error_count += 1;
516 let delta = distance - self.mean_distance;
517 self.mean_distance += delta / self.error_count as f64;
518 self.m2_distance += delta * (distance - self.mean_distance);
519
520 let criterion = self.criterion();
521 if criterion > self.max_criterion {
522 self.max_criterion = criterion;
523 }
524 }
525
526 let criterion = self.criterion();
527 let ratio = if self.max_criterion > 0.0 {
528 criterion / self.max_criterion
529 } else {
530 1.0
531 };
532
533 let variance = if self.error_count > 1 {
537 self.m2_distance / (self.error_count - 1) as f64
538 } else {
539 0.0
540 };
541 let standard_error = if self.error_count > 0 {
542 (variance / self.error_count as f64).sqrt()
543 } else {
544 0.0
545 };
546 let z = if standard_error > 0.0 {
547 (self.max_criterion - criterion) / standard_error
548 } else {
549 0.0
550 };
551 let p_value = stats::standard_normal_sf(z)?;
552
553 let enough_errors = self.error_count >= self.min_errors;
554 self.warning_active =
555 enough_errors && ratio < self.warning_ratio && ratio >= self.drift_ratio;
556 let drift_detected =
557 enough_errors && (ratio < self.drift_ratio || p_value < self.significance_level);
558
559 if drift_detected {
560 self.error_count = 0;
561 self.mean_distance = 0.0;
562 self.m2_distance = 0.0;
563 self.max_criterion = 0.0;
564 self.since_last_error = 0;
565 self.warning_active = false;
566 }
567
568 let mut metadata = HashMap::new();
569 metadata.insert("criterion_ratio".to_string(), from_f64::<A>(ratio)?);
570 metadata.insert(
571 "mean_error_distance".to_string(),
572 from_f64::<A>(self.mean_distance)?,
573 );
574 metadata.insert(
575 "max_criterion".to_string(),
576 from_f64::<A>(self.max_criterion)?,
577 );
578
579 Ok(DriftTestResult {
580 drift_detected,
581 test_statistic: from_f64(ratio)?,
583 p_value: from_f64(p_value)?,
584 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
585 metadata,
586 })
587 }
588
589 fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
590 let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
591 self.drift_ratio = (self.drift_ratio + 0.01 * feedback).clamp(0.70, 0.99);
592 self.warning_ratio = self.warning_ratio.max(self.drift_ratio + 0.005).min(0.999);
593 Ok(())
594 }
595
596 fn reset(&mut self) {
597 self.since_last_error = 0;
598 self.error_count = 0;
599 self.mean_distance = 0.0;
600 self.m2_distance = 0.0;
601 self.max_criterion = 0.0;
602 self.warning_active = false;
603 }
604}
605
606pub struct PageHinkleyTest<A: Float + Send + Sync> {
626 significance_level: f64,
627 delta: f64,
629 lambda: f64,
631 count: usize,
633 mean: f64,
634 m2: f64,
635 sum_increase: f64,
637 min_increase: f64,
638 sum_decrease: f64,
639 min_decrease: f64,
640 run_length: usize,
642 _marker: std::marker::PhantomData<A>,
643}
644
645impl<A: Float + Send + Sync> PageHinkleyTest<A> {
646 pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
650 if !(sensitivity.is_finite() && sensitivity > 0.0) {
651 return Err(format!(
652 "Page-Hinkley sensitivity must be positive, got {sensitivity}"
653 ));
654 }
655 Ok(Self {
656 significance_level,
657 delta: sensitivity,
658 lambda: (5.0 / sensitivity).clamp(5.0, 500.0),
662 count: 0,
663 mean: 0.0,
664 m2: 0.0,
665 sum_increase: 0.0,
666 min_increase: 0.0,
667 sum_decrease: 0.0,
668 min_decrease: 0.0,
669 run_length: 0,
670 _marker: std::marker::PhantomData,
671 })
672 }
673}
674
675impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
676 for PageHinkleyTest<A>
677{
678 fn test_for_drift(
679 &mut self,
680 _reference: &[A],
681 current: &[A],
682 ) -> Result<DriftTestResult<A>, String> {
683 if current.is_empty() {
684 return Err("Page-Hinkley: empty observation batch".to_string());
685 }
686
687 for value in finite_f64(current) {
688 self.count += 1;
689 self.run_length += 1;
690 let delta_from_mean = value - self.mean;
691 self.mean += delta_from_mean / self.count as f64;
692 self.m2 += delta_from_mean * (value - self.mean);
693
694 let deviation = value - self.mean;
695 self.sum_increase += deviation - self.delta;
696 self.sum_decrease += -deviation - self.delta;
697 if self.sum_increase < self.min_increase {
698 self.min_increase = self.sum_increase;
699 }
700 if self.sum_decrease < self.min_decrease {
701 self.min_decrease = self.sum_decrease;
702 }
703 }
704
705 let ph_increase = self.sum_increase - self.min_increase;
706 let ph_decrease = self.sum_decrease - self.min_decrease;
707 let statistic = ph_increase.max(ph_decrease);
708
709 let variance = if self.count > 1 {
713 self.m2 / (self.count - 1) as f64
714 } else {
715 0.0
716 };
717 let sigma = variance.max(0.0).sqrt();
718 let z = if sigma > 0.0 && self.run_length > 0 {
719 statistic / (sigma * (self.run_length as f64).sqrt())
720 } else {
721 0.0
722 };
723 let p_value = stats::standard_normal_sf(z)?;
724
725 let drift_detected = statistic > self.lambda || p_value < self.significance_level;
726 if drift_detected {
727 self.sum_increase = 0.0;
728 self.min_increase = 0.0;
729 self.sum_decrease = 0.0;
730 self.min_decrease = 0.0;
731 self.run_length = 0;
732 }
733
734 let mut metadata = HashMap::new();
735 metadata.insert("ph_increase".to_string(), from_f64::<A>(ph_increase)?);
736 metadata.insert("ph_decrease".to_string(), from_f64::<A>(ph_decrease)?);
737 metadata.insert("running_mean".to_string(), from_f64::<A>(self.mean)?);
738 metadata.insert("lambda".to_string(), from_f64::<A>(self.lambda)?);
739
740 Ok(DriftTestResult {
741 drift_detected,
742 test_statistic: from_f64(statistic)?,
743 p_value: from_f64(p_value)?,
744 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
745 metadata,
746 })
747 }
748
749 fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
750 let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
751 self.lambda = (self.lambda * (1.0 - 0.1 * feedback)).clamp(1.0, 1000.0);
752 Ok(())
753 }
754
755 fn reset(&mut self) {
756 self.count = 0;
757 self.mean = 0.0;
758 self.m2 = 0.0;
759 self.sum_increase = 0.0;
760 self.min_increase = 0.0;
761 self.sum_decrease = 0.0;
762 self.min_decrease = 0.0;
763 self.run_length = 0;
764 }
765}
766
767pub struct CusumTest<A: Float + Send + Sync> {
782 significance_level: f64,
783 k_sigma: f64,
785 h_sigma: f64,
787 positive_sum: f64,
788 negative_sum: f64,
789 run_length: usize,
790 _marker: std::marker::PhantomData<A>,
791}
792
793impl<A: Float + Send + Sync> CusumTest<A> {
794 pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
797 if !(sensitivity.is_finite() && sensitivity > 0.0) {
798 return Err(format!(
799 "CUSUM sensitivity must be positive, got {sensitivity}"
800 ));
801 }
802 Ok(Self {
803 significance_level,
804 k_sigma: 0.5,
805 h_sigma: (5.0 * (1.0 - sensitivity).max(0.2)).clamp(1.0, 10.0),
806 positive_sum: 0.0,
807 negative_sum: 0.0,
808 run_length: 0,
809 _marker: std::marker::PhantomData,
810 })
811 }
812}
813
814impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
815 for CusumTest<A>
816{
817 fn test_for_drift(
818 &mut self,
819 reference: &[A],
820 current: &[A],
821 ) -> Result<DriftTestResult<A>, String> {
822 if current.is_empty() {
823 return Err("CUSUM: empty observation batch".to_string());
824 }
825 let baseline = ReferenceBaseline::from_sample(reference)?;
826 let sigma = baseline.std_dev;
827 let slack = self.k_sigma * sigma;
828
829 for value in finite_f64(current) {
830 self.run_length += 1;
831 let deviation = value - baseline.mean;
832 self.positive_sum = (self.positive_sum + deviation - slack).max(0.0);
833 self.negative_sum = (self.negative_sum - deviation - slack).max(0.0);
834 }
835
836 let statistic = self.positive_sum.max(self.negative_sum);
837 let threshold = self.h_sigma * sigma;
838
839 let z = if sigma > 0.0 && self.run_length > 0 {
842 statistic / (sigma * (self.run_length as f64).sqrt())
843 } else {
844 0.0
845 };
846 let p_value = stats::standard_normal_sf(z)?;
847
848 let drift_detected = statistic > threshold || p_value < self.significance_level;
849 if drift_detected {
850 self.positive_sum = 0.0;
851 self.negative_sum = 0.0;
852 self.run_length = 0;
853 }
854
855 let mut metadata = HashMap::new();
856 metadata.insert(
857 "positive_sum".to_string(),
858 from_f64::<A>(self.positive_sum)?,
859 );
860 metadata.insert(
861 "negative_sum".to_string(),
862 from_f64::<A>(self.negative_sum)?,
863 );
864 metadata.insert("threshold".to_string(), from_f64::<A>(threshold)?);
865 metadata.insert("reference_mean".to_string(), from_f64::<A>(baseline.mean)?);
866
867 Ok(DriftTestResult {
868 drift_detected,
869 test_statistic: from_f64(statistic)?,
870 p_value: from_f64(p_value)?,
871 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
872 metadata,
873 })
874 }
875
876 fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
877 let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
878 self.h_sigma = (self.h_sigma * (1.0 - 0.1 * feedback)).clamp(1.0, 12.0);
879 Ok(())
880 }
881
882 fn reset(&mut self) {
883 self.positive_sum = 0.0;
884 self.negative_sum = 0.0;
885 self.run_length = 0;
886 }
887}
888
889pub struct KsTest<A: Float + Send + Sync> {
902 significance_level: f64,
903 _marker: std::marker::PhantomData<A>,
904}
905
906impl<A: Float + Send + Sync> KsTest<A> {
907 pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
911 if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
912 return Err(format!(
913 "KS significance level must lie strictly in (0, 1), got {sensitivity}"
914 ));
915 }
916 Ok(Self {
917 significance_level: sensitivity.max(significance_level),
918 _marker: std::marker::PhantomData,
919 })
920 }
921}
922
923impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for KsTest<A> {
924 fn test_for_drift(
925 &mut self,
926 reference: &[A],
927 current: &[A],
928 ) -> Result<DriftTestResult<A>, String> {
929 let reference_sample = finite_f64(reference);
930 let current_sample = finite_f64(current);
931 if reference_sample.is_empty() || current_sample.is_empty() {
932 return Err("KS test: both samples must be non-empty".to_string());
933 }
934
935 let d = stats::ks_statistic(&reference_sample, ¤t_sample)
936 .ok_or_else(|| "KS test: empirical CDF is undefined".to_string())?;
937 let p_value = stats::ks_two_sample_p(d, reference_sample.len(), current_sample.len());
938
939 let mut metadata = HashMap::new();
940 metadata.insert(
941 "reference_size".to_string(),
942 from_f64::<A>(reference_sample.len() as f64)?,
943 );
944 metadata.insert(
945 "current_size".to_string(),
946 from_f64::<A>(current_sample.len() as f64)?,
947 );
948
949 Ok(DriftTestResult {
950 drift_detected: p_value < self.significance_level,
951 test_statistic: from_f64(d)?,
952 p_value: from_f64(p_value)?,
953 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
954 metadata,
955 })
956 }
957
958 fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
959 let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
960 self.significance_level =
961 (self.significance_level * (1.0 + 0.1 * feedback)).clamp(1e-6, 0.5);
962 Ok(())
963 }
964
965 fn reset(&mut self) {
966 }
968}
969
970pub struct MannWhitneyUTest<A: Float + Send + Sync> {
983 significance_level: f64,
984 _marker: std::marker::PhantomData<A>,
985}
986
987impl<A: Float + Send + Sync> MannWhitneyUTest<A> {
988 pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
990 if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
991 return Err(format!(
992 "Mann-Whitney significance level must lie strictly in (0, 1), got {sensitivity}"
993 ));
994 }
995 Ok(Self {
996 significance_level: sensitivity.max(significance_level),
997 _marker: std::marker::PhantomData,
998 })
999 }
1000}
1001
1002fn rank_sum_with_ties(group_a: &[f64], group_b: &[f64]) -> (f64, f64) {
1005 let mut pooled: Vec<(f64, bool)> = group_a
1006 .iter()
1007 .map(|&v| (v, true))
1008 .chain(group_b.iter().map(|&v| (v, false)))
1009 .collect();
1010 pooled.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
1011
1012 let mut rank_sum_a = 0.0_f64;
1013 let mut tie_correction = 0.0_f64;
1014 let mut index = 0usize;
1015 while index < pooled.len() {
1016 let mut end = index + 1;
1017 while end < pooled.len() && pooled[end].0 == pooled[index].0 {
1018 end += 1;
1019 }
1020 let tie_size = (end - index) as f64;
1021 let mid_rank = (index as f64 + 1.0 + end as f64) / 2.0;
1023 for entry in &pooled[index..end] {
1024 if entry.1 {
1025 rank_sum_a += mid_rank;
1026 }
1027 }
1028 if tie_size > 1.0 {
1029 tie_correction += tie_size * tie_size * tie_size - tie_size;
1030 }
1031 index = end;
1032 }
1033
1034 (rank_sum_a, tie_correction)
1035}
1036
1037impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
1038 for MannWhitneyUTest<A>
1039{
1040 fn test_for_drift(
1041 &mut self,
1042 reference: &[A],
1043 current: &[A],
1044 ) -> Result<DriftTestResult<A>, String> {
1045 let reference_sample = finite_f64(reference);
1046 let current_sample = finite_f64(current);
1047 if reference_sample.is_empty() || current_sample.is_empty() {
1048 return Err("Mann-Whitney U: both samples must be non-empty".to_string());
1049 }
1050
1051 let n1 = reference_sample.len() as f64;
1052 let n2 = current_sample.len() as f64;
1053 let total = n1 + n2;
1054
1055 let (rank_sum, tie_correction) = rank_sum_with_ties(&reference_sample, ¤t_sample);
1056 let u = rank_sum - n1 * (n1 + 1.0) / 2.0;
1057 let mean_u = n1 * n2 / 2.0;
1058
1059 let tie_term = if total > 1.0 {
1060 tie_correction / (total * (total - 1.0))
1061 } else {
1062 0.0
1063 };
1064 let variance_u = (n1 * n2 / 12.0) * ((total + 1.0) - tie_term);
1065
1066 let z = if variance_u > 0.0 {
1067 let deviation = (u - mean_u).abs();
1069 (deviation - 0.5).max(0.0) / variance_u.sqrt()
1070 } else {
1071 0.0
1072 };
1073 let p_value = stats::normal_two_sided_p(z)?;
1074
1075 let mut metadata = HashMap::new();
1076 metadata.insert("u_statistic".to_string(), from_f64::<A>(u)?);
1077 metadata.insert("expected_u".to_string(), from_f64::<A>(mean_u)?);
1078 metadata.insert("tie_correction".to_string(), from_f64::<A>(tie_correction)?);
1079
1080 Ok(DriftTestResult {
1081 drift_detected: p_value < self.significance_level,
1082 test_statistic: from_f64(z)?,
1085 p_value: from_f64(p_value)?,
1086 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1087 metadata,
1088 })
1089 }
1090
1091 fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
1092 let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
1093 self.significance_level =
1094 (self.significance_level * (1.0 + 0.1 * feedback)).clamp(1e-6, 0.5);
1095 Ok(())
1096 }
1097
1098 fn reset(&mut self) {
1099 }
1101}
1102
1103fn insignificant_result<A: Float + Send + Sync>(
1105 statistic: f64,
1106 metadata: HashMap<String, A>,
1107) -> Result<DriftTestResult<A>, String> {
1108 Ok(DriftTestResult {
1109 drift_detected: false,
1110 p_value: A::one(),
1111 test_statistic: from_f64(statistic)?,
1112 confidence: A::zero(),
1113 metadata,
1114 })
1115}
1116
1117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1123pub enum HistogramDivergence {
1124 KullbackLeibler,
1126 JensenShannon,
1128 Hellinger,
1130}
1131
1132impl HistogramDivergence {
1133 fn upper_bound(self) -> Option<f64> {
1136 match self {
1137 HistogramDivergence::KullbackLeibler => None,
1138 HistogramDivergence::JensenShannon => Some(std::f64::consts::LN_2),
1139 HistogramDivergence::Hellinger => Some(1.0),
1140 }
1141 }
1142
1143 fn label(self) -> &'static str {
1144 match self {
1145 HistogramDivergence::KullbackLeibler => "kl_divergence",
1146 HistogramDivergence::JensenShannon => "js_divergence",
1147 HistogramDivergence::Hellinger => "hellinger_distance",
1148 }
1149 }
1150}
1151
1152pub struct HistogramComparator<A: Float + Send + Sync> {
1161 divergence: HistogramDivergence,
1162 threshold: A,
1163 bins: usize,
1164 _marker: std::marker::PhantomData<A>,
1165}
1166
1167impl<A: Float + Send + Sync> HistogramComparator<A> {
1168 pub fn new(divergence: HistogramDivergence, sensitivity: f64) -> Result<Self, String> {
1173 if !(sensitivity.is_finite() && sensitivity > 0.0) {
1174 return Err(format!(
1175 "{} sensitivity must be positive, got {sensitivity}",
1176 divergence.label()
1177 ));
1178 }
1179 let threshold_value = match divergence.upper_bound() {
1180 Some(bound) => (sensitivity.min(1.0)) * bound,
1181 None => sensitivity,
1182 };
1183 Ok(Self {
1184 divergence,
1185 threshold: from_f64(threshold_value)?,
1186 bins: HISTOGRAM_BINS,
1187 _marker: std::marker::PhantomData,
1188 })
1189 }
1190
1191 pub fn divergence(&self) -> HistogramDivergence {
1193 self.divergence
1194 }
1195}
1196
1197impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> DistributionComparator<A>
1198 for HistogramComparator<A>
1199{
1200 fn compare_distributions(
1201 &self,
1202 reference: &[A],
1203 current: &[A],
1204 ) -> Result<DistributionComparison<A>, String> {
1205 let reference_sample = finite_f64(reference);
1206 let current_sample = finite_f64(current);
1207 if reference_sample.is_empty() || current_sample.is_empty() {
1208 return Err(format!(
1209 "{}: both samples must be non-empty",
1210 self.divergence.label()
1211 ));
1212 }
1213
1214 let (ref_min, ref_max) = stats::finite_range(&reference_sample)
1216 .ok_or_else(|| "reference sample has no finite observations".to_string())?;
1217 let (cur_min, cur_max) = stats::finite_range(¤t_sample)
1218 .ok_or_else(|| "current sample has no finite observations".to_string())?;
1219 let min = ref_min.min(cur_min);
1220 let max = ref_max.max(cur_max);
1221
1222 let reference_counts = stats::histogram_counts(&reference_sample, min, max, self.bins);
1223 let current_counts = stats::histogram_counts(¤t_sample, min, max, self.bins);
1224
1225 let reference_pmf = stats::smoothed_pmf(&reference_counts, HISTOGRAM_SMOOTHING);
1226 let current_pmf = stats::smoothed_pmf(¤t_counts, HISTOGRAM_SMOOTHING);
1227
1228 let distance = match self.divergence {
1229 HistogramDivergence::KullbackLeibler => {
1230 stats::kl_divergence(¤t_pmf, &reference_pmf)?
1231 }
1232 HistogramDivergence::JensenShannon => {
1233 stats::js_divergence(&reference_pmf, ¤t_pmf)?
1234 }
1235 HistogramDivergence::Hellinger => {
1236 stats::hellinger_distance(&reference_pmf, ¤t_pmf)?
1237 }
1238 };
1239
1240 let (g, degrees_of_freedom) = stats::g_test_statistic(&reference_counts, ¤t_counts)?;
1242 let p_value = if degrees_of_freedom >= 1.0 {
1243 stats::chi_square_sf(g, degrees_of_freedom)?
1244 } else {
1245 1.0
1246 };
1247
1248 let threshold = to_f64(self.threshold)?;
1249 Ok(DistributionComparison {
1250 distance: from_f64(distance)?,
1251 threshold: self.threshold,
1252 drift_detected: distance > threshold,
1253 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1254 })
1255 }
1256
1257 fn get_threshold(&self) -> A {
1258 self.threshold
1259 }
1260
1261 fn update_threshold(&mut self, new_threshold: A) {
1262 self.threshold = new_threshold;
1263 }
1264}
1265
1266pub struct WassersteinComparator<A: Float + Send + Sync> {
1277 relative_threshold: f64,
1280 threshold: A,
1281}
1282
1283impl<A: Float + Send + Sync> WassersteinComparator<A> {
1284 pub fn new(sensitivity: f64) -> Result<Self, String> {
1287 if !(sensitivity.is_finite() && sensitivity > 0.0) {
1288 return Err(format!(
1289 "Wasserstein sensitivity must be positive, got {sensitivity}"
1290 ));
1291 }
1292 Ok(Self {
1293 relative_threshold: sensitivity,
1294 threshold: from_f64(sensitivity)?,
1295 })
1296 }
1297}
1298
1299impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> DistributionComparator<A>
1300 for WassersteinComparator<A>
1301{
1302 fn compare_distributions(
1303 &self,
1304 reference: &[A],
1305 current: &[A],
1306 ) -> Result<DistributionComparison<A>, String> {
1307 let reference_sample = finite_f64(reference);
1308 let current_sample = finite_f64(current);
1309 if reference_sample.is_empty() || current_sample.is_empty() {
1310 return Err("Wasserstein distance: both samples must be non-empty".to_string());
1311 }
1312
1313 let distance = stats::wasserstein_1d(&reference_sample, ¤t_sample)
1314 .ok_or_else(|| "Wasserstein distance is undefined for these samples".to_string())?;
1315
1316 let spread = stats::sample_std_dev(&reference_sample).unwrap_or(0.0);
1318 let effective_threshold = if spread > 0.0 {
1319 self.relative_threshold * spread
1320 } else {
1321 to_f64(self.threshold)?
1322 };
1323
1324 let d = stats::ks_statistic(&reference_sample, ¤t_sample)
1328 .ok_or_else(|| "Wasserstein: empirical CDF is undefined".to_string())?;
1329 let p_value = stats::ks_two_sample_p(d, reference_sample.len(), current_sample.len());
1330
1331 Ok(DistributionComparison {
1332 distance: from_f64(distance)?,
1333 threshold: from_f64(effective_threshold)?,
1334 drift_detected: distance > effective_threshold,
1335 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1336 })
1337 }
1338
1339 fn get_threshold(&self) -> A {
1340 self.threshold
1341 }
1342
1343 fn update_threshold(&mut self, new_threshold: A) {
1344 self.threshold = new_threshold;
1345 if let Some(value) = new_threshold.to_f64() {
1346 if value > 0.0 {
1347 self.relative_threshold = value;
1348 }
1349 }
1350 }
1351}
1352
1353pub struct LinearModelDetector<A: Float + Send + Sync> {
1371 weights: Vec<f64>,
1373 bias: f64,
1375 learning_rate: f64,
1377 l2_lambda: f64,
1379 error_alpha: f64,
1381 baseline_performance: f64,
1383 model_performance: f64,
1385 error_m2: f64,
1387 error_mean: f64,
1388 updates: usize,
1390 warmup_updates: usize,
1392 baseline_weights: Vec<f64>,
1394 degradation_threshold: f64,
1396 _marker: std::marker::PhantomData<A>,
1397}
1398
1399impl<A: Float + Send + Sync> LinearModelDetector<A> {
1400 pub fn new(sensitivity: f64) -> Result<Self, String> {
1403 if !(sensitivity.is_finite() && sensitivity > 0.0) {
1404 return Err(format!(
1405 "linear model sensitivity must be positive, got {sensitivity}"
1406 ));
1407 }
1408 Ok(Self {
1409 weights: Vec::new(),
1410 bias: 0.0,
1411 learning_rate: 0.5,
1414 l2_lambda: 1e-5,
1415 error_alpha: 0.1,
1416 baseline_performance: f64::NAN,
1417 model_performance: f64::NAN,
1418 error_m2: 0.0,
1419 error_mean: 0.0,
1420 updates: 0,
1421 warmup_updates: 20,
1422 baseline_weights: Vec::new(),
1423 degradation_threshold: sensitivity,
1424 _marker: std::marker::PhantomData,
1425 })
1426 }
1427
1428 pub fn current_error(&self) -> Option<f64> {
1431 if self.model_performance.is_finite() {
1432 Some(self.model_performance)
1433 } else {
1434 None
1435 }
1436 }
1437
1438 pub fn baseline_error(&self) -> Option<f64> {
1440 if self.baseline_performance.is_finite() {
1441 Some(self.baseline_performance)
1442 } else {
1443 None
1444 }
1445 }
1446
1447 fn predict(&self, features: &[f64]) -> f64 {
1448 let mut prediction = self.bias;
1449 for (weight, &feature) in self.weights.iter().zip(features.iter()) {
1450 prediction += weight * feature;
1451 }
1452 prediction
1453 }
1454
1455 fn learn_one(&mut self, features: &[f64], target: f64) {
1467 if self.weights.len() < features.len() {
1468 self.weights.resize(features.len(), 0.0);
1469 }
1470
1471 let prediction = self.predict(features);
1472 let error = prediction - target;
1473 let squared_error = error * error;
1474
1475 let input_energy = 1.0 + features.iter().map(|&f| f * f).sum::<f64>();
1476 let step = self.learning_rate * error / input_energy;
1477 for (weight, &feature) in self.weights.iter_mut().zip(features.iter()) {
1478 *weight -= step * feature + self.l2_lambda * *weight;
1479 }
1480 self.bias -= step;
1481
1482 self.model_performance = if self.model_performance.is_finite() {
1484 self.error_alpha * squared_error + (1.0 - self.error_alpha) * self.model_performance
1485 } else {
1486 squared_error
1487 };
1488
1489 self.updates += 1;
1490 let delta = squared_error - self.error_mean;
1491 self.error_mean += delta / self.updates as f64;
1492 self.error_m2 += delta * (squared_error - self.error_mean);
1493
1494 if self.updates == self.warmup_updates || !self.baseline_performance.is_finite() {
1496 self.baseline_performance = self.model_performance;
1497 self.baseline_weights = self.weights.clone();
1498 } else if self.model_performance <= self.baseline_performance {
1499 self.baseline_performance =
1502 0.9 * self.baseline_performance + 0.1 * self.model_performance;
1503 self.baseline_weights = self.weights.clone();
1504 }
1505 }
1506
1507 fn supervised_pair(data_point: &StreamingDataPoint<A>) -> Option<(Vec<f64>, f64)> {
1513 let target = data_point.target.as_ref()?;
1514 let target_value = target.iter().next()?.to_f64()?;
1515 if !target_value.is_finite() {
1516 return None;
1517 }
1518 let features: Vec<f64> = data_point
1519 .features
1520 .iter()
1521 .filter_map(|v| v.to_f64())
1522 .filter(|v| v.is_finite())
1523 .collect();
1524 if features.is_empty() {
1525 return None;
1526 }
1527 Some((features, target_value))
1528 }
1529}
1530
1531impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
1532 for LinearModelDetector<A>
1533{
1534 fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String> {
1535 let mut trained = 0usize;
1536 for data_point in data {
1537 if let Some((features, target)) = Self::supervised_pair(data_point) {
1538 self.learn_one(&features, target);
1539 trained += 1;
1540 }
1541 }
1542 if trained == 0 && !data.is_empty() {
1543 return Err(
1544 "linear model drift detector requires labelled data points (target is None)"
1545 .to_string(),
1546 );
1547 }
1548 Ok(())
1549 }
1550
1551 fn detect_drift(
1552 &mut self,
1553 data: &[StreamingDataPoint<A>],
1554 ) -> Result<ModelDriftResult<A>, String> {
1555 self.update_model(data)?;
1557
1558 let baseline = self
1559 .baseline_performance
1560 .is_finite()
1561 .then_some(self.baseline_performance)
1562 .ok_or_else(|| "linear model drift detector has no baseline yet".to_string())?;
1563 let current = self
1564 .model_performance
1565 .is_finite()
1566 .then_some(self.model_performance)
1567 .ok_or_else(|| "linear model drift detector has no error estimate yet".to_string())?;
1568
1569 let denominator = baseline.max(f64::MIN_POSITIVE);
1571 let degradation = (current - baseline) / denominator;
1572
1573 let variance = if self.updates > 1 {
1576 self.error_m2 / (self.updates - 1) as f64
1577 } else {
1578 0.0
1579 };
1580 let standard_error = if self.updates > 0 {
1581 (variance / self.updates as f64).sqrt()
1582 } else {
1583 0.0
1584 };
1585 let z = if standard_error > 0.0 {
1586 (current - baseline) / standard_error
1587 } else {
1588 0.0
1589 };
1590 let p_value = stats::standard_normal_sf(z)?;
1591
1592 let ready = self.updates >= self.warmup_updates;
1593 let drift_detected = ready && degradation > self.degradation_threshold;
1594
1595 let mut feature_importance_changes = Vec::with_capacity(self.weights.len());
1597 for (index, weight) in self.weights.iter().enumerate() {
1598 let baseline_weight = self.baseline_weights.get(index).copied().unwrap_or(0.0);
1599 feature_importance_changes.push(from_f64::<A>(weight - baseline_weight)?);
1600 }
1601
1602 Ok(ModelDriftResult {
1603 drift_detected,
1604 performance_degradation: from_f64(degradation)?,
1605 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1606 feature_importance_changes,
1607 })
1608 }
1609
1610 fn reset_model(&mut self) -> Result<(), String> {
1611 self.weights.clear();
1612 self.baseline_weights.clear();
1613 self.bias = 0.0;
1614 self.baseline_performance = f64::NAN;
1615 self.model_performance = f64::NAN;
1616 self.error_mean = 0.0;
1617 self.error_m2 = 0.0;
1618 self.updates = 0;
1619 Ok(())
1620 }
1621}
1622
1623#[cfg(test)]
1624#[path = "drift_tests_regression_tests.rs"]
1625mod regression_tests;