use crate::estimate::EstimationError;
pub fn conformal_scale_multiplier(
residuals: &[f64],
scales: &[f64],
alpha: f64,
) -> Result<f64, EstimationError> {
if residuals.len() != scales.len() {
return Err(EstimationError::InvalidInput(format!(
"conformal calibration: {} residuals but {} scales",
residuals.len(),
scales.len()
)));
}
let n = residuals.len();
if n == 0 {
return Err(EstimationError::InvalidInput(
"conformal calibration requires at least one calibration point".to_string(),
));
}
if !(alpha > 0.0 && alpha < 1.0) {
return Err(EstimationError::InvalidInput(format!(
"conformal calibration: alpha must lie in (0, 1); got {alpha}"
)));
}
let mut scores = Vec::with_capacity(n);
for (i, (&r, &s)) in residuals.iter().zip(scales.iter()).enumerate() {
if !r.is_finite() {
return Err(EstimationError::InvalidInput(format!(
"conformal calibration: residual[{i}] is not finite ({r})"
)));
}
if !(s.is_finite() && s > 0.0) {
return Err(EstimationError::InvalidInput(format!(
"conformal calibration: scale[{i}] must be finite and positive; got {s}"
)));
}
scores.push(r.abs() / s);
}
let rank = (((n + 1) as f64) * (1.0 - alpha)).ceil() as usize;
if rank > n {
return Ok(f64::INFINITY);
}
scores.sort_by(f64::total_cmp);
Ok(scores[rank - 1])
}
pub fn conformal_interval(mu: f64, scale: f64, multiplier: f64) -> (f64, f64) {
let half = multiplier * scale;
(mu - half, mu + half)
}
pub fn conformal_calibrate_intervals(
cal_residuals: &[f64],
cal_scales: &[f64],
test_mu: &[f64],
test_scales: &[f64],
alpha: f64,
) -> Result<Vec<(f64, f64)>, EstimationError> {
if test_mu.len() != test_scales.len() {
return Err(EstimationError::InvalidInput(format!(
"conformal calibration: {} test means but {} test scales",
test_mu.len(),
test_scales.len()
)));
}
let multiplier = conformal_scale_multiplier(cal_residuals, cal_scales, alpha)?;
Ok(test_mu
.iter()
.zip(test_scales.iter())
.map(|(&mu, &s)| conformal_interval(mu, s, multiplier))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multiplier_is_the_exact_conformal_order_statistic() {
let residuals: Vec<f64> = (1..=19).map(|v| v as f64).collect();
let scales = vec![1.0_f64; 19];
let q = conformal_scale_multiplier(&residuals, &scales, 0.10).unwrap();
assert_eq!(q, 18.0);
let q2 = conformal_scale_multiplier(&residuals, &scales, 0.20).unwrap();
assert_eq!(q2, 16.0);
}
#[test]
fn heteroscedastic_scales_rescale_the_multiplier() {
let residuals: Vec<f64> = (1..=9).map(|v| v as f64).collect();
let unit = vec![1.0_f64; 9];
let base = conformal_scale_multiplier(&residuals, &unit, 0.2).unwrap();
let doubled = vec![2.0_f64; 9];
let halved = conformal_scale_multiplier(&residuals, &doubled, 0.2).unwrap();
assert!(
(halved - base / 2.0).abs() < 1e-12,
"{halved} vs {}",
base / 2.0
);
let big: Vec<f64> = residuals.iter().map(|r| 2.0 * r).collect();
let doubled_q = conformal_scale_multiplier(&big, &unit, 0.2).unwrap();
assert!((doubled_q - 2.0 * base).abs() < 1e-12);
}
#[test]
fn insufficient_calibration_data_returns_infinity() {
let residuals = vec![0.3, 1.1, 0.7, 2.0];
let scales = vec![1.0; 4];
let q = conformal_scale_multiplier(&residuals, &scales, 0.1).unwrap();
assert!(q.is_infinite());
}
#[test]
fn finite_sample_coverage_guarantee_holds_combinatorially() {
let n = 99usize;
let residuals: Vec<f64> = (1..=n).map(|v| v as f64).collect();
let scales = vec![1.0_f64; n];
for &alpha in &[0.05_f64, 0.1, 0.2, 0.5] {
let q = conformal_scale_multiplier(&residuals, &scales, alpha).unwrap();
let covered_cal = residuals.iter().filter(|&&r| r <= q).count();
let coverage = (covered_cal as f64 + 1.0) / (n as f64 + 1.0);
assert!(
coverage >= 1.0 - alpha - 1e-12,
"alpha={alpha}: coverage {coverage} < {}",
1.0 - alpha
);
}
}
#[test]
fn interval_is_symmetric_about_the_mean() {
let (lo, hi) = conformal_interval(3.0, 2.0, 1.5);
assert_eq!(lo, 3.0 - 3.0);
assert_eq!(hi, 3.0 + 3.0);
}
#[test]
fn calibrate_intervals_applies_multiplier_per_test_point() {
let cal_res: Vec<f64> = (1..=9).map(|v| v as f64).collect();
let cal_scales = vec![1.0_f64; 9];
let q = conformal_scale_multiplier(&cal_res, &cal_scales, 0.2).unwrap();
let test_mu = vec![0.0, 10.0];
let test_scales = vec![1.0, 3.0];
let intervals =
conformal_calibrate_intervals(&cal_res, &cal_scales, &test_mu, &test_scales, 0.2)
.unwrap();
assert_eq!(intervals.len(), 2);
assert!((intervals[0].1 - intervals[0].0 - 2.0 * q).abs() < 1e-12);
assert!((intervals[1].1 - intervals[1].0 - 2.0 * q * 3.0).abs() < 1e-12);
}
#[test]
fn mismatched_lengths_and_bad_alpha_are_rejected() {
assert!(conformal_scale_multiplier(&[1.0, 2.0], &[1.0], 0.1).is_err());
assert!(conformal_scale_multiplier(&[1.0], &[1.0], 0.0).is_err());
assert!(conformal_scale_multiplier(&[1.0], &[1.0], 1.0).is_err());
assert!(conformal_scale_multiplier(&[1.0], &[-1.0], 0.1).is_err());
assert!(conformal_scale_multiplier(&[], &[], 0.1).is_err());
}
}