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))]
44pub struct ResidualIntervalConfig {
45 pub k: f64,
50
51 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#[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 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 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 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 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 pub fn samples_seen(&self) -> u64 {
149 self.error_ew.samples_seen()
150 }
151
152 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)); assert!(iv.contains(3.0)); assert!(iv.contains(2.0)); assert!(!iv.contains(0.9)); assert!(!iv.contains(3.1)); }
190
191 #[test]
192 fn observe_then_interval() {
193 let mut ri = ResidualInterval::default();
194 ri.observe(10.0, 11.0).unwrap(); ri.observe(10.0, 9.0).unwrap(); let iv = ri.interval(10.0).unwrap();
197 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(); 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 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(); ri.observe(0.0, 5.0).unwrap(); ri.observe(0.0, 8.0).unwrap(); 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 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 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 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 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 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}