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            if !sum.is_finite() {
78                return None;
79            }
80            Some(sum / self.window.len() as f64)
81        }
82    }
83}
84
85impl OnlineStatistic for RollingMean {
86    fn update(&mut self, value: f64) -> Result<(), RillError> {
87        ensure_finite("value", value)?;
88        if self.window.len() == self.capacity {
89            self.window.pop_front();
90        }
91        self.window.push_back(value);
92        Ok(())
93    }
94
95    fn samples_seen(&self) -> u64 {
96        self.window.len() as u64
97    }
98
99    fn reset(&mut self) {
100        self.window.clear();
101    }
102}
103
104/// Rolling variance over a fixed-size window.
105///
106/// Uses [`VarianceKind`] to select population or sample variance. The window
107/// contents are stored and the variance recomputed on each query.
108#[derive(Debug, Clone)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct RollingVariance {
111    window: VecDeque<f64>,
112    capacity: usize,
113    kind: VarianceKind,
114}
115
116impl RollingVariance {
117    /// Create a new rolling variance accumulator.
118    ///
119    /// Returns an error if `capacity` is zero.
120    pub fn new(capacity: usize, kind: VarianceKind) -> Result<Self, RillError> {
121        if capacity == 0 {
122            return Err(RillError::InvalidWindowSize);
123        }
124        Ok(Self {
125            window: VecDeque::with_capacity(capacity),
126            capacity,
127            kind,
128        })
129    }
130
131    /// The configured window capacity.
132    pub const fn capacity(&self) -> usize {
133        self.capacity
134    }
135
136    /// The configured variance kind.
137    pub const fn kind(&self) -> VarianceKind {
138        self.kind
139    }
140
141    /// Number of observations currently in the window.
142    pub fn len(&self) -> usize {
143        self.window.len()
144    }
145
146    /// Whether the window is currently empty.
147    pub fn is_empty(&self) -> bool {
148        self.window.is_empty()
149    }
150
151    /// Current rolling mean, or `None` if the window is empty.
152    pub fn mean(&self) -> Option<f64> {
153        if self.window.is_empty() {
154            None
155        } else {
156            let sum: f64 = self.window.iter().sum();
157            if !sum.is_finite() {
158                return None;
159            }
160            Some(sum / self.window.len() as f64)
161        }
162    }
163
164    /// Current rolling variance, or `None` when not enough data is in the window.
165    pub fn value(&self) -> Option<f64> {
166        let n = self.window.len();
167        if n == 0 {
168            return None;
169        }
170        let denom = match self.kind {
171            VarianceKind::Population => n,
172            VarianceKind::Sample => {
173                if n < 2 {
174                    return None;
175                }
176                n - 1
177            }
178        };
179        let mean = self.mean()?;
180        let ss = self.window.iter().map(|x| (x - mean).powi(2)).sum::<f64>();
181        if !ss.is_finite() {
182            return None;
183        }
184        Some(ss / denom as f64)
185    }
186
187    /// Current rolling standard deviation, or `None` when not enough data.
188    pub fn std_dev(&self) -> Option<f64> {
189        self.value().map(|v| v.sqrt())
190    }
191}
192
193impl OnlineStatistic for RollingVariance {
194    fn update(&mut self, value: f64) -> Result<(), RillError> {
195        ensure_finite("value", value)?;
196        if self.window.len() == self.capacity {
197            self.window.pop_front();
198        }
199        self.window.push_back(value);
200        Ok(())
201    }
202
203    fn samples_seen(&self) -> u64 {
204        self.window.len() as u64
205    }
206
207    fn reset(&mut self) {
208        self.window.clear();
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn rolling_mean_basic() {
218        let mut rm = RollingMean::new(3).unwrap();
219        for x in [1.0, 2.0, 3.0] {
220            rm.update(x).unwrap();
221        }
222        assert!((rm.value().unwrap() - 2.0).abs() < 1e-12);
223    }
224
225    #[test]
226    fn rolling_mean_evicts_oldest() {
227        let mut rm = RollingMean::new(2).unwrap();
228        rm.update(10.0).unwrap();
229        rm.update(20.0).unwrap();
230        rm.update(30.0).unwrap();
231        assert!((rm.value().unwrap() - 25.0).abs() < 1e-12);
232    }
233
234    #[test]
235    fn rolling_mean_empty_returns_none() {
236        let rm = RollingMean::new(5).unwrap();
237        assert!(rm.value().is_none());
238    }
239
240    #[test]
241    fn rolling_mean_zero_capacity_rejected() {
242        assert!(matches!(
243            RollingMean::new(0),
244            Err(RillError::InvalidWindowSize)
245        ));
246    }
247
248    #[test]
249    fn rolling_variance_population() {
250        let mut rv = RollingVariance::new(4, VarianceKind::Population).unwrap();
251        for x in [1.0, 2.0, 3.0, 4.0] {
252            rv.update(x).unwrap();
253        }
254        // population variance of [1,2,3,4] = 1.25
255        assert!((rv.value().unwrap() - 1.25).abs() < 1e-12);
256    }
257
258    #[test]
259    fn rolling_variance_evicts_correctly() {
260        let mut rv = RollingVariance::new(3, VarianceKind::Population).unwrap();
261        for x in [1.0, 2.0, 3.0, 4.0] {
262            rv.update(x).unwrap();
263        }
264        // window is now [2, 3, 4], mean=3, var = (1+0+1)/3 = 0.6666...
265        assert!((rv.value().unwrap() - 2.0 / 3.0).abs() < 1e-12);
266    }
267
268    #[test]
269    fn rolling_variance_sample_insufficient() {
270        let mut rv = RollingVariance::new(5, VarianceKind::Sample).unwrap();
271        rv.update(1.0).unwrap();
272        assert!(rv.value().is_none());
273    }
274
275    #[test]
276    fn rolling_variance_zero_capacity_rejected() {
277        assert!(matches!(
278            RollingVariance::new(0, VarianceKind::Population),
279            Err(RillError::InvalidWindowSize)
280        ));
281    }
282}