Skip to main content

rill_ml/stats/
rolling.rs

1//! Rolling (windowed) statistics with fixed capacity.
2//!
3//! Time complexity per update: `O(1)`. Space complexity: `O(window_size)`.
4//!
5//! The implementation stores the window contents in a `VecDeque` and recomputes
6//! the statistic on each update. For the window sizes typically used in online
7//! learning this is simple and correct. A more sophisticated incremental update
8//! could be added later, but only if accompanied by thorough removal tests.
9
10use std::collections::VecDeque;
11
12use crate::error::{RillError, ensure_finite};
13use crate::stats::variance::VarianceKind;
14use crate::traits::OnlineStatistic;
15
16/// Rolling mean over a fixed-size window.
17///
18/// The window is a FIFO queue: when full, the oldest observation is removed
19/// before the newest is inserted.
20///
21/// # Examples
22///
23/// ```
24/// use rill_ml::stats::RollingMean;
25/// use rill_ml::OnlineStatistic;
26///
27/// let mut rm = RollingMean::new(3).unwrap();
28/// rm.update(1.0).unwrap();
29/// rm.update(2.0).unwrap();
30/// rm.update(3.0).unwrap();
31/// assert!((rm.value().unwrap() - 2.0).abs() < 1e-12);
32/// rm.update(6.0).unwrap(); // window becomes [2, 3, 6]
33/// assert!((rm.value().unwrap() - 3.6666666666666665).abs() < 1e-12);
34/// ```
35#[derive(Debug, Clone)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub struct RollingMean {
38    window: VecDeque<f64>,
39    capacity: usize,
40}
41
42impl RollingMean {
43    /// Create a new rolling mean with the given window capacity.
44    ///
45    /// Returns an error if `capacity` is zero.
46    pub fn new(capacity: usize) -> Result<Self, RillError> {
47        if capacity == 0 {
48            return Err(RillError::InvalidWindowSize);
49        }
50        Ok(Self {
51            window: VecDeque::with_capacity(capacity),
52            capacity,
53        })
54    }
55
56    /// The configured window capacity.
57    pub const fn capacity(&self) -> usize {
58        self.capacity
59    }
60
61    /// Number of observations currently in the window.
62    pub fn len(&self) -> usize {
63        self.window.len()
64    }
65
66    /// Whether the window is currently empty.
67    pub fn is_empty(&self) -> bool {
68        self.window.is_empty()
69    }
70
71    /// Current rolling mean, or `None` if the window is empty.
72    pub fn value(&self) -> Option<f64> {
73        if self.window.is_empty() {
74            None
75        } else {
76            let sum: f64 = self.window.iter().sum();
77            Some(sum / self.window.len() as f64)
78        }
79    }
80}
81
82impl OnlineStatistic for RollingMean {
83    fn update(&mut self, value: f64) -> Result<(), RillError> {
84        ensure_finite("value", value)?;
85        if self.window.len() == self.capacity {
86            self.window.pop_front();
87        }
88        self.window.push_back(value);
89        Ok(())
90    }
91
92    fn samples_seen(&self) -> u64 {
93        self.window.len() as u64
94    }
95
96    fn reset(&mut self) {
97        self.window.clear();
98    }
99}
100
101/// Rolling variance over a fixed-size window.
102///
103/// Uses [`VarianceKind`] to select population or sample variance. The window
104/// contents are stored and the variance recomputed on each query.
105#[derive(Debug, Clone)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107pub struct RollingVariance {
108    window: VecDeque<f64>,
109    capacity: usize,
110    kind: VarianceKind,
111}
112
113impl RollingVariance {
114    /// Create a new rolling variance accumulator.
115    ///
116    /// Returns an error if `capacity` is zero.
117    pub fn new(capacity: usize, kind: VarianceKind) -> Result<Self, RillError> {
118        if capacity == 0 {
119            return Err(RillError::InvalidWindowSize);
120        }
121        Ok(Self {
122            window: VecDeque::with_capacity(capacity),
123            capacity,
124            kind,
125        })
126    }
127
128    /// The configured window capacity.
129    pub const fn capacity(&self) -> usize {
130        self.capacity
131    }
132
133    /// The configured variance kind.
134    pub const fn kind(&self) -> VarianceKind {
135        self.kind
136    }
137
138    /// Number of observations currently in the window.
139    pub fn len(&self) -> usize {
140        self.window.len()
141    }
142
143    /// Whether the window is currently empty.
144    pub fn is_empty(&self) -> bool {
145        self.window.is_empty()
146    }
147
148    /// Current rolling mean, or `None` if the window is empty.
149    pub fn mean(&self) -> Option<f64> {
150        if self.window.is_empty() {
151            None
152        } else {
153            let sum: f64 = self.window.iter().sum();
154            Some(sum / self.window.len() as f64)
155        }
156    }
157
158    /// Current rolling variance, or `None` when not enough data is in the window.
159    pub fn value(&self) -> Option<f64> {
160        let n = self.window.len();
161        if n == 0 {
162            return None;
163        }
164        let denom = match self.kind {
165            VarianceKind::Population => n,
166            VarianceKind::Sample => {
167                if n < 2 {
168                    return None;
169                }
170                n - 1
171            }
172        };
173        let mean = self.mean()?;
174        let ss = self.window.iter().map(|x| (x - mean).powi(2)).sum::<f64>();
175        Some(ss / denom as f64)
176    }
177
178    /// Current rolling standard deviation, or `None` when not enough data.
179    pub fn std_dev(&self) -> Option<f64> {
180        self.value().map(|v| v.sqrt())
181    }
182}
183
184impl OnlineStatistic for RollingVariance {
185    fn update(&mut self, value: f64) -> Result<(), RillError> {
186        ensure_finite("value", value)?;
187        if self.window.len() == self.capacity {
188            self.window.pop_front();
189        }
190        self.window.push_back(value);
191        Ok(())
192    }
193
194    fn samples_seen(&self) -> u64 {
195        self.window.len() as u64
196    }
197
198    fn reset(&mut self) {
199        self.window.clear();
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn rolling_mean_basic() {
209        let mut rm = RollingMean::new(3).unwrap();
210        for x in [1.0, 2.0, 3.0] {
211            rm.update(x).unwrap();
212        }
213        assert!((rm.value().unwrap() - 2.0).abs() < 1e-12);
214    }
215
216    #[test]
217    fn rolling_mean_evicts_oldest() {
218        let mut rm = RollingMean::new(2).unwrap();
219        rm.update(10.0).unwrap();
220        rm.update(20.0).unwrap();
221        rm.update(30.0).unwrap();
222        assert!((rm.value().unwrap() - 25.0).abs() < 1e-12);
223    }
224
225    #[test]
226    fn rolling_mean_empty_returns_none() {
227        let rm = RollingMean::new(5).unwrap();
228        assert!(rm.value().is_none());
229    }
230
231    #[test]
232    fn rolling_mean_zero_capacity_rejected() {
233        assert!(matches!(
234            RollingMean::new(0),
235            Err(RillError::InvalidWindowSize)
236        ));
237    }
238
239    #[test]
240    fn rolling_variance_population() {
241        let mut rv = RollingVariance::new(4, VarianceKind::Population).unwrap();
242        for x in [1.0, 2.0, 3.0, 4.0] {
243            rv.update(x).unwrap();
244        }
245        // population variance of [1,2,3,4] = 1.25
246        assert!((rv.value().unwrap() - 1.25).abs() < 1e-12);
247    }
248
249    #[test]
250    fn rolling_variance_evicts_correctly() {
251        let mut rv = RollingVariance::new(3, VarianceKind::Population).unwrap();
252        for x in [1.0, 2.0, 3.0, 4.0] {
253            rv.update(x).unwrap();
254        }
255        // window is now [2, 3, 4], mean=3, var = (1+0+1)/3 = 0.6666...
256        assert!((rv.value().unwrap() - 2.0 / 3.0).abs() < 1e-12);
257    }
258
259    #[test]
260    fn rolling_variance_sample_insufficient() {
261        let mut rv = RollingVariance::new(5, VarianceKind::Sample).unwrap();
262        rv.update(1.0).unwrap();
263        assert!(rv.value().is_none());
264    }
265
266    #[test]
267    fn rolling_variance_zero_capacity_rejected() {
268        assert!(matches!(
269            RollingVariance::new(0, VarianceKind::Population),
270            Err(RillError::InvalidWindowSize)
271        ));
272    }
273}