Skip to main content

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;