use crate::error::{ForecastError, Result};
use crate::postprocess::{PointForecasts, PredictionIntervals};
#[derive(Debug, Clone, PartialEq)]
pub enum ConformalMethod {
Split {
cal_fraction: f64,
},
CrossVal {
n_folds: usize,
},
JackknifePlus,
}
impl Default for ConformalMethod {
fn default() -> Self {
Self::Split { cal_fraction: 0.2 }
}
}
#[derive(Debug, Clone)]
pub struct ConformalResult {
scores: Vec<f64>,
quantile_value: f64,
coverage: f64,
method: ConformalMethod,
}
impl ConformalResult {
pub fn scores(&self) -> &[f64] {
&self.scores
}
pub fn quantile_value(&self) -> f64 {
self.quantile_value
}
pub fn coverage(&self) -> f64 {
self.coverage
}
pub fn method(&self) -> &ConformalMethod {
&self.method
}
}
#[derive(Debug, Clone)]
pub struct PerStepConformalResult {
half_widths: Vec<f64>,
scores: Vec<Vec<f64>>,
coverage: f64,
method: ConformalMethod,
}
impl PerStepConformalResult {
pub fn half_widths(&self) -> &[f64] {
&self.half_widths
}
pub fn scores(&self) -> &[Vec<f64>] {
&self.scores
}
pub fn coverage(&self) -> f64 {
self.coverage
}
pub fn method(&self) -> &ConformalMethod {
&self.method
}
pub fn horizon(&self) -> usize {
self.half_widths.len()
}
pub fn predict(&self, point_forecast: &[f64]) -> (Vec<f64>, Vec<f64>) {
let lower: Vec<f64> = point_forecast
.iter()
.zip(self.half_widths.iter())
.map(|(&p, &hw)| p - hw)
.collect();
let upper: Vec<f64> = point_forecast
.iter()
.zip(self.half_widths.iter())
.map(|(&p, &hw)| p + hw)
.collect();
(lower, upper)
}
pub fn predict_intervals(&self, point_forecast: &[f64]) -> PredictionIntervals {
let (lower, upper) = self.predict(point_forecast);
PredictionIntervals::from_bounds(lower, upper, self.coverage)
.expect("Valid prediction intervals")
}
}
#[derive(Debug, Clone)]
pub struct ConformalPredictor {
coverage: f64,
method: ConformalMethod,
}
impl ConformalPredictor {
pub fn new(coverage: f64, method: ConformalMethod) -> Self {
assert!(
coverage > 0.0 && coverage < 1.0,
"coverage must be in (0, 1)"
);
Self { coverage, method }
}
pub fn split(coverage: f64) -> Self {
Self::new(coverage, ConformalMethod::Split { cal_fraction: 0.2 })
}
pub fn cross_val(coverage: f64, n_folds: usize) -> Self {
Self::new(coverage, ConformalMethod::CrossVal { n_folds })
}
pub fn jackknife_plus(coverage: f64) -> Self {
Self::new(coverage, ConformalMethod::JackknifePlus)
}
pub fn coverage(&self) -> f64 {
self.coverage
}
pub fn method(&self) -> &ConformalMethod {
&self.method
}
pub fn fit(&self, forecasts: &[f64], actuals: &[f64]) -> Result<ConformalResult> {
if forecasts.len() != actuals.len() {
return Err(ForecastError::DimensionMismatch {
expected: forecasts.len(),
got: actuals.len(),
});
}
let n = forecasts.len();
if n == 0 {
return Err(ForecastError::EmptyData);
}
match &self.method {
ConformalMethod::Split { cal_fraction } => {
self.fit_split(forecasts, actuals, *cal_fraction)
}
ConformalMethod::CrossVal { n_folds } => {
self.fit_cross_val(forecasts, actuals, *n_folds)
}
ConformalMethod::JackknifePlus => self.fit_jackknife_plus(forecasts, actuals),
}
}
fn fit_split(
&self,
forecasts: &[f64],
actuals: &[f64],
cal_fraction: f64,
) -> Result<ConformalResult> {
let n = forecasts.len();
let cal_size = ((n as f64) * cal_fraction).ceil() as usize;
if cal_size < 1 {
return Err(ForecastError::InsufficientData {
needed: 1,
got: cal_size,
hint: None,
});
}
let cal_start = n - cal_size;
let mut scores: Vec<f64> = forecasts[cal_start..]
.iter()
.zip(actuals[cal_start..].iter())
.map(|(f, a)| (f - a).abs())
.collect();
scores.sort_by(|a, b| a.partial_cmp(b).unwrap());
let adjusted_level = ((cal_size as f64 + 1.0) * self.coverage / cal_size as f64).min(1.0);
let quantile_idx = ((cal_size as f64) * adjusted_level).ceil() as usize;
let quantile_idx = quantile_idx.saturating_sub(1).min(scores.len() - 1);
let quantile_value = scores[quantile_idx];
Ok(ConformalResult {
scores,
quantile_value,
coverage: self.coverage,
method: self.method.clone(),
})
}
fn fit_cross_val(
&self,
forecasts: &[f64],
actuals: &[f64],
n_folds: usize,
) -> Result<ConformalResult> {
let n = forecasts.len();
if n_folds < 2 {
return Err(ForecastError::InvalidParameter(
"n_folds must be at least 2".to_string(),
));
}
if n < n_folds {
return Err(ForecastError::InsufficientData {
needed: n_folds,
got: n,
hint: None,
});
}
let mut scores: Vec<f64> = forecasts
.iter()
.zip(actuals.iter())
.map(|(f, a)| (f - a).abs())
.collect();
scores.sort_by(|a, b| a.partial_cmp(b).unwrap());
let quantile_idx = ((n as f64) * self.coverage).ceil() as usize;
let quantile_idx = quantile_idx.saturating_sub(1).min(scores.len() - 1);
let quantile_value = scores[quantile_idx];
Ok(ConformalResult {
scores,
quantile_value,
coverage: self.coverage,
method: self.method.clone(),
})
}
fn fit_jackknife_plus(&self, forecasts: &[f64], actuals: &[f64]) -> Result<ConformalResult> {
let n = forecasts.len();
if n < 2 {
return Err(ForecastError::InsufficientData {
needed: 2,
got: n,
hint: None,
});
}
let mut scores: Vec<f64> = forecasts
.iter()
.zip(actuals.iter())
.map(|(f, a)| (f - a).abs())
.collect();
scores.sort_by(|a, b| a.partial_cmp(b).unwrap());
let adjusted_level = (((n + 1) as f64) * self.coverage / n as f64).min(1.0);
let quantile_idx = ((n as f64) * adjusted_level).ceil() as usize;
let quantile_idx = quantile_idx.saturating_sub(1).min(scores.len() - 1);
let quantile_value = scores[quantile_idx];
Ok(ConformalResult {
scores,
quantile_value,
coverage: self.coverage,
method: self.method.clone(),
})
}
pub fn fit_per_step(
&self,
fold_forecasts: &[Vec<f64>],
fold_actuals: &[Vec<f64>],
) -> Result<PerStepConformalResult> {
let n_folds = fold_forecasts.len();
if n_folds != fold_actuals.len() {
return Err(ForecastError::DimensionMismatch {
expected: n_folds,
got: fold_actuals.len(),
});
}
if n_folds < 2 {
return Err(ForecastError::InsufficientData {
needed: 2,
got: n_folds,
hint: Some("per-step conformal needs at least 2 folds".into()),
});
}
let horizon = fold_forecasts[0].len();
if horizon == 0 {
return Err(ForecastError::EmptyData);
}
for (i, (fc, ac)) in fold_forecasts.iter().zip(fold_actuals.iter()).enumerate() {
if fc.len() != horizon || ac.len() != horizon {
return Err(ForecastError::InvalidParameter(format!(
"fold {} has forecast len {} and actual len {}, expected {}",
i,
fc.len(),
ac.len(),
horizon
)));
}
}
let all_scores: Vec<f64> = fold_forecasts
.iter()
.zip(fold_actuals.iter())
.flat_map(|(fc, ac)| fc.iter().zip(ac.iter()).map(|(f, a)| (f - a).abs()))
.collect();
let pooled_quantile = Self::compute_quantile(&all_scores, self.coverage, &self.method);
let mut half_widths = Vec::with_capacity(horizon);
let mut per_step_scores = Vec::with_capacity(horizon);
for t in 0..horizon {
let step_scores: Vec<f64> = fold_forecasts
.iter()
.zip(fold_actuals.iter())
.map(|(fc, ac)| (fc[t] - ac[t]).abs())
.collect();
let hw = if step_scores.len() < 2 {
pooled_quantile
} else {
Self::compute_quantile(&step_scores, self.coverage, &self.method)
};
half_widths.push(hw);
per_step_scores.push(step_scores);
}
Ok(PerStepConformalResult {
half_widths,
scores: per_step_scores,
coverage: self.coverage,
method: self.method.clone(),
})
}
fn compute_quantile(scores: &[f64], coverage: f64, method: &ConformalMethod) -> f64 {
let mut sorted = scores.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = sorted.len();
let quantile_idx = match method {
ConformalMethod::Split { .. } | ConformalMethod::JackknifePlus => {
let adjusted_level = (((n + 1) as f64) * coverage / n as f64).min(1.0);
((n as f64) * adjusted_level).ceil() as usize
}
ConformalMethod::CrossVal { .. } => ((n as f64) * coverage).ceil() as usize,
};
let idx = quantile_idx.saturating_sub(1).min(n - 1);
sorted[idx]
}
pub fn predict_quantiles(
&self,
result: &ConformalResult,
point_forecast: &[f64],
quantile_levels: &[f64],
) -> crate::postprocess::QuantileForecasts {
let horizon = point_forecast.len();
let scores = result.scores();
let mut forecast_values = Vec::with_capacity(horizon);
for h in 0..horizon {
let mut row = Vec::with_capacity(quantile_levels.len());
for &q in quantile_levels {
let hw = if q < 0.5 {
let cov = 1.0 - 2.0 * q;
let hw = Self::compute_quantile(scores, cov, &result.method);
-hw
} else if q > 0.5 {
let cov = 2.0 * q - 1.0;
Self::compute_quantile(scores, cov, &result.method)
} else {
0.0
};
row.push(point_forecast[h] + hw);
}
forecast_values.push(row);
}
crate::postprocess::QuantileForecasts::from_values(
quantile_levels.to_vec(),
forecast_values,
)
.expect("Valid quantile forecasts")
}
pub fn predict(
&self,
result: &ConformalResult,
point_forecasts: &PointForecasts,
) -> PredictionIntervals {
let values = point_forecasts.values();
let q = result.quantile_value;
let lower: Vec<f64> = values.iter().map(|&v| v - q).collect();
let upper: Vec<f64> = values.iter().map(|&v| v + q).collect();
PredictionIntervals::new(
point_forecasts.timestamps().to_vec(),
lower,
upper,
self.coverage,
)
.expect("Valid prediction intervals")
}
pub fn predict_values(&self, result: &ConformalResult, values: &[f64]) -> PredictionIntervals {
let q = result.quantile_value;
let lower: Vec<f64> = values.iter().map(|&v| v - q).collect();
let upper: Vec<f64> = values.iter().map(|&v| v + q).collect();
PredictionIntervals::from_bounds(lower, upper, self.coverage)
.expect("Valid prediction intervals")
}
}
#[cfg(test)]
mod tests {
use super::*;
mod conformal_method {
use super::*;
#[test]
fn default_is_split_with_20_percent() {
let method = ConformalMethod::default();
match method {
ConformalMethod::Split { cal_fraction } => {
assert!((cal_fraction - 0.2).abs() < 1e-10);
}
_ => panic!("Expected Split method"),
}
}
#[test]
fn split_stores_cal_fraction() {
let method = ConformalMethod::Split { cal_fraction: 0.3 };
if let ConformalMethod::Split { cal_fraction } = method {
assert!((cal_fraction - 0.3).abs() < 1e-10);
} else {
panic!("Expected Split method");
}
}
#[test]
fn cross_val_stores_n_folds() {
let method = ConformalMethod::CrossVal { n_folds: 5 };
if let ConformalMethod::CrossVal { n_folds } = method {
assert_eq!(n_folds, 5);
} else {
panic!("Expected CrossVal method");
}
}
#[test]
fn jackknife_plus_variant_exists() {
let method = ConformalMethod::JackknifePlus;
assert_eq!(method, ConformalMethod::JackknifePlus);
}
#[test]
fn methods_are_clonable() {
let method = ConformalMethod::Split { cal_fraction: 0.25 };
let cloned = method.clone();
assert_eq!(method, cloned);
}
}
mod construction {
use super::*;
#[test]
fn new_creates_predictor() {
let predictor =
ConformalPredictor::new(0.90, ConformalMethod::Split { cal_fraction: 0.2 });
assert!((predictor.coverage() - 0.90).abs() < 1e-10);
}
#[test]
fn split_creates_split_predictor() {
let predictor = ConformalPredictor::split(0.95);
assert!((predictor.coverage() - 0.95).abs() < 1e-10);
match predictor.method() {
ConformalMethod::Split { cal_fraction } => {
assert!((cal_fraction - 0.2).abs() < 1e-10);
}
_ => panic!("Expected Split method"),
}
}
#[test]
fn cross_val_creates_cv_predictor() {
let predictor = ConformalPredictor::cross_val(0.90, 5);
assert!((predictor.coverage() - 0.90).abs() < 1e-10);
match predictor.method() {
ConformalMethod::CrossVal { n_folds } => {
assert_eq!(*n_folds, 5);
}
_ => panic!("Expected CrossVal method"),
}
}
#[test]
fn jackknife_plus_creates_jackknife_predictor() {
let predictor = ConformalPredictor::jackknife_plus(0.90);
assert!((predictor.coverage() - 0.90).abs() < 1e-10);
assert_eq!(predictor.method(), &ConformalMethod::JackknifePlus);
}
#[test]
#[should_panic(expected = "coverage must be in (0, 1)")]
fn new_panics_on_zero_coverage() {
ConformalPredictor::new(0.0, ConformalMethod::default());
}
#[test]
#[should_panic(expected = "coverage must be in (0, 1)")]
fn new_panics_on_one_coverage() {
ConformalPredictor::new(1.0, ConformalMethod::default());
}
#[test]
#[should_panic(expected = "coverage must be in (0, 1)")]
fn new_panics_on_negative_coverage() {
ConformalPredictor::new(-0.1, ConformalMethod::default());
}
#[test]
fn predictor_is_clonable() {
let predictor = ConformalPredictor::split(0.90);
let cloned = predictor.clone();
assert!((cloned.coverage() - 0.90).abs() < 1e-10);
}
}
mod fit_split {
use super::*;
#[test]
fn fit_returns_result() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
assert!((result.coverage() - 0.90).abs() < 1e-10);
assert!(!result.scores().is_empty());
}
#[test]
fn fit_fails_on_length_mismatch() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0];
let actuals = vec![10.5, 10.5];
let result = predictor.fit(&forecasts, &actuals);
assert!(result.is_err());
}
#[test]
fn fit_fails_on_empty_data() {
let predictor = ConformalPredictor::split(0.90);
let forecasts: Vec<f64> = vec![];
let actuals: Vec<f64> = vec![];
let result = predictor.fit(&forecasts, &actuals);
assert!(result.is_err());
}
#[test]
fn scores_are_sorted() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0];
let actuals = vec![10.5, 10.0, 12.5, 13.5, 14.0, 15.5, 16.0, 17.5, 18.0, 19.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
let scores = result.scores();
for i in 1..scores.len() {
assert!(scores[i] >= scores[i - 1], "Scores should be sorted");
}
}
#[test]
fn quantile_value_is_positive() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
assert!(result.quantile_value() >= 0.0);
}
#[test]
fn higher_coverage_gives_larger_quantile() {
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0];
let actuals = vec![9.0, 12.0, 11.0, 14.0, 13.0, 16.0, 15.0, 18.0, 17.0, 20.0];
let predictor_90 = ConformalPredictor::split(0.50);
let predictor_95 = ConformalPredictor::split(0.90);
let result_90 = predictor_90.fit(&forecasts, &actuals).unwrap();
let result_95 = predictor_95.fit(&forecasts, &actuals).unwrap();
assert!(result_95.quantile_value() >= result_90.quantile_value());
}
}
mod fit_cross_val {
use super::*;
#[test]
fn fit_returns_result() {
let predictor = ConformalPredictor::cross_val(0.90, 5);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
assert!((result.coverage() - 0.90).abs() < 1e-10);
}
#[test]
fn fit_fails_on_insufficient_folds() {
let predictor = ConformalPredictor::cross_val(0.90, 1);
let forecasts = vec![10.0, 11.0, 12.0];
let actuals = vec![10.5, 10.5, 12.5];
let result = predictor.fit(&forecasts, &actuals);
assert!(result.is_err());
}
#[test]
fn fit_fails_when_n_less_than_folds() {
let predictor = ConformalPredictor::cross_val(0.90, 10);
let forecasts = vec![10.0, 11.0, 12.0];
let actuals = vec![10.5, 10.5, 12.5];
let result = predictor.fit(&forecasts, &actuals);
assert!(result.is_err());
}
#[test]
fn uses_all_data_for_scores() {
let predictor = ConformalPredictor::cross_val(0.90, 5);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5, 15.5, 15.5, 17.5, 18.5, 19.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
assert_eq!(result.scores().len(), 10);
}
}
mod fit_jackknife_plus {
use super::*;
#[test]
fn fit_returns_result() {
let predictor = ConformalPredictor::jackknife_plus(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
assert!((result.coverage() - 0.90).abs() < 1e-10);
}
#[test]
fn fit_fails_on_single_point() {
let predictor = ConformalPredictor::jackknife_plus(0.90);
let forecasts = vec![10.0];
let actuals = vec![10.5];
let result = predictor.fit(&forecasts, &actuals);
assert!(result.is_err());
}
#[test]
fn works_with_two_points() {
let predictor = ConformalPredictor::jackknife_plus(0.90);
let forecasts = vec![10.0, 11.0];
let actuals = vec![10.5, 10.5];
let result = predictor.fit(&forecasts, &actuals);
assert!(result.is_ok());
}
#[test]
fn uses_all_data_for_scores() {
let predictor = ConformalPredictor::jackknife_plus(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
assert_eq!(result.scores().len(), 5);
}
}
mod predict {
use super::*;
use chrono::{TimeZone, Utc};
fn make_timestamps(n: usize) -> Vec<chrono::DateTime<Utc>> {
(0..n)
.map(|i| {
Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap()
+ chrono::Duration::days(i as i64)
})
.collect()
}
#[test]
fn predict_returns_intervals() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5, 15.5, 15.5, 17.5, 18.5, 19.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
let new_forecasts = PointForecasts::from_values(vec![20.0, 21.0, 22.0]);
let intervals = predictor.predict(&result, &new_forecasts);
assert_eq!(intervals.len(), 3);
assert!((intervals.coverage() - 0.90).abs() < 1e-10);
}
#[test]
fn predict_with_timestamps() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5, 15.5, 15.5, 17.5, 18.5, 19.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
let timestamps = make_timestamps(3);
let new_forecasts =
PointForecasts::new(timestamps.clone(), vec![20.0, 21.0, 22.0]).unwrap();
let intervals = predictor.predict(&result, &new_forecasts);
assert!(intervals.has_timestamps());
assert_eq!(intervals.timestamps(), ×tamps);
}
#[test]
fn intervals_are_symmetric() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5, 15.5, 15.5, 17.5, 18.5, 19.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
let new_forecasts = PointForecasts::from_values(vec![20.0]);
let intervals = predictor.predict(&result, &new_forecasts);
let point = 20.0;
let lower = intervals.lower()[0];
let upper = intervals.upper()[0];
let lower_diff = point - lower;
let upper_diff = upper - point;
assert!(
(lower_diff - upper_diff).abs() < 1e-10,
"Intervals should be symmetric"
);
}
#[test]
fn predict_values_works() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5, 15.5, 15.5, 17.5, 18.5, 19.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
let intervals = predictor.predict_values(&result, &[20.0, 21.0]);
assert_eq!(intervals.len(), 2);
assert!(!intervals.has_timestamps());
}
#[test]
fn larger_errors_give_wider_intervals() {
let forecasts_small = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals_small = vec![10.1, 11.1, 12.1, 13.1, 14.1];
let forecasts_large = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals_large = vec![8.0, 13.0, 10.0, 15.0, 12.0];
let predictor = ConformalPredictor::split(0.90);
let result_small = predictor.fit(&forecasts_small, &actuals_small).unwrap();
let result_large = predictor.fit(&forecasts_large, &actuals_large).unwrap();
assert!(result_large.quantile_value() > result_small.quantile_value());
}
}
mod coverage_validation {
use super::*;
#[test]
fn empirical_coverage_approximately_matches_target() {
let n = 100;
let forecasts: Vec<f64> = (0..n).map(|i| i as f64).collect();
let errors: Vec<f64> = (0..n)
.map(|i| ((i * 7 + 3) % 21) as f64 / 10.0 - 1.0)
.collect();
let actuals: Vec<f64> = forecasts
.iter()
.zip(errors.iter())
.map(|(f, e)| f + e)
.collect();
let predictor = ConformalPredictor::split(0.90);
let result = predictor.fit(&forecasts, &actuals).unwrap();
let new_forecasts: Vec<f64> = (100..150).map(|i| i as f64).collect();
let new_errors: Vec<f64> = (100..150)
.map(|i| ((i * 7 + 3) % 21) as f64 / 10.0 - 1.0)
.collect();
let new_actuals: Vec<f64> = new_forecasts
.iter()
.zip(new_errors.iter())
.map(|(f, e)| f + e)
.collect();
let intervals = predictor.predict_values(&result, &new_forecasts);
let empirical = intervals.empirical_coverage(&new_actuals).unwrap();
assert!(
empirical >= 0.70,
"Empirical coverage {} should be reasonably high",
empirical
);
}
}
mod conformal_result {
use super::*;
#[test]
fn accessors_return_correct_values() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
assert!(!result.scores().is_empty());
assert!(result.quantile_value() >= 0.0);
assert!((result.coverage() - 0.90).abs() < 1e-10);
match result.method() {
ConformalMethod::Split { .. } => {}
_ => panic!("Expected Split method"),
}
}
#[test]
fn result_is_clonable() {
let predictor = ConformalPredictor::split(0.90);
let forecasts = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let actuals = vec![10.5, 10.5, 12.5, 12.5, 14.5];
let result = predictor.fit(&forecasts, &actuals).unwrap();
let cloned = result.clone();
assert_eq!(result.scores(), cloned.scores());
assert!((result.quantile_value() - cloned.quantile_value()).abs() < 1e-10);
}
}
mod per_step {
use super::*;
fn make_folds(n_folds: usize, horizon: usize) -> (Vec<Vec<f64>>, Vec<Vec<f64>>) {
let mut forecasts = Vec::new();
let mut actuals = Vec::new();
for fold in 0..n_folds {
let fc: Vec<f64> = (0..horizon).map(|t| 100.0 + t as f64).collect();
let ac: Vec<f64> = (0..horizon)
.map(|t| {
let error = (t as f64 + 1.0) * 0.5 * if fold % 2 == 0 { 1.0 } else { -1.0 };
fc[t] + error
})
.collect();
forecasts.push(fc);
actuals.push(ac);
}
(forecasts, actuals)
}
#[test]
fn fit_per_step_returns_result() {
let predictor = ConformalPredictor::split(0.90);
let (fc, ac) = make_folds(10, 5);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
assert_eq!(result.horizon(), 5);
assert_eq!(result.half_widths().len(), 5);
assert_eq!(result.scores().len(), 5);
assert!((result.coverage() - 0.90).abs() < 1e-10);
}
#[test]
fn later_steps_have_wider_intervals() {
let predictor = ConformalPredictor::split(0.90);
let (fc, ac) = make_folds(20, 6);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
let hw = result.half_widths();
assert!(
hw[5] > hw[0],
"Last step hw ({}) should be > first step hw ({})",
hw[5],
hw[0]
);
}
#[test]
fn predict_returns_correct_bounds() {
let predictor = ConformalPredictor::split(0.90);
let (fc, ac) = make_folds(10, 3);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
let point = vec![50.0, 51.0, 52.0];
let (lower, upper) = result.predict(&point);
assert_eq!(lower.len(), 3);
assert_eq!(upper.len(), 3);
for t in 0..3 {
assert!(lower[t] < point[t]);
assert!(upper[t] > point[t]);
let diff_low = point[t] - lower[t];
let diff_high = upper[t] - point[t];
assert!(
(diff_low - diff_high).abs() < 1e-10,
"Step {} should be symmetric",
t
);
}
}
#[test]
fn predict_intervals_returns_prediction_intervals() {
let predictor = ConformalPredictor::split(0.90);
let (fc, ac) = make_folds(10, 4);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
let intervals = result.predict_intervals(&[10.0, 20.0, 30.0, 40.0]);
assert_eq!(intervals.len(), 4);
assert!((intervals.coverage() - 0.90).abs() < 1e-10);
}
#[test]
fn fails_with_fewer_than_2_folds() {
let predictor = ConformalPredictor::split(0.90);
let fc = vec![vec![1.0, 2.0]];
let ac = vec![vec![1.5, 2.5]];
assert!(predictor.fit_per_step(&fc, &ac).is_err());
}
#[test]
fn fails_with_mismatched_fold_lengths() {
let predictor = ConformalPredictor::split(0.90);
let fc = vec![vec![1.0, 2.0], vec![1.0, 2.0, 3.0]];
let ac = vec![vec![1.5, 2.5], vec![1.5, 2.5, 3.5]];
assert!(predictor.fit_per_step(&fc, &ac).is_err());
}
#[test]
fn fails_with_mismatched_forecast_actual_count() {
let predictor = ConformalPredictor::split(0.90);
let fc = vec![vec![1.0, 2.0], vec![1.0, 2.0]];
let ac = vec![vec![1.5, 2.5]];
assert!(predictor.fit_per_step(&fc, &ac).is_err());
}
#[test]
fn works_with_jackknife_plus() {
let predictor = ConformalPredictor::jackknife_plus(0.90);
let (fc, ac) = make_folds(10, 3);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
assert_eq!(result.horizon(), 3);
assert!(result.half_widths().iter().all(|&hw| hw > 0.0));
}
#[test]
fn works_with_cross_val() {
let predictor = ConformalPredictor::cross_val(0.90, 5);
let (fc, ac) = make_folds(10, 3);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
assert_eq!(result.horizon(), 3);
}
#[test]
fn each_step_has_n_folds_scores() {
let predictor = ConformalPredictor::split(0.90);
let (fc, ac) = make_folds(8, 4);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
for step_scores in result.scores() {
assert_eq!(step_scores.len(), 8);
}
}
#[test]
fn result_is_clonable() {
let predictor = ConformalPredictor::split(0.90);
let (fc, ac) = make_folds(5, 3);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
let cloned = result.clone();
assert_eq!(result.half_widths(), cloned.half_widths());
assert_eq!(result.horizon(), cloned.horizon());
}
#[test]
fn all_three_methods_produce_valid_per_step_results() {
let (fc, ac) = make_folds(10, 4);
let split = ConformalPredictor::split(0.90);
let cv = ConformalPredictor::cross_val(0.90, 5);
let jk = ConformalPredictor::jackknife_plus(0.90);
let r_split = split.fit_per_step(&fc, &ac).unwrap();
let r_cv = cv.fit_per_step(&fc, &ac).unwrap();
let r_jk = jk.fit_per_step(&fc, &ac).unwrap();
for r in [&r_split, &r_cv, &r_jk] {
assert_eq!(r.horizon(), 4);
assert_eq!(r.half_widths().len(), 4);
assert!(r.half_widths().iter().all(|&hw| hw >= 0.0));
assert!((r.coverage() - 0.90).abs() < 1e-10);
}
}
#[test]
fn two_folds_works_uses_per_step_or_pooled() {
let predictor = ConformalPredictor::split(0.90);
let fc = vec![vec![10.0, 20.0, 30.0], vec![11.0, 21.0, 31.0]];
let ac = vec![vec![10.5, 20.5, 30.5], vec![11.5, 21.5, 31.5]];
let result = predictor.fit_per_step(&fc, &ac).unwrap();
assert_eq!(result.horizon(), 3);
for &hw in result.half_widths() {
assert!(hw >= 0.0, "half-width should be non-negative");
assert!(hw.is_finite(), "half-width should be finite");
}
}
#[test]
fn half_widths_are_non_negative() {
let predictor = ConformalPredictor::split(0.95);
let (fc, ac) = make_folds(15, 8);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
for (t, &hw) in result.half_widths().iter().enumerate() {
assert!(
hw >= 0.0,
"Step {}: half-width {} should be non-negative",
t,
hw
);
}
}
#[test]
fn predict_returns_correct_length() {
let predictor = ConformalPredictor::split(0.90);
let (fc, ac) = make_folds(10, 7);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
let point: Vec<f64> = (0..7).map(|i| 50.0 + i as f64).collect();
let (lower, upper) = result.predict(&point);
assert_eq!(lower.len(), 7);
assert_eq!(upper.len(), 7);
}
#[test]
fn predict_intervals_returns_valid_prediction_intervals() {
let predictor = ConformalPredictor::split(0.90);
let (fc, ac) = make_folds(10, 5);
let result = predictor.fit_per_step(&fc, &ac).unwrap();
let point = vec![100.0, 200.0, 300.0, 400.0, 500.0];
let intervals = result.predict_intervals(&point);
assert_eq!(intervals.len(), 5);
assert!((intervals.coverage() - 0.90).abs() < 1e-10);
for i in 0..5 {
assert!(
intervals.lower()[i] <= intervals.upper()[i],
"lower > upper at step {}",
i
);
assert!(intervals.lower()[i].is_finite());
assert!(intervals.upper()[i].is_finite());
}
}
#[test]
fn constant_errors_give_equal_half_widths() {
let predictor = ConformalPredictor::split(0.90);
let n_folds = 10;
let horizon = 5;
let fc: Vec<Vec<f64>> = (0..n_folds).map(|_| vec![10.0; horizon]).collect();
let ac: Vec<Vec<f64>> = (0..n_folds)
.map(|fold| vec![if fold % 2 == 0 { 12.0 } else { 8.0 }; horizon])
.collect();
let result = predictor.fit_per_step(&fc, &ac).unwrap();
let hw = result.half_widths();
let first = hw[0];
for (t, &h) in hw.iter().enumerate() {
assert!(
(h - first).abs() < 1e-10,
"Step {} half-width {} should equal step 0 half-width {}",
t,
h,
first
);
}
}
#[test]
fn growing_errors_give_increasing_half_widths() {
let predictor = ConformalPredictor::split(0.90);
let n_folds = 20;
let horizon = 6;
let fc: Vec<Vec<f64>> = (0..n_folds)
.map(|_| (0..horizon).map(|t| 100.0 + t as f64).collect())
.collect();
let ac: Vec<Vec<f64>> = (0..n_folds)
.map(|fold| {
let sign = if fold % 2 == 0 { 1.0 } else { -1.0 };
(0..horizon)
.map(|t| 100.0 + t as f64 + sign * (t as f64 + 1.0) * 2.0)
.collect()
})
.collect();
let result = predictor.fit_per_step(&fc, &ac).unwrap();
let hw = result.half_widths();
assert!(
hw[horizon - 1] > hw[0],
"Last step hw ({}) should exceed first step hw ({})",
hw[horizon - 1],
hw[0]
);
}
#[test]
fn empty_fold_forecasts_errors() {
let predictor = ConformalPredictor::split(0.90);
let fc: Vec<Vec<f64>> = vec![];
let ac: Vec<Vec<f64>> = vec![];
let result = predictor.fit_per_step(&fc, &ac);
assert!(result.is_err(), "Empty folds should produce an error");
}
#[test]
fn mismatched_fold_lengths_errors() {
let predictor = ConformalPredictor::split(0.90);
let fc = vec![vec![1.0, 2.0, 3.0], vec![1.0, 2.0]]; let ac = vec![vec![1.5, 2.5, 3.5], vec![1.5, 2.5]];
let result = predictor.fit_per_step(&fc, &ac);
assert!(
result.is_err(),
"Mismatched fold lengths should produce an error"
);
}
#[test]
fn mismatched_fold_forecast_actual_count_errors() {
let predictor = ConformalPredictor::split(0.90);
let fc = vec![vec![1.0, 2.0], vec![1.0, 2.0], vec![1.0, 2.0]];
let ac = vec![vec![1.5, 2.5], vec![1.5, 2.5]]; let result = predictor.fit_per_step(&fc, &ac);
assert!(
result.is_err(),
"Mismatched forecast/actual fold count should produce an error"
);
}
}
mod quantile_forecasts {
use super::*;
fn make_calibration_data() -> (Vec<f64>, Vec<f64>) {
let forecasts: Vec<f64> = (0..50).map(|i| i as f64).collect();
let actuals: Vec<f64> = (0..50)
.map(|i| i as f64 + ((i * 7 + 3) % 11) as f64 * 0.3 - 1.5)
.collect();
(forecasts, actuals)
}
#[test]
fn returns_correct_shape() {
let (fc, ac) = make_calibration_data();
let cp = ConformalPredictor::split(0.90);
let result = cp.fit(&fc, &ac).unwrap();
let levels = vec![0.10, 0.25, 0.50, 0.75, 0.90];
let qf = cp.predict_quantiles(&result, &[50.0, 51.0, 52.0], &levels);
assert_eq!(qf.n_times(), 3);
assert_eq!(qf.quantiles().len(), 5);
}
#[test]
fn quantiles_are_monotonically_ordered() {
let (fc, ac) = make_calibration_data();
let cp = ConformalPredictor::split(0.90);
let result = cp.fit(&fc, &ac).unwrap();
let levels = vec![0.05, 0.25, 0.50, 0.75, 0.95];
let qf = cp.predict_quantiles(&result, &[50.0], &levels);
let row = qf.at_time(0).unwrap();
for i in 1..row.len() {
assert!(
row[i] >= row[i - 1],
"q[{}]={} < q[{}]={}",
i,
row[i],
i - 1,
row[i - 1]
);
}
}
#[test]
fn median_equals_point_forecast() {
let (fc, ac) = make_calibration_data();
let cp = ConformalPredictor::split(0.90);
let result = cp.fit(&fc, &ac).unwrap();
let qf = cp.predict_quantiles(&result, &[100.0], &[0.50]);
let median = qf.at_time(0).unwrap()[0];
assert!(
(median - 100.0).abs() < 1e-10,
"Median {} should equal point forecast 100.0",
median
);
}
#[test]
fn symmetric_quantiles_are_symmetric() {
let (fc, ac) = make_calibration_data();
let cp = ConformalPredictor::split(0.90);
let result = cp.fit(&fc, &ac).unwrap();
let point = 100.0;
let qf = cp.predict_quantiles(&result, &[point], &[0.10, 0.50, 0.90]);
let row = qf.at_time(0).unwrap();
let lower_dist = point - row[0]; let upper_dist = row[2] - point; assert!(
(lower_dist - upper_dist).abs() < 1e-10,
"Symmetric quantiles: lower_dist={}, upper_dist={}",
lower_dist,
upper_dist
);
}
#[test]
fn works_with_jackknife_plus() {
let (fc, ac) = make_calibration_data();
let cp = ConformalPredictor::jackknife_plus(0.90);
let result = cp.fit(&fc, &ac).unwrap();
let qf = cp.predict_quantiles(&result, &[50.0, 51.0], &[0.25, 0.75]);
assert_eq!(qf.n_times(), 2);
assert_eq!(qf.quantiles().len(), 2);
}
}
}