Skip to main content

rill_ml/diagnostics/
prediction_interval.rs

1//! Prediction interval estimation.
2//!
3//! Maintains a bounded-memory estimate of prediction uncertainty based on the
4//! exponentially weighted mean of absolute residuals. The interval is
5//! `prediction ± k × recent_error`, where `recent_error` tracks the recent
6//! average absolute error.
7//!
8//! Space complexity: `O(1)`.
9
10use crate::error::{RillError, ensure_finite};
11use crate::stats::ExponentiallyWeightedMean;
12use crate::traits::OnlineStatistic;
13
14/// An immutable prediction interval `[lower, upper]`.
15///
16/// Both bounds are inclusive.
17#[derive(Debug, Clone, PartialEq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct PredictionInterval {
20    lower: f64,
21    upper: f64,
22}
23
24impl PredictionInterval {
25    /// Lower bound of the interval (inclusive).
26    pub const fn lower(&self) -> f64 {
27        self.lower
28    }
29
30    /// Upper bound of the interval (inclusive).
31    pub const fn upper(&self) -> f64 {
32        self.upper
33    }
34
35    /// Whether `value` lies within `[lower, upper]` (inclusive on both ends).
36    pub fn contains(&self, value: f64) -> bool {
37        self.lower <= value && value <= self.upper
38    }
39}
40
41/// Configuration for [`ResidualInterval`].
42#[derive(Debug, Clone)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44#[non_exhaustive]
45pub struct ResidualIntervalConfig {
46    /// Multiplier applied to the recent error when forming the interval.
47    ///
48    /// Must be strictly positive. Larger values produce wider, more
49    /// conservative intervals. Defaults to `1.0`.
50    pub k: f64,
51
52    /// Alpha for the exponentially weighted mean of absolute errors.
53    ///
54    /// Must be in `(0, 1]`. Smaller values give a longer memory.
55    /// Defaults to `0.1`.
56    pub alpha: f64,
57}
58
59impl Default for ResidualIntervalConfig {
60    fn default() -> Self {
61        Self { k: 1.0, alpha: 0.1 }
62    }
63}
64
65/// Residual-based prediction interval estimator.
66///
67/// Tracks the exponentially weighted mean of absolute prediction errors and
68/// forms intervals as `prediction ± k × recent_error`. Does not store raw
69/// samples.
70///
71/// # Examples
72///
73/// ```
74/// use rill_ml::diagnostics::{PredictionInterval, ResidualInterval};
75///
76/// let mut ri = ResidualInterval::default();
77/// ri.observe(10.0, 11.0).unwrap();
78/// ri.observe(10.0, 9.0).unwrap();
79///
80/// let interval: PredictionInterval = ri.interval(10.0).unwrap();
81/// assert!(interval.contains(10.5));
82/// ```
83#[derive(Debug, Clone)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct ResidualInterval {
86    config: ResidualIntervalConfig,
87    error_ew: ExponentiallyWeightedMean,
88}
89
90impl ResidualInterval {
91    /// Create a new residual interval estimator with the given configuration.
92    ///
93    /// Returns an error if `k` is not finite or not strictly positive, or if
94    /// `alpha` is not in `(0, 1]` (the latter is validated by
95    /// [`ExponentiallyWeightedMean::new`]).
96    pub fn new(config: ResidualIntervalConfig) -> Result<Self, RillError> {
97        ensure_finite("k", config.k)?;
98        if config.k <= 0.0 {
99            return Err(RillError::InvalidParameter {
100                name: "k",
101                value: config.k,
102            });
103        }
104        Ok(Self {
105            config: ResidualIntervalConfig {
106                k: config.k,
107                alpha: config.alpha,
108            },
109            error_ew: ExponentiallyWeightedMean::new(config.alpha)?,
110        })
111    }
112
113    /// Observe a prediction and its ground truth, updating the error estimate.
114    ///
115    /// The absolute residual `|truth - prediction|` is fed to the internally
116    /// tracked exponentially weighted mean. Non-finite inputs are rejected.
117    pub fn observe(&mut self, prediction: f64, truth: f64) -> Result<(), RillError> {
118        let abs_error = (truth - prediction).abs();
119        self.error_ew.update(abs_error)
120    }
121
122    /// Compute the prediction interval centred on `prediction`.
123    ///
124    /// Returns [`RillError::InsufficientData`] if no observations have been
125    /// recorded yet, and [`RillError::NonFiniteValue`] if `prediction` is not
126    /// finite.
127    pub fn interval(&self, prediction: f64) -> Result<PredictionInterval, RillError> {
128        ensure_finite("prediction", prediction)?;
129        if self.error_ew.count() == 0 {
130            return Err(RillError::InsufficientData);
131        }
132        let margin = self.config.k * self.error_ew.value();
133        Ok(PredictionInterval {
134            lower: prediction - margin,
135            upper: prediction + margin,
136        })
137    }
138
139    /// Recent average absolute error, or `None` if no observations recorded.
140    pub fn recent_error(&self) -> Option<f64> {
141        if self.error_ew.count() == 0 {
142            None
143        } else {
144            Some(self.error_ew.value())
145        }
146    }
147
148    /// Number of observations recorded so far.
149    pub fn samples_seen(&self) -> u64 {
150        self.error_ew.samples_seen()
151    }
152
153    /// Reset the estimator to its initial state.
154    pub fn reset(&mut self) {
155        self.error_ew.reset();
156    }
157}
158
159impl Default for ResidualInterval {
160    fn default() -> Self {
161        Self::new(ResidualIntervalConfig::default()).expect("default config is valid")
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn interval_constructs_correctly() {
171        let iv = PredictionInterval {
172            lower: 1.0,
173            upper: 3.0,
174        };
175        assert_eq!(iv.lower(), 1.0);
176        assert_eq!(iv.upper(), 3.0);
177    }
178
179    #[test]
180    fn contains_checks_bounds() {
181        let iv = PredictionInterval {
182            lower: 1.0,
183            upper: 3.0,
184        };
185        assert!(iv.contains(1.0)); // lower bound inclusive
186        assert!(iv.contains(3.0)); // upper bound inclusive
187        assert!(iv.contains(2.0)); // middle
188        assert!(!iv.contains(0.9)); // below
189        assert!(!iv.contains(3.1)); // above
190    }
191
192    #[test]
193    fn observe_then_interval() {
194        let mut ri = ResidualInterval::default();
195        ri.observe(10.0, 11.0).unwrap(); // |error| = 1.0
196        ri.observe(10.0, 9.0).unwrap(); // |error| = 1.0
197        let iv = ri.interval(10.0).unwrap();
198        // EW mean: seed=1.0, then 0.1*1.0 + 0.9*1.0 = 1.0
199        // margin = 1.0 (k) * 1.0 (recent) = 1.0
200        assert!((iv.lower() - 9.0).abs() < 1e-9);
201        assert!((iv.upper() - 11.0).abs() < 1e-9);
202    }
203
204    #[test]
205    fn interval_without_observations_errors() {
206        let ri = ResidualInterval::default();
207        assert!(matches!(
208            ri.interval(10.0),
209            Err(RillError::InsufficientData)
210        ));
211    }
212
213    #[test]
214    fn recent_error_none_initially() {
215        let ri = ResidualInterval::default();
216        assert_eq!(ri.recent_error(), None);
217        assert_eq!(ri.samples_seen(), 0);
218    }
219
220    #[test]
221    fn custom_k_widens_interval() {
222        let mut ri1 = ResidualInterval::new(ResidualIntervalConfig { k: 1.0, alpha: 0.1 }).unwrap();
223        let mut ri2 = ResidualInterval::new(ResidualIntervalConfig { k: 2.0, alpha: 0.1 }).unwrap();
224        ri1.observe(10.0, 12.0).unwrap(); // |error| = 2.0
225        ri2.observe(10.0, 12.0).unwrap();
226        let iv1 = ri1.interval(10.0).unwrap();
227        let iv2 = ri2.interval(10.0).unwrap();
228        let width1 = iv1.upper() - iv1.lower();
229        let width2 = iv2.upper() - iv2.lower();
230        assert!(width2 > width1);
231        // k=2.0 doubles the margin, so the width doubles.
232        assert!((width2 - 2.0 * width1).abs() < 1e-9);
233    }
234
235    #[test]
236    fn alpha_affects_memory() {
237        let mut ri = ResidualInterval::new(ResidualIntervalConfig { k: 1.0, alpha: 1.0 }).unwrap();
238        ri.observe(0.0, 10.0).unwrap(); // |error| = 10.0
239        ri.observe(0.0, 5.0).unwrap(); // |error| = 5.0
240        ri.observe(0.0, 8.0).unwrap(); // |error| = 8.0
241        // alpha=1.0 tracks the last value exactly.
242        assert!((ri.recent_error().unwrap() - 8.0).abs() < 1e-12);
243    }
244
245    #[test]
246    fn reset_clears_state() {
247        let mut ri = ResidualInterval::default();
248        ri.observe(10.0, 12.0).unwrap();
249        assert!(ri.recent_error().is_some());
250        ri.reset();
251        assert_eq!(ri.recent_error(), None);
252        assert_eq!(ri.samples_seen(), 0);
253        assert!(matches!(
254            ri.interval(10.0),
255            Err(RillError::InsufficientData)
256        ));
257    }
258
259    #[test]
260    fn non_finite_prediction_rejected() {
261        let mut ri = ResidualInterval::default();
262        ri.observe(10.0, 11.0).unwrap();
263        assert!(ri.interval(f64::NAN).is_err());
264        assert!(ri.interval(f64::INFINITY).is_err());
265        assert!(ri.interval(f64::NEG_INFINITY).is_err());
266    }
267
268    #[test]
269    fn non_finite_truth_rejected() {
270        let mut ri = ResidualInterval::default();
271        assert!(ri.observe(10.0, f64::NAN).is_err());
272        assert!(ri.observe(10.0, f64::INFINITY).is_err());
273        assert!(ri.observe(10.0, f64::NEG_INFINITY).is_err());
274        // No observations should have been recorded.
275        assert_eq!(ri.samples_seen(), 0);
276        assert_eq!(ri.recent_error(), None);
277    }
278
279    #[test]
280    fn invalid_k_rejected() {
281        let config = ResidualIntervalConfig { k: 0.0, alpha: 0.1 };
282        assert!(ResidualInterval::new(config).is_err());
283        let config = ResidualIntervalConfig {
284            k: -1.0,
285            alpha: 0.1,
286        };
287        assert!(ResidualInterval::new(config).is_err());
288    }
289
290    #[test]
291    fn invalid_alpha_rejected() {
292        let config = ResidualIntervalConfig { k: 1.0, alpha: 0.0 };
293        assert!(ResidualInterval::new(config).is_err());
294    }
295
296    /// Deterministic pseudo-random number in `[0, 1)` using a simple LCG
297    /// (Knuth MMIX constants) so the coverage test is reproducible.
298    fn next_unit(seed: &mut u64) -> f64 {
299        *seed = seed
300            .wrapping_mul(6364136223846793005)
301            .wrapping_add(1442695040888963407);
302        ((*seed >> 11) as f64) / ((1u64 << 53) as f64)
303    }
304
305    #[test]
306    fn interval_contains_subsequent_observation() {
307        let config = ResidualIntervalConfig { k: 3.0, alpha: 0.1 };
308        let mut ri = ResidualInterval::new(config).unwrap();
309        let mut seed: u64 = 42;
310        let prediction = 10.0;
311
312        // Warm up the error estimate with bounded noise in [-1, 1].
313        for _ in 0..50 {
314            let noise = 2.0 * next_unit(&mut seed) - 1.0;
315            ri.observe(prediction, prediction + noise).unwrap();
316        }
317
318        // Most subsequent observations should fall within the interval.
319        let mut contained = 0u64;
320        let total = 100u64;
321        for _ in 0..total {
322            let noise = 2.0 * next_unit(&mut seed) - 1.0;
323            let truth = prediction + noise;
324            ri.observe(prediction, truth).unwrap();
325            let iv = ri.interval(prediction).unwrap();
326            if iv.contains(truth) {
327                contained += 1;
328            }
329        }
330        // With k=3.0 and |error| <= 1.0, the EW mean (~0.5) gives a margin of
331        // ~1.5, which comfortably covers the noise range.
332        assert!(
333            contained as f64 / total as f64 > 0.9,
334            "only {}/{} observations contained",
335            contained,
336            total
337        );
338    }
339
340    #[cfg(feature = "serde")]
341    #[test]
342    fn serde_roundtrip() {
343        let mut ri = ResidualInterval::default();
344        ri.observe(10.0, 12.0).unwrap();
345        ri.observe(10.0, 9.0).unwrap();
346
347        let json = serde_json::to_string(&ri).unwrap();
348        let restored: ResidualInterval = serde_json::from_str(&json).unwrap();
349        assert_eq!(restored.samples_seen(), 2);
350        assert!((restored.recent_error().unwrap() - ri.recent_error().unwrap()).abs() < 1e-12);
351        let iv = restored.interval(10.0).unwrap();
352        assert!(iv.contains(10.0));
353    }
354}