use crate::calibration::shared_binning_core::{SharedBinningCore, SharedBinningConfig, BinningResult};
use rand::{Rng, SeedableRng};
use rand::rngs::StdRng;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Instant, Duration};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WilsonCI {
pub lower: f64,
pub upper: f64,
pub point_estimate: f64,
}
impl WilsonCI {
pub fn compute(p_hat: f64, n: usize, confidence: f64) -> Self {
let z = match confidence {
0.90 => 1.645,
0.95 => 1.96,
0.99 => 2.576,
_ => 1.96, };
let n_f = n as f64;
let z_squared = z * z;
let denominator = 1.0 + z_squared / n_f;
let center = (p_hat + z_squared / (2.0 * n_f)) / denominator;
let margin = z * (p_hat * (1.0 - p_hat) / n_f + z_squared / (4.0 * n_f * n_f)).sqrt() / denominator;
Self {
lower: (center - margin).max(0.0),
upper: (center + margin).min(1.0),
point_estimate: p_hat,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FastBootstrapConfig {
pub early_stop_samples: usize,
pub max_samples: usize,
pub target_coverage: f64,
pub wilson_confidence: f64,
pub early_stop_threshold: f64,
pub blb_bags: usize,
pub blb_subsample_size: Option<usize>,
pub random_seed: u64,
pub use_poisson_bootstrap: bool,
}
impl Default for FastBootstrapConfig {
fn default() -> Self {
Self {
early_stop_samples: 200,
max_samples: 1000,
target_coverage: 0.95,
wilson_confidence: 0.95,
early_stop_threshold: 0.925, blb_bags: 10,
blb_subsample_size: None, random_seed: 42,
use_poisson_bootstrap: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapResult {
pub coverage_probability: f64,
pub coverage_ci: WilsonCI,
pub samples_used: usize,
pub early_stopped: bool,
pub passing_samples: usize,
pub ece_threshold: f64,
pub timing: BootstrapTiming,
pub used_blb: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapTiming {
pub total_duration: Duration,
pub per_sample_us: f64,
pub binning_per_sample_us: f64,
pub ece_per_sample_us: f64,
}
pub struct FastBootstrap {
config: FastBootstrapConfig,
binning_core: SharedBinningCore,
rng: StdRng,
sample_buffer: Vec<usize>,
weight_buffer: Vec<f64>,
}
impl FastBootstrap {
pub fn new(bootstrap_config: FastBootstrapConfig, binning_config: SharedBinningConfig) -> Self {
let rng = StdRng::seed_from_u64(bootstrap_config.random_seed);
let binning_core = SharedBinningCore::new(binning_config);
Self {
config: bootstrap_config,
binning_core,
rng,
sample_buffer: Vec::new(),
weight_buffer: Vec::new(),
}
}
fn compute_ece_threshold(&self, n: usize, k_eff: usize, c_hat: f64) -> f64 {
let statistical_component = c_hat * (k_eff as f64 / n as f64).sqrt();
0.015f64.max(statistical_component)
}
fn poisson_resample(&mut self, n: usize) -> &[f64] {
self.weight_buffer.clear();
self.weight_buffer.reserve(n);
for _ in 0..n {
let weight = self.sample_poisson(1.0);
self.weight_buffer.push(weight);
}
&self.weight_buffer
}
fn sample_poisson(&mut self, lambda: f64) -> f64 {
let l = (-lambda).exp();
let mut k = 0.0f64;
let mut p = 1.0f64;
loop {
k += 1.0;
p *= self.rng.gen::<f64>();
if p <= l {
return (k - 1.0).max(0.0f64);
}
if k > 20.0 { return k - 1.0;
}
}
}
fn multinomial_resample(&mut self, n: usize) -> &[usize] {
self.sample_buffer.clear();
self.sample_buffer.reserve(n);
for _ in 0..n {
let idx = self.rng.gen_range(0..n);
self.sample_buffer.push(idx);
}
&self.sample_buffer
}
fn compute_ece(&self, binning_result: &BinningResult) -> f64 {
let mut ece = 0.0;
let total_weight: f64 = binning_result.bin_stats.iter()
.map(|stats| stats.weight)
.sum();
if total_weight <= 0.0 {
return 0.0;
}
for stats in &binning_result.bin_stats {
if stats.weight > 0.0 {
let bin_fraction = stats.weight / total_weight;
let calibration_error = (stats.accuracy - stats.confidence).abs();
ece += bin_fraction * calibration_error;
}
}
ece
}
fn bootstrap_iteration(&mut self, predictions: &[f64], labels: &[f64], weights: &[f64]) -> f64 {
let n = predictions.len();
if self.config.use_poisson_bootstrap {
let poisson_weights = self.poisson_resample(n);
let effective_weights: Vec<f64> = weights.iter()
.zip(poisson_weights.iter())
.map(|(w, pw)| w * pw)
.collect();
let binning_result = self.binning_core.bin_samples(predictions, labels, &effective_weights);
self.compute_ece(&binning_result)
} else {
let indices = self.multinomial_resample(n);
let boot_preds: Vec<f64> = indices.iter().map(|&i| predictions[i]).collect();
let boot_labels: Vec<f64> = indices.iter().map(|&i| labels[i]).collect();
let boot_weights: Vec<f64> = indices.iter().map(|&i| weights[i]).collect();
let binning_result = self.binning_core.bin_samples(&boot_preds, &boot_labels, &boot_weights);
self.compute_ece(&binning_result)
}
}
fn blb_bootstrap(&mut self, predictions: &[f64], labels: &[f64], weights: &[f64], ece_threshold: f64) -> BootstrapResult {
let n = predictions.len();
let subsample_size = self.config.blb_subsample_size
.unwrap_or_else(|| (n as f64).powf(0.6) as usize);
let start_time = Instant::now();
let mut total_passing = 0;
let mut total_samples = 0;
for bag_idx in 0..self.config.blb_bags {
let mut subsample_indices: Vec<usize> = (0..n).collect();
for i in 0..subsample_size {
let j = self.rng.gen_range(i..n);
subsample_indices.swap(i, j);
}
subsample_indices.truncate(subsample_size);
let sub_preds: Vec<f64> = subsample_indices.iter().map(|&i| predictions[i]).collect();
let sub_labels: Vec<f64> = subsample_indices.iter().map(|&i| labels[i]).collect();
let sub_weights: Vec<f64> = subsample_indices.iter().map(|&i| weights[i] * (n as f64 / subsample_size as f64)).collect();
let bag_samples = self.config.max_samples / self.config.blb_bags;
for _ in 0..bag_samples {
let ece = self.bootstrap_iteration(&sub_preds, &sub_labels, &sub_weights);
if ece <= ece_threshold {
total_passing += 1;
}
total_samples += 1;
}
}
let duration = start_time.elapsed();
let coverage_prob = total_passing as f64 / total_samples as f64;
let coverage_ci = WilsonCI::compute(coverage_prob, total_samples, self.config.wilson_confidence);
BootstrapResult {
coverage_probability: coverage_prob,
coverage_ci,
samples_used: total_samples,
early_stopped: false, passing_samples: total_passing,
ece_threshold,
timing: BootstrapTiming {
total_duration: duration,
per_sample_us: duration.as_micros() as f64 / total_samples as f64,
binning_per_sample_us: 0.0, ece_per_sample_us: 0.0,
},
used_blb: true,
}
}
pub fn run_bootstrap(&mut self, predictions: &[f64], labels: &[f64], weights: &[f64], k_eff: usize, c_hat: f64) -> BootstrapResult {
let n = predictions.len();
let ece_threshold = self.compute_ece_threshold(n, k_eff, c_hat);
if n > 50000 {
return self.blb_bootstrap(predictions, labels, weights, ece_threshold);
}
let start_time = Instant::now();
let mut passing_samples = 0;
let mut samples_used = 0;
let mut early_stopped = false;
for _ in 0..self.config.early_stop_samples {
let ece = self.bootstrap_iteration(predictions, labels, weights);
if ece <= ece_threshold {
passing_samples += 1;
}
samples_used += 1;
}
let early_coverage = passing_samples as f64 / samples_used as f64;
let early_ci = WilsonCI::compute(early_coverage, samples_used, self.config.wilson_confidence);
if early_ci.lower >= self.config.early_stop_threshold {
early_stopped = true;
} else {
for _ in samples_used..self.config.max_samples {
let ece = self.bootstrap_iteration(predictions, labels, weights);
if ece <= ece_threshold {
passing_samples += 1;
}
samples_used += 1;
}
}
let duration = start_time.elapsed();
let coverage_prob = passing_samples as f64 / samples_used as f64;
let coverage_ci = WilsonCI::compute(coverage_prob, samples_used, self.config.wilson_confidence);
BootstrapResult {
coverage_probability: coverage_prob,
coverage_ci,
samples_used,
early_stopped,
passing_samples,
ece_threshold,
timing: BootstrapTiming {
total_duration: duration,
per_sample_us: duration.as_micros() as f64 / samples_used as f64,
binning_per_sample_us: 0.0, ece_per_sample_us: 0.0,
},
used_blb: false,
}
}
pub fn validate_sla(&mut self, predictions: &[f64], labels: &[f64], weights: &[f64]) -> bool {
let mut timings = Vec::new();
for _ in 0..100 {
let start = Instant::now();
let _ece = self.bootstrap_iteration(predictions, labels, weights);
let duration = start.elapsed();
timings.push(duration.as_micros() as f64);
}
timings.sort_by(|a, b| a.partial_cmp(b).unwrap());
let p99 = timings[(timings.len() * 99 / 100).min(timings.len() - 1)];
p99 < 1000.0 }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calibration::shared_binning_core::SharedBinningConfig;
#[test]
fn test_wilson_ci() {
let ci = WilsonCI::compute(0.95, 100, 0.95);
assert!(ci.lower < ci.point_estimate);
assert!(ci.point_estimate < ci.upper);
assert!(ci.lower >= 0.0);
assert!(ci.upper <= 1.0);
assert_eq!(ci.point_estimate, 0.95);
}
#[test]
fn test_fast_bootstrap_early_stopping() {
let bootstrap_config = FastBootstrapConfig {
early_stop_samples: 50,
max_samples: 200,
early_stop_threshold: 0.90,
..Default::default()
};
let binning_config = SharedBinningConfig::default();
let mut bootstrap = FastBootstrap::new(bootstrap_config, binning_config);
let predictions = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
let labels = vec![0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
let weights = vec![1.0; 9];
let result = bootstrap.run_bootstrap(&predictions, &labels, &weights, 9, 1.5);
assert!(result.coverage_probability >= 0.0);
assert!(result.samples_used > 0);
assert!(result.samples_used <= 200);
println!("Bootstrap result: early_stopped={}, samples_used={}, coverage={:.3}",
result.early_stopped, result.samples_used, result.coverage_probability);
}
#[test]
fn test_poisson_vs_multinomial_bootstrap() {
let bootstrap_config = FastBootstrapConfig {
max_samples: 100,
use_poisson_bootstrap: true,
..Default::default()
};
let binning_config = SharedBinningConfig::default();
let mut bootstrap_poisson = FastBootstrap::new(bootstrap_config.clone(), binning_config.clone());
let mut bootstrap_multinomial = FastBootstrap::new(
FastBootstrapConfig { use_poisson_bootstrap: false, ..bootstrap_config },
binning_config
);
let predictions = vec![0.2, 0.4, 0.6, 0.8];
let labels = vec![0.0, 0.0, 1.0, 1.0];
let weights = vec![1.0; 4];
let result_poisson = bootstrap_poisson.run_bootstrap(&predictions, &labels, &weights, 4, 1.5);
let result_multinomial = bootstrap_multinomial.run_bootstrap(&predictions, &labels, &weights, 4, 1.5);
assert!(result_poisson.coverage_probability >= 0.0 && result_poisson.coverage_probability <= 1.0);
assert!(result_multinomial.coverage_probability >= 0.0 && result_multinomial.coverage_probability <= 1.0);
println!("Poisson coverage: {:.3}, Multinomial coverage: {:.3}",
result_poisson.coverage_probability, result_multinomial.coverage_probability);
}
}