use super::exponential_trend::ExponentialTrend;
use super::piecewise::PiecewiseLinearTrend;
use super::polynomial::PolynomialTrend;
use super::theilsen::TheilSenTrend;
use super::traits::{Recency, TrendComponent};
use crate::error::{ForecastError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TrendCriterion {
#[default]
AICc,
BIC,
Holdout,
}
#[derive(Debug, Clone)]
pub struct AutoTrendResult {
pub selected: String,
pub criterion: TrendCriterion,
pub scores: Vec<(String, f64)>,
}
#[derive(Debug, Clone)]
enum FittedTrendComponent {
Polynomial(PolynomialTrend),
Exponential(ExponentialTrend),
PiecewiseLinear(PiecewiseLinearTrend),
TheilSen(TheilSenTrend),
}
impl FittedTrendComponent {
fn fitted_trend(&self) -> &[f64] {
match self {
Self::Polynomial(c) => c.fitted_trend(),
Self::Exponential(c) => c.fitted_trend(),
Self::PiecewiseLinear(c) => c.fitted_trend(),
Self::TheilSen(c) => c.fitted_trend(),
}
}
fn predict_trend(&self, n_ahead: usize) -> Vec<f64> {
match self {
Self::Polynomial(c) => c.predict_trend(n_ahead),
Self::Exponential(c) => c.predict_trend(n_ahead),
Self::PiecewiseLinear(c) => c.predict_trend(n_ahead),
Self::TheilSen(c) => c.predict_trend(n_ahead),
}
}
fn trend_features(&self) -> Vec<(&str, f64)> {
match self {
Self::Polynomial(c) => c.trend_features(),
Self::Exponential(c) => c.trend_features(),
Self::PiecewiseLinear(c) => c.trend_features(),
Self::TheilSen(c) => c.trend_features(),
}
}
fn trend_name(&self) -> &str {
match self {
Self::Polynomial(c) => c.trend_name(),
Self::Exponential(c) => c.trend_name(),
Self::PiecewiseLinear(c) => c.trend_name(),
Self::TheilSen(c) => c.trend_name(),
}
}
fn n_params(&self) -> usize {
match self {
Self::Polynomial(c) => c.n_params(),
Self::Exponential(c) => c.n_params(),
Self::PiecewiseLinear(c) => c.n_params(),
Self::TheilSen(c) => c.n_params(),
}
}
}
#[derive(Debug, Clone)]
pub struct AutoTrend {
recency: Recency,
criterion: TrendCriterion,
winner: Option<FittedTrendComponent>,
result: Option<AutoTrendResult>,
}
impl AutoTrend {
pub fn new() -> Self {
Self {
recency: Recency::Fraction(0.3),
criterion: TrendCriterion::AICc,
winner: None,
result: None,
}
}
pub fn with_recency(mut self, recency: Recency) -> Self {
self.recency = recency;
self
}
pub fn with_criterion(mut self, criterion: TrendCriterion) -> Self {
self.criterion = criterion;
self
}
pub fn selection_result(&self) -> Option<&AutoTrendResult> {
self.result.as_ref()
}
}
impl Default for AutoTrend {
fn default() -> Self {
Self::new()
}
}
fn compute_aicc(values: &[f64], fitted: &[f64], k: usize) -> f64 {
let n = values.len();
if n == 0 {
return f64::INFINITY;
}
let nf = n as f64;
let kf = k as f64;
let ss_res: f64 = values
.iter()
.zip(fitted.iter())
.map(|(v, f)| (v - f).powi(2))
.sum();
let var = ss_res / nf;
if var <= 0.0 || !var.is_finite() {
return f64::INFINITY;
}
let ll = -0.5 * nf * (1.0 + var.ln() + (2.0 * std::f64::consts::PI).ln());
let denom = (nf - kf - 1.0).max(1.0);
-2.0 * ll + 2.0 * kf * nf / denom
}
fn compute_bic(values: &[f64], fitted: &[f64], k: usize) -> f64 {
let n = values.len();
if n == 0 {
return f64::INFINITY;
}
let nf = n as f64;
let kf = k as f64;
let ss_res: f64 = values
.iter()
.zip(fitted.iter())
.map(|(v, f)| (v - f).powi(2))
.sum();
let var = ss_res / nf;
if var <= 0.0 || !var.is_finite() {
return f64::INFINITY;
}
let ll = -0.5 * nf * (1.0 + var.ln() + (2.0 * std::f64::consts::PI).ln());
-2.0 * ll + kf * nf.ln()
}
fn compute_holdout_score(
values: &[f64],
fitted: &[f64],
predict_fn: &dyn Fn(usize) -> Vec<f64>,
) -> f64 {
let n = values.len();
let split = (n as f64 * 0.8).ceil() as usize;
if split >= n || split == 0 {
return f64::INFINITY;
}
let test_n = n - split;
let preds = predict_fn(test_n);
if preds.len() != test_n {
return f64::INFINITY;
}
let mse: f64 = values[split..]
.iter()
.zip(preds.iter())
.map(|(v, p)| (v - p).powi(2))
.sum::<f64>()
/ test_n as f64;
let _ = fitted;
mse
}
impl TrendComponent for AutoTrend {
fn fit_trend(&mut self, values: &[f64]) -> Result<()> {
if values.is_empty() {
return Err(ForecastError::EmptyData);
}
let all_positive = values.iter().all(|&v| v > 0.0);
let mut candidates: Vec<(String, Box<dyn FnOnce() -> Option<FittedTrendComponent>>)> =
Vec::new();
let recency = self.recency.clone();
{
let r = recency.clone();
candidates.push((
"Linear".to_string(),
Box::new(move || {
let mut c = PolynomialTrend::new(1).with_recency(r);
c.fit_trend(values).ok()?;
Some(FittedTrendComponent::Polynomial(c))
}),
));
}
{
let r = recency.clone();
candidates.push((
"Quadratic".to_string(),
Box::new(move || {
let mut c = PolynomialTrend::new(2).with_recency(r);
c.fit_trend(values).ok()?;
Some(FittedTrendComponent::Polynomial(c))
}),
));
}
if all_positive {
let r = recency.clone();
candidates.push((
"Exponential".to_string(),
Box::new(move || {
let mut c = ExponentialTrend::new().with_recency(r);
c.fit_trend(values).ok()?;
Some(FittedTrendComponent::Exponential(c))
}),
));
}
{
let r = recency.clone();
candidates.push((
"TheilSen".to_string(),
Box::new(move || {
let mut c = TheilSenTrend::new().with_recency(r);
c.fit_trend(values).ok()?;
Some(FittedTrendComponent::TheilSen(c))
}),
));
}
{
let r = recency.clone();
candidates.push((
"PiecewiseLinear".to_string(),
Box::new(move || {
let mut c = PiecewiseLinearTrend::new().with_recency(r);
c.fit_trend(values).ok()?;
Some(FittedTrendComponent::PiecewiseLinear(c))
}),
));
}
let mut scored: Vec<(String, f64, FittedTrendComponent)> = Vec::new();
for (name, fit_fn) in candidates {
if let Some(component) = fit_fn() {
let fitted = component.fitted_trend();
let k = component.n_params();
let score = match self.criterion {
TrendCriterion::AICc => compute_aicc(values, fitted, k),
TrendCriterion::BIC => compute_bic(values, fitted, k),
TrendCriterion::Holdout => {
let component_ref = &component;
compute_holdout_score(values, fitted, &|n_ahead| {
component_ref.predict_trend(n_ahead)
})
}
};
if score.is_finite() {
scored.push((name, score, component));
}
}
}
if scored.is_empty() {
return Err(ForecastError::ComputationError(
"AutoTrend: all candidates failed".to_string(),
));
}
scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let scores: Vec<(String, f64)> = scored.iter().map(|(n, s, _)| (n.clone(), *s)).collect();
let selected = scored[0].0.clone();
self.result = Some(AutoTrendResult {
selected: selected.clone(),
criterion: self.criterion,
scores,
});
let (_, _, winner) = scored.into_iter().next().unwrap();
self.winner = Some(winner);
Ok(())
}
fn fitted_trend(&self) -> &[f64] {
match &self.winner {
Some(w) => w.fitted_trend(),
None => &[],
}
}
fn predict_trend(&self, n_ahead: usize) -> Vec<f64> {
match &self.winner {
Some(w) => w.predict_trend(n_ahead),
None => vec![f64::NAN; n_ahead],
}
}
fn trend_features(&self) -> Vec<(&str, f64)> {
match &self.winner {
Some(w) => w.trend_features(),
None => Vec::new(),
}
}
fn trend_name(&self) -> &str {
match &self.winner {
Some(w) => w.trend_name(),
None => "auto_trend",
}
}
fn n_params(&self) -> usize {
match &self.winner {
Some(w) => w.n_params(),
None => 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
#[test]
fn selects_linear_for_linear_data() {
let values: Vec<f64> = (0..100).map(|i| 2.0 * i as f64 + 1.0).collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let result = auto.selection_result().unwrap();
assert!(
result.selected == "Linear" || result.selected == "TheilSen",
"expected Linear or TheilSen, got {}",
result.selected
);
}
#[test]
fn fitted_matches_data_for_linear() {
let values: Vec<f64> = (0..100).map(|i| 3.0 * i as f64 - 5.0).collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let fitted = auto.fitted_trend();
assert_eq!(fitted.len(), 100);
for (f, v) in fitted.iter().zip(values.iter()) {
assert_abs_diff_eq!(*f, *v, epsilon = 0.1);
}
}
#[test]
fn selects_exponential_for_exponential_data() {
let values: Vec<f64> = (0..100).map(|i| 2.0 * (0.05 * i as f64).exp()).collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let result = auto.selection_result().unwrap();
assert_eq!(
result.selected, "Exponential",
"scores: {:?}",
result.scores
);
}
#[test]
fn selects_quadratic_for_quadratic_data() {
let values: Vec<f64> = (0..100)
.map(|i| {
let t = i as f64;
0.01 * t * t + 0.5 * t + 10.0
})
.collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let result = auto.selection_result().unwrap();
assert_eq!(result.selected, "Quadratic", "scores: {:?}", result.scores);
}
#[test]
fn selection_result_has_all_candidates() {
let values: Vec<f64> = (0..100).map(|i| 2.0 * (0.05 * i as f64).exp()).collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let result = auto.selection_result().unwrap();
assert!(
result.scores.len() >= 4,
"got {} scores",
result.scores.len()
);
}
#[test]
fn bic_criterion_works() {
let values: Vec<f64> = (0..100).map(|i| 2.0 * i as f64 + 1.0).collect();
let mut auto = AutoTrend::new()
.with_criterion(TrendCriterion::BIC)
.with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let result = auto.selection_result().unwrap();
assert_eq!(result.criterion, TrendCriterion::BIC);
assert!(!result.scores.is_empty());
}
#[test]
fn holdout_criterion_works() {
let values: Vec<f64> = (0..100).map(|i| 2.0 * i as f64 + 1.0).collect();
let mut auto = AutoTrend::new()
.with_criterion(TrendCriterion::Holdout)
.with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let result = auto.selection_result().unwrap();
assert_eq!(result.criterion, TrendCriterion::Holdout);
}
#[test]
fn predict_delegates_to_winner() {
let values: Vec<f64> = (0..100).map(|i| 2.0 * i as f64 + 1.0).collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let forecast = auto.predict_trend(10);
assert_eq!(forecast.len(), 10);
assert!(forecast[0] > values.last().unwrap() - 1.0);
}
#[test]
fn features_delegate_to_winner() {
let values: Vec<f64> = (0..100).map(|i| 2.0 * i as f64 + 1.0).collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let features = auto.trend_features();
assert!(!features.is_empty());
}
#[test]
fn empty_data_error() {
let mut auto = AutoTrend::new();
let result = auto.fit_trend(&[]);
assert!(matches!(result, Err(ForecastError::EmptyData)));
}
#[test]
fn unfitted_returns_defaults() {
let auto = AutoTrend::new();
assert!(auto.fitted_trend().is_empty());
assert!(auto.selection_result().is_none());
assert_eq!(auto.trend_name(), "auto_trend");
assert_eq!(auto.n_params(), 0);
}
#[test]
fn scores_are_sorted_ascending() {
let values: Vec<f64> = (0..100).map(|i| 2.0 * i as f64 + 1.0).collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let result = auto.selection_result().unwrap();
for w in result.scores.windows(2) {
assert!(
w[0].1 <= w[1].1,
"scores not sorted: {} > {}",
w[0].1,
w[1].1
);
}
}
#[test]
fn default_is_unfitted() {
let auto = AutoTrend::default();
assert!(auto.winner.is_none());
assert!(auto.result.is_none());
}
#[test]
fn negative_data_excludes_exponential() {
let values: Vec<f64> = (0..100).map(|i| -(i as f64) - 1.0).collect();
let mut auto = AutoTrend::new().with_recency(Recency::Full);
auto.fit_trend(&values).unwrap();
let result = auto.selection_result().unwrap();
assert!(
!result.scores.iter().any(|(n, _)| n == "Exponential"),
"exponential should not be a candidate for negative data"
);
}
#[test]
fn with_recency_builder() {
let auto = AutoTrend::new().with_recency(Recency::Window(50));
assert_eq!(auto.recency, Recency::Window(50));
}
#[test]
fn with_criterion_builder() {
let auto = AutoTrend::new().with_criterion(TrendCriterion::BIC);
assert_eq!(auto.criterion, TrendCriterion::BIC);
}
}