From 99b96ffed6623c69c2d18aeb21ac6a86e768862a Mon Sep 17 00:00:00 2001 From: Meysam Rahimipour <ایمیلت@example.com> Date: Thu, 2 Jul 2026 12:32:16 +0200 Subject: [PATCH 1/4] docs: add Geometry-Aware recentering example (cross-session double dissociation) --- docs/source/whats_new.rst | 2 + .../plot_geometry_aware_recentering.py | 191 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100755 examples/advanced_examples/plot_geometry_aware_recentering.py diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index 08418d41e2..0e53aa0457 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -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 @@ -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 diff --git a/examples/advanced_examples/plot_geometry_aware_recentering.py b/examples/advanced_examples/plot_geometry_aware_recentering.py new file mode 100755 index 0000000000..7fba36124d --- /dev/null +++ b/examples/advanced_examples/plot_geometry_aware_recentering.py @@ -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 +# +# 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. From 622308868838ef9a48dcb91be491c71004168290 Mon Sep 17 00:00:00 2001 From: Meysam Rahimipour Date: Thu, 2 Jul 2026 14:24:57 +0200 Subject: [PATCH 2/4] fix: remove executable bit from example script --- examples/advanced_examples/plot_geometry_aware_recentering.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 examples/advanced_examples/plot_geometry_aware_recentering.py diff --git a/examples/advanced_examples/plot_geometry_aware_recentering.py b/examples/advanced_examples/plot_geometry_aware_recentering.py old mode 100755 new mode 100644 From 971c6642b735b3ecca234daf958f1efb8b31902c Mon Sep 17 00:00:00 2001 From: Bru Date: Sat, 25 Jul 2026 16:54:49 +0200 Subject: [PATCH 3/4] FIX: isolate geometry-aware example results with suffix= The example constructed both CrossSessionEvaluation and WithinSessionEvaluation with overwrite=True and no suffix=, so Results resolved them to the shared suffix-less files results/LeftRightImagery/CrossSessionEvaluation/results_.hdf5 results/LeftRightImagery/WithinSessionEvaluation/results_.hdf5 moabb/analysis/results.py:127-128 implements overwrite by opening that path with h5py mode "w", which truncates the WHOLE file rather than just this example expressions rows, and the Results object is built in BaseEvaluation.__init__ (moabb/evaluations/base.py:465), so truncation happens at construction time. Those two files are shared with tutorial_2_using_mulitple_datasets.py, tutorial_3_benchmarking_multiple_pipelines.py (both overwrite=False; tutorial_3 prose explicitly relies on reusing them) and advanced_examples/plot_pre_processing_steps.py:140. sphinx-gallery runs examples in parallel on a warm cache (docs/source/conf.py:209 + .github/workflows/docs.yml:182) and BaseEvaluation.process returns the frame read back out of the HDF5, so a mid-flight truncation hands another example an empty frame and, with neither expected_failing_examples nor only_warn_on_example_error set, takes the whole gallery down. Adding suffix="geometry_aware" is the fix already used by plot_riemannian_artifact_rejection.py (suffix="rar_tutorial"), plot_select_electrodes_resample.py (suffix="resample") and plot_use_an_X_y_dataset.py (suffix="motan"). Verified: the example now writes results_geometry_aware.hdf5 and a marker planted in the shared results_.hdf5 survives the run; scores are unchanged. --- .../plot_geometry_aware_recentering.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/advanced_examples/plot_geometry_aware_recentering.py b/examples/advanced_examples/plot_geometry_aware_recentering.py index 7fba36124d..7d6ec508f7 100644 --- a/examples/advanced_examples/plot_geometry_aware_recentering.py +++ b/examples/advanced_examples/plot_geometry_aware_recentering.py @@ -103,7 +103,11 @@ def make_geometry_aware(tsupdate): paradigm = LeftRightImagery(fmin=8, fmax=32) cross_session_eval = CrossSessionEvaluation( - paradigm=paradigm, datasets=[dataset], overwrite=True, random_state=42 + paradigm=paradigm, + datasets=[dataset], + suffix="geometry_aware", + overwrite=True, + random_state=42, ) cross_results = cross_session_eval.process(pipelines) @@ -118,7 +122,11 @@ def make_geometry_aware(tsupdate): # cross-session result above. within_session_eval = WithinSessionEvaluation( - paradigm=paradigm, datasets=[dataset], overwrite=True, random_state=42 + paradigm=paradigm, + datasets=[dataset], + suffix="geometry_aware", + overwrite=True, + random_state=42, ) within_results = within_session_eval.process(pipelines) From 578b0905bd19976a8b39c28fc045eab263d91511 Mon Sep 17 00:00:00 2001 From: Bru Date: Sat, 25 Jul 2026 16:56:22 +0200 Subject: [PATCH 4/4] STY: tidy geometry-aware example and its changelog bullet Cosmetic only, no behaviour change (scores byte-identical before and after): - Rename make_geometry_aware -> make_ts_pipeline and give it a docstring. The old name was actively misleading: make_geometry_aware(tsupdate=False) returns the pipeline that is NOT geometry-aware. - Index within_means[name] / cross_means[name] directly instead of .get(name, np.nan), so a pipeline missing from the results raises a loud KeyError instead of silently rendering a blank bar. - Reuse the cross_means already bound three lines earlier for the final print instead of recomputing the same groupby, matching the within_means line above. - whats_new.rst: use a double-backtick literal for the example path instead of the :file: role. :file: appears nowhere else in whats_new.rst on develop; the established form is the literal, cf. the plot_riemannian_artifact_rejection.py bullet four lines above. --- docs/source/whats_new.rst | 2 +- .../plot_geometry_aware_recentering.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst index c46ed573e1..716940da45 100644 --- a/docs/source/whats_new.rst +++ b/docs/source/whats_new.rst @@ -41,7 +41,7 @@ Enhancements - 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 an ``n_jobs`` parameter to :meth:`moabb.paradigms.base.BaseParadigm.get_data` and :meth:`moabb.datasets.base.BaseDataset.get_data` to load and preprocess subjects in parallel with :class:`joblib.Parallel`. Per-subject processing (reading, filtering, resampling, epoching) is independent, so this gives a near-linear speedup on datasets with many subjects, with identical numerical results. moabb's own patches to the shared BIDS cache files (``participants.tsv``/``.json``, ``dataset_description.json``) now take the mne-bids cross-process file lock, so parallel caching stays consistent (:gh:`1124` 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`_). +- Add ``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 diff --git a/examples/advanced_examples/plot_geometry_aware_recentering.py b/examples/advanced_examples/plot_geometry_aware_recentering.py index 7d6ec508f7..22c0e347d4 100644 --- a/examples/advanced_examples/plot_geometry_aware_recentering.py +++ b/examples/advanced_examples/plot_geometry_aware_recentering.py @@ -76,7 +76,8 @@ # fixed isolates recentering as the only difference between the two. -def make_geometry_aware(tsupdate): +def make_ts_pipeline(tsupdate): + """Build the tangent-space pipeline, with recentering on (``True``) or off.""" return make_pipeline( Covariances(estimator="oas"), TangentSpace(metric="riemann", tsupdate=tsupdate), @@ -85,8 +86,8 @@ def make_geometry_aware(tsupdate): pipelines = { - "Geometry-Aware (recenter)": make_geometry_aware(tsupdate=True), - "TS + LR (no recenter)": make_geometry_aware(tsupdate=False), + "Geometry-Aware (recenter)": make_ts_pipeline(tsupdate=True), + "TS + LR (no recenter)": make_ts_pipeline(tsupdate=False), } ############################################################################### @@ -146,7 +147,7 @@ def make_geometry_aware(tsupdate): 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)] + vals = [within_means[name], cross_means[name]] 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)"]) @@ -157,7 +158,7 @@ def make_geometry_aware(tsupdate): plt.show() print("Within-session means:\n", within_means) -print("\nCross-session means:\n", cross_results.groupby("pipeline")["score"].mean()) +print("\nCross-session means:\n", cross_means) ############################################################################### # Using it inside your own evaluation