use scirs2_core::ndarray::{Array1, ArrayBase, Data, Ix1};
use scirs2_core::numeric::{Float, FromPrimitive};
use std::fmt::{Debug, Display};
use crate::error::{Result, TimeSeriesError};
pub fn mae<S1, S2, F>(actual: &ArrayBase<S1, Ix1>, forecast: &ArrayBase<S2, Ix1>) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
check_lengths(actual.len(), forecast.len())?;
check_minimum_length(actual.len(), 1, "MAE")?;
let n = F::from_usize(actual.len())
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert length".to_string()))?;
let sum: F = actual
.iter()
.zip(forecast.iter())
.map(|(&a, &f)| (a - f).abs())
.fold(F::zero(), |acc, x| acc + x);
Ok(sum / n)
}
pub fn mape<S1, S2, F>(actual: &ArrayBase<S1, Ix1>, forecast: &ArrayBase<S2, Ix1>) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
check_lengths(actual.len(), forecast.len())?;
check_minimum_length(actual.len(), 1, "MAPE")?;
let hundred = F::from_f64(100.0).ok_or_else(|| {
TimeSeriesError::ComputationError("Failed to convert constant".to_string())
})?;
let mut sum = F::zero();
let mut valid_count = 0usize;
for (&a, &f) in actual.iter().zip(forecast.iter()) {
let abs_a = a.abs();
if abs_a > F::epsilon() {
sum = sum + (a - f).abs() / abs_a;
valid_count += 1;
}
}
if valid_count == 0 {
return Err(TimeSeriesError::ComputationError(
"MAPE undefined: all actual values are zero".to_string(),
));
}
let n = F::from_usize(valid_count)
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert count".to_string()))?;
Ok(hundred * sum / n)
}
pub fn smape<S1, S2, F>(actual: &ArrayBase<S1, Ix1>, forecast: &ArrayBase<S2, Ix1>) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
check_lengths(actual.len(), forecast.len())?;
check_minimum_length(actual.len(), 1, "sMAPE")?;
let two_hundred = F::from_f64(200.0).ok_or_else(|| {
TimeSeriesError::ComputationError("Failed to convert constant".to_string())
})?;
let mut sum = F::zero();
let mut valid_count = 0usize;
for (&a, &f) in actual.iter().zip(forecast.iter()) {
let denom = a.abs() + f.abs();
if denom > F::epsilon() {
sum = sum + (a - f).abs() / denom;
valid_count += 1;
}
}
if valid_count == 0 {
return Ok(F::zero());
}
let n = F::from_usize(actual.len())
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert count".to_string()))?;
Ok(two_hundred * sum / n)
}
pub fn mase<S1, S2, S3, F>(
actual: &ArrayBase<S1, Ix1>,
forecast: &ArrayBase<S2, Ix1>,
training: &ArrayBase<S3, Ix1>,
) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
S3: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
check_lengths(actual.len(), forecast.len())?;
check_minimum_length(actual.len(), 1, "MASE")?;
check_minimum_length(training.len(), 2, "MASE (training)")?;
let naive_scale = naive_mae(training, 1)?;
if naive_scale <= F::epsilon() {
return Err(TimeSeriesError::ComputationError(
"MASE undefined: training data has zero naive MAE (constant series)".to_string(),
));
}
let forecast_mae = mae(actual, forecast)?;
Ok(forecast_mae / naive_scale)
}
pub fn mase_seasonal<S1, S2, S3, F>(
actual: &ArrayBase<S1, Ix1>,
forecast: &ArrayBase<S2, Ix1>,
training: &ArrayBase<S3, Ix1>,
period: usize,
) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
S3: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
check_lengths(actual.len(), forecast.len())?;
check_minimum_length(actual.len(), 1, "seasonal MASE")?;
if period == 0 {
return Err(TimeSeriesError::InvalidParameter {
name: "period".to_string(),
message: "Seasonal period must be positive".to_string(),
});
}
check_minimum_length(training.len(), period + 1, "seasonal MASE (training)")?;
let naive_scale = naive_mae(training, period)?;
if naive_scale <= F::epsilon() {
return Err(TimeSeriesError::ComputationError(
"Seasonal MASE undefined: training series has zero seasonal naive MAE".to_string(),
));
}
let forecast_mae = mae(actual, forecast)?;
Ok(forecast_mae / naive_scale)
}
pub fn rmse<S1, S2, F>(actual: &ArrayBase<S1, Ix1>, forecast: &ArrayBase<S2, Ix1>) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
check_lengths(actual.len(), forecast.len())?;
check_minimum_length(actual.len(), 1, "RMSE")?;
let n = F::from_usize(actual.len())
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert length".to_string()))?;
let sum_sq: F = actual
.iter()
.zip(forecast.iter())
.map(|(&a, &f)| {
let e = a - f;
e * e
})
.fold(F::zero(), |acc, x| acc + x);
Ok((sum_sq / n).sqrt())
}
pub fn mse<S1, S2, F>(actual: &ArrayBase<S1, Ix1>, forecast: &ArrayBase<S2, Ix1>) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
check_lengths(actual.len(), forecast.len())?;
check_minimum_length(actual.len(), 1, "MSE")?;
let n = F::from_usize(actual.len())
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert length".to_string()))?;
let sum_sq: F = actual
.iter()
.zip(forecast.iter())
.map(|(&a, &f)| {
let e = a - f;
e * e
})
.fold(F::zero(), |acc, x| acc + x);
Ok(sum_sq / n)
}
pub fn wape<S1, S2, F>(actual: &ArrayBase<S1, Ix1>, forecast: &ArrayBase<S2, Ix1>) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
check_lengths(actual.len(), forecast.len())?;
check_minimum_length(actual.len(), 1, "WAPE")?;
let sum_abs_actual: F = actual
.iter()
.map(|&a| a.abs())
.fold(F::zero(), |acc, x| acc + x);
if sum_abs_actual <= F::epsilon() {
return Err(TimeSeriesError::ComputationError(
"WAPE undefined: sum of absolute actual values is zero".to_string(),
));
}
let sum_abs_error: F = actual
.iter()
.zip(forecast.iter())
.map(|(&a, &f)| (a - f).abs())
.fold(F::zero(), |acc, x| acc + x);
Ok(sum_abs_error / sum_abs_actual)
}
pub fn coverage_probability<S1, S2, S3, F>(
actual: &ArrayBase<S1, Ix1>,
lower: &ArrayBase<S2, Ix1>,
upper: &ArrayBase<S3, Ix1>,
) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
S3: Data<Elem = F>,
F: Float + FromPrimitive + Debug,
{
let n = actual.len();
check_minimum_length(n, 1, "coverage probability")?;
if lower.len() != n || upper.len() != n {
return Err(TimeSeriesError::DimensionMismatch {
expected: n,
actual: if lower.len() != n {
lower.len()
} else {
upper.len()
},
});
}
let count: usize = actual
.iter()
.zip(lower.iter())
.zip(upper.iter())
.filter(|((&a, &lo), &hi)| a >= lo && a <= hi)
.count();
let n_f = F::from_usize(n)
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert length".to_string()))?;
let count_f = F::from_usize(count)
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert count".to_string()))?;
Ok(count_f / n_f)
}
pub fn winkler_score<S1, S2, S3, F>(
actual: &ArrayBase<S1, Ix1>,
lower: &ArrayBase<S2, Ix1>,
upper: &ArrayBase<S3, Ix1>,
alpha: F,
) -> Result<F>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
S3: Data<Elem = F>,
F: Float + FromPrimitive + Debug + Display,
{
let n = actual.len();
check_minimum_length(n, 1, "Winkler score")?;
if lower.len() != n || upper.len() != n {
return Err(TimeSeriesError::DimensionMismatch {
expected: n,
actual: if lower.len() != n {
lower.len()
} else {
upper.len()
},
});
}
if alpha <= F::zero() || alpha >= F::one() {
return Err(TimeSeriesError::InvalidParameter {
name: "alpha".to_string(),
message: format!("Must be in (0, 1), got {alpha}"),
});
}
let two = F::from_f64(2.0).ok_or_else(|| {
TimeSeriesError::ComputationError("Failed to convert constant".to_string())
})?;
let penalty_factor = two / alpha;
let mut total_score = F::zero();
for ((&a, &lo), &hi) in actual.iter().zip(lower.iter()).zip(upper.iter()) {
let width = hi - lo;
let penalty = if a < lo {
penalty_factor * (lo - a)
} else if a > hi {
penalty_factor * (a - hi)
} else {
F::zero()
};
total_score = total_score + width + penalty;
}
let n_f = F::from_usize(n)
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert length".to_string()))?;
Ok(total_score / n_f)
}
#[derive(Debug, Clone)]
pub struct DieboldMarianoResult<F: Float> {
pub statistic: F,
pub p_value: F,
pub mean_loss_diff: F,
pub long_run_variance: F,
pub horizon: usize,
}
#[derive(Debug, Clone, Copy)]
pub enum DMLossFunction {
SquaredError,
AbsoluteError,
}
pub fn diebold_mariano<S1, S2, S3, F>(
actual: &ArrayBase<S1, Ix1>,
forecast1: &ArrayBase<S2, Ix1>,
forecast2: &ArrayBase<S3, Ix1>,
loss: DMLossFunction,
horizon: usize,
) -> Result<DieboldMarianoResult<F>>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
S3: Data<Elem = F>,
F: Float + FromPrimitive + Debug + Display,
{
let n = actual.len();
check_minimum_length(n, 2, "Diebold-Mariano test")?;
if forecast1.len() != n || forecast2.len() != n {
return Err(TimeSeriesError::DimensionMismatch {
expected: n,
actual: if forecast1.len() != n {
forecast1.len()
} else {
forecast2.len()
},
});
}
if horizon == 0 {
return Err(TimeSeriesError::InvalidParameter {
name: "horizon".to_string(),
message: "Forecast horizon must be at least 1".to_string(),
});
}
let loss_diff: Vec<F> = actual
.iter()
.zip(forecast1.iter())
.zip(forecast2.iter())
.map(|((&a, &f1), &f2)| {
let l1 = compute_loss(a, f1, loss);
let l2 = compute_loss(a, f2, loss);
l1 - l2
})
.collect();
let n_f = F::from_usize(n)
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert length".to_string()))?;
let d_bar = loss_diff.iter().copied().fold(F::zero(), |a, x| a + x) / n_f;
let bandwidth = if horizon > 1 { horizon - 1 } else { 0 };
let long_run_var = newey_west_variance(&loss_diff, d_bar, bandwidth)?;
if long_run_var <= F::epsilon() {
return Err(TimeSeriesError::ComputationError(
"Diebold-Mariano test: zero variance in loss differentials (forecasts may be identical)"
.to_string(),
));
}
let dm_stat = d_bar / (long_run_var / n_f).sqrt();
let p_value = two_sided_normal_p(dm_stat);
Ok(DieboldMarianoResult {
statistic: dm_stat,
p_value,
mean_loss_diff: d_bar,
long_run_variance: long_run_var,
horizon,
})
}
pub fn evaluation_report<S1, S2, F>(
actual: &ArrayBase<S1, Ix1>,
forecast: &ArrayBase<S2, Ix1>,
training: Option<&Array1<F>>,
) -> Result<EvaluationReport<F>>
where
S1: Data<Elem = F>,
S2: Data<Elem = F>,
F: Float + FromPrimitive + Debug + Display,
{
let mae_val = mae(actual, forecast)?;
let rmse_val = rmse(actual, forecast)?;
let mse_val = mse(actual, forecast)?;
let mape_val = mape(actual, forecast).ok();
let smape_val = smape(actual, forecast)?;
let wape_val = wape(actual, forecast).ok();
let mase_val = if let Some(train) = training {
mase(actual, forecast, train).ok()
} else {
None
};
Ok(EvaluationReport {
mae: mae_val,
rmse: rmse_val,
mse: mse_val,
mape: mape_val,
smape: smape_val,
wape: wape_val,
mase: mase_val,
})
}
#[derive(Debug, Clone)]
pub struct EvaluationReport<F: Float> {
pub mae: F,
pub rmse: F,
pub mse: F,
pub mape: Option<F>,
pub smape: F,
pub wape: Option<F>,
pub mase: Option<F>,
}
impl<F: Float + Display> std::fmt::Display for EvaluationReport<F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Forecast Evaluation Report")?;
writeln!(f, "=========================")?;
writeln!(f, "MAE: {:.6}", self.mae)?;
writeln!(f, "RMSE: {:.6}", self.rmse)?;
writeln!(f, "MSE: {:.6}", self.mse)?;
if let Some(m) = self.mape {
writeln!(f, "MAPE: {:.4}%", m)?;
}
writeln!(f, "sMAPE: {:.4}", self.smape)?;
if let Some(w) = self.wape {
writeln!(f, "WAPE: {:.6}", w)?;
}
if let Some(m) = self.mase {
writeln!(f, "MASE: {:.6}", m)?;
}
Ok(())
}
}
fn naive_mae<S, F>(data: &ArrayBase<S, Ix1>, lag: usize) -> Result<F>
where
S: Data<Elem = F>,
F: Float + FromPrimitive,
{
let n = data.len();
if n <= lag {
return Err(TimeSeriesError::InsufficientData {
message: "for naive MAE computation".to_string(),
required: lag + 1,
actual: n,
});
}
let diffs = n - lag;
let n_diffs = F::from_usize(diffs)
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert count".to_string()))?;
let sum: F = (lag..n)
.map(|i| (data[i] - data[i - lag]).abs())
.fold(F::zero(), |acc, x| acc + x);
Ok(sum / n_diffs)
}
fn compute_loss<F: Float>(actual: F, forecast: F, loss: DMLossFunction) -> F {
match loss {
DMLossFunction::SquaredError => {
let e = actual - forecast;
e * e
}
DMLossFunction::AbsoluteError => (actual - forecast).abs(),
}
}
fn newey_west_variance<F: Float + FromPrimitive>(
data: &[F],
mean: F,
bandwidth: usize,
) -> Result<F> {
let n = data.len();
let n_f = F::from_usize(n)
.ok_or_else(|| TimeSeriesError::ComputationError("Failed to convert length".to_string()))?;
let gamma0: F = data
.iter()
.map(|&d| {
let dev = d - mean;
dev * dev
})
.fold(F::zero(), |acc, x| acc + x)
/ n_f;
let mut lrv = gamma0;
for lag in 1..=bandwidth {
if lag >= n {
break;
}
let lag_f = F::from_usize(lag).ok_or_else(|| {
TimeSeriesError::ComputationError("Failed to convert lag".to_string())
})?;
let bw_f = F::from_usize(bandwidth + 1).ok_or_else(|| {
TimeSeriesError::ComputationError("Failed to convert bandwidth".to_string())
})?;
let weight = F::one() - lag_f / bw_f;
let gamma_lag: F = (lag..n)
.map(|i| (data[i] - mean) * (data[i - lag] - mean))
.fold(F::zero(), |acc, x| acc + x)
/ n_f;
let two = F::from_f64(2.0).ok_or_else(|| {
TimeSeriesError::ComputationError("Failed to convert constant".to_string())
})?;
lrv = lrv + two * weight * gamma_lag;
}
if lrv < F::zero() {
Ok(F::zero())
} else {
Ok(lrv)
}
}
fn two_sided_normal_p<F: Float + FromPrimitive>(z: F) -> F {
let abs_z = z.abs();
let one = F::one();
let two = F::from_f64(2.0).unwrap_or(one + one);
let threshold = F::from_f64(8.0).unwrap_or(F::from_f64(8.0).unwrap_or(one));
if abs_z > threshold {
return F::zero();
}
let p = F::from_f64(0.2316419).unwrap_or(F::zero());
let b1 = F::from_f64(0.319381530).unwrap_or(F::zero());
let b2 = F::from_f64(-0.356563782).unwrap_or(F::zero());
let b3 = F::from_f64(1.781477937).unwrap_or(F::zero());
let b4 = F::from_f64(-1.821255978).unwrap_or(F::zero());
let b5 = F::from_f64(1.330274429).unwrap_or(F::zero());
let inv_sqrt_2pi = F::from_f64(0.39894228040143268).unwrap_or(F::zero());
let t = one / (one + p * abs_z);
let t2 = t * t;
let t3 = t2 * t;
let t4 = t3 * t;
let t5 = t4 * t;
let poly = b1 * t + b2 * t2 + b3 * t3 + b4 * t4 + b5 * t5;
let pdf = inv_sqrt_2pi * (-abs_z * abs_z / two).exp();
let tail_prob = pdf * poly;
two * tail_prob
}
fn check_lengths(len1: usize, len2: usize) -> Result<()> {
if len1 != len2 {
return Err(TimeSeriesError::DimensionMismatch {
expected: len1,
actual: len2,
});
}
Ok(())
}
fn check_minimum_length(len: usize, min: usize, operation: &str) -> Result<()> {
if len < min {
return Err(TimeSeriesError::InsufficientData {
message: format!("for {operation}"),
required: min,
actual: len,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
const TOL: f64 = 1e-10;
const LOOSE_TOL: f64 = 1e-4;
#[test]
fn test_mae_basic() {
let actual = array![1.0, 2.0, 3.0, 4.0, 5.0];
let forecast = array![1.1, 2.2, 2.8, 4.3, 4.7];
let result = mae(&actual, &forecast).expect("MAE should succeed");
assert!((result - 0.22).abs() < TOL);
}
#[test]
fn test_mae_perfect() {
let actual = array![1.0, 2.0, 3.0];
let forecast = array![1.0, 2.0, 3.0];
let result = mae(&actual, &forecast).expect("MAE should succeed");
assert!(result.abs() < TOL);
}
#[test]
fn test_mae_length_mismatch() {
let actual = array![1.0, 2.0];
let forecast = array![1.0, 2.0, 3.0];
assert!(mae(&actual, &forecast).is_err());
}
#[test]
fn test_mae_empty() {
let actual: Array1<f64> = array![];
let forecast: Array1<f64> = array![];
assert!(mae(&actual, &forecast).is_err());
}
#[test]
fn test_mape_basic() {
let actual = array![100.0, 200.0, 300.0, 400.0];
let forecast = array![110.0, 190.0, 280.0, 420.0];
let result = mape(&actual, &forecast).expect("MAPE should succeed");
let expected = (0.10 + 0.05 + 20.0 / 300.0 + 0.05) * 100.0 / 4.0;
assert!((result - expected).abs() < TOL);
}
#[test]
fn test_mape_zero_actual() {
let actual = array![0.0, 0.0, 0.0];
let forecast = array![1.0, 2.0, 3.0];
assert!(mape(&actual, &forecast).is_err());
}
#[test]
fn test_smape_basic() {
let actual = array![100.0, 200.0, 300.0];
let forecast = array![110.0, 190.0, 310.0];
let result = smape(&actual, &forecast).expect("sMAPE should succeed");
let expected = 200.0 / 3.0 * (10.0 / 210.0 + 10.0 / 390.0 + 10.0 / 610.0);
assert!((result - expected).abs() < LOOSE_TOL);
}
#[test]
fn test_smape_both_zero() {
let actual = array![0.0, 0.0];
let forecast = array![0.0, 0.0];
let result = smape(&actual, &forecast).expect("sMAPE should succeed");
assert!(result.abs() < TOL);
}
#[test]
fn test_mase_basic() {
let training = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let actual = array![11.0, 12.0, 13.0];
let forecast = array![10.5, 12.5, 12.0];
let result = mase(&actual, &forecast, &training).expect("MASE should succeed");
assert!((result - 2.0 / 3.0).abs() < TOL);
}
#[test]
fn test_mase_constant_training() {
let training = array![5.0, 5.0, 5.0, 5.0];
let actual = array![6.0];
let forecast = array![5.5];
assert!(mase(&actual, &forecast, &training).is_err());
}
#[test]
fn test_mase_seasonal() {
let training = array![1.0, 2.0, 3.0, 4.0, 2.0, 3.0, 4.0, 5.0, 3.0, 4.0, 5.0, 6.0];
let actual = array![4.0, 5.0, 6.0, 7.0];
let forecast = array![3.5, 5.5, 5.5, 7.5];
let result =
mase_seasonal(&actual, &forecast, &training, 4).expect("Seasonal MASE should succeed");
assert!((result - 0.5).abs() < TOL);
}
#[test]
fn test_rmse_basic() {
let actual = array![1.0, 2.0, 3.0, 4.0];
let forecast = array![1.1, 1.8, 3.2, 3.7];
let result = rmse(&actual, &forecast).expect("RMSE should succeed");
let expected = (0.045_f64).sqrt();
assert!((result - expected).abs() < TOL);
}
#[test]
fn test_mse_basic() {
let actual = array![3.0, 5.0, 2.5];
let forecast = array![2.5, 5.0, 3.0];
let result = mse(&actual, &forecast).expect("MSE should succeed");
assert!((result - 0.5 / 3.0).abs() < TOL);
}
#[test]
fn test_wape_basic() {
let actual = array![100.0, 200.0, 300.0];
let forecast = array![110.0, 190.0, 280.0];
let result = wape(&actual, &forecast).expect("WAPE should succeed");
assert!((result - 40.0 / 600.0).abs() < TOL);
}
#[test]
fn test_coverage_basic() {
let actual = array![1.0, 2.0, 3.0, 4.0, 5.0];
let lower = array![0.5, 1.5, 2.0, 3.5, 6.0];
let upper = array![1.5, 2.5, 4.0, 4.5, 7.0];
let result =
coverage_probability(&actual, &lower, &upper).expect("Coverage should succeed");
assert!((result - 0.8).abs() < TOL);
}
#[test]
fn test_coverage_perfect() {
let actual = array![1.0, 2.0, 3.0];
let lower = array![0.0, 0.0, 0.0];
let upper = array![10.0, 10.0, 10.0];
let result =
coverage_probability(&actual, &lower, &upper).expect("Coverage should succeed");
assert!((result - 1.0).abs() < TOL);
}
#[test]
fn test_winkler_score_all_covered() {
let actual = array![2.0, 3.0, 4.0];
let lower = array![1.0, 2.0, 3.0];
let upper = array![3.0, 4.0, 5.0];
let result =
winkler_score(&actual, &lower, &upper, 0.05).expect("Winkler score should succeed");
assert!((result - 2.0).abs() < TOL);
}
#[test]
fn test_winkler_score_with_penalty() {
let actual = array![0.0, 5.0];
let lower = array![1.0, 2.0];
let upper = array![3.0, 4.0];
let alpha = 0.1_f64;
let result =
winkler_score(&actual, &lower, &upper, alpha).expect("Winkler score should succeed");
assert!((result - 22.0).abs() < TOL);
}
#[test]
fn test_winkler_invalid_alpha() {
let actual = array![1.0];
let lower = array![0.0];
let upper = array![2.0];
assert!(winkler_score(&actual, &lower, &upper, 0.0_f64).is_err());
assert!(winkler_score(&actual, &lower, &upper, 1.0_f64).is_err());
assert!(winkler_score(&actual, &lower, &upper, -0.1_f64).is_err());
}
#[test]
fn test_diebold_mariano_identical() {
let actual = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let f1 = array![1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 8.1, 9.1, 10.1];
let f2 = f1.clone();
assert!(diebold_mariano(&actual, &f1, &f2, DMLossFunction::SquaredError, 1).is_err());
}
#[test]
fn test_diebold_mariano_different() {
let actual = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let f1 = array![1.5, 1.8, 3.5, 3.8, 5.5, 5.8, 7.5, 7.8, 9.5, 9.8];
let f2 = array![1.2, 2.5, 2.8, 4.5, 4.8, 6.5, 6.8, 8.5, 8.8, 10.5];
let result = diebold_mariano(&actual, &f1, &f2, DMLossFunction::SquaredError, 1)
.expect("DM test should succeed");
assert!(result.mean_loss_diff.abs() < 0.1);
assert!(result.p_value > 0.05);
}
#[test]
fn test_diebold_mariano_clearly_better() {
let actual = array![
1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
17.0, 18.0, 19.0, 20.0
];
let f1 = array![
1.5, 3.0, 4.5, 6.0, 7.5, 7.0, 8.5, 10.0, 11.5, 13.0, 12.5, 14.0, 15.5, 17.0, 18.5,
17.0, 18.5, 20.0, 21.5, 23.0
];
let f2 = array![
1.1, 2.2, 3.3, 4.1, 5.2, 6.3, 7.1, 8.2, 9.3, 10.1, 11.2, 12.3, 13.1, 14.2, 15.3,
16.1, 17.2, 18.3, 19.1, 20.2
];
let result = diebold_mariano(&actual, &f1, &f2, DMLossFunction::SquaredError, 1)
.expect("DM test should succeed");
assert!(result.mean_loss_diff > 0.0_f64);
assert!(result.statistic > 0.0_f64);
}
type F64 = f64;
#[test]
fn test_diebold_mariano_absolute_loss() {
let actual = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let f1 = array![2.0, 3.5, 4.0, 5.5, 6.0, 7.5, 8.0, 9.5, 10.0, 11.5];
let f2 = array![1.1, 1.9, 3.2, 3.8, 5.1, 5.9, 7.2, 7.8, 9.1, 9.9];
let result = diebold_mariano(&actual, &f1, &f2, DMLossFunction::AbsoluteError, 1)
.expect("DM test should succeed");
assert!(result.mean_loss_diff > 0.0);
assert!((result.mean_loss_diff - 1.11).abs() < 0.01);
}
#[test]
fn test_evaluation_report() {
let actual = array![10.0, 20.0, 30.0, 40.0, 50.0];
let forecast = array![11.0, 19.0, 31.0, 38.0, 52.0];
let training = array![5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let report =
evaluation_report(&actual, &forecast, Some(&training)).expect("Report should succeed");
assert!(report.mae > 0.0);
assert!(report.rmse > 0.0);
assert!(report.mse > 0.0);
assert!(report.smape > 0.0);
assert!(report.mape.is_some());
assert!(report.wape.is_some());
assert!(report.mase.is_some());
}
#[test]
fn test_evaluation_report_no_training() {
let actual = array![1.0, 2.0, 3.0];
let forecast = array![1.0, 2.0, 3.0];
let report = evaluation_report::<_, _, f64>(&actual, &forecast, None)
.expect("Report should succeed");
assert!(report.mase.is_none());
}
#[test]
fn test_rmse_ge_mae() {
let actual = array![1.0, 5.0, 3.0, 8.0, 2.0];
let forecast = array![2.0, 4.0, 5.0, 6.0, 3.0];
let mae_val: f64 = mae(&actual, &forecast).expect("MAE should work");
let rmse_val: f64 = rmse(&actual, &forecast).expect("RMSE should work");
assert!(rmse_val >= mae_val);
}
#[test]
fn test_smape_symmetric() {
let a = array![100.0, 200.0];
let b = array![200.0, 100.0];
let smape_ab: f64 = smape(&a, &b).expect("smape ab");
let smape_ba: f64 = smape(&b, &a).expect("smape ba");
assert!((smape_ab - smape_ba).abs() < TOL);
}
#[test]
fn test_coverage_boundary_inclusion() {
let actual = array![1.0, 3.0];
let lower = array![1.0, 2.0];
let upper = array![2.0, 3.0];
let cov: f64 = coverage_probability(&actual, &lower, &upper).expect("Coverage should work");
assert!((cov - 1.0).abs() < TOL);
}
#[test]
fn test_winkler_alpha_bounds() {
let actual = array![1.0];
let lower = array![0.0];
let upper = array![2.0];
assert!(winkler_score(&actual, &lower, &upper, 0.05_f64).is_ok());
assert!(winkler_score(&actual, &lower, &upper, 0.5_f64).is_ok());
}
#[test]
fn test_f32_support() {
let actual: Array1<f32> = array![1.0f32, 2.0, 3.0];
let forecast: Array1<f32> = array![1.1f32, 2.2, 2.8];
let result = mae(&actual, &forecast);
assert!(result.is_ok());
}
}