Skip to main content

rill_ml/drift/
adwin.rs

1//! ADWIN (Adaptive Windowing) drift detector.
2//!
3//! ADWIN maintains a variable-length window of recent observations and
4//! detects when the distribution of the window's two halves differs
5//! significantly. When drift is detected, the older portion is dropped.
6//!
7//! ## Algorithm
8//!
9//! Based on Bifet & Gavaldà (2007). For each new observation:
10//!
11//! 1. Add the value to the window.
12//! 2. For each possible split point `k` (dividing the window into `W0`
13//!    and `W1`), compute the means `μ0` and `μ1`.
14//! 3. Compute the Hoeffding bound:
15//!    `ε = √(1/(2·m) · ln(4/δ'))` where `m = n0·n1/(n0+n1)` and
16//!    `δ' = δ / ln(n)` (Bonferroni correction for repeated testing).
17//! 4. If `|μ0 − μ1| > ε`, drop `W0` and signal drift.
18//!
19//! ## Space complexity
20//!
21//! `O(max_window)` — the window stores individual values up to
22//! [`AdwinConfig::max_window`] elements. Prefix sums are maintained
23//! incrementally so each update is `O(max_window)` in the worst case.
24
25use crate::drift::detector::{DriftDetector, DriftLevel};
26use crate::error::{RillError, ensure_finite};
27
28/// Configuration for [`Adwin`].
29#[derive(Debug, Clone)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct AdwinConfig {
32    /// Significance level for drift detection. Must be in `(0, 1)`.
33    /// Smaller values reduce false positives. Defaults to `0.002`.
34    pub delta: f64,
35
36    /// Significance level for warnings. Must be in `(0, 1)` and
37    /// greater than or equal to `delta`. Defaults to `0.01`.
38    pub warning_delta: f64,
39
40    /// Maximum window size (number of stored observations). Must be > 0.
41    /// Larger values improve detection sensitivity but increase memory
42    /// and computation. Defaults to `1000`.
43    pub max_window: usize,
44
45    /// Minimum number of samples before any detection is attempted.
46    /// Must be greater than zero. Defaults to `10`.
47    pub min_samples: u64,
48}
49
50impl Default for AdwinConfig {
51    fn default() -> Self {
52        Self {
53            delta: 0.002,
54            warning_delta: 0.01,
55            max_window: 1000,
56            min_samples: 10,
57        }
58    }
59}
60
61/// ADWIN (Adaptive Windowing) drift detector.
62///
63/// Maintains a variable-length window and detects distribution changes by
64/// comparing the means of the window's two halves. See the
65/// [module documentation](crate::drift::adwin) for the algorithm.
66///
67/// # Examples
68///
69/// ```
70/// use rill_ml::drift::{Adwin, DriftDetector, DriftLevel};
71///
72/// let mut adwin = Adwin::default();
73///
74/// // Stable stream.
75/// for _ in 0..100 {
76///     adwin.update(0.0).unwrap();
77/// }
78/// assert_eq!(adwin.level(), DriftLevel::None);
79///
80/// // Sudden shift. ADWIN's level is transient: after detecting drift and
81/// // trimming the window, subsequent stable updates reset the level to
82/// // None, so we check the level returned by each update.
83/// let mut detected = false;
84/// for _ in 0..100 {
85///     let level = adwin.update(5.0).unwrap();
86///     if level == DriftLevel::Drift {
87///         detected = true;
88///         break;
89///     }
90/// }
91/// assert!(detected);
92/// ```
93#[derive(Debug, Clone)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95pub struct Adwin {
96    config: AdwinConfig,
97    window: std::collections::VecDeque<f64>,
98    total: f64,
99    samples: u64,
100    current_level: DriftLevel,
101}
102
103impl Adwin {
104    /// Create a new ADWIN detector with the given configuration.
105    ///
106    /// Returns an error if:
107    /// - `delta` is not in `(0, 1)`.
108    /// - `warning_delta` is not in `(0, 1)` or is less than `delta`.
109    /// - `max_window` is zero.
110    /// - `min_samples` is zero.
111    pub fn new(config: AdwinConfig) -> Result<Self, RillError> {
112        ensure_finite("delta", config.delta)?;
113        if config.delta <= 0.0 || config.delta >= 1.0 {
114            return Err(RillError::InvalidSignificanceLevel(config.delta));
115        }
116        ensure_finite("warning_delta", config.warning_delta)?;
117        if config.warning_delta <= 0.0 || config.warning_delta >= 1.0 {
118            return Err(RillError::InvalidSignificanceLevel(config.warning_delta));
119        }
120        if config.warning_delta < config.delta {
121            return Err(RillError::InvalidParameter {
122                name: "warning_delta",
123                value: config.warning_delta,
124            });
125        }
126        if config.max_window == 0 {
127            return Err(RillError::InvalidCapacity(config.max_window));
128        }
129        if config.min_samples == 0 {
130            return Err(RillError::InvalidParameter {
131                name: "min_samples",
132                value: 0.0,
133            });
134        }
135        Ok(Self {
136            window: std::collections::VecDeque::with_capacity(config.max_window),
137            config,
138            total: 0.0,
139            samples: 0,
140            current_level: DriftLevel::None,
141        })
142    }
143
144    /// The number of values currently in the window.
145    pub fn window_size(&self) -> usize {
146        self.window.len()
147    }
148
149    /// The mean of all values currently in the window, or `0.0` if empty.
150    pub fn window_mean(&self) -> f64 {
151        if self.window.is_empty() {
152            0.0
153        } else {
154            self.total / self.window.len() as f64
155        }
156    }
157
158    /// The configuration of this detector.
159    pub const fn config(&self) -> &AdwinConfig {
160        &self.config
161    }
162
163    /// Compute the Hoeffding bound for a split with `n0` and `n1` elements
164    /// at significance level `delta` with total stream length `n`.
165    fn hoeffding_bound(n0: f64, n1: f64, n: u64, delta: f64) -> f64 {
166        let m = n0 * n1 / (n0 + n1);
167        let ln_n = (n as f64).ln().max(1.0);
168        let delta_eff = delta / ln_n;
169        (1.0 / (2.0 * m) * (4.0 / delta_eff).ln()).sqrt()
170    }
171
172    /// Check all split points and return the index where drift is detected,
173    /// along with the level (Warning or Drift). Returns `None` if no split
174    /// exceeds the threshold.
175    fn check_splits(&self) -> Option<(usize, DriftLevel, f64)> {
176        let n = self.window.len();
177        if n < 2 {
178            return None;
179        }
180        // Build prefix sums for O(1) mean computation per split.
181        let mut prefix = Vec::with_capacity(n + 1);
182        prefix.push(0.0_f64);
183        let mut acc = 0.0;
184        for &v in &self.window {
185            acc += v;
186            prefix.push(acc);
187        }
188        let total = prefix[n];
189        let n_total = n as u64;
190
191        let mut best_split: Option<(usize, DriftLevel, f64)> = None;
192        // Check split points from the oldest end.
193        for (k, &sum0) in prefix.iter().enumerate().take(n).skip(1) {
194            let n0 = k as f64;
195            let n1 = (n - k) as f64;
196            let sum1 = total - sum0;
197            let mean0 = sum0 / n0;
198            let mean1 = sum1 / n1;
199            let diff = (mean0 - mean1).abs();
200
201            // Check drift threshold.
202            let eps_drift = Self::hoeffding_bound(n0, n1, n_total, self.config.delta);
203            if diff > eps_drift {
204                return Some((k, DriftLevel::Drift, diff));
205            }
206            // Check warning threshold.
207            let eps_warn = Self::hoeffding_bound(n0, n1, n_total, self.config.warning_delta);
208            if diff > eps_warn && best_split.is_none() {
209                best_split = Some((k, DriftLevel::Warning, diff));
210            }
211        }
212        best_split
213    }
214
215    /// Trim the window by removing the oldest `count` elements.
216    fn trim_front(&mut self, count: usize) {
217        for _ in 0..count {
218            if let Some(v) = self.window.pop_front() {
219                self.total -= v;
220            }
221        }
222    }
223}
224
225impl Default for Adwin {
226    fn default() -> Self {
227        Self::new(AdwinConfig::default()).expect("default config is valid")
228    }
229}
230
231impl DriftDetector for Adwin {
232    fn update(&mut self, value: f64) -> Result<DriftLevel, RillError> {
233        ensure_finite("value", value)?;
234        self.samples += 1;
235        // Add the new value to the window.
236        self.window.push_back(value);
237        self.total += value;
238        // Enforce the max window size by dropping the oldest element.
239        if self.window.len() > self.config.max_window
240            && let Some(v) = self.window.pop_front()
241        {
242            self.total -= v;
243        }
244        // Gate detection by minimum samples.
245        if self.samples < self.config.min_samples || self.window.len() < 2 {
246            self.current_level = DriftLevel::None;
247            return Ok(DriftLevel::None);
248        }
249        // Check for drift.
250        if let Some((split, level, _diff)) = self.check_splits() {
251            // Trim the older portion when drift or warning is detected.
252            // For Drift, trim aggressively. For Warning, keep the window intact.
253            if level == DriftLevel::Drift {
254                self.trim_front(split);
255            }
256            self.current_level = level;
257        } else {
258            self.current_level = DriftLevel::None;
259        }
260        Ok(self.current_level)
261    }
262
263    fn detected(&self) -> bool {
264        self.current_level == DriftLevel::Drift
265    }
266
267    fn warning(&self) -> bool {
268        self.current_level == DriftLevel::Warning
269    }
270
271    fn level(&self) -> DriftLevel {
272        self.current_level
273    }
274
275    fn samples_seen(&self) -> u64 {
276        self.samples
277    }
278
279    fn reset(&mut self) {
280        self.window.clear();
281        self.total = 0.0;
282        self.samples = 0;
283        self.current_level = DriftLevel::None;
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    /// Deterministic pseudo-random number in `[0, 1)` using a simple LCG.
292    fn next_unit(seed: &mut u64) -> f64 {
293        *seed = seed
294            .wrapping_mul(6364136223846793005)
295            .wrapping_add(1442695040888963407);
296        ((*seed >> 11) as f64) / ((1u64 << 53) as f64)
297    }
298
299    #[test]
300    fn default_config_is_valid() {
301        let adwin = Adwin::default();
302        assert_eq!(adwin.samples_seen(), 0);
303        assert_eq!(adwin.level(), DriftLevel::None);
304        assert_eq!(adwin.window_size(), 0);
305    }
306
307    #[test]
308    fn detects_sudden_mean_shift() {
309        let mut adwin = Adwin::new(AdwinConfig {
310            delta: 0.05,
311            warning_delta: 0.1,
312            max_window: 500,
313            min_samples: 5,
314        })
315        .unwrap();
316        // Stable stream around 0.
317        let mut seed = 42u64;
318        for _ in 0..100 {
319            let noise = 0.1 * (next_unit(&mut seed) - 0.5);
320            adwin.update(noise).unwrap();
321        }
322        assert_eq!(adwin.level(), DriftLevel::None);
323        // Sudden shift to mean 5.
324        let mut detected = false;
325        for _ in 0..200 {
326            let noise = 0.1 * (next_unit(&mut seed) - 0.5);
327            let level = adwin.update(5.0 + noise).unwrap();
328            if level == DriftLevel::Drift {
329                detected = true;
330                break;
331            }
332        }
333        assert!(detected, "ADWIN should detect the sudden mean shift");
334    }
335
336    #[test]
337    fn no_false_positive_on_stable_stream() {
338        let mut adwin = Adwin::new(AdwinConfig {
339            delta: 0.002,
340            warning_delta: 0.01,
341            max_window: 500,
342            min_samples: 10,
343        })
344        .unwrap();
345        let mut seed = 7u64;
346        for _ in 0..2000 {
347            let noise = 0.5 * (next_unit(&mut seed) - 0.5);
348            adwin.update(noise).unwrap();
349        }
350        assert!(
351            !adwin.detected(),
352            "false positive: drift reported on stable stream"
353        );
354    }
355
356    #[test]
357    fn detects_gradual_drift() {
358        let mut adwin = Adwin::new(AdwinConfig {
359            delta: 0.05,
360            warning_delta: 0.1,
361            max_window: 300,
362            min_samples: 5,
363        })
364        .unwrap();
365        // Start at mean 0, gradually increase to mean 5.
366        let mut seed = 99u64;
367        let mut detected = false;
368        for i in 0..500 {
369            let mean = (i as f64 / 100.0).min(5.0);
370            let noise = 0.1 * (next_unit(&mut seed) - 0.5);
371            let level = adwin.update(mean + noise).unwrap();
372            if level == DriftLevel::Drift {
373                detected = true;
374                break;
375            }
376        }
377        assert!(detected, "ADWIN should detect gradual drift");
378    }
379
380    #[test]
381    fn window_trims_after_drift() {
382        let mut adwin = Adwin::new(AdwinConfig {
383            delta: 0.05,
384            warning_delta: 0.1,
385            max_window: 500,
386            min_samples: 5,
387        })
388        .unwrap();
389        // Build up a window of 100 samples at mean 0.
390        for _ in 0..100 {
391            adwin.update(0.0).unwrap();
392        }
393        let size_before = adwin.window_size();
394        assert!(size_before > 0);
395        // Shift to mean 10 to trigger drift.
396        let mut trimmed = false;
397        for _ in 0..200 {
398            adwin.update(10.0).unwrap();
399            if adwin.detected() {
400                // After drift detection and trimming, the window should be
401                // smaller than its peak (all 10.0 values are kept, old 0.0
402                // values are trimmed).
403                if adwin.window_size() < size_before + 200 {
404                    trimmed = true;
405                    break;
406                }
407            }
408        }
409        assert!(trimmed, "window should be trimmed after drift");
410    }
411
412    #[test]
413    fn max_window_enforced() {
414        let mut adwin = Adwin::new(AdwinConfig {
415            max_window: 50,
416            ..Default::default()
417        })
418        .unwrap();
419        for i in 0..200u64 {
420            adwin.update(i as f64).unwrap();
421        }
422        // Drift detection may trim the window below max_window; the invariant
423        // is that the window never exceeds max_window.
424        assert!(
425            adwin.window_size() <= 50,
426            "window should not exceed max_window, got {}",
427            adwin.window_size()
428        );
429    }
430
431    #[test]
432    fn min_samples_gates_detection() {
433        let mut adwin = Adwin::new(AdwinConfig {
434            delta: 0.5,
435            warning_delta: 0.5,
436            max_window: 100,
437            min_samples: 50,
438        })
439        .unwrap();
440        // 48 zeros: samples_seen = 48 < min_samples = 50.
441        for _ in 0..48 {
442            adwin.update(0.0).unwrap();
443        }
444        // Sample 49: extreme value, but 49 < min_samples = 50 → no detection.
445        adwin.update(100.0).unwrap();
446        assert_eq!(adwin.level(), DriftLevel::None);
447        // After min_samples, detection can trigger. The level is transient:
448        // after drift is detected and the window trimmed, subsequent stable
449        // updates reset the level to None. Track detection across the loop.
450        let mut detected = false;
451        for _ in 0..50 {
452            let level = adwin.update(100.0).unwrap();
453            if level.is_change() {
454                detected = true;
455            }
456        }
457        assert!(detected, "should have detected drift after min_samples");
458    }
459
460    #[test]
461    fn reset_clears_state() {
462        let mut adwin = Adwin::default();
463        for _ in 0..50 {
464            adwin.update(1.0).unwrap();
465        }
466        assert!(adwin.window_size() > 0);
467        adwin.reset();
468        assert_eq!(adwin.window_size(), 0);
469        assert_eq!(adwin.samples_seen(), 0);
470        assert_eq!(adwin.level(), DriftLevel::None);
471        assert_eq!(adwin.window_mean(), 0.0);
472    }
473
474    #[test]
475    fn rejects_non_finite_input() {
476        let mut adwin = Adwin::default();
477        assert!(adwin.update(f64::NAN).is_err());
478        assert!(adwin.update(f64::INFINITY).is_err());
479        assert!(adwin.update(f64::NEG_INFINITY).is_err());
480        assert_eq!(adwin.samples_seen(), 0);
481        assert_eq!(adwin.window_size(), 0);
482    }
483
484    #[test]
485    fn rejects_invalid_config() {
486        // delta out of range
487        assert!(
488            Adwin::new(AdwinConfig {
489                delta: 0.0,
490                ..Default::default()
491            })
492            .is_err()
493        );
494        assert!(
495            Adwin::new(AdwinConfig {
496                delta: 1.0,
497                ..Default::default()
498            })
499            .is_err()
500        );
501        // warning_delta < delta
502        assert!(
503            Adwin::new(AdwinConfig {
504                delta: 0.05,
505                warning_delta: 0.01,
506                ..Default::default()
507            })
508            .is_err()
509        );
510        // max_window == 0
511        assert!(
512            Adwin::new(AdwinConfig {
513                max_window: 0,
514                ..Default::default()
515            })
516            .is_err()
517        );
518        // min_samples == 0
519        assert!(
520            Adwin::new(AdwinConfig {
521                min_samples: 0,
522                ..Default::default()
523            })
524            .is_err()
525        );
526    }
527
528    #[test]
529    fn window_mean_correct() {
530        let mut adwin = Adwin::new(AdwinConfig {
531            max_window: 100,
532            min_samples: 11, // prevent drift detection with only 10 samples
533            ..Default::default()
534        })
535        .unwrap();
536        for i in 1..=10 {
537            adwin.update(i as f64).unwrap();
538        }
539        // mean of 1..=10 is 5.5
540        assert!((adwin.window_mean() - 5.5).abs() < 1e-9);
541    }
542
543    #[test]
544    fn hoeffding_bound_decreases_with_more_data() {
545        // With more data, the bound should be tighter (smaller).
546        let b1 = Adwin::hoeffding_bound(5.0, 5.0, 10, 0.01);
547        let b2 = Adwin::hoeffding_bound(50.0, 50.0, 100, 0.01);
548        assert!(
549            b2 < b1,
550            "bound should decrease with more data: {} vs {}",
551            b2,
552            b1
553        );
554    }
555
556    #[cfg(feature = "serde")]
557    #[test]
558    fn serde_roundtrip() {
559        let mut adwin = Adwin::new(AdwinConfig {
560            delta: 0.01,
561            warning_delta: 0.05,
562            max_window: 200,
563            min_samples: 5,
564        })
565        .unwrap();
566        for i in 0..50 {
567            adwin.update(i as f64 * 0.1).unwrap();
568        }
569        let json = serde_json::to_string(&adwin).unwrap();
570        let restored: Adwin = serde_json::from_str(&json).unwrap();
571        assert_eq!(restored.samples_seen(), 50);
572        assert_eq!(restored.window_size(), adwin.window_size());
573        assert!((restored.window_mean() - adwin.window_mean()).abs() < 1e-12);
574        assert_eq!(restored.level(), adwin.level());
575    }
576}