Source code for mne_denoise.dss.variants.tsr

"""Time-shift DSS and smoothing variants."""

from __future__ import annotations

import warnings
from collections.abc import Sequence
from numbers import Integral, Real
from typing import TYPE_CHECKING, Any

import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted

if TYPE_CHECKING:
    from mne.epochs import BaseEpochs

from ..._cca import canonical_correlation
from ..._data import extract_data_from_mne, reconstruct_mne_object
from ..._logging import logger, verbose
from ..._spatial import fit_mixing_matrix
from ..._validation import check_channel_layout, check_positive_integer, resolve_sfreq
from ..denoisers import AverageBias, SmoothingBias
from ..linear import DSS

_ACTIONS = frozenset({"extract", "retain", "subtract"})
_DISTORTION_CONTROLS = frozenset({None, "cca"})


def _resolve_lags(
    *,
    lag_samples: Sequence[int] | None,
    lag_times: Sequence[float] | None,
    sfreq: float | None,
) -> tuple[tuple[int, ...], tuple[float, ...] | None, float | None]:
    """Resolve exactly one physical or sample lag declaration."""

    def _as_samples(values: Sequence[int]) -> tuple[int, ...]:
        if isinstance(values, str | bytes):
            raise TypeError(
                "lag_samples must be a one-dimensional sequence of integers"
            )
        array = np.asarray(values, dtype=object)
        if array.ndim != 1 or array.size == 0:
            raise ValueError("lag_samples must be a non-empty one-dimensional sequence")
        samples = []
        for value in array.tolist():
            if isinstance(value, bool) or not isinstance(value, Integral):
                raise TypeError("lag_samples must contain only integers")
            samples.append(int(value))
        resolved = tuple(sorted(set(samples)))
        if len(resolved) < 2 or 0 not in resolved:
            raise ValueError(
                "lag_samples must contain zero and at least one nonzero lag"
            )
        return resolved

    if (lag_samples is None) == (lag_times is None):
        raise ValueError("Provide exactly one of lag_samples or lag_times")
    if lag_samples is not None:
        samples = _as_samples(lag_samples)
        times = (
            tuple(sample / sfreq for sample in samples) if sfreq is not None else None
        )
        return samples, times, sfreq

    sfreq = resolve_sfreq(sfreq, None, context="lag_times")
    if isinstance(lag_times, str | bytes):
        raise TypeError("lag_times must be a one-dimensional sequence")
    raw_values = np.asarray(lag_times, dtype=object)
    if any(isinstance(value, bool) for value in raw_values.reshape(-1).tolist()):
        raise TypeError("lag_times must not contain booleans")
    values = np.asarray(lag_times, dtype=float)
    if values.ndim != 1 or values.size == 0 or not np.all(np.isfinite(values)):
        raise ValueError(
            "lag_times must be a non-empty finite one-dimensional sequence"
        )
    sample_values = values * sfreq
    rounded = np.rint(sample_values)
    if not np.allclose(sample_values, rounded, rtol=0.0, atol=1e-10):
        raise ValueError("Every lag_time must fall exactly on the sampling grid")
    samples = _as_samples([int(value) for value in rounded])
    return samples, tuple(sample / sfreq for sample in samples), sfreq


def _validate_epoched_array(data: Any) -> np.ndarray:
    """Return finite float data in channel-by-time-by-epoch orientation."""
    data = np.asarray(data, dtype=np.float64)
    if data.ndim != 3:
        raise ValueError(
            "TimeShiftDSS requires epoched data shaped (n_channels, n_times, n_epochs)"
        )
    if min(data.shape) < 1 or data.shape[1] < 2:
        raise ValueError("TimeShiftDSS input dimensions must be non-empty")
    if data.shape[2] < 2:
        raise ValueError("TimeShiftDSS requires at least two repeated epochs")
    if not np.all(np.isfinite(data)):
        raise ValueError("TimeShiftDSS input must contain only finite values")
    return data


def _lag_augment(
    data: np.ndarray, lags: tuple[int, ...]
) -> tuple[np.ndarray, int, int]:
    """Stack lag-major sensor blocks without wrapping or joining epochs."""
    start = max(lags)
    stop = data.shape[1] + min(lags)
    if stop - start < 2:
        raise ValueError(
            "The lag span leaves fewer than two common time samples per epoch"
        )
    blocks = [data[:, start - lag : stop - lag, :] for lag in lags]
    return np.concatenate(blocks, axis=0), start, stop


def _observation_weights(
    weights: np.ndarray | None,
    *,
    n_times: int,
    n_epochs: int,
    lags: tuple[int, ...],
    start: int,
    stop: int,
) -> np.ndarray:
    """Validate weights and dilate zeros through every lagged observation."""
    if weights is None:
        return np.ones((stop - start, n_epochs), dtype=np.float64)

    base = np.asarray(weights, dtype=np.float64)
    if base.shape == (n_times,):
        base = np.broadcast_to(base[:, np.newaxis], (n_times, n_epochs)).copy()
    elif base.shape != (n_times, n_epochs):
        raise ValueError(
            "sample_weight must have shape "
            f"({n_times},) or ({n_times}, {n_epochs}); got {base.shape}"
        )
    if not np.all(np.isfinite(base)):
        raise ValueError("sample_weight must contain only finite values")
    if np.any(base < 0):
        raise ValueError("sample_weight must be non-negative")

    shifted = [base[start - lag : stop - lag, :] for lag in lags]
    valid = np.minimum.reduce(shifted)
    if not np.any(valid > 0):
        raise ValueError("sample_weight leaves no positive-weight lag observations")
    return valid


[docs] class TimeShiftDSS(BaseEstimator, TransformerMixin): """Lag-augmented DSS estimator for repeated trials. The estimator augments each sensor with delayed copies, fits a trial-average DSS decomposition in the resulting spatiotemporal space, and supports source extraction or sensor-space retain/subtract operations. Parameters ---------- lag_samples : sequence of int or None, default=None Explicit lag grid in samples. It must contain zero and a nonzero lag. lag_times : sequence of float or None, default=None Explicit lag grid in seconds. Exactly one lag representation is required; values must lie on the sampling grid. sfreq : float or None, default=None Sampling frequency for array data when ``lag_times`` is used. n_components : int Number of lag-space DSS components. rank : int Whitening rank in the augmented feature space. n_select : int or None, default=None Leading components used by ``score``, ``retain``, or ``subtract``. component_action : {"extract", "retain", "subtract"}, default="extract" Source extraction or sensor-space operation. center : bool, default=False Fit and reuse one augmented-feature mean when true. distortion_control : {None, "cca"}, default=None Optional CCA rotation of the fitted reproducible subspace. reg : float, default=1e-9 Relative numerical rank tolerance. verbose : bool, str, int, or None, default=None Logging level. See Also -------- DSS Ordinary spatial DSS without lag augmentation. AverageBias Trial-average bias used by ordinary DSS. Notes ----- Input must be repeated-trial data: NumPy arrays use ``(n_channels, n_times, n_epochs)`` and MNE ``Epochs`` use their native layout. Lags define the common valid support; samples outside it are unchanged by sensor-space operations. A sampling frequency is required when lags are specified in seconds :footcite:p:`decheveigne2010_time_shift`. References ---------- .. footbibliography:: Examples -------- >>> import numpy as np >>> from mne_denoise.dss import TimeShiftDSS >>> rng = np.random.default_rng(0) >>> epochs = rng.standard_normal((8, 200, 20)) >>> model = TimeShiftDSS( ... lag_samples=[0, 1, 2], ... n_components=2, ... rank=4, ... n_select=1, ... component_action="extract", ... ) >>> sources = model.fit_transform(epochs) """ def __init__( self, *, lag_samples: Sequence[int] | None = None, lag_times: Sequence[float] | None = None, sfreq: float | None = None, n_components: int, rank: int, n_select: int | None = None, component_action: str = "extract", center: bool = False, distortion_control: str | None = None, reg: float = 1e-9, verbose: bool | str | int | None = None, ) -> None: self.lag_samples = lag_samples self.lag_times = lag_times self.sfreq = sfreq self.n_components = n_components self.rank = rank self.n_select = n_select self.component_action = component_action self.center = center self.distortion_control = distortion_control self.reg = reg self.verbose = verbose def _validate_parameters(self) -> None: """Validate constructor state without mutating it.""" check_positive_integer(self.n_components, name="n_components") check_positive_integer(self.rank, name="rank") if self.n_select is not None: check_positive_integer(self.n_select, name="n_select") if self.component_action not in _ACTIONS: raise ValueError(f"component_action must be one of {sorted(_ACTIONS)}") if self.component_action != "extract" and self.n_select is None: raise ValueError("n_select is required for retain and subtract actions") if not isinstance(self.center, bool): raise TypeError("center must be a bool") if self.distortion_control not in _DISTORTION_CONTROLS: raise ValueError("distortion_control must be None or 'cca'") if self.distortion_control == "cca" and self.n_select not in (None, 1): raise ValueError("CCA distortion control supports only n_select=1") if isinstance(self.reg, bool) or not isinstance(self.reg, Real): raise TypeError("reg must be a positive finite real number") if not np.isfinite(self.reg) or self.reg <= 0: raise ValueError("reg must be a positive finite real number") def _prepare_epochs( self, X: BaseEpochs | np.ndarray, *, fitting: bool, ) -> tuple[np.ndarray, float | None, str, Any, np.ndarray | None]: """Use shared extraction while enforcing the fitted epoch contract.""" data, data_sfreq, mne_type, orig, picks, ch_names = extract_data_from_mne( X, ch_names=None if fitting else self._mne_ch_names_, channel_first_epochs=True, exclude_bads=fitting, ) is_mne = mne_type == "epochs" if not is_mne and not isinstance(X, np.ndarray): raise TypeError("TimeShiftDSS supports MNE Epochs or NumPy arrays") if not fitting and is_mne != self._fit_was_mne_: raise TypeError("Transform input must use the container family used in fit") data = _validate_epoched_array(data) if fitting: self._fit_was_mne_ = is_mne self._mne_ch_names_ = ch_names if orig is not None: fitted = orig.copy() if picks is not None: fitted.pick(picks) self.info_ = fitted.info else: self.info_ = None else: if self.sfreq_ is not None and data_sfreq is not None: resolve_sfreq(self.sfreq_, data_sfreq) check_channel_layout( "TimeShiftDSS", n_channels=data.shape[0], fitted_n_channels=self.n_features_in_, ch_names=ch_names, fitted_ch_names=self._mne_ch_names_, ) return data, data_sfreq, mne_type, orig, picks
[docs] @verbose def fit( self, X: BaseEpochs | np.ndarray, y: None = None, *, sample_weight: np.ndarray | None = None, verbose: bool | str | int | None = None, ) -> TimeShiftDSS: """Fit the lag-augmented repeated-trial DSS decomposition. Parameters ---------- X : mne.BaseEpochs or ndarray Repeated-trial input. y : None, default=None Ignored for scikit-learn compatibility. sample_weight : ndarray or None, default=None Non-negative weights with shape ``(n_times,)`` or ``(n_times, n_epochs)``. verbose : bool, str, int, or None, default=None Logging level for this call. Returns ------- TimeShiftDSS The fitted estimator. """ del y self._validate_parameters() data, data_sfreq, _, _, _ = self._prepare_epochs(X, fitting=True) effective_sfreq = resolve_sfreq( self.sfreq, data_sfreq, context="lag_times", required=self.lag_times is not None, ) lags, lag_times, effective_sfreq = _resolve_lags( lag_samples=self.lag_samples, lag_times=self.lag_times, sfreq=effective_sfreq, ) augmented, start, stop = _lag_augment(data, lags) weights = _observation_weights( sample_weight, n_times=data.shape[1], n_epochs=data.shape[2], lags=lags, start=start, stop=stop, ) n_features = augmented.shape[0] rank = check_positive_integer(self.rank, name="rank") n_components = check_positive_integer(self.n_components, name="n_components") if rank > n_features: raise ValueError( f"rank={rank} exceeds {n_features} augmented sensor-lag features" ) if n_components > rank: raise ValueError("n_components cannot exceed rank") if self.n_select is not None and self.n_select > n_components: raise ValueError("n_select cannot exceed n_components") weight_flat = weights.reshape(-1) self.dss_ = DSS( bias=AverageBias(axis="epochs", weights=weights), n_components=n_components, rank=rank, reg=float(self.reg), normalize_input=False, center=self.center, cov_method="empirical", component_action="extract", # TimeShiftDSS owns the user-facing report. The nested ordinary # DSS fit is a numerical implementation detail. verbose="WARNING", ) # TimeShiftDSS owns the high-level report; the ordinary DSS fit is # only the lag-space numerical implementation. self.dss_.fit(augmented, weights=weights, verbose="WARNING") filters = self.dss_.filters_ eigenvalues = self.dss_.eigenvalues_ if filters.shape[0] < n_components: raise ValueError( f"n_components={n_components} exceeds the fitted numerical " f"whitening rank ({filters.shape[0]})" ) feature_mean = self.dss_.mean_ sources = self.dss_.transform(augmented) zero_index = lags.index(0) sensor_mean = feature_mean[ zero_index * data.shape[0] : (zero_index + 1) * data.shape[0] ] self.cca_correlations_ = None self.cca_rotation_ = None self.cca_source_mean_ = None if self.distortion_control == "cca": source_2d = sources.reshape(sources.shape[0], -1) sensor_2d = data[:, start:stop, :].reshape(data.shape[0], -1) cca_source_mean = (source_2d @ weight_flat / weight_flat.sum())[ :, np.newaxis ] cca_sensor_mean = (sensor_2d @ weight_flat / weight_flat.sum())[ :, np.newaxis ] source_coefficients, _, correlations, _, _ = canonical_correlation( source_2d.T, sensor_2d.T, sample_weight=weight_flat, rtol=float(self.reg), ) if correlations.size == 0: raise ValueError("CCA input has no variance above the rank threshold") rotation = source_coefficients[:, :1].T canonical = rotation @ (source_2d - cca_source_mean) canonical = canonical.reshape((1, *sources.shape[1:])) patterns = fit_mixing_matrix( data[:, start:stop, :] - cca_sensor_mean.reshape(data.shape[0], 1, 1), canonical, sample_weight=weights, ) self.cca_rotation_ = rotation self.cca_source_mean_ = cca_source_mean self.cca_correlations_ = correlations sensor_mean = cca_sensor_mean else: sensors = data[:, start:stop, :] - sensor_mean.reshape(data.shape[0], 1, 1) patterns = fit_mixing_matrix(sensors, sources, sample_weight=weights) effective_observations = weight_flat.sum() ** 2 / np.dot( weight_flat, weight_flat ) if n_features / effective_observations >= 0.5: warnings.warn( "The augmented feature count approaches the Kish effective " "observation count; TimeShiftDSS is at high risk of overfitting. " "Use held-out and surrogate validation.", UserWarning, stacklevel=2, ) self.filters_ = ( filters if self.cca_rotation_ is None else self.cca_rotation_ @ filters ) self.patterns_ = patterns self.eigenvalues_ = eigenvalues self.feature_mean_ = feature_mean self.sensor_mean_ = sensor_mean self.lag_samples_ = lags self.lag_times_ = lag_times self.sfreq_ = effective_sfreq self.n_features_in_ = data.shape[0] self.n_augmented_features_ = n_features self.positive_weight_observations_ = int(np.count_nonzero(weight_flat > 0)) self.effective_observations_ = float(effective_observations) self.valid_slice_ = slice(start, stop) logger.info( "TimeShiftDSS: lags=%s sample(s), rank=%d, components=%d, " "action=%s, distortion_control=%s, effective observations=%.1f.", self.lag_samples_, rank, self.filters_.shape[0], self.component_action, self.distortion_control or "none", self.effective_observations_, ) return self
def _sources(self, data: np.ndarray) -> tuple[np.ndarray, int, int]: """Apply the frozen lag-space transform.""" augmented, start, stop = _lag_augment(data, self.lag_samples_) sources = self.dss_.transform(augmented) sources_2d = sources.reshape(sources.shape[0], -1) if self.cca_rotation_ is not None: sources_2d = self.cca_rotation_ @ (sources_2d - self.cca_source_mean_) sources = sources_2d.reshape((sources_2d.shape[0], stop - start, data.shape[2])) return sources, start, stop
[docs] def score( self, X: BaseEpochs | np.ndarray, y: None = None, *, sample_weight: np.ndarray | None = None, ) -> float: """Score the fitted leading subspace on repeated trials. Parameters ---------- X : mne.BaseEpochs or ndarray Held-out repeated-trial input. y : None, default=None Ignored for scikit-learn compatibility. sample_weight : ndarray or None, default=None Optional observation weights. Returns ------- float Weighted trial-average power divided by weighted total power for the selected leading components. """ del y check_is_fitted(self, "dss_") if self.n_select is None: raise ValueError("score requires an explicit n_select") data, _, _, _, _ = self._prepare_epochs(X, fitting=False) sources, start, stop = self._sources(data) selected = sources[: int(self.n_select)] weights = _observation_weights( sample_weight, n_times=data.shape[1], n_epochs=data.shape[2], lags=self.lag_samples_, start=start, stop=stop, ) weight_per_time = weights.sum(axis=1) valid_times = weight_per_time > 0 average = np.einsum("cte,te->ct", selected, weights, optimize=True) average[:, valid_times] /= weight_per_time[valid_times] evoked_power = float( np.sum(average[:, valid_times] ** 2 * weight_per_time[valid_times]) / weight_per_time[valid_times].sum() ) total_power = float( np.sum(selected**2 * weights[np.newaxis, :, :]) / weights.sum() ) return evoked_power / total_power if total_power > 0 else 0.0
[docs] @verbose def transform( self, X: BaseEpochs | np.ndarray, *, verbose: bool | str | int | None = None, ) -> BaseEpochs | np.ndarray: """Apply the fitted lag-augmented DSS operation. Parameters ---------- X : mne.BaseEpochs or ndarray Repeated-trial input compatible with :meth:`fit`. verbose : bool, str, int, or None, default=None Logging level for this call. Returns ------- ndarray or mne.BaseEpochs Sources or sensor-space output according to ``component_action``; samples outside the common lag support are preserved. """ check_is_fitted(self, "dss_") self._validate_parameters() data, _, mne_type, orig, picks = self._prepare_epochs(X, fitting=False) sources, start, stop = self._sources(data) if self.component_action == "extract": if orig is not None: return np.transpose(sources, (2, 0, 1)) return sources count = int(self.n_select) selected = self.patterns_[:, :count] @ sources[:count].reshape(count, -1) selected = selected.reshape(data.shape[0], stop - start, data.shape[2]) if self.component_action == "retain": valid_output = selected + self.sensor_mean_.reshape(data.shape[0], 1, 1) else: valid_output = data[:, start:stop, :] - selected output = data.copy() output[:, start:stop, :] = valid_output if orig is None: return output return reconstruct_mne_object( np.transpose(output, (2, 0, 1)), orig, mne_type, picks=picks, )
[docs] def smooth_dss( window: int = 10, *, n_components: int | None = None, **dss_kws, ) -> DSS: """Create an ordinary DSS estimator with :class:`SmoothingBias`. Parameters ---------- window : int, default=10 Smoothing window in samples. n_components : int or None, default=None Number of DSS components. **dss_kws Additional keyword arguments for :class:`~mne_denoise.dss.DSS`. Returns ------- DSS Configured estimator. """ bias = SmoothingBias(window=window) return DSS(bias=bias, n_components=n_components, **dss_kws)
__all__ = ["TimeShiftDSS", "smooth_dss"]