use super::drift_detection::{
DistributionComparator, DistributionComparison, DriftTestResult, ModelBasedDetector,
ModelDriftResult, StatisticalTest,
};
use super::optimizer::StreamingDataPoint;
use super::statistics as stats;
use scirs2_core::numeric::Float;
use std::collections::{HashMap, VecDeque};
fn to_f64<A: Float>(value: A) -> Result<f64, String> {
value
.to_f64()
.ok_or_else(|| "value cannot be represented as f64".to_string())
}
fn from_f64<A: Float>(value: f64) -> Result<A, String> {
A::from(value).ok_or_else(|| format!("{value} cannot be represented in the element type"))
}
fn finite_f64<A: Float>(values: &[A]) -> Vec<f64> {
values
.iter()
.filter_map(|v| v.to_f64())
.filter(|v| v.is_finite())
.collect()
}
const MAX_DETECTOR_WINDOW: usize = 4096;
const HISTOGRAM_BINS: usize = 16;
const HISTOGRAM_SMOOTHING: f64 = 0.5;
pub struct AdwinTest<A: Float + Send + Sync> {
delta: f64,
significance_level: f64,
window: VecDeque<A>,
min_sub_window: usize,
}
impl<A: Float + Send + Sync> AdwinTest<A> {
pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
return Err(format!(
"ADWIN delta must lie strictly in (0, 1), got {sensitivity}"
));
}
Ok(Self {
delta: sensitivity,
significance_level,
window: VecDeque::with_capacity(256),
min_sub_window: 5,
})
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
for AdwinTest<A>
{
fn test_for_drift(
&mut self,
_reference: &[A],
current: &[A],
) -> Result<DriftTestResult<A>, String> {
if current.is_empty() {
return Err("ADWIN: empty observation batch".to_string());
}
for &value in current {
if self.window.len() >= MAX_DETECTOR_WINDOW {
self.window.pop_front();
}
self.window.push_back(value);
}
let observations: Vec<f64> = finite_f64(self.window.make_contiguous());
let n = observations.len();
if n < 2 * self.min_sub_window {
return insignificant_result(0.0, HashMap::new());
}
let (min, max) = stats::finite_range(&observations)
.ok_or_else(|| "ADWIN: window contains no finite observations".to_string())?;
let range = (max - min).max(f64::MIN_POSITIVE);
let mut prefix = Vec::with_capacity(n + 1);
prefix.push(0.0_f64);
for &value in &observations {
let last = prefix[prefix.len() - 1];
prefix.push(last + value);
}
let total = prefix[n];
let ln_term = (4.0 / self.delta).ln();
let mut best_excess = f64::NEG_INFINITY;
let mut best_diff = 0.0_f64;
let mut best_split = 0usize;
let mut best_eps = 0.0_f64;
let mut best_m = 0.0_f64;
let first_split = self.min_sub_window;
let last_split = n - self.min_sub_window;
for (offset, &prefix_sum) in prefix[first_split..=last_split].iter().enumerate() {
let split = first_split + offset;
let n0 = split as f64;
let n1 = (n - split) as f64;
let mean0 = prefix_sum / n0;
let mean1 = (total - prefix_sum) / n1;
let diff = (mean0 - mean1).abs();
let harmonic_m = 1.0 / (1.0 / n0 + 1.0 / n1);
let eps_cut = range * (ln_term / (2.0 * harmonic_m)).sqrt();
let excess = diff - eps_cut;
if excess > best_excess {
best_excess = excess;
best_diff = diff;
best_split = split;
best_eps = eps_cut;
best_m = harmonic_m;
}
}
let normalised = best_diff / range;
let p_value = (4.0 * (-2.0 * best_m * normalised * normalised).exp()).clamp(0.0, 1.0);
let cut_found = best_excess > 0.0;
if cut_found {
for _ in 0..best_split {
self.window.pop_front();
}
}
let mut metadata = HashMap::new();
metadata.insert("split_point".to_string(), from_f64::<A>(best_split as f64)?);
metadata.insert("window_size".to_string(), from_f64::<A>(n as f64)?);
metadata.insert("epsilon_cut".to_string(), from_f64::<A>(best_eps)?);
metadata.insert("observed_range".to_string(), from_f64::<A>(range)?);
Ok(DriftTestResult {
drift_detected: cut_found || p_value < self.significance_level,
p_value: from_f64(p_value)?,
test_statistic: from_f64(best_diff)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
metadata,
})
}
fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
let scale = (1.0 + 0.1 * feedback).clamp(0.5, 2.0);
self.delta = (self.delta * scale).clamp(1e-8, 0.5);
Ok(())
}
fn reset(&mut self) {
self.window.clear();
}
}
#[derive(Debug, Clone, Copy)]
struct ReferenceBaseline {
mean: f64,
std_dev: f64,
}
impl ReferenceBaseline {
fn from_sample<A: Float>(reference: &[A]) -> Result<Self, String> {
let sample = finite_f64(reference);
if sample.len() < 2 {
return Err(
"error-stream baseline requires at least two reference observations".to_string(),
);
}
let mean = stats::mean(&sample).ok_or_else(|| "reference mean is undefined".to_string())?;
let std_dev = stats::sample_std_dev(&sample)
.ok_or_else(|| "reference standard deviation is undefined".to_string())?;
Ok(Self {
mean,
std_dev: std_dev.max(f64::MIN_POSITIVE),
})
}
fn is_error(&self, value: f64) -> bool {
(value - self.mean).abs() > self.std_dev
}
}
pub struct DdmTest<A: Float + Send + Sync> {
significance_level: f64,
warning_level: f64,
drift_level: f64,
min_instances: usize,
instances: usize,
errors: usize,
p_min: f64,
s_min: f64,
warning_active: bool,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> DdmTest<A> {
pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0) {
return Err(format!(
"DDM sensitivity must be positive, got {sensitivity}"
));
}
let scale = (0.5 + sensitivity).clamp(0.5, 1.5);
Ok(Self {
significance_level,
warning_level: 2.0 * scale,
drift_level: 3.0 * scale,
min_instances: 30,
instances: 0,
errors: 0,
p_min: f64::INFINITY,
s_min: f64::INFINITY,
warning_active: false,
_marker: std::marker::PhantomData,
})
}
pub fn is_warning(&self) -> bool {
self.warning_active
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for DdmTest<A> {
fn test_for_drift(
&mut self,
reference: &[A],
current: &[A],
) -> Result<DriftTestResult<A>, String> {
if current.is_empty() {
return Err("DDM: empty observation batch".to_string());
}
let baseline = ReferenceBaseline::from_sample(reference)?;
for value in finite_f64(current) {
self.instances += 1;
if baseline.is_error(value) {
self.errors += 1;
}
let n = self.instances as f64;
let p = self.errors as f64 / n;
let s = (p * (1.0 - p) / n).sqrt();
if p + s < self.p_min + self.s_min {
self.p_min = p;
self.s_min = s;
}
}
let n = self.instances as f64;
let p = self.errors as f64 / n;
let s = (p * (1.0 - p) / n).sqrt();
let s_min = if self.s_min.is_finite() && self.s_min > 0.0 {
self.s_min
} else {
(p * (1.0 - p) / n).sqrt().max(f64::MIN_POSITIVE)
};
let p_min = if self.p_min.is_finite() {
self.p_min
} else {
p
};
let z = ((p + s) - p_min) / s_min;
let p_value = stats::standard_normal_sf(z)?;
let enough_data = self.instances >= self.min_instances;
self.warning_active = enough_data && z >= self.warning_level && z < self.drift_level;
let drift_detected =
enough_data && (z >= self.drift_level || p_value < self.significance_level);
if drift_detected {
self.instances = 0;
self.errors = 0;
self.p_min = f64::INFINITY;
self.s_min = f64::INFINITY;
self.warning_active = false;
}
let mut metadata = HashMap::new();
metadata.insert("error_rate".to_string(), from_f64::<A>(p)?);
metadata.insert("p_min".to_string(), from_f64::<A>(p_min)?);
metadata.insert("s_min".to_string(), from_f64::<A>(s_min)?);
metadata.insert(
"warning_level".to_string(),
from_f64::<A>(self.warning_level)?,
);
Ok(DriftTestResult {
drift_detected,
p_value: from_f64(p_value)?,
test_statistic: from_f64(z)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
metadata,
})
}
fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
self.drift_level = (self.drift_level - 0.2 * feedback).clamp(1.5, 6.0);
self.warning_level = self.warning_level.min(self.drift_level - 0.25).max(1.0);
Ok(())
}
fn reset(&mut self) {
self.instances = 0;
self.errors = 0;
self.p_min = f64::INFINITY;
self.s_min = f64::INFINITY;
self.warning_active = false;
}
}
pub struct EddmTest<A: Float + Send + Sync> {
significance_level: f64,
warning_ratio: f64,
drift_ratio: f64,
min_errors: usize,
since_last_error: usize,
error_count: usize,
mean_distance: f64,
m2_distance: f64,
max_criterion: f64,
warning_active: bool,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> EddmTest<A> {
pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0) {
return Err(format!(
"EDDM sensitivity must be positive, got {sensitivity}"
));
}
let shift = (sensitivity * 0.1).clamp(0.0, 0.05);
Ok(Self {
significance_level,
warning_ratio: 0.95 + shift,
drift_ratio: 0.90 + shift,
min_errors: 30,
since_last_error: 0,
error_count: 0,
mean_distance: 0.0,
m2_distance: 0.0,
max_criterion: 0.0,
warning_active: false,
_marker: std::marker::PhantomData,
})
}
pub fn is_warning(&self) -> bool {
self.warning_active
}
fn criterion(&self) -> f64 {
let variance = if self.error_count > 1 {
self.m2_distance / (self.error_count - 1) as f64
} else {
0.0
};
self.mean_distance + 2.0 * variance.max(0.0).sqrt()
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for EddmTest<A> {
fn test_for_drift(
&mut self,
reference: &[A],
current: &[A],
) -> Result<DriftTestResult<A>, String> {
if current.is_empty() {
return Err("EDDM: empty observation batch".to_string());
}
let baseline = ReferenceBaseline::from_sample(reference)?;
for value in finite_f64(current) {
self.since_last_error += 1;
if !baseline.is_error(value) {
continue;
}
let distance = self.since_last_error as f64;
self.since_last_error = 0;
self.error_count += 1;
let delta = distance - self.mean_distance;
self.mean_distance += delta / self.error_count as f64;
self.m2_distance += delta * (distance - self.mean_distance);
let criterion = self.criterion();
if criterion > self.max_criterion {
self.max_criterion = criterion;
}
}
let criterion = self.criterion();
let ratio = if self.max_criterion > 0.0 {
criterion / self.max_criterion
} else {
1.0
};
let variance = if self.error_count > 1 {
self.m2_distance / (self.error_count - 1) as f64
} else {
0.0
};
let standard_error = if self.error_count > 0 {
(variance / self.error_count as f64).sqrt()
} else {
0.0
};
let z = if standard_error > 0.0 {
(self.max_criterion - criterion) / standard_error
} else {
0.0
};
let p_value = stats::standard_normal_sf(z)?;
let enough_errors = self.error_count >= self.min_errors;
self.warning_active =
enough_errors && ratio < self.warning_ratio && ratio >= self.drift_ratio;
let drift_detected =
enough_errors && (ratio < self.drift_ratio || p_value < self.significance_level);
if drift_detected {
self.error_count = 0;
self.mean_distance = 0.0;
self.m2_distance = 0.0;
self.max_criterion = 0.0;
self.since_last_error = 0;
self.warning_active = false;
}
let mut metadata = HashMap::new();
metadata.insert("criterion_ratio".to_string(), from_f64::<A>(ratio)?);
metadata.insert(
"mean_error_distance".to_string(),
from_f64::<A>(self.mean_distance)?,
);
metadata.insert(
"max_criterion".to_string(),
from_f64::<A>(self.max_criterion)?,
);
Ok(DriftTestResult {
drift_detected,
test_statistic: from_f64(ratio)?,
p_value: from_f64(p_value)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
metadata,
})
}
fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
self.drift_ratio = (self.drift_ratio + 0.01 * feedback).clamp(0.70, 0.99);
self.warning_ratio = self.warning_ratio.max(self.drift_ratio + 0.005).min(0.999);
Ok(())
}
fn reset(&mut self) {
self.since_last_error = 0;
self.error_count = 0;
self.mean_distance = 0.0;
self.m2_distance = 0.0;
self.max_criterion = 0.0;
self.warning_active = false;
}
}
pub struct PageHinkleyTest<A: Float + Send + Sync> {
significance_level: f64,
delta: f64,
lambda: f64,
count: usize,
mean: f64,
m2: f64,
sum_increase: f64,
min_increase: f64,
sum_decrease: f64,
min_decrease: f64,
run_length: usize,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> PageHinkleyTest<A> {
pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0) {
return Err(format!(
"Page-Hinkley sensitivity must be positive, got {sensitivity}"
));
}
Ok(Self {
significance_level,
delta: sensitivity,
lambda: (5.0 / sensitivity).clamp(5.0, 500.0),
count: 0,
mean: 0.0,
m2: 0.0,
sum_increase: 0.0,
min_increase: 0.0,
sum_decrease: 0.0,
min_decrease: 0.0,
run_length: 0,
_marker: std::marker::PhantomData,
})
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
for PageHinkleyTest<A>
{
fn test_for_drift(
&mut self,
_reference: &[A],
current: &[A],
) -> Result<DriftTestResult<A>, String> {
if current.is_empty() {
return Err("Page-Hinkley: empty observation batch".to_string());
}
for value in finite_f64(current) {
self.count += 1;
self.run_length += 1;
let delta_from_mean = value - self.mean;
self.mean += delta_from_mean / self.count as f64;
self.m2 += delta_from_mean * (value - self.mean);
let deviation = value - self.mean;
self.sum_increase += deviation - self.delta;
self.sum_decrease += -deviation - self.delta;
if self.sum_increase < self.min_increase {
self.min_increase = self.sum_increase;
}
if self.sum_decrease < self.min_decrease {
self.min_decrease = self.sum_decrease;
}
}
let ph_increase = self.sum_increase - self.min_increase;
let ph_decrease = self.sum_decrease - self.min_decrease;
let statistic = ph_increase.max(ph_decrease);
let variance = if self.count > 1 {
self.m2 / (self.count - 1) as f64
} else {
0.0
};
let sigma = variance.max(0.0).sqrt();
let z = if sigma > 0.0 && self.run_length > 0 {
statistic / (sigma * (self.run_length as f64).sqrt())
} else {
0.0
};
let p_value = stats::standard_normal_sf(z)?;
let drift_detected = statistic > self.lambda || p_value < self.significance_level;
if drift_detected {
self.sum_increase = 0.0;
self.min_increase = 0.0;
self.sum_decrease = 0.0;
self.min_decrease = 0.0;
self.run_length = 0;
}
let mut metadata = HashMap::new();
metadata.insert("ph_increase".to_string(), from_f64::<A>(ph_increase)?);
metadata.insert("ph_decrease".to_string(), from_f64::<A>(ph_decrease)?);
metadata.insert("running_mean".to_string(), from_f64::<A>(self.mean)?);
metadata.insert("lambda".to_string(), from_f64::<A>(self.lambda)?);
Ok(DriftTestResult {
drift_detected,
test_statistic: from_f64(statistic)?,
p_value: from_f64(p_value)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
metadata,
})
}
fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
self.lambda = (self.lambda * (1.0 - 0.1 * feedback)).clamp(1.0, 1000.0);
Ok(())
}
fn reset(&mut self) {
self.count = 0;
self.mean = 0.0;
self.m2 = 0.0;
self.sum_increase = 0.0;
self.min_increase = 0.0;
self.sum_decrease = 0.0;
self.min_decrease = 0.0;
self.run_length = 0;
}
}
pub struct CusumTest<A: Float + Send + Sync> {
significance_level: f64,
k_sigma: f64,
h_sigma: f64,
positive_sum: f64,
negative_sum: f64,
run_length: usize,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> CusumTest<A> {
pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0) {
return Err(format!(
"CUSUM sensitivity must be positive, got {sensitivity}"
));
}
Ok(Self {
significance_level,
k_sigma: 0.5,
h_sigma: (5.0 * (1.0 - sensitivity).max(0.2)).clamp(1.0, 10.0),
positive_sum: 0.0,
negative_sum: 0.0,
run_length: 0,
_marker: std::marker::PhantomData,
})
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
for CusumTest<A>
{
fn test_for_drift(
&mut self,
reference: &[A],
current: &[A],
) -> Result<DriftTestResult<A>, String> {
if current.is_empty() {
return Err("CUSUM: empty observation batch".to_string());
}
let baseline = ReferenceBaseline::from_sample(reference)?;
let sigma = baseline.std_dev;
let slack = self.k_sigma * sigma;
for value in finite_f64(current) {
self.run_length += 1;
let deviation = value - baseline.mean;
self.positive_sum = (self.positive_sum + deviation - slack).max(0.0);
self.negative_sum = (self.negative_sum - deviation - slack).max(0.0);
}
let statistic = self.positive_sum.max(self.negative_sum);
let threshold = self.h_sigma * sigma;
let z = if sigma > 0.0 && self.run_length > 0 {
statistic / (sigma * (self.run_length as f64).sqrt())
} else {
0.0
};
let p_value = stats::standard_normal_sf(z)?;
let drift_detected = statistic > threshold || p_value < self.significance_level;
if drift_detected {
self.positive_sum = 0.0;
self.negative_sum = 0.0;
self.run_length = 0;
}
let mut metadata = HashMap::new();
metadata.insert(
"positive_sum".to_string(),
from_f64::<A>(self.positive_sum)?,
);
metadata.insert(
"negative_sum".to_string(),
from_f64::<A>(self.negative_sum)?,
);
metadata.insert("threshold".to_string(), from_f64::<A>(threshold)?);
metadata.insert("reference_mean".to_string(), from_f64::<A>(baseline.mean)?);
Ok(DriftTestResult {
drift_detected,
test_statistic: from_f64(statistic)?,
p_value: from_f64(p_value)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
metadata,
})
}
fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
self.h_sigma = (self.h_sigma * (1.0 - 0.1 * feedback)).clamp(1.0, 12.0);
Ok(())
}
fn reset(&mut self) {
self.positive_sum = 0.0;
self.negative_sum = 0.0;
self.run_length = 0;
}
}
pub struct KsTest<A: Float + Send + Sync> {
significance_level: f64,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> KsTest<A> {
pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
return Err(format!(
"KS significance level must lie strictly in (0, 1), got {sensitivity}"
));
}
Ok(Self {
significance_level: sensitivity.max(significance_level),
_marker: std::marker::PhantomData,
})
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A> for KsTest<A> {
fn test_for_drift(
&mut self,
reference: &[A],
current: &[A],
) -> Result<DriftTestResult<A>, String> {
let reference_sample = finite_f64(reference);
let current_sample = finite_f64(current);
if reference_sample.is_empty() || current_sample.is_empty() {
return Err("KS test: both samples must be non-empty".to_string());
}
let d = stats::ks_statistic(&reference_sample, ¤t_sample)
.ok_or_else(|| "KS test: empirical CDF is undefined".to_string())?;
let p_value = stats::ks_two_sample_p(d, reference_sample.len(), current_sample.len());
let mut metadata = HashMap::new();
metadata.insert(
"reference_size".to_string(),
from_f64::<A>(reference_sample.len() as f64)?,
);
metadata.insert(
"current_size".to_string(),
from_f64::<A>(current_sample.len() as f64)?,
);
Ok(DriftTestResult {
drift_detected: p_value < self.significance_level,
test_statistic: from_f64(d)?,
p_value: from_f64(p_value)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
metadata,
})
}
fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
self.significance_level =
(self.significance_level * (1.0 + 0.1 * feedback)).clamp(1e-6, 0.5);
Ok(())
}
fn reset(&mut self) {
}
}
pub struct MannWhitneyUTest<A: Float + Send + Sync> {
significance_level: f64,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> MannWhitneyUTest<A> {
pub fn new(sensitivity: f64, significance_level: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0 && sensitivity < 1.0) {
return Err(format!(
"Mann-Whitney significance level must lie strictly in (0, 1), got {sensitivity}"
));
}
Ok(Self {
significance_level: sensitivity.max(significance_level),
_marker: std::marker::PhantomData,
})
}
}
fn rank_sum_with_ties(group_a: &[f64], group_b: &[f64]) -> (f64, f64) {
let mut pooled: Vec<(f64, bool)> = group_a
.iter()
.map(|&v| (v, true))
.chain(group_b.iter().map(|&v| (v, false)))
.collect();
pooled.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
let mut rank_sum_a = 0.0_f64;
let mut tie_correction = 0.0_f64;
let mut index = 0usize;
while index < pooled.len() {
let mut end = index + 1;
while end < pooled.len() && pooled[end].0 == pooled[index].0 {
end += 1;
}
let tie_size = (end - index) as f64;
let mid_rank = (index as f64 + 1.0 + end as f64) / 2.0;
for entry in &pooled[index..end] {
if entry.1 {
rank_sum_a += mid_rank;
}
}
if tie_size > 1.0 {
tie_correction += tie_size * tie_size * tie_size - tie_size;
}
index = end;
}
(rank_sum_a, tie_correction)
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> StatisticalTest<A>
for MannWhitneyUTest<A>
{
fn test_for_drift(
&mut self,
reference: &[A],
current: &[A],
) -> Result<DriftTestResult<A>, String> {
let reference_sample = finite_f64(reference);
let current_sample = finite_f64(current);
if reference_sample.is_empty() || current_sample.is_empty() {
return Err("Mann-Whitney U: both samples must be non-empty".to_string());
}
let n1 = reference_sample.len() as f64;
let n2 = current_sample.len() as f64;
let total = n1 + n2;
let (rank_sum, tie_correction) = rank_sum_with_ties(&reference_sample, ¤t_sample);
let u = rank_sum - n1 * (n1 + 1.0) / 2.0;
let mean_u = n1 * n2 / 2.0;
let tie_term = if total > 1.0 {
tie_correction / (total * (total - 1.0))
} else {
0.0
};
let variance_u = (n1 * n2 / 12.0) * ((total + 1.0) - tie_term);
let z = if variance_u > 0.0 {
let deviation = (u - mean_u).abs();
(deviation - 0.5).max(0.0) / variance_u.sqrt()
} else {
0.0
};
let p_value = stats::normal_two_sided_p(z)?;
let mut metadata = HashMap::new();
metadata.insert("u_statistic".to_string(), from_f64::<A>(u)?);
metadata.insert("expected_u".to_string(), from_f64::<A>(mean_u)?);
metadata.insert("tie_correction".to_string(), from_f64::<A>(tie_correction)?);
Ok(DriftTestResult {
drift_detected: p_value < self.significance_level,
test_statistic: from_f64(z)?,
p_value: from_f64(p_value)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
metadata,
})
}
fn update_parameters(&mut self, performance_feedback: A) -> Result<(), String> {
let feedback = to_f64(performance_feedback)?.clamp(-1.0, 1.0);
self.significance_level =
(self.significance_level * (1.0 + 0.1 * feedback)).clamp(1e-6, 0.5);
Ok(())
}
fn reset(&mut self) {
}
}
fn insignificant_result<A: Float + Send + Sync>(
statistic: f64,
metadata: HashMap<String, A>,
) -> Result<DriftTestResult<A>, String> {
Ok(DriftTestResult {
drift_detected: false,
p_value: A::one(),
test_statistic: from_f64(statistic)?,
confidence: A::zero(),
metadata,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistogramDivergence {
KullbackLeibler,
JensenShannon,
Hellinger,
}
impl HistogramDivergence {
fn upper_bound(self) -> Option<f64> {
match self {
HistogramDivergence::KullbackLeibler => None,
HistogramDivergence::JensenShannon => Some(std::f64::consts::LN_2),
HistogramDivergence::Hellinger => Some(1.0),
}
}
fn label(self) -> &'static str {
match self {
HistogramDivergence::KullbackLeibler => "kl_divergence",
HistogramDivergence::JensenShannon => "js_divergence",
HistogramDivergence::Hellinger => "hellinger_distance",
}
}
}
pub struct HistogramComparator<A: Float + Send + Sync> {
divergence: HistogramDivergence,
threshold: A,
bins: usize,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> HistogramComparator<A> {
pub fn new(divergence: HistogramDivergence, sensitivity: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0) {
return Err(format!(
"{} sensitivity must be positive, got {sensitivity}",
divergence.label()
));
}
let threshold_value = match divergence.upper_bound() {
Some(bound) => (sensitivity.min(1.0)) * bound,
None => sensitivity,
};
Ok(Self {
divergence,
threshold: from_f64(threshold_value)?,
bins: HISTOGRAM_BINS,
_marker: std::marker::PhantomData,
})
}
pub fn divergence(&self) -> HistogramDivergence {
self.divergence
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> DistributionComparator<A>
for HistogramComparator<A>
{
fn compare_distributions(
&self,
reference: &[A],
current: &[A],
) -> Result<DistributionComparison<A>, String> {
let reference_sample = finite_f64(reference);
let current_sample = finite_f64(current);
if reference_sample.is_empty() || current_sample.is_empty() {
return Err(format!(
"{}: both samples must be non-empty",
self.divergence.label()
));
}
let (ref_min, ref_max) = stats::finite_range(&reference_sample)
.ok_or_else(|| "reference sample has no finite observations".to_string())?;
let (cur_min, cur_max) = stats::finite_range(¤t_sample)
.ok_or_else(|| "current sample has no finite observations".to_string())?;
let min = ref_min.min(cur_min);
let max = ref_max.max(cur_max);
let reference_counts = stats::histogram_counts(&reference_sample, min, max, self.bins);
let current_counts = stats::histogram_counts(¤t_sample, min, max, self.bins);
let reference_pmf = stats::smoothed_pmf(&reference_counts, HISTOGRAM_SMOOTHING);
let current_pmf = stats::smoothed_pmf(¤t_counts, HISTOGRAM_SMOOTHING);
let distance = match self.divergence {
HistogramDivergence::KullbackLeibler => {
stats::kl_divergence(¤t_pmf, &reference_pmf)?
}
HistogramDivergence::JensenShannon => {
stats::js_divergence(&reference_pmf, ¤t_pmf)?
}
HistogramDivergence::Hellinger => {
stats::hellinger_distance(&reference_pmf, ¤t_pmf)?
}
};
let (g, degrees_of_freedom) = stats::g_test_statistic(&reference_counts, ¤t_counts)?;
let p_value = if degrees_of_freedom >= 1.0 {
stats::chi_square_sf(g, degrees_of_freedom)?
} else {
1.0
};
let threshold = to_f64(self.threshold)?;
Ok(DistributionComparison {
distance: from_f64(distance)?,
threshold: self.threshold,
drift_detected: distance > threshold,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
})
}
fn get_threshold(&self) -> A {
self.threshold
}
fn update_threshold(&mut self, new_threshold: A) {
self.threshold = new_threshold;
}
}
pub struct WassersteinComparator<A: Float + Send + Sync> {
relative_threshold: f64,
threshold: A,
}
impl<A: Float + Send + Sync> WassersteinComparator<A> {
pub fn new(sensitivity: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0) {
return Err(format!(
"Wasserstein sensitivity must be positive, got {sensitivity}"
));
}
Ok(Self {
relative_threshold: sensitivity,
threshold: from_f64(sensitivity)?,
})
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> DistributionComparator<A>
for WassersteinComparator<A>
{
fn compare_distributions(
&self,
reference: &[A],
current: &[A],
) -> Result<DistributionComparison<A>, String> {
let reference_sample = finite_f64(reference);
let current_sample = finite_f64(current);
if reference_sample.is_empty() || current_sample.is_empty() {
return Err("Wasserstein distance: both samples must be non-empty".to_string());
}
let distance = stats::wasserstein_1d(&reference_sample, ¤t_sample)
.ok_or_else(|| "Wasserstein distance is undefined for these samples".to_string())?;
let spread = stats::sample_std_dev(&reference_sample).unwrap_or(0.0);
let effective_threshold = if spread > 0.0 {
self.relative_threshold * spread
} else {
to_f64(self.threshold)?
};
let d = stats::ks_statistic(&reference_sample, ¤t_sample)
.ok_or_else(|| "Wasserstein: empirical CDF is undefined".to_string())?;
let p_value = stats::ks_two_sample_p(d, reference_sample.len(), current_sample.len());
Ok(DistributionComparison {
distance: from_f64(distance)?,
threshold: from_f64(effective_threshold)?,
drift_detected: distance > effective_threshold,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
})
}
fn get_threshold(&self) -> A {
self.threshold
}
fn update_threshold(&mut self, new_threshold: A) {
self.threshold = new_threshold;
if let Some(value) = new_threshold.to_f64() {
if value > 0.0 {
self.relative_threshold = value;
}
}
}
}
pub struct LinearModelDetector<A: Float + Send + Sync> {
weights: Vec<f64>,
bias: f64,
learning_rate: f64,
l2_lambda: f64,
error_alpha: f64,
baseline_performance: f64,
model_performance: f64,
error_m2: f64,
error_mean: f64,
updates: usize,
warmup_updates: usize,
baseline_weights: Vec<f64>,
degradation_threshold: f64,
_marker: std::marker::PhantomData<A>,
}
impl<A: Float + Send + Sync> LinearModelDetector<A> {
pub fn new(sensitivity: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0) {
return Err(format!(
"linear model sensitivity must be positive, got {sensitivity}"
));
}
Ok(Self {
weights: Vec::new(),
bias: 0.0,
learning_rate: 0.5,
l2_lambda: 1e-5,
error_alpha: 0.1,
baseline_performance: f64::NAN,
model_performance: f64::NAN,
error_m2: 0.0,
error_mean: 0.0,
updates: 0,
warmup_updates: 20,
baseline_weights: Vec::new(),
degradation_threshold: sensitivity,
_marker: std::marker::PhantomData,
})
}
pub fn current_error(&self) -> Option<f64> {
if self.model_performance.is_finite() {
Some(self.model_performance)
} else {
None
}
}
pub fn baseline_error(&self) -> Option<f64> {
if self.baseline_performance.is_finite() {
Some(self.baseline_performance)
} else {
None
}
}
fn predict(&self, features: &[f64]) -> f64 {
let mut prediction = self.bias;
for (weight, &feature) in self.weights.iter().zip(features.iter()) {
prediction += weight * feature;
}
prediction
}
fn learn_one(&mut self, features: &[f64], target: f64) {
if self.weights.len() < features.len() {
self.weights.resize(features.len(), 0.0);
}
let prediction = self.predict(features);
let error = prediction - target;
let squared_error = error * error;
let input_energy = 1.0 + features.iter().map(|&f| f * f).sum::<f64>();
let step = self.learning_rate * error / input_energy;
for (weight, &feature) in self.weights.iter_mut().zip(features.iter()) {
*weight -= step * feature + self.l2_lambda * *weight;
}
self.bias -= step;
self.model_performance = if self.model_performance.is_finite() {
self.error_alpha * squared_error + (1.0 - self.error_alpha) * self.model_performance
} else {
squared_error
};
self.updates += 1;
let delta = squared_error - self.error_mean;
self.error_mean += delta / self.updates as f64;
self.error_m2 += delta * (squared_error - self.error_mean);
if self.updates == self.warmup_updates || !self.baseline_performance.is_finite() {
self.baseline_performance = self.model_performance;
self.baseline_weights = self.weights.clone();
} else if self.model_performance <= self.baseline_performance {
self.baseline_performance =
0.9 * self.baseline_performance + 0.1 * self.model_performance;
self.baseline_weights = self.weights.clone();
}
}
fn supervised_pair(data_point: &StreamingDataPoint<A>) -> Option<(Vec<f64>, f64)> {
let target = data_point.target.as_ref()?;
let target_value = target.iter().next()?.to_f64()?;
if !target_value.is_finite() {
return None;
}
let features: Vec<f64> = data_point
.features
.iter()
.filter_map(|v| v.to_f64())
.filter(|v| v.is_finite())
.collect();
if features.is_empty() {
return None;
}
Some((features, target_value))
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
for LinearModelDetector<A>
{
fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String> {
let mut trained = 0usize;
for data_point in data {
if let Some((features, target)) = Self::supervised_pair(data_point) {
self.learn_one(&features, target);
trained += 1;
}
}
if trained == 0 && !data.is_empty() {
return Err(
"linear model drift detector requires labelled data points (target is None)"
.to_string(),
);
}
Ok(())
}
fn detect_drift(
&mut self,
data: &[StreamingDataPoint<A>],
) -> Result<ModelDriftResult<A>, String> {
self.update_model(data)?;
let baseline = self
.baseline_performance
.is_finite()
.then_some(self.baseline_performance)
.ok_or_else(|| "linear model drift detector has no baseline yet".to_string())?;
let current = self
.model_performance
.is_finite()
.then_some(self.model_performance)
.ok_or_else(|| "linear model drift detector has no error estimate yet".to_string())?;
let denominator = baseline.max(f64::MIN_POSITIVE);
let degradation = (current - baseline) / denominator;
let variance = if self.updates > 1 {
self.error_m2 / (self.updates - 1) as f64
} else {
0.0
};
let standard_error = if self.updates > 0 {
(variance / self.updates as f64).sqrt()
} else {
0.0
};
let z = if standard_error > 0.0 {
(current - baseline) / standard_error
} else {
0.0
};
let p_value = stats::standard_normal_sf(z)?;
let ready = self.updates >= self.warmup_updates;
let drift_detected = ready && degradation > self.degradation_threshold;
let mut feature_importance_changes = Vec::with_capacity(self.weights.len());
for (index, weight) in self.weights.iter().enumerate() {
let baseline_weight = self.baseline_weights.get(index).copied().unwrap_or(0.0);
feature_importance_changes.push(from_f64::<A>(weight - baseline_weight)?);
}
Ok(ModelDriftResult {
drift_detected,
performance_degradation: from_f64(degradation)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
feature_importance_changes,
})
}
fn reset_model(&mut self) -> Result<(), String> {
self.weights.clear();
self.baseline_weights.clear();
self.bias = 0.0;
self.baseline_performance = f64::NAN;
self.model_performance = f64::NAN;
self.error_mean = 0.0;
self.error_m2 = 0.0;
self.updates = 0;
Ok(())
}
}
#[cfg(test)]
#[path = "drift_tests_regression_tests.rs"]
mod regression_tests;