Source code for mne_rt.decoding

"""Real-time single-trial decoding built from :mod:`mne.decoding` + scikit-learn.

:class:`RTDecode` wraps a scikit-learn ``sklearn.pipeline.Pipeline``
assembled from MNE's own decoding building blocks — :class:`mne.decoding.CSP`
or :class:`mne.decoding.Scaler` + :class:`mne.decoding.Vectorizer` — followed
by any scikit-learn classifier. It is fit **offline** on labelled epochs
(e.g. a calibration block or a prior session) and then queried **online**,
once per acquisition window. Unlike the built-in NF modalities in
:mod:`mne_rt.modalities`, which are pure functions of a window plus scalar
YAML config, a decoder is a stateful, externally-fitted object attached via
:meth:`~mne_rt.RTStream.set_decoder` — see the Notes section below.

Pipeline position::

    calibration epochs  →  RTDecode.fit(X, y)  →  RTStream.set_decoder()
    live window  →  RTStream._acquire loop  →  RTDecode.predict_proba()  →  nf_data["decode"]

Quick example::

    from mne_rt import RTDecode, RTStream

    # --- offline (calibration session) ---
    X_cal = ...  # shape (n_epochs, n_channels, n_times)
    y_cal = ...  # shape (n_epochs,), e.g. {"rest": 0, "motor_imagery": 1}
    decoder = RTDecode(info=epochs_info, spatial_filter="csp").fit(X_cal, y_cal)

    # --- real-time session ---
    nf = RTStream(subject_id="sub01", montage="easycap-M1", data_type="eeg")
    nf.connect_to_lsl(...)
    nf.set_decoder(decoder)
    nf.record_main(modality=["decode"], winsize=1.0)

Classes
-------
RTDecode
    Fit-offline / predict-online single-trial classifier for MNE-RT sessions.
"""

from __future__ import annotations

from typing import Any, Optional

import numpy as np
from mne.decoding import CSP, Scaler, Vectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline

from mne_rt._logging import verbose

_SPATIAL_FILTERS = ("csp", "scaler")


[docs] class RTDecode: """Fit-offline / predict-online single-trial decoder for MNE-RT sessions. Wraps a scikit-learn ``sklearn.pipeline.Pipeline`` built from MNE's decoding primitives so that a classifier trained on labelled calibration epochs can be queried once per real-time acquisition window, wired into :meth:`~mne_rt.RTStream.record_main` via :meth:`~mne_rt.RTStream.set_decoder` as the ``"decode"`` modality (see :mod:`mne_rt.modalities`). Parameters ---------- info : mne.Info Info describing the channels the decoder will be fit and queried on. Only used when ``spatial_filter="scaler"`` (passed to :class:`mne.decoding.Scaler` for channel-type-aware standardisation). estimator : sklearn-compatible classifier, default None Final pipeline step. Defaults to ``sklearn.linear_model.LogisticRegression()`` when ``None``. Must implement ``fit``/``predict``; ``predict_proba`` is additionally required for :meth:`predict_proba`. spatial_filter : "csp" | "scaler", default "csp" Feature-extraction step applied before *estimator*: * ``"csp"`` — :class:`mne.decoding.CSP`, log-variance of the ``n_components`` most discriminative spatial filters. Well suited to oscillatory/motor-imagery decoding; requires at least two classes. * ``"scaler"`` — :class:`mne.decoding.Scaler` (channel-type-aware standardisation) followed by :class:`mne.decoding.Vectorizer` (flattens to ``(n_epochs, n_channels * n_times)``). A simpler, filter-agnostic alternative when CSP's oscillatory-power assumption doesn't fit the decoding target (e.g. ERP decoding). n_components : int, default 4 Number of CSP spatial filters. Only used when ``spatial_filter="csp"``; must not exceed the number of channels. scalings : "mean" | "median" | dict, default "mean" Passed to :class:`mne.decoding.Scaler`. Only used when ``spatial_filter="scaler"``. Attributes ---------- pipeline : sklearn.pipeline.Pipeline The assembled, not-yet-fit (until :meth:`fit` is called) pipeline. classes_ : ndarray | None Class labels seen during :meth:`fit`; ``None`` before fitting. n_channels_ : int | None Number of channels seen during :meth:`fit`; ``None`` before fitting. Notes ----- As with :class:`~mne_rt.combiners.LearnedCombiner`, fitting happens **offline** against a full calibration recording — there is no incremental/online update during a live session. Re-fit and swap in a new :class:`RTDecode` instance between sessions instead. The fitted pipeline is sensitive to channel **count and order**: query windows must present the same channels, in the same order, as the ``X`` passed to :meth:`fit`. When used as the ``"decode"`` modality, this means :meth:`~mne_rt.RTStream.record_main`'s ``picks`` must resolve to the same channel selection the decoder was fit on — a channel-*count* mismatch is caught at ``record_main()`` start, but a same-count reordering is not detected and will silently degrade predictions. The ``"decode"`` modality always reports :meth:`predict_proba` (a continuous class probability), not :meth:`predict` (a discrete label) — unlike the other NF modalities' outputs, a discrete label is not meaningful after the EMA smoothing / z-scoring every modality's value passes through in :meth:`~mne_rt.RTStream.record_main`. Use :meth:`predict` directly for offline/standalone decoding outside a live session. Examples -------- >>> import numpy as np >>> from mne import create_info >>> from mne_rt import RTDecode >>> info = create_info(["C3", "Cz", "C4"], sfreq=256.0, ch_types="eeg") >>> rng = np.random.default_rng(0) >>> X = rng.standard_normal((20, 3, 256)) >>> y = np.array([0, 1] * 10) >>> decoder = RTDecode(info=info, spatial_filter="csp", n_components=2) >>> _ = decoder.fit(X, y) >>> proba = decoder.predict_proba(rng.standard_normal((3, 256))) >>> proba.shape (2,) """
[docs] def __init__( self, info: Optional[Any] = None, estimator: Optional[Any] = None, spatial_filter: str = "csp", n_components: int = 4, scalings: Any = "mean", ) -> None: if spatial_filter not in _SPATIAL_FILTERS: raise ValueError( f"`spatial_filter` must be one of {_SPATIAL_FILTERS}, got {spatial_filter!r}." ) if spatial_filter == "scaler" and info is None: raise ValueError("`info` is required when spatial_filter='scaler'.") self.info = info self.estimator = estimator if estimator is not None else LogisticRegression() self.spatial_filter = spatial_filter self.n_components = n_components self.scalings = scalings self.classes_: Optional[np.ndarray] = None self.n_channels_: Optional[int] = None self._supports_proba: bool = hasattr(self.estimator, "predict_proba") self.pipeline: Pipeline = self._build_pipeline()
def _build_pipeline(self) -> Pipeline: if self.spatial_filter == "csp": steps = [ ("csp", CSP(n_components=self.n_components, reg="ledoit_wolf", log=True)), ("clf", self.estimator), ] elif self.spatial_filter == "scaler": steps = [ ("scaler", Scaler(self.info, scalings=self.scalings)), ("vectorizer", Vectorizer()), ("clf", self.estimator), ] return Pipeline(steps) @property def fitted(self) -> bool: """Whether :meth:`fit` has been called.""" return self.classes_ is not None
[docs] @verbose def fit( self, X: np.ndarray, y: np.ndarray, verbose: Optional[Any] = None, ) -> "RTDecode": """Fit the pipeline on labelled calibration epochs. Parameters ---------- X : array, shape (n_epochs, n_channels, n_times) Calibration epochs. y : array, shape (n_epochs,) Class label per epoch. verbose : bool | str | None, default None Control logging verbosity for this call, e.g. ``False`` to silence CSP's per-class covariance-estimation messages. See :func:`mne_rt.set_log_level`. Returns ------- self : instance of RTDecode The fitted decoder, for chaining. """ X = np.asarray(X, dtype=np.float64) y = np.asarray(y) if X.ndim != 3: raise ValueError( f"`X` must be a 3D (n_epochs, n_channels, n_times) array, got shape {X.shape}." ) if X.shape[0] != y.shape[0]: raise ValueError(f"`X` has {X.shape[0]} epochs but `y` has {y.shape[0]} labels.") self.pipeline.fit(X, y) self.classes_ = self.pipeline.classes_ self.n_channels_ = X.shape[1] return self
def _check_fitted(self) -> None: if not self.fitted: raise RuntimeError( f"{type(self).__name__} must be fit() on calibration epochs " "before predict()/predict_proba() can be called." ) def _prepare_window(self, window: np.ndarray) -> np.ndarray: window = np.asarray(window, dtype=np.float64) if window.ndim != 2: raise ValueError( f"`window` must be a 2D (n_channels, n_times) array, got shape {window.shape}." ) return window[np.newaxis, ...]
[docs] def predict(self, window: np.ndarray) -> Any: """Predict the class label for a single window. Parameters ---------- window : array, shape (n_channels, n_times) One acquisition window, e.g. from ``stream.get_data(winsize)[0]``. Returns ------- label : scalar The predicted class label (same dtype as the ``y`` passed to :meth:`fit`). """ self._check_fitted() return self.pipeline.predict(self._prepare_window(window))[0]
[docs] def predict_proba(self, window: np.ndarray) -> np.ndarray: """Predict per-class probabilities for a single window. Parameters ---------- window : array, shape (n_channels, n_times) One acquisition window, e.g. from ``stream.get_data(winsize)[0]``. Returns ------- proba : array, shape (n_classes,) Class probabilities in :attr:`classes_` order. """ self._check_fitted() if not self._supports_proba: raise AttributeError( f"The estimator {type(self.estimator).__name__} does not implement " "predict_proba(); pass an estimator that does, e.g. " "LogisticRegression or SVC(probability=True)." ) return self.pipeline.predict_proba(self._prepare_window(window))[0]
def __repr__(self) -> str: status = "fitted" if self.fitted else "not fitted" return ( f"<RTDecode | spatial_filter={self.spatial_filter!r}, " f"estimator={type(self.estimator).__name__}, {status}>" )