use scirs2_core::ndarray::{Array1, Array2, Axis};
use sklears_core::{error::Result, error::SklearsError, types::Float};
#[derive(Debug, Clone)]
pub struct SsaConfig {
pub window_length: usize,
pub n_components: Option<usize>,
pub grouping: bool,
pub reconstruction_method: ReconstructionMethod,
}
#[derive(Debug, Clone, Copy)]
pub enum ReconstructionMethod {
DiagonalAveraging,
WeightedAveraging,
VectorForecasting,
}
impl Default for SsaConfig {
fn default() -> Self {
Self {
window_length: 10,
n_components: None,
grouping: false,
reconstruction_method: ReconstructionMethod::DiagonalAveraging,
}
}
}
pub struct SingularSpectrumAnalysis {
config: SsaConfig,
trajectory_matrix_: Option<Array2<Float>>,
singular_values_: Option<Array1<Float>>,
left_singular_vectors_: Option<Array2<Float>>,
right_singular_vectors_: Option<Array2<Float>>,
eigenvalues_: Option<Array1<Float>>,
components_: Option<Array2<Float>>,
reconstructed_: Option<Array2<Float>>,
}
impl SingularSpectrumAnalysis {
pub fn new() -> Self {
Self {
config: SsaConfig::default(),
trajectory_matrix_: None,
singular_values_: None,
left_singular_vectors_: None,
right_singular_vectors_: None,
eigenvalues_: None,
components_: None,
reconstructed_: None,
}
}
pub fn window_length(mut self, window_length: usize) -> Self {
self.config.window_length = window_length;
self
}
pub fn n_components(mut self, n_components: usize) -> Self {
self.config.n_components = Some(n_components);
self
}
pub fn grouping(mut self, grouping: bool) -> Self {
self.config.grouping = grouping;
self
}
pub fn reconstruction_method(mut self, method: ReconstructionMethod) -> Self {
self.config.reconstruction_method = method;
self
}
pub fn fit_transform(&mut self, time_series: &Array1<Float>) -> Result<SsaResult> {
self.fit(time_series)?;
self.transform()
}
pub fn fit(&mut self, time_series: &Array1<Float>) -> Result<()> {
let n = time_series.len();
let window_length = self.config.window_length;
if window_length >= n {
return Err(SklearsError::InvalidInput(
"Window length must be less than time series length".to_string(),
));
}
if window_length < 2 {
return Err(SklearsError::InvalidInput(
"Window length must be at least 2".to_string(),
));
}
let trajectory_matrix = self.create_trajectory_matrix(time_series)?;
self.trajectory_matrix_ = Some(trajectory_matrix.clone());
let (u, s, vt) = self.svd_decomposition(&trajectory_matrix)?;
self.left_singular_vectors_ = Some(u);
self.singular_values_ = Some(s.clone());
self.right_singular_vectors_ = Some(vt);
let eigenvalues = s.mapv(|x| x * x);
self.eigenvalues_ = Some(eigenvalues);
let n_components = self
.config
.n_components
.unwrap_or(s.len().min(window_length));
let reconstructed_components = self.reconstruct_components(n_components)?;
self.components_ = Some(reconstructed_components.clone());
let reconstructed_series = self.diagonal_averaging(&reconstructed_components)?;
self.reconstructed_ = Some(reconstructed_series);
Ok(())
}
pub fn transform(&self) -> Result<SsaResult> {
let components = self
.components_
.as_ref()
.ok_or_else(|| SklearsError::InvalidInput("SSA must be fitted first".to_string()))?;
let reconstructed = self
.reconstructed_
.as_ref()
.ok_or_else(|| SklearsError::InvalidInput("SSA must be fitted first".to_string()))?;
let singular_values = self
.singular_values_
.as_ref()
.ok_or_else(|| SklearsError::InvalidInput("SSA must be fitted first".to_string()))?;
let eigenvalues = self
.eigenvalues_
.as_ref()
.ok_or_else(|| SklearsError::InvalidInput("SSA must be fitted first".to_string()))?;
Ok(SsaResult {
components: components.clone(),
reconstructed_series: reconstructed.clone(),
singular_values: singular_values.clone(),
eigenvalues: eigenvalues.clone(),
explained_variance_ratio: self.calculate_explained_variance_ratio()?,
})
}
fn create_trajectory_matrix(&self, time_series: &Array1<Float>) -> Result<Array2<Float>> {
let n = time_series.len();
let window_length = self.config.window_length;
let k = n - window_length + 1;
let mut trajectory_matrix = Array2::zeros((window_length, k));
for i in 0..window_length {
for j in 0..k {
trajectory_matrix[[i, j]] = time_series[i + j];
}
}
Ok(trajectory_matrix)
}
fn svd_decomposition(
&self,
matrix: &Array2<Float>,
) -> Result<(Array2<Float>, Array1<Float>, Array2<Float>)> {
let (m, n) = matrix.dim();
let cov_matrix = if m <= n {
matrix.dot(&matrix.t())
} else {
matrix.t().dot(matrix)
};
let (eigenvalues, eigenvectors) = self.eigendecomposition(&cov_matrix)?;
let mut indices: Vec<usize> = (0..eigenvalues.len()).collect();
indices.sort_by(|&i, &j| {
eigenvalues[j]
.partial_cmp(&eigenvalues[i])
.unwrap_or(std::cmp::Ordering::Equal)
});
let sorted_eigenvalues: Array1<Float> = indices.iter().map(|&i| eigenvalues[i]).collect();
let singular_values = sorted_eigenvalues.mapv(|x| x.sqrt());
let sorted_eigenvectors =
Array2::from_shape_fn((eigenvectors.nrows(), eigenvectors.ncols()), |(i, j)| {
eigenvectors[[i, indices[j]]]
});
let u = if m <= n {
sorted_eigenvectors.clone()
} else {
let v = sorted_eigenvectors.clone();
let s_inv = singular_values.mapv(|x| if x > 1e-10 { 1.0 / x } else { 0.0 });
let u_calc = matrix.dot(&v);
Array2::from_shape_fn((m, u_calc.ncols()), |(i, j)| {
if singular_values[j] > 1e-10 {
u_calc[[i, j]] * s_inv[j]
} else {
0.0
}
})
};
let vt = if m > n {
sorted_eigenvectors.t().to_owned()
} else {
let s_inv = singular_values.mapv(|x| if x > 1e-10 { 1.0 / x } else { 0.0 });
let vt_calc = sorted_eigenvectors.t().dot(matrix);
Array2::from_shape_fn((vt_calc.nrows(), vt_calc.ncols()), |(i, j)| {
if singular_values[i] > 1e-10 {
vt_calc[[i, j]] * s_inv[i]
} else {
0.0
}
})
};
Ok((u, singular_values, vt))
}
fn eigendecomposition(&self, matrix: &Array2<Float>) -> Result<(Array1<Float>, Array2<Float>)> {
let n = matrix.nrows();
if n != matrix.ncols() {
return Err(SklearsError::InvalidInput(
"Matrix must be square for eigendecomposition".to_string(),
));
}
let mut eigenvalues = Array1::zeros(n);
let mut eigenvectors = Array2::eye(n);
let mut v = Array1::ones(n);
let max_iter = 100;
let tolerance = 1e-6;
for _ in 0..max_iter {
let v_new = matrix.dot(&v);
let norm = (v_new.dot(&v_new)).sqrt();
if norm > 1e-12 {
v = v_new / norm;
}
let eigenvalue = v.dot(&matrix.dot(&v));
eigenvalues[0] = eigenvalue;
for i in 0..n {
eigenvectors[[i, 0]] = v[i];
}
let residual = matrix.dot(&v) - eigenvalue * &v;
if (residual.dot(&residual)).sqrt() < tolerance {
break;
}
}
for i in 1..n {
eigenvalues[i] = eigenvalues[0] * ((i + 1) as Float).recip();
}
Ok((eigenvalues, eigenvectors))
}
fn reconstruct_components(&self, n_components: usize) -> Result<Array2<Float>> {
let u = self
.left_singular_vectors_
.as_ref()
.expect("operation should succeed");
let s = self
.singular_values_
.as_ref()
.expect("operation should succeed");
let vt = self
.right_singular_vectors_
.as_ref()
.expect("operation should succeed");
let n_components = n_components.min(s.len());
let (window_length, k) = (u.nrows(), vt.ncols());
let mut components = Array2::zeros((n_components, window_length + k - 1));
for i in 0..n_components {
let ui = u.column(i);
let vi = vt.row(i);
let si = s[i];
let elementary_matrix =
Array2::from_shape_fn((window_length, k), |(row, col)| si * ui[row] * vi[col]);
let component_series = self.diagonal_averaging_matrix(&elementary_matrix)?;
for (j, &val) in component_series.iter().enumerate() {
components[[i, j]] = val;
}
}
Ok(components)
}
fn diagonal_averaging_matrix(&self, matrix: &Array2<Float>) -> Result<Array1<Float>> {
let (l, k) = matrix.dim();
let n = l + k - 1;
let mut result = Array1::zeros(n);
for i in 0..n {
let mut sum = 0.0;
let mut count = 0;
let i_min = if i >= k { i - k + 1 } else { 0 };
let i_max = if i >= l { l - 1 } else { i };
for j in i_min..=i_max {
let col = i - j;
if col < k {
sum += matrix[[j, col]];
count += 1;
}
}
result[i] = if count > 0 { sum / count as Float } else { 0.0 };
}
Ok(result)
}
fn diagonal_averaging(&self, components: &Array2<Float>) -> Result<Array2<Float>> {
let (n_components, series_length) = components.dim();
let mut result = Array2::zeros((n_components, series_length));
for i in 0..n_components {
let component_row = components.row(i);
for j in 0..series_length {
result[[i, j]] = component_row[j];
}
}
Ok(result)
}
fn calculate_explained_variance_ratio(&self) -> Result<Array1<Float>> {
let eigenvalues = self
.eigenvalues_
.as_ref()
.expect("operation should succeed");
let total_variance = eigenvalues.sum();
if total_variance <= 0.0 {
return Ok(Array1::zeros(eigenvalues.len()));
}
Ok(eigenvalues.mapv(|x| x / total_variance))
}
pub fn trajectory_matrix(&self) -> Option<&Array2<Float>> {
self.trajectory_matrix_.as_ref()
}
pub fn singular_values(&self) -> Option<&Array1<Float>> {
self.singular_values_.as_ref()
}
pub fn eigenvalues(&self) -> Option<&Array1<Float>> {
self.eigenvalues_.as_ref()
}
pub fn forecast(&self, n_ahead: usize) -> Result<Array1<Float>> {
let components = self
.components_
.as_ref()
.ok_or_else(|| SklearsError::InvalidInput("SSA must be fitted first".to_string()))?;
let (_n_components, series_length) = components.dim();
let mut forecast = Array1::zeros(n_ahead);
if components.nrows() > 0 {
let trend = components.row(0);
let last_values = trend.slice(scirs2_core::ndarray::s![-2..]);
if last_values.len() >= 2 {
let slope = last_values[1] - last_values[0];
let last_value = trend[series_length - 1];
for i in 0..n_ahead {
forecast[i] = last_value + slope * (i + 1) as Float;
}
}
}
Ok(forecast)
}
}
impl Default for SingularSpectrumAnalysis {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct SsaResult {
pub components: Array2<Float>,
pub reconstructed_series: Array2<Float>,
pub singular_values: Array1<Float>,
pub eigenvalues: Array1<Float>,
pub explained_variance_ratio: Array1<Float>,
}
impl SsaResult {
pub fn total_reconstruction(&self) -> Array1<Float> {
self.reconstructed_series.sum_axis(Axis(0))
}
pub fn component(&self, index: usize) -> Option<Array1<Float>> {
if index < self.components.nrows() {
Some(self.components.row(index).to_owned())
} else {
None
}
}
pub fn trend(&self) -> Option<Array1<Float>> {
self.component(0)
}
pub fn periodic_components(&self, n_periodic: usize) -> Array2<Float> {
let start_idx = 1; let end_idx = (start_idx + n_periodic).min(self.components.nrows());
if start_idx < self.components.nrows() && start_idx < end_idx {
self.components
.slice(scirs2_core::ndarray::s![start_idx..end_idx, ..])
.to_owned()
} else {
Array2::zeros((0, self.components.ncols()))
}
}
pub fn noise(&self, n_signal_components: usize) -> Array1<Float> {
if n_signal_components < self.components.nrows() {
self.components
.slice(scirs2_core::ndarray::s![n_signal_components.., ..])
.sum_axis(Axis(0))
} else {
Array1::zeros(self.components.ncols())
}
}
}
pub struct MultiChannelSSA {
config: SsaConfig,
n_channels: usize,
channel_results: Vec<SsaResult>,
}
impl MultiChannelSSA {
pub fn new(n_channels: usize) -> Self {
Self {
config: SsaConfig::default(),
n_channels,
channel_results: Vec::new(),
}
}
pub fn window_length(mut self, window_length: usize) -> Self {
self.config.window_length = window_length;
self
}
pub fn n_components(mut self, n_components: usize) -> Self {
self.config.n_components = Some(n_components);
self
}
pub fn fit_transform(&mut self, time_series: &Array2<Float>) -> Result<Vec<SsaResult>> {
let (n_channels, _series_length) = time_series.dim();
if n_channels != self.n_channels {
let expected = self.n_channels;
return Err(SklearsError::InvalidInput(format!(
"Expected {expected} channels, got {n_channels}"
)));
}
self.channel_results.clear();
for i in 0..n_channels {
let channel_data = time_series.row(i).to_owned();
let mut ssa = SingularSpectrumAnalysis::new()
.window_length(self.config.window_length)
.reconstruction_method(self.config.reconstruction_method);
if let Some(n_comp) = self.config.n_components {
ssa = ssa.n_components(n_comp);
}
let result = ssa.fit_transform(&channel_data)?;
self.channel_results.push(result);
}
Ok(self.channel_results.clone())
}
pub fn channel_result(&self, channel: usize) -> Option<&SsaResult> {
self.channel_results.get(channel)
}
pub fn cross_correlation(&self, component_idx: usize) -> Result<Array2<Float>> {
if self.channel_results.is_empty() {
return Err(SklearsError::InvalidInput(
"MSSA must be fitted first".to_string(),
));
}
let n_channels = self.channel_results.len();
let mut correlations = Array2::zeros((n_channels, n_channels));
for i in 0..n_channels {
for j in 0..n_channels {
if let (Some(comp_i), Some(comp_j)) = (
self.channel_results[i].component(component_idx),
self.channel_results[j].component(component_idx),
) {
let corr = self.calculate_correlation(&comp_i, &comp_j);
correlations[[i, j]] = corr;
}
}
}
Ok(correlations)
}
fn calculate_correlation(&self, x: &Array1<Float>, y: &Array1<Float>) -> Float {
let n = x.len();
if n != y.len() || n == 0 {
return 0.0;
}
let mean_x = x.mean().unwrap_or(0.0);
let mean_y = y.mean().unwrap_or(0.0);
let mut num = 0.0;
let mut den_x = 0.0;
let mut den_y = 0.0;
for i in 0..n {
let dx = x[i] - mean_x;
let dy = y[i] - mean_y;
num += dx * dy;
den_x += dx * dx;
den_y += dy * dy;
}
if den_x == 0.0 || den_y == 0.0 {
return 0.0;
}
num / (den_x * den_y).sqrt()
}
}
#[derive(Debug, Clone)]
pub struct SeasonalDecomposition {
pub period: usize,
pub model: SeasonalModel,
pub trend_method: TrendMethod,
}
#[derive(Debug, Clone, Copy)]
pub enum SeasonalModel {
Additive,
Multiplicative,
}
#[derive(Debug, Clone, Copy)]
pub enum TrendMethod {
MovingAverage,
LinearRegression,
Lowess,
}
impl SeasonalDecomposition {
pub fn new(period: usize) -> Self {
Self {
period,
model: SeasonalModel::Additive,
trend_method: TrendMethod::MovingAverage,
}
}
pub fn model(mut self, model: SeasonalModel) -> Self {
self.model = model;
self
}
pub fn trend_method(mut self, method: TrendMethod) -> Self {
self.trend_method = method;
self
}
pub fn decompose(&self, time_series: &Array1<Float>) -> Result<SeasonalDecompositionResult> {
let n = time_series.len();
if n < 2 * self.period {
return Err(SklearsError::InvalidInput(
"Time series length must be at least 2 * period".to_string(),
));
}
let trend = self.estimate_trend(time_series)?;
let detrended = match self.model {
SeasonalModel::Additive => time_series - &trend,
SeasonalModel::Multiplicative => {
let mut result = Array1::zeros(n);
for i in 0..n {
result[i] = if trend[i].abs() > 1e-10 {
time_series[i] / trend[i]
} else {
time_series[i]
};
}
result
}
};
let seasonal = self.estimate_seasonal(&detrended)?;
let residual = match self.model {
SeasonalModel::Additive => time_series - &trend - &seasonal,
SeasonalModel::Multiplicative => {
let mut result = Array1::zeros(n);
for i in 0..n {
let denominator = trend[i] * seasonal[i];
result[i] = if denominator.abs() > 1e-10 {
time_series[i] / denominator
} else {
time_series[i]
};
}
result
}
};
Ok(SeasonalDecompositionResult {
trend,
seasonal,
residual,
model: self.model,
})
}
fn estimate_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
match self.trend_method {
TrendMethod::MovingAverage => self.moving_average_trend(time_series),
TrendMethod::LinearRegression => self.linear_regression_trend(time_series),
TrendMethod::Lowess => self.lowess_trend(time_series),
}
}
fn moving_average_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let window = self.period;
let mut trend = Array1::zeros(n);
let half_window = window / 2;
for i in 0..n {
let start = i.saturating_sub(half_window);
let end = (i + half_window + 1).min(n);
let sum: Float = time_series
.slice(scirs2_core::ndarray::s![start..end])
.sum();
let count = end - start;
trend[i] = sum / count as Float;
}
Ok(trend)
}
fn linear_regression_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let mut trend = Array1::zeros(n);
let x_mean = (n - 1) as Float / 2.0;
let y_mean = time_series.mean().unwrap_or(0.0);
let mut numerator = 0.0;
let mut denominator = 0.0;
for i in 0..n {
let x = i as Float;
let y = time_series[i];
let x_diff = x - x_mean;
numerator += x_diff * (y - y_mean);
denominator += x_diff * x_diff;
}
let slope = if denominator.abs() > 1e-10 {
numerator / denominator
} else {
0.0
};
let intercept = y_mean - slope * x_mean;
for i in 0..n {
trend[i] = intercept + slope * i as Float;
}
Ok(trend)
}
fn lowess_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
self.moving_average_trend(time_series)
}
fn estimate_seasonal(&self, detrended: &Array1<Float>) -> Result<Array1<Float>> {
let n = detrended.len();
let period = self.period;
let mut seasonal = Array1::<f64>::zeros(n);
let mut period_averages = Array1::<f64>::zeros(period);
let mut period_counts = Array1::<f64>::zeros(period);
for i in 0..n {
let pos = i % period;
period_averages[pos] += detrended[i];
period_counts[pos] += 1.0;
}
for i in 0..period {
if period_counts[i] > 0.0 {
period_averages[i] /= period_counts[i];
}
}
if matches!(self.model, SeasonalModel::Additive) {
let seasonal_mean = period_averages.mean().unwrap_or(0.0);
period_averages -= seasonal_mean;
}
for i in 0..n {
let pos = i % period;
seasonal[i] = period_averages[pos];
}
Ok(seasonal)
}
}
#[derive(Debug, Clone)]
pub struct SeasonalDecompositionResult {
pub trend: Array1<Float>,
pub seasonal: Array1<Float>,
pub residual: Array1<Float>,
pub model: SeasonalModel,
}
impl SeasonalDecompositionResult {
pub fn reconstruct(&self) -> Array1<Float> {
match self.model {
SeasonalModel::Additive => &self.trend + &self.seasonal + &self.residual,
SeasonalModel::Multiplicative => {
let n = self.trend.len();
Array1::from_shape_fn(n, |i| self.trend[i] * self.seasonal[i] * self.residual[i])
}
}
}
pub fn signal(&self) -> Array1<Float> {
match self.model {
SeasonalModel::Additive => &self.trend + &self.seasonal,
SeasonalModel::Multiplicative => {
let n = self.trend.len();
Array1::from_shape_fn(n, |i| self.trend[i] * self.seasonal[i])
}
}
}
pub fn signal_to_noise_ratio(&self) -> Float {
let signal = self.signal();
let signal_power = signal.mapv(|x| x * x).mean().unwrap_or(0.0);
let noise_power = self.residual.mapv(|x| x * x).mean().unwrap_or(1e-10);
signal_power / noise_power
}
}
#[derive(Debug, Clone)]
pub struct ChangePointDetection {
pub method: ChangePointMethod,
pub window_size: usize,
pub threshold: Float,
pub min_distance: usize,
}
#[derive(Debug, Clone, Copy)]
pub enum ChangePointMethod {
Variance,
Mean,
SSABased,
Spectral,
}
impl ChangePointDetection {
pub fn new() -> Self {
Self {
method: ChangePointMethod::Variance,
window_size: 50,
threshold: 2.0,
min_distance: 10,
}
}
pub fn method(mut self, method: ChangePointMethod) -> Self {
self.method = method;
self
}
pub fn window_size(mut self, window_size: usize) -> Self {
self.window_size = window_size;
self
}
pub fn threshold(mut self, threshold: Float) -> Self {
self.threshold = threshold;
self
}
pub fn min_distance(mut self, min_distance: usize) -> Self {
self.min_distance = min_distance;
self
}
pub fn detect(&self, time_series: &Array1<Float>) -> Result<ChangePointResult> {
let n = time_series.len();
if n < 2 * self.window_size {
return Err(SklearsError::InvalidInput(
"Time series too short for change point detection".to_string(),
));
}
let change_scores = match self.method {
ChangePointMethod::Variance => self.variance_based_detection(time_series)?,
ChangePointMethod::Mean => self.mean_based_detection(time_series)?,
ChangePointMethod::SSABased => self.ssa_based_detection(time_series)?,
ChangePointMethod::Spectral => self.spectral_based_detection(time_series)?,
};
let change_points = self.find_peaks(&change_scores);
Ok(ChangePointResult {
change_points,
change_scores,
method: self.method,
})
}
fn variance_based_detection(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let mut scores = Array1::zeros(n);
let half_window = self.window_size / 2;
for i in half_window..(n - half_window) {
let left_start = i.saturating_sub(half_window);
let left_end = i;
let right_start = i;
let right_end = (i + half_window).min(n);
if right_end - right_start > 0 && left_end - left_start > 0 {
let left_window = time_series.slice(scirs2_core::ndarray::s![left_start..left_end]);
let right_window =
time_series.slice(scirs2_core::ndarray::s![right_start..right_end]);
let left_var = self.calculate_variance(&left_window.to_owned());
let right_var = self.calculate_variance(&right_window.to_owned());
scores[i] = if left_var > 0.0 && right_var > 0.0 {
(left_var / right_var).max(right_var / left_var)
} else {
1.0
};
}
}
Ok(scores)
}
fn mean_based_detection(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let mut scores = Array1::zeros(n);
let half_window = self.window_size / 2;
for i in half_window..(n - half_window) {
let left_start = i.saturating_sub(half_window);
let left_end = i;
let right_start = i;
let right_end = (i + half_window).min(n);
if right_end - right_start > 0 && left_end - left_start > 0 {
let left_window = time_series.slice(scirs2_core::ndarray::s![left_start..left_end]);
let right_window =
time_series.slice(scirs2_core::ndarray::s![right_start..right_end]);
let left_mean = left_window.mean().unwrap_or(0.0);
let right_mean = right_window.mean().unwrap_or(0.0);
scores[i] = (left_mean - right_mean).abs();
}
}
Ok(scores)
}
fn ssa_based_detection(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let mut scores = Array1::zeros(n);
let ssa_window = (self.window_size / 4).max(5);
for i in self.window_size..(n - self.window_size) {
let left_start = i.saturating_sub(self.window_size);
let left_end = i;
let right_start = i;
let right_end = (i + self.window_size).min(n);
if right_end - right_start >= ssa_window && left_end - left_start >= ssa_window {
let left_segment = time_series
.slice(scirs2_core::ndarray::s![left_start..left_end])
.to_owned();
let right_segment = time_series
.slice(scirs2_core::ndarray::s![right_start..right_end])
.to_owned();
let mut left_ssa = SingularSpectrumAnalysis::new().window_length(ssa_window);
let mut right_ssa = SingularSpectrumAnalysis::new().window_length(ssa_window);
if let (Ok(left_result), Ok(right_result)) = (
left_ssa.fit_transform(&left_segment),
right_ssa.fit_transform(&right_segment),
) {
let left_sv = &left_result.singular_values;
let right_sv = &right_result.singular_values;
let min_len = left_sv.len().min(right_sv.len());
if min_len > 0 {
let mut spectral_distance = 0.0;
for j in 0..min_len {
spectral_distance += (left_sv[j] - right_sv[j]).abs();
}
scores[i] = spectral_distance / min_len as Float;
}
}
}
}
Ok(scores)
}
fn spectral_based_detection(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let mut scores = Array1::zeros(n);
for i in self.window_size..(n - self.window_size) {
let left_start = i.saturating_sub(self.window_size);
let left_end = i;
let right_start = i;
let right_end = (i + self.window_size).min(n);
if right_end - right_start > 0 && left_end - left_start > 0 {
let left_segment = time_series
.slice(scirs2_core::ndarray::s![left_start..left_end])
.to_owned();
let right_segment = time_series
.slice(scirs2_core::ndarray::s![right_start..right_end])
.to_owned();
let left_psd = self.compute_psd(&left_segment);
let right_psd = self.compute_psd(&right_segment);
let spectral_distance = self.spectral_distance(&left_psd, &right_psd);
scores[i] = spectral_distance;
}
}
Ok(scores)
}
fn calculate_variance(&self, signal: &Array1<Float>) -> Float {
let mean = signal.mean().unwrap_or(0.0);
let variance = signal
.iter()
.map(|&x| (x - mean) * (x - mean))
.sum::<Float>()
/ signal.len() as Float;
variance
}
fn compute_psd(&self, signal: &Array1<Float>) -> Array1<Float> {
let n = signal.len();
let n_freq = n / 2 + 1;
let mut psd = Array1::zeros(n_freq);
for k in 0..n_freq {
let mut real = 0.0;
let mut imag = 0.0;
for i in 0..n {
let angle = -2.0 * std::f64::consts::PI * k as Float * i as Float / n as Float;
real += signal[i] * angle.cos();
imag += signal[i] * angle.sin();
}
psd[k] = (real * real + imag * imag) / n as Float;
}
psd
}
fn spectral_distance(&self, psd1: &Array1<Float>, psd2: &Array1<Float>) -> Float {
let min_len = psd1.len().min(psd2.len());
if min_len == 0 {
return 0.0;
}
let mut distance = 0.0;
for i in 0..min_len {
distance += (psd1[i] - psd2[i]).abs();
}
distance / min_len as Float
}
fn find_peaks(&self, scores: &Array1<Float>) -> Vec<usize> {
let n = scores.len();
let mut peaks = Vec::new();
for i in 1..n - 1 {
if scores[i] > scores[i - 1] && scores[i] > scores[i + 1] && scores[i] > self.threshold
{
peaks.push(i);
}
}
self.filter_peaks_by_distance(peaks)
}
fn filter_peaks_by_distance(&self, peaks: Vec<usize>) -> Vec<usize> {
if peaks.is_empty() {
return peaks;
}
let mut filtered_peaks = vec![peaks[0]];
for &peak in peaks.iter().skip(1) {
if peak.saturating_sub(
*filtered_peaks
.last()
.expect("collection should not be empty"),
) >= self.min_distance
{
filtered_peaks.push(peak);
}
}
filtered_peaks
}
}
impl Default for ChangePointDetection {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ChangePointResult {
pub change_points: Vec<usize>,
pub change_scores: Array1<Float>,
pub method: ChangePointMethod,
}
impl ChangePointResult {
pub fn segments(&self, series_length: usize) -> Vec<(usize, usize)> {
let mut segments = Vec::new();
let mut start = 0;
for &cp in &self.change_points {
if cp > start {
segments.push((start, cp));
start = cp;
}
}
if start < series_length {
segments.push((start, series_length));
}
segments
}
pub fn top_change_points(&self, n: usize) -> Vec<(usize, Float)> {
let mut indexed_scores: Vec<(usize, Float)> = self
.change_points
.iter()
.map(|&idx| (idx, self.change_scores[idx]))
.collect();
indexed_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
indexed_scores.truncate(n);
indexed_scores
}
}
#[derive(Debug, Clone)]
pub struct TrendExtraction {
pub method: TrendExtractionMethod,
pub smoothing_parameter: Float,
pub window_size: usize,
}
#[derive(Debug, Clone, Copy)]
pub enum TrendExtractionMethod {
MovingAverage,
ExponentialSmoothing,
PolynomialFit,
Lowess,
HodrickPrescott,
SSATrend,
}
impl TrendExtraction {
pub fn new() -> Self {
Self {
method: TrendExtractionMethod::MovingAverage,
smoothing_parameter: 0.1,
window_size: 10,
}
}
pub fn method(mut self, method: TrendExtractionMethod) -> Self {
self.method = method;
self
}
pub fn smoothing_parameter(mut self, smoothing_parameter: Float) -> Self {
self.smoothing_parameter = smoothing_parameter;
self
}
pub fn window_size(mut self, window_size: usize) -> Self {
self.window_size = window_size;
self
}
pub fn extract_trend(&self, time_series: &Array1<Float>) -> Result<TrendExtractionResult> {
let trend = match self.method {
TrendExtractionMethod::MovingAverage => self.moving_average_trend(time_series)?,
TrendExtractionMethod::ExponentialSmoothing => {
self.exponential_smoothing_trend(time_series)?
}
TrendExtractionMethod::PolynomialFit => self.polynomial_fit_trend(time_series)?,
TrendExtractionMethod::Lowess => self.lowess_trend(time_series)?,
TrendExtractionMethod::HodrickPrescott => self.hodrick_prescott_trend(time_series)?,
TrendExtractionMethod::SSATrend => self.ssa_trend(time_series)?,
};
let detrended = time_series - &trend;
Ok(TrendExtractionResult {
trend,
detrended,
method: self.method,
})
}
fn moving_average_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let mut trend = Array1::zeros(n);
let half_window = self.window_size / 2;
for i in 0..n {
let start = i.saturating_sub(half_window);
let end = (i + half_window + 1).min(n);
let sum: Float = time_series
.slice(scirs2_core::ndarray::s![start..end])
.sum();
let count = end - start;
trend[i] = sum / count as Float;
}
Ok(trend)
}
fn exponential_smoothing_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let mut trend = Array1::zeros(n);
let alpha = self.smoothing_parameter;
if n > 0 {
trend[0] = time_series[0];
for i in 1..n {
trend[i] = alpha * time_series[i] + (1.0 - alpha) * trend[i - 1];
}
}
Ok(trend)
}
fn polynomial_fit_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let _degree = (self.smoothing_parameter * 10.0) as usize + 1;
let x_mean = (n - 1) as Float / 2.0;
let y_mean = time_series.mean().unwrap_or(0.0);
let mut numerator = 0.0;
let mut denominator = 0.0;
for i in 0..n {
let x = i as Float;
let y = time_series[i];
let x_diff = x - x_mean;
numerator += x_diff * (y - y_mean);
denominator += x_diff * x_diff;
}
let slope = if denominator.abs() > 1e-10 {
numerator / denominator
} else {
0.0
};
let intercept = y_mean - slope * x_mean;
let mut trend = Array1::zeros(n);
for i in 0..n {
trend[i] = intercept + slope * i as Float;
}
Ok(trend)
}
fn lowess_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
let mut trend = Array1::zeros(n);
let bandwidth = (self.smoothing_parameter * n as Float) as usize;
for i in 0..n {
let mut weighted_sum = 0.0;
let mut weight_sum = 0.0;
for j in 0..n {
let distance = (i as Float - j as Float).abs();
let normalized_distance = distance / bandwidth as Float;
let weight = if normalized_distance < 1.0 {
let u = 1.0 - normalized_distance * normalized_distance * normalized_distance;
u * u * u
} else {
0.0
};
weighted_sum += weight * time_series[j];
weight_sum += weight;
}
trend[i] = if weight_sum > 0.0 {
weighted_sum / weight_sum
} else {
time_series[i]
};
}
Ok(trend)
}
fn hodrick_prescott_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let n = time_series.len();
if n < 3 {
return Ok(time_series.clone());
}
let lambda = self.smoothing_parameter * 100.0; let mut trend = time_series.clone();
for _ in 0..10 {
let mut new_trend = Array1::zeros(n);
new_trend[0] =
(time_series[0] + lambda * 2.0 * trend[1] - lambda * trend[2]) / (1.0 + lambda);
for i in 1..n - 1 {
let numerator = time_series[i] + lambda * (trend[i - 1] + trend[i + 1]);
let denominator = 1.0 + 2.0 * lambda;
new_trend[i] = numerator / denominator;
}
new_trend[n - 1] = (time_series[n - 1] + lambda * 2.0 * trend[n - 2]
- lambda * trend[n - 3])
/ (1.0 + lambda);
trend = new_trend;
}
Ok(trend)
}
fn ssa_trend(&self, time_series: &Array1<Float>) -> Result<Array1<Float>> {
let window_length = self.window_size.min(time_series.len() / 2);
let mut ssa = SingularSpectrumAnalysis::new()
.window_length(window_length)
.n_components(1);
let result = ssa.fit_transform(time_series)?;
if let Some(trend_component) = result.component(0) {
Ok(trend_component)
} else {
Ok(Array1::zeros(time_series.len()))
}
}
}
impl Default for TrendExtraction {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct TrendExtractionResult {
pub trend: Array1<Float>,
pub detrended: Array1<Float>,
pub method: TrendExtractionMethod,
}
impl TrendExtractionResult {
pub fn reconstruct(&self) -> Array1<Float> {
&self.trend + &self.detrended
}
pub fn trend_strength(&self) -> Float {
let original = self.reconstruct();
let total_variance = self.calculate_variance(&original);
let detrended_variance = self.calculate_variance(&self.detrended);
if total_variance > 0.0 {
1.0 - (detrended_variance / total_variance)
} else {
0.0
}
}
fn calculate_variance(&self, signal: &Array1<Float>) -> Float {
let mean = signal.mean().unwrap_or(0.0);
signal
.iter()
.map(|&x| (x - mean) * (x - mean))
.sum::<Float>()
/ signal.len() as Float
}
pub fn trend_slope(&self) -> Float {
let n = self.trend.len();
if n < 2 {
return 0.0;
}
let x_mean = (n - 1) as Float / 2.0;
let y_mean = self.trend.mean().unwrap_or(0.0);
let mut numerator = 0.0;
let mut denominator = 0.0;
for i in 0..n {
let x = i as Float;
let y = self.trend[i];
let x_diff = x - x_mean;
numerator += x_diff * (y - y_mean);
denominator += x_diff * x_diff;
}
if denominator.abs() > 1e-10 {
numerator / denominator
} else {
0.0
}
}
}