use std::fmt;
#[derive(Debug, Clone)]
pub struct HorizonStep {
pub horizon: usize,
pub mae: f64,
pub rmse: f64,
pub mape: Option<f64>,
pub bias: f64,
pub n_samples: usize,
}
#[derive(Debug, Clone)]
pub struct HorizonAnalysis {
pub steps: Vec<HorizonStep>,
pub model_name: String,
}
impl HorizonAnalysis {
pub fn from_folds(
model_name: impl Into<String>,
fold_actuals: &[&[f64]],
fold_forecasts: &[&[f64]],
) -> Self {
let model_name = model_name.into();
let horizon = match fold_actuals.first() {
Some(first) => first.len(),
None => {
return Self {
steps: Vec::new(),
model_name,
};
}
};
let n_folds = fold_actuals.len();
let mut steps = Vec::with_capacity(horizon);
for h in 0..horizon {
let mut sum_abs_err = 0.0;
let mut sum_sq_err = 0.0;
let mut sum_bias = 0.0;
let mut sum_ape = 0.0;
let mut has_zero_actual = false;
for i in 0..n_folds {
let actual = fold_actuals[i][h];
let forecast = fold_forecasts[i][h];
let err = forecast - actual;
sum_abs_err += err.abs();
sum_sq_err += err * err;
sum_bias += err;
if actual == 0.0 {
has_zero_actual = true;
} else {
sum_ape += (err.abs()) / actual.abs();
}
}
let n = n_folds as f64;
let mae = sum_abs_err / n;
let rmse = (sum_sq_err / n).sqrt();
let bias = sum_bias / n;
let mape = if has_zero_actual {
None
} else {
Some(sum_ape / n * 100.0)
};
steps.push(HorizonStep {
horizon: h + 1,
mae,
rmse,
mape,
bias,
n_samples: n_folds,
});
}
Self { steps, model_name }
}
pub fn horizon_length(&self) -> usize {
self.steps.len()
}
pub fn hardest_horizon(&self) -> Option<&HorizonStep> {
self.steps.iter().max_by(|a, b| {
a.rmse
.partial_cmp(&b.rmse)
.unwrap_or(std::cmp::Ordering::Equal)
})
}
pub fn easiest_horizon(&self) -> Option<&HorizonStep> {
self.steps.iter().min_by(|a, b| {
a.rmse
.partial_cmp(&b.rmse)
.unwrap_or(std::cmp::Ordering::Equal)
})
}
pub fn error_growth_rate(&self) -> Option<f64> {
if self.steps.len() < 2 {
return None;
}
let first_rmse = self.steps.first().unwrap().rmse;
let last_rmse = self.steps.last().unwrap().rmse;
if first_rmse == 0.0 {
return None;
}
Some((last_rmse - first_rmse) / first_rmse)
}
pub fn summary(&self) -> String {
let mut out = String::new();
out.push_str(&format!("Horizon Analysis for '{}'\n", self.model_name));
out.push_str(&format!(
"{:>7} {:>10} {:>10} {:>10} {:>10} {:>9}\n",
"Horizon", "MAE", "RMSE", "MAPE(%)", "Bias", "Samples"
));
out.push_str(&format!("{}\n", "-".repeat(62)));
for step in &self.steps {
let mape_str = match step.mape {
Some(v) => format!("{:.4}", v),
None => "N/A".to_string(),
};
out.push_str(&format!(
"{:>7} {:>10.4} {:>10.4} {:>10} {:>10.4} {:>9}\n",
step.horizon, step.mae, step.rmse, mape_str, step.bias, step.n_samples
));
}
if let Some(rate) = self.error_growth_rate() {
out.push_str(&format!("Error growth rate: {:.2}%\n", rate * 100.0));
}
out
}
}
impl fmt::Display for HorizonAnalysis {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.summary())
}
}
impl fmt::Display for HorizonStep {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mape_str = match self.mape {
Some(v) => format!("{:.4}%", v),
None => "N/A".to_string(),
};
write!(
f,
"h={}: MAE={:.4}, RMSE={:.4}, MAPE={}, Bias={:.4}, n={}",
self.horizon, self.mae, self.rmse, mape_str, self.bias, self.n_samples
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_folds() {
let analysis = HorizonAnalysis::from_folds("test", &[], &[]);
assert!(analysis.steps.is_empty());
assert_eq!(analysis.horizon_length(), 0);
assert!(analysis.hardest_horizon().is_none());
assert!(analysis.easiest_horizon().is_none());
assert!(analysis.error_growth_rate().is_none());
}
#[test]
fn single_fold_single_horizon() {
let actuals: &[f64] = &[10.0];
let forecasts: &[f64] = &[12.0];
let analysis = HorizonAnalysis::from_folds("single", &[actuals], &[forecasts]);
assert_eq!(analysis.horizon_length(), 1);
let step = &analysis.steps[0];
assert_eq!(step.horizon, 1);
assert!((step.mae - 2.0).abs() < 1e-10);
assert!((step.rmse - 2.0).abs() < 1e-10);
assert!((step.bias - 2.0).abs() < 1e-10);
assert!(step.mape.is_some());
assert!((step.mape.unwrap() - 20.0).abs() < 1e-10);
assert_eq!(step.n_samples, 1);
}
#[test]
fn multiple_folds_multiple_horizons() {
let a1: &[f64] = &[10.0, 20.0, 30.0, 40.0];
let f1: &[f64] = &[11.0, 22.0, 33.0, 44.0];
let a2: &[f64] = &[10.0, 20.0, 30.0, 40.0];
let f2: &[f64] = &[9.0, 18.0, 27.0, 36.0];
let a3: &[f64] = &[10.0, 20.0, 30.0, 40.0];
let f3: &[f64] = &[10.0, 20.0, 30.0, 40.0];
let analysis = HorizonAnalysis::from_folds("multi", &[a1, a2, a3], &[f1, f2, f3]);
assert_eq!(analysis.horizon_length(), 4);
assert_eq!(analysis.steps[0].n_samples, 3);
let s0 = &analysis.steps[0];
assert!((s0.mae - 2.0 / 3.0).abs() < 1e-10);
assert!((s0.bias - 0.0).abs() < 1e-10);
}
#[test]
fn perfect_forecast() {
let actuals: &[f64] = &[5.0, 10.0, 15.0];
let forecasts: &[f64] = &[5.0, 10.0, 15.0];
let analysis = HorizonAnalysis::from_folds("perfect", &[actuals], &[forecasts]);
for step in &analysis.steps {
assert!((step.mae - 0.0).abs() < 1e-10);
assert!((step.rmse - 0.0).abs() < 1e-10);
assert!((step.bias - 0.0).abs() < 1e-10);
assert!(step.mape.is_some());
assert!((step.mape.unwrap() - 0.0).abs() < 1e-10);
}
}
#[test]
fn increasing_error() {
let actuals: &[f64] = &[10.0, 10.0, 10.0];
let forecasts: &[f64] = &[11.0, 12.0, 13.0];
let analysis = HorizonAnalysis::from_folds("growing", &[actuals], &[forecasts]);
assert!(analysis.steps[0].rmse < analysis.steps[1].rmse);
assert!(analysis.steps[1].rmse < analysis.steps[2].rmse);
}
#[test]
fn hardest_and_easiest() {
let a1: &[f64] = &[10.0, 10.0, 10.0];
let f1: &[f64] = &[11.0, 15.0, 10.5];
let analysis = HorizonAnalysis::from_folds("extremes", &[a1], &[f1]);
let hardest = analysis.hardest_horizon().unwrap();
assert_eq!(hardest.horizon, 2);
let easiest = analysis.easiest_horizon().unwrap();
assert_eq!(easiest.horizon, 3); }
#[test]
fn error_growth_rate_positive() {
let actuals: &[f64] = &[10.0, 10.0];
let forecasts: &[f64] = &[11.0, 13.0];
let analysis = HorizonAnalysis::from_folds("growth", &[actuals], &[forecasts]);
let rate = analysis.error_growth_rate().unwrap();
assert!((rate - 2.0).abs() < 1e-10);
}
#[test]
fn error_growth_rate_zero() {
let actuals: &[f64] = &[10.0, 20.0, 30.0];
let forecasts: &[f64] = &[12.0, 22.0, 32.0];
let analysis = HorizonAnalysis::from_folds("constant", &[actuals], &[forecasts]);
let rate = analysis.error_growth_rate().unwrap();
assert!((rate - 0.0).abs() < 1e-10);
}
#[test]
fn mape_with_zero_actual() {
let actuals: &[f64] = &[0.0, 10.0];
let forecasts: &[f64] = &[5.0, 12.0];
let analysis = HorizonAnalysis::from_folds("zero_act", &[actuals], &[forecasts]);
assert!(analysis.steps[0].mape.is_none());
assert!(analysis.steps[1].mape.is_some());
}
#[test]
fn bias_overforecast() {
let a1: &[f64] = &[10.0];
let f1: &[f64] = &[15.0];
let a2: &[f64] = &[20.0];
let f2: &[f64] = &[25.0];
let analysis = HorizonAnalysis::from_folds("over", &[a1, a2], &[f1, f2]);
assert!(analysis.steps[0].bias > 0.0);
assert!((analysis.steps[0].bias - 5.0).abs() < 1e-10);
}
#[test]
fn bias_underforecast() {
let a1: &[f64] = &[10.0];
let f1: &[f64] = &[7.0];
let a2: &[f64] = &[20.0];
let f2: &[f64] = &[17.0];
let analysis = HorizonAnalysis::from_folds("under", &[a1, a2], &[f1, f2]);
assert!(analysis.steps[0].bias < 0.0);
assert!((analysis.steps[0].bias - (-3.0)).abs() < 1e-10);
}
#[test]
fn display_contains_table() {
let actuals: &[f64] = &[10.0, 20.0];
let forecasts: &[f64] = &[12.0, 22.0];
let analysis = HorizonAnalysis::from_folds("display_test", &[actuals], &[forecasts]);
let text = analysis.to_string();
assert!(text.contains("display_test"));
assert!(text.contains("Horizon"));
assert!(text.contains("1"));
assert!(text.contains("2"));
assert!(text.contains("MAE"));
assert!(text.contains("RMSE"));
let step_text = format!("{}", analysis.steps[0]);
assert!(step_text.contains("h=1"));
assert!(step_text.contains("MAE="));
assert!(step_text.contains("RMSE="));
}
}