use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct PlattCalibrator {
pub parameter_a: f64,
pub parameter_b: f64,
pub fit_metadata: PlattFitMetadata,
pub is_fitted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlattFitMetadata {
pub total_samples: usize,
pub positive_samples: usize,
pub negative_samples: usize,
pub training_ece: f64,
pub converged: bool,
pub iterations_used: u32,
pub final_log_likelihood: f64,
pub fit_timestamp: std::time::SystemTime,
}
#[derive(Debug, Error)]
pub enum PlattError {
#[error("Insufficient data for calibration: need at least {required} samples, got {actual}")]
InsufficientData { required: usize, actual: usize },
#[error("Invalid probability values: {message}")]
InvalidProbabilities { message: String },
#[error("Calibration fitting failed: {message}")]
FittingFailed { message: String },
#[error("Prediction failed: {message}")]
PredictionFailed { message: String },
#[error("Optimization did not converge after {iterations} iterations")]
ConvergenceError { iterations: u32 },
}
impl Default for PlattCalibrator {
fn default() -> Self {
Self::new()
}
}
impl PlattCalibrator {
pub fn new() -> Self {
Self {
parameter_a: 0.0,
parameter_b: 0.0,
fit_metadata: PlattFitMetadata {
total_samples: 0,
positive_samples: 0,
negative_samples: 0,
training_ece: 0.0,
converged: false,
iterations_used: 0,
final_log_likelihood: f64::NEG_INFINITY,
fit_timestamp: std::time::SystemTime::now(),
},
is_fitted: false,
}
}
pub fn fit(&mut self, scores: &[f64], true_labels: &[f64]) -> Result<(), PlattError> {
if scores.len() != true_labels.len() {
return Err(PlattError::InvalidProbabilities {
message: format!("Scores and labels length mismatch: {} vs {}", scores.len(), true_labels.len())
});
}
if scores.len() < 2 {
return Err(PlattError::InsufficientData {
required: 2,
actual: scores.len()
});
}
info!("Fitting Platt scaling on {} samples", scores.len());
let positive_count = true_labels.iter().filter(|&&label| label > 0.5).count();
let negative_count = scores.len() - positive_count;
if positive_count == 0 || negative_count == 0 {
return Err(PlattError::InvalidProbabilities {
message: "Need both positive and negative samples for Platt scaling".to_string()
});
}
let target_pos = (positive_count as f64 + 1.0) / (positive_count as f64 + 2.0);
let target_neg = 1.0 / (negative_count as f64 + 2.0);
let targets: Vec<f64> = true_labels.iter().map(|&label| {
if label > 0.5 { target_pos } else { target_neg }
}).collect();
let (param_a, param_b, converged, iterations, final_ll) =
self.fit_sigmoid_parameters(scores, &targets)?;
self.parameter_a = param_a;
self.parameter_b = param_b;
self.is_fitted = true;
let training_ece = self.calculate_ece(scores, true_labels);
self.fit_metadata = PlattFitMetadata {
total_samples: scores.len(),
positive_samples: positive_count,
negative_samples: negative_count,
training_ece,
converged,
iterations_used: iterations,
final_log_likelihood: final_ll,
fit_timestamp: std::time::SystemTime::now(),
};
info!("Platt scaling fitted: A={:.4}, B={:.4}, ECE={:.4}, converged={}",
param_a, param_b, training_ece, converged);
Ok(())
}
fn fit_sigmoid_parameters(&self, scores: &[f64], targets: &[f64]) -> Result<(f64, f64, bool, u32, f64), PlattError> {
const MAX_ITERATIONS: u32 = 100;
const TOLERANCE: f64 = 1e-12;
const MIN_STEP: f64 = 1e-10;
let mut a = 0.0;
let mut b = 0.0;
let n = scores.len() as f64;
let sum_targets: f64 = targets.iter().sum();
let mean_score: f64 = scores.iter().sum::<f64>() / n;
let mean_target = sum_targets / n;
b = -(mean_target.ln() - (1.0 - mean_target).ln());
for iteration in 0..MAX_ITERATIONS {
let mut gradient_a = 0.0;
let mut gradient_b = 0.0;
let mut hessian_aa = 0.0;
let mut hessian_ab = 0.0;
let mut hessian_bb = 0.0;
let mut log_likelihood = 0.0;
for i in 0..scores.len() {
let score = scores[i];
let target = targets[i];
let fval = a * score + b;
let p = sigmoid(fval);
let p_clamped = p.clamp(1e-15, 1.0 - 1e-15);
log_likelihood += target * p_clamped.ln() + (1.0 - target) * (1.0 - p_clamped).ln();
let d1 = target - p;
gradient_a += d1 * score;
gradient_b += d1;
let d2 = p * (1.0 - p);
hessian_aa += score * score * d2;
hessian_ab += score * d2;
hessian_bb += d2;
}
let grad_norm = (gradient_a * gradient_a + gradient_b * gradient_b).sqrt();
if grad_norm < TOLERANCE {
return Ok((a, b, true, iteration, log_likelihood));
}
let det = hessian_aa * hessian_bb - hessian_ab * hessian_ab;
if det.abs() < MIN_STEP {
let step_size = 0.01;
a += step_size * gradient_a;
b += step_size * gradient_b;
} else {
let step_a = (hessian_bb * gradient_a - hessian_ab * gradient_b) / det;
let step_b = (hessian_aa * gradient_b - hessian_ab * gradient_a) / det;
a += step_a;
b += step_b;
}
}
warn!("Platt scaling did not converge after {} iterations", MAX_ITERATIONS);
Ok((a, b, false, MAX_ITERATIONS, f64::NEG_INFINITY))
}
pub fn predict(&self, scores: &[f64]) -> Result<Vec<f64>, PlattError> {
if !self.is_fitted {
return Err(PlattError::PredictionFailed {
message: "Calibrator has not been fitted yet".to_string()
});
}
let mut predictions = Vec::with_capacity(scores.len());
for &score in scores {
let fval = self.parameter_a * score + self.parameter_b;
let probability = sigmoid(fval);
predictions.push(probability);
}
Ok(predictions)
}
fn calculate_ece(&self, scores: &[f64], true_labels: &[f64]) -> f64 {
if scores.is_empty() || !self.is_fitted {
return 0.0;
}
let predictions = match self.predict(scores) {
Ok(preds) => preds,
Err(_) => return f64::NAN,
};
const NUM_BINS: usize = 10;
let mut bins: Vec<Vec<(f64, f64)>> = vec![Vec::new(); NUM_BINS];
for i in 0..predictions.len() {
let bin_idx = ((predictions[i] * NUM_BINS as f64).floor() as usize).min(NUM_BINS - 1);
bins[bin_idx].push((predictions[i], true_labels[i]));
}
let mut ece = 0.0;
let total_samples = predictions.len() as f64;
for bin in &bins {
if bin.is_empty() {
continue;
}
let bin_size = bin.len() as f64;
let avg_confidence: f64 = bin.iter().map(|(conf, _)| conf).sum::<f64>() / bin_size;
let avg_accuracy: f64 = bin.iter().map(|(_, acc)| acc).sum::<f64>() / bin_size;
ece += (bin_size / total_samples) * (avg_confidence - avg_accuracy).abs();
}
ece
}
pub fn get_calibration_stats(&self) -> CalibrationStats {
CalibrationStats {
parameter_a: self.parameter_a,
parameter_b: self.parameter_b,
is_fitted: self.is_fitted,
converged: self.fit_metadata.converged,
training_ece: self.fit_metadata.training_ece,
total_training_samples: self.fit_metadata.total_samples,
positive_training_samples: self.fit_metadata.positive_samples,
negative_training_samples: self.fit_metadata.negative_samples,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalibrationStats {
pub parameter_a: f64,
pub parameter_b: f64,
pub is_fitted: bool,
pub converged: bool,
pub training_ece: f64,
pub total_training_samples: usize,
pub positive_training_samples: usize,
pub negative_training_samples: usize,
}
fn sigmoid(x: f64) -> f64 {
if x >= 0.0 {
let exp_neg_x = (-x).exp();
1.0 / (1.0 + exp_neg_x)
} else {
let exp_x = x.exp();
exp_x / (1.0 + exp_x)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_platt_calibrator_creation() {
let calibrator = PlattCalibrator::new();
assert!(!calibrator.is_fitted);
assert_eq!(calibrator.parameter_a, 0.0);
assert_eq!(calibrator.parameter_b, 0.0);
}
#[test]
fn test_sigmoid_function() {
assert!((sigmoid(0.0) - 0.5).abs() < 1e-10);
assert!(sigmoid(1000.0) > 0.99);
assert!(sigmoid(-1000.0) < 0.01);
}
#[test]
fn test_platt_calibrator_fit() {
let mut calibrator = PlattCalibrator::new();
let scores = vec![-2.0, -1.0, 0.0, 1.0, 2.0];
let labels = vec![0.0, 0.0, 0.0, 1.0, 1.0];
let result = calibrator.fit(&scores, &labels);
assert!(result.is_ok());
assert!(calibrator.is_fitted);
assert!(calibrator.parameter_a.is_finite());
assert!(calibrator.parameter_b.is_finite());
}
#[test]
fn test_platt_prediction() {
let mut calibrator = PlattCalibrator::new();
let scores = vec![-1.0, 0.0, 1.0];
let labels = vec![0.0, 0.5, 1.0];
calibrator.fit(&scores, &labels).unwrap();
let test_scores = vec![-0.5, 0.5];
let predictions = calibrator.predict(&test_scores);
assert!(predictions.is_ok());
let preds = predictions.unwrap();
assert_eq!(preds.len(), 2);
for &pred in &preds {
assert!(pred >= 0.0 && pred <= 1.0);
}
assert!(preds[1] > preds[0]);
}
#[test]
fn test_platt_insufficient_data() {
let mut calibrator = PlattCalibrator::new();
let scores = vec![0.0];
let labels = vec![1.0];
let result = calibrator.fit(&scores, &labels);
assert!(result.is_err());
match result {
Err(PlattError::InsufficientData { required: 2, actual: 1 }) => {},
_ => panic!("Expected InsufficientData error"),
}
}
#[test]
fn test_platt_no_positive_samples() {
let mut calibrator = PlattCalibrator::new();
let scores = vec![0.0, 1.0, 2.0];
let labels = vec![0.0, 0.0, 0.0];
let result = calibrator.fit(&scores, &labels);
assert!(result.is_err());
match result {
Err(PlattError::InvalidProbabilities { .. }) => {},
_ => panic!("Expected InvalidProbabilities error"),
}
}
}