Skip to main content

corescout_selfmodel/
uncertainty.rs

1//! How sure the model is, and why that has to be reported.
2//!
3//! # A prediction without a confidence is not usable
4//!
5//! A controller deciding whether to move a thread needs to distinguish
6//! "predicted improvement 12%, and I have been within 2% on this cell for the
7//! last hundred ticks" from "predicted improvement 12%, and I have never seen
8//! this cell behave this way before". Those are the same number and opposite
9//! decisions.
10//!
11//! # Confidence is per-cell and recent
12//!
13//! A global "the model is 80% accurate" figure is nearly useless: a mirror has
14//! cells that are trivially predictable and cells that are essentially noise,
15//! and averaging them describes neither. So confidence is tracked per cell,
16//! from a decaying window of recent errors, which also lets it *fall* when the
17//! machine changes character.
18
19use serde::{Deserialize, Serialize};
20
21/// A predicted range.
22#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
23pub struct Interval {
24    #[serde(with = "corescout_core::serde_util::maybe_finite")]
25    pub point: f64,
26    #[serde(with = "corescout_core::serde_util::maybe_finite")]
27    pub low: f64,
28    #[serde(with = "corescout_core::serde_util::maybe_finite")]
29    pub high: f64,
30}
31
32impl Interval {
33    pub fn new(point: f64, half_width: f64) -> Interval {
34        let half_width = half_width.abs();
35        Interval {
36            point,
37            low: point - half_width,
38            high: point + half_width,
39        }
40    }
41
42    /// An interval with no width, for a value known exactly.
43    pub fn exact(point: f64) -> Interval {
44        Interval {
45            point,
46            low: point,
47            high: point,
48        }
49    }
50
51    /// An interval expressing complete ignorance.
52    pub fn unknown() -> Interval {
53        Interval {
54            point: f64::NAN,
55            low: f64::NEG_INFINITY,
56            high: f64::INFINITY,
57        }
58    }
59
60    pub fn width(&self) -> f64 {
61        self.high - self.low
62    }
63
64    pub fn contains(&self, value: f64) -> bool {
65        value >= self.low && value <= self.high
66    }
67
68    pub fn is_known(&self) -> bool {
69        self.point.is_finite() && self.width().is_finite()
70    }
71}
72
73/// A running estimate of how well one cell is being predicted.
74///
75/// Exponentially weighted, so it adapts when the machine changes rather than
76/// averaging over an epoch of behaviour that has ended.
77#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
78pub struct Confidence {
79    /// Decaying mean absolute error.
80    mean_error: f64,
81    /// Decaying mean absolute error of the naive baseline, for comparison.
82    baseline_error: f64,
83    /// Decaying mean squared error, for the interval width.
84    mean_squared_error: f64,
85    /// Observations folded in.
86    samples: u64,
87    /// Weight given to each new observation.
88    alpha: f64,
89}
90
91impl Default for Confidence {
92    fn default() -> Self {
93        Confidence::new(0.05)
94    }
95}
96
97impl Confidence {
98    /// `alpha` is the weight of each new sample. 0.05 gives a memory of
99    /// roughly the last twenty observations.
100    pub fn new(alpha: f64) -> Confidence {
101        Confidence {
102            mean_error: 0.0,
103            baseline_error: 0.0,
104            mean_squared_error: 0.0,
105            samples: 0,
106            alpha: alpha.clamp(1e-4, 1.0),
107        }
108    }
109
110    /// Fold in one prediction and what actually happened.
111    pub fn observe(&mut self, predicted: f64, baseline: f64, actual: f64) {
112        if !predicted.is_finite() || !actual.is_finite() {
113            return;
114        }
115        let error = (predicted - actual).abs();
116        let baseline_error = (baseline - actual).abs();
117        if self.samples == 0 {
118            self.mean_error = error;
119            self.baseline_error = baseline_error;
120            self.mean_squared_error = error * error;
121        } else {
122            self.mean_error += self.alpha * (error - self.mean_error);
123            self.baseline_error += self.alpha * (baseline_error - self.baseline_error);
124            self.mean_squared_error += self.alpha * (error * error - self.mean_squared_error);
125        }
126        self.samples += 1;
127    }
128
129    pub fn samples(&self) -> u64 {
130        self.samples
131    }
132
133    pub fn mean_error(&self) -> f64 {
134        self.mean_error
135    }
136
137    /// Typical error magnitude, for sizing a prediction interval.
138    pub fn sigma(&self) -> f64 {
139        self.mean_squared_error.max(0.0).sqrt()
140    }
141
142    /// Skill against the naive baseline: `1 - model/baseline`.
143    ///
144    /// Positive means the model is beating "assume nothing changed"; zero means
145    /// it is merely matching it; negative means it is actively worse, which is
146    /// worth knowing and worth reporting rather than clamping away.
147    pub fn skill(&self) -> f64 {
148        // The baseline is perfect, or so nearly perfect that the ratio is an
149        // artefact of the divisor rather than a fact about the model.
150        //
151        // The threshold has to be *relative*. An absolute floor of 1e-12 is
152        // meaningless on a channel whose values are around 1e15: a baseline
153        // error of 1e-9 there is a perfect prediction in every sense that
154        // matters, and dividing by it produced a reported skill of -3.7e11 the
155        // first time this ran on real hardware.
156        let scale = self.mean_error.abs().max(self.baseline_error.abs());
157        if self.baseline_error <= 1e-12 || self.baseline_error <= scale * 1e-9 {
158            return 0.0;
159        }
160        // Bounded below. A model can be arbitrarily worse than the baseline,
161        // and letting one cell report -400 would let it swamp any average it
162        // appears in. Minus one means "as wrong as the baseline is right",
163        // which is as much detail as an aggregate can carry.
164        (1.0 - (self.mean_error / self.baseline_error)).max(-1.0)
165    }
166
167    /// A confidence in `0.0 ..= 1.0`, combining skill with evidence.
168    ///
169    /// A model that has beaten the baseline five times is not as trustworthy as
170    /// one that has beaten it five hundred times, so the score is damped by how
171    /// much has been seen.
172    pub fn score(&self) -> f64 {
173        if self.samples == 0 {
174            return 0.0;
175        }
176        let evidence = (self.samples as f64 / 50.0).min(1.0);
177        let skill = self.skill().clamp(0.0, 1.0);
178        (skill * evidence).clamp(0.0, 1.0)
179    }
180
181    /// A prediction interval around a point estimate.
182    ///
183    /// Two sigma of recent error. Not a rigorous confidence interval, and
184    /// documented as such: it is a statement about how wrong this model has
185    /// recently been on this cell, which is the useful thing and is not the
186    /// same as a probabilistic guarantee.
187    pub fn interval(&self, point: f64) -> Interval {
188        if self.samples < 3 {
189            return Interval::unknown();
190        }
191        Interval::new(point, 2.0 * self.sigma())
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn an_interval_knows_what_it_contains() {
201        let interval = Interval::new(10.0, 2.0);
202        assert!(interval.contains(9.0));
203        assert!(!interval.contains(13.0));
204        assert_eq!(interval.width(), 4.0);
205        assert!(interval.is_known());
206    }
207
208    #[test]
209    fn ignorance_is_representable() {
210        let unknown = Interval::unknown();
211        assert!(!unknown.is_known());
212        assert!(
213            unknown.contains(1e300),
214            "an unknown interval excludes nothing"
215        );
216    }
217
218    #[test]
219    fn a_model_that_matches_the_baseline_has_no_skill() {
220        let mut confidence = Confidence::new(0.2);
221        for _ in 0..50 {
222            confidence.observe(10.0, 10.0, 12.0);
223        }
224        assert!(confidence.skill().abs() < 1e-6);
225        assert_eq!(confidence.score(), 0.0);
226    }
227
228    #[test]
229    fn a_model_that_beats_the_baseline_earns_confidence() {
230        let mut confidence = Confidence::new(0.2);
231        for _ in 0..100 {
232            // The model is off by 1, the baseline by 4.
233            confidence.observe(11.0, 8.0, 12.0);
234        }
235        assert!(confidence.skill() > 0.7, "skill was {}", confidence.skill());
236        assert!(confidence.score() > 0.7);
237    }
238
239    #[test]
240    fn a_model_that_is_worse_than_the_baseline_reports_negative_skill() {
241        // Clamping this to zero would hide the most important thing a model
242        // can tell you about itself.
243        let mut confidence = Confidence::new(0.2);
244        for _ in 0..50 {
245            confidence.observe(50.0, 11.0, 12.0);
246        }
247        assert!(confidence.skill() < 0.0);
248        assert_eq!(confidence.score(), 0.0, "score floors at zero");
249    }
250
251    #[test]
252    fn little_evidence_means_little_confidence_even_when_right() {
253        let mut sparse = Confidence::new(0.2);
254        let mut plentiful = Confidence::new(0.2);
255        for _ in 0..3 {
256            sparse.observe(11.0, 8.0, 12.0);
257        }
258        for _ in 0..100 {
259            plentiful.observe(11.0, 8.0, 12.0);
260        }
261        assert!(
262            sparse.score() < plentiful.score(),
263            "three correct predictions is not the same evidence as a hundred"
264        );
265    }
266
267    #[test]
268    fn confidence_falls_when_the_machine_changes_character() {
269        let mut confidence = Confidence::new(0.2);
270        for _ in 0..100 {
271            confidence.observe(11.0, 8.0, 12.0);
272        }
273        let before = confidence.skill();
274        for _ in 0..50 {
275            // The model is suddenly wrong and the baseline is right.
276            confidence.observe(11.0, 40.0, 40.0);
277        }
278        assert!(
279            confidence.skill() < before,
280            "a model must notice when it stops working"
281        );
282    }
283
284    #[test]
285    fn an_interval_needs_evidence_before_it_claims_a_width() {
286        let mut confidence = Confidence::new(0.2);
287        assert!(!confidence.interval(10.0).is_known());
288        for _ in 0..10 {
289            confidence.observe(10.0, 10.0, 10.5);
290        }
291        let interval = confidence.interval(10.0);
292        assert!(interval.is_known());
293        assert!(interval.contains(10.5));
294    }
295
296    #[test]
297    fn unobservable_values_are_ignored_rather_than_poisoning_the_estimate() {
298        let mut confidence = Confidence::new(0.2);
299        confidence.observe(f64::NAN, 1.0, 1.0);
300        confidence.observe(1.0, 1.0, f64::NAN);
301        assert_eq!(confidence.samples(), 0);
302    }
303}