kestrel_chartkit/evaluation/
probability.rs1use std::fmt;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq)]
13#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14pub struct CalibratedProbability {
15 pub raw_score: f64,
17 pub probability: f64,
19 pub model_version: String,
21 pub train_sample_size: usize,
23}
24
25#[derive(Debug, Clone, PartialEq)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28pub struct IsotonicCalibrator {
29 pub thresholds: Vec<f64>,
31 pub probabilities: Vec<f64>,
33 pub train_sample_size: usize,
34}
35
36impl IsotonicCalibrator {
37 pub fn fit(samples: &[(f64, bool)]) -> Option<Self> {
41 if samples.is_empty() {
42 return None;
43 }
44
45 let mut sorted = samples.to_vec();
47 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
48
49 let mut scores: Vec<f64> = Vec::with_capacity(sorted.len());
51 let mut weights: Vec<f64> = Vec::with_capacity(sorted.len());
52 let mut values: Vec<f64> = Vec::with_capacity(sorted.len());
53
54 for (score, outcome) in sorted {
55 scores.push(score);
56 weights.push(1.0);
57 values.push(if outcome { 1.0 } else { 0.0 });
58 }
59
60 let mut i = 0;
62 while i < values.len() {
63 if i > 0 && values[i] < values[i - 1] {
64 let w1 = weights[i - 1];
66 let w2 = weights[i];
67 let v1 = values[i - 1];
68 let v2 = values[i];
69
70 let w_new = w1 + w2;
71 let v_new = (w1 * v1 + w2 * v2) / w_new;
72
73 weights[i - 1] = w_new;
74 values[i - 1] = v_new;
75 scores[i - 1] = scores[i]; weights.remove(i);
78 values.remove(i);
79 scores.remove(i);
80
81 i -= 1; } else {
83 i += 1;
84 }
85 }
86
87 Some(Self {
88 thresholds: scores,
89 probabilities: values,
90 train_sample_size: samples.len(),
91 })
92 }
93
94 pub fn predict(&self, raw_score: f64) -> f64 {
98 if self.thresholds.is_empty() {
99 return 0.5;
100 }
101 if raw_score <= self.thresholds[0] {
102 return self.probabilities[0].clamp(0.0, 1.0);
103 }
104 let last_idx = self.thresholds.len() - 1;
105 if raw_score >= self.thresholds[last_idx] {
106 return self.probabilities[last_idx].clamp(0.0, 1.0);
107 }
108
109 match self
111 .thresholds
112 .binary_search_by(|t| t.total_cmp(&raw_score))
113 {
114 Ok(idx) => self.probabilities[idx].clamp(0.0, 1.0),
115 Err(idx) => {
116 let x0 = self.thresholds[idx - 1];
118 let x1 = self.thresholds[idx];
119 let y0 = self.probabilities[idx - 1];
120 let y1 = self.probabilities[idx];
121 let frac = if (x1 - x0).abs() > 1e-12 {
122 (raw_score - x0) / (x1 - x0)
123 } else {
124 0.0
125 };
126 (y0 + frac * (y1 - y0)).clamp(0.0, 1.0)
127 }
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq)]
134#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
135pub struct CalibrationMetrics {
136 pub brier_score: f64,
138 pub baseline_brier_score: f64,
140 pub brier_skill_score: f64,
143 pub log_loss: f64,
145 pub expected_calibration_error: f64,
147 pub sample_size: usize,
149}
150
151impl fmt::Display for CalibrationMetrics {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 write!(
154 f,
155 "Brier: {:.4} (BSS: {:.2}%), LogLoss: {:.4}, ECE: {:.4} (N={})",
156 self.brier_score,
157 self.brier_skill_score * 100.0,
158 self.log_loss,
159 self.expected_calibration_error,
160 self.sample_size
161 )
162 }
163}
164
165pub fn compute_calibration_metrics(
172 predicted: &[f64],
173 actual: &[bool],
174 baseline_rate: f64,
175 num_bins: usize,
176) -> Option<CalibrationMetrics> {
177 if predicted.len() != actual.len() || predicted.is_empty() {
178 return None;
179 }
180
181 let n = predicted.len() as f64;
182 let baseline = baseline_rate.clamp(1e-6, 1.0 - 1e-6);
183
184 let mut brier_sum = 0.0;
185 let mut baseline_brier_sum = 0.0;
186 let mut log_loss_sum = 0.0;
187
188 for (&p_raw, &y) in predicted.iter().zip(actual.iter()) {
189 let p = p_raw.clamp(1e-15, 1.0 - 1e-15);
190 let target = if y { 1.0 } else { 0.0 };
191
192 brier_sum += (p - target).powi(2);
193 baseline_brier_sum += (baseline - target).powi(2);
194
195 let log_p = if y { p.ln() } else { (1.0 - p).ln() };
196 log_loss_sum -= log_p;
197 }
198
199 let brier_score = brier_sum / n;
200 let baseline_brier_score = baseline_brier_sum / n;
201 let brier_skill_score = if baseline_brier_score > 1e-12 {
202 1.0 - (brier_score / baseline_brier_score)
203 } else {
204 0.0
205 };
206 let log_loss = log_loss_sum / n;
207
208 let bins = num_bins.max(1);
210 let mut bin_counts = vec![0usize; bins];
211 let mut bin_pred_sums = vec![0.0f64; bins];
212 let mut bin_actual_sums = vec![0.0f64; bins];
213
214 for (&p_raw, &y) in predicted.iter().zip(actual.iter()) {
215 let p = p_raw.clamp(0.0, 1.0);
216 let bin_idx = ((p * bins as f64).floor() as usize).min(bins - 1);
217 bin_counts[bin_idx] += 1;
218 bin_pred_sums[bin_idx] += p;
219 bin_actual_sums[bin_idx] += if y { 1.0 } else { 0.0 };
220 }
221
222 let mut ece = 0.0;
223 for i in 0..bins {
224 if bin_counts[i] > 0 {
225 let count = bin_counts[i] as f64;
226 let avg_pred = bin_pred_sums[i] / count;
227 let avg_actual = bin_actual_sums[i] / count;
228 ece += (count / n) * (avg_pred - avg_actual).abs();
229 }
230 }
231
232 Some(CalibrationMetrics {
233 brier_score,
234 baseline_brier_score,
235 brier_skill_score,
236 log_loss,
237 expected_calibration_error: ece,
238 sample_size: predicted.len(),
239 })
240}
241
242pub fn block_bootstrap_brier(
246 predicted: &[f64],
247 actual: &[bool],
248 block_size: usize,
249 num_bootstraps: usize,
250 seed: u64,
251) -> Option<(f64, f64, f64)> {
252 let n = predicted.len();
253 if n == 0 || block_size == 0 || num_bootstraps == 0 {
254 return None;
255 }
256
257 let mut lcg = seed.wrapping_add(1);
258 let mut bootstrap_scores = Vec::with_capacity(num_bootstraps);
259
260 for _ in 0..num_bootstraps {
261 let mut sample_brier_sum = 0.0;
262 let mut count = 0;
263
264 while count < n {
265 lcg = lcg.wrapping_mul(6364136223846793005).wrapping_add(1);
267 let start = (lcg as usize) % n;
268
269 let take = block_size.min(n - count);
270 for k in 0..take {
271 let idx = (start + k) % n;
272 let p = predicted[idx].clamp(0.0, 1.0);
273 let y = if actual[idx] { 1.0 } else { 0.0 };
274 sample_brier_sum += (p - y).powi(2);
275 }
276 count += take;
277 }
278
279 bootstrap_scores.push(sample_brier_sum / count as f64);
280 }
281
282 bootstrap_scores.sort_by(|a, b| a.total_cmp(b));
283 let mean = bootstrap_scores.iter().sum::<f64>() / num_bootstraps as f64;
284 let p05_idx = ((0.05 * num_bootstraps as f64).floor() as usize).min(num_bootstraps - 1);
285 let p95_idx = ((0.95 * num_bootstraps as f64).floor() as usize).min(num_bootstraps - 1);
286
287 Some((mean, bootstrap_scores[p05_idx], bootstrap_scores[p95_idx]))
288}
289
290#[derive(Debug, Clone, PartialEq)]
292#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
293pub struct ValidationExperimentManifest {
294 pub experiment_id: String,
295 pub model_version: String,
296 pub train_range: (usize, usize),
297 pub test_range: (usize, usize),
298 pub embargo_bars: usize,
299 pub train_sample_size: usize,
300 pub test_sample_size: usize,
301 pub metrics: CalibrationMetrics,
302}