use super::traits::SeasonalComponent;
use crate::error::{ForecastError, Result};
#[derive(Debug, Clone)]
pub struct SeasonalDifference {
period: usize,
initial_values: Vec<f64>,
differenced: Vec<f64>,
fitted: Vec<f64>,
seasonal_indices: Vec<f64>,
}
impl SeasonalDifference {
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(ForecastError::InvalidParameter(
"seasonal differencing period must be > 0".to_string(),
));
}
Ok(Self {
period,
initial_values: Vec::new(),
differenced: Vec::new(),
fitted: Vec::new(),
seasonal_indices: Vec::new(),
})
}
pub fn differenced(&self) -> &[f64] {
&self.differenced
}
pub fn inverse(&self, differenced: &[f64]) -> Result<Vec<f64>> {
if self.initial_values.is_empty() {
return Err(ForecastError::FitRequired {
model: Some("SeasonalDifference".to_string()),
});
}
let n = self.period + differenced.len();
let mut out = Vec::with_capacity(n);
out.extend_from_slice(&self.initial_values);
for (i, &d) in differenced.iter().enumerate() {
out.push(d + out[i]); }
Ok(out)
}
pub fn period(&self) -> usize {
self.period
}
}
impl SeasonalComponent for SeasonalDifference {
fn fit_seasonal(&mut self, values: &[f64], period: usize) -> Result<()> {
if values.is_empty() {
return Err(ForecastError::EmptyData);
}
if period == 0 {
return Err(ForecastError::InvalidParameter(
"seasonal differencing period must be > 0".to_string(),
));
}
let n = values.len();
if n <= period {
return Err(ForecastError::InsufficientData {
needed: period + 1,
got: n,
hint: Some(format!(
"need more than {} observations for seasonal differencing with period {}",
period, period
)),
});
}
self.period = period;
self.initial_values = values[..period].to_vec();
self.differenced = values[period..]
.iter()
.enumerate()
.map(|(i, &v)| v - values[i])
.collect();
let mean: f64 = values.iter().sum::<f64>() / n as f64;
let mut sums = vec![0.0; period];
let mut counts = vec![0usize; period];
for (t, &v) in values.iter().enumerate() {
let p = t % period;
sums[p] += v - mean;
counts[p] += 1;
}
self.seasonal_indices = sums
.iter()
.zip(counts.iter())
.map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
.collect();
self.fitted = (0..n).map(|t| self.seasonal_indices[t % period]).collect();
Ok(())
}
fn fitted_seasonal(&self) -> &[f64] {
&self.fitted
}
fn predict_seasonal(&self, n_ahead: usize) -> Vec<f64> {
if self.seasonal_indices.is_empty() || self.period == 0 {
return vec![0.0; n_ahead];
}
(0..n_ahead)
.map(|i| self.seasonal_indices[i % self.period])
.collect()
}
fn seasonal_features(&self) -> Vec<(&str, f64)> {
if self.fitted.is_empty() {
return Vec::new();
}
let strength = compute_strength(&self.differenced, &self.fitted);
let variance_reduction = compute_variance_reduction(&self.differenced, &self.fitted);
let acf1 = compute_acf1(&self.differenced);
let amplitude = compute_amplitude(&self.seasonal_indices);
vec![
("seasonal_diff_strength", strength),
("seasonal_diff_variance_reduction", variance_reduction),
("seasonal_diff_acf1", acf1),
("seasonal_diff_amplitude", amplitude),
]
}
fn seasonal_name(&self) -> &str {
"seasonal_difference"
}
fn n_params(&self) -> usize {
self.period
}
}
fn variance(data: &[f64]) -> f64 {
if data.is_empty() {
return 0.0;
}
let n = data.len() as f64;
let mean = data.iter().sum::<f64>() / n;
data.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n
}
fn compute_strength(differenced: &[f64], fitted: &[f64]) -> f64 {
let var_diff = variance(differenced);
let var_fitted = variance(fitted);
let var_total = var_fitted + var_diff;
if var_total <= 0.0 {
return 0.0;
}
(1.0 - var_diff / var_total).clamp(0.0, 1.0)
}
fn compute_variance_reduction(differenced: &[f64], fitted: &[f64]) -> f64 {
let var_diff = variance(differenced);
let var_fitted = variance(fitted);
let var_original = var_fitted + var_diff;
if var_diff <= 0.0 {
return f64::INFINITY;
}
var_original / var_diff
}
fn compute_acf1(data: &[f64]) -> f64 {
if data.len() < 2 {
return 0.0;
}
let n = data.len() as f64;
let mean = data.iter().sum::<f64>() / n;
let denom: f64 = data.iter().map(|&x| (x - mean).powi(2)).sum();
if denom == 0.0 {
return 0.0;
}
let numer: f64 = data.windows(2).map(|w| (w[1] - mean) * (w[0] - mean)).sum();
numer / denom
}
fn compute_amplitude(indices: &[f64]) -> f64 {
if indices.is_empty() {
return 0.0;
}
let min = indices.iter().cloned().fold(f64::INFINITY, f64::min);
let max = indices.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
max - min
}
pub fn seasonal_diff_strength(values: &[f64], period: usize) -> f64 {
if period == 0 || values.len() <= period {
return 0.0;
}
let mut sd = match SeasonalDifference::new(period) {
Ok(sd) => sd,
Err(_) => return 0.0,
};
if sd.fit_seasonal(values, period).is_err() {
return 0.0;
}
let features = sd.seasonal_features();
features
.iter()
.find(|(name, _)| *name == "seasonal_diff_strength")
.map(|(_, v)| *v)
.unwrap_or(0.0)
}
pub fn seasonal_diff_variance_reduction(values: &[f64], period: usize) -> f64 {
if period == 0 || values.len() <= period {
return 1.0;
}
let mut sd = match SeasonalDifference::new(period) {
Ok(sd) => sd,
Err(_) => return 1.0,
};
if sd.fit_seasonal(values, period).is_err() {
return 1.0;
}
let features = sd.seasonal_features();
features
.iter()
.find(|(name, _)| *name == "seasonal_diff_variance_reduction")
.map(|(_, v)| *v)
.unwrap_or(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
fn pure_seasonal(period: usize, n_cycles: usize) -> Vec<f64> {
(0..period * n_cycles)
.map(|i| (i % period) as f64)
.collect()
}
fn pseudo_noise(n: usize, seed: u64) -> Vec<f64> {
let mut state = seed;
(0..n)
.map(|_| {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
(state >> 33) as f64 / (1u64 << 31) as f64 - 1.0
})
.collect()
}
#[test]
fn perfect_seasonal_strength_near_one() {
let period = 7;
let values = pure_seasonal(period, 10);
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let features = sd.seasonal_features();
let strength = features
.iter()
.find(|(n, _)| *n == "seasonal_diff_strength")
.unwrap()
.1;
for &d in sd.differenced() {
assert_abs_diff_eq!(d, 0.0, epsilon = 1e-12);
}
assert_abs_diff_eq!(strength, 1.0, epsilon = 1e-10);
}
#[test]
fn no_seasonal_strength_near_zero() {
let values = vec![5.0; 100];
let mut sd = SeasonalDifference::new(7).unwrap();
sd.fit_seasonal(&values, 7).unwrap();
let features = sd.seasonal_features();
let strength = features
.iter()
.find(|(n, _)| *n == "seasonal_diff_strength")
.unwrap()
.1;
assert_abs_diff_eq!(strength, 0.0, epsilon = 1e-10);
}
#[test]
fn noise_has_low_strength() {
let values = pseudo_noise(200, 42);
let mut sd = SeasonalDifference::new(7).unwrap();
sd.fit_seasonal(&values, 7).unwrap();
let features = sd.seasonal_features();
let strength = features
.iter()
.find(|(n, _)| *n == "seasonal_diff_strength")
.unwrap()
.1;
assert!(
strength < 0.5,
"noise strength {} should be < 0.5",
strength
);
}
#[test]
fn inverse_recovers_original() {
let period = 4;
let values: Vec<f64> = (0..20)
.map(|i| (i as f64).sin() * 10.0 + i as f64)
.collect();
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let recovered = sd.inverse(sd.differenced()).unwrap();
assert_eq!(recovered.len(), values.len());
for (&orig, &rec) in values.iter().zip(recovered.iter()) {
assert_abs_diff_eq!(orig, rec, epsilon = 1e-10);
}
}
#[test]
fn inverse_with_known_values() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 7.0, 9.0, 11.0];
let period = 4;
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let diff = sd.differenced();
assert_eq!(diff.len(), 4);
assert_abs_diff_eq!(diff[0], 4.0, epsilon = 1e-12);
assert_abs_diff_eq!(diff[1], 5.0, epsilon = 1e-12);
assert_abs_diff_eq!(diff[2], 6.0, epsilon = 1e-12);
assert_abs_diff_eq!(diff[3], 7.0, epsilon = 1e-12);
let recovered = sd.inverse(diff).unwrap();
for (&o, &r) in values.iter().zip(recovered.iter()) {
assert_abs_diff_eq!(o, r, epsilon = 1e-12);
}
}
#[test]
fn predict_continues_seasonal_pattern() {
let period = 4;
let values = pure_seasonal(period, 5);
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let prediction = sd.predict_seasonal(8);
assert_eq!(prediction.len(), 8);
for i in 0..8 {
assert_abs_diff_eq!(prediction[i], prediction[i % period], epsilon = 1e-10,);
}
assert_abs_diff_eq!(prediction[0], prediction[4], epsilon = 1e-10);
assert_abs_diff_eq!(prediction[1], prediction[5], epsilon = 1e-10);
}
#[test]
fn predict_zero_ahead() {
let values = pure_seasonal(4, 3);
let mut sd = SeasonalDifference::new(4).unwrap();
sd.fit_seasonal(&values, 4).unwrap();
let prediction = sd.predict_seasonal(0);
assert!(prediction.is_empty());
}
#[test]
fn features_all_present() {
let values = pure_seasonal(7, 5);
let mut sd = SeasonalDifference::new(7).unwrap();
sd.fit_seasonal(&values, 7).unwrap();
let features = sd.seasonal_features();
assert_eq!(features.len(), 4);
let names: Vec<&str> = features.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"seasonal_diff_strength"));
assert!(names.contains(&"seasonal_diff_variance_reduction"));
assert!(names.contains(&"seasonal_diff_acf1"));
assert!(names.contains(&"seasonal_diff_amplitude"));
}
#[test]
fn features_empty_before_fit() {
let sd = SeasonalDifference::new(7).unwrap();
let features = sd.seasonal_features();
assert!(features.is_empty());
}
#[test]
fn variance_reduction_greater_than_one_for_seasonal() {
let period = 12;
let values: Vec<f64> = (0..120)
.map(|i| (i % period) as f64 * 3.0 + 100.0)
.collect();
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let features = sd.seasonal_features();
let vr = features
.iter()
.find(|(n, _)| *n == "seasonal_diff_variance_reduction")
.unwrap()
.1;
assert!(
vr > 1.0,
"variance reduction {} should be > 1 for seasonal data",
vr
);
}
#[test]
fn acf1_near_zero_for_perfect_seasonal() {
let period = 4;
let values = pure_seasonal(period, 20);
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let features = sd.seasonal_features();
let acf1 = features
.iter()
.find(|(n, _)| *n == "seasonal_diff_acf1")
.unwrap()
.1;
assert_abs_diff_eq!(acf1, 0.0, epsilon = 1e-10);
}
#[test]
fn amplitude_captures_range() {
let period = 4;
let values = pure_seasonal(period, 5);
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let features = sd.seasonal_features();
let amp = features
.iter()
.find(|(n, _)| *n == "seasonal_diff_amplitude")
.unwrap()
.1;
assert_abs_diff_eq!(amp, 3.0, epsilon = 1e-10);
}
#[test]
fn standalone_strength_matches_trait() {
let period = 7;
let values = pure_seasonal(period, 8);
let standalone = seasonal_diff_strength(&values, period);
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let trait_strength = sd
.seasonal_features()
.iter()
.find(|(n, _)| *n == "seasonal_diff_strength")
.unwrap()
.1;
assert_abs_diff_eq!(standalone, trait_strength, epsilon = 1e-12);
}
#[test]
fn standalone_variance_reduction_matches_trait() {
let period = 12;
let values: Vec<f64> = (0..120)
.map(|i| (i % period) as f64 * 2.0 + 50.0 + i as f64 * 0.1)
.collect();
let standalone = seasonal_diff_variance_reduction(&values, period);
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let trait_vr = sd
.seasonal_features()
.iter()
.find(|(n, _)| *n == "seasonal_diff_variance_reduction")
.unwrap()
.1;
assert_abs_diff_eq!(standalone, trait_vr, epsilon = 1e-12);
}
#[test]
fn standalone_edge_cases() {
assert_abs_diff_eq!(seasonal_diff_strength(&[1.0, 2.0], 0), 0.0, epsilon = 1e-12);
assert_abs_diff_eq!(
seasonal_diff_variance_reduction(&[1.0, 2.0], 0),
1.0,
epsilon = 1e-12
);
assert_abs_diff_eq!(
seasonal_diff_strength(&[1.0, 2.0, 3.0], 5),
0.0,
epsilon = 1e-12
);
assert_abs_diff_eq!(
seasonal_diff_variance_reduction(&[1.0, 2.0, 3.0], 5),
1.0,
epsilon = 1e-12
);
}
#[test]
fn new_period_zero_errors() {
let result = SeasonalDifference::new(0);
assert!(matches!(result, Err(ForecastError::InvalidParameter(_))));
}
#[test]
fn fit_empty_data_errors() {
let mut sd = SeasonalDifference::new(4).unwrap();
let result = sd.fit_seasonal(&[], 4);
assert!(matches!(result, Err(ForecastError::EmptyData)));
}
#[test]
fn fit_insufficient_data_errors() {
let mut sd = SeasonalDifference::new(10).unwrap();
let values = vec![1.0; 10]; let result = sd.fit_seasonal(&values, 10);
assert!(matches!(
result,
Err(ForecastError::InsufficientData { .. })
));
}
#[test]
fn fit_period_zero_errors() {
let mut sd = SeasonalDifference::new(1).unwrap();
let result = sd.fit_seasonal(&[1.0, 2.0, 3.0], 0);
assert!(matches!(result, Err(ForecastError::InvalidParameter(_))));
}
#[test]
fn inverse_before_fit_errors() {
let sd = SeasonalDifference::new(4).unwrap();
let result = sd.inverse(&[1.0, 2.0]);
assert!(matches!(result, Err(ForecastError::FitRequired { .. })));
}
#[test]
fn fitted_seasonal_same_length_as_input() {
let period = 5;
let values: Vec<f64> = (0..30).map(|i| (i % period) as f64 * 2.0).collect();
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
assert_eq!(sd.fitted_seasonal().len(), values.len());
}
#[test]
fn fitted_seasonal_is_periodic() {
let period = 3;
let values: Vec<f64> = (0..21).map(|i| (i % period) as f64 + 10.0).collect();
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let fitted = sd.fitted_seasonal();
for i in period..fitted.len() {
assert_abs_diff_eq!(fitted[i], fitted[i % period], epsilon = 1e-12);
}
}
#[test]
fn name_is_correct() {
let sd = SeasonalDifference::new(4).unwrap();
assert_eq!(sd.seasonal_name(), "seasonal_difference");
}
#[test]
fn differenced_length() {
let period = 6;
let n = 30;
let values: Vec<f64> = (0..n).map(|i| i as f64).collect();
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
assert_eq!(sd.differenced().len(), n - period);
}
#[test]
fn refit_resets_state() {
let mut sd = SeasonalDifference::new(4).unwrap();
let values1: Vec<f64> = (0..20).map(|i| (i % 4) as f64).collect();
sd.fit_seasonal(&values1, 4).unwrap();
let fitted1 = sd.fitted_seasonal().to_vec();
let values2: Vec<f64> = (0..20).map(|i| (i % 4) as f64 * 10.0).collect();
sd.fit_seasonal(&values2, 4).unwrap();
let fitted2 = sd.fitted_seasonal().to_vec();
let diff: f64 = fitted1
.iter()
.zip(fitted2.iter())
.map(|(a, b)| (a - b).abs())
.sum();
assert!(diff > 1.0, "refitting should change fitted values");
}
#[test]
fn clone_preserves_state() {
let period = 5;
let values = pure_seasonal(period, 6);
let mut sd = SeasonalDifference::new(period).unwrap();
sd.fit_seasonal(&values, period).unwrap();
let cloned = sd.clone();
assert_eq!(sd.differenced(), cloned.differenced());
assert_eq!(sd.fitted_seasonal(), cloned.fitted_seasonal());
assert_eq!(sd.predict_seasonal(10), cloned.predict_seasonal(10));
}
}