use scirs2_core::ndarray::Array1;
use scirs2_core::numeric::{Float, FromPrimitive};
use std::fmt::Debug;
use super::RobustFilterOptions;
use crate::error::{Result, TimeSeriesError};
#[derive(Debug, Clone)]
pub struct SeasonalDecomposition<F: Float> {
pub trend: Array1<F>,
pub seasonal: Array1<F>,
pub residual: Array1<F>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecompositionType {
Additive,
Multiplicative,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecompositionMethod {
Classical,
STL,
X11,
SEATS,
}
#[derive(Debug, Clone)]
pub struct SeasonalDecompositionOptions {
pub decomposition_type: DecompositionType,
pub method: DecompositionMethod,
pub period: usize,
pub observations_per_year: Option<usize>,
pub robust: bool,
pub trend_options: Option<RobustFilterOptions>,
pub num_iterations: usize,
}
impl Default for SeasonalDecompositionOptions {
fn default() -> Self {
SeasonalDecompositionOptions {
decomposition_type: DecompositionType::Additive,
method: DecompositionMethod::Classical,
period: 12, observations_per_year: None,
robust: false,
trend_options: None,
num_iterations: 2,
}
}
}
#[allow(dead_code)]
pub fn seasonal_decomposition<F>(
ts: &Array1<F>,
options: &SeasonalDecompositionOptions,
) -> Result<SeasonalDecomposition<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = ts.len();
let period = options.period;
if n < 2 * period {
return Err(TimeSeriesError::InsufficientData {
message: format!(
"Time series too short for seasonal decomposition with period={period}"
),
required: 2 * period,
actual: n,
});
}
match options.method {
DecompositionMethod::Classical => classical_decomposition(ts, options),
DecompositionMethod::STL => stl_decomposition(ts, options),
DecompositionMethod::X11 => x11_decomposition(ts, options),
DecompositionMethod::SEATS => seats_decomposition(ts, options),
}
}
#[allow(dead_code)]
fn classical_decomposition<F>(
ts: &Array1<F>,
options: &SeasonalDecompositionOptions,
) -> Result<SeasonalDecomposition<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = ts.len();
let period = options.period;
let is_additive = options.decomposition_type == DecompositionType::Additive;
let trend = estimate_trend_by_moving_average(ts, period, options.robust)?;
let detrended = if is_additive {
Array1::from_shape_fn(n, |i| {
if i < trend.len() {
ts[i] - trend[i]
} else {
F::nan()
}
})
} else {
Array1::from_shape_fn(n, |i| {
if i < trend.len() && trend[i] != F::zero() {
ts[i] / trend[i]
} else {
F::nan()
}
})
};
let seasonal_raw = estimate_seasonal_component(&detrended, period)?;
let seasonal = normalize_seasonal_component(&seasonal_raw, is_additive)?;
let residual = if is_additive {
Array1::from_shape_fn(n, |i| ts[i] - trend[i] - seasonal[i])
} else {
Array1::from_shape_fn(n, |i| {
let denominator = trend[i] * seasonal[i];
if denominator != F::zero() {
ts[i] / denominator
} else {
F::one() }
})
};
Ok(SeasonalDecomposition {
trend,
seasonal,
residual,
})
}
#[allow(dead_code)]
fn stl_decomposition<F>(
ts: &Array1<F>,
options: &SeasonalDecompositionOptions,
) -> Result<SeasonalDecomposition<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = ts.len();
let period = options.period;
let num_iterations = options.num_iterations;
let is_additive = options.decomposition_type == DecompositionType::Additive;
if !is_additive {
let log_ts = Array1::from_shape_fn(n, |i| ts[i].ln());
let decomp = stl_decomposition_inner(&log_ts, period, num_iterations, options.robust)?;
let trend = Array1::from_shape_fn(n, |i| decomp.trend[i].exp());
let seasonal = Array1::from_shape_fn(n, |i| decomp.seasonal[i].exp());
let residual = Array1::from_shape_fn(n, |i| decomp.residual[i].exp());
return Ok(SeasonalDecomposition {
trend,
seasonal,
residual,
});
}
stl_decomposition_inner(ts, period, num_iterations, options.robust)
}
#[allow(dead_code)]
fn stl_decomposition_inner<F>(
ts: &Array1<F>,
period: usize,
num_iterations: usize,
robust: bool,
) -> Result<SeasonalDecomposition<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = ts.len();
let mut trend = Array1::<F>::zeros(n);
let mut seasonal = Array1::<F>::zeros(n);
let mut residual = Array1::<F>::zeros(n);
let n_p = period;
let n_t = 1 + 2 * n_p; let n_s = 7; let n_l = n;
for iter in 0..num_iterations {
let detrended = Array1::from_shape_fn(n, |i| ts[i] - trend[i]);
for i in 0..period {
let mut subseries = Vec::new();
let mut subseries_idx = Vec::new();
let mut idx = i;
while idx < n {
subseries.push(detrended[idx]);
subseries_idx.push(idx);
idx += period;
}
if subseries.len() < 2 {
continue;
}
let smoothed = loess_smooth(&Array1::from_vec(subseries), n_s, robust)?;
for (j, &idx) in subseries_idx.iter().enumerate() {
if j < smoothed.len() {
seasonal[idx] = smoothed[j];
}
}
}
let low_pass = moving_average_filter(&seasonal, n_t)?;
for i in 0..n {
seasonal[i] = seasonal[i] - low_pass[i];
}
let deseasonalized = Array1::from_shape_fn(n, |i| ts[i] - seasonal[i]);
trend = loess_smooth(&deseasonalized, n_l, robust)?;
for i in 0..n {
residual[i] = ts[i] - trend[i] - seasonal[i];
}
if robust && iter < num_iterations - 1 {
let abs_residuals: Vec<F> = residual.iter().map(|&r| r.abs()).collect();
let weights = calculate_robust_weights(
&abs_residuals,
F::from_f64(6.0).expect("Operation failed"),
)?;
let deseasonalized = Array1::from_shape_fn(n, |i| ts[i] - seasonal[i]);
trend = loess_smooth_weighted(&deseasonalized, n_l, &weights)?;
}
}
Ok(SeasonalDecomposition {
trend,
seasonal,
residual,
})
}
#[allow(dead_code)]
fn x11_decomposition<F>(
ts: &Array1<F>,
options: &SeasonalDecompositionOptions,
) -> Result<SeasonalDecomposition<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = ts.len();
let period = options.period;
let is_additive = options.decomposition_type == DecompositionType::Additive;
let trend_init = estimate_trend_by_moving_average(ts, period, false)?;
let detrended = if is_additive {
Array1::from_shape_fn(n, |i| ts[i] - trend_init[i])
} else {
Array1::from_shape_fn(n, |i| {
if trend_init[i] != F::zero() {
ts[i] / trend_init[i]
} else {
F::one()
}
})
};
let seasonal_init = estimate_seasonal_component(&detrended, period)?;
let seasonal_norm = normalize_seasonal_component(&seasonal_init, is_additive)?;
let deseasonalized = if is_additive {
Array1::from_shape_fn(n, |i| ts[i] - seasonal_norm[i])
} else {
Array1::from_shape_fn(n, |i| {
if seasonal_norm[i] != F::zero() {
ts[i] / seasonal_norm[i]
} else {
ts[i]
}
})
};
let trend_refined = henderson_filter(&deseasonalized, 13)?;
let detrended_refined = if is_additive {
Array1::from_shape_fn(n, |i| ts[i] - trend_refined[i])
} else {
Array1::from_shape_fn(n, |i| {
if trend_refined[i] != F::zero() {
ts[i] / trend_refined[i]
} else {
F::one()
}
})
};
let seasonal_refined = estimate_seasonal_component(&detrended_refined, period)?;
let seasonal_final = normalize_seasonal_component(&seasonal_refined, is_additive)?;
let residual = if is_additive {
Array1::from_shape_fn(n, |i| ts[i] - trend_refined[i] - seasonal_final[i])
} else {
Array1::from_shape_fn(n, |i| {
let denominator = trend_refined[i] * seasonal_final[i];
if denominator != F::zero() {
ts[i] / denominator
} else {
F::one()
}
})
};
Ok(SeasonalDecomposition {
trend: trend_refined,
seasonal: seasonal_final,
residual,
})
}
#[allow(dead_code)]
fn seats_decomposition<F>(
_ts: &Array1<F>,
_options: &SeasonalDecompositionOptions,
) -> Result<SeasonalDecomposition<F>>
where
F: Float + FromPrimitive + Debug,
{
Err(TimeSeriesError::NotImplemented(
"SEATS decomposition not yet implemented".to_string(),
))
}
#[allow(dead_code)]
fn estimate_trend_by_moving_average<F>(
ts: &Array1<F>,
period: usize,
robust: bool,
) -> Result<Array1<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = ts.len();
let window_size = if period.is_multiple_of(2) {
period + 1
} else {
period
};
let half_window = window_size / 2;
let mut trend = Array1::<F>::zeros(n);
for i in 0..n {
let start = i.saturating_sub(half_window);
let end = if i + half_window < n {
i + half_window
} else {
n - 1
};
if end - start + 1 < window_size / 2 {
trend[i] = F::nan();
continue;
}
if robust {
let mut values: Vec<F> = (start..=end).map(|j| ts[j]).collect();
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let trim_count = values.len() / 10;
let middle_values = &values[trim_count..(values.len() - trim_count)];
let sum = middle_values.iter().fold(F::zero(), |acc, &v| acc + v);
trend[i] = sum / F::from_usize(middle_values.len()).expect("Operation failed");
} else {
let sum = (start..=end).fold(F::zero(), |acc, j| acc + ts[j]);
trend[i] = sum / F::from_usize(end - start + 1).expect("Operation failed");
}
}
interpolate_nan_values(&mut trend)?;
Ok(trend)
}
#[allow(dead_code)]
fn estimate_seasonal_component<F>(detrended: &Array1<F>, period: usize) -> Result<Array1<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = detrended.len();
let mut seasonal_factors = vec![F::zero(); period];
let mut count = vec![0; period];
for i in 0..n {
let season = i % period;
if !detrended[i].is_nan() {
seasonal_factors[season] = seasonal_factors[season] + detrended[i];
count[season] += 1;
}
}
for season in 0..period {
if count[season] > 0 {
seasonal_factors[season] =
seasonal_factors[season] / F::from_usize(count[season]).expect("Operation failed");
}
}
let mut seasonal = Array1::<F>::zeros(n);
for i in 0..n {
seasonal[i] = seasonal_factors[i % period];
}
Ok(seasonal)
}
#[allow(dead_code)]
fn normalize_seasonal_component<F>(_seasonal: &Array1<F>, isadditive: bool) -> Result<Array1<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = _seasonal.len();
if n == 0 {
return Err(TimeSeriesError::InsufficientData {
message: "Empty _seasonal component".to_string(),
required: 1,
actual: 0,
});
}
let mut normalized = _seasonal.clone();
if isadditive {
let mean = _seasonal.sum() / F::from_usize(n).expect("Operation failed");
for i in 0..n {
normalized[i] = _seasonal[i] - mean;
}
} else {
let mut product = F::one();
let mut count = 0;
for &val in _seasonal.iter() {
if !val.is_nan() && val != F::zero() {
product = product * val;
count += 1;
}
}
if count > 0 {
let geometric_mean =
product.powf(F::one() / F::from_usize(count).expect("Operation failed"));
for i in 0..n {
if !_seasonal[i].is_nan() && _seasonal[i] != F::zero() {
normalized[i] = _seasonal[i] / geometric_mean;
} else {
normalized[i] = F::one();
}
}
}
}
Ok(normalized)
}
#[allow(dead_code)]
fn moving_average_filter<F>(_ts: &Array1<F>, windowsize: usize) -> Result<Array1<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = _ts.len();
let half_window = windowsize / 2;
let mut filtered = Array1::<F>::zeros(n);
for i in 0..n {
let start = i.saturating_sub(half_window);
let end = if i + half_window < n {
i + half_window
} else {
n - 1
};
let sum = (start..=end).fold(F::zero(), |acc, j| acc + _ts[j]);
filtered[i] = sum / F::from_usize(end - start + 1).expect("Operation failed");
}
Ok(filtered)
}
#[allow(dead_code)]
fn henderson_filter<F>(_ts: &Array1<F>, windowsize: usize) -> Result<Array1<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = _ts.len();
if windowsize.is_multiple_of(2) {
return Err(TimeSeriesError::InvalidInput(
"Henderson filter window size must be odd".to_string(),
));
}
let half_window = windowsize / 2;
let weights = calculate_henderson_weights(windowsize)?;
let mut filtered = Array1::<F>::zeros(n);
for i in 0..n {
let start = i.saturating_sub(half_window);
let end = if i + half_window < n {
i + half_window
} else {
n - 1
};
let mut weighted_sum = F::zero();
let mut weight_sum = F::zero();
for (j, k) in (start..=end).enumerate() {
let weight_idx = j + (start as i32 - (i as i32 - half_window as i32)) as usize;
if weight_idx < weights.len() {
weighted_sum = weighted_sum + _ts[k] * weights[weight_idx];
weight_sum = weight_sum + weights[weight_idx];
}
}
if weight_sum != F::zero() {
filtered[i] = weighted_sum / weight_sum;
} else {
filtered[i] = _ts[i];
}
}
Ok(filtered)
}
#[allow(dead_code)]
fn calculate_henderson_weights<F>(_windowsize: usize) -> Result<Vec<F>>
where
F: Float + FromPrimitive + Debug,
{
let m = (_windowsize - 1) / 2;
let mut weights = vec![F::zero(); _windowsize];
let m_f = F::from_usize(m).expect("Operation failed");
for i in 0..=m {
let i_f = F::from_usize(i).expect("Operation failed");
let term1 = (F::from_usize(315).expect("Operation failed")
* (m_f + F::one())
* (m_f + F::one())
- F::from_usize(105).expect("Operation failed") * i_f * i_f)
* (F::from_usize(231).expect("Operation failed") * (m_f + F::one()) * (m_f + F::one())
- F::from_usize(42).expect("Operation failed") * i_f * i_f
- F::from_usize(3).expect("Operation failed")
* (m_f + F::one())
* (m_f + F::one())
* (m_f + F::one())
* (m_f + F::one()));
let term2 =
F::from_usize(105).expect("Operation failed") * (m_f + F::one()) * (m_f + F::one())
- F::from_usize(45).expect("Operation failed") * i_f * i_f;
weights[m - i] = term1 / term2;
weights[m + i] = weights[m - i];
}
let sum = weights.iter().fold(F::zero(), |acc, &w| acc + w);
for weight in &mut weights {
*weight = *weight / sum;
}
Ok(weights)
}
#[allow(dead_code)]
fn loess_smooth<F>(_ts: &Array1<F>, windowsize: usize, robust: bool) -> Result<Array1<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = _ts.len();
if n < windowsize {
return Err(TimeSeriesError::InsufficientData {
message: format!(
"Time series too short for LOESS smoothing with window size {windowsize}"
),
required: windowsize,
actual: n,
});
}
let mut smoothed = Array1::<F>::zeros(n);
let half_window = windowsize / 2;
for i in 0..n {
let start = i.saturating_sub(half_window);
let end = if i + half_window < n {
i + half_window
} else {
n - 1
};
let window_length = end - start + 1;
let mut x = Vec::with_capacity(window_length);
let mut y = Vec::with_capacity(window_length);
let mut weights = vec![F::one(); window_length];
for (j, idx) in (start..=end).enumerate() {
x.push(F::from_usize(idx).expect("Operation failed"));
y.push(_ts[idx]);
let d = (F::from_usize(idx).expect("Operation failed")
- F::from_usize(i).expect("Operation failed"))
.abs()
/ F::from_usize(half_window).expect("Operation failed");
if d < F::one() {
let t = F::one() - d * d * d;
weights[j] = t * t * t;
} else {
weights[j] = F::zero();
}
}
if robust {
let max_iter = 4;
let mut iter_weights = weights.clone();
for _ in 0..max_iter {
let fit = weighted_polynomial_fit(&x, &y, &iter_weights, 1)?;
smoothed[i] = fit[0] + fit[1] * F::from_usize(i).expect("Operation failed");
let mut residuals = Vec::with_capacity(window_length);
for (j, idx) in (start..=end).enumerate() {
let pred = fit[0] + fit[1] * F::from_usize(idx).expect("Operation failed");
residuals.push(y[j] - pred);
}
let robustness_weights = calculate_robust_weights(
&residuals,
F::from_f64(6.0).expect("Operation failed"),
)?;
for j in 0..window_length {
iter_weights[j] = weights[j] * robustness_weights[j];
}
}
} else {
let fit = weighted_polynomial_fit(&x, &y, &weights, 1)?;
smoothed[i] = fit[0] + fit[1] * F::from_usize(i).expect("Operation failed");
}
}
Ok(smoothed)
}
#[allow(dead_code)]
fn loess_smooth_weighted<F>(
ts: &Array1<F>,
window_size: usize,
external_weights: &[F],
) -> Result<Array1<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = ts.len();
if n != external_weights.len() {
return Err(TimeSeriesError::InvalidInput(
"Time series and weights must have same length".to_string(),
));
}
if n < window_size {
return Err(TimeSeriesError::InsufficientData {
message: format!(
"Time series too short for weighted LOESS smoothing with window _size {window_size}"
),
required: window_size,
actual: n,
});
}
let mut smoothed = Array1::<F>::zeros(n);
let half_window = window_size / 2;
for i in 0..n {
let start = i.saturating_sub(half_window);
let end = if i + half_window < n {
i + half_window
} else {
n - 1
};
let window_length = end - start + 1;
let mut x = Vec::with_capacity(window_length);
let mut y = Vec::with_capacity(window_length);
let mut _weights = Vec::with_capacity(window_length);
for idx in start..=end {
x.push(F::from_usize(idx).expect("Operation failed"));
y.push(ts[idx]);
let d = (F::from_usize(idx).expect("Operation failed")
- F::from_usize(i).expect("Operation failed"))
.abs()
/ F::from_usize(half_window).expect("Operation failed");
let tricube_weight = if d < F::one() {
let t = F::one() - d * d * d;
t * t * t
} else {
F::zero()
};
_weights.push(tricube_weight * external_weights[idx]);
}
let fit = weighted_polynomial_fit(&x, &y, &_weights, 1)?;
smoothed[i] = fit[0] + fit[1] * F::from_usize(i).expect("Operation failed");
}
Ok(smoothed)
}
#[allow(dead_code)]
fn weighted_polynomial_fit<F>(x: &[F], y: &[F], weights: &[F], degree: usize) -> Result<Vec<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = x.len();
if n <= degree {
return Err(TimeSeriesError::InsufficientData {
message: format!("Not enough data points for polynomial fit of degree {degree}"),
required: degree + 1,
actual: n,
});
}
if n != y.len() || n != weights.len() {
return Err(TimeSeriesError::InvalidInput(
"x, y, and weights must have the same length".to_string(),
));
}
if degree == 1 {
let mut sum_w = F::zero();
let mut sum_wx = F::zero();
let mut sum_wy = F::zero();
let mut sum_wxx = F::zero();
let mut sum_wxy = F::zero();
for i in 0..n {
let w = weights[i];
let xi = x[i];
let yi = y[i];
sum_w = sum_w + w;
sum_wx = sum_wx + w * xi;
sum_wy = sum_wy + w * yi;
sum_wxx = sum_wxx + w * xi * xi;
sum_wxy = sum_wxy + w * xi * yi;
}
let denom = sum_w * sum_wxx - sum_wx * sum_wx;
if denom.abs() < F::from_f64(1e-10).expect("Operation failed") {
let intercept = sum_wy / sum_w;
let slope = F::zero();
return Ok(vec![intercept, slope]);
}
let intercept = (sum_wxx * sum_wy - sum_wx * sum_wxy) / denom;
let slope = (sum_w * sum_wxy - sum_wx * sum_wy) / denom;
return Ok(vec![intercept, slope]);
}
Err(TimeSeriesError::NotImplemented(format!(
"Polynomial fit of degree {degree} not implemented"
)))
}
#[allow(dead_code)]
fn calculate_robust_weights<F>(residuals: &[F], c: F) -> Result<Vec<F>>
where
F: Float + FromPrimitive + Debug,
{
let n = residuals.len();
if n == 0 {
return Err(TimeSeriesError::InsufficientData {
message: "Empty _residuals array".to_string(),
required: 1,
actual: 0,
});
}
let mut abs_residuals = residuals.to_vec();
for r in &mut abs_residuals {
*r = r.abs();
}
abs_residuals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_idx = n / 2;
let mad = if n.is_multiple_of(2) {
(abs_residuals[median_idx - 1] + abs_residuals[median_idx])
/ F::from_f64(2.0).expect("Operation failed")
} else {
abs_residuals[median_idx]
};
let s = mad / F::from_f64(0.6745).expect("Operation failed");
let scale = if s > F::from_f64(1e-10).expect("Operation failed") {
s
} else {
F::from_f64(1e-10).expect("Operation failed")
};
let mut weights = Vec::with_capacity(n);
for &r in residuals {
let u = r.abs() / (c * scale);
if u < F::one() {
let temp = F::one() - u * u;
weights.push(temp * temp);
} else {
weights.push(F::zero());
}
}
Ok(weights)
}
#[allow(dead_code)]
fn interpolate_nan_values<F>(ts: &mut Array1<F>) -> Result<()>
where
F: Float + FromPrimitive + Debug,
{
let n = ts.len();
if n == 0 {
return Ok(());
}
let mut last_valid = None;
for i in 0..n {
if !ts[i].is_nan() {
last_valid = Some(ts[i]);
} else if let Some(val) = last_valid {
ts[i] = val;
}
}
last_valid = None;
for i in (0..n).rev() {
if !ts[i].is_nan() {
last_valid = Some(ts[i]);
} else if let Some(val) = last_valid {
ts[i] = val;
}
}
for i in 0..n {
if ts[i].is_nan() {
ts[i] = F::zero();
}
}
Ok(())
}