Skip to main content

kestrel_chartkit/evaluation/
probability.rs

1//! Empirical and isotonic probability calibration, out-of-sample metrics, and validation manifests.
2//!
3//! Maps raw heuristic indicator/strategy scores to monotone calibrated probabilities
4//! fitted exclusively on training data and frozen for evaluation on unseen test datasets.
5
6use std::fmt;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11/// A calibrated probability output distinguishing raw confidence from empirical likelihood.
12#[derive(Debug, Clone, PartialEq)]
13#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
14pub struct CalibratedProbability {
15    /// Raw uncalibrated score or confidence (e.g. 0.0..100.0 or 0.0..1.0).
16    pub raw_score: f64,
17    /// Calibrated empirical win probability in `0.0..=1.0`.
18    pub probability: f64,
19    /// Identifier / version of the calibration model used.
20    pub model_version: String,
21    /// Training sample size underlying the calibration curve.
22    pub train_sample_size: usize,
23}
24
25/// A non-parametric monotonic probability calibrator using the Pool Adjacent Violators Algorithm (PAVA).
26#[derive(Debug, Clone, PartialEq)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28pub struct IsotonicCalibrator {
29    /// Score cutoffs sorted ascending.
30    pub thresholds: Vec<f64>,
31    /// Corresponding calibrated probabilities in `0.0..=1.0` (guaranteed non-decreasing).
32    pub probabilities: Vec<f64>,
33    pub train_sample_size: usize,
34}
35
36impl IsotonicCalibrator {
37    /// Fits an isotonic calibrator from a slice of `(raw_score, actual_outcome)` pairs.
38    ///
39    /// Out-of-sample discipline: Call this method ONLY on training data!
40    pub fn fit(samples: &[(f64, bool)]) -> Option<Self> {
41        if samples.is_empty() {
42            return None;
43        }
44
45        // 1. Sort samples ascending by score
46        let mut sorted = samples.to_vec();
47        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
48
49        // 2. Initialize PAVA blocks: (score, weight, value)
50        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        // 3. Pool adjacent violators
61        let mut i = 0;
62        while i < values.len() {
63            if i > 0 && values[i] < values[i - 1] {
64                // Violation: merge block i into block i-1
65                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]; // upper boundary of merged block
76
77                weights.remove(i);
78                values.remove(i);
79                scores.remove(i);
80
81                i -= 1; // Step back to check previous pair
82            } 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    /// Evaluates a raw score against the frozen calibration curve.
95    ///
96    /// Returns a monotone calibrated probability in `0.0..=1.0`.
97    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        // Binary search for bracket
110        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                // Linear interpolation between idx - 1 and idx
117                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/// Out-of-sample calibration performance metrics and baseline comparisons.
133#[derive(Debug, Clone, Copy, PartialEq)]
134#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
135pub struct CalibrationMetrics {
136    /// Mean squared error between calibrated probability and realized outcome: `sum((p - y)^2) / N`.
137    pub brier_score: f64,
138    /// Brier score of a trivial constant baseline prediction (e.g. historical training winrate).
139    pub baseline_brier_score: f64,
140    /// Brier Skill Score: `1.0 - (brier_score / baseline_brier_score)`.
141    /// Positive values indicate genuine skill exceeding the trivial baseline.
142    pub brier_skill_score: f64,
143    /// Average logarithmic loss (binary cross entropy).
144    pub log_loss: f64,
145    /// Expected Calibration Error across equal-width probability bins.
146    pub expected_calibration_error: f64,
147    /// Number of evaluated out-of-sample observations.
148    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
165/// Computes comprehensive calibration metrics comparing predicted probabilities against actual outcomes.
166///
167/// - `predicted`: slice of predicted probabilities in `0.0..=1.0`.
168/// - `actual`: slice of realized binary outcomes.
169/// - `baseline_rate`: base rate probability of the training population (e.g. 0.50).
170/// - `num_bins`: number of bins for Expected Calibration Error (typically 10).
171pub 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    // Expected Calibration Error (ECE) across `num_bins`
209    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
242/// Circular block bootstrap estimating confidence intervals for Brier Score under time dependence.
243///
244/// Returns `(mean, percentile_05, percentile_95)`.
245pub 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 next pseudo-random integer
266            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/// Verifiable experiment manifest documenting the full training and out-of-sample evaluation context.
291#[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}