rill_ml/diagnostics/
prediction_interval.rs1use crate::error::{RillError, ensure_finite};
11use crate::stats::ExponentiallyWeightedMean;
12use crate::traits::OnlineStatistic;
13
14#[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 pub const fn lower(&self) -> f64 {
27 self.lower
28 }
29
30 pub const fn upper(&self) -> f64 {
32 self.upper
33 }
34
35 pub fn contains(&self, value: f64) -> bool {
37 self.lower <= value && value <= self.upper
38 }
39}
40
41#[derive(Debug, Clone)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44#[non_exhaustive]
45pub struct ResidualIntervalConfig {
46 pub k: f64,
51
52 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#[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 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 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 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 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 pub fn samples_seen(&self) -> u64 {
150 self.error_ew.samples_seen()
151 }
152
153 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)); assert!(iv.contains(3.0)); assert!(iv.contains(2.0)); assert!(!iv.contains(0.9)); assert!(!iv.contains(3.1)); }
191
192 #[test]
193 fn observe_then_interval() {
194 let mut ri = ResidualInterval::default();
195 ri.observe(10.0, 11.0).unwrap(); ri.observe(10.0, 9.0).unwrap(); let iv = ri.interval(10.0).unwrap();
198 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(); 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 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(); ri.observe(0.0, 5.0).unwrap(); ri.observe(0.0, 8.0).unwrap(); 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 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 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 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 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 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}