auracle_session/calib.rs
1//! Prequential calibration: is the model's confidence *honest*?
2//!
3//! Every duel is forecast before it is answered — `record_duel` scores the
4//! posterior's `P(A wins)` and only then appends the observation — so these
5//! are genuinely out-of-sample, one-step-ahead predictions. What was done with
6//! them was not: a running count of `p > 0.5` outcomes is **accuracy**, and
7//! accuracy is not a proper scoring rule. A model that says 0.51 every time
8//! and is right 51 % of the time scores identically to one that says 0.99 and
9//! is right 51 % of the time. Worse, an information-seeking acquisition
10//! function *deliberately* picks pairs near p = 0.5, so the hit rate is pinned
11//! near 50 % by construction — a perfectly calibrated model looks like a coin
12//! flip, and the user concludes it is not learning.
13//!
14//! Two honest replacements:
15//!
16//! - **Brier score** `B = mean (p_chosen − 1)²`, reported as skill
17//! `1 − B/0.25` against the always-0.5 baseline. Proper, bounded, and it
18//! moves as sharpness improves rather than only as accuracy does.
19//! - **A reliability diagram**: bin the forecasts and compare predicted with
20//! observed frequency. This is the display that makes calibration legible —
21//! the diagonal is the claim, the bars are the evidence.
22//!
23//! And a selection-bias fix, because the acquisition function chooses which
24//! duels get scored: a fraction of duels are drawn uniformly at random and
25//! flagged ([`Forecast::random_check`]). Calibration on *those* is unbiased,
26//! and it is reported separately. It costs a small share of the query budget
27//! and it is the only number here that means what it says without an asterisk.
28
29use serde::{Deserialize, Serialize};
30
31use auracle_taste::Provenance;
32
33/// One out-of-sample duel forecast, recorded before the answer was known.
34#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
35pub struct Forecast {
36 /// Posterior probability the model gave to candidate A winning.
37 pub p_a: f64,
38 /// What the user actually did.
39 pub chose_a: bool,
40 /// True when the pair was drawn uniformly at random rather than by the
41 /// acquisition function — the unbiased subsample.
42 pub random_check: bool,
43 /// How the answer was collected. `#[serde(default)]` because forecasts
44 /// persist with the session and every one already on disk was a dealt
45 /// duel, which is exactly what [`Provenance::Duel`] means.
46 #[serde(default)]
47 pub provenance: Provenance,
48}
49
50impl Forecast {
51 /// Probability the model gave to the option the user actually picked.
52 pub fn p_chosen(&self) -> f64 {
53 if self.chose_a {
54 self.p_a
55 } else {
56 1.0 - self.p_a
57 }
58 }
59}
60
61/// One bucket of the reliability diagram.
62#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
63pub struct ReliabilityBin {
64 /// Inclusive lower edge of the forecast bucket.
65 pub lo: f64,
66 /// Exclusive upper edge (inclusive for the last bucket).
67 pub hi: f64,
68 /// Forecasts in this bucket.
69 pub n: usize,
70 /// Mean forecast probability in the bucket (the model's claim).
71 pub predicted: f64,
72 /// Observed frequency of "A won" in the bucket (the evidence).
73 pub observed: f64,
74}
75
76/// One provenance's slice of the forecast stream.
77///
78/// The comparison this exists for: a hand edit committed through a **heard**
79/// duel and one committed by ticking "my edit is better" make the same claim
80/// in the log, and there is no reason to believe they are equally reliable.
81/// Scoring them against forecasts the model made *before* either answer
82/// arrived is the only way to find out which — and it costs one tag.
83#[derive(Clone, Debug, Serialize, Deserialize)]
84pub struct ProvenanceScore {
85 /// Which stream (`"duel"`, `"heard_edit"`, `"self_report"`).
86 pub provenance: String,
87 /// Forecasts scored in it.
88 pub n: usize,
89 /// Mean Brier score over them.
90 pub brier: f64,
91 /// Mean log-loss over them, in nats.
92 pub log_loss: f64,
93 /// Brier skill against a coin flip, `1 − B/0.25`.
94 pub skill: f64,
95}
96
97/// Calibration summary over a set of forecasts.
98#[derive(Clone, Debug, Serialize, Deserialize)]
99pub struct Calibration {
100 /// Forecasts scored.
101 pub n: usize,
102 /// Prequential Brier score, `mean (p_chosen − 1)²`. Lower is better;
103 /// 0.25 is the always-0.5 baseline.
104 pub brier: f64,
105 /// Prequential log-loss, `mean −ln p_chosen`, in nats. Lower is better;
106 /// `ln 2 ≈ 0.693` is the always-0.5 baseline.
107 ///
108 /// Comparable across *time* for one acquisition rule, and **not**
109 /// comparable across acquisition rules: an information-seeking rule
110 /// deliberately serves duels near p = 0.5, which carry the highest
111 /// log-loss by construction. Comparing rules on their own self-chosen
112 /// question sets would score the willingness to ask hard questions as a
113 /// failure. Use `check_log_loss` for that.
114 pub log_loss: f64,
115 /// Brier skill against a coin flip, `1 − B/0.25`. 0 = no better than
116 /// chance, 1 = perfect and certain, negative = worse than a coin.
117 pub skill: f64,
118 /// Reliability diagram buckets over `P(A wins)`.
119 pub bins: Vec<ReliabilityBin>,
120 /// Forecasts among the uniformly-random check duels.
121 pub check_n: usize,
122 /// Brier skill restricted to the check duels — the selection-bias-free
123 /// number.
124 pub check_skill: f64,
125 /// Log-loss restricted to the uniformly-random check duels, in nats. The
126 /// only log-loss here that means the same thing under any acquisition
127 /// rule.
128 pub check_log_loss: f64,
129 /// Running hit rate, kept only so a frontend can show how misleading it
130 /// is next to the skill score.
131 pub hit_rate: f64,
132 /// The same scores, split by how the answer was collected. Empty streams
133 /// are omitted, so a session that has never committed a hand edit carries
134 /// exactly one row and reads as it always did.
135 #[serde(default)]
136 pub by_provenance: Vec<ProvenanceScore>,
137}
138
139/// Number of reliability buckets. Five is the most a small session can fill
140/// without every bucket being noise.
141const N_BINS: usize = 5;
142
143/// Count, mean Brier, and mean log-loss (nats) over a forecast stream.
144fn score(fs: impl Iterator<Item = Forecast>) -> (usize, f64, f64) {
145 let mut n = 0usize;
146 let (mut b, mut ll) = (0.0, 0.0);
147 for f in fs {
148 n += 1;
149 let p = f.p_chosen();
150 let e = p - 1.0;
151 b += e * e;
152 ll += -p.clamp(1e-12, 1.0).ln();
153 }
154 if n == 0 {
155 (0, 0.0, 0.0)
156 } else {
157 (n, b / n as f64, ll / n as f64)
158 }
159}
160
161/// Summarize a forecast stream.
162pub fn calibration(forecasts: &[Forecast]) -> Calibration {
163 let (n, b, ll) = score(forecasts.iter().copied());
164 let (check_n, check_b, check_ll) = score(forecasts.iter().copied().filter(|f| f.random_check));
165 let hits = forecasts.iter().filter(|f| f.p_chosen() > 0.5).count();
166
167 let mut bins: Vec<ReliabilityBin> = (0..N_BINS)
168 .map(|i| ReliabilityBin {
169 lo: i as f64 / N_BINS as f64,
170 hi: (i + 1) as f64 / N_BINS as f64,
171 n: 0,
172 predicted: 0.0,
173 observed: 0.0,
174 })
175 .collect();
176 for f in forecasts {
177 let i = ((f.p_a * N_BINS as f64) as usize).min(N_BINS - 1);
178 bins[i].n += 1;
179 bins[i].predicted += f.p_a;
180 bins[i].observed += f.chose_a as u8 as f64;
181 }
182 for b in &mut bins {
183 if b.n > 0 {
184 b.predicted /= b.n as f64;
185 b.observed /= b.n as f64;
186 }
187 }
188
189 let by_provenance = [
190 Provenance::Duel,
191 Provenance::HeardEdit,
192 Provenance::SelfReport,
193 ]
194 .into_iter()
195 .filter_map(|p| {
196 let (n, b, ll) = score(forecasts.iter().copied().filter(|f| f.provenance == p));
197 (n > 0).then(|| ProvenanceScore {
198 provenance: p.as_str().into(),
199 n,
200 brier: b,
201 log_loss: ll,
202 skill: 1.0 - b / 0.25,
203 })
204 })
205 .collect();
206
207 Calibration {
208 n,
209 brier: b,
210 log_loss: ll,
211 skill: if n == 0 { 0.0 } else { 1.0 - b / 0.25 },
212 bins,
213 check_n,
214 check_skill: if check_n == 0 {
215 0.0
216 } else {
217 1.0 - check_b / 0.25
218 },
219 check_log_loss: check_ll,
220 hit_rate: if n == 0 { 0.0 } else { hits as f64 / n as f64 },
221 by_provenance,
222 }
223}