use crate::core::{Forecast, TimeSeries};
use crate::error::{ForecastError, Result};
use crate::models::{validate_series_complete, Forecaster};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SeasonalWindowAverage {
period: usize,
window: usize, history: Option<Vec<f64>>,
fitted: Option<Vec<f64>>,
residuals: Option<Vec<f64>>,
residual_variance: Option<f64>,
}
impl SeasonalWindowAverage {
pub fn new(period: usize, window: usize) -> Self {
Self {
period,
window: window.max(1), history: None,
fitted: None,
residuals: None,
residual_variance: None,
}
}
pub fn period(&self) -> usize {
self.period
}
pub fn window(&self) -> usize {
self.window
}
}
impl Default for SeasonalWindowAverage {
fn default() -> Self {
Self::new(12, 2) }
}
impl Forecaster for SeasonalWindowAverage {
fn fit(&mut self, series: &TimeSeries) -> Result<()> {
validate_series_complete(series)?;
if self.period < 2 {
return Err(ForecastError::InvalidParameter(format!(
"seasonal period must be >= 2, got {}",
self.period
)));
}
let values = series.primary_values();
if values.len() < self.period {
return Err(ForecastError::InsufficientData {
needed: self.period,
got: values.len(),
hint: None,
});
}
self.history = Some(values.to_vec());
let n = values.len();
let mut fitted = Vec::with_capacity(n);
for i in 0..n {
if i < self.period {
fitted.push(f64::NAN);
} else {
let mut sum = 0.0;
let mut count = 0;
for k in 1..=self.window {
if i >= k * self.period {
let idx = i - k * self.period;
sum += values[idx];
count += 1;
}
}
if count > 0 {
fitted.push(sum / count as f64);
} else {
fitted.push(f64::NAN);
}
}
}
self.fitted = Some(fitted.clone());
let residuals: Vec<f64> = (0..n)
.map(|i| {
if fitted[i].is_nan() {
f64::NAN
} else {
values[i] - fitted[i]
}
})
.collect();
let valid_residuals: Vec<f64> = residuals.iter().copied().filter(|r| !r.is_nan()).collect();
if !valid_residuals.is_empty() {
let variance =
crate::simd::sum_of_squares(&valid_residuals) / valid_residuals.len() as f64;
self.residual_variance = Some(variance);
}
self.residuals = Some(residuals);
Ok(())
}
fn predict(&self, horizon: usize) -> Result<Forecast> {
let history = self
.history
.as_ref()
.ok_or(ForecastError::FitRequired { model: None })?;
if horizon == 0 {
return Ok(Forecast::new());
}
let n = history.len();
let mut predictions = Vec::with_capacity(horizon);
for h in 0..horizon {
let _target_season = (n + h) % self.period;
let mut sum = 0.0;
let mut count = 0;
for k in 1..=self.window {
let forecast_pos = n + h;
if forecast_pos >= k * self.period {
let idx = forecast_pos - k * self.period;
if idx < n {
sum += history[idx];
count += 1;
}
}
}
if count > 0 {
predictions.push(sum / count as f64);
} else {
predictions.push(*history.last().ok_or(ForecastError::EmptyData)?);
}
}
Ok(Forecast::from_values(predictions))
}
fn predict_with_intervals(&self, horizon: usize, level: f64) -> Result<Forecast> {
let base = self.predict(horizon)?;
let variance = self.residual_variance.unwrap_or(0.0);
let sigma = variance.sqrt();
if horizon == 0 {
return Ok(base);
}
let z = quantile_normal((1.0 + level) / 2.0);
let preds = base.primary();
let mut lower = Vec::with_capacity(horizon);
let mut upper = Vec::with_capacity(horizon);
for (h, pred) in preds.iter().enumerate() {
let k = (h / self.period) + 1;
let se = sigma * (k as f64).sqrt();
lower.push(pred - z * se);
upper.push(pred + z * se);
}
Ok(Forecast::from_values_with_intervals(
preds.to_vec(),
lower,
upper,
))
}
fn fitted_values(&self) -> Option<&[f64]> {
self.fitted.as_deref()
}
fn fitted_values_with_intervals(&self, level: f64) -> Option<Forecast> {
let fitted = self.fitted.as_ref()?;
let variance = self.residual_variance?;
if variance <= 0.0 {
return Some(Forecast::from_values(fitted.clone()));
}
let z = quantile_normal((1.0 + level) / 2.0);
let sigma = variance.sqrt();
let lower: Vec<f64> = fitted.iter().map(|&f| f - z * sigma).collect();
let upper: Vec<f64> = fitted.iter().map(|&f| f + z * sigma).collect();
Some(Forecast::from_values_with_intervals(
fitted.clone(),
lower,
upper,
))
}
fn residuals(&self) -> Option<&[f64]> {
self.residuals.as_deref()
}
fn name(&self) -> &str {
"SeasonalWindowAverage"
}
}
fn quantile_normal(p: f64) -> f64 {
if p <= 0.0 {
return f64::NEG_INFINITY;
}
if p >= 1.0 {
return f64::INFINITY;
}
let t = if p < 0.5 {
(-2.0 * p.ln()).sqrt()
} else {
(-2.0 * (1.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 result = t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t);
if p < 0.5 {
-result
} else {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::TimeSeries;
use crate::models::baseline::SeasonalNaive;
use crate::models::Forecaster;
use approx::assert_relative_eq;
use chrono::{TimeZone, Utc};
fn make_timestamps(n: usize) -> Vec<chrono::DateTime<Utc>> {
(0..n)
.map(|i| {
Utc.with_ymd_and_hms(2024, 1, 1, i as u32 % 24, 0, 0)
.unwrap()
})
.collect()
}
#[test]
fn seasonal_window_average_averages_seasonal_values() {
let timestamps = make_timestamps(12);
let values = vec![
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 ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = SeasonalWindowAverage::new(4, 2);
model.fit(&ts).unwrap();
let forecast = model.predict(4).unwrap();
let preds = forecast.primary();
assert_relative_eq!(preds[0], 2.5, epsilon = 1e-10);
assert_relative_eq!(preds[1], 3.5, epsilon = 1e-10);
assert_relative_eq!(preds[2], 4.5, epsilon = 1e-10);
assert_relative_eq!(preds[3], 5.5, epsilon = 1e-10);
}
#[test]
fn seasonal_window_window_1_equals_seasonal_naive() {
let timestamps = make_timestamps(12);
let values: Vec<f64> = (0..12).map(|i| (i as f64) + 0.5).collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut swa = SeasonalWindowAverage::new(4, 1);
swa.fit(&ts).unwrap();
let mut sn = SeasonalNaive::new(4);
sn.fit(&ts).unwrap();
let swa_forecast = swa.predict(4).unwrap();
let sn_forecast = sn.predict(4).unwrap();
for i in 0..4 {
assert_relative_eq!(
swa_forecast.primary()[i],
sn_forecast.primary()[i],
epsilon = 1e-10
);
}
}
#[test]
fn seasonal_window_different_window_sizes() {
let timestamps = make_timestamps(16);
let values: Vec<f64> = (0..16).map(|i| ((i / 4) + 1) as f64).collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model1 = SeasonalWindowAverage::new(4, 1);
let mut model2 = SeasonalWindowAverage::new(4, 2);
let mut model3 = SeasonalWindowAverage::new(4, 4);
model1.fit(&ts).unwrap();
model2.fit(&ts).unwrap();
model3.fit(&ts).unwrap();
let f1 = model1.predict(1).unwrap();
let f2 = model2.predict(1).unwrap();
let f3 = model3.predict(1).unwrap();
assert_relative_eq!(f1.primary()[0], 4.0, epsilon = 1e-10);
assert_relative_eq!(f2.primary()[0], 3.5, epsilon = 1e-10);
assert_relative_eq!(f3.primary()[0], 2.5, epsilon = 1e-10);
}
#[test]
fn seasonal_window_confidence_intervals() {
let timestamps = make_timestamps(16);
let values: Vec<f64> = (0..16)
.map(|i| ((i % 4) as f64) + 0.1 * (i as f64))
.collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = SeasonalWindowAverage::new(4, 2);
model.fit(&ts).unwrap();
let forecast = model.predict_with_intervals(8, 0.95).unwrap();
assert!(forecast.has_lower());
assert!(forecast.has_upper());
let lower = forecast.lower_series(0).unwrap();
let upper = forecast.upper_series(0).unwrap();
for i in 0..8 {
assert!(lower[i] < upper[i]);
}
}
#[test]
fn seasonal_window_handles_limited_data() {
let timestamps = make_timestamps(8);
let values = vec![1.0, 2.0, 3.0, 4.0, 2.0, 3.0, 4.0, 5.0];
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = SeasonalWindowAverage::new(4, 3);
model.fit(&ts).unwrap();
let forecast = model.predict(4).unwrap();
assert_eq!(forecast.horizon(), 4);
}
#[test]
fn seasonal_window_smooths_vs_seasonal_naive() {
let timestamps = make_timestamps(12);
let values = vec![
10.0, 20.0, 30.0, 40.0, 12.0, 22.0, 32.0, 42.0, 8.0, 18.0, 28.0, 38.0, ];
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut swa = SeasonalWindowAverage::new(4, 3);
swa.fit(&ts).unwrap();
let mut sn = SeasonalNaive::new(4);
sn.fit(&ts).unwrap();
let swa_forecast = swa.predict(4).unwrap();
let sn_forecast = sn.predict(4).unwrap();
assert_relative_eq!(swa_forecast.primary()[0], 10.0, epsilon = 1e-10);
assert_relative_eq!(sn_forecast.primary()[0], 8.0, epsilon = 1e-10);
}
#[test]
fn seasonal_window_quarterly_seasonality() {
let timestamps = make_timestamps(8);
let values = vec![100.0, 120.0, 80.0, 90.0, 110.0, 130.0, 90.0, 100.0];
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = SeasonalWindowAverage::new(4, 2);
model.fit(&ts).unwrap();
let forecast = model.predict(4).unwrap();
let preds = forecast.primary();
assert_relative_eq!(preds[0], (100.0 + 110.0) / 2.0, epsilon = 1e-10);
assert_relative_eq!(preds[1], (120.0 + 130.0) / 2.0, epsilon = 1e-10);
assert_relative_eq!(preds[2], (80.0 + 90.0) / 2.0, epsilon = 1e-10);
assert_relative_eq!(preds[3], (90.0 + 100.0) / 2.0, epsilon = 1e-10);
}
#[test]
fn seasonal_window_name_is_correct() {
let model = SeasonalWindowAverage::new(4, 2);
assert_eq!(model.name(), "SeasonalWindowAverage");
}
#[test]
fn constant_series_produces_constant_forecast() {
let timestamps = make_timestamps(20);
let values = vec![5.0; 20];
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = SeasonalWindowAverage::new(4, 2);
model.fit(&ts).unwrap();
let forecast = model.predict(8).unwrap();
let preds = forecast.primary();
assert!(preds.iter().all(|v| v.is_finite()));
for &p in preds {
assert_relative_eq!(p, 5.0, epsilon = 1e-10);
}
}
#[test]
fn seasonal_window_rejects_period_zero() {
let timestamps = make_timestamps(12);
let values = vec![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 ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = SeasonalWindowAverage::new(0, 2);
assert!(matches!(
model.fit(&ts),
Err(ForecastError::InvalidParameter(ref msg)) if msg.contains("seasonal period must be >= 2")
));
}
#[test]
fn seasonal_window_rejects_period_one() {
let timestamps = make_timestamps(12);
let values = vec![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 ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = SeasonalWindowAverage::new(1, 2);
assert!(matches!(
model.fit(&ts),
Err(ForecastError::InvalidParameter(ref msg)) if msg.contains("seasonal period must be >= 2")
));
}
}