1use 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 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#[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}