optirs_core/streaming/adaptive_streaming/drift_models.rs
1// Real model-based drift detectors for `ModelType::{NeuralNetwork, DecisionTree, Ensemble}`.
2//
3// `ModelType` names four model families. Only `Linear` had an implementation
4// (`drift_tests::LinearModelDetector`); the other three were name-only variants
5// that `EnhancedDriftDetector` answered with an honest "no detector is
6// registered" error. This module supplies the three missing models.
7//
8// All three follow the same contract as the linear detector, because
9// `EnhancedDriftDetector::detect_model_drift` depends on it:
10//
11// * they are **supervised** — a `StreamingDataPoint` without a target cannot
12// train or score a predictor, so unlabelled points are skipped rather than
13// trained against a fabricated label;
14// * they are **prequential** — every labelled point is scored by the current
15// model *before* the model learns from it, which is the standard streaming
16// evaluation and the only ordering under which a rise in error means "the
17// world changed" rather than "the model has not seen this yet";
18// * `ModelDriftResult::confidence` is `1 - p` where `p` comes from a real null
19// distribution. `detect_model_drift` recovers the p-value as
20// `1 - confidence`, so anything else there (a vote share, a squashed score)
21// would make the reported significance a fabrication.
22//
23// Drift rule (shared, see [`PrequentialErrorTracker`]): a fast and a slow
24// exponentially-weighted mean of the squared prediction error are tracked
25// together. On a stationary stream both converge to the same value, so their
26// difference fluctuates around zero; a genuine change makes the fast mean jump
27// while the slow one lags. Drift is reported when the fast mean stays above
28// `slow * (1 + sensitivity)` for `MIN_DRIFT_RUN` consecutive observations *and*
29// a one-sided z test on the gap clears `DRIFT_SIGNIFICANCE` — the persistence
30// requirement that DDM and Page-Hinkley use to separate a sustained shift from
31// ordinary noise, plus a significance gate scaled by how much this particular
32// stream's squared error actually varies.
33//
34// Two properties make that rule survive contact with a *self-adapting* model,
35// which is what all three of these detectors monitor:
36//
37// * the baseline is **Winsorised** (`BASELINE_CLIP_SIGMAS`). The slow mean and
38// the spread the z test divides by are updated from a clipped deviation, so
39// the reference cannot be redefined by the very change it exists to detect.
40// Without it a single 100x jump multiplies the estimated spread by five
41// orders of magnitude on the first observation of the drift, and the
42// detector's own p-value climbs back through `0.05` while the error is still
43// several times its baseline.
44// * once drift is reported the error statistics **restart**, which is DDM's
45// published behaviour and what `DdmTest`/`PageHinkleyTest` already do in this
46// crate. The learned model is kept; only the reference is retired with the
47// concept it described.
48//
49// References
50// - Gama, Žliobaitė, Bifet, Pechenizkiy & Bouchachia, "A Survey on Concept
51// Drift Adaptation", ACM Computing Surveys 2014 (prequential evaluation,
52// error-rate drift detectors).
53// - Breiman, Friedman, Olshen & Stone, "Classification and Regression Trees",
54// 1984 (the CART split rule used by the tree detector).
55// - Fisher, "Statistical Methods for Research Workers", 1932 (the combination
56// of independent p-values used by the ensemble).
57
58use super::drift_detection::{ModelBasedDetector, ModelDriftResult};
59use super::drift_tests::LinearModelDetector;
60use super::optimizer::StreamingDataPoint;
61use super::statistics as stats;
62
63use crate::utils::try_scalar_str;
64use scirs2_core::numeric::Float;
65use std::collections::VecDeque;
66use std::marker::PhantomData;
67
68/// Weight of the most recent observation in the *fast* error mean.
69const ALPHA_FAST: f64 = 0.1;
70
71/// Weight of the most recent observation in the *slow* (baseline) error mean.
72const ALPHA_SLOW: f64 = 0.01;
73
74/// Consecutive observations that must exceed the degradation threshold before
75/// drift is reported.
76///
77/// This is deliberately several times the fast mean's correlation length
78/// (`1 / ALPHA_FAST = 10` observations). A single excursion of an
79/// exponentially-weighted mean above a threshold is *not* rare — successive
80/// values of the mean are ~90% correlated, so an excursion that happens at all
81/// typically lasts about one correlation length. Requiring five correlation
82/// lengths is what separates "the mean wandered" from "the level moved".
83///
84/// **This budget is only satisfiable because the baseline is Winsorised.** It is
85/// not a property of the fast/slow pair on its own: the models these detectors
86/// monitor re-adapt, so the excursion ends when either the model re-learns *or*
87/// the baseline climbs to meet the fast mean, whichever happens first. Measured
88/// with an unclipped baseline, a complete change of the target relationship
89/// produced a run of 41–49 observations before the slow mean overtook the fast
90/// one — just short of this constant, so the drift was missed. With the clip at
91/// [`BASELINE_CLIP_SIGMAS`] the same shift produces a run of 75+ and the verdict
92/// lands at observation 50.
93///
94/// The two constants therefore have to be tuned together: widening the clip
95/// lets the baseline climb faster and shortens the achievable run, and at
96/// `BASELINE_CLIP_SIGMAS = 6` the baseline overtakes the fast mean before this
97/// budget is met.
98const MIN_DRIFT_RUN: usize = 50;
99
100/// Significance the one-sided z test on the fast/slow gap must reach before
101/// drift is reported, in addition to the persistence rule above.
102///
103/// The two gates measure different things and both are needed: the persistence
104/// rule answers "has the level moved and stayed moved", the z test answers "is
105/// the gap large relative to how much this stream's squared error varies".
106/// `EnhancedDriftDetector` separately applies the *configured*
107/// `significance_level` to the p-value reported here, so this constant only
108/// governs the detector's own verdict.
109const DRIFT_SIGNIFICANCE: f64 = 0.05;
110
111/// Observations required before any verdict is issued, so that the slow mean
112/// has settled (its time constant is `1 / ALPHA_SLOW = 100` observations) and
113/// the model has left its initial learning transient.
114const WARMUP_OBSERVATIONS: usize = 200;
115
116/// Standard deviations at which the *baseline* update is Winsorised.
117///
118/// The baseline exists to say what the error level was **before** the change,
119/// so it must not absorb the change itself. Without this the detector blinds
120/// itself in a single step: the variance estimator is an EWMA of the squared
121/// deviation from the baseline, so one observation `k` standard deviations out
122/// multiplies the variance by roughly `ALPHA_SLOW * k^2`. A 100x jump in the
123/// squared error therefore inflates the estimated spread by five orders of
124/// magnitude *on the first observation of the drift*, which is exactly when the
125/// z test needs the pre-drift spread. Measured on the neural detector: the
126/// variance went from `1.0e-5` to `70` in one observation, and the p-value —
127/// `4e-5` on that first observation — climbed back above `0.05` twenty
128/// observations later while the error was still five times its baseline.
129///
130/// So the deviation that drives the slow mean and its spread is clipped to
131/// `+/- BASELINE_CLIP_SIGMAS` standard deviations, while the *fast* mean stays
132/// unclipped. That is the whole asymmetry the detector rests on: the fast mean
133/// must register the change, the baseline must not.
134///
135/// Four sigma is a deliberate choice rather than a round number. Winsorising
136/// biases the scale estimate downwards (the fixed point of
137/// `V = E[min(d^2, c^2 V)]` sits below `E[d^2]`), which makes the z test
138/// slightly liberal; a wider clip reduces that bias but also lets the baseline
139/// climb faster during a real excursion, since the baseline's per-observation
140/// step is `ALPHA_SLOW * c * sqrt(V)` and `V` itself then grows by a factor
141/// `1 + ALPHA_SLOW * (c^2 - 1)` per clipped observation. At `c = 4` the scale
142/// estimate settles around 85% of the true spread on a chi-square-like error
143/// distribution (a ~9% inflation of the z score, which the persistence rule
144/// absorbs), while the baseline needs well over [`MIN_DRIFT_RUN`] observations
145/// to climb far enough to end a genuine excursion. At `c = 6` the baseline
146/// overtakes the fast mean before the persistence rule is satisfied, and the
147/// drift is missed.
148const BASELINE_CLIP_SIGMAS: f64 = 4.0;
149
150/// Observations required before the Winsorising clip engages.
151///
152/// The clip radius is derived from the tracker's own spread estimate, so it can
153/// only be applied once that estimate describes something. Engaging it earlier
154/// would bootstrap the radius off the first one or two observations and could
155/// pin the baseline to whatever the stream happened to start at.
156const BASELINE_CLIP_WARMUP: usize = 30;
157
158/// Hidden units in the online MLP.
159const MLP_HIDDEN_UNITS: usize = 8;
160
161/// Seed for the deterministic weight initialiser. Fixed so that two detectors
162/// constructed the same way behave identically — a drift detector whose verdict
163/// depends on process-level entropy is not reproducible.
164const MLP_INIT_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
165
166/// Labelled observations retained by the decision-tree detector.
167const TREE_WINDOW_CAPACITY: usize = 256;
168
169/// Observations between two refits of the decision tree.
170const TREE_REFIT_INTERVAL: usize = 32;
171
172/// Maximum depth of the fitted tree (root counts as depth 0).
173const TREE_MAX_DEPTH: usize = 4;
174
175/// Minimum observations that must remain in each child of a split.
176const TREE_MIN_SAMPLES_LEAF: usize = 8;
177
178/// Converts a generic float into the element type, reporting an honest error.
179fn from_f64<A: Float>(value: f64) -> Result<A, String> {
180 try_scalar_str::<A, _>(value)
181}
182
183/// Whether `value` is **not** strictly greater than `bound`.
184///
185/// A NaN on either side answers `true`: it is not greater, and every caller
186/// here wants a NaN to take the conservative branch (no split, no clip radius,
187/// no significance) rather than to propagate. Written through `partial_cmp`
188/// rather than as `!(value > bound)` so that the incomparable case is visible
189/// in the code instead of hiding inside a negated float comparison.
190fn is_not_above(value: f64, bound: f64) -> bool {
191 !matches!(value.partial_cmp(&bound), Some(std::cmp::Ordering::Greater))
192}
193
194/// Extracts a supervised `(features, target)` pair from a data point.
195///
196/// A point without a target cannot train or score a predictor, so it is
197/// skipped rather than trained against an invented label.
198fn supervised_pair<A: Float + Send + Sync>(
199 data_point: &StreamingDataPoint<A>,
200) -> Option<(Vec<f64>, f64)> {
201 let target = data_point.target.as_ref()?;
202 let target_value = target.iter().next()?.to_f64()?;
203 if !target_value.is_finite() {
204 return None;
205 }
206 let features: Vec<f64> = data_point
207 .features
208 .iter()
209 .filter_map(|v| v.to_f64())
210 .filter(|v| v.is_finite())
211 .collect();
212 if features.is_empty() {
213 return None;
214 }
215 Some((features, target_value))
216}
217
218// ---------------------------------------------------------------------------
219// shared error tracking
220// ---------------------------------------------------------------------------
221
222/// Fast/slow exponentially-weighted tracking of a model's squared prediction
223/// error, plus the persistence rule that turns it into a drift verdict.
224///
225/// A single mean compared against a min-tracking baseline (what the linear
226/// detector does) drifts downwards on a stationary stream, so ordinary noise
227/// eventually clears any relative threshold. Comparing two means of the *same*
228/// series removes that bias: both are unbiased estimates of the current error
229/// level, so on a stationary stream their difference has mean zero regardless
230/// of how long the detector has been running.
231#[derive(Debug, Clone)]
232pub struct PrequentialErrorTracker {
233 /// Fast exponentially-weighted mean of the squared error.
234 fast: f64,
235 /// Slow exponentially-weighted mean of the squared error (the baseline).
236 slow: f64,
237 /// Exponentially-weighted variance of the squared error, for the z test.
238 variance: f64,
239 /// Observations folded in so far.
240 updates: usize,
241 /// Consecutive observations above the degradation threshold.
242 run_length: usize,
243 /// Relative rise in mean squared error that counts as degradation.
244 threshold: f64,
245}
246
247impl PrequentialErrorTracker {
248 /// Creates a tracker. `sensitivity` is the relative rise in mean squared
249 /// error that counts as degradation (`0.05` = a 5% rise).
250 fn new(sensitivity: f64) -> Result<Self, String> {
251 if !(sensitivity.is_finite() && sensitivity > 0.0) {
252 return Err(format!(
253 "model drift sensitivity must be positive and finite, got {sensitivity}"
254 ));
255 }
256 Ok(Self {
257 fast: f64::NAN,
258 slow: f64::NAN,
259 variance: 0.0,
260 updates: 0,
261 run_length: 0,
262 threshold: sensitivity,
263 })
264 }
265
266 /// Folds one squared prediction error into both means.
267 ///
268 /// The fast mean sees the observation as it is; the baseline and its spread
269 /// see it Winsorised to [`BASELINE_CLIP_SIGMAS`] standard deviations (see
270 /// that constant for why). `slow += ALPHA_SLOW * deviation` is algebraically
271 /// the same EWMA as `ALPHA_SLOW * x + (1 - ALPHA_SLOW) * slow`, written in
272 /// deviation form so the clip has somewhere to apply.
273 fn observe(&mut self, squared_error: f64) {
274 if !squared_error.is_finite() {
275 return;
276 }
277 if self.fast.is_finite() && self.slow.is_finite() {
278 let deviation = squared_error - self.slow;
279 let baseline_step = self.winsorise(deviation);
280 self.variance =
281 ALPHA_SLOW * baseline_step * baseline_step + (1.0 - ALPHA_SLOW) * self.variance;
282 self.fast = ALPHA_FAST * squared_error + (1.0 - ALPHA_FAST) * self.fast;
283 self.slow += ALPHA_SLOW * baseline_step;
284 } else {
285 self.fast = squared_error;
286 self.slow = squared_error;
287 self.variance = 0.0;
288 }
289 self.updates += 1;
290
291 if self.fast > self.slow * (1.0 + self.threshold) {
292 self.run_length += 1;
293 } else {
294 self.run_length = 0;
295 }
296 }
297
298 /// Clips a deviation from the baseline to [`BASELINE_CLIP_SIGMAS`] standard
299 /// deviations, once the spread estimate is old enough to define a radius.
300 ///
301 /// Two special cases:
302 ///
303 /// * Before [`BASELINE_CLIP_WARMUP`] observations there is no trustworthy
304 /// radius yet, so the deviation passes through unchanged.
305 /// * A spread of *exactly* zero — a model whose prequential error has not
306 /// varied at all, which a perfectly-fitting model on a noiseless stream
307 /// really does produce — is a degenerate reference with no scale to clip
308 /// against. An upward deviation is then held out entirely: it is the
309 /// drift, and letting it in would define both the baseline level *and*
310 /// the spread the z test divides by from the very change under test. (One
311 /// such observation is enough to blind the detector: `ALPHA_SLOW * d^2`
312 /// for `d = 152100` is a variance of `2.3e8`, against which the whole
313 /// excursion then looks like ordinary noise.) A *downward* deviation is
314 /// adopted in full: a model doing better than its reference is not drift,
315 /// and adopting it is what re-establishes a usable scale.
316 fn winsorise(&self, deviation: f64) -> f64 {
317 if self.updates < BASELINE_CLIP_WARMUP {
318 return deviation;
319 }
320 if is_not_above(self.variance, 0.0) {
321 return deviation.min(0.0);
322 }
323 let radius = BASELINE_CLIP_SIGMAS * self.variance.sqrt();
324 deviation.clamp(-radius, radius)
325 }
326
327 /// Relative rise of the fast mean over the slow one, or `None` before the
328 /// first observation.
329 fn degradation(&self) -> Option<f64> {
330 if !(self.fast.is_finite() && self.slow.is_finite()) {
331 return None;
332 }
333 Some((self.fast - self.slow) / self.slow.abs().max(f64::MIN_POSITIVE))
334 }
335
336 /// One-sided p-value for "the recent squared error sits above the
337 /// baseline".
338 ///
339 /// The standard error uses the *effective* sample size of the fast mean,
340 /// `(2 - alpha) / alpha`, not the total number of updates: an
341 /// exponentially-weighted mean averages over roughly that many recent
342 /// observations no matter how long the stream is, so dividing by the full
343 /// update count would shrink the standard error without bound and make an
344 /// arbitrarily small difference look decisive.
345 fn p_value(&self) -> Result<f64, String> {
346 let (Some(fast), Some(slow)) = (
347 self.fast.is_finite().then_some(self.fast),
348 self.slow.is_finite().then_some(self.slow),
349 ) else {
350 return Ok(1.0);
351 };
352 let effective_n = (2.0 - ALPHA_FAST) / ALPHA_FAST;
353 let standard_error = (self.variance / effective_n).sqrt();
354 if is_not_above(standard_error, 0.0) {
355 // Degenerate reference: the baseline error had no spread at all.
356 // Under that null the error is a point mass, so *any* rise above it
357 // has probability zero and any other outcome is the null itself.
358 // Returning the mid-value `0.5` here (what `sf(0)` gives) would
359 // silently veto every verdict on such a stream, which is the
360 // opposite of what a zero-variance reference implies.
361 return Ok(if fast > slow { 0.0 } else { 1.0 });
362 }
363 stats::standard_normal_sf((fast - slow) / standard_error)
364 }
365
366 /// Whether enough observations have accumulated for a verdict.
367 fn ready(&self) -> bool {
368 self.updates >= WARMUP_OBSERVATIONS
369 }
370
371 /// Whether a sustained *and* statistically significant degradation is
372 /// currently in force.
373 fn drift_detected(&self) -> Result<bool, String> {
374 if !(self.ready() && self.run_length >= MIN_DRIFT_RUN) {
375 return Ok(false);
376 }
377 Ok(self.p_value()? < DRIFT_SIGNIFICANCE)
378 }
379
380 /// Length of the current run of observations above the threshold.
381 #[cfg(test)]
382 fn run_length(&self) -> usize {
383 self.run_length
384 }
385
386 /// Observations folded in so far.
387 fn updates(&self) -> usize {
388 self.updates
389 }
390
391 fn reset(&mut self) {
392 self.fast = f64::NAN;
393 self.slow = f64::NAN;
394 self.variance = 0.0;
395 self.updates = 0;
396 self.run_length = 0;
397 }
398}
399
400// ---------------------------------------------------------------------------
401// neural network
402// ---------------------------------------------------------------------------
403
404/// Deterministic xorshift64* generator, used only to break the symmetry of the
405/// initial hidden layer.
406///
407/// An all-zero (or all-equal) initialisation is degenerate for a fully
408/// connected layer: every hidden unit receives the same gradient forever and
409/// the network can never represent more than one unit's worth of function. A
410/// fixed-seed generator breaks that symmetry while keeping the detector
411/// reproducible.
412#[derive(Debug, Clone)]
413struct SymmetryBreaker {
414 state: u64,
415}
416
417impl SymmetryBreaker {
418 fn new(seed: u64) -> Self {
419 Self {
420 state: if seed == 0 { MLP_INIT_SEED } else { seed },
421 }
422 }
423
424 /// Next value, uniform on `[-1, 1)`.
425 fn next_signed_unit(&mut self) -> f64 {
426 let mut x = self.state;
427 x ^= x >> 12;
428 x ^= x << 25;
429 x ^= x >> 27;
430 self.state = x;
431 let scrambled = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
432 let unit = (scrambled >> 11) as f64 / ((1u64 << 53) as f64);
433 unit * 2.0 - 1.0
434 }
435}
436
437/// Online multilayer perceptron drift detector.
438///
439/// One hidden layer of `MLP_HIDDEN_UNITS` `tanh` units over the feature
440/// window, a linear output, and a stochastic-gradient step on the squared
441/// prediction error taken once per labelled point.
442///
443/// The step is *normalised* by the instantaneous input energy (`1 + ||x||^2`
444/// for the hidden layer, `1 + ||a||^2` for the output layer), exactly as the
445/// linear detector's normalised-LMS update is. Streaming features are not
446/// standardised and their scale is unknown a priori, so a raw step size that is
447/// stable for one stream diverges on another; normalising makes the update
448/// scale-invariant and stable for any `0 < learning_rate < 2`.
449#[derive(Debug, Clone)]
450pub struct NeuralNetworkDriftDetector<A: Float + Send + Sync> {
451 /// Hidden-layer weights, `[hidden][input]`.
452 hidden_weights: Vec<Vec<f64>>,
453 /// Hidden-layer biases.
454 hidden_bias: Vec<f64>,
455 /// Output-layer weights, one per hidden unit.
456 output_weights: Vec<f64>,
457 /// Output-layer bias.
458 output_bias: f64,
459 /// Number of input features the network currently spans.
460 input_width: usize,
461 /// Step size of the normalised SGD update.
462 learning_rate: f64,
463 /// L2 weight-decay coefficient.
464 l2_lambda: f64,
465 /// Deterministic initialiser for newly grown weights.
466 initialiser: SymmetryBreaker,
467 /// Linearised input sensitivity captured when the baseline was taken.
468 baseline_sensitivity: Vec<f64>,
469 /// Error tracking and the drift rule.
470 tracker: PrequentialErrorTracker,
471 _marker: PhantomData<A>,
472}
473
474impl<A: Float + Send + Sync> NeuralNetworkDriftDetector<A> {
475 /// Creates a detector. `sensitivity` is the relative rise in mean squared
476 /// error that counts as drift.
477 pub fn new(sensitivity: f64) -> Result<Self, String> {
478 Ok(Self {
479 hidden_weights: vec![Vec::new(); MLP_HIDDEN_UNITS],
480 hidden_bias: vec![0.0; MLP_HIDDEN_UNITS],
481 output_weights: Vec::new(),
482 output_bias: 0.0,
483 input_width: 0,
484 // Mid-range normalised step: stable for any input scale.
485 learning_rate: 0.5,
486 l2_lambda: 1e-5,
487 initialiser: SymmetryBreaker::new(MLP_INIT_SEED),
488 baseline_sensitivity: Vec::new(),
489 tracker: PrequentialErrorTracker::new(sensitivity)?,
490 _marker: PhantomData,
491 })
492 }
493
494 /// Number of input features the network currently spans.
495 pub fn input_width(&self) -> usize {
496 self.input_width
497 }
498
499 /// Current fast mean of the squared prediction error, or `None` before the
500 /// first supervised update.
501 pub fn current_error(&self) -> Option<f64> {
502 self.tracker.fast.is_finite().then_some(self.tracker.fast)
503 }
504
505 /// Grows the network to span `width` inputs, initialising the new columns
506 /// with the deterministic symmetry breaker scaled by the Glorot factor.
507 fn grow_to(&mut self, width: usize) {
508 if width <= self.input_width {
509 return;
510 }
511 if self.output_weights.is_empty() {
512 // Glorot uniform for the output layer: fan_in = hidden units,
513 // fan_out = 1.
514 let scale = (6.0 / (MLP_HIDDEN_UNITS as f64 + 1.0)).sqrt();
515 self.output_weights = (0..MLP_HIDDEN_UNITS)
516 .map(|_| self.initialiser.next_signed_unit() * scale)
517 .collect();
518 }
519 let scale = (6.0 / (width as f64 + MLP_HIDDEN_UNITS as f64)).sqrt();
520 for row in &mut self.hidden_weights {
521 while row.len() < width {
522 row.push(self.initialiser.next_signed_unit() * scale);
523 }
524 }
525 self.input_width = width;
526 }
527
528 /// Hidden activations for one input vector.
529 fn activate(&self, features: &[f64]) -> Vec<f64> {
530 self.hidden_weights
531 .iter()
532 .zip(self.hidden_bias.iter())
533 .map(|(row, bias)| {
534 let mut sum = *bias;
535 for (weight, value) in row.iter().zip(features.iter()) {
536 sum += weight * value;
537 }
538 sum.tanh()
539 })
540 .collect()
541 }
542
543 fn predict_from(&self, activations: &[f64]) -> f64 {
544 let mut prediction = self.output_bias;
545 for (weight, activation) in self.output_weights.iter().zip(activations.iter()) {
546 prediction += weight * activation;
547 }
548 prediction
549 }
550
551 /// Linearised sensitivity of the output to each input feature,
552 /// `sum_h |w2_h * w1[h][i]|`. This is a real property of the trained
553 /// network (the magnitude of the first-order term at the operating point
554 /// `tanh'(0) = 1`), so its change since the baseline is a measured
555 /// feature-importance shift rather than an invented score.
556 fn input_sensitivity(&self) -> Vec<f64> {
557 (0..self.input_width)
558 .map(|index| {
559 self.hidden_weights
560 .iter()
561 .zip(self.output_weights.iter())
562 .map(|(row, output_weight)| {
563 row.get(index)
564 .map(|weight| (weight * output_weight).abs())
565 .unwrap_or(0.0)
566 })
567 .sum()
568 })
569 .collect()
570 }
571
572 /// Restarts the error statistics after a drift has been reported, leaving
573 /// the learned network in place.
574 ///
575 /// This is DDM's published post-detection restart, and the same restart
576 /// `DdmTest` and `PageHinkleyTest` perform in this crate: once the verdict
577 /// has been issued, the reference the verdict was measured against belongs
578 /// to the old concept and must not be carried into the new one. Without it
579 /// a baseline that (correctly) refused to absorb the drift would keep the
580 /// detector alarmed for as long as it took the fast mean to decay — several
581 /// thousand observations on a stream whose reference error was exactly
582 /// zero. The importance baseline is cleared with it, so the next concept is
583 /// compared against its own starting point rather than the previous one's.
584 ///
585 /// The cost is [`WARMUP_OBSERVATIONS`]: the detector issues no further
586 /// verdict until the restarted statistics have settled, so a second drift
587 /// arriving inside that window is not reported. Both halves of that
588 /// constant's justification survive a restart and neither can be shortened
589 /// here. The slow mean still needs its two time constants to settle (after
590 /// 100 observations 37% of its weight is still the single squared error it
591 /// was re-seeded from, after 200 it is 13%), and the model is *not* already
592 /// trained: a concept change puts it into a fresh learning transient, whose
593 /// legitimately elevated and noisy error is exactly what a shortened
594 /// warm-up would start testing against a half-formed baseline.
595 fn restart_after_detection(&mut self) {
596 self.tracker.reset();
597 self.baseline_sensitivity.clear();
598 }
599
600 /// Scores the point, folds the observed error into the tracker, then takes
601 /// one gradient step (prequential order).
602 fn learn_one(&mut self, features: &[f64], target: f64) {
603 self.grow_to(features.len());
604
605 let activations = self.activate(features);
606 let prediction = self.predict_from(&activations);
607 let error = prediction - target;
608 self.tracker.observe(error * error);
609
610 if self.tracker.updates() == WARMUP_OBSERVATIONS || self.baseline_sensitivity.is_empty() {
611 self.baseline_sensitivity = self.input_sensitivity();
612 }
613
614 // Backpropagation. The hidden deltas use the output weights *before*
615 // they are updated, which is what the chain rule prescribes.
616 let hidden_deltas: Vec<f64> = activations
617 .iter()
618 .zip(self.output_weights.iter())
619 .map(|(activation, output_weight)| {
620 error * output_weight * (1.0 - activation * activation)
621 })
622 .collect();
623
624 let output_energy = 1.0 + activations.iter().map(|a| a * a).sum::<f64>();
625 let output_step = self.learning_rate * error / output_energy;
626 for (weight, activation) in self.output_weights.iter_mut().zip(activations.iter()) {
627 *weight -= output_step * activation + self.l2_lambda * *weight;
628 }
629 self.output_bias -= output_step;
630
631 let input_energy = 1.0 + features.iter().map(|f| f * f).sum::<f64>();
632 let hidden_step = self.learning_rate / input_energy;
633 for ((row, bias), delta) in self
634 .hidden_weights
635 .iter_mut()
636 .zip(self.hidden_bias.iter_mut())
637 .zip(hidden_deltas.iter())
638 {
639 for (weight, value) in row.iter_mut().zip(features.iter()) {
640 *weight -= hidden_step * delta * value + self.l2_lambda * *weight;
641 }
642 *bias -= hidden_step * delta;
643 }
644 }
645}
646
647impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
648 for NeuralNetworkDriftDetector<A>
649{
650 fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String> {
651 let mut trained = 0usize;
652 for data_point in data {
653 if let Some((features, target)) = supervised_pair(data_point) {
654 self.learn_one(&features, target);
655 trained += 1;
656 }
657 }
658 if trained == 0 && !data.is_empty() {
659 return Err(
660 "neural-network drift detector requires labelled data points (target is None)"
661 .to_string(),
662 );
663 }
664 Ok(())
665 }
666
667 fn detect_drift(
668 &mut self,
669 data: &[StreamingDataPoint<A>],
670 ) -> Result<ModelDriftResult<A>, String> {
671 self.update_model(data)?;
672
673 let degradation = self
674 .tracker
675 .degradation()
676 .ok_or_else(|| "neural-network drift detector has no error estimate yet".to_string())?;
677 let p_value = self.tracker.p_value()?;
678
679 let sensitivity = self.input_sensitivity();
680 let mut feature_importance_changes = Vec::with_capacity(sensitivity.len());
681 for (index, current) in sensitivity.iter().enumerate() {
682 let baseline = self.baseline_sensitivity.get(index).copied().unwrap_or(0.0);
683 feature_importance_changes.push(from_f64::<A>(current - baseline)?);
684 }
685
686 let drift_detected = self.tracker.drift_detected()?;
687 let result = ModelDriftResult {
688 drift_detected,
689 performance_degradation: from_f64(degradation)?,
690 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
691 feature_importance_changes,
692 };
693 if drift_detected {
694 self.restart_after_detection();
695 }
696 Ok(result)
697 }
698
699 fn reset_model(&mut self) -> Result<(), String> {
700 self.hidden_weights = vec![Vec::new(); MLP_HIDDEN_UNITS];
701 self.hidden_bias = vec![0.0; MLP_HIDDEN_UNITS];
702 self.output_weights.clear();
703 self.output_bias = 0.0;
704 self.input_width = 0;
705 self.initialiser = SymmetryBreaker::new(MLP_INIT_SEED);
706 self.baseline_sensitivity.clear();
707 self.tracker.reset();
708 Ok(())
709 }
710}
711
712// ---------------------------------------------------------------------------
713// decision tree
714// ---------------------------------------------------------------------------
715
716/// A node of the fitted regression tree.
717#[derive(Debug, Clone)]
718enum TreeNode {
719 /// Terminal node holding the mean target of the observations that reached
720 /// it.
721 Leaf { value: f64 },
722 /// Internal node testing `features[feature] <= threshold`.
723 Split {
724 feature: usize,
725 threshold: f64,
726 left: Box<TreeNode>,
727 right: Box<TreeNode>,
728 },
729}
730
731impl TreeNode {
732 fn predict(&self, features: &[f64]) -> f64 {
733 match self {
734 TreeNode::Leaf { value } => *value,
735 TreeNode::Split {
736 feature,
737 threshold,
738 left,
739 right,
740 } => {
741 // A row that does not carry this feature cannot be routed by
742 // it; it follows the left branch, which is the branch the
743 // fitting code puts the low side of the split on.
744 let value = features.get(*feature).copied().unwrap_or(f64::NEG_INFINITY);
745 if value <= *threshold {
746 left.predict(features)
747 } else {
748 right.predict(features)
749 }
750 }
751 }
752 }
753}
754
755/// Depth-limited CART regression tree over a sliding window of labelled
756/// observations.
757///
758/// The tree is refit from the window every `TREE_REFIT_INTERVAL`
759/// observations rather than grown incrementally — a periodically refit CART,
760/// not a Hoeffding tree. Each split is the `(feature, threshold)` pair that
761/// maximally *diverges* the two children's squared-error rates from the
762/// parent's, i.e. that maximises `SSE(parent) - SSE(left) - SSE(right)`, which
763/// is CART's variance-reduction criterion.
764#[derive(Debug, Clone)]
765pub struct DecisionTreeDriftDetector<A: Float + Send + Sync> {
766 /// Sliding window of labelled observations the tree is fit from.
767 window: VecDeque<(Vec<f64>, f64)>,
768 /// Fitted tree, absent until the window has enough observations.
769 tree: Option<TreeNode>,
770 /// Total squared-error reduction attributed to each feature by the current
771 /// tree — CART's own feature-importance measure.
772 importances: Vec<f64>,
773 /// Importance snapshot taken when the baseline was established.
774 baseline_importances: Vec<f64>,
775 /// Observations since the last refit.
776 since_refit: usize,
777 /// Error tracking and the drift rule.
778 tracker: PrequentialErrorTracker,
779 _marker: PhantomData<A>,
780}
781
782impl<A: Float + Send + Sync> DecisionTreeDriftDetector<A> {
783 /// Creates a detector. `sensitivity` is the relative rise in mean squared
784 /// error that counts as drift.
785 pub fn new(sensitivity: f64) -> Result<Self, String> {
786 Ok(Self {
787 window: VecDeque::with_capacity(TREE_WINDOW_CAPACITY),
788 tree: None,
789 importances: Vec::new(),
790 baseline_importances: Vec::new(),
791 since_refit: 0,
792 tracker: PrequentialErrorTracker::new(sensitivity)?,
793 _marker: PhantomData,
794 })
795 }
796
797 /// Whether a tree has been fit yet.
798 pub fn is_fitted(&self) -> bool {
799 self.tree.is_some()
800 }
801
802 /// Per-feature squared-error reduction attributed by the current tree.
803 pub fn feature_importances(&self) -> &[f64] {
804 &self.importances
805 }
806
807 /// Sum and sum-of-squares of the targets of a subset.
808 fn subset_moments(&self, indices: &[usize]) -> (f64, f64) {
809 let mut sum = 0.0;
810 let mut sum_squares = 0.0;
811 for index in indices {
812 if let Some((_, target)) = self.window.get(*index) {
813 sum += *target;
814 sum_squares += target * target;
815 }
816 }
817 (sum, sum_squares)
818 }
819
820 fn subset_sse(&self, indices: &[usize]) -> f64 {
821 if indices.is_empty() {
822 return 0.0;
823 }
824 let (sum, sum_squares) = self.subset_moments(indices);
825 (sum_squares - sum * sum / indices.len() as f64).max(0.0)
826 }
827
828 fn feature_value(&self, index: usize, feature: usize) -> Option<f64> {
829 self.window
830 .get(index)
831 .and_then(|(features, _)| features.get(feature).copied())
832 }
833
834 /// Best `(feature, threshold, gain)` split of a subset, or `None` when no
835 /// admissible split reduces the squared error.
836 fn best_split(&self, indices: &[usize], width: usize) -> Option<(usize, f64, f64)> {
837 let parent_sse = self.subset_sse(indices);
838 if is_not_above(parent_sse, 0.0) {
839 return None;
840 }
841 let (total_sum, total_squares) = self.subset_moments(indices);
842 let count = indices.len();
843
844 let mut best: Option<(usize, f64, f64)> = None;
845 let mut order: Vec<usize> = Vec::with_capacity(count);
846 for feature in 0..width {
847 order.clear();
848 order.extend_from_slice(indices);
849 order.sort_by(|left, right| {
850 let a = self.feature_value(*left, feature).unwrap_or(0.0);
851 let b = self.feature_value(*right, feature).unwrap_or(0.0);
852 a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal)
853 });
854
855 let mut left_sum = 0.0;
856 let mut left_squares = 0.0;
857 for position in 0..count.saturating_sub(1) {
858 let Some((_, target)) = self.window.get(order[position]) else {
859 continue;
860 };
861 left_sum += *target;
862 left_squares += target * target;
863
864 let current = self.feature_value(order[position], feature).unwrap_or(0.0);
865 let next = self
866 .feature_value(order[position + 1], feature)
867 .unwrap_or(0.0);
868 if is_not_above(next, current) {
869 // Identical feature values cannot be separated.
870 continue;
871 }
872 let left_count = position + 1;
873 let right_count = count - left_count;
874 if left_count < TREE_MIN_SAMPLES_LEAF || right_count < TREE_MIN_SAMPLES_LEAF {
875 continue;
876 }
877 let left_sse = (left_squares - left_sum * left_sum / left_count as f64).max(0.0);
878 let right_sum = total_sum - left_sum;
879 let right_sse = ((total_squares - left_squares)
880 - right_sum * right_sum / right_count as f64)
881 .max(0.0);
882 let gain = parent_sse - left_sse - right_sse;
883 if gain.is_finite() && gain > 0.0 && best.is_none_or(|(_, _, top)| gain > top) {
884 best = Some((feature, (current + next) / 2.0, gain));
885 }
886 }
887 }
888 best
889 }
890
891 fn grow(
892 &self,
893 indices: &[usize],
894 width: usize,
895 depth: usize,
896 importances: &mut [f64],
897 ) -> TreeNode {
898 let leaf = || {
899 let (sum, _) = self.subset_moments(indices);
900 TreeNode::Leaf {
901 value: if indices.is_empty() {
902 0.0
903 } else {
904 sum / indices.len() as f64
905 },
906 }
907 };
908 if depth >= TREE_MAX_DEPTH || indices.len() < 2 * TREE_MIN_SAMPLES_LEAF {
909 return leaf();
910 }
911 let Some((feature, threshold, gain)) = self.best_split(indices, width) else {
912 return leaf();
913 };
914 if let Some(slot) = importances.get_mut(feature) {
915 *slot += gain;
916 }
917
918 let mut left = Vec::new();
919 let mut right = Vec::new();
920 for index in indices {
921 let value = self
922 .feature_value(*index, feature)
923 .unwrap_or(f64::NEG_INFINITY);
924 if value <= threshold {
925 left.push(*index);
926 } else {
927 right.push(*index);
928 }
929 }
930 if left.is_empty() || right.is_empty() {
931 return leaf();
932 }
933
934 TreeNode::Split {
935 feature,
936 threshold,
937 left: Box::new(self.grow(&left, width, depth + 1, importances)),
938 right: Box::new(self.grow(&right, width, depth + 1, importances)),
939 }
940 }
941
942 /// Refits the tree from the current window.
943 ///
944 /// Only the features every retained row actually carries are considered: a
945 /// row with a shorter feature vector has no value for the wider columns,
946 /// and substituting a zero would invent an observation.
947 fn refit(&mut self) {
948 let width = self
949 .window
950 .iter()
951 .map(|(features, _)| features.len())
952 .min()
953 .unwrap_or(0);
954 if width == 0 || self.window.len() < 2 * TREE_MIN_SAMPLES_LEAF {
955 return;
956 }
957 let indices: Vec<usize> = (0..self.window.len()).collect();
958 let mut importances = vec![0.0; width];
959 let tree = self.grow(&indices, width, 0, &mut importances);
960 self.tree = Some(tree);
961 self.importances = importances;
962 if self.baseline_importances.is_empty() {
963 self.baseline_importances = self.importances.clone();
964 }
965 }
966
967 /// Restarts the error statistics after a drift has been reported, leaving
968 /// the fitted tree and its window in place.
969 ///
970 /// See [`NeuralNetworkDriftDetector::restart_after_detection`] for why the
971 /// reference cannot outlive the concept it was measured on, and for the
972 /// [`WARMUP_OBSERVATIONS`] blind window the restart costs. The window is
973 /// deliberately *not* cleared: the tree is the model, and discarding the
974 /// model on every verdict would make the next verdict meaningless.
975 fn restart_after_detection(&mut self) {
976 self.tracker.reset();
977 self.baseline_importances.clear();
978 }
979
980 /// Scores the point with the current tree, folds the error in, then adds
981 /// it to the window (prequential order) and refits on schedule.
982 fn learn_one(&mut self, features: Vec<f64>, target: f64) {
983 if let Some(tree) = &self.tree {
984 let error = tree.predict(&features) - target;
985 self.tracker.observe(error * error);
986 if self.tracker.updates() == WARMUP_OBSERVATIONS
987 && !self.importances.is_empty()
988 && self.baseline_importances.is_empty()
989 {
990 self.baseline_importances = self.importances.clone();
991 }
992 }
993
994 if self.window.len() >= TREE_WINDOW_CAPACITY {
995 self.window.pop_front();
996 }
997 self.window.push_back((features, target));
998 self.since_refit += 1;
999
1000 let due = self.tree.is_none() || self.since_refit >= TREE_REFIT_INTERVAL;
1001 if due && self.window.len() >= 2 * TREE_MIN_SAMPLES_LEAF {
1002 self.since_refit = 0;
1003 self.refit();
1004 }
1005 }
1006}
1007
1008impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
1009 for DecisionTreeDriftDetector<A>
1010{
1011 fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String> {
1012 let mut trained = 0usize;
1013 for data_point in data {
1014 if let Some((features, target)) = supervised_pair(data_point) {
1015 self.learn_one(features, target);
1016 trained += 1;
1017 }
1018 }
1019 if trained == 0 && !data.is_empty() {
1020 return Err(
1021 "decision-tree drift detector requires labelled data points (target is None)"
1022 .to_string(),
1023 );
1024 }
1025 Ok(())
1026 }
1027
1028 fn detect_drift(
1029 &mut self,
1030 data: &[StreamingDataPoint<A>],
1031 ) -> Result<ModelDriftResult<A>, String> {
1032 self.update_model(data)?;
1033
1034 let degradation = self.tracker.degradation().ok_or_else(|| {
1035 "decision-tree drift detector has not scored any point yet (the tree is still \
1036 being fit from its first window)"
1037 .to_string()
1038 })?;
1039 let p_value = self.tracker.p_value()?;
1040
1041 let mut feature_importance_changes = Vec::with_capacity(self.importances.len());
1042 for (index, current) in self.importances.iter().enumerate() {
1043 let baseline = self.baseline_importances.get(index).copied().unwrap_or(0.0);
1044 feature_importance_changes.push(from_f64::<A>(current - baseline)?);
1045 }
1046
1047 let drift_detected = self.tracker.drift_detected()?;
1048 let result = ModelDriftResult {
1049 drift_detected,
1050 performance_degradation: from_f64(degradation)?,
1051 confidence: from_f64((1.0 - p_value).clamp(0.0, 1.0))?,
1052 feature_importance_changes,
1053 };
1054 if drift_detected {
1055 self.restart_after_detection();
1056 }
1057 Ok(result)
1058 }
1059
1060 fn reset_model(&mut self) -> Result<(), String> {
1061 self.window.clear();
1062 self.tree = None;
1063 self.importances.clear();
1064 self.baseline_importances.clear();
1065 self.since_refit = 0;
1066 self.tracker.reset();
1067 Ok(())
1068 }
1069}
1070
1071// ---------------------------------------------------------------------------
1072// ensemble
1073// ---------------------------------------------------------------------------
1074
1075/// Ensemble of model-based drift detectors.
1076///
1077/// The ensemble owns **its own** freshly constructed linear, neural-network and
1078/// decision-tree members rather than borrowing the instances registered in
1079/// `EnhancedDriftDetector::model_detectors`: those are keyed by `ModelType` in
1080/// the same map this detector is stored in, so they cannot be aliased, and
1081/// sharing them would also mean the ensemble's verdict depended on how often
1082/// the other model types happened to be selected.
1083///
1084/// The verdict is a weighted majority of the members' own verdicts. Weights
1085/// default to uniform, which is exactly a plain majority vote; a caller who has
1086/// measured the members' relative reliability can supply their own through
1087/// [`EnsembleDriftDetector::with_weights`].
1088///
1089/// The reported significance is Fisher's combination of the members' p-values,
1090/// `-2 * sum(ln p_i) ~ chi^2(2k)`. Averaging the members' confidences would not
1091/// be a p-value at all, and `EnhancedDriftDetector` recovers the p-value from
1092/// `1 - confidence`, so it has to be a real one.
1093pub struct EnsembleDriftDetector<A: Float + Send + Sync> {
1094 members: Vec<Box<dyn ModelBasedDetector<A>>>,
1095 names: Vec<&'static str>,
1096 weights: Vec<f64>,
1097}
1098
1099impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum + 'static> EnsembleDriftDetector<A> {
1100 /// Creates an ensemble with uniform member weights (a plain majority vote).
1101 pub fn new(sensitivity: f64) -> Result<Self, String> {
1102 Self::with_weights(sensitivity, &[1.0, 1.0, 1.0])
1103 }
1104
1105 /// Creates an ensemble with explicit member weights, in the order
1106 /// `[linear, neural_network, decision_tree]`.
1107 pub fn with_weights(sensitivity: f64, weights: &[f64]) -> Result<Self, String> {
1108 let members: Vec<Box<dyn ModelBasedDetector<A>>> = vec![
1109 Box::new(LinearModelDetector::<A>::new(sensitivity)?),
1110 Box::new(NeuralNetworkDriftDetector::<A>::new(sensitivity)?),
1111 Box::new(DecisionTreeDriftDetector::<A>::new(sensitivity)?),
1112 ];
1113 let names = vec!["linear", "neural_network", "decision_tree"];
1114 if weights.len() != members.len() {
1115 return Err(format!(
1116 "ensemble drift detector has {} members but {} weights were supplied",
1117 members.len(),
1118 weights.len()
1119 ));
1120 }
1121 if weights.iter().any(|w| !(w.is_finite() && *w >= 0.0)) {
1122 return Err("ensemble member weights must be finite and non-negative".to_string());
1123 }
1124 if weights.iter().sum::<f64>() <= 0.0 {
1125 return Err("ensemble member weights must not sum to zero".to_string());
1126 }
1127 Ok(Self {
1128 members,
1129 names,
1130 weights: weights.to_vec(),
1131 })
1132 }
1133
1134 /// Names of the members, in vote order.
1135 pub fn member_names(&self) -> &[&'static str] {
1136 &self.names
1137 }
1138}
1139
1140impl<A: Float + Default + Clone + Send + Sync + std::iter::Sum> ModelBasedDetector<A>
1141 for EnsembleDriftDetector<A>
1142{
1143 fn update_model(&mut self, data: &[StreamingDataPoint<A>]) -> Result<(), String> {
1144 for member in &mut self.members {
1145 member.update_model(data)?;
1146 }
1147 Ok(())
1148 }
1149
1150 fn detect_drift(
1151 &mut self,
1152 data: &[StreamingDataPoint<A>],
1153 ) -> Result<ModelDriftResult<A>, String> {
1154 let mut results = Vec::with_capacity(self.members.len());
1155 for (member, name) in self.members.iter_mut().zip(self.names.iter()) {
1156 let result = member
1157 .detect_drift(data)
1158 .map_err(|error| format!("ensemble member `{name}` failed: {error}"))?;
1159 results.push(result);
1160 }
1161
1162 let total_weight: f64 = self.weights.iter().sum();
1163 let mut votes = 0.0;
1164 let mut degradation_sum = 0.0;
1165 let mut log_p_sum = 0.0;
1166 let mut widest = 0usize;
1167 for (result, weight) in results.iter().zip(self.weights.iter()) {
1168 if result.drift_detected {
1169 votes += *weight;
1170 }
1171 degradation_sum += result
1172 .performance_degradation
1173 .to_f64()
1174 .ok_or_else(|| "member degradation is not representable as f64".to_string())?
1175 * *weight;
1176 let confidence = result
1177 .confidence
1178 .to_f64()
1179 .ok_or_else(|| "member confidence is not representable as f64".to_string())?;
1180 // Fisher's method needs a strictly positive p; a member reporting
1181 // an exact zero is clamped to the smallest positive double rather
1182 // than making the combined statistic infinite.
1183 let p = (1.0 - confidence).clamp(f64::MIN_POSITIVE, 1.0);
1184 log_p_sum += p.ln();
1185 widest = widest.max(result.feature_importance_changes.len());
1186 }
1187
1188 let combined_p = stats::chi_square_sf(-2.0 * log_p_sum, 2.0 * results.len() as f64)?;
1189 let degradation = degradation_sum / total_weight;
1190
1191 // Element-wise weighted mean of the members' feature-importance
1192 // changes. The members disagree about how many features they track, so
1193 // each index averages only over the members that actually report it.
1194 let mut feature_importance_changes = Vec::with_capacity(widest);
1195 for index in 0..widest {
1196 let mut sum = 0.0;
1197 let mut weight_sum = 0.0;
1198 for (result, weight) in results.iter().zip(self.weights.iter()) {
1199 if let Some(change) = result.feature_importance_changes.get(index) {
1200 let value = change
1201 .to_f64()
1202 .ok_or_else(|| "member importance is not representable".to_string())?;
1203 sum += value * *weight;
1204 weight_sum += *weight;
1205 }
1206 }
1207 let mean = if weight_sum > 0.0 {
1208 sum / weight_sum
1209 } else {
1210 0.0
1211 };
1212 feature_importance_changes.push(from_f64::<A>(mean)?);
1213 }
1214
1215 Ok(ModelDriftResult {
1216 drift_detected: votes > total_weight / 2.0,
1217 performance_degradation: from_f64(degradation)?,
1218 confidence: from_f64((1.0 - combined_p).clamp(0.0, 1.0))?,
1219 feature_importance_changes,
1220 })
1221 }
1222
1223 fn reset_model(&mut self) -> Result<(), String> {
1224 for member in &mut self.members {
1225 member.reset_model()?;
1226 }
1227 Ok(())
1228 }
1229}
1230
1231impl<A: Float + Send + Sync> std::fmt::Debug for EnsembleDriftDetector<A> {
1232 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1233 formatter
1234 .debug_struct("EnsembleDriftDetector")
1235 .field("members", &self.names)
1236 .field("weights", &self.weights)
1237 .finish()
1238 }
1239}
1240
1241#[cfg(test)]
1242#[path = "drift_models_tests.rs"]
1243mod drift_models_tests;