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