"""Basic Singular Spectrum Analysis."""
from __future__ import annotations
from numbers import Real
from typing import Any
import numpy as np
from .._logging import logger, verbose
from .._validation import (
check_channel_first_data,
check_positive_integer,
check_positive_real,
)
from ..progress import _emit_progress, _ProgressCallback, _validate_callback
from ._common import (
_BaseSSATransformer,
_diagonal_average,
_resolve_window_length,
_trajectory_matrix,
)
[docs]
def ssa_decompose(
x: np.ndarray,
window_length: int | None = None,
*,
window_seconds: float | None = None,
sfreq: float | None = None,
max_window: int = 100,
) -> tuple[np.ndarray, dict[str, Any]]:
"""Decompose a one-dimensional series into Basic SSA components.
Parameters
----------
x : array-like, shape (n_times,)
Finite scalar time series.
window_length : int | None, default=None
Embedding dimension in samples. If None, choose it automatically.
window_seconds : float | None, default=None
Embedding duration in seconds; mutually exclusive with window_length and
requiring sfreq.
sfreq : float | None, default=None
Sampling frequency in Hz, used with window_seconds and automatic selection.
max_window : int, default=100
Maximum automatic embedding dimension.
Returns
-------
components : ndarray, shape (n_components, n_times)
Reconstructed elementary components in decreasing singular-value order.
info : dict
Resolved window, trajectory shape, singular values, and numerical rank.
Notes
-----
The trajectory matrix is decomposed by SVD and reconstructed by anti-diagonal
averaging. :footcite:p:`golyandina_zhigljavsky2013_ssa`.
References
----------
.. footbibliography::
Raises
------
TypeError
If a scalar parameter has an invalid type.
ValueError
If x or the requested embedding is invalid.
"""
x = np.asarray(x, dtype=np.float64)
if x.ndim != 1:
raise ValueError("x must be one-dimensional")
if not np.isfinite(x).all():
raise ValueError("x must contain only finite values")
resolved = _resolve_window_length(
x.size,
window_length,
window_seconds=window_seconds,
sfreq=sfreq,
max_window=max_window,
)
trajectory = _trajectory_matrix(x, resolved)
left, singular_values, right_t = np.linalg.svd(trajectory, full_matrices=False)
components = np.stack(
[
_diagonal_average(
singular_values[index] * np.outer(left[:, index], right_t[index])
)
for index in range(singular_values.size)
]
)
tolerance = (
np.finfo(float).eps
* max(trajectory.shape)
* (singular_values[0] if singular_values.size else 0.0)
)
return components, {
"singular_values": singular_values,
"window_length": resolved,
"trajectory_shape": trajectory.shape,
"rank": int(np.count_nonzero(singular_values > tolerance)),
}
[docs]
def ssa_w_correlation(components: np.ndarray, window_length: int) -> np.ndarray:
"""Compute weighted correlations between SSA reconstructions.
Parameters
----------
components : array-like, shape (n_components, n_times)
Reconstructed components.
window_length : int
Embedding dimension used to obtain components.
Returns
-------
correlation : ndarray, shape (n_components, n_components)
Symmetric weighted-correlation matrix; zero-energy components have zero
rows and columns.
Notes
-----
The weights are the anti-diagonal multiplicities of the trajectory matrix.
This is a separability diagnostic, not an artifact-selection rule.
:footcite:p:`golyandina_zhigljavsky2013_ssa`.
References
----------
.. footbibliography::
"""
components = np.asarray(components, dtype=np.float64)
if components.ndim != 2 or components.shape[1] < 1:
raise ValueError("components must have shape (n_components, n_times)")
window_length = check_positive_integer(window_length, name="window_length")
n_times = components.shape[1]
n_columns = n_times - window_length + 1
if window_length > n_columns or window_length < 2:
raise ValueError("window_length must satisfy 2 <= L <= K")
indices = np.arange(n_times)
weights = np.minimum.reduce(
(
indices + 1,
np.full(n_times, window_length),
np.full(n_times, n_columns),
n_times - indices,
)
).astype(float)
weighted = components * np.sqrt(weights)
gram = weighted @ weighted.T
norms = np.sqrt(np.clip(np.diag(gram), 0.0, None))
denominator = np.outer(norms, norms)
correlation = np.divide(
gram,
denominator,
out=np.zeros_like(gram),
where=denominator > 0,
)
return np.clip(correlation, -1.0, 1.0)
def _check_frequency_parameters(
sfreq: float,
drop_freq_max: float,
drop_band: tuple[float, float] | None,
n_check: int | None,
) -> tuple[float, float, tuple[float, float] | None, int | None]:
"""Validate the package-specific Basic SSA grouping rule."""
sfreq = check_positive_real(sfreq, name="sfreq")
if isinstance(drop_freq_max, bool) or not isinstance(drop_freq_max, Real):
raise TypeError("drop_freq_max must be a finite number")
drop_freq_max = float(drop_freq_max)
if not np.isfinite(drop_freq_max):
raise ValueError("drop_freq_max must be finite")
nyquist = sfreq / 2.0
if not 0.0 <= drop_freq_max <= nyquist:
raise ValueError("drop_freq_max must be between 0 and Nyquist")
if drop_band is not None:
if not isinstance(drop_band, tuple) or len(drop_band) != 2:
raise TypeError("drop_band must be a (low, high) tuple or None")
low, high = drop_band
if any(isinstance(v, bool) or not isinstance(v, Real) for v in (low, high)):
raise TypeError("drop_band bounds must be finite numbers")
low, high = float(low), float(high)
if not np.isfinite((low, high)).all():
raise ValueError("drop_band bounds must be finite numbers")
if not 0.0 <= low < high <= nyquist:
raise ValueError("drop_band must satisfy 0 <= low < high <= Nyquist")
drop_band = (low, high)
if n_check is not None:
n_check = check_positive_integer(n_check, name="n_check")
return sfreq, drop_freq_max, drop_band, n_check
[docs]
def ssa_clean_channel(
x: np.ndarray,
sfreq: float,
window_length: int | None = None,
drop_freq_max: float = 3.0,
drop_band: tuple[float, float] | None = None,
n_check: int | None = None,
max_window: int = 100,
*,
window_seconds: float | None = None,
return_info: bool = False,
) -> np.ndarray | tuple[np.ndarray, dict[str, Any]]:
"""Clean one channel by frequency-grouping Basic SSA components.
Parameters
----------
x : array-like, shape (n_times,)
Finite scalar time series.
sfreq : float
Sampling frequency in Hz.
window_length : int | None, default=None
Embedding dimension in samples; None selects it automatically.
drop_freq_max : float, default=3.0
Upper bound, in Hz, for the dominant-frequency rejection rule when
drop_band is None.
drop_band : tuple of float | None, default=None
Inclusive dominant-frequency interval to reject, in Hz.
n_check : int | None, default=None
Number of leading numerical-rank components to inspect; None inspects all.
max_window : int, default=100
Maximum automatic embedding dimension.
window_seconds : float | None, default=None
Embedding duration in seconds, mutually exclusive with window_length.
return_info : bool, default=False
If True, also return decomposition and grouping diagnostics.
Returns
-------
x_clean : ndarray, shape (n_times,)
Cleaned time series.
info : dict
Diagnostics returned only when return_info=True.
Notes
-----
Dominant frequency is the largest real-FFT magnitude bin; DC is included.
The frequency grouping and thresholds are package heuristics.
"""
sfreq, drop_freq_max, drop_band, n_check = _check_frequency_parameters(
sfreq, drop_freq_max, drop_band, n_check
)
if not isinstance(return_info, bool):
raise TypeError("return_info must be a bool")
components, decomposition = ssa_decompose(
x,
window_length,
window_seconds=window_seconds,
sfreq=sfreq,
max_window=max_window,
)
x = np.asarray(x, dtype=np.float64)
spectrum = np.abs(np.fft.rfft(components, axis=-1))
bins = np.fft.rfftfreq(x.size, 1.0 / sfreq)
dominant_frequencies = bins[np.argmax(spectrum, axis=-1)]
candidates = np.arange(decomposition["rank"])
if n_check is not None:
candidates = candidates[:n_check]
if drop_band is None:
selected = candidates[dominant_frequencies[candidates] <= drop_freq_max]
else:
selected = candidates[
(dominant_frequencies[candidates] >= drop_band[0])
& (dominant_frequencies[candidates] <= drop_band[1])
]
artifact = components[selected].sum(axis=0) if selected.size else np.zeros_like(x)
cleaned = x - artifact
info = {
**decomposition,
"components": components,
"dominant_frequencies": dominant_frequencies,
"dropped_indices": selected,
"dropped_frequencies": dominant_frequencies[selected],
"artifact": artifact,
"frequency_resolution": sfreq / x.size,
}
if return_info:
return cleaned, info
return cleaned
[docs]
@verbose
def compute_basic_ssa(
X: np.ndarray,
sfreq: float,
window_length: int | None = None,
drop_freq_max: float = 3.0,
drop_band: tuple[float, float] | None = None,
n_check: int | None = None,
max_window: int = 100,
*,
window_seconds: float | None = None,
callback=None,
verbose: bool | str | int | None = None,
) -> tuple[np.ndarray, dict[str, Any]]:
"""Apply frequency-guided Basic SSA independently to each channel.
Parameters
----------
X : array-like, shape (n_channels, n_times)
Finite channel-first data. Channels are not mixed.
sfreq : float
Sampling frequency in Hz.
window_length : int | None, default=None
Embedding dimension in samples; None selects it automatically.
drop_freq_max : float, default=3.0
Dominant-frequency upper bound in Hz.
drop_band : tuple of float | None, default=None
Inclusive dominant-frequency rejection interval in Hz.
n_check : int | None, default=None
Number of leading numerical-rank components to inspect.
max_window : int, default=100
Maximum automatic embedding dimension.
window_seconds : float | None, default=None
Embedding duration in seconds, mutually exclusive with window_length.
callback : callable | None, default=None
Synchronous callback after each channel; return values are ignored and
callback exceptions propagate.
verbose : bool, str, int, or None, default=None
Logging level.
Returns
-------
X_clean : ndarray, shape (n_channels, n_times)
Independently cleaned channels.
info : dict
Per-channel selection diagnostics and the resolved operating point.
Notes
-----
This is repeated univariate SSA, not a multivariate decomposition.
"""
callback = _validate_callback(callback)
X = check_channel_first_data(
X, name="SSA", allow_epochs=False, min_channels=1, min_times=2
)
if X.shape[-1] < 3:
raise ValueError("SSA requires at least 3 time samples")
cleaned = np.empty_like(X)
records = []
for channel_idx, channel in enumerate(X):
result, info = ssa_clean_channel(
channel,
sfreq,
window_length,
drop_freq_max,
drop_band,
n_check,
max_window,
window_seconds=window_seconds,
return_info=True,
)
cleaned[channel_idx] = result
records.append(info)
_emit_progress(
callback,
method="basic_ssa",
stage="channel",
current=channel_idx + 1,
total=X.shape[0],
component=None,
metric=float(len(info["dropped_indices"])),
)
info = {
"method": "basic-frequency",
"dropped_counts": np.array(
[len(record["dropped_indices"]) for record in records], dtype=int
),
"dropped_freqs": [record["dropped_frequencies"].tolist() for record in records],
"dropped_frequencies": [
record["dropped_frequencies"].copy() for record in records
],
"dropped_indices": [record["dropped_indices"].copy() for record in records],
"dominant_frequencies": [
record["dominant_frequencies"].copy() for record in records
],
"singular_values": [record["singular_values"].copy() for record in records],
"window_length": records[0]["window_length"],
"frequency_resolution": records[0]["frequency_resolution"],
}
logger.info(
"Basic SSA: window=%d samples, channels=%d, dropped=%d component(s) "
"(mean %.1f/channel).",
info["window_length"],
X.shape[0],
int(np.sum(info["dropped_counts"])),
float(np.mean(info["dropped_counts"])),
)
return cleaned, info
[docs]
class SingularSpectrumAnalysis(_BaseSSATransformer):
"""Frequency-guided, channel-wise Basic SSA transformer.
Parameters
----------
sfreq : float | None, default=None
Sampling frequency in Hz. NumPy input requires it; MNE input supplies it
from metadata.
window_length : int | None, default=None
Embedding dimension in samples.
drop_freq_max : float, default=3.0
Dominant-frequency upper bound in Hz.
drop_band : tuple of float | None, default=None
Inclusive dominant-frequency rejection interval in Hz.
n_check : int | None, default=None
Number of leading numerical-rank components to inspect.
max_window : int, default=100
Maximum automatic embedding dimension.
verbose : bool, str, int, or None, default=None
Logging level.
window_seconds : float | None, default=None
Embedding duration in seconds, mutually exclusive with window_length.
Attributes
----------
sfreq_ : float
Sampling frequency used for fitting.
n_channels_in_ : int
Number of fitted data channels.
ch_names_in_ : tuple of str | None
Fitted MNE channel names and order, or None for arrays.
diagnostics_ : dict | list of dict
Diagnostics from the most recent transform.
dropped_counts_ : ndarray
Number of rejected components per channel or epoch and channel.
dropped_frequencies_ : list
Dominant frequencies of rejected components.
See Also
--------
LocalSingularSpectrumAnalysis
Local delay-vector clustering and reconstruction.
compute_basic_ssa
One-shot Basic SSA interface.
Notes
-----
The estimator is transductive: fit records the operating point and channel
layout, while each transform decomposes its input records independently.
:footcite:p:`golyandina_zhigljavsky2013_ssa`.
References
----------
.. footbibliography::
Examples
--------
>>> import numpy as np
>>> from mne_denoise.ssa import SingularSpectrumAnalysis
>>> rng = np.random.default_rng(0)
>>> data = rng.standard_normal((8, 2000))
>>> model = SingularSpectrumAnalysis(sfreq=250.0, drop_freq_max=3.0)
>>> clean = model.fit_transform(data)
"""
_requires_sfreq = True
_progress_method = "basic_ssa"
def __init__(
self,
sfreq: float | None = None,
window_length: int | None = None,
drop_freq_max: float = 3.0,
drop_band: tuple[float, float] | None = None,
n_check: int | None = None,
max_window: int = 100,
verbose: bool | str | int | None = None,
*,
window_seconds: float | None = None,
) -> None:
self.sfreq = sfreq
self.window_length = window_length
self.drop_freq_max = drop_freq_max
self.drop_band = drop_band
self.n_check = n_check
self.max_window = max_window
self.verbose = verbose
self.window_seconds = window_seconds
def _validate_fit_parameters(self, data: np.ndarray, sfreq: float) -> None:
_check_frequency_parameters(
sfreq, self.drop_freq_max, self.drop_band, self.n_check
)
_resolve_window_length(
data.shape[-1],
self.window_length,
window_seconds=self.window_seconds,
sfreq=sfreq,
max_window=self.max_window,
)
def _compute_record(
self,
data: np.ndarray,
sfreq: float,
*,
callback: _ProgressCallback | None = None,
) -> tuple[np.ndarray, dict[str, Any]]:
# The estimator owns one aggregate SSA report; suppress the core's
# standalone summary for each record while retaining its computation.
return compute_basic_ssa(
data,
sfreq,
self.window_length,
self.drop_freq_max,
self.drop_band,
self.n_check,
self.max_window,
window_seconds=self.window_seconds,
callback=callback,
verbose="WARNING",
)
def _set_diagnostic_attributes(
self, records: list[dict[str, Any]], *, epoched: bool
) -> None:
if epoched:
self.dropped_counts_ = np.stack(
[record["dropped_counts"] for record in records]
)
self.dropped_frequencies_ = [
record["dropped_frequencies"] for record in records
]
mean_count = float(np.mean(self.dropped_counts_))
else:
self.dropped_counts_ = records[0]["dropped_counts"]
self.dropped_frequencies_ = records[0]["dropped_frequencies"]
mean_count = float(np.mean(self.dropped_counts_))
window = records[0].get("window_length", self.window_length or "auto")
logger.info(
"Basic SSA: window=%s samples, channels=%d, dropped=%d component(s) "
"(mean %.1f/channel).",
window,
self.n_channels_in_,
int(np.sum(self.dropped_counts_)),
mean_count,
)