Skip to main content

chronix_analytics/lifecycle/
adwin.rs

1//! ADWIN (ADaptive WINdowing) streaming change detection.
2//!
3//! Implements the algorithm from Bifet & Gavaldà (2007) with
4//! exponential histogram compression, providing:
5//!
6//! - O(log W) amortised cost per observation
7//! - O(log W) memory
8//! - Automatic window shrinking when concept drift is detected
9//!
10//! # Example
11//!
12//! ```no_run
13//! use chronix_analytics::lifecycle::adwin::Adwin;
14//!
15//! let mut adwin = Adwin::new(0.002);
16//!
17//! // Stable phase — no drift expected.
18//! for _ in 0..200 {
19//!     assert!(!adwin.add(5.0));
20//! }
21//!
22//! // Sudden shift — drift should be detected.
23//! let mut detected = false;
24//! for _ in 0..50 {
25//!     if adwin.add(15.0) {
26//!         detected = true;
27//!         break;
28//!     }
29//! }
30//! assert!(detected, "ADWIN should detect mean shift from 5 to 15");
31//! ```
32
33/// Maximum number of buckets per row in the exponential histogram.
34/// More buckets per row → finer granularity but more memory.
35const MAX_BUCKETS_PER_ROW: usize = 5;
36
37/// A single bucket in the exponential histogram.
38#[derive(Debug, Clone, Copy)]
39struct Bucket {
40    /// Sum of the values in this bucket.
41    total: f64,
42    /// Sum of squared values (for variance estimation).
43    variance: f64,
44    /// Number of observations in this bucket.
45    count: usize,
46}
47
48/// ADWIN streaming change detector.
49///
50/// Maintains a variable-length window of recent observations using an
51/// exponential histogram.  After each [`add`](Self::add) call the
52/// algorithm checks whether any sub-window split shows a statistically
53/// significant mean difference (Hoeffding bound at confidence `1 - δ`).
54/// If so it shrinks the window to the more recent portion and reports
55/// drift.
56///
57/// # Parameters
58///
59/// - `delta` — confidence parameter (smaller = fewer false positives,
60///   slower reaction).  Typical value: 0.002.
61#[derive(Debug, Clone)]
62pub struct Adwin {
63    /// Confidence parameter δ.
64    delta: f64,
65    /// Exponential histogram: `rows[i]` contains buckets of size `2^i`.
66    rows: Vec<Vec<Bucket>>,
67    /// Total number of observations in the window.
68    total_count: usize,
69    /// Running sum of all values in the window.
70    total_sum: f64,
71    /// Running sum of squared values.
72    total_variance: f64,
73    /// Width (number of observations) stored.
74    width: usize,
75}
76
77impl Adwin {
78    /// Create a new ADWIN detector with the given confidence parameter.
79    ///
80    /// Lower `delta` values make the detector less sensitive (fewer false
81    /// positives).  `0.002` is a common choice.
82    #[must_use]
83    pub fn new(delta: f64) -> Self {
84        Self {
85            delta: delta.max(1e-15),
86            rows: Vec::new(),
87            total_count: 0,
88            total_sum: 0.0,
89            total_variance: 0.0,
90            width: 0,
91        }
92    }
93
94    /// Add a new observation and check for drift.
95    ///
96    /// Returns `true` if drift is detected (the window was shrunk).
97    ///
98    /// Non-finite values (`NaN`, `±Inf`) are rejected and the method returns
99    /// `false` without modifying the window, because they would silently
100    /// poison `total_sum` and `total_variance`.
101    pub fn add(&mut self, value: f64) -> bool {
102        if !value.is_finite() {
103            return false;
104        }
105
106        // Insert a new size-1 bucket at position 0.
107        self.insert_bucket(value);
108        // Compress: merge if a row has too many buckets.
109        self.compress();
110        // Check for drift from the tail (oldest) end.
111        self.detect_drift()
112    }
113
114    /// Current window mean.
115    #[must_use]
116    pub fn mean(&self) -> f64 {
117        if self.width == 0 {
118            return 0.0;
119        }
120        self.total_sum / self.width as f64
121    }
122
123    /// Current window size (number of observations).
124    #[must_use]
125    pub fn window_size(&self) -> usize {
126        self.width
127    }
128
129    /// Current estimated standard deviation of the window.
130    #[must_use]
131    pub fn std_dev(&self) -> f64 {
132        if self.width < 2 {
133            return 0.0;
134        }
135        let var = (self.total_variance / self.width as f64)
136            - (self.total_sum / self.width as f64).powi(2);
137        var.max(0.0).sqrt()
138    }
139
140    /// Reset the detector to its initial state.
141    pub fn reset(&mut self) {
142        self.rows.clear();
143        self.total_count = 0;
144        self.total_sum = 0.0;
145        self.total_variance = 0.0;
146        self.width = 0;
147    }
148
149    // ── Internals ──────────────────────────────────────────────────
150
151    fn insert_bucket(&mut self, value: f64) {
152        if self.rows.is_empty() {
153            self.rows.push(Vec::new());
154        }
155        self.rows[0].push(Bucket {
156            total: value,
157            variance: value * value,
158            count: 1,
159        });
160        self.total_count += 1;
161        self.total_sum += value;
162        self.total_variance += value * value;
163        self.width += 1;
164    }
165
166    fn compress(&mut self) {
167        for i in 0..self.rows.len() {
168            if self.rows[i].len() > MAX_BUCKETS_PER_ROW + 1 {
169                // Merge the two oldest (last) buckets and push to
170                // the next row.
171                let b2 = self.rows[i].remove(0);
172                let b1 = self.rows[i].remove(0);
173                let merged = Bucket {
174                    total: b1.total + b2.total,
175                    variance: b1.variance + b2.variance,
176                    count: b1.count + b2.count,
177                };
178                if i + 1 >= self.rows.len() {
179                    self.rows.push(Vec::new());
180                }
181                self.rows[i + 1].push(merged);
182            }
183        }
184    }
185
186    fn detect_drift(&mut self) -> bool {
187        let mut drift = false;
188        // Walk from the oldest (largest) buckets to the newest.
189        // Try removing the oldest bucket and check if the remaining
190        // window has a significantly different mean.
191        let mut n0: usize = 0;
192        let mut sum0: f64 = 0.0;
193
194        // Iterate from largest row to smallest (oldest to newest).
195        let n_rows = self.rows.len();
196        for i in (0..n_rows).rev() {
197            let row = &self.rows[i];
198            for j in 0..row.len() {
199                let bucket = &row[j];
200                n0 += bucket.count;
201                sum0 += bucket.total;
202
203                let n1 = self.width - n0;
204                if n0 < 5 || n1 < 5 {
205                    continue;
206                }
207                let sum1 = self.total_sum - sum0;
208
209                let mean0 = sum0 / n0 as f64;
210                let mean1 = sum1 / n1 as f64;
211
212                let m = 1.0 / (1.0 / n0 as f64 + 1.0 / n1 as f64);
213                let epsilon = ((2.0 / m) * (4.0 * self.width as f64 / self.delta).ln()).sqrt();
214
215                if (mean0 - mean1).abs() >= epsilon {
216                    // Drift detected — shrink the window by removing
217                    // the oldest observations (up to and including this bucket).
218                    self.remove_oldest(i, j);
219                    drift = true;
220                    break;
221                }
222            }
223            if drift {
224                break;
225            }
226        }
227        drift
228    }
229
230    fn remove_oldest(&mut self, row_idx: usize, bucket_idx: usize) {
231        // Remove all buckets from row_idx down (older) and all buckets
232        // at indices ≤ bucket_idx in row_idx.
233
234        // First: remove rows larger than row_idx entirely.
235        let n_rows = self.rows.len();
236        for i in (row_idx + 1..n_rows).rev() {
237            for b in &self.rows[i] {
238                self.total_sum -= b.total;
239                self.total_variance -= b.variance;
240                self.width -= b.count;
241            }
242            self.rows[i].clear();
243        }
244
245        // Then: remove buckets 0..=bucket_idx in row_idx.
246        let to_remove = bucket_idx + 1;
247        for _ in 0..to_remove {
248            if self.rows[row_idx].is_empty() {
249                break;
250            }
251            let b = self.rows[row_idx].remove(0);
252            self.total_sum -= b.total;
253            self.total_variance -= b.variance;
254            self.width -= b.count;
255        }
256
257        // Clean up trailing empty rows.
258        while self.rows.last().is_some_and(std::vec::Vec::is_empty) {
259            self.rows.pop();
260        }
261    }
262}
263
264// ── Tests ──────────────────────────────────────────────────────────
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn no_drift_on_stable_stream() {
272        let mut adwin = Adwin::new(0.002);
273        for i in 0..500 {
274            let v = 10.0 + 0.1 * ((i * 7 + 3) % 11) as f64;
275            assert!(!adwin.add(v), "unexpected drift at step {i}");
276        }
277        assert!(adwin.window_size() > 400);
278    }
279
280    #[test]
281    fn detects_mean_shift() {
282        let mut adwin = Adwin::new(0.002);
283        for _ in 0..300 {
284            adwin.add(5.0);
285        }
286        let mut detected = false;
287        for _ in 0..100 {
288            if adwin.add(50.0) {
289                detected = true;
290                break;
291            }
292        }
293        assert!(detected, "should detect shift from 5 to 50");
294    }
295
296    #[test]
297    fn window_shrinks_after_drift() {
298        let mut adwin = Adwin::new(0.002);
299        for _ in 0..300 {
300            adwin.add(0.0);
301        }
302        let size_before = adwin.window_size();
303
304        // Introduce drift.
305        for _ in 0..200 {
306            adwin.add(100.0);
307        }
308        // The old data should have been dropped.
309        assert!(
310            adwin.window_size() < size_before,
311            "window should shrink: was {size_before}, now {}",
312            adwin.window_size()
313        );
314    }
315
316    #[test]
317    fn mean_tracks_current_distribution() {
318        let mut adwin = Adwin::new(0.002);
319        for _ in 0..500 {
320            adwin.add(10.0);
321        }
322        assert!((adwin.mean() - 10.0).abs() < 0.01);
323
324        // Shift distribution.
325        for _ in 0..500 {
326            adwin.add(20.0);
327        }
328        // Mean should be close to 20 since old data was dropped.
329        assert!(
330            adwin.mean() > 15.0,
331            "mean after shift should be near 20, got {}",
332            adwin.mean()
333        );
334    }
335
336    #[test]
337    fn reset_clears_state() {
338        let mut adwin = Adwin::new(0.002);
339        for _ in 0..100 {
340            adwin.add(5.0);
341        }
342        adwin.reset();
343        assert_eq!(adwin.window_size(), 0);
344        assert_eq!(adwin.mean(), 0.0);
345    }
346
347    #[test]
348    fn lower_delta_is_less_sensitive() {
349        // More conservative (lower delta) should need more evidence.
350        let mut adwin_sensitive = Adwin::new(0.5);
351        let mut adwin_conservative = Adwin::new(0.0001);
352
353        let mut sensitive_detected_at = None;
354        let mut conservative_detected_at = None;
355
356        for _ in 0..200 {
357            adwin_sensitive.add(1.0);
358            adwin_conservative.add(1.0);
359        }
360
361        for i in 0..200 {
362            if sensitive_detected_at.is_none() && adwin_sensitive.add(5.0) {
363                sensitive_detected_at = Some(i);
364            } else if sensitive_detected_at.is_some() {
365                adwin_sensitive.add(5.0);
366            }
367            if conservative_detected_at.is_none() && adwin_conservative.add(5.0) {
368                conservative_detected_at = Some(i);
369            } else if conservative_detected_at.is_some() {
370                adwin_conservative.add(5.0);
371            }
372        }
373
374        if let (Some(s), Some(c)) = (sensitive_detected_at, conservative_detected_at) {
375            assert!(
376                s <= c,
377                "sensitive (δ=0.5) detected at {s}, conservative (δ=0.0001) at {c}"
378            );
379        }
380    }
381
382    #[test]
383    fn std_dev_reasonable() {
384        let mut adwin = Adwin::new(0.002);
385        for i in 0..1000 {
386            adwin.add((i % 10) as f64);
387        }
388        let sd = adwin.std_dev();
389        assert!(sd > 0.0 && sd < 5.0, "std_dev = {sd}");
390    }
391
392    #[test]
393    fn gradual_drift_eventually_detected() {
394        let mut adwin = Adwin::new(0.01);
395        // Start with baseline.
396        for _ in 0..200 {
397            adwin.add(0.0);
398        }
399        // Gradual increase.
400        let mut detected = false;
401        for i in 0..2000 {
402            let v = i as f64 * 0.1;
403            if adwin.add(v) {
404                detected = true;
405                break;
406            }
407        }
408        assert!(detected, "should detect gradual drift");
409    }
410
411    #[test]
412    fn nan_rejected_without_poisoning() {
413        let mut adwin = Adwin::new(0.002);
414        for _ in 0..50 {
415            adwin.add(1.0);
416        }
417        let mean_before = adwin.mean();
418
419        // NaN must be rejected.
420        assert!(!adwin.add(f64::NAN));
421        assert_eq!(adwin.mean(), mean_before);
422        assert_eq!(adwin.window_size(), 50);
423    }
424
425    #[test]
426    fn infinity_rejected() {
427        let mut adwin = Adwin::new(0.002);
428        assert!(!adwin.add(f64::INFINITY));
429        assert!(!adwin.add(f64::NEG_INFINITY));
430        assert_eq!(adwin.window_size(), 0);
431    }
432}