Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/source/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Enhancements
- Re-enable auto-execution of the Riemannian Artifact Rejection tutorial (``examples/advanced_examples/plot_riemannian_artifact_rejection.py``) now that pyRiemann 0.11 is on PyPI with per-potato metrics and ``method_combination`` support on ``PotatoField`` (by `Bruno Aristimunha`_)
- Use NEMAR as the default download source for datasets with an assigned ``nemar_id``, while preserving existing dataset-specific downloaders as a fallback (by `Bruno Aristimunha`_).
- Add :class:`moabb.datasets.preprocessing.EuclideanAlignment`, a trial-level Euclidean Alignment transformer (He & Wu 2020; Junqueira et al. 2024) that whitens each trial by the inverse square root of the Euclidean mean covariance to remove per-domain covariance shift before a (deep) model sees the data. Inductive and leakage-free by default (``fit`` learns the reference from training trials, ``transform`` re-applies it to unseen trials); ``fit_transform`` gives the transductive, per-recording form. Accepts an :class:`mne.BaseEpochs` or an ``(n_trials, n_channels, n_times)`` ndarray, uses a shrinkage covariance estimator (``"lwf"``) for robustness, and adds no new dependency (``pyriemann >= 0.11`` is already required). Distinct from :class:`pyriemann.transfer.TLCenter`, which recenters covariance *matrices* (:gh:`1108` by `Bruno Aristimunha`_).
- Add :file:`examples/advanced_examples/plot_geometry_aware_recentering.py`, reproducing the within/cross double dissociation showing unsupervised test-time tangent-space recentering (``TangentSpace(tsupdate=True)``) drives the cross-session gain in EEG motor-imagery decoding, with no corresponding within-session effect (Rahimipour et al., in preparation) (by `Meysam Rahimipour`_).
- Drive cross-validation folds with any stock scikit-learn cross-validator passed as ``cv_class``, controlled by a ``groups`` argument — a metadata column name, a list of column names (compound key, e.g. ``["subject", "session"]``), or a callable ``metadata -> array`` — together with callable ``cv_kwargs`` resolved against the metadata (e.g. ``cv_class=PredefinedSplit`` with a ``test_fold`` callable to target a single fold). ``groups`` is exposed on :class:`moabb.evaluations.WithinSessionEvaluation`, :class:`moabb.evaluations.WithinSubjectEvaluation`, :class:`moabb.evaluations.CrossSessionEvaluation` and :class:`moabb.evaluations.CrossSubjectEvaluation` and threaded to their splitters; each splitter keeps its default grouping (``"subject"`` / ``"session"`` / labels) when ``groups`` is ``None``. :class:`moabb.evaluations.splitters.CrossDatasetSplitter` gains ``groups`` (its ``group_column`` argument is now a deprecated alias) (:gh:`1104` by `Bruno Aristimunha`_).

API changes
Expand Down Expand Up @@ -852,6 +853,7 @@ API changes
.. _Anton Andreev: https://github.com/toncho11
.. _Igor Carrara: https://github.com/carraraig
.. _Bruno Aristimunha: https://github.com/bruAristimunha
.. _Meysam Rahimipour: https://github.com/rahimipour-meysam-NeurIPS
.. _Alexandre Barachant: https://github.com/alexandrebarachant
.. _Quentin Barthelemy: https://github.com/qbarthelemy
.. _Erik Bjäreholt: https://github.com/ErikBjare
Expand Down
191 changes: 191 additions & 0 deletions examples/advanced_examples/plot_geometry_aware_recentering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
r"""
=====================================================
Geometry-Aware recentering for cross-session transfer
=====================================================
EEG covariance statistics drift from session to session (electrode
repositioning, impedance changes, baseline arousal): the same mental task
produces differently-shaped data on each recording. A classical Riemannian
tangent-space pipeline (:class:`pyriemann.tangentspace.TangentSpace`) is
already a strong baseline for motor-imagery decoding, but its tangent-space
reference point is normally *frozen* at training time — so it does not
correct for this drift. Setting ``tsupdate=True`` re-estimates that reference
point, unsupervised, from each new (unlabelled) evaluation batch: a
**single, label-free correction** cheap enough to put in front of any linear
classifier [1]_.

In a controlled, feature-matched benchmark across eight public MOABB
motor-imagery datasets, Rahimipour, Yang & Van Hulle (in preparation) [1]_
show that this recentering step accounts for a large, statistically decisive
cross-session gain (Cohen's d = 1.06-1.50, all p_FDR < 1.1e-12) over its
recentering-free twin, while the same two pipelines are statistically
indistinguishable within-session (d ~ 0) — a double dissociation showing the
gain is attributable to recentering specifically, not to the choice of final
classifier. The same study found that substantially more complex deep
sequence models (a bidirectional Mamba mixture-of-experts, an SPDNet-style
network), given the *same* covariance features and a fair training budget,
did not recover this gain and in fact underperformed the simple recentering
pipeline in both protocols.

This example reproduces the core comparison — recentering on vs. off — on
the workhorse tangent-space + logistic-regression pipeline, following the
within/cross double-dissociation design of [1]_.

Note
----
Unlike :class:`moabb.datasets.preprocessing.EuclideanAlignment`, which
whitens raw *trials* before any covariance step, this example acts on the
tangent-space *reference point* used to linearise the SPD manifold — the
mechanism is closely related to :class:`pyriemann.transfer.TLCenter`
(matrix/tangent-vector recentering for transfer learning), but is expressed
here directly via ``TangentSpace(tsupdate=True)`` on a single target session,
matching how the study in [1]_ evaluates it under MOABB's
:class:`~moabb.evaluations.CrossSessionEvaluation` /
:class:`~moabb.evaluations.WithinSessionEvaluation` protocols, with no
domain-encoding machinery required.
"""

# Authors: Meysam Rahimipour <rahimipour.2110739@studenti.uniroma1.it>
#
# License: BSD (3-clause)

import matplotlib.pyplot as plt
import mne
import numpy as np
from pyriemann.estimation import Covariances
from pyriemann.tangentspace import TangentSpace
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

import moabb
from moabb.datasets import BNCI2014_001
from moabb.evaluations import CrossSessionEvaluation, WithinSessionEvaluation
from moabb.paradigms import LeftRightImagery


moabb.set_log_level("info")
mne.set_log_level("WARNING") # keep the gallery output readable

###############################################################################
# Build the two pipelines
# ------------------------
#
# Both pipelines are identical except for one flag: whether the tangent-space
# reference point is re-estimated at transform time (``tsupdate=True``,
# "Geometry-Aware") or frozen from the training data ("TS + LR", the
# recentering-free twin). Holding the classifier and covariance estimator
# fixed isolates recentering as the only difference between the two.


def make_geometry_aware(tsupdate):
return make_pipeline(
Covariances(estimator="oas"),
TangentSpace(metric="riemann", tsupdate=tsupdate),
LogisticRegression(max_iter=1000, C=1.0),
)


pipelines = {
"Geometry-Aware (recenter)": make_geometry_aware(tsupdate=True),
"TS + LR (no recenter)": make_geometry_aware(tsupdate=False),
}

###############################################################################
# Cross-session evaluation: where recentering should matter
# ------------------------------------------------------------
#
# :class:`~moabb.evaluations.CrossSessionEvaluation` trains on one session and
# tests on another (MOABB's standard leave-one-session-out protocol) — exactly
# the setting where between-session covariance drift is present for
# recentering to correct.

dataset = BNCI2014_001()
dataset.subject_list = dataset.subject_list[:4] # keep the example fast
paradigm = LeftRightImagery(fmin=8, fmax=32)

cross_session_eval = CrossSessionEvaluation(
paradigm=paradigm, datasets=[dataset], overwrite=True, random_state=42
)
cross_results = cross_session_eval.process(pipelines)

###############################################################################
# Within-session evaluation: the control condition
# ---------------------------------------------------
#
# :class:`~moabb.evaluations.WithinSessionEvaluation` trains and tests within
# the *same* recording session, so there is no between-session shift for
# recentering to correct. Per [1]_, recentering should therefore make little
# to no difference here — the mechanism-isolating control for the
# cross-session result above.

within_session_eval = WithinSessionEvaluation(
paradigm=paradigm, datasets=[dataset], overwrite=True, random_state=42
)
within_results = within_session_eval.process(pipelines)

###############################################################################
# The double dissociation
# -------------------------
#
# Plot the mean score per pipeline in both protocols side by side. Per [1]_,
# the expected pattern is: a clear Geometry-Aware advantage cross-session,
# and near-parity within-session.

cross_means = cross_results.groupby("pipeline")["score"].mean()
within_means = within_results.groupby("pipeline")["score"].mean()

fig, ax = plt.subplots(figsize=(6, 4.5))
x = np.arange(2)
width = 0.35
names = list(pipelines.keys())
for i, name in enumerate(names):
vals = [within_means.get(name, np.nan), cross_means.get(name, np.nan)]
ax.bar(x + i * width, vals, width, label=name)
ax.set_xticks(x + width / 2)
ax.set_xticklabels(["Within-session\n(no drift)", "Cross-session\n(drift)"])
ax.set_ylabel("Mean score")
ax.set_title("Recentering helps specifically where there is drift to correct")
ax.legend()
fig.tight_layout()
plt.show()

print("Within-session means:\n", within_means)
print("\nCross-session means:\n", cross_results.groupby("pipeline")["score"].mean())

###############################################################################
# Using it inside your own evaluation
# --------------------------------------
#
# The recentering step is a single argument, no new dependency, and drops
# into any tangent-space pipeline::
#
# from pyriemann.estimation import Covariances
# from pyriemann.tangentspace import TangentSpace
# from sklearn.linear_model import LogisticRegression
# from sklearn.pipeline import make_pipeline
#
# pipelines = {
# "Geometry-Aware": make_pipeline(
# Covariances(estimator="oas"),
# TangentSpace(metric="riemann", tsupdate=True),
# LogisticRegression(max_iter=1000),
# )
# }
#
# As in the note above, use ``tsupdate=True`` under
# :class:`~moabb.evaluations.CrossSessionEvaluation` /
# :class:`~moabb.evaluations.CrossSubjectEvaluation` (there is a shift to
# correct), and ``tsupdate=False`` under
# :class:`~moabb.evaluations.WithinSessionEvaluation` (there is not) — mixing
# these up is exactly the confound the double-dissociation design in [1]_ is
# built to rule out.
#
# For the full eight-dataset benchmark, deep-model comparison (bidirectional
# Mamba mixture-of-experts, SPDNet), and complete statistical validation
# (Friedman omnibus, FDR/Holm-corrected Wilcoxon, Cohen's d, bootstrap CIs,
# Critical-Difference analysis), see [1]_.
#
# References
# ----------
# .. [1] Rahimipour, M., Yang, L., & Van Hulle, M. Simple Geometric
# Recentering Rivals Deep Sequence Models for Cross-Session EEG
# Motor-Imagery Decoding. In preparation, 2026.