use super::drift_detection::{ModelBasedDetector, ModelDriftResult};
use super::drift_tests::LinearModelDetector;
use super::optimizer::StreamingDataPoint;
use super::statistics as stats;
use crate::utils::try_scalar_str;
use scirs2_core::numeric::Float;
use std::collections::VecDeque;
use std::marker::PhantomData;
const ALPHA_FAST: f64 = 0.1;
const ALPHA_SLOW: f64 = 0.01;
const MIN_DRIFT_RUN: usize = 50;
const DRIFT_SIGNIFICANCE: f64 = 0.05;
const WARMUP_OBSERVATIONS: usize = 200;
const BASELINE_CLIP_SIGMAS: f64 = 4.0;
const BASELINE_CLIP_WARMUP: usize = 30;
const MLP_HIDDEN_UNITS: usize = 8;
const MLP_INIT_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
const TREE_WINDOW_CAPACITY: usize = 256;
const TREE_REFIT_INTERVAL: usize = 32;
const TREE_MAX_DEPTH: usize = 4;
const TREE_MIN_SAMPLES_LEAF: usize = 8;
fn from_f64<A: Float>(value: f64) -> Result<A, String> {
try_scalar_str::<A, _>(value)
}
fn is_not_above(value: f64, bound: f64) -> bool {
!matches!(value.partial_cmp(&bound), Some(std::cmp::Ordering::Greater))
}
fn supervised_pair<A: Float + Send + Sync>(
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))
}
#[derive(Debug, Clone)]
pub struct PrequentialErrorTracker {
fast: f64,
slow: f64,
variance: f64,
updates: usize,
run_length: usize,
threshold: f64,
}
impl PrequentialErrorTracker {
fn new(sensitivity: f64) -> Result<Self, String> {
if !(sensitivity.is_finite() && sensitivity > 0.0) {
return Err(format!(
"model drift sensitivity must be positive and finite, got {sensitivity}"
));
}
Ok(Self {
fast: f64::NAN,
slow: f64::NAN,
variance: 0.0,
updates: 0,
run_length: 0,
threshold: sensitivity,
})
}
fn observe(&mut self, squared_error: f64) {
if !squared_error.is_finite() {
return;
}
if self.fast.is_finite() && self.slow.is_finite() {
let deviation = squared_error - self.slow;
let baseline_step = self.winsorise(deviation);
self.variance =
ALPHA_SLOW * baseline_step * baseline_step + (1.0 - ALPHA_SLOW) * self.variance;
self.fast = ALPHA_FAST * squared_error + (1.0 - ALPHA_FAST) * self.fast;
self.slow += ALPHA_SLOW * baseline_step;
} else {
self.fast = squared_error;
self.slow = squared_error;
self.variance = 0.0;
}
self.updates += 1;
if self.fast > self.slow * (1.0 + self.threshold) {
self.run_length += 1;
} else {
self.run_length = 0;
}
}
fn winsorise(&self, deviation: f64) -> f64 {
if self.updates < BASELINE_CLIP_WARMUP {
return deviation;
}
if is_not_above(self.variance, 0.0) {
return deviation.min(0.0);
}
let radius = BASELINE_CLIP_SIGMAS * self.variance.sqrt();
deviation.clamp(-radius, radius)
}
fn degradation(&self) -> Option<f64> {
if !(self.fast.is_finite() && self.slow.is_finite()) {
return None;
}
Some((self.fast - self.slow) / self.slow.abs().max(f64::MIN_POSITIVE))
}
fn p_value(&self) -> Result<f64, String> {
let (Some(fast), Some(slow)) = (
self.fast.is_finite().then_some(self.fast),
self.slow.is_finite().then_some(self.slow),
) else {
return Ok(1.0);
};
let effective_n = (2.0 - ALPHA_FAST) / ALPHA_FAST;
let standard_error = (self.variance / effective_n).sqrt();
if is_not_above(standard_error, 0.0) {
return Ok(if fast > slow { 0.0 } else { 1.0 });
}
stats::standard_normal_sf((fast - slow) / standard_error)
}
fn ready(&self) -> bool {
self.updates >= WARMUP_OBSERVATIONS
}
fn drift_detected(&self) -> Result<bool, String> {
if !(self.ready() && self.run_length >= MIN_DRIFT_RUN) {
return Ok(false);
}
Ok(self.p_value()? < DRIFT_SIGNIFICANCE)
}
#[cfg(test)]
fn run_length(&self) -> usize {
self.run_length
}
fn updates(&self) -> usize {
self.updates
}
fn reset(&mut self) {
self.fast = f64::NAN;
self.slow = f64::NAN;
self.variance = 0.0;
self.updates = 0;
self.run_length = 0;
}
}
#[derive(Debug, Clone)]
struct SymmetryBreaker {
state: u64,
}
impl SymmetryBreaker {
fn new(seed: u64) -> Self {
Self {
state: if seed == 0 { MLP_INIT_SEED } else { seed },
}
}
fn next_signed_unit(&mut self) -> f64 {
let mut x = self.state;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.state = x;
let scrambled = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
let unit = (scrambled >> 11) as f64 / ((1u64 << 53) as f64);
unit * 2.0 - 1.0
}
}
#[derive(Debug, Clone)]
pub struct NeuralNetworkDriftDetector<A: Float + Send + Sync> {
hidden_weights: Vec<Vec<f64>>,
hidden_bias: Vec<f64>,
output_weights: Vec<f64>,
output_bias: f64,
input_width: usize,
learning_rate: f64,
l2_lambda: f64,
initialiser: SymmetryBreaker,
baseline_sensitivity: Vec<f64>,
tracker: PrequentialErrorTracker,
_marker: PhantomData<A>,
}
impl<A: Float + Send + Sync> NeuralNetworkDriftDetector<A> {
pub fn new(sensitivity: f64) -> Result<Self, String> {
Ok(Self {
hidden_weights: vec![Vec::new(); MLP_HIDDEN_UNITS],
hidden_bias: vec![0.0; MLP_HIDDEN_UNITS],
output_weights: Vec::new(),
output_bias: 0.0,
input_width: 0,
learning_rate: 0.5,
l2_lambda: 1e-5,
initialiser: SymmetryBreaker::new(MLP_INIT_SEED),
baseline_sensitivity: Vec::new(),
tracker: PrequentialErrorTracker::new(sensitivity)?,
_marker: PhantomData,
})
}
pub fn input_width(&self) -> usize {
self.input_width
}
pub fn current_error(&self) -> Option<f64> {
self.tracker.fast.is_finite().then_some(self.tracker.fast)
}
fn grow_to(&mut self, width: usize) {
if width <= self.input_width {
return;
}
if self.output_weights.is_empty() {
let scale = (6.0 / (MLP_HIDDEN_UNITS as f64 + 1.0)).sqrt();
self.output_weights = (0..MLP_HIDDEN_UNITS)
.map(|_| self.initialiser.next_signed_unit() * scale)
.collect();
}
let scale = (6.0 / (width as f64 + MLP_HIDDEN_UNITS as f64)).sqrt();
for row in &mut self.hidden_weights {
while row.len() < width {
row.push(self.initialiser.next_signed_unit() * scale);
}
}
self.input_width = width;
}
fn activate(&self, features: &[f64]) -> Vec<f64> {
self.hidden_weights
.iter()
.zip(self.hidden_bias.iter())
.map(|(row, bias)| {
let mut sum = *bias;
for (weight, value) in row.iter().zip(features.iter()) {
sum += weight * value;
}
sum.tanh()
})
.collect()
}
fn predict_from(&self, activations: &[f64]) -> f64 {
let mut prediction = self.output_bias;
for (weight, activation) in self.output_weights.iter().zip(activations.iter()) {
prediction += weight * activation;
}
prediction
}
fn input_sensitivity(&self) -> Vec<f64> {
(0..self.input_width)
.map(|index| {
self.hidden_weights
.iter()
.zip(self.output_weights.iter())
.map(|(row, output_weight)| {
row.get(index)
.map(|weight| (weight * output_weight).abs())
.unwrap_or(0.0)
})
.sum()
})
.collect()
}
fn restart_after_detection(&mut self) {
self.tracker.reset();
self.baseline_sensitivity.clear();
}
fn learn_one(&mut self, features: &[f64], target: f64) {
self.grow_to(features.len());
let activations = self.activate(features);
let prediction = self.predict_from(&activations);
let error = prediction - target;
self.tracker.observe(error * error);
if self.tracker.updates() == WARMUP_OBSERVATIONS || self.baseline_sensitivity.is_empty() {
self.baseline_sensitivity = self.input_sensitivity();
}
let hidden_deltas: Vec<f64> = activations
.iter()
.zip(self.output_weights.iter())
.map(|(activation, output_weight)| {
error * output_weight * (1.0 - activation * activation)
})
.collect();
let output_energy = 1.0 + activations.iter().map(|a| a * a).sum::<f64>();
let output_step = self.learning_rate * error / output_energy;
for (weight, activation) in self.output_weights.iter_mut().zip(activations.iter()) {
*weight -= output_step * activation + self.l2_lambda * *weight;
}
self.output_bias -= output_step;
let input_energy = 1.0 + features.iter().map(|f| f * f).sum::<f64>();
let hidden_step = self.learning_rate / input_energy;
for ((row, bias), delta) in self
.hidden_weights
.iter_mut()
.zip(self.hidden_bias.iter_mut())
.zip(hidden_deltas.iter())
{
for (weight, value) in row.iter_mut().zip(features.iter()) {
*weight -= hidden_step * delta * value + self.l2_lambda * *weight;
}
*bias -= hidden_step * delta;
}
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
for NeuralNetworkDriftDetector<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)) = supervised_pair(data_point) {
self.learn_one(&features, target);
trained += 1;
}
}
if trained == 0 && !data.is_empty() {
return Err(
"neural-network 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 degradation = self
.tracker
.degradation()
.ok_or_else(|| "neural-network drift detector has no error estimate yet".to_string())?;
let p_value = self.tracker.p_value()?;
let sensitivity = self.input_sensitivity();
let mut feature_importance_changes = Vec::with_capacity(sensitivity.len());
for (index, current) in sensitivity.iter().enumerate() {
let baseline = self.baseline_sensitivity.get(index).copied().unwrap_or(0.0);
feature_importance_changes.push(from_f64::<A>(current - baseline)?);
}
let drift_detected = self.tracker.drift_detected()?;
let result = ModelDriftResult {
drift_detected,
performance_degradation: from_f64(degradation)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
feature_importance_changes,
};
if drift_detected {
self.restart_after_detection();
}
Ok(result)
}
fn reset_model(&mut self) -> Result<(), String> {
self.hidden_weights = vec![Vec::new(); MLP_HIDDEN_UNITS];
self.hidden_bias = vec![0.0; MLP_HIDDEN_UNITS];
self.output_weights.clear();
self.output_bias = 0.0;
self.input_width = 0;
self.initialiser = SymmetryBreaker::new(MLP_INIT_SEED);
self.baseline_sensitivity.clear();
self.tracker.reset();
Ok(())
}
}
#[derive(Debug, Clone)]
enum TreeNode {
Leaf { value: f64 },
Split {
feature: usize,
threshold: f64,
left: Box<TreeNode>,
right: Box<TreeNode>,
},
}
impl TreeNode {
fn predict(&self, features: &[f64]) -> f64 {
match self {
TreeNode::Leaf { value } => *value,
TreeNode::Split {
feature,
threshold,
left,
right,
} => {
let value = features.get(*feature).copied().unwrap_or(f64::NEG_INFINITY);
if value <= *threshold {
left.predict(features)
} else {
right.predict(features)
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct DecisionTreeDriftDetector<A: Float + Send + Sync> {
window: VecDeque<(Vec<f64>, f64)>,
tree: Option<TreeNode>,
importances: Vec<f64>,
baseline_importances: Vec<f64>,
since_refit: usize,
tracker: PrequentialErrorTracker,
_marker: PhantomData<A>,
}
impl<A: Float + Send + Sync> DecisionTreeDriftDetector<A> {
pub fn new(sensitivity: f64) -> Result<Self, String> {
Ok(Self {
window: VecDeque::with_capacity(TREE_WINDOW_CAPACITY),
tree: None,
importances: Vec::new(),
baseline_importances: Vec::new(),
since_refit: 0,
tracker: PrequentialErrorTracker::new(sensitivity)?,
_marker: PhantomData,
})
}
pub fn is_fitted(&self) -> bool {
self.tree.is_some()
}
pub fn feature_importances(&self) -> &[f64] {
&self.importances
}
fn subset_moments(&self, indices: &[usize]) -> (f64, f64) {
let mut sum = 0.0;
let mut sum_squares = 0.0;
for index in indices {
if let Some((_, target)) = self.window.get(*index) {
sum += *target;
sum_squares += target * target;
}
}
(sum, sum_squares)
}
fn subset_sse(&self, indices: &[usize]) -> f64 {
if indices.is_empty() {
return 0.0;
}
let (sum, sum_squares) = self.subset_moments(indices);
(sum_squares - sum * sum / indices.len() as f64).max(0.0)
}
fn feature_value(&self, index: usize, feature: usize) -> Option<f64> {
self.window
.get(index)
.and_then(|(features, _)| features.get(feature).copied())
}
fn best_split(&self, indices: &[usize], width: usize) -> Option<(usize, f64, f64)> {
let parent_sse = self.subset_sse(indices);
if is_not_above(parent_sse, 0.0) {
return None;
}
let (total_sum, total_squares) = self.subset_moments(indices);
let count = indices.len();
let mut best: Option<(usize, f64, f64)> = None;
let mut order: Vec<usize> = Vec::with_capacity(count);
for feature in 0..width {
order.clear();
order.extend_from_slice(indices);
order.sort_by(|left, right| {
let a = self.feature_value(*left, feature).unwrap_or(0.0);
let b = self.feature_value(*right, feature).unwrap_or(0.0);
a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal)
});
let mut left_sum = 0.0;
let mut left_squares = 0.0;
for position in 0..count.saturating_sub(1) {
let Some((_, target)) = self.window.get(order[position]) else {
continue;
};
left_sum += *target;
left_squares += target * target;
let current = self.feature_value(order[position], feature).unwrap_or(0.0);
let next = self
.feature_value(order[position + 1], feature)
.unwrap_or(0.0);
if is_not_above(next, current) {
continue;
}
let left_count = position + 1;
let right_count = count - left_count;
if left_count < TREE_MIN_SAMPLES_LEAF || right_count < TREE_MIN_SAMPLES_LEAF {
continue;
}
let left_sse = (left_squares - left_sum * left_sum / left_count as f64).max(0.0);
let right_sum = total_sum - left_sum;
let right_sse = ((total_squares - left_squares)
- right_sum * right_sum / right_count as f64)
.max(0.0);
let gain = parent_sse - left_sse - right_sse;
if gain.is_finite() && gain > 0.0 && best.is_none_or(|(_, _, top)| gain > top) {
best = Some((feature, (current + next) / 2.0, gain));
}
}
}
best
}
fn grow(
&self,
indices: &[usize],
width: usize,
depth: usize,
importances: &mut [f64],
) -> TreeNode {
let leaf = || {
let (sum, _) = self.subset_moments(indices);
TreeNode::Leaf {
value: if indices.is_empty() {
0.0
} else {
sum / indices.len() as f64
},
}
};
if depth >= TREE_MAX_DEPTH || indices.len() < 2 * TREE_MIN_SAMPLES_LEAF {
return leaf();
}
let Some((feature, threshold, gain)) = self.best_split(indices, width) else {
return leaf();
};
if let Some(slot) = importances.get_mut(feature) {
*slot += gain;
}
let mut left = Vec::new();
let mut right = Vec::new();
for index in indices {
let value = self
.feature_value(*index, feature)
.unwrap_or(f64::NEG_INFINITY);
if value <= threshold {
left.push(*index);
} else {
right.push(*index);
}
}
if left.is_empty() || right.is_empty() {
return leaf();
}
TreeNode::Split {
feature,
threshold,
left: Box::new(self.grow(&left, width, depth + 1, importances)),
right: Box::new(self.grow(&right, width, depth + 1, importances)),
}
}
fn refit(&mut self) {
let width = self
.window
.iter()
.map(|(features, _)| features.len())
.min()
.unwrap_or(0);
if width == 0 || self.window.len() < 2 * TREE_MIN_SAMPLES_LEAF {
return;
}
let indices: Vec<usize> = (0..self.window.len()).collect();
let mut importances = vec![0.0; width];
let tree = self.grow(&indices, width, 0, &mut importances);
self.tree = Some(tree);
self.importances = importances;
if self.baseline_importances.is_empty() {
self.baseline_importances = self.importances.clone();
}
}
fn restart_after_detection(&mut self) {
self.tracker.reset();
self.baseline_importances.clear();
}
fn learn_one(&mut self, features: Vec<f64>, target: f64) {
if let Some(tree) = &self.tree {
let error = tree.predict(&features) - target;
self.tracker.observe(error * error);
if self.tracker.updates() == WARMUP_OBSERVATIONS
&& !self.importances.is_empty()
&& self.baseline_importances.is_empty()
{
self.baseline_importances = self.importances.clone();
}
}
if self.window.len() >= TREE_WINDOW_CAPACITY {
self.window.pop_front();
}
self.window.push_back((features, target));
self.since_refit += 1;
let due = self.tree.is_none() || self.since_refit >= TREE_REFIT_INTERVAL;
if due && self.window.len() >= 2 * TREE_MIN_SAMPLES_LEAF {
self.since_refit = 0;
self.refit();
}
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
for DecisionTreeDriftDetector<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)) = supervised_pair(data_point) {
self.learn_one(features, target);
trained += 1;
}
}
if trained == 0 && !data.is_empty() {
return Err(
"decision-tree 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 degradation = self.tracker.degradation().ok_or_else(|| {
"decision-tree drift detector has not scored any point yet (the tree is still \
being fit from its first window)"
.to_string()
})?;
let p_value = self.tracker.p_value()?;
let mut feature_importance_changes = Vec::with_capacity(self.importances.len());
for (index, current) in self.importances.iter().enumerate() {
let baseline = self.baseline_importances.get(index).copied().unwrap_or(0.0);
feature_importance_changes.push(from_f64::<A>(current - baseline)?);
}
let drift_detected = self.tracker.drift_detected()?;
let result = ModelDriftResult {
drift_detected,
performance_degradation: from_f64(degradation)?,
confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
feature_importance_changes,
};
if drift_detected {
self.restart_after_detection();
}
Ok(result)
}
fn reset_model(&mut self) -> Result<(), String> {
self.window.clear();
self.tree = None;
self.importances.clear();
self.baseline_importances.clear();
self.since_refit = 0;
self.tracker.reset();
Ok(())
}
}
pub struct EnsembleDriftDetector<A: Float + Send + Sync> {
members: Vec<Box<dyn ModelBasedDetector<A>>>,
names: Vec<&'static str>,
weights: Vec<f64>,
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum + 'static> EnsembleDriftDetector<A> {
pub fn new(sensitivity: f64) -> Result<Self, String> {
Self::with_weights(sensitivity, &[1.0, 1.0, 1.0])
}
pub fn with_weights(sensitivity: f64, weights: &[f64]) -> Result<Self, String> {
let members: Vec<Box<dyn ModelBasedDetector<A>>> = vec![
Box::new(LinearModelDetector::<A>::new(sensitivity)?),
Box::new(NeuralNetworkDriftDetector::<A>::new(sensitivity)?),
Box::new(DecisionTreeDriftDetector::<A>::new(sensitivity)?),
];
let names = vec!["linear", "neural_network", "decision_tree"];
if weights.len() != members.len() {
return Err(format!(
"ensemble drift detector has {} members but {} weights were supplied",
members.len(),
weights.len()
));
}
if weights.iter().any(|w| !(w.is_finite() && *w >= 0.0)) {
return Err("ensemble member weights must be finite and non-negative".to_string());
}
if weights.iter().sum::<f64>() <= 0.0 {
return Err("ensemble member weights must not sum to zero".to_string());
}
Ok(Self {
members,
names,
weights: weights.to_vec(),
})
}
pub fn member_names(&self) -> &[&'static str] {
&self.names
}
}
impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
for EnsembleDriftDetector<A>
{
fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String> {
for member in &mut self.members {
member.update_model(data)?;
}
Ok(())
}
fn detect_drift(
&mut self,
data: &[StreamingDataPoint<A>],
) -> Result<ModelDriftResult<A>, String> {
let mut results = Vec::with_capacity(self.members.len());
for (member, name) in self.members.iter_mut().zip(self.names.iter()) {
let result = member
.detect_drift(data)
.map_err(|error| format!("ensemble member `{name}` failed: {error}"))?;
results.push(result);
}
let total_weight: f64 = self.weights.iter().sum();
let mut votes = 0.0;
let mut degradation_sum = 0.0;
let mut log_p_sum = 0.0;
let mut widest = 0usize;
for (result, weight) in results.iter().zip(self.weights.iter()) {
if result.drift_detected {
votes += *weight;
}
degradation_sum += result
.performance_degradation
.to_f64()
.ok_or_else(|| "member degradation is not representable as f64".to_string())?
* *weight;
let confidence = result
.confidence
.to_f64()
.ok_or_else(|| "member confidence is not representable as f64".to_string())?;
let p = (1.0 - confidence).clamp(f64::MIN_POSITIVE, 1.0);
log_p_sum += p.ln();
widest = widest.max(result.feature_importance_changes.len());
}
let combined_p = stats::chi_square_sf(-2.0 * log_p_sum, 2.0 * results.len() as f64)?;
let degradation = degradation_sum / total_weight;
let mut feature_importance_changes = Vec::with_capacity(widest);
for index in 0..widest {
let mut sum = 0.0;
let mut weight_sum = 0.0;
for (result, weight) in results.iter().zip(self.weights.iter()) {
if let Some(change) = result.feature_importance_changes.get(index) {
let value = change
.to_f64()
.ok_or_else(|| "member importance is not representable".to_string())?;
sum += value * *weight;
weight_sum += *weight;
}
}
let mean = if weight_sum > 0.0 {
sum / weight_sum
} else {
0.0
};
feature_importance_changes.push(from_f64::<A>(mean)?);
}
Ok(ModelDriftResult {
drift_detected: votes > total_weight / 2.0,
performance_degradation: from_f64(degradation)?,
confidence: from_f64((1.0 - combined_p).clamp(0.0, 1.0))?,
feature_importance_changes,
})
}
fn reset_model(&mut self) -> Result<(), String> {
for member in &mut self.members {
member.reset_model()?;
}
Ok(())
}
}
impl<A: Float + Send + Sync> std::fmt::Debug for EnsembleDriftDetector<A> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("EnsembleDriftDetector")
.field("members", &self.names)
.field("weights", &self.weights)
.finish()
}
}
#[cfg(test)]
#[path = "drift_models_tests.rs"]
mod drift_models_tests;