use crate::error::{NumRs2Error, Result};
use scirs2_core::ndarray::{s, Array1, ArrayView1};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TrendComponent {
None,
Additive,
Damped,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SeasonalComponent {
None,
Additive,
Multiplicative,
}
#[derive(Debug, Clone)]
pub struct OptimizationConfig {
pub grid_resolution: usize,
pub param_min: f64,
pub param_max: f64,
pub refinement_iterations: usize,
}
impl Default for OptimizationConfig {
fn default() -> Self {
Self {
grid_resolution: 20,
param_min: 0.01,
param_max: 0.99,
refinement_iterations: 2,
}
}
}
#[derive(Debug, Clone)]
pub struct ExponentialSmoothingResult {
pub fitted: Array1<f64>,
pub residuals: Array1<f64>,
pub level: Array1<f64>,
pub trend: Option<Array1<f64>>,
pub seasonal: Option<Array1<f64>>,
pub sse: f64,
pub mse: f64,
pub n_obs: usize,
pub n_params: usize,
}
#[derive(Debug, Clone)]
pub struct ExponentialSmoothingForecast {
pub point: Array1<f64>,
pub lower: Option<Array1<f64>>,
pub upper: Option<Array1<f64>>,
pub confidence_level: f64,
}
#[derive(Debug, Clone)]
pub struct InformationCriteria {
pub aic: f64,
pub aicc: f64,
pub bic: f64,
}
#[derive(Debug, Clone)]
pub struct ExponentialSmoothing {
alpha: f64,
beta: Option<f64>,
gamma: Option<f64>,
phi: Option<f64>,
trend: TrendComponent,
seasonal: SeasonalComponent,
period: Option<usize>,
}
impl ExponentialSmoothing {
pub fn ses(alpha: f64) -> Result<Self> {
validate_param(alpha, "alpha")?;
Ok(Self {
alpha,
beta: None,
gamma: None,
phi: None,
trend: TrendComponent::None,
seasonal: SeasonalComponent::None,
period: None,
})
}
pub fn holt(alpha: f64, beta: f64) -> Result<Self> {
validate_param(alpha, "alpha")?;
validate_param(beta, "beta")?;
Ok(Self {
alpha,
beta: Some(beta),
gamma: None,
phi: None,
trend: TrendComponent::Additive,
seasonal: SeasonalComponent::None,
period: None,
})
}
pub fn damped_trend(alpha: f64, beta: f64, phi: f64) -> Result<Self> {
validate_param(alpha, "alpha")?;
validate_param(beta, "beta")?;
validate_param(phi, "phi")?;
Ok(Self {
alpha,
beta: Some(beta),
gamma: None,
phi: Some(phi),
trend: TrendComponent::Damped,
seasonal: SeasonalComponent::None,
period: None,
})
}
pub fn holt_winters(
alpha: f64,
beta: f64,
gamma: f64,
period: usize,
seasonal: SeasonalComponent,
) -> Result<Self> {
validate_param(alpha, "alpha")?;
validate_param(beta, "beta")?;
validate_param(gamma, "gamma")?;
if period < 2 {
return Err(NumRs2Error::ValueError(
"Seasonal period must be at least 2".to_string(),
));
}
if seasonal == SeasonalComponent::None {
return Err(NumRs2Error::ValueError(
"Use holt() for non-seasonal models".to_string(),
));
}
Ok(Self {
alpha,
beta: Some(beta),
gamma: Some(gamma),
phi: None,
trend: TrendComponent::Additive,
seasonal,
period: Some(period),
})
}
pub fn damped_holt_winters(
alpha: f64,
beta: f64,
gamma: f64,
phi: f64,
period: usize,
seasonal: SeasonalComponent,
) -> Result<Self> {
validate_param(alpha, "alpha")?;
validate_param(beta, "beta")?;
validate_param(gamma, "gamma")?;
validate_param(phi, "phi")?;
if period < 2 {
return Err(NumRs2Error::ValueError(
"Seasonal period must be at least 2".to_string(),
));
}
if seasonal == SeasonalComponent::None {
return Err(NumRs2Error::ValueError(
"Use damped_trend() for non-seasonal models".to_string(),
));
}
Ok(Self {
alpha,
beta: Some(beta),
gamma: Some(gamma),
phi: Some(phi),
trend: TrendComponent::Damped,
seasonal,
period: Some(period),
})
}
pub fn custom(
alpha: f64,
beta: Option<f64>,
gamma: Option<f64>,
phi: Option<f64>,
trend: TrendComponent,
seasonal: SeasonalComponent,
period: Option<usize>,
) -> Result<Self> {
validate_param(alpha, "alpha")?;
if let Some(b) = beta {
validate_param(b, "beta")?;
}
if let Some(g) = gamma {
validate_param(g, "gamma")?;
}
if let Some(p) = phi {
validate_param(p, "phi")?;
}
if trend != TrendComponent::None && beta.is_none() {
return Err(NumRs2Error::ValueError(
"beta is required when trend is not None".to_string(),
));
}
if seasonal != SeasonalComponent::None && gamma.is_none() {
return Err(NumRs2Error::ValueError(
"gamma is required for seasonal models".to_string(),
));
}
if seasonal != SeasonalComponent::None {
match period {
Some(p) if p < 2 => {
return Err(NumRs2Error::ValueError(
"Seasonal period must be at least 2".to_string(),
));
}
None => {
return Err(NumRs2Error::ValueError(
"period is required for seasonal models".to_string(),
));
}
_ => {}
}
}
if trend == TrendComponent::Damped && phi.is_none() {
return Err(NumRs2Error::ValueError(
"phi is required for damped trend models".to_string(),
));
}
Ok(Self {
alpha,
beta,
gamma,
phi,
trend,
seasonal,
period,
})
}
pub fn fit(&self, data: &ArrayView1<f64>) -> Result<ExponentialSmoothingResult> {
let n = data.len();
self.validate_data_length(n)?;
let m = self.period.unwrap_or(1);
let (init_level, init_trend, init_seasonal) = self.initialize(data)?;
let mut level_hist = Array1::zeros(n);
let mut trend_hist = if self.trend != TrendComponent::None {
Some(Array1::zeros(n))
} else {
None
};
let mut fitted = Array1::zeros(n);
let mut level = init_level;
let mut trend = init_trend;
let mut seasonal = init_seasonal.clone();
let phi = self.phi.unwrap_or(1.0);
let beta = self.beta.unwrap_or(0.0);
let gamma = self.gamma.unwrap_or(0.0);
for t in 0..n {
let season_idx = t % m;
let ft = self.compute_fitted_value(level, trend, &seasonal, season_idx, phi);
fitted[t] = ft;
let prev_level = level;
match self.seasonal {
SeasonalComponent::Additive => {
level = self.alpha * (data[t] - seasonal[season_idx])
+ (1.0 - self.alpha) * (prev_level + phi * trend);
}
SeasonalComponent::Multiplicative => {
let s_val = seasonal[season_idx].max(1e-10);
level = self.alpha * (data[t] / s_val)
+ (1.0 - self.alpha) * (prev_level + phi * trend);
}
SeasonalComponent::None => {
level = self.alpha * data[t] + (1.0 - self.alpha) * (prev_level + phi * trend);
}
}
if self.trend != TrendComponent::None {
trend = beta * (level - prev_level) + (1.0 - beta) * phi * trend;
}
match self.seasonal {
SeasonalComponent::Additive => {
seasonal[season_idx] =
gamma * (data[t] - level) + (1.0 - gamma) * seasonal[season_idx];
}
SeasonalComponent::Multiplicative => {
let l_val = level.max(1e-10);
seasonal[season_idx] =
gamma * (data[t] / l_val) + (1.0 - gamma) * seasonal[season_idx];
}
SeasonalComponent::None => {}
}
level_hist[t] = level;
if let Some(ref mut th) = trend_hist {
th[t] = trend;
}
}
let residuals = data - &fitted;
let sse: f64 = residuals.iter().map(|&r| r * r).sum();
let mse = sse / n as f64;
let n_params = self.count_parameters();
Ok(ExponentialSmoothingResult {
fitted,
residuals,
level: level_hist,
trend: trend_hist,
seasonal: if self.seasonal != SeasonalComponent::None {
Some(seasonal)
} else {
None
},
sse,
mse,
n_obs: n,
n_params,
})
}
pub fn forecast(
&self,
data: &ArrayView1<f64>,
h: usize,
confidence_level: f64,
) -> Result<ExponentialSmoothingForecast> {
if h == 0 {
return Err(NumRs2Error::ValueError(
"Forecast horizon must be at least 1".to_string(),
));
}
if confidence_level <= 0.0 || confidence_level >= 1.0 {
return Err(NumRs2Error::ValueError(
"Confidence level must be in (0, 1)".to_string(),
));
}
let result = self.fit(data)?;
let n = data.len();
let m = self.period.unwrap_or(1);
let final_level = result.level[n - 1];
let final_trend = result.trend.as_ref().map_or(0.0, |t| t[n - 1]);
let phi = self.phi.unwrap_or(1.0);
let mut point = Array1::zeros(h);
for i in 0..h {
let steps = (i + 1) as f64;
let trend_contrib = if self.trend == TrendComponent::None {
0.0
} else {
damped_trend_sum(phi, i + 1) * final_trend
};
let season_idx = (n + i) % m;
match self.seasonal {
SeasonalComponent::Additive => {
let s_val = result.seasonal.as_ref().map_or(0.0, |s| s[season_idx]);
point[i] = final_level + trend_contrib + s_val;
}
SeasonalComponent::Multiplicative => {
let s_val = result.seasonal.as_ref().map_or(1.0, |s| s[season_idx]);
point[i] = (final_level + trend_contrib) * s_val;
}
SeasonalComponent::None => {
point[i] = final_level + trend_contrib;
}
}
}
let (lower, upper) =
self.compute_prediction_intervals(&result, &point, h, confidence_level)?;
Ok(ExponentialSmoothingForecast {
point,
lower: Some(lower),
upper: Some(upper),
confidence_level,
})
}
pub fn information_criteria(
&self,
result: &ExponentialSmoothingResult,
) -> Result<InformationCriteria> {
let n = result.n_obs as f64;
let k = result.n_params as f64;
if n <= k + 1.0 {
return Err(NumRs2Error::ValueError(
"Not enough observations relative to parameters for information criteria"
.to_string(),
));
}
let sigma_sq = result.sse / n;
if sigma_sq <= 0.0 {
return Err(NumRs2Error::ComputationError(
"Zero or negative variance in residuals".to_string(),
));
}
let log_likelihood = -0.5 * n * (2.0 * std::f64::consts::PI * sigma_sq).ln() - 0.5 * n;
let aic = -2.0 * log_likelihood + 2.0 * k;
let aicc = aic + 2.0 * k * (k + 1.0) / (n - k - 1.0);
let bic = -2.0 * log_likelihood + k * n.ln();
Ok(InformationCriteria { aic, aicc, bic })
}
fn validate_data_length(&self, n: usize) -> Result<()> {
let min_len = match (&self.trend, &self.seasonal) {
(TrendComponent::None, SeasonalComponent::None) => 2,
(_, SeasonalComponent::None) => 3,
(_, _) => {
let m = self.period.unwrap_or(2);
2 * m
}
};
if n < min_len {
return Err(NumRs2Error::ValueError(format!(
"Need at least {} observations for this model, got {}",
min_len, n
)));
}
Ok(())
}
fn initialize(&self, data: &ArrayView1<f64>) -> Result<(f64, f64, Array1<f64>)> {
let n = data.len();
let m = self.period.unwrap_or(1);
let level = if self.seasonal != SeasonalComponent::None && n >= m {
data.slice(s![0..m]).iter().sum::<f64>() / m as f64
} else {
data[0]
};
let trend = if self.trend != TrendComponent::None {
if self.seasonal != SeasonalComponent::None && n >= 2 * m {
let first_mean = data.slice(s![0..m]).iter().sum::<f64>() / m as f64;
let second_mean = data.slice(s![m..2 * m]).iter().sum::<f64>() / m as f64;
(second_mean - first_mean) / m as f64
} else if n >= 2 {
data[1] - data[0]
} else {
0.0
}
} else {
0.0
};
let seasonal = if self.seasonal != SeasonalComponent::None {
let mut s = Array1::zeros(m);
let n_cycles = (n / m).min(3); let n_cycles = n_cycles.max(1);
for j in 0..m {
let mut sum = 0.0;
let mut count = 0;
for cycle in 0..n_cycles {
let idx = cycle * m + j;
if idx < n {
match self.seasonal {
SeasonalComponent::Additive => {
sum += data[idx] - level;
}
SeasonalComponent::Multiplicative => {
if level.abs() > 1e-10 {
sum += data[idx] / level;
} else {
sum += 1.0;
}
}
SeasonalComponent::None => {}
}
count += 1;
}
}
s[j] = if count > 0 { sum / count as f64 } else { 0.0 };
}
match self.seasonal {
SeasonalComponent::Additive => {
let mean = s.iter().sum::<f64>() / m as f64;
s -= mean;
}
SeasonalComponent::Multiplicative => {
let mean = s.iter().sum::<f64>() / m as f64;
if mean.abs() > 1e-10 {
s /= mean;
}
}
SeasonalComponent::None => {}
}
s
} else {
Array1::zeros(m)
};
Ok((level, trend, seasonal))
}
fn compute_fitted_value(
&self,
level: f64,
trend: f64,
seasonal: &Array1<f64>,
season_idx: usize,
phi: f64,
) -> f64 {
let trend_contrib = if self.trend == TrendComponent::None {
0.0
} else {
phi * trend
};
match self.seasonal {
SeasonalComponent::Additive => level + trend_contrib + seasonal[season_idx],
SeasonalComponent::Multiplicative => (level + trend_contrib) * seasonal[season_idx],
SeasonalComponent::None => level + trend_contrib,
}
}
fn compute_prediction_intervals(
&self,
result: &ExponentialSmoothingResult,
_point: &Array1<f64>,
h: usize,
confidence_level: f64,
) -> Result<(Array1<f64>, Array1<f64>)> {
let sigma_sq = result.mse;
let z = quantile_normal((1.0 + confidence_level) / 2.0);
let phi = self.phi.unwrap_or(1.0);
let alpha = self.alpha;
let mut lower = Array1::zeros(h);
let mut upper = Array1::zeros(h);
let n = result.n_obs;
let m = self.period.unwrap_or(1);
for i in 0..h {
let j = (i + 1) as f64;
let var_multiplier = match (&self.trend, &self.seasonal) {
(TrendComponent::None, SeasonalComponent::None) => {
1.0 + (j - 1.0) * alpha * alpha
}
(TrendComponent::Additive, SeasonalComponent::None) => {
let beta = self.beta.unwrap_or(0.0);
1.0 + (j - 1.0)
* (alpha * alpha
+ alpha * beta * j
+ beta * beta * j * (2.0 * j - 1.0) / 6.0)
}
(TrendComponent::Damped, SeasonalComponent::None) => {
let sum_phi = damped_trend_sum(phi, i + 1);
1.0 + (j - 1.0) * alpha * alpha * (1.0 + sum_phi / j)
}
(_, SeasonalComponent::Additive) => {
let k = ((i / m) + 1) as f64;
1.0 + (j - 1.0) * alpha * alpha + k * self.gamma.unwrap_or(0.0).powi(2)
}
(_, SeasonalComponent::Multiplicative) => {
let season_idx = (n + i) % m;
let s_val = result
.seasonal
.as_ref()
.map_or(1.0, |s| s[season_idx])
.powi(2);
s_val * (1.0 + (j - 1.0) * alpha * alpha)
}
};
let se = (sigma_sq * var_multiplier).sqrt();
let point_i = _point[i];
lower[i] = point_i - z * se;
upper[i] = point_i + z * se;
}
Ok((lower, upper))
}
fn count_parameters(&self) -> usize {
let mut k = 1; if self.beta.is_some() {
k += 1; }
if self.gamma.is_some() {
k += 1; }
if self.phi.is_some() {
k += 1; }
k += 1; if self.trend != TrendComponent::None {
k += 1; }
if let Some(m) = self.period {
if self.seasonal != SeasonalComponent::None {
k += m - 1; }
}
k += 1; k
}
}
pub fn optimize_parameters(
data: &ArrayView1<f64>,
trend: TrendComponent,
seasonal: SeasonalComponent,
period: Option<usize>,
config: &OptimizationConfig,
) -> Result<(ExponentialSmoothing, ExponentialSmoothingResult)> {
let n_grid = config.grid_resolution;
let lo = config.param_min;
let hi = config.param_max;
let mut best_sse = f64::INFINITY;
let mut best_params: (f64, Option<f64>, Option<f64>, Option<f64>) = (0.5, None, None, None);
let has_trend = trend != TrendComponent::None;
let has_season = seasonal != SeasonalComponent::None;
let is_damped = trend == TrendComponent::Damped;
let alpha_grid: Vec<f64> = (0..n_grid)
.map(|i| lo + (hi - lo) * i as f64 / (n_grid - 1).max(1) as f64)
.collect();
let beta_grid: Vec<f64> = if has_trend {
(0..n_grid)
.map(|i| lo + (hi - lo) * i as f64 / (n_grid - 1).max(1) as f64)
.collect()
} else {
vec![0.0]
};
let gamma_grid: Vec<f64> = if has_season {
(0..n_grid)
.map(|i| lo + (hi - lo) * i as f64 / (n_grid - 1).max(1) as f64)
.collect()
} else {
vec![0.0]
};
let phi_grid: Vec<f64> = if is_damped {
let phi_lo = 0.80_f64.max(lo);
let phi_hi = 0.98_f64.min(hi);
let n_phi = (n_grid / 2).max(5);
(0..n_phi)
.map(|i| phi_lo + (phi_hi - phi_lo) * i as f64 / (n_phi - 1).max(1) as f64)
.collect()
} else {
vec![1.0]
};
for &a in &alpha_grid {
for &b in &beta_grid {
for &g in &gamma_grid {
for &p in &phi_grid {
let model_result = build_and_fit(
a,
if has_trend { Some(b) } else { None },
if has_season { Some(g) } else { None },
if is_damped { Some(p) } else { None },
trend,
seasonal,
period,
data,
);
if let Ok(res) = model_result {
if res.sse < best_sse && res.sse.is_finite() {
best_sse = res.sse;
best_params = (
a,
if has_trend { Some(b) } else { None },
if has_season { Some(g) } else { None },
if is_damped { Some(p) } else { None },
);
}
}
}
}
}
}
let mut current_best = best_params;
let mut current_sse = best_sse;
for iter in 0..config.refinement_iterations {
let scale = 1.0 / ((iter + 1) as f64 * n_grid as f64);
let half_range = (hi - lo) * scale;
let n_refine = 11_usize;
let refine_grid = |center: f64| -> Vec<f64> {
let r_lo = (center - half_range).max(lo);
let r_hi = (center + half_range).min(hi);
(0..n_refine)
.map(|i| r_lo + (r_hi - r_lo) * i as f64 / (n_refine - 1).max(1) as f64)
.collect()
};
let a_refine = refine_grid(current_best.0);
let b_refine = current_best.1.map_or_else(|| vec![0.0], &refine_grid);
let g_refine = current_best.2.map_or_else(|| vec![0.0], &refine_grid);
let p_refine = if is_damped {
let center = current_best.3.unwrap_or(0.9);
let pr_lo = (center - half_range).max(0.80);
let pr_hi = (center + half_range).min(0.98);
(0..n_refine)
.map(|i| pr_lo + (pr_hi - pr_lo) * i as f64 / (n_refine - 1).max(1) as f64)
.collect()
} else {
vec![1.0]
};
for &a in &a_refine {
for &b in &b_refine {
for &g in &g_refine {
for &p in &p_refine {
let model_result = build_and_fit(
a,
if has_trend { Some(b) } else { None },
if has_season { Some(g) } else { None },
if is_damped { Some(p) } else { None },
trend,
seasonal,
period,
data,
);
if let Ok(res) = model_result {
if res.sse < current_sse && res.sse.is_finite() {
current_sse = res.sse;
current_best = (
a,
if has_trend { Some(b) } else { None },
if has_season { Some(g) } else { None },
if is_damped { Some(p) } else { None },
);
}
}
}
}
}
}
}
let best_model = ExponentialSmoothing::custom(
current_best.0,
current_best.1,
current_best.2,
current_best.3,
trend,
seasonal,
period,
)?;
let best_result = best_model.fit(data)?;
Ok((best_model, best_result))
}
pub fn select_best_model(
data: &ArrayView1<f64>,
period: Option<usize>,
config: &OptimizationConfig,
) -> Result<(
ExponentialSmoothing,
ExponentialSmoothingResult,
InformationCriteria,
)> {
let mut candidates: Vec<(TrendComponent, SeasonalComponent)> = vec![
(TrendComponent::None, SeasonalComponent::None),
(TrendComponent::Additive, SeasonalComponent::None),
(TrendComponent::Damped, SeasonalComponent::None),
];
if let Some(p) = period {
if data.len() >= 2 * p {
candidates.push((TrendComponent::None, SeasonalComponent::Additive));
candidates.push((TrendComponent::Additive, SeasonalComponent::Additive));
candidates.push((TrendComponent::Damped, SeasonalComponent::Additive));
let all_positive = data.iter().all(|&x| x > 0.0);
if all_positive {
candidates.push((TrendComponent::None, SeasonalComponent::Multiplicative));
candidates.push((TrendComponent::Additive, SeasonalComponent::Multiplicative));
candidates.push((TrendComponent::Damped, SeasonalComponent::Multiplicative));
}
}
}
let mut best_aicc = f64::INFINITY;
let mut best_model: Option<ExponentialSmoothing> = None;
let mut best_result: Option<ExponentialSmoothingResult> = None;
let mut best_criteria: Option<InformationCriteria> = None;
for (trend, seasonal) in candidates {
let p = if seasonal != SeasonalComponent::None {
period
} else {
None
};
match optimize_parameters(data, trend, seasonal, p, config) {
Ok((model, result)) => {
if let Ok(ic) = model.information_criteria(&result) {
if ic.aicc < best_aicc && ic.aicc.is_finite() {
best_aicc = ic.aicc;
best_model = Some(model);
best_result = Some(result);
best_criteria = Some(ic);
}
}
}
Err(_) => continue, }
}
match (best_model, best_result, best_criteria) {
(Some(m), Some(r), Some(c)) => Ok((m, r, c)),
_ => Err(NumRs2Error::ComputationError(
"No valid model could be fitted to the data".to_string(),
)),
}
}
fn validate_param(value: f64, name: &str) -> Result<()> {
if value <= 0.0 || value >= 1.0 {
return Err(NumRs2Error::ValueError(format!(
"{} must be in (0, 1), got {}",
name, value
)));
}
Ok(())
}
fn damped_trend_sum(phi: f64, h: usize) -> f64 {
if (phi - 1.0).abs() < 1e-12 {
return h as f64;
}
if phi.abs() < 1e-12 {
return 0.0;
}
phi * (1.0 - phi.powi(h as i32)) / (1.0 - phi)
}
fn build_and_fit(
alpha: f64,
beta: Option<f64>,
gamma: Option<f64>,
phi: Option<f64>,
trend: TrendComponent,
seasonal: SeasonalComponent,
period: Option<usize>,
data: &ArrayView1<f64>,
) -> Result<ExponentialSmoothingResult> {
let model = ExponentialSmoothing::custom(alpha, beta, gamma, phi, trend, seasonal, period)?;
model.fit(data)
}
fn quantile_normal(p: f64) -> f64 {
if p <= 0.0 {
return f64::NEG_INFINITY;
}
if p >= 1.0 {
return f64::INFINITY;
}
if p > 0.5 {
return -quantile_normal(1.0 - p);
}
let t = (-2.0 * p.ln()).sqrt();
let c0 = 2.515517;
let c1 = 0.802853;
let c2 = 0.010328;
let d1 = 1.432788;
let d2 = 0.189269;
let d3 = 0.001308;
let numerator = c0 + c1 * t + c2 * t * t;
let denominator = 1.0 + d1 * t + d2 * t * t + d3 * t * t * t;
-(t - numerator / denominator)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use scirs2_core::ndarray::Array1;
#[test]
fn test_ses_constant_data() {
let data = Array1::from_vec(vec![5.0; 20]);
let model = ExponentialSmoothing::ses(0.3).expect("SES creation should succeed");
let result = model.fit(&data.view()).expect("fit should succeed");
for i in 1..20 {
assert_relative_eq!(result.fitted[i], 5.0, epsilon = 1e-10);
}
assert!(result.mse < 1e-10);
}
#[test]
fn test_ses_trending_data() {
let data = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]);
let model = ExponentialSmoothing::ses(0.5).expect("SES creation should succeed");
let result = model.fit(&data.view()).expect("fit should succeed");
for i in 1..10 {
assert!(result.fitted[i] > 0.0);
}
assert!(result.fitted[9] < data[9]);
}
#[test]
fn test_ses_forecast_flat() {
let data = Array1::from_vec(vec![10.0, 12.0, 11.0, 13.0, 12.5, 14.0]);
let model = ExponentialSmoothing::ses(0.3).expect("SES creation should succeed");
let forecast = model
.forecast(&data.view(), 5, 0.95)
.expect("forecast should succeed");
for i in 1..5 {
assert_relative_eq!(forecast.point[i], forecast.point[0], epsilon = 1e-10);
}
let lower = forecast.lower.as_ref().expect("should have lower bounds");
let upper = forecast.upper.as_ref().expect("should have upper bounds");
assert!(upper[4] - lower[4] > upper[0] - lower[0]);
}
#[test]
fn test_holt_linear_trend() {
let data: Array1<f64> = Array1::from_vec((0..20).map(|i| 10.0 + 2.0 * i as f64).collect());
let model = ExponentialSmoothing::holt(0.5, 0.3).expect("Holt creation should succeed");
let forecast = model
.forecast(&data.view(), 5, 0.95)
.expect("forecast should succeed");
for i in 1..5 {
assert!(
forecast.point[i] > forecast.point[i - 1],
"Forecast should increase: {} vs {}",
forecast.point[i],
forecast.point[i - 1]
);
}
assert!(
(forecast.point[0] - 50.0).abs() < 10.0,
"First forecast {} should be near 50",
forecast.point[0]
);
}
#[test]
fn test_holt_versus_ses_on_trend() {
let data: Array1<f64> = Array1::from_vec((0..30).map(|i| 5.0 + 1.5 * i as f64).collect());
let ses = ExponentialSmoothing::ses(0.5).expect("SES should succeed");
let holt = ExponentialSmoothing::holt(0.5, 0.3).expect("Holt should succeed");
let ses_result = ses.fit(&data.view()).expect("SES fit");
let holt_result = holt.fit(&data.view()).expect("Holt fit");
assert!(
holt_result.mse < ses_result.mse,
"Holt MSE ({}) should be less than SES MSE ({}) on trending data",
holt_result.mse,
ses_result.mse
);
}
#[test]
fn test_damped_trend_converges() {
let data: Array1<f64> = Array1::from_vec((0..20).map(|i| 10.0 + 2.0 * i as f64).collect());
let model = ExponentialSmoothing::damped_trend(0.5, 0.3, 0.9)
.expect("Damped trend creation should succeed");
let forecast = model
.forecast(&data.view(), 50, 0.95)
.expect("forecast should succeed");
let diff_early = (forecast.point[1] - forecast.point[0]).abs();
let diff_late = (forecast.point[49] - forecast.point[48]).abs();
assert!(
diff_late < diff_early,
"Late forecast steps should be closer together: {} vs {}",
diff_late,
diff_early
);
}
#[test]
fn test_damped_vs_undamped_long_horizon() {
let data: Array1<f64> = Array1::from_vec((0..20).map(|i| 10.0 + 2.0 * i as f64).collect());
let holt = ExponentialSmoothing::holt(0.5, 0.3).expect("Holt should succeed");
let damped =
ExponentialSmoothing::damped_trend(0.5, 0.3, 0.85).expect("Damped should succeed");
let holt_fc = holt
.forecast(&data.view(), 30, 0.95)
.expect("Holt forecast");
let damped_fc = damped
.forecast(&data.view(), 30, 0.95)
.expect("Damped forecast");
assert!(
damped_fc.point[29] < holt_fc.point[29],
"Damped forecast ({}) should be less than Holt ({}) at long horizon",
damped_fc.point[29],
holt_fc.point[29]
);
}
#[test]
fn test_hw_additive_seasonal_data() {
let mut data_vec = Vec::with_capacity(48);
let seasonal_pattern = [0.0, 3.0, 6.0, 3.0]; for i in 0..48 {
let trend = 10.0 + 0.5 * i as f64;
let season = seasonal_pattern[i % 4];
data_vec.push(trend + season);
}
let data = Array1::from_vec(data_vec);
let model =
ExponentialSmoothing::holt_winters(0.3, 0.1, 0.2, 4, SeasonalComponent::Additive)
.expect("HW additive creation should succeed");
let result = model.fit(&data.view()).expect("HW fit should succeed");
assert!(
result.mse < 10.0,
"MSE ({}) should be small for clean seasonal data",
result.mse
);
assert!(result.seasonal.is_some());
}
#[test]
fn test_hw_additive_forecast_preserves_season() {
let mut data_vec = Vec::with_capacity(40);
let seasonal_pattern = [0.0, 5.0, 10.0, 5.0];
for i in 0..40 {
let trend = 20.0 + 1.0 * i as f64;
let season = seasonal_pattern[i % 4];
data_vec.push(trend + season);
}
let data = Array1::from_vec(data_vec);
let model =
ExponentialSmoothing::holt_winters(0.3, 0.1, 0.3, 4, SeasonalComponent::Additive)
.expect("HW should succeed");
let forecast = model
.forecast(&data.view(), 8, 0.95)
.expect("forecast should succeed");
let diff_same_season = (forecast.point[0] - forecast.point[4]).abs();
let diff_adjacent = (forecast.point[0] - forecast.point[1]).abs();
assert!(
diff_same_season < diff_adjacent * 3.0,
"Same-season forecasts should be relatively close"
);
}
#[test]
fn test_hw_multiplicative_seasonal_data() {
let mut data_vec = Vec::with_capacity(48);
let seasonal_factors = [0.8, 1.0, 1.3, 0.9]; for i in 0..48 {
let trend = 50.0 + 2.0 * i as f64;
let season = seasonal_factors[i % 4];
data_vec.push(trend * season);
}
let data = Array1::from_vec(data_vec);
let model =
ExponentialSmoothing::holt_winters(0.3, 0.1, 0.2, 4, SeasonalComponent::Multiplicative)
.expect("HW multiplicative creation should succeed");
let result = model.fit(&data.view()).expect("fit should succeed");
assert!(result.mse.is_finite(), "MSE should be finite");
assert!(result.seasonal.is_some());
let s = result.seasonal.as_ref().expect("seasonal should exist");
let s_sum: f64 = s.iter().sum();
assert_relative_eq!(s_sum / 4.0, 1.0, epsilon = 0.3);
}
#[test]
fn test_hw_multiplicative_forecast() {
let mut data_vec = Vec::with_capacity(40);
let seasonal_factors = [0.7, 1.1, 1.4, 0.8];
for i in 0..40 {
let base = 100.0 + 3.0 * i as f64;
data_vec.push(base * seasonal_factors[i % 4]);
}
let data = Array1::from_vec(data_vec);
let model =
ExponentialSmoothing::holt_winters(0.3, 0.1, 0.2, 4, SeasonalComponent::Multiplicative)
.expect("HW should succeed");
let forecast = model
.forecast(&data.view(), 4, 0.95)
.expect("forecast should succeed");
assert!(forecast.point.iter().all(|&x| x > 0.0));
assert!(forecast.lower.is_some());
assert!(forecast.upper.is_some());
}
#[test]
fn test_optimize_ses_parameters() {
let data: Array1<f64> =
Array1::from_vec((0..30).map(|i| 10.0 + 0.1 * (i as f64).sin()).collect());
let config = OptimizationConfig {
grid_resolution: 10,
refinement_iterations: 1,
..Default::default()
};
let (model, result) = optimize_parameters(
&data.view(),
TrendComponent::None,
SeasonalComponent::None,
None,
&config,
)
.expect("optimization should succeed");
assert!(model.alpha > 0.0 && model.alpha < 1.0);
assert!(result.mse.is_finite());
assert!(result.mse >= 0.0);
}
#[test]
fn test_optimize_holt_parameters() {
let data: Array1<f64> = Array1::from_vec(
(0..40)
.map(|i| 5.0 + 2.0 * i as f64 + 0.5 * (i as f64 * 0.3).sin())
.collect(),
);
let config = OptimizationConfig {
grid_resolution: 8,
refinement_iterations: 1,
..Default::default()
};
let (model, result) = optimize_parameters(
&data.view(),
TrendComponent::Additive,
SeasonalComponent::None,
None,
&config,
)
.expect("optimization should succeed");
assert!(model.alpha > 0.0 && model.alpha < 1.0);
assert!(model.beta.is_some());
let beta = model.beta.expect("beta should exist");
assert!(beta > 0.0 && beta < 1.0);
assert!(result.mse.is_finite());
}
#[test]
fn test_forecast_accuracy_on_known_data() {
let train: Array1<f64> = Array1::from_vec((0..30).map(|i| 10.0 + 2.0 * i as f64).collect());
let actual_next = 10.0 + 2.0 * 30.0;
let model = ExponentialSmoothing::holt(0.8, 0.5).expect("Holt should succeed");
let forecast = model
.forecast(&train.view(), 1, 0.95)
.expect("forecast should succeed");
let error = (forecast.point[0] - actual_next).abs();
assert!(
error < 5.0,
"Forecast error ({}) should be small for linear data",
error
);
}
#[test]
fn test_prediction_intervals_coverage() {
let data = Array1::from_vec(vec![
10.0, 12.0, 11.0, 13.0, 12.5, 14.0, 13.0, 15.0, 14.5, 16.0,
]);
let model = ExponentialSmoothing::ses(0.3).expect("SES should succeed");
let forecast = model
.forecast(&data.view(), 5, 0.95)
.expect("forecast should succeed");
let lower = forecast.lower.as_ref().expect("lower bounds");
let upper = forecast.upper.as_ref().expect("upper bounds");
for i in 0..5 {
assert!(
lower[i] < forecast.point[i],
"Lower bound should be below point forecast"
);
assert!(
upper[i] > forecast.point[i],
"Upper bound should be above point forecast"
);
}
assert_relative_eq!(forecast.confidence_level, 0.95, epsilon = 1e-10);
}
#[test]
fn test_prediction_intervals_widen_with_horizon() {
let data = Array1::from_vec(vec![5.0, 6.0, 7.0, 5.5, 6.5, 7.5, 5.0, 6.0, 7.0, 8.0]);
let model = ExponentialSmoothing::holt(0.3, 0.1).expect("Holt should succeed");
let forecast = model
.forecast(&data.view(), 10, 0.90)
.expect("forecast should succeed");
let lower = forecast.lower.as_ref().expect("lower bounds");
let upper = forecast.upper.as_ref().expect("upper bounds");
let width_1 = upper[0] - lower[0];
let width_10 = upper[9] - lower[9];
assert!(
width_10 > width_1,
"Prediction interval at h=10 ({}) should be wider than at h=1 ({})",
width_10,
width_1
);
}
#[test]
fn test_information_criteria_computed() {
let data = Array1::from_vec(vec![
10.0, 12.0, 11.0, 13.0, 12.5, 14.0, 13.0, 15.0, 14.5, 16.0, 15.0, 17.0, 16.5, 18.0,
17.0,
]);
let model = ExponentialSmoothing::ses(0.3).expect("SES should succeed");
let result = model.fit(&data.view()).expect("fit should succeed");
let ic = model
.information_criteria(&result)
.expect("IC should succeed");
assert!(ic.aic.is_finite(), "AIC should be finite");
assert!(ic.aicc.is_finite(), "AICc should be finite");
assert!(ic.bic.is_finite(), "BIC should be finite");
assert!(
ic.aicc >= ic.aic - 1e-10,
"AICc ({}) should be >= AIC ({})",
ic.aicc,
ic.aic
);
}
#[test]
fn test_model_selection_prefers_simpler_for_constant() {
let data = Array1::from_vec(vec![5.0; 30]);
let mut noisy = data.clone();
for i in 0..30 {
noisy[i] += 0.001 * (i as f64 * 0.7).sin();
}
let ses = ExponentialSmoothing::ses(0.1).expect("SES should succeed");
let holt = ExponentialSmoothing::holt(0.1, 0.1).expect("Holt should succeed");
let ses_result = ses.fit(&noisy.view()).expect("SES fit");
let holt_result = holt.fit(&noisy.view()).expect("Holt fit");
let ses_ic = ses.information_criteria(&ses_result).expect("SES IC");
let holt_ic = holt.information_criteria(&holt_result).expect("Holt IC");
assert!(
ses_ic.bic < holt_ic.bic,
"SES BIC ({}) should be less than Holt BIC ({}) for constant data",
ses_ic.bic,
holt_ic.bic
);
}
#[test]
fn test_short_series_ses() {
let data = Array1::from_vec(vec![1.0, 2.0]);
let model = ExponentialSmoothing::ses(0.5).expect("SES should succeed");
let result = model.fit(&data.view()).expect("fit should succeed");
assert_eq!(result.fitted.len(), 2);
}
#[test]
fn test_single_observation_ses_fails() {
let data = Array1::from_vec(vec![42.0]);
let model = ExponentialSmoothing::ses(0.5).expect("SES should succeed");
let result = model.fit(&data.view());
assert!(result.is_err(), "Single observation should fail");
}
#[test]
fn test_invalid_parameters() {
assert!(ExponentialSmoothing::ses(0.0).is_err());
assert!(ExponentialSmoothing::ses(1.0).is_err());
assert!(ExponentialSmoothing::ses(-0.1).is_err());
assert!(ExponentialSmoothing::ses(1.5).is_err());
assert!(ExponentialSmoothing::holt(0.5, 0.0).is_err());
assert!(ExponentialSmoothing::holt(0.5, 1.0).is_err());
assert!(ExponentialSmoothing::damped_trend(0.5, 0.3, 0.0).is_err());
assert!(ExponentialSmoothing::damped_trend(0.5, 0.3, 1.0).is_err());
assert!(
ExponentialSmoothing::holt_winters(0.3, 0.1, 0.2, 1, SeasonalComponent::Additive)
.is_err()
);
}
#[test]
fn test_insufficient_data_for_seasonal() {
let data = Array1::from_vec(vec![1.0, 2.0, 3.0]); let model =
ExponentialSmoothing::holt_winters(0.3, 0.1, 0.2, 4, SeasonalComponent::Additive)
.expect("Model creation should succeed (validation at fit time)");
let result = model.fit(&data.view());
assert!(
result.is_err(),
"Fitting with insufficient data should fail"
);
}
#[test]
fn test_damped_holt_winters() {
let mut data_vec = Vec::with_capacity(48);
let seasonal_pattern = [0.0, 4.0, 8.0, 4.0];
for i in 0..48 {
let trend = 20.0 + 1.0 * i as f64;
let season = seasonal_pattern[i % 4];
data_vec.push(trend + season);
}
let data = Array1::from_vec(data_vec);
let model = ExponentialSmoothing::damped_holt_winters(
0.3,
0.1,
0.2,
0.9,
4,
SeasonalComponent::Additive,
)
.expect("Damped HW should succeed");
let forecast = model
.forecast(&data.view(), 20, 0.95)
.expect("forecast should succeed");
assert!(forecast.point.iter().all(|&x| x > 0.0));
assert!(forecast.lower.is_some());
assert!(forecast.upper.is_some());
let diff_early = (forecast.point[1] - forecast.point[0]).abs();
let diff_late = (forecast.point[19] - forecast.point[18]).abs();
let diff_season_early = (forecast.point[4] - forecast.point[0]).abs();
let diff_season_late = (forecast.point[16] - forecast.point[12]).abs();
assert!(
diff_season_late < diff_season_early + 5.0,
"Damped seasonal steps should converge"
);
}
#[test]
fn test_select_best_model() {
let mut data_vec = Vec::with_capacity(60);
let seasonal = [0.0, 3.0, 6.0, 3.0];
for i in 0..60 {
data_vec.push(10.0 + 0.5 * i as f64 + seasonal[i % 4]);
}
let data = Array1::from_vec(data_vec);
let config = OptimizationConfig {
grid_resolution: 5,
refinement_iterations: 0,
..Default::default()
};
let result = select_best_model(&data.view(), Some(4), &config);
assert!(result.is_ok(), "Model selection should succeed");
let (_model, _result, criteria) = result.expect("should have result");
assert!(criteria.aicc.is_finite());
}
#[test]
fn test_quantile_normal_symmetry() {
let z95 = quantile_normal(0.975);
assert_relative_eq!(z95, 1.96, epsilon = 0.02);
let z_sym = quantile_normal(0.025);
assert_relative_eq!(z_sym, -z95, epsilon = 0.02);
}
#[test]
fn test_damped_trend_sum_values() {
assert_relative_eq!(damped_trend_sum(1.0, 5), 5.0, epsilon = 1e-10);
assert_relative_eq!(damped_trend_sum(0.9, 1), 0.9, epsilon = 1e-10);
assert_relative_eq!(damped_trend_sum(0.9, 2), 1.71, epsilon = 1e-10);
assert_relative_eq!(
damped_trend_sum(0.001, 100),
0.001 / (1.0 - 0.001),
epsilon = 0.01
);
}
}