Skip to main content

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}