use crate::forecast::cross_validation::{CrossValidationMode, CrossValidator};
use crate::forecast::error::ForecastError;
use crate::forecast::result::{ForecastResult, ModelType};
use crate::forecast::traits::ForecastModel;
use crate::forecast::{
ArimaModel, AutoArimaOptions, HoltLinearModel, HoltWintersModel, LinearRegressionModel,
SarimaModel, SearchStrategy, SesModel, auto_arima, select_differencing_order,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SelectionMetric {
#[default]
Rmse,
Mae,
Smape,
}
impl std::fmt::Display for SelectionMetric {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Rmse => "RMSE",
Self::Mae => "MAE",
Self::Smape => "sMAPE",
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct AutoForecastOptions {
pub period: Option<usize>,
pub max_period: usize,
pub folds: usize,
pub metric: SelectionMetric,
pub max_order: usize,
}
impl Default for AutoForecastOptions {
fn default() -> Self {
Self {
period: None,
max_period: 288,
folds: 3,
metric: SelectionMetric::default(),
max_order: 2,
}
}
}
#[derive(Debug, Clone)]
pub struct CandidateScore {
pub label: String,
pub model_type: ModelType,
pub score: f64,
pub rmse: f64,
pub mae: f64,
pub smape: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct ModelSelection {
pub label: String,
pub model_type: ModelType,
pub score: f64,
pub metric: SelectionMetric,
pub candidates: Vec<CandidateScore>,
pub rejected: Vec<(String, String)>,
pub period: Option<usize>,
pub differencing_order: usize,
pub folds: usize,
pub min_train_size: usize,
}
#[derive(Debug, Clone)]
pub struct AutoForecast {
pub result: ForecastResult,
pub selection: ModelSelection,
}
type Factory = Box<dyn Fn() -> Box<dyn ForecastModel> + Send + Sync>;
struct Candidate {
label: String,
model_type: ModelType,
factory: Factory,
}
fn candidates(
timestamps: &[i64],
values: &[f64],
period: Option<usize>,
min_train: usize,
options: &AutoForecastOptions,
rejected: &mut Vec<(String, String)>,
) -> Vec<Candidate> {
let n = values.len();
let mut out: Vec<Candidate> = Vec::new();
out.push(Candidate {
label: "SES".into(),
model_type: ModelType::Ses,
factory: Box::new(|| Box::new(SesModel::new(None))),
});
if n >= 10 {
out.push(Candidate {
label: "Holt linear".into(),
model_type: ModelType::HoltLinear,
factory: Box::new(|| Box::new(HoltLinearModel::new(None, None, 1.0))),
});
out.push(Candidate {
label: "Holt damped (φ=0.9)".into(),
model_type: ModelType::HoltLinear,
factory: Box::new(|| Box::new(HoltLinearModel::new(None, None, 0.9))),
});
out.push(Candidate {
label: "Linear regression".into(),
model_type: ModelType::LinearRegression,
factory: Box::new(|| Box::new(LinearRegressionModel::new())),
});
} else {
rejected.push((
"Holt / linear regression".into(),
format!("needs 10 observations, got {n}"),
));
}
if min_train >= 30 {
let opts = AutoArimaOptions {
max_evals: None,
strategy: SearchStrategy::Stepwise,
};
match auto_arima(
×tamps[..min_train],
&values[..min_train],
options.max_order,
options.max_order,
options.max_order,
Some(&opts),
) {
Ok(found) => {
let (p, d, q) = found.order;
out.push(Candidate {
label: format!("ARIMA({p},{d},{q})"),
model_type: ModelType::Arima,
factory: Box::new(move || Box::new(ArimaModel::new(p, d, q))),
});
if let Some(m) = period {
let sd = crate::forecast::select_seasonal_differencing_order(values, m, 1);
for (sp, sq) in [(1usize, 0usize), (0, 1)] {
let needed = (p.max(q) + sp.max(sq) * m) + 2 * d + 2 * sd * m + 10;
if min_train < needed {
rejected.push((
format!("SARIMA({p},{d},{q})({sp},{sd},{sq})[{m}]"),
format!("needs {needed} observations per fold, got {min_train}"),
));
continue;
}
out.push(Candidate {
label: format!("SARIMA({p},{d},{q})({sp},{sd},{sq})[{m}]"),
model_type: ModelType::Sarima,
factory: Box::new(move || {
Box::new(SarimaModel::new(p, d, q, sp, sd, sq, m))
}),
});
}
}
}
Err(e) => rejected.push(("ARIMA".into(), e.to_string())),
}
} else {
rejected.push((
"ARIMA / SARIMA".into(),
format!("needs a 30-observation training window, got {min_train}"),
));
}
match period {
Some(m) if min_train >= 2 * m => {
out.push(Candidate {
label: format!("Holt-Winters additive[{m}]"),
model_type: ModelType::HoltWinters,
factory: Box::new(move || {
Box::new(HoltWintersModel::new(None, None, None, Some(m), false))
}),
});
if values.iter().all(|v| *v > 0.0) {
out.push(Candidate {
label: format!("Holt-Winters multiplicative[{m}]"),
model_type: ModelType::HoltWinters,
factory: Box::new(move || {
Box::new(HoltWintersModel::new(None, None, None, Some(m), true))
}),
});
} else {
rejected.push((
"Holt-Winters multiplicative".into(),
"series contains a value that is not strictly positive".into(),
));
}
}
Some(m) => rejected.push((
format!("Holt-Winters[{m}]"),
format!("needs two seasons ({}) per fold, got {min_train}", 2 * m),
)),
None => rejected.push((
"Holt-Winters / SARIMA".into(),
"no seasonal period detected".into(),
)),
}
out
}
pub fn select_model(
timestamps: &[i64],
values: &[f64],
horizon: usize,
options: &AutoForecastOptions,
) -> Result<ModelSelection, ForecastError> {
crate::forecast::util::validate_input(timestamps, values)?;
if horizon == 0 {
return Err(ForecastError::InvalidParams {
name: "horizon",
value: "0".into(),
reason: "a forecast needs at least one step",
});
}
let n = values.len();
let min_required = horizon * 2 + 4;
if n < min_required {
return Err(ForecastError::InsufficientData {
min: min_required,
got: n,
});
}
let folds = options.folds.max(1);
let min_train = n
.saturating_sub(horizon * folds)
.max(n / 2)
.min(n - horizon);
let period = options
.period
.filter(|m| *m >= 2)
.or_else(|| crate::preprocess::detect_period(values, options.max_period.min(n / 2)));
let differencing_order = select_differencing_order(values, options.max_order.max(2));
let mut rejected = Vec::new();
let set = candidates(
timestamps,
values,
period,
min_train,
options,
&mut rejected,
);
let cv = CrossValidator::new(
CrossValidationMode::ExpandingWindow,
min_train,
horizon,
folds,
);
let mut scored: Vec<CandidateScore> = Vec::new();
for candidate in &set {
match cv.evaluate(timestamps, values, || (candidate.factory)()) {
Ok(r) => {
let score = match options.metric {
SelectionMetric::Rmse => r.mean_rmse,
SelectionMetric::Mae => r.mean_mae,
SelectionMetric::Smape => r.mean_smape.unwrap_or(r.mean_rmse),
};
if score.is_finite() {
scored.push(CandidateScore {
label: candidate.label.clone(),
model_type: candidate.model_type.clone(),
score,
rmse: r.mean_rmse,
mae: r.mean_mae,
smape: r.mean_smape,
});
} else {
rejected.push((candidate.label.clone(), "score was not finite".into()));
}
}
Err(e) => rejected.push((candidate.label.clone(), e.to_string())),
}
}
scored.sort_by(|a, b| a.score.total_cmp(&b.score));
let best = scored.first().cloned().ok_or_else(|| {
ForecastError::NumericalInstability(
"no forecast model could be cross-validated on this series".into(),
)
})?;
metrics::counter!("chronix_forecast_model_selected_total",
"model" => format!("{:?}", best.model_type))
.increment(1);
Ok(ModelSelection {
label: best.label,
model_type: best.model_type,
score: best.score,
metric: options.metric,
candidates: scored,
rejected,
period,
differencing_order,
folds,
min_train_size: min_train,
})
}
pub fn auto_forecast(
timestamps: &[i64],
values: &[f64],
horizon: usize,
options: &AutoForecastOptions,
) -> Result<AutoForecast, ForecastError> {
let selection = select_model(timestamps, values, horizon, options)?;
let mut rejected = Vec::new();
let set = candidates(
timestamps,
values,
selection.period,
selection.min_train_size,
options,
&mut rejected,
);
let winner = set
.iter()
.find(|c| c.label == selection.label)
.ok_or_else(|| {
ForecastError::NumericalInstability(format!(
"selected model {} could not be rebuilt",
selection.label
))
})?;
let mut model = (winner.factory)();
model.fit(timestamps, values)?;
let result = model.predict(horizon)?;
Ok(AutoForecast { result, selection })
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn ts(n: usize) -> Vec<i64> {
(0..n as i64).map(|i| i * 1_000_000_000).collect()
}
fn noise(seed: u64, n: usize, amp: f64) -> Vec<f64> {
let mut s = seed;
(0..n)
.map(|_| {
s = s
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((s >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 2.0 * amp
})
.collect()
}
#[test]
fn a_seasonal_series_selects_a_seasonal_model() {
let m = 24;
let n = 240;
let values: Vec<f64> = (0..n)
.map(|i| 50.0 + 10.0 * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
.zip(noise(1, n, 0.2))
.map(|(a, b)| a + b)
.collect();
let opts = AutoForecastOptions {
period: Some(m),
..Default::default()
};
let chosen = select_model(&ts(n), &values, 12, &opts).unwrap();
assert!(
matches!(
chosen.model_type,
ModelType::HoltWinters | ModelType::Sarima
),
"picked {} ({:?}); candidates: {:?}",
chosen.label,
chosen.model_type,
chosen
.candidates
.iter()
.map(|c| (&c.label, c.score))
.collect::<Vec<_>>()
);
assert_eq!(chosen.period, Some(m));
}
#[test]
fn a_trending_series_beats_ses_with_a_trended_model() {
let n = 200;
let values: Vec<f64> = (0..n)
.map(|i| 5.0 + i as f64 * 0.7)
.zip(noise(2, n, 0.5))
.map(|(a, b)| a + b)
.collect();
let chosen = select_model(&ts(n), &values, 10, &AutoForecastOptions::default()).unwrap();
let ses = chosen
.candidates
.iter()
.find(|c| c.label == "SES")
.expect("SES is always a candidate");
assert!(
chosen.score < ses.score,
"SES won on a trending series: {:?}",
chosen
.candidates
.iter()
.map(|c| (&c.label, c.score))
.collect::<Vec<_>>()
);
assert_ne!(chosen.model_type, ModelType::Ses);
}
#[test]
fn flat_noise_selects_a_flat_model() {
let n = 200;
let values: Vec<f64> = noise(3, n, 1.0).iter().map(|v| v + 20.0).collect();
let chosen = select_model(&ts(n), &values, 8, &AutoForecastOptions::default()).unwrap();
assert_eq!(chosen.differencing_order, 0, "stationary noise differenced");
assert!(chosen.score.is_finite());
let ses = chosen.candidates.iter().find(|c| c.label == "SES").unwrap();
assert!(chosen.score <= ses.score + 1e-9);
}
#[test]
fn every_candidate_is_scored_or_explained() {
let n = 120;
let values: Vec<f64> = noise(4, n, 1.0).iter().map(|v| v + 3.0).collect();
let chosen = select_model(&ts(n), &values, 6, &AutoForecastOptions::default()).unwrap();
assert!(!chosen.candidates.is_empty());
for w in chosen.candidates.windows(2) {
assert!(w[0].score <= w[1].score, "candidates not sorted");
}
for (label, reason) in &chosen.rejected {
assert!(!reason.is_empty(), "{label} rejected with no reason");
}
}
#[test]
fn auto_forecast_returns_the_winner_fitted_on_everything() {
let m = 12;
let n = 180;
let values: Vec<f64> = (0..n)
.map(|i| 20.0 + 4.0 * (2.0 * std::f64::consts::PI * (i % m) as f64 / m as f64).sin())
.collect();
let opts = AutoForecastOptions {
period: Some(m),
..Default::default()
};
let out = auto_forecast(&ts(n), &values, m, &opts).unwrap();
assert_eq!(out.result.values.len(), m);
assert_eq!(out.result.timestamps.len(), m);
assert!(out.result.timestamps[0] > ts(n)[n - 1]);
assert_eq!(out.selection.label, out.selection.candidates[0].label);
for v in &out.result.values {
assert!(v.is_finite(), "forecast contained {v}");
}
}
#[test]
fn a_horizon_of_zero_is_rejected() {
let values: Vec<f64> = (0..50).map(f64::from).collect();
assert!(select_model(&ts(50), &values, 0, &AutoForecastOptions::default()).is_err());
}
#[test]
fn too_short_a_series_is_rejected() {
let values: Vec<f64> = (0..10).map(f64::from).collect();
assert!(select_model(&ts(10), &values, 8, &AutoForecastOptions::default()).is_err());
}
#[test]
fn the_metric_can_change_the_winner_ordering() {
let n = 200;
let values: Vec<f64> = (0..n)
.map(|i| 100.0 + (i as f64 * 0.05).sin() * 10.0)
.zip(noise(5, n, 1.0))
.map(|(a, b)| a + b)
.collect();
for metric in [
SelectionMetric::Rmse,
SelectionMetric::Mae,
SelectionMetric::Smape,
] {
let opts = AutoForecastOptions {
metric,
..Default::default()
};
let chosen = select_model(&ts(n), &values, 10, &opts).unwrap();
assert_eq!(chosen.metric, metric);
assert!(
chosen.score.is_finite(),
"{metric} produced {}",
chosen.score
);
for c in &chosen.candidates {
assert!(c.rmse.is_finite() && c.mae.is_finite());
}
}
}
}