|
| 1 | +r""" |
| 2 | +===================================================== |
| 3 | +Euclidean Alignment for cross-subject transfer |
| 4 | +===================================================== |
| 5 | +
|
| 6 | +EEG covariance statistics drift from subject to subject (and session to |
| 7 | +session): the same mental task produces differently-shaped data on each |
| 8 | +recording. That domain shift is the main reason a decoder trained on one set of |
| 9 | +subjects transfers poorly to a new one. **Euclidean Alignment** (EA) removes it |
| 10 | +with a single, label-free whitening step — cheap enough to put in front of any |
| 11 | +model, deep or classical [1]_. |
| 12 | +
|
| 13 | +In a systematic evaluation across MOABB motor-imagery datasets, Junqueira, |
| 14 | +Aristimunha, Chevallier & de Camargo (2024) [2]_ showed that aligning each |
| 15 | +recording with EA before training a *shared* deep model improved target-subject |
| 16 | +decoding by **+4.33%** on average and cut convergence time by **more than 70%** — |
| 17 | +for almost no compute and no extra labels. This example reproduces the core |
| 18 | +idea on the workhorse CSP+LDA motor-imagery pipeline using |
| 19 | +:class:`moabb.datasets.preprocessing.EuclideanAlignment`. |
| 20 | +
|
| 21 | +Each trial :math:`X_i` is whitened by the inverse square root of the |
| 22 | +**Euclidean (arithmetic) mean** of the per-trial covariances of its recording, |
| 23 | +
|
| 24 | +.. math:: |
| 25 | +
|
| 26 | + \bar{C} = \frac{1}{N}\sum_{i=1}^{N} C_i, |
| 27 | + \qquad \tilde{X}_i = \bar{C}^{-1/2} X_i, |
| 28 | +
|
| 29 | +so after alignment every recording shares an identity-like average covariance |
| 30 | +and the subjects become comparable. We apply EA **per subject** (the |
| 31 | +transductive, per-recording form — :meth:`fit_transform` on one recording; it |
| 32 | +uses only the trial covariances, never the labels) and compare leave-one-subject |
| 33 | +-out decoding with and without it. |
| 34 | +""" |
| 35 | + |
| 36 | +# Authors: Bruno Aristimunha <b.aristimunha@gmail.com> |
| 37 | +# |
| 38 | +# License: BSD (3-clause) |
| 39 | + |
| 40 | +import matplotlib.pyplot as plt |
| 41 | +import mne |
| 42 | +import numpy as np |
| 43 | +from mne.decoding import CSP |
| 44 | +from pyriemann.estimation import Covariances |
| 45 | +from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA |
| 46 | +from sklearn.metrics import roc_auc_score |
| 47 | +from sklearn.pipeline import make_pipeline |
| 48 | +from sklearn.preprocessing import LabelEncoder |
| 49 | + |
| 50 | +import moabb |
| 51 | +from moabb.datasets import BNCI2014_001 |
| 52 | +from moabb.datasets.preprocessing import EuclideanAlignment |
| 53 | +from moabb.paradigms import LeftRightImagery |
| 54 | + |
| 55 | + |
| 56 | +moabb.set_log_level("info") |
| 57 | +mne.set_log_level("WARNING") # keep the gallery output readable |
| 58 | + |
| 59 | +############################################################################### |
| 60 | +# Load the data per subject |
| 61 | +# ------------------------- |
| 62 | +# |
| 63 | +# We use the BCI Competition IV 2a dataset (:class:`moabb.datasets.BNCI2014_001`) |
| 64 | +# and the :class:`moabb.paradigms.LeftRightImagery` paradigm (left- vs right-hand |
| 65 | +# motor imagery, scored with ROC-AUC). We keep the trials of each subject |
| 66 | +# separate, because Euclidean Alignment is defined **per recording**. |
| 67 | + |
| 68 | +paradigm = LeftRightImagery() |
| 69 | +dataset = BNCI2014_001() |
| 70 | +subjects = dataset.subject_list[:8] |
| 71 | + |
| 72 | +# Pull each subject's trials once; X is (n_trials, n_channels, n_times). |
| 73 | +data = {} |
| 74 | +for subject in subjects: |
| 75 | + X, labels, _ = paradigm.get_data(dataset, [subject]) |
| 76 | + data[subject] = (X, LabelEncoder().fit_transform(labels)) |
| 77 | + |
| 78 | +############################################################################### |
| 79 | +# Euclidean Alignment reduces the between-subject covariance shift |
| 80 | +# ---------------------------------------------------------------- |
| 81 | +# |
| 82 | +# Before any classification, we can *see* what EA does. For every subject we |
| 83 | +# compute the mean trial covariance, then measure how far apart the subjects are |
| 84 | +# as the average pairwise distance between those mean covariances. EA pulls them |
| 85 | +# together — each subject's mean covariance becomes ~identity. |
| 86 | + |
| 87 | + |
| 88 | +def mean_covariance(X): |
| 89 | + """Euclidean mean of the per-trial covariances of one recording.""" |
| 90 | + return Covariances("oas").transform(X).mean(axis=0) |
| 91 | + |
| 92 | + |
| 93 | +def between_subject_dispersion(means): |
| 94 | + """Average pairwise Frobenius distance between subject mean covariances.""" |
| 95 | + dists = [ |
| 96 | + np.linalg.norm(means[i] - means[j]) |
| 97 | + for i in range(len(means)) |
| 98 | + for j in range(i + 1, len(means)) |
| 99 | + ] |
| 100 | + return float(np.mean(dists)) |
| 101 | + |
| 102 | + |
| 103 | +raw_means, aligned_means = [], [] |
| 104 | +for subject in subjects: |
| 105 | + X, _ = data[subject] |
| 106 | + raw_means.append(mean_covariance(X)) |
| 107 | + # Per-subject (transductive) Euclidean Alignment: label-free, leakage-free. |
| 108 | + X_aligned = EuclideanAlignment().fit_transform(X) |
| 109 | + aligned_means.append(mean_covariance(X_aligned)) |
| 110 | + |
| 111 | +dispersion = { |
| 112 | + "No alignment": between_subject_dispersion(raw_means), |
| 113 | + "Euclidean Alignment": between_subject_dispersion(aligned_means), |
| 114 | +} |
| 115 | +print("Between-subject covariance dispersion:", dispersion) |
| 116 | + |
| 117 | +fig, ax = plt.subplots(figsize=(5, 4)) |
| 118 | +ax.bar(dispersion.keys(), dispersion.values(), color=["#999999", "#0072B2"]) |
| 119 | +ax.set_ylabel("Mean pairwise distance between\nsubject covariances (Frobenius)") |
| 120 | +ax.set_title("Euclidean Alignment shrinks the\nbetween-subject domain shift") |
| 121 | +fig.tight_layout() |
| 122 | +plt.show() |
| 123 | + |
| 124 | +############################################################################### |
| 125 | +# Leave-one-subject-out decoding, with and without alignment |
| 126 | +# ---------------------------------------------------------- |
| 127 | +# |
| 128 | +# Now the payoff. For each held-out subject we train a standard CSP+LDA pipeline |
| 129 | +# on the *other* subjects and test on the held-out one — the cross-subject |
| 130 | +# transfer setting. We run it twice: on the raw trials, and on trials that have |
| 131 | +# each been Euclidean-aligned per subject. |
| 132 | +# |
| 133 | +# CSP+LDA is a *Euclidean* classifier and is therefore sensitive to the |
| 134 | +# covariance shift EA removes. (Riemannian tangent-space pipelines already |
| 135 | +# recenter covariances internally, so they benefit less — EA is most valuable |
| 136 | +# for Euclidean and deep models, exactly the setting of [2]_.) |
| 137 | + |
| 138 | + |
| 139 | +def decode_loso(aligned): |
| 140 | + """Leave-one-subject-out ROC-AUC, optionally with per-subject EA.""" |
| 141 | + scores = [] |
| 142 | + for test_subject in subjects: |
| 143 | + train_subjects = [s for s in subjects if s != test_subject] |
| 144 | + |
| 145 | + def prep(subject): |
| 146 | + X, y = data[subject] |
| 147 | + if aligned: |
| 148 | + X = EuclideanAlignment().fit_transform(X) |
| 149 | + return X, y |
| 150 | + |
| 151 | + X_train = np.concatenate([prep(s)[0] for s in train_subjects]) |
| 152 | + y_train = np.concatenate([prep(s)[1] for s in train_subjects]) |
| 153 | + X_test, y_test = prep(test_subject) |
| 154 | + |
| 155 | + clf = make_pipeline(CSP(n_components=8), LDA()) |
| 156 | + clf.fit(X_train, y_train) |
| 157 | + proba = clf.predict_proba(X_test)[:, 1] |
| 158 | + scores.append(roc_auc_score(y_test, proba)) |
| 159 | + return np.array(scores) |
| 160 | + |
| 161 | + |
| 162 | +raw_scores = decode_loso(aligned=False) |
| 163 | +aligned_scores = decode_loso(aligned=True) |
| 164 | + |
| 165 | +for subject, raw, aligned in zip(subjects, raw_scores, aligned_scores): |
| 166 | + print(f"subject {subject}: raw={raw:.3f} aligned={aligned:.3f}") |
| 167 | +print( |
| 168 | + f"mean: raw={raw_scores.mean():.3f} aligned={aligned_scores.mean():.3f} " |
| 169 | + f"(EA wins on {(aligned_scores > raw_scores).sum()}/{len(subjects)} subjects)" |
| 170 | +) |
| 171 | + |
| 172 | +############################################################################### |
| 173 | +# A point per held-out subject: above the diagonal means Euclidean Alignment |
| 174 | +# helped that subject's cross-subject transfer. |
| 175 | + |
| 176 | +fig, ax = plt.subplots(figsize=(5, 5)) |
| 177 | +ax.scatter(raw_scores, aligned_scores, c="#0072B2", s=70, zorder=3) |
| 178 | +for subject, raw, aligned in zip(subjects, raw_scores, aligned_scores): |
| 179 | + ax.annotate(f"S{subject}", (raw, aligned), textcoords="offset points", xytext=(6, 0)) |
| 180 | +lims = [min(raw_scores.min(), aligned_scores.min()) - 0.02, 1.0] |
| 181 | +ax.plot(lims, lims, "--", color="grey", zorder=1) |
| 182 | +ax.set_xlim(lims) |
| 183 | +ax.set_ylim(lims) |
| 184 | +ax.set_xlabel("ROC-AUC without alignment") |
| 185 | +ax.set_ylabel("ROC-AUC with Euclidean Alignment") |
| 186 | +ax.set_title("Cross-subject transfer (leave-one-subject-out)") |
| 187 | +ax.set_aspect("equal") |
| 188 | +fig.tight_layout() |
| 189 | +plt.show() |
| 190 | + |
| 191 | +############################################################################### |
| 192 | +# Using it inside a MOABB evaluation |
| 193 | +# ---------------------------------- |
| 194 | +# |
| 195 | +# Above we used the **transductive** per-recording form (``fit_transform`` on |
| 196 | +# each subject). :class:`~moabb.datasets.preprocessing.EuclideanAlignment` is |
| 197 | +# also a regular scikit-learn transformer, so its **inductive**, leakage-free |
| 198 | +# form drops straight into a pipeline for any MOABB evaluation: ``fit`` learns |
| 199 | +# the reference whitener from the training trials only and ``transform`` reuses |
| 200 | +# it on the test trials. For example:: |
| 201 | +# |
| 202 | +# from moabb.evaluations import CrossSubjectEvaluation |
| 203 | +# |
| 204 | +# pipelines = { |
| 205 | +# "EA+CSP+LDA": make_pipeline( |
| 206 | +# EuclideanAlignment(), CSP(n_components=8), LDA() |
| 207 | +# ) |
| 208 | +# } |
| 209 | +# evaluation = CrossSubjectEvaluation(paradigm=paradigm, datasets=[dataset]) |
| 210 | +# results = evaluation.process(pipelines) |
| 211 | +# |
| 212 | +# For the full deep-learning story — where EA shines most, improving target |
| 213 | +# accuracy by +4.33% and cutting training time by >70% — see Junqueira et al. |
| 214 | +# (2024) [2]_. |
| 215 | +# |
| 216 | +# References |
| 217 | +# ---------- |
| 218 | +# .. [1] He, H., & Wu, D. (2020). Transfer learning for brain-computer |
| 219 | +# interfaces: A Euclidean space data alignment approach. *IEEE |
| 220 | +# Transactions on Biomedical Engineering*, 67(2), 399-410. |
| 221 | +# https://doi.org/10.1109/TBME.2019.2913914 |
| 222 | +# .. [2] Junqueira, B., Aristimunha, B., Chevallier, S., & de Camargo, R. Y. |
| 223 | +# (2024). A systematic evaluation of Euclidean alignment with deep |
| 224 | +# learning for EEG decoding. *Journal of Neural Engineering*, 21(3), |
| 225 | +# 036038. https://doi.org/10.1088/1741-2552/ad4f18 |
0 commit comments