Skip to main content

rlox_core/training/
cpd.rs

1// Change-Point Detection (CPD) algorithms for non-stationary RL.
2//
3// Implements streaming change-point detectors that operate on reward/loss
4// signals to detect when the underlying MDP has shifted. These are pure
5// data-plane operations (O(1) per observation, no autograd) and belong
6// in the Rust data plane per the Polars pattern.
7
8/// Two-sided CUSUM (Cumulative Sum) change-point detector.
9///
10/// Tracks cumulative deviations from a reference level. When the cumulative
11/// sum exceeds a threshold `h`, a change-point alarm is raised.
12///
13/// # Algorithm
14///
15/// For each observation x_t:
16///   S_t^+ = max(0, S_{t-1}^+ + x_t - mu_0 - delta)  (upward shift)
17///   S_t^- = max(0, S_{t-1}^- - x_t + mu_0 - delta)  (downward shift)
18///
19/// Alarm when S_t^+ > h OR S_t^- > h.
20///
21/// # Parameters
22///
23/// - `mu_0`: Reference level (estimated from burn-in or provided)
24/// - `delta`: Allowance parameter (minimum shift to detect). Controls sensitivity.
25/// - `h`: Decision threshold. Higher h = fewer false alarms, slower detection.
26///
27/// # Usage in RL
28///
29/// Feed the detector with a streaming signal (e.g., episode rewards, TD errors,
30/// policy loss). When it fires, the environment dynamics or reward function
31/// have likely shifted, triggering policy adaptation.
32#[derive(Debug, Clone)]
33pub struct CusumDetector {
34    /// Reference level (target mean under null hypothesis)
35    mu_0: f64,
36    /// Allowance parameter: minimum shift magnitude to detect
37    delta: f64,
38    /// Decision threshold: alarm fires when S > h
39    h: f64,
40    /// Upward CUSUM statistic
41    s_pos: f64,
42    /// Downward CUSUM statistic
43    s_neg: f64,
44    /// Total observations processed
45    count: u64,
46    /// Number of alarms fired
47    alarm_count: u64,
48    /// Whether to estimate mu_0 from the first `burnin` samples
49    burnin: u64,
50    /// Sum of burnin samples (for estimating mu_0)
51    burnin_sum: f64,
52    /// Whether burnin estimation is complete
53    burnin_done: bool,
54}
55
56impl CusumDetector {
57    /// Create a CUSUM detector with a known reference level.
58    ///
59    /// # Parameters
60    /// - `mu_0`: Expected mean of the signal under the null (no-change) hypothesis
61    /// - `delta`: Minimum detectable shift (sensitivity). Typical: 0.5 * expected_shift
62    /// - `h`: Detection threshold. Typical: 4-8 for moderate false alarm rates
63    pub fn new(mu_0: f64, delta: f64, h: f64) -> Self {
64        assert!(delta >= 0.0, "delta must be non-negative, got {delta}");
65        assert!(h > 0.0, "h must be positive, got {h}");
66        Self {
67            mu_0,
68            delta,
69            h,
70            s_pos: 0.0,
71            s_neg: 0.0,
72            count: 0,
73            alarm_count: 0,
74            burnin: 0,
75            burnin_sum: 0.0,
76            burnin_done: true,
77        }
78    }
79
80    /// Create a CUSUM detector that estimates mu_0 from the first `burnin` samples.
81    ///
82    /// During the burn-in period, no alarms will fire. After burn-in, mu_0 is set
83    /// to the mean of the observed samples and detection begins.
84    pub fn with_burnin(burnin: u64, delta: f64, h: f64) -> Self {
85        assert!(burnin > 0, "burnin must be > 0, got {burnin}");
86        assert!(delta >= 0.0, "delta must be non-negative, got {delta}");
87        assert!(h > 0.0, "h must be positive, got {h}");
88        Self {
89            mu_0: 0.0,
90            delta,
91            h,
92            s_pos: 0.0,
93            s_neg: 0.0,
94            count: 0,
95            alarm_count: 0,
96            burnin,
97            burnin_sum: 0.0,
98            burnin_done: false,
99        }
100    }
101
102    /// Feed one observation. Returns `true` if a change-point alarm fires.
103    pub fn update(&mut self, value: f64) -> bool {
104        self.count += 1;
105
106        // Handle burn-in phase
107        if !self.burnin_done {
108            self.burnin_sum += value;
109            if self.count >= self.burnin {
110                self.mu_0 = self.burnin_sum / self.count as f64;
111                self.burnin_done = true;
112            }
113            return false;
114        }
115
116        // Two-sided CUSUM update
117        self.s_pos = (self.s_pos + value - self.mu_0 - self.delta).max(0.0);
118        self.s_neg = (self.s_neg - value + self.mu_0 - self.delta).max(0.0);
119
120        let alarm = self.s_pos > self.h || self.s_neg > self.h;
121        if alarm {
122            self.alarm_count += 1;
123        }
124        alarm
125    }
126
127    /// Feed a batch of observations. Returns the index of the first alarm (if any).
128    pub fn batch_update(&mut self, values: &[f64]) -> Option<usize> {
129        for (i, &v) in values.iter().enumerate() {
130            if self.update(v) {
131                return Some(i);
132            }
133        }
134        None
135    }
136
137    /// Reset the CUSUM statistics without changing parameters.
138    ///
139    /// Call this after handling an alarm to begin detecting the next change-point.
140    /// If burn-in was used, pass the new reference level explicitly, or call
141    /// `reset_with_burnin()` to re-estimate from scratch.
142    pub fn reset(&mut self) {
143        self.s_pos = 0.0;
144        self.s_neg = 0.0;
145    }
146
147    /// Reset and re-estimate mu_0 from the next `burnin` samples.
148    pub fn reset_with_burnin(&mut self) {
149        self.s_pos = 0.0;
150        self.s_neg = 0.0;
151        self.count = 0;
152        self.burnin_sum = 0.0;
153        self.burnin_done = self.burnin == 0;
154    }
155
156    /// Set a new reference level manually (e.g., after adaptation).
157    pub fn set_mu(&mut self, mu_0: f64) {
158        self.mu_0 = mu_0;
159        self.reset();
160    }
161
162    /// Current upward CUSUM statistic.
163    pub fn s_pos(&self) -> f64 {
164        self.s_pos
165    }
166
167    /// Current downward CUSUM statistic.
168    pub fn s_neg(&self) -> f64 {
169        self.s_neg
170    }
171
172    /// Reference level.
173    pub fn mu_0(&self) -> f64 {
174        self.mu_0
175    }
176
177    /// Total observations processed.
178    pub fn count(&self) -> u64 {
179        self.count
180    }
181
182    /// Total alarms fired.
183    pub fn alarm_count(&self) -> u64 {
184        self.alarm_count
185    }
186
187    /// Whether burn-in is complete.
188    pub fn is_ready(&self) -> bool {
189        self.burnin_done
190    }
191}
192
193/// Page-Hinkley change-point detector (one-sided, detects increases).
194///
195/// Simpler alternative to CUSUM. Tracks the cumulative deviation from
196/// the running mean and alarms when it exceeds a threshold.
197///
198/// # Algorithm
199///
200/// m_t = sum_{i=1}^t (x_i - mean_t - delta)
201/// M_t = min_{1<=i<=t} m_i
202/// Alarm when m_t - M_t > lambda
203#[derive(Debug, Clone)]
204pub struct PageHinkleyDetector {
205    delta: f64,
206    lambda: f64,
207    sum: f64,
208    count: u64,
209    running_mean: f64,
210    cumsum: f64,
211    min_cumsum: f64,
212    alarm_count: u64,
213}
214
215impl PageHinkleyDetector {
216    /// Create a Page-Hinkley detector.
217    ///
218    /// # Parameters
219    /// - `delta`: Allowance parameter (tolerance for drift)
220    /// - `lambda`: Detection threshold
221    pub fn new(delta: f64, lambda: f64) -> Self {
222        assert!(lambda > 0.0, "lambda must be positive, got {lambda}");
223        Self {
224            delta,
225            lambda,
226            sum: 0.0,
227            count: 0,
228            running_mean: 0.0,
229            cumsum: 0.0,
230            min_cumsum: 0.0,
231            alarm_count: 0,
232        }
233    }
234
235    /// Feed one observation. Returns `true` if alarm fires.
236    pub fn update(&mut self, value: f64) -> bool {
237        self.count += 1;
238        self.sum += value;
239        self.running_mean = self.sum / self.count as f64;
240
241        self.cumsum += value - self.running_mean - self.delta;
242        if self.cumsum < self.min_cumsum {
243            self.min_cumsum = self.cumsum;
244        }
245
246        let alarm = (self.cumsum - self.min_cumsum) > self.lambda;
247        if alarm {
248            self.alarm_count += 1;
249        }
250        alarm
251    }
252
253    /// Reset detector state.
254    pub fn reset(&mut self) {
255        self.sum = 0.0;
256        self.count = 0;
257        self.running_mean = 0.0;
258        self.cumsum = 0.0;
259        self.min_cumsum = 0.0;
260    }
261
262    /// Total observations processed.
263    pub fn count(&self) -> u64 {
264        self.count
265    }
266
267    /// Total alarms fired.
268    pub fn alarm_count(&self) -> u64 {
269        self.alarm_count
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn cusum_no_alarm_on_stationary() {
279        let mut det = CusumDetector::new(0.0, 0.5, 5.0);
280        // Feed 100 samples from a stationary signal at mean=0
281        for _ in 0..100 {
282            assert!(!det.update(0.1));
283            assert!(!det.update(-0.1));
284        }
285        assert_eq!(det.alarm_count(), 0);
286    }
287
288    #[test]
289    fn cusum_detects_upward_shift() {
290        let mut det = CusumDetector::new(0.0, 0.5, 5.0);
291        // Large upward shift should trigger alarm
292        let mut alarm_step = None;
293        for i in 0..100 {
294            if det.update(3.0) {
295                alarm_step = Some(i);
296                break;
297            }
298        }
299        assert!(alarm_step.is_some(), "should detect upward shift");
300        assert!(alarm_step.unwrap() < 10, "should detect quickly");
301    }
302
303    #[test]
304    fn cusum_detects_downward_shift() {
305        let mut det = CusumDetector::new(0.0, 0.5, 5.0);
306        let mut alarm_step = None;
307        for i in 0..100 {
308            if det.update(-3.0) {
309                alarm_step = Some(i);
310                break;
311            }
312        }
313        assert!(alarm_step.is_some(), "should detect downward shift");
314    }
315
316    #[test]
317    fn cusum_burnin_delays_detection() {
318        let mut det = CusumDetector::with_burnin(20, 0.5, 5.0);
319        // During burnin, no alarms
320        for _ in 0..19 {
321            assert!(!det.update(100.0)); // even extreme values
322        }
323        assert!(!det.is_ready()); // Wait, 19 < 20... let me check the logic
324                                  // Actually count starts at 1, so after 19 updates count=19 < burnin=20
325                                  // After one more:
326        assert!(!det.update(100.0)); // count=20, burnin completes, no alarm on this step
327        assert!(det.is_ready());
328    }
329
330    #[test]
331    fn cusum_reset_clears_statistics() {
332        let mut det = CusumDetector::new(0.0, 0.5, 5.0);
333        for _ in 0..5 {
334            det.update(3.0);
335        }
336        assert!(det.s_pos() > 0.0);
337        det.reset();
338        assert!((det.s_pos()).abs() < 1e-10);
339        assert!((det.s_neg()).abs() < 1e-10);
340    }
341
342    #[test]
343    fn cusum_batch_update_returns_first_alarm() {
344        let mut det = CusumDetector::new(0.0, 0.5, 2.0);
345        let values = vec![5.0, 5.0, 5.0, 5.0, 5.0];
346        let idx = det.batch_update(&values);
347        assert!(idx.is_some());
348        assert!(idx.unwrap() < 3);
349    }
350
351    #[test]
352    fn cusum_set_mu_resets_and_updates_reference() {
353        let mut det = CusumDetector::new(0.0, 0.5, 5.0);
354        for _ in 0..10 {
355            det.update(5.0);
356        }
357        det.set_mu(5.0);
358        assert!((det.s_pos()).abs() < 1e-10);
359        // Now at the new reference, no alarm
360        for _ in 0..50 {
361            assert!(!det.update(5.1));
362            assert!(!det.update(4.9));
363        }
364    }
365
366    #[test]
367    fn page_hinkley_no_alarm_stationary() {
368        let mut det = PageHinkleyDetector::new(0.5, 10.0);
369        for _ in 0..100 {
370            assert!(!det.update(1.0));
371        }
372        assert_eq!(det.alarm_count(), 0);
373    }
374
375    #[test]
376    fn page_hinkley_detects_increase() {
377        let mut det = PageHinkleyDetector::new(0.1, 5.0);
378        // Stationary phase
379        for _ in 0..50 {
380            det.update(0.0);
381        }
382        // Shift up
383        let mut detected = false;
384        for _ in 0..50 {
385            if det.update(5.0) {
386                detected = true;
387                break;
388            }
389        }
390        assert!(detected, "should detect upward shift");
391    }
392
393    #[test]
394    fn page_hinkley_reset_clears() {
395        let mut det = PageHinkleyDetector::new(0.1, 5.0);
396        for _ in 0..10 {
397            det.update(10.0);
398        }
399        det.reset();
400        assert_eq!(det.count(), 0);
401    }
402
403    #[test]
404    #[should_panic(expected = "h must be positive")]
405    fn cusum_invalid_h_panics() {
406        CusumDetector::new(0.0, 0.5, 0.0);
407    }
408
409    #[test]
410    #[should_panic(expected = "lambda must be positive")]
411    fn page_hinkley_invalid_lambda_panics() {
412        PageHinkleyDetector::new(0.1, 0.0);
413    }
414}