Skip to main content

uqa_scoring/
calibration_validation.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Held-out calibration validation with deterministic uncertainty estimates.
8//!
9//! These utilities do not turn an unlabeled transform into a probability
10//! model. They evaluate already-produced probabilities on a held-out target
11//! population, attach bootstrap confidence intervals, and transfer a decision
12//! threshold selected on a disjoint validation split without retuning it.
13
14use crate::error::{invalid_input, require_finite, require_probability};
15use crate::{CalibrationMetrics, CalibrationReport, ScoringResult};
16
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct BootstrapConfig {
19    pub resamples: usize,
20    pub confidence_level: f64,
21    pub seed: u64,
22}
23
24impl BootstrapConfig {
25    pub fn validate(self) -> ScoringResult<()> {
26        if self.resamples < 2 {
27            return Err(invalid_input(format!(
28                "bootstrap resamples must be at least 2, got {}",
29                self.resamples
30            )));
31        }
32        require_finite(self.confidence_level, "bootstrap confidence_level")?;
33        if self.confidence_level <= 0.0 || self.confidence_level >= 1.0 {
34            return Err(invalid_input(format!(
35                "bootstrap confidence_level must be in (0, 1), got {}",
36                self.confidence_level
37            )));
38        }
39        Ok(())
40    }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct ConfidenceInterval {
45    pub lower: f64,
46    pub upper: f64,
47    pub confidence_level: f64,
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub struct HeldOutCalibrationReport {
52    pub point: CalibrationReport,
53    pub ece_interval: ConfidenceInterval,
54    pub brier_interval: ConfidenceInterval,
55    pub log_loss_interval: ConfidenceInterval,
56    pub sample_count: usize,
57    pub bootstrap: BootstrapConfig,
58}
59
60impl HeldOutCalibrationReport {
61    pub fn evaluate(
62        probabilities: &[f64],
63        labels: &[u8],
64        n_bins: usize,
65        bootstrap: BootstrapConfig,
66    ) -> ScoringResult<Self> {
67        bootstrap.validate()?;
68        if probabilities.is_empty() {
69            return Err(invalid_input(
70                "held-out calibration evaluation requires at least one sample",
71            ));
72        }
73        let point = CalibrationMetrics::report(probabilities, labels, n_bins)?;
74        let mut rng = SplitMix64::new(bootstrap.seed);
75        let mut sampled_probabilities = vec![0.0; probabilities.len()];
76        let mut sampled_labels = vec![0_u8; labels.len()];
77        let mut ece = Vec::with_capacity(bootstrap.resamples);
78        let mut brier = Vec::with_capacity(bootstrap.resamples);
79        let mut log_loss = Vec::with_capacity(bootstrap.resamples);
80        for _ in 0..bootstrap.resamples {
81            for index in 0..probabilities.len() {
82                let sampled = rng.index(probabilities.len())?;
83                sampled_probabilities[index] = probabilities[sampled];
84                sampled_labels[index] = labels[sampled];
85            }
86            let report =
87                CalibrationMetrics::report(&sampled_probabilities, &sampled_labels, n_bins)?;
88            ece.push(report.ece);
89            brier.push(report.brier);
90            log_loss.push(report.log_loss);
91        }
92
93        Ok(Self {
94            ece_interval: percentile_interval(&mut ece, point.ece, bootstrap.confidence_level),
95            brier_interval: percentile_interval(
96                &mut brier,
97                point.brier,
98                bootstrap.confidence_level,
99            ),
100            log_loss_interval: percentile_interval(
101                &mut log_loss,
102                point.log_loss,
103                bootstrap.confidence_level,
104            ),
105            sample_count: probabilities.len(),
106            point,
107            bootstrap,
108        })
109    }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct BinaryDecisionMetrics {
114    pub precision: f64,
115    pub recall: f64,
116    pub f1: f64,
117    pub predicted_positive: usize,
118    pub actual_positive: usize,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq)]
122pub struct ThresholdTransferReport {
123    pub threshold: f64,
124    pub validation: BinaryDecisionMetrics,
125    pub held_out: BinaryDecisionMetrics,
126}
127
128impl ThresholdTransferReport {
129    /// Select the F1-maximizing threshold on `validation_*`, then apply that
130    /// exact threshold to the disjoint held-out split.
131    pub fn evaluate(
132        validation_probabilities: &[f64],
133        validation_labels: &[u8],
134        held_out_probabilities: &[f64],
135        held_out_labels: &[u8],
136    ) -> ScoringResult<Self> {
137        validate_labeled_probabilities(validation_probabilities, validation_labels, "validation")?;
138        validate_labeled_probabilities(held_out_probabilities, held_out_labels, "held_out")?;
139        if validation_probabilities.is_empty() || held_out_probabilities.is_empty() {
140            return Err(invalid_input(
141                "threshold transfer requires non-empty validation and held-out splits",
142            ));
143        }
144
145        let mut thresholds = validation_probabilities.to_vec();
146        thresholds.push(0.0);
147        thresholds.push(1.0);
148        thresholds.sort_by(f64::total_cmp);
149        thresholds.dedup_by(|left, right| left.to_bits() == right.to_bits());
150
151        let mut best_threshold = thresholds[0];
152        let mut best =
153            decision_metrics(validation_probabilities, validation_labels, best_threshold);
154        for threshold in thresholds.into_iter().skip(1) {
155            let metrics = decision_metrics(validation_probabilities, validation_labels, threshold);
156            let order = metrics
157                .f1
158                .total_cmp(&best.f1)
159                .then_with(|| metrics.precision.total_cmp(&best.precision))
160                .then_with(|| threshold.total_cmp(&best_threshold));
161            if order.is_gt() {
162                best_threshold = threshold;
163                best = metrics;
164            }
165        }
166
167        Ok(Self {
168            threshold: best_threshold,
169            validation: best,
170            held_out: decision_metrics(held_out_probabilities, held_out_labels, best_threshold),
171        })
172    }
173}
174
175/// Regression gate applied to held-out metrics. Calibration thresholds use
176/// the upper bootstrap bound, so passing means the configured maximum remains
177/// satisfied at the requested confidence level. The transferred decision
178/// threshold is gated on held-out F1 without selecting it on held-out labels.
179#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct HeldOutCalibrationGate {
181    pub max_ece_upper: f64,
182    pub max_brier_upper: f64,
183    pub max_log_loss_upper: f64,
184    pub min_transferred_f1: f64,
185}
186
187impl HeldOutCalibrationGate {
188    pub fn check(
189        self,
190        calibration: &HeldOutCalibrationReport,
191        threshold_transfer: &ThresholdTransferReport,
192    ) -> ScoringResult<()> {
193        require_probability(self.max_ece_upper, "max_ece_upper")?;
194        require_probability(self.max_brier_upper, "max_brier_upper")?;
195        require_finite(self.max_log_loss_upper, "max_log_loss_upper")?;
196        if self.max_log_loss_upper < 0.0 {
197            return Err(invalid_input("max_log_loss_upper must be non-negative"));
198        }
199        require_probability(self.min_transferred_f1, "min_transferred_f1")?;
200
201        let mut failures = Vec::new();
202        if calibration.ece_interval.upper > self.max_ece_upper {
203            failures.push(format!(
204                "ECE upper bound {} exceeds {}",
205                calibration.ece_interval.upper, self.max_ece_upper
206            ));
207        }
208        if calibration.brier_interval.upper > self.max_brier_upper {
209            failures.push(format!(
210                "Brier upper bound {} exceeds {}",
211                calibration.brier_interval.upper, self.max_brier_upper
212            ));
213        }
214        if calibration.log_loss_interval.upper > self.max_log_loss_upper {
215            failures.push(format!(
216                "log-loss upper bound {} exceeds {}",
217                calibration.log_loss_interval.upper, self.max_log_loss_upper
218            ));
219        }
220        if threshold_transfer.held_out.f1 < self.min_transferred_f1 {
221            failures.push(format!(
222                "transferred held-out F1 {} is below {}",
223                threshold_transfer.held_out.f1, self.min_transferred_f1
224            ));
225        }
226        if failures.is_empty() {
227            Ok(())
228        } else {
229            Err(invalid_input(format!(
230                "held-out calibration gate failed: {}",
231                failures.join("; ")
232            )))
233        }
234    }
235}
236
237fn validate_labeled_probabilities(
238    probabilities: &[f64],
239    labels: &[u8],
240    split: &str,
241) -> ScoringResult<()> {
242    if probabilities.len() != labels.len() {
243        return Err(invalid_input(format!(
244            "{split} probabilities length {} does not match labels length {}",
245            probabilities.len(),
246            labels.len()
247        )));
248    }
249    for (index, probability) in probabilities.iter().copied().enumerate() {
250        require_probability(probability, &format!("{split}_probabilities[{index}]"))?;
251    }
252    for (index, label) in labels.iter().copied().enumerate() {
253        if label > 1 {
254            return Err(invalid_input(format!(
255                "{split}_labels[{index}] must be 0 or 1, got {label}"
256            )));
257        }
258    }
259    Ok(())
260}
261
262fn decision_metrics(probabilities: &[f64], labels: &[u8], threshold: f64) -> BinaryDecisionMetrics {
263    let mut true_positive = 0_usize;
264    let mut predicted_positive = 0_usize;
265    let mut actual_positive = 0_usize;
266    for (&probability, &label) in probabilities.iter().zip(labels) {
267        let predicted = probability >= threshold;
268        if predicted {
269            predicted_positive += 1;
270        }
271        if label == 1 {
272            actual_positive += 1;
273            if predicted {
274                true_positive += 1;
275            }
276        }
277    }
278    let precision = ratio(true_positive, predicted_positive);
279    let recall = ratio(true_positive, actual_positive);
280    let f1 = if precision + recall > 0.0 {
281        2.0 * precision * recall / (precision + recall)
282    } else {
283        0.0
284    };
285    BinaryDecisionMetrics {
286        precision,
287        recall,
288        f1,
289        predicted_positive,
290        actual_positive,
291    }
292}
293
294fn ratio(numerator: usize, denominator: usize) -> f64 {
295    if denominator == 0 {
296        0.0
297    } else {
298        numerator as f64 / denominator as f64
299    }
300}
301
302fn percentile_interval(
303    samples: &mut [f64],
304    point: f64,
305    confidence_level: f64,
306) -> ConfidenceInterval {
307    samples.sort_by(f64::total_cmp);
308    let tail = (1.0 - confidence_level) / 2.0;
309    let last = samples.len() - 1;
310    let lower_index = (tail * last as f64).floor() as usize;
311    let upper_index = ((1.0 - tail) * last as f64).ceil() as usize;
312    ConfidenceInterval {
313        lower: samples[lower_index.min(last)].min(point),
314        upper: samples[upper_index.min(last)].max(point),
315        confidence_level,
316    }
317}
318
319struct SplitMix64 {
320    state: u64,
321}
322
323impl SplitMix64 {
324    fn new(seed: u64) -> Self {
325        Self { state: seed }
326    }
327
328    fn next(&mut self) -> u64 {
329        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
330        let mut value = self.state;
331        value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
332        value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
333        value ^ (value >> 31)
334    }
335
336    fn index(&mut self, upper: usize) -> ScoringResult<usize> {
337        let upper = u64::try_from(upper)
338            .map_err(|_| invalid_input("bootstrap sample count exceeds u64 range"))?;
339        usize::try_from(self.next() % upper)
340            .map_err(|_| invalid_input("bootstrap index exceeds usize range"))
341    }
342}