Note
Go to the end to download the full example code.
Cross-Session Transfer with TSMNet#
This tutorial demonstrates how to use TSMNet for cross-session motor imagery classification with domain adaptation. TSMNet’s SPDBatchNormMeanVar layer enables adaptation to new sessions without labeled data from the target session.
Introduction#
In EEG-based BCIs, a common challenge is session-to-session variability: models trained on one day often perform poorly on another day due to changes in electrode impedance, mental state, and environment.
TSMNet [Kobler et al., 2022] addresses this through SPDBatchNormMeanVar, which:
Normalizes SPD matrices using the Fréchet mean
Maintains running statistics that can be updated on new data
Enables Source-Free Unsupervised Domain Adaptation (SFUDA)
This means we can adapt to a new subject using only unlabeled data!
Setup and Imports#
import warnings
from typing import List, Tuple
import matplotlib.pyplot as plt
import numpy as np
import torch
from braindecode import EEGClassifier
from moabb.datasets import BNCI2014_001
from moabb.paradigms import MotorImagery
from skada import (
CORALAdapter,
EntropicOTMapping,
SubspaceAlignment,
make_da_pipeline,
)
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.preprocessing import LabelEncoder, StandardScaler
from skorch.callbacks import EpochScoring, GradientNormClipping
from skorch.dataset import ValidSplit
from spd_learn.models import TSMNet
warnings.filterwarnings("ignore")
Loading the Dataset#
BNCI2014_001 contains EEG recordings from 9 subjects performing - 22 EEG channels: Standard 10-20 montage - 250 Hz sampling rate: After resampling
We’ll demonstrate cross-session transfer:
Source domain: Subject 1, Session 1 (training)
Target domain: Subject 1, Session 2 (testing/adaptation)
Cross-session transfer is a realistic BCI scenario where we want to avoid recalibration for a returning user.
dataset = BNCI2014_001()
paradigm = MotorImagery(n_classes=4)
print(f"Dataset: {dataset.code}")
print("Cross-subject transfer: Subject 1 (source) -> Subject 2 (target)")
Dataset: BNCI2014-001
Cross-subject transfer: Subject 1 (source) -> Subject 2 (target)
Creating the TSMNet Model#
TSMNet architecture:
Temporal Conv: Learns temporal filters
Spatial Conv: Learns spatial combinations
CovLayer: Computes covariance matrices
BiMap + ReEig: SPD dimensionality reduction
SPDBatchNormMeanVar: Riemannian batch normalization (key for adaptation)
LogEig: Projects to tangent space
Linear: Classification head
n_chans = 22
n_outputs = 4
model = TSMNet(
n_chans=n_chans,
n_outputs=n_outputs,
n_temp_filters=8, # Temporal filters
temp_kernel_length=50, # ~200ms at 250Hz
n_spatiotemp_filters=32, # Spatiotemporal features
n_bimap_filters=16, # BiMap output dimension
reeig_threshold=1e-4, # ReEig threshold
)
print("TSMNet Architecture:")
print(model)
TSMNet Architecture:
TSMNet(
(cnn): Sequential(
(0): Conv2d(1, 8, kernel_size=(1, 50), stride=(1, 1), padding=same, padding_mode=reflect)
(1): Conv2d(8, 32, kernel_size=(22, 1), stride=(1, 1))
(2): Flatten(start_dim=2, end_dim=-1)
)
(covpool): CovLayer()
(spdnet): Sequential(
(0): ParametrizedBiMap(
(parametrizations): ModuleDict(
(weight): ParametrizationList(
(0): _Orthogonal()
)
)
)
(1): ReEig()
)
(spdbnorm): ParametrizedSPDBatchNormMeanVar(
(parametrizations): ModuleDict(
(weight): ParametrizationList(
(0): PositiveDefiniteScalar()
)
(bias): ParametrizationList(
(0): SymmetricPositiveDefinite()
)
)
)
(logeig): Sequential(
(0): LogEig()
(1): Flatten(start_dim=1, end_dim=-1)
)
(head): Linear(in_features=136, out_features=4, bias=True)
)
Training on Source Domain#
First, we train TSMNet on Session 1 (source domain).
source_subject = 1
target_subject = 1 # Same subject, different session
batch_size = 32
max_epochs = 300
learning_rate = 1e-4 # Optimal learning rate from grid search
weight_decay = 1e-4 # L2 regularization for better generalization
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"\nUsing device: {device}")
# Cache configuration
cache_config = dict(
save_raw=True,
save_epochs=True,
save_array=True,
use=True,
overwrite_raw=False,
overwrite_epochs=False,
overwrite_array=False,
)
# Load data for both subjects
X, labels, meta = paradigm.get_data(
dataset=dataset,
subjects=[source_subject, target_subject],
cache_config=cache_config,
)
# Encode labels
le = LabelEncoder()
y = le.fit_transform(labels)
# Split by session
# Session '0train' is the first session (source)
# Session '1test' is the second session (target)
source_idx = meta.query("session == '0train'").index.to_numpy()
target_idx = meta.query("session == '1test'").index.to_numpy()
X_source, y_source = X[source_idx], y[source_idx]
X_target, y_target = X[target_idx], y[target_idx]
print(f"\nSource domain (Session 1): {len(source_idx)} samples")
print(f"Target domain (Session 2): {len(target_idx)} samples")
# Create classifier
# Note: SPD networks benefit from gradient clipping to prevent
# divergence during training on the Riemannian manifold.
clf = EEGClassifier(
model,
criterion=torch.nn.CrossEntropyLoss,
optimizer=torch.optim.AdamW,
optimizer__lr=learning_rate,
optimizer__weight_decay=weight_decay,
train_split=ValidSplit(0.1, stratified=True, random_state=42),
batch_size=batch_size,
max_epochs=max_epochs,
callbacks=[
(
"train_acc",
EpochScoring(
"accuracy", lower_is_better=False, on_train=True, name="train_acc"
),
),
("gradient_clip", GradientNormClipping(gradient_clip_value=1.0)),
],
device=device,
verbose=1,
)
# Train on source domain
print("\n" + "=" * 50)
print("Training on Source Domain")
print("=" * 50)
clf.fit(X_source, y_source)
Using device: cpu
Source domain (Session 1): 288 samples
Target domain (Session 2): 288 samples
==================================================
Training on Source Domain
==================================================
epoch train_acc train_loss valid_acc valid_loss dur
------- ----------- ------------ ----------- ------------ ------
1 0.2734 1.3800 0.1724 1.3664 1.3281
2 0.3008 1.3710 0.2759 1.3590 1.3201
3 0.3281 1.3620 0.3448 1.3511 1.3254
4 0.3828 1.3541 0.4483 1.3447 1.3216
5 0.4180 1.3484 0.4828 1.3374 1.3337
6 0.4531 1.3416 0.4828 1.3306 1.3321
7 0.5117 1.3343 0.5172 1.3242 1.3164
8 0.5039 1.3282 0.5862 1.3187 1.3241
9 0.5469 1.3237 0.5517 1.3131 1.3143
10 0.5547 1.3176 0.5517 1.3079 1.3189
11 0.5625 1.3124 0.5517 1.3031 1.3579
12 0.5469 1.3096 0.5172 1.2985 1.3358
13 0.5898 1.3024 0.5517 1.2950 1.3745
14 0.6172 1.2970 0.5517 1.2910 1.3215
15 0.6133 1.2929 0.5517 1.2856 1.3197
16 0.6211 1.2897 0.6552 1.2807 1.3354
17 0.6367 1.2843 0.5862 1.2777 1.3437
18 0.6523 1.2790 0.5862 1.2735 1.3409
19 0.6445 1.2747 0.5517 1.2705 1.3226
20 0.6758 1.2686 0.5517 1.2666 1.3232
21 0.6836 1.2657 0.5862 1.2619 1.3529
22 0.7070 1.2656 0.6552 1.2556 1.3223
23 0.6914 1.2572 0.7241 1.2527 1.3147
24 0.7188 1.2537 0.6552 1.2489 1.3202
25 0.7070 1.2496 0.6552 1.2456 1.3269
26 0.6836 1.2476 0.6552 1.2427 1.3458
27 0.7109 1.2414 0.6552 1.2400 1.3236
28 0.7031 1.2382 0.6552 1.2357 1.4047
29 0.7188 1.2347 0.6552 1.2302 1.3398
30 0.7109 1.2289 0.6552 1.2270 1.3353
31 0.7188 1.2266 0.6552 1.2222 1.3297
32 0.7305 1.2220 0.6207 1.2184 1.2782
33 0.7188 1.2211 0.6207 1.2151 0.8369
34 0.7266 1.2153 0.7241 1.2124 0.8316
35 0.7305 1.2108 0.7241 1.2096 0.8413
36 0.7148 1.2075 0.6207 1.2054 0.8372
37 0.7422 1.2050 0.6552 1.2031 0.8356
38 0.7383 1.1982 0.6552 1.1998 0.8374
39 0.7188 1.1998 0.6552 1.1955 0.8350
40 0.7266 1.1938 0.6897 1.1929 0.8295
41 0.7109 1.1891 0.6552 1.1879 0.8414
42 0.7070 1.1876 0.6897 1.1840 0.8314
43 0.7539 1.1879 0.6552 1.1834 0.8325
44 0.7383 1.1865 0.6552 1.1792 0.8307
45 0.7266 1.1784 0.6552 1.1760 0.8293
46 0.7344 1.1735 0.6552 1.1726 0.8316
47 0.7344 1.1702 0.6897 1.1677 0.8326
48 0.7266 1.1669 0.6552 1.1660 0.8284
49 0.7227 1.1627 0.6552 1.1620 0.8340
50 0.7383 1.1599 0.6552 1.1591 0.8303
51 0.7461 1.1578 0.6552 1.1557 0.8289
52 0.7422 1.1515 0.6552 1.1511 0.8308
53 0.7617 1.1497 0.6552 1.1493 0.8354
54 0.7422 1.1478 0.6897 1.1441 0.8233
55 0.7500 1.1442 0.6897 1.1420 0.8263
56 0.7148 1.1414 0.6552 1.1400 0.8429
57 0.7539 1.1368 0.6207 1.1381 0.8335
58 0.7188 1.1420 0.6552 1.1324 0.8274
59 0.7461 1.1318 0.6207 1.1288 0.8280
60 0.7305 1.1282 0.6207 1.1261 0.8297
61 0.7266 1.1249 0.6552 1.1216 0.8607
62 0.7500 1.1196 0.6207 1.1180 0.8295
63 0.7227 1.1251 0.5862 1.1140 0.8398
64 0.7500 1.1173 0.6552 1.1139 0.8341
65 0.7617 1.1187 0.6207 1.1128 0.8258
66 0.7266 1.1205 0.6552 1.1075 0.8248
67 0.7461 1.1115 0.6207 1.1086 0.8242
68 0.7344 1.1054 0.6207 1.1056 0.8279
69 0.7383 1.1004 0.6207 1.0997 0.8297
70 0.7344 1.1063 0.6207 1.0951 0.8303
71 0.7383 1.1028 0.6552 1.0945 0.8711
72 0.7539 1.0958 0.6552 1.0913 0.8304
73 0.7617 1.0873 0.6552 1.0879 0.8236
74 0.7227 1.0917 0.6207 1.0866 0.8266
75 0.7539 1.0839 0.6552 1.0824 0.8298
76 0.7422 1.0836 0.6207 1.0810 0.8433
77 0.7344 1.0879 0.6552 1.0756 0.8276
78 0.7695 1.0746 0.6552 1.0741 0.8229
79 0.7305 1.0761 0.6207 1.0704 0.8400
80 0.7422 1.0752 0.6207 1.0699 0.8215
81 0.7383 1.0729 0.6207 1.0667 0.8385
82 0.7383 1.0666 0.6552 1.0619 0.8291
83 0.7344 1.0653 0.6897 1.0598 0.8212
84 0.7461 1.0609 0.6207 1.0557 0.8388
85 0.7383 1.0601 0.6552 1.0531 0.8217
86 0.7539 1.0620 0.6552 1.0520 0.8606
87 0.7617 1.0507 0.6552 1.0504 0.8381
88 0.7422 1.0525 0.6207 1.0500 0.8216
89 0.7383 1.0498 0.6207 1.0471 0.8190
90 0.7461 1.0517 0.6207 1.0442 0.8223
91 0.7461 1.0458 0.6552 1.0407 0.8268
92 0.7422 1.0447 0.6552 1.0377 0.8217
93 0.7461 1.0423 0.6552 1.0349 0.8219
94 0.7344 1.0397 0.6207 1.0309 0.8211
95 0.7617 1.0338 0.5862 1.0317 0.8391
96 0.7578 1.0303 0.5862 1.0314 0.8261
97 0.7383 1.0287 0.6552 1.0287 0.8291
98 0.7539 1.0303 0.6207 1.0268 0.8246
99 0.7539 1.0232 0.6207 1.0232 0.8205
100 0.7461 1.0205 0.6207 1.0202 0.8387
101 0.7461 1.0183 0.5862 1.0172 0.8311
102 0.7578 1.0208 0.6207 1.0132 0.8296
103 0.7539 1.0192 0.6207 1.0135 0.8232
104 0.7422 1.0125 0.5862 1.0099 0.8203
105 0.7539 1.0209 0.5862 1.0084 0.8242
106 0.7500 1.0048 0.6207 1.0048 0.8397
107 0.7227 1.0025 0.6552 1.0022 0.8267
108 0.7656 1.0031 0.5862 0.9990 0.8198
109 0.7383 1.0041 0.6207 0.9962 0.8193
110 0.7383 1.0031 0.6897 0.9927 0.8362
111 0.7773 0.9918 0.6897 0.9922 0.8303
112 0.7266 1.0063 0.5862 0.9924 0.8216
113 0.7305 0.9962 0.6207 0.9890 0.8191
114 0.7188 0.9989 0.6207 0.9867 0.8414
115 0.7617 0.9834 0.6552 0.9840 0.8390
116 0.7500 0.9847 0.6552 0.9817 0.8228
117 0.7227 0.9904 0.6552 0.9807 0.8211
118 0.7461 0.9858 0.6207 0.9803 0.8250
119 0.7539 0.9833 0.6207 0.9762 0.8208
120 0.7422 0.9805 0.5862 0.9762 0.8398
121 0.7305 0.9849 0.6207 0.9729 0.8275
122 0.7266 0.9749 0.5517 0.9688 0.8234
123 0.7422 0.9810 0.6207 0.9690 0.8461
124 0.7539 0.9679 0.6552 0.9660 0.8244
125 0.7344 0.9756 0.6552 0.9677 0.8246
126 0.7305 0.9684 0.7241 0.9616 0.8404
127 0.7461 0.9612 0.6897 0.9612 0.8462
128 0.7461 0.9659 0.6207 0.9609 0.8316
129 0.7461 0.9621 0.6207 0.9610 0.8470
130 0.7461 0.9563 0.5862 0.9590 0.8319
131 0.7539 0.9599 0.5862 0.9576 0.8268
132 0.7461 0.9494 0.6207 0.9554 0.8256
133 0.7383 0.9550 0.5862 0.9519 0.8233
134 0.7305 0.9480 0.5862 0.9483 0.8269
135 0.7422 0.9543 0.5862 0.9471 0.8241
136 0.7500 0.9454 0.6207 0.9461 0.8249
137 0.7500 0.9439 0.6552 0.9444 0.8246
138 0.7578 0.9431 0.6552 0.9423 0.8266
139 0.7422 0.9382 0.5862 0.9410 0.8203
140 0.7344 0.9389 0.5862 0.9471 0.8199
141 0.7422 0.9490 0.6207 0.9401 0.8193
142 0.7344 0.9499 0.6207 0.9403 0.8210
143 0.7461 0.9289 0.6552 0.9369 0.8232
144 0.7344 0.9379 0.6552 0.9340 0.8379
145 0.7500 0.9325 0.6207 0.9273 0.8430
146 0.7500 0.9308 0.6207 0.9249 0.8253
147 0.7539 0.9315 0.5862 0.9239 0.8238
148 0.7266 0.9283 0.5862 0.9257 0.8320
149 0.7500 0.9252 0.5862 0.9234 0.8270
150 0.7188 0.9329 0.5862 0.9247 0.8407
151 0.7266 0.9262 0.5862 0.9221 0.8236
152 0.7539 0.9169 0.5862 0.9203 0.8414
153 0.7422 0.9145 0.5862 0.9202 0.8246
154 0.7500 0.9090 0.5862 0.9170 0.8448
155 0.7500 0.9095 0.6207 0.9112 0.8263
156 0.7461 0.9156 0.6552 0.9096 0.8314
157 0.7578 0.9091 0.6207 0.9090 0.8448
158 0.7695 0.9018 0.6207 0.9090 0.8225
159 0.7461 0.9106 0.6207 0.9067 0.8381
160 0.7500 0.9010 0.6207 0.9039 0.8195
161 0.7500 0.9091 0.6552 0.9037 0.8222
162 0.7305 0.9136 0.6897 0.8977 0.8184
163 0.7500 0.8968 0.6897 0.8985 0.8189
164 0.7461 0.9056 0.5862 0.9023 0.8217
165 0.7461 0.8944 0.5862 0.9049 0.8322
166 0.7461 0.8933 0.6207 0.9004 0.8254
167 0.7500 0.8874 0.5862 0.9008 0.8379
168 0.7461 0.8945 0.5862 0.8973 0.8271
169 0.7383 0.8938 0.6207 0.8908 0.8215
170 0.7344 0.8849 0.6207 0.8932 0.8249
171 0.7383 0.8842 0.5862 0.8897 0.8204
172 0.7461 0.8884 0.5862 0.8886 0.8235
173 0.7422 0.8894 0.6552 0.8863 0.8209
174 0.7500 0.8785 0.6207 0.8882 0.8221
175 0.7422 0.8883 0.6897 0.8791 0.8192
176 0.7578 0.8813 0.7241 0.8767 0.8484
177 0.7539 0.8696 0.6552 0.8798 0.8234
178 0.7383 0.8897 0.6552 0.8788 0.8365
179 0.7539 0.8765 0.6207 0.8815 0.8242
180 0.7539 0.8717 0.6207 0.8814 0.8221
181 0.7617 0.8730 0.5862 0.8808 0.8242
182 0.7305 0.8718 0.6207 0.8793 0.8204
183 0.7461 0.8637 0.6207 0.8714 0.8223
184 0.7461 0.8588 0.6207 0.8719 0.8226
185 0.7500 0.8640 0.5862 0.8756 0.8240
186 0.7578 0.8645 0.6552 0.8681 0.8217
187 0.7188 0.8911 0.6552 0.8665 0.8221
188 0.7422 0.8797 0.5862 0.8651 0.8206
189 0.7383 0.8585 0.6207 0.8637 0.8278
190 0.7461 0.8555 0.6207 0.8684 0.8204
191 0.7305 0.8650 0.6207 0.8598 0.8230
192 0.7617 0.8496 0.6552 0.8595 0.8435
193 0.7539 0.8564 0.6552 0.8625 0.8341
194 0.7422 0.8479 0.6207 0.8635 0.8240
195 0.7500 0.8557 0.5517 0.8662 0.8224
196 0.7344 0.8509 0.6207 0.8607 0.8189
197 0.7422 0.8506 0.6552 0.8559 0.8224
198 0.7344 0.8704 0.6552 0.8518 0.8218
199 0.7383 0.8523 0.6552 0.8493 0.8299
200 0.7422 0.8575 0.5862 0.8467 0.8388
201 0.7617 0.8426 0.6207 0.8492 0.8453
202 0.7383 0.8383 0.6207 0.8489 0.8226
203 0.7461 0.8577 0.6552 0.8458 0.8370
204 0.7617 0.8353 0.6207 0.8542 0.8253
205 0.7617 0.8321 0.6552 0.8546 0.8380
206 0.7422 0.8436 0.6552 0.8467 0.8168
207 0.7344 0.8427 0.6552 0.8401 0.8187
208 0.7305 0.8410 0.7241 0.8372 0.8265
209 0.7305 0.8458 0.6552 0.8419 0.8257
210 0.7500 0.8319 0.6552 0.8369 0.8442
211 0.7617 0.8262 0.5862 0.8475 0.8630
212 0.7812 0.8387 0.6552 0.8448 0.8208
213 0.7500 0.8254 0.6552 0.8352 0.8237
214 0.7422 0.8350 0.6552 0.8355 0.8212
215 0.7617 0.8324 0.6207 0.8361 0.8298
216 0.7422 0.8364 0.6897 0.8287 0.8211
217 0.7461 0.8247 0.6552 0.8309 0.8190
218 0.7656 0.8337 0.6207 0.8257 0.8215
219 0.7578 0.8315 0.6552 0.8274 0.8218
220 0.7539 0.8219 0.6552 0.8295 0.8245
221 0.7773 0.8241 0.6552 0.8258 0.8276
222 0.7422 0.8228 0.6207 0.8335 0.8234
223 0.7578 0.8221 0.5517 0.8370 0.8307
224 0.7539 0.8087 0.6207 0.8323 0.8198
225 0.7500 0.8140 0.6552 0.8275 0.8238
226 0.7422 0.8204 0.6897 0.8239 0.8283
227 0.7539 0.8070 0.6552 0.8320 0.8202
228 0.7539 0.8100 0.6552 0.8276 0.8265
229 0.7344 0.8136 0.6207 0.8190 0.8216
230 0.7656 0.8215 0.6207 0.8246 0.8215
231 0.7305 0.8190 0.6207 0.8214 0.8185
232 0.7617 0.7971 0.6552 0.8162 0.8458
233 0.7734 0.7983 0.6552 0.8213 0.8444
234 0.7461 0.7987 0.6207 0.8211 0.8443
235 0.7422 0.8091 0.5517 0.8233 0.8330
236 0.7656 0.8143 0.6552 0.8173 0.8206
237 0.7422 0.8062 0.6897 0.8096 0.8382
238 0.7578 0.7981 0.6552 0.8164 0.8223
239 0.7539 0.7921 0.6207 0.8165 0.8199
240 0.7461 0.8039 0.5862 0.8253 0.8195
241 0.7734 0.7940 0.6207 0.8192 0.8233
242 0.7539 0.7995 0.6552 0.8101 0.8293
243 0.7734 0.7880 0.6552 0.8135 0.8225
244 0.7539 0.7906 0.6207 0.8164 0.8186
245 0.7539 0.8025 0.6207 0.8135 0.8237
246 0.7578 0.7873 0.6897 0.8030 0.8221
247 0.7500 0.7953 0.6552 0.8046 0.8212
248 0.7734 0.7913 0.6897 0.8098 0.8619
249 0.7383 0.8018 0.6897 0.8006 0.8228
250 0.7617 0.7826 0.6552 0.8040 0.8242
251 0.7812 0.7849 0.6552 0.8137 0.8237
252 0.7656 0.7889 0.6207 0.8134 0.8225
253 0.7734 0.7838 0.5862 0.8097 0.8312
254 0.7539 0.7899 0.6207 0.8087 0.8232
255 0.7695 0.7839 0.6552 0.8030 0.8228
256 0.7852 0.7795 0.6552 0.7989 0.8360
257 0.7695 0.7841 0.6552 0.7966 0.8220
258 0.7617 0.7843 0.6552 0.8030 0.8193
259 0.7617 0.7835 0.5862 0.8002 0.8221
260 0.7852 0.7834 0.6207 0.7994 0.8412
261 0.7656 0.7791 0.6552 0.7941 0.8257
262 0.7500 0.7846 0.6552 0.7896 0.8205
263 0.7578 0.7807 0.7241 0.7878 0.8403
264 0.7500 0.7807 0.6552 0.7904 0.8598
265 0.7617 0.7764 0.6897 0.7852 0.8176
266 0.7695 0.7820 0.6552 0.7926 0.8263
267 0.7734 0.7688 0.6897 0.7957 0.8234
268 0.7930 0.7669 0.6552 0.8006 0.8258
269 0.7500 0.7777 0.6552 0.7912 0.8314
270 0.7539 0.7681 0.6552 0.7915 0.8183
271 0.7656 0.7865 0.6552 0.7863 0.8206
272 0.7617 0.7645 0.6552 0.7904 0.8203
273 0.7734 0.7844 0.6552 0.7903 0.8218
274 0.7930 0.7583 0.6552 0.7940 0.8266
275 0.7656 0.7531 0.6207 0.7957 0.8221
276 0.7539 0.7621 0.6552 0.7883 0.8255
277 0.7656 0.7629 0.6552 0.7878 0.8244
278 0.7656 0.7709 0.6897 0.7853 0.8205
279 0.7734 0.7630 0.6552 0.7789 0.8196
280 0.7617 0.7643 0.6552 0.7802 0.8234
281 0.7578 0.7606 0.6897 0.7854 0.8188
282 0.7812 0.7627 0.6552 0.7833 0.8214
283 0.7773 0.7502 0.6897 0.7836 0.8395
284 0.7656 0.7593 0.6207 0.7834 0.8176
285 0.7852 0.7459 0.6552 0.7827 0.8260
286 0.7734 0.7496 0.6552 0.7744 0.8490
287 0.7656 0.7497 0.6552 0.7823 0.8237
288 0.7812 0.7600 0.6552 0.7776 0.8392
289 0.7773 0.7456 0.6552 0.7794 0.8200
290 0.7773 0.7584 0.6552 0.7745 0.8366
291 0.7734 0.7461 0.6897 0.7763 0.8198
292 0.7930 0.7444 0.6897 0.7773 0.8161
293 0.7773 0.7513 0.6552 0.7832 0.8188
294 0.7930 0.7501 0.6207 0.7788 0.8212
295 0.7969 0.7499 0.6552 0.7764 0.8471
296 0.7734 0.7417 0.6552 0.7733 0.8228
297 0.7852 0.7536 0.6897 0.7644 0.8221
298 0.7734 0.7495 0.7241 0.7610 0.8199
299 0.7695 0.7445 0.6897 0.7694 0.8217
300 0.7695 0.7415 0.6897 0.7660 0.8193
Evaluating Without Adaptation#
Let’s first see how the model performs on the target domain WITHOUT any adaptation.
# Evaluate on source (should be high)
y_pred_source = clf.predict(X_source)
source_acc = accuracy_score(y_source, y_pred_source)
# Evaluate on target WITHOUT adaptation
y_pred_target_no_adapt = clf.predict(X_target)
target_acc_no_adapt = accuracy_score(y_target, y_pred_target_no_adapt)
print(f"\n{'=' * 50}")
print("Results WITHOUT Domain Adaptation")
print(f"{'=' * 50}")
print(f"Source Domain Accuracy: {source_acc * 100:.2f}%")
print(f"Target Domain Accuracy: {target_acc_no_adapt * 100:.2f}%")
print(f"Performance Drop: {(source_acc - target_acc_no_adapt) * 100:.2f}%")
==================================================
Results WITHOUT Domain Adaptation
==================================================
Source Domain Accuracy: 80.56%
Target Domain Accuracy: 75.69%
Performance Drop: 4.86%
Domain Adaptation via SPDBatchNormMeanVar#
Now we perform Source-Free Unsupervised Domain Adaptation (SFUDA):
Put the model in eval mode (freeze all parameters)
Put SPDBatchNormMeanVar in train mode (update running statistics)
Pass target domain data through the model (no labels needed!)
The running mean adapts to the target domain distribution
def adapt_spdbn(
model,
X_target,
n_passes=10,
reset_stats=False,
adapt_momentum=0.8,
batch_size=64,
):
"""Adapt SPDBatchNormMeanVar statistics to target domain.
Parameters
----------
model : nn.Module
TSMNet model with SPDBatchNormMeanVar layer.
X_target : array
Target domain data (unlabeled).
n_passes : int
Number of passes through the data for statistics update.
reset_stats : bool
If True, reset running statistics before adaptation.
Default is False to preserve source domain knowledge.
adapt_momentum : float
Momentum to use during adaptation (higher = faster adaptation).
batch_size : int
Batch size for adaptation (larger = more stable statistics).
Returns
-------
model : nn.Module
The adapted model with updated SPDBatchNormMeanVar statistics.
"""
model.eval() # Freeze other layers
# Find SPDBatchNormMeanVar layers and configure for adaptation
spdbn_modules = []
original_momentums = []
for module in model.modules():
class_name = module.__class__.__name__
if "SPDBatchNormMeanVar" in class_name:
spdbn_modules.append(module)
original_momentums.append(module.momentum)
if reset_stats:
# Reset to identity mean and unit variance for fresh adaptation
module.reset_running_stats()
module.train() # Enable running stats update
print(f"Found {len(spdbn_modules)} SPDBatchNormMeanVar layer(s) to adapt")
# Convert to tensor
X_tensor = torch.tensor(X_target, dtype=torch.float32)
if next(model.parameters()).is_cuda:
X_tensor = X_tensor.cuda()
# Pass data through model multiple times to update statistics
with torch.no_grad():
for pass_idx in range(n_passes):
# Set momentum for this pass
for module in spdbn_modules:
module.momentum = adapt_momentum
# Reshuffle each pass for better statistics
perm = torch.randperm(len(X_tensor))
X_shuffled = X_tensor[perm]
# Process in batches
for i in range(0, len(X_shuffled), batch_size):
batch = X_shuffled[i : i + batch_size]
_ = model(batch)
if (pass_idx + 1) % 10 == 0 or pass_idx == 0:
print(f" Adaptation pass {pass_idx + 1}/{n_passes}")
# Restore original momentum values
for module, orig_momentum in zip(spdbn_modules, original_momentums):
module.momentum = orig_momentum
model.eval() # Set everything back to eval
return model
def predict_with_domain_specific_bn(model, X_data):
"""Predict using domain-specific batch normalization (SPDDSMBN approach).
This implements the key idea from Kobler et al. (NeurIPS 2022):
Compute domain-specific statistics on the target domain and use them
directly for normalization. This is different from standard adaptation
which tries to blend source and target statistics.
Parameters
----------
model : nn.Module
TSMNet model with SPDBatchNormMeanVar layer.
X_data : array
Target domain data to predict on.
Returns
-------
predictions : array
Predicted class labels.
"""
# Find SPDBatchNormMeanVar layers
spdbn_modules = []
for module in model.modules():
class_name = module.__class__.__name__
if "SPDBatchNormMeanVar" in class_name:
spdbn_modules.append(module)
model.eval()
# Convert to tensor
X_tensor = torch.tensor(X_data, dtype=torch.float32)
if next(model.parameters()).is_cuda:
X_tensor = X_tensor.cuda()
# Key insight from SPDDSMBN: Compute domain-specific statistics
# by processing ALL target data with momentum=1.0 (full batch stats)
# This estimates the target domain's Fréchet mean and variance
for module in spdbn_modules:
module.reset_running_stats() # Start fresh for target domain
module.momentum = 1.0 # Use full batch statistics
module.train() # Enable running stats update
# Single pass to compute target domain statistics using all data
with torch.no_grad():
_ = model(X_tensor) # This updates running_mean and running_var
# Now predict using the target domain statistics
model.eval() # Back to eval mode - uses the updated running stats
with torch.no_grad():
logits = model(X_tensor)
predictions = logits.argmax(dim=1).cpu().numpy()
return predictions
def extract_features_from_tsmnet(model, X, batch_size=32):
"""Extract tangent space features from TSMNet (before classification head).
This extracts the Euclidean features from the tangent space projection,
which can be used with standard domain adaptation methods like CORAL,
Subspace Alignment, and Optimal Transport.
Parameters
----------
model : nn.Module
TSMNet model.
X : array
Input EEG data of shape (n_samples, n_channels, n_times).
batch_size : int
Batch size for processing.
Returns
-------
features : np.ndarray
Tangent space features of shape (n_samples, n_features).
"""
model.eval()
X_tensor = torch.tensor(X, dtype=torch.float32)
device = next(model.parameters()).device
X_tensor = X_tensor.to(device)
features_list = []
with torch.no_grad():
for i in range(0, len(X_tensor), batch_size):
batch = X_tensor[i : i + batch_size]
# Process through TSMNet layers up to LogEig (before classification head)
x = batch[:, None, ...] # Add channel dim for CNN
x = model.cnn(x)
x = model.covpool(x)
x = model.spdnet(x)
x = model.spdbnorm(x)
x = model.logeig(x) # Tangent space features
features_list.append(x.cpu())
return torch.cat(features_list, dim=0).numpy()
def plot_domain_shift_comprehensive(
features_source: np.ndarray,
features_target: np.ndarray,
y_source: np.ndarray,
y_target: np.ndarray,
class_names: List[str],
title: str = "Domain Shift Visualization",
figsize: Tuple[int, int] = (16, 12),
) -> plt.Figure:
"""Comprehensive visualization of domain shift.
Creates a 2x3 grid showing:
- Domain distributions (PCA)
- Source and target by class
- Feature histograms per domain
- Class-conditional distributions
Parameters
----------
features_source : np.ndarray
Source domain features.
features_target : np.ndarray
Target domain features.
y_source : np.ndarray
Source domain labels.
y_target : np.ndarray
Target domain labels.
class_names : List[str]
Names of the classes.
title : str
Overall figure title.
figsize : Tuple[int, int]
Figure size.
Returns
-------
plt.Figure
The matplotlib figure.
"""
# Combine features for PCA
features_all = np.vstack([features_source, features_target])
pca = PCA(n_components=2)
features_2d = pca.fit_transform(features_all)
n_source = len(features_source)
source_2d = features_2d[:n_source]
target_2d = features_2d[n_source:]
fig, axes = plt.subplots(2, 3, figsize=figsize)
# --- Row 1 ---
# Plot 1: All data by domain
ax1 = axes[0, 0]
ax1.scatter(
source_2d[:, 0],
source_2d[:, 1],
c="blue",
alpha=0.5,
label="Source",
marker="o",
s=40,
)
ax1.scatter(
target_2d[:, 0],
target_2d[:, 1],
c="red",
alpha=0.5,
label="Target",
marker="s",
s=40,
)
ax1.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0] * 100:.1f}%)")
ax1.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1] * 100:.1f}%)")
ax1.set_title("Domain Distribution", fontweight="bold")
ax1.legend()
ax1.grid(True, alpha=0.3)
# Plot 2: Source domain by class
ax2 = axes[0, 1]
n_classes = len(np.unique(y_source))
colors = plt.cm.tab10(np.linspace(0, 1, max(n_classes, 4)))[:n_classes]
for label_idx, label in enumerate(np.unique(y_source)):
mask = y_source == label
ax2.scatter(
source_2d[mask, 0],
source_2d[mask, 1],
c=colors[label_idx],
alpha=0.6,
label=f"{class_names[label_idx]}",
marker="o",
s=40,
)
ax2.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0] * 100:.1f}%)")
ax2.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1] * 100:.1f}%)")
ax2.set_title("Source Domain (by class)", fontweight="bold")
ax2.legend()
ax2.grid(True, alpha=0.3)
# Plot 3: Target domain by class
ax3 = axes[0, 2]
for label_idx, label in enumerate(np.unique(y_target)):
mask = y_target == label
ax3.scatter(
target_2d[mask, 0],
target_2d[mask, 1],
c=colors[label_idx],
alpha=0.6,
label=f"{class_names[label_idx]}",
marker="s",
s=40,
)
ax3.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0] * 100:.1f}%)")
ax3.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1] * 100:.1f}%)")
ax3.set_title("Target Domain (by class)", fontweight="bold")
ax3.legend()
ax3.grid(True, alpha=0.3)
# --- Row 2 ---
# Plot 4: Feature histogram (first 3 features)
ax4 = axes[1, 0]
for feat_idx in range(min(3, features_source.shape[1])):
ax4.hist(
features_source[:, feat_idx],
bins=20,
alpha=0.5,
label=f"Source feat {feat_idx}",
density=True,
)
ax4.hist(
features_target[:, feat_idx],
bins=20,
alpha=0.5,
label=f"Target feat {feat_idx}",
density=True,
linestyle="--",
)
ax4.set_xlabel("Feature Value")
ax4.set_ylabel("Density")
ax4.set_title("Feature Distribution (first 3)", fontweight="bold")
ax4.legend(fontsize=8)
ax4.grid(True, alpha=0.3)
# Plot 5: Class-conditional distributions (Source)
ax5 = axes[1, 1]
for label_idx, label in enumerate(np.unique(y_source)):
mask_s = y_source == label
ax5.hist(
source_2d[mask_s, 0],
bins=15,
alpha=0.5,
label=f"Source-{class_names[label_idx]}",
color=colors[label_idx],
density=True,
)
ax5.set_xlabel("PC1")
ax5.set_ylabel("Density")
ax5.set_title("Class Distribution (Source)", fontweight="bold")
ax5.legend()
ax5.grid(True, alpha=0.3)
# Plot 6: Class-conditional distributions (Target)
ax6 = axes[1, 2]
for label_idx, label in enumerate(np.unique(y_target)):
mask_t = y_target == label
ax6.hist(
target_2d[mask_t, 0],
bins=15,
alpha=0.5,
label=f"Target-{class_names[label_idx]}",
color=colors[label_idx],
density=True,
)
ax6.set_xlabel("PC1")
ax6.set_ylabel("Density")
ax6.set_title("Class Distribution (Target)", fontweight="bold")
ax6.legend()
ax6.grid(True, alpha=0.3)
plt.suptitle(title, fontsize=14, fontweight="bold")
plt.tight_layout()
return fig
# Get the underlying model from the classifier
underlying_model = clf.module_
# Use Domain-Specific Batch Normalization (SPDDSMBN approach from Kobler et al.)
# This computes target-domain-specific statistics and uses them for normalization
print("\n" + "=" * 50)
print("Using Domain-Specific Batch Normalization (SPDDSMBN)")
print("=" * 50)
print("Computing target domain statistics (Fréchet mean and variance)")
==================================================
Using Domain-Specific Batch Normalization (SPDDSMBN)
==================================================
Computing target domain statistics (Fréchet mean and variance)
Evaluating After Adaptation#
Note
Cross-session transfer typically shows distribution shifts that SPDBatchNormMeanVar can correct. The improvement depends on:
Non-stationarity between sessions
Training convergence on the source session
How well the learned features generalize
Typical improvements range from 3-10% for cross-session transfer.
# Predict using domain-specific batch normalization
# Key insight: Use target domain statistics directly (not blended with source)
y_pred_target_adapted = predict_with_domain_specific_bn(underlying_model, X_target)
target_acc_adapted = accuracy_score(y_target, y_pred_target_adapted)
improvement = target_acc_adapted - target_acc_no_adapt
print(f"\n{'=' * 50}")
print("Results WITH Domain Adaptation")
print(f"{'=' * 50}")
print(f"Target Accuracy (No Adaptation): {target_acc_no_adapt * 100:.2f}%")
print(f"Target Accuracy (With Adaptation): {target_acc_adapted * 100:.2f}%")
if improvement >= 0:
print(f"Improvement: +{improvement * 100:.2f}%")
else:
print(f"Improvement: {improvement * 100:.2f}%")
==================================================
Results WITH Domain Adaptation
==================================================
Target Accuracy (No Adaptation): 75.69%
Target Accuracy (With Adaptation): 72.57%
Improvement: -3.12%
Domain Adaptation with SKADA#
Now we compare SPDBatchNormMeanVar/TTBN with domain adaptation methods from skada (scikit-learn domain adaptation). These methods operate on the Euclidean tangent space features extracted from TSMNet.
Methods compared:
CORAL: Correlation Alignment - aligns second-order statistics
Subspace Alignment: Linear subspace mapping between domains
Entropic Optimal Transport: Sample-to-sample mapping
print("\n" + "=" * 50)
print("SKADA Domain Adaptation Methods")
print("=" * 50)
# Extract tangent space features for SKADA methods
features_source = extract_features_from_tsmnet(underlying_model, X_source)
features_target = extract_features_from_tsmnet(underlying_model, X_target)
print(f"Source features shape: {features_source.shape}")
print(f"Target features shape: {features_target.shape}")
==================================================
SKADA Domain Adaptation Methods
==================================================
Source features shape: (288, 136)
Target features shape: (288, 136)
Visualizing Domain Shift#
Before applying domain adaptation, let’s visualize the distribution shift between source and target sessions using PCA projection.
class_names = [str(c) for c in le.classes_]
fig = plot_domain_shift_comprehensive(
features_source,
features_target,
y_source,
y_target,
class_names=class_names,
title="Riemannian Feature Space - Cross-Session Distribution",
)
plt.show()
# Prepare data in SKADA format
# SKADA uses sample_domain to distinguish domains:
# - Positive values (1): Source domain
# - Negative values (-1): Target domain
X_combined = np.vstack([features_source, features_target])
y_combined = np.concatenate([y_source, -np.ones(len(y_target))])
sample_domain = np.concatenate(
[np.ones(len(features_source)), -np.ones(len(features_target))]
)
# Initialize results dictionary
results = {
"No Adaptation": target_acc_no_adapt,
"SPDBatchNormMeanVar (TTBN)": target_acc_adapted,
}

CORAL (Correlation Alignment)#
CORAL aligns the second-order statistics (covariance) of source and target feature distributions. This is particularly suitable for SPD-derived features since they already capture covariance structure.
print("\n" + "-" * 50)
print("CORAL (Correlation Alignment)")
print("-" * 50)
coral_pipeline = make_da_pipeline(
StandardScaler(),
CORALAdapter(reg=1e-3),
LogisticRegression(max_iter=1000),
)
coral_pipeline.fit(X_combined, y_combined, sample_domain=sample_domain)
y_pred_coral = coral_pipeline.predict(features_target)
coral_acc = accuracy_score(y_target, y_pred_coral)
print(f"CORAL Accuracy: {coral_acc * 100:.2f}%")
print(f"Improvement over baseline: {(coral_acc - target_acc_no_adapt) * 100:+.2f}%")
results["CORAL"] = coral_acc
--------------------------------------------------
CORAL (Correlation Alignment)
--------------------------------------------------
CORAL Accuracy: 77.43%
Improvement over baseline: +1.74%
Subspace Alignment#
Subspace Alignment learns a linear transformation that aligns the principal subspaces of source and target domains.
print("\n" + "-" * 50)
print("Subspace Alignment")
print("-" * 50)
sa_clf = SubspaceAlignment(
base_estimator=LogisticRegression(max_iter=1000),
n_components=min(10, features_source.shape[1]),
)
sa_clf.fit(X_combined, y_combined, sample_domain=sample_domain)
y_pred_sa = sa_clf.predict(features_target)
sa_acc = accuracy_score(y_target, y_pred_sa)
print(f"Subspace Alignment Accuracy: {sa_acc * 100:.2f}%")
print(f"Improvement over baseline: {(sa_acc - target_acc_no_adapt) * 100:+.2f}%")
results["Subspace Alignment"] = sa_acc
--------------------------------------------------
Subspace Alignment
--------------------------------------------------
Subspace Alignment Accuracy: 72.92%
Improvement over baseline: -2.78%
Entropic Optimal Transport#
Optimal Transport finds the minimum cost mapping between source and target distributions. Entropic regularization makes the optimization tractable and provides smoother mappings.
print("\n" + "-" * 50)
print("Entropic Optimal Transport")
print("-" * 50)
try:
ot_clf = EntropicOTMapping(
base_estimator=LogisticRegression(max_iter=1000),
reg_e=1.0,
)
ot_clf.fit(X_combined, y_combined, sample_domain=sample_domain)
y_pred_ot = ot_clf.predict(features_target)
ot_acc = accuracy_score(y_target, y_pred_ot)
print(f"Entropic OT Accuracy: {ot_acc * 100:.2f}%")
print(f"Improvement over baseline: {(ot_acc - target_acc_no_adapt) * 100:+.2f}%")
results["Entropic OT"] = ot_acc
except Exception as e:
print(f"Entropic OT failed: {e}")
--------------------------------------------------
Entropic Optimal Transport
--------------------------------------------------
Entropic OT Accuracy: 72.57%
Improvement over baseline: -3.12%
Results Summary#
print("\n" + "=" * 60)
print("Domain Adaptation Results Summary")
print("=" * 60)
print(f"{'Method':<25} {'Accuracy':>12} {'vs Baseline':>14}")
print("-" * 55)
for method, acc in results.items():
if method == "No Adaptation":
print(f"{method:<25} {acc * 100:>10.2f}% {'-':>14}")
else:
imp = acc - target_acc_no_adapt
print(f"{method:<25} {acc * 100:>10.2f}% {imp * 100:>+12.2f}%")
print("-" * 55)
print("Chance level: 25.00% (4 classes)")
# Find best method
best_method = max(results.keys(), key=lambda k: results[k])
print(f"\nBest method: {best_method} ({results[best_method] * 100:.2f}%)")
============================================================
Domain Adaptation Results Summary
============================================================
Method Accuracy vs Baseline
-------------------------------------------------------
No Adaptation 75.69% -
SPDBatchNormMeanVar (TTBN) 72.57% -3.12%
CORAL 77.43% +1.74%
Subspace Alignment 72.92% -2.78%
Entropic OT 72.57% -3.12%
-------------------------------------------------------
Chance level: 25.00% (4 classes)
Best method: CORAL (77.43%)
Visualizing Results#
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
# 1. Domain Adaptation Methods Comparison
ax1 = axes[0]
methods = list(results.keys())
accuracies = [results[m] * 100 for m in methods]
colors = ["#e74c3c", "#3498db", "#2ecc71", "#9b59b6", "#f39c12"][: len(methods)]
bars = ax1.bar(methods, accuracies, color=colors, edgecolor="black", linewidth=1.5)
ax1.set_ylabel("Accuracy (%)", fontsize=12)
ax1.set_title("Domain Adaptation Comparison", fontsize=14)
ax1.set_ylim([0, 100])
ax1.axhline(y=25, color="gray", linestyle="--", alpha=0.5, label="Chance (25%)")
ax1.axhline(
y=source_acc * 100,
color="blue",
linestyle=":",
alpha=0.5,
label=f"Source ({source_acc * 100:.1f}%)",
)
# Add value labels
for bar, acc in zip(bars, accuracies):
ax1.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + 2,
f"{acc:.1f}%",
ha="center",
va="bottom",
fontsize=9,
fontweight="bold",
)
ax1.legend(loc="lower right", fontsize=8)
plt.setp(ax1.xaxis.get_majorticklabels(), rotation=30, ha="right")
# 2. Training history
ax2 = axes[1]
history = clf.history
epochs_hist = range(1, len(history) + 1)
ax2.plot(epochs_hist, history[:, "train_loss"], "b-", label="Train Loss", linewidth=2)
ax2.plot(epochs_hist, history[:, "valid_loss"], "r--", label="Valid Loss", linewidth=2)
ax2.set_xlabel("Epoch", fontsize=12)
ax2.set_ylabel("Loss", fontsize=12)
ax2.set_title("Training History", fontsize=14)
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)
# 3. Feature space PCA visualization
ax3 = axes[2]
pca = PCA(n_components=2)
features_all = np.vstack([features_source, features_target])
features_2d = pca.fit_transform(features_all)
n_source = len(features_source)
ax3.scatter(
features_2d[:n_source, 0],
features_2d[:n_source, 1],
c="blue",
alpha=0.5,
label="Source",
marker="o",
s=30,
)
ax3.scatter(
features_2d[n_source:, 0],
features_2d[n_source:, 1],
c="red",
alpha=0.5,
label="Target",
marker="s",
s=30,
)
ax3.set_xlabel(f"PC1 ({pca.explained_variance_ratio_[0] * 100:.1f}%)", fontsize=12)
ax3.set_ylabel(f"PC2 ({pca.explained_variance_ratio_[1] * 100:.1f}%)", fontsize=12)
ax3.set_title("Feature Space (PCA)", fontsize=14)
ax3.legend(fontsize=10)
ax3.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Understanding SPDBatchNormMeanVar Adaptation#
The key insight is that session variability manifests as a shift in the distribution of SPD matrices. SPDBatchNormMeanVar counters this by:
Centering: Removes the batch mean (Fréchet mean on SPD manifold)
\[\tilde{P}_i = G^{-1/2} P_i G^{-1/2}\]Scaling: Normalizes dispersion
\[\hat{P}_i = \tilde{P}_i^{w/\sqrt{\sigma^2 + \varepsilon}}\]
When we adapt, we update the running mean \(G\) and variance \(\sigma^2\) to match the target domain, aligning the distributions without any labeled data.
Summary#
In this tutorial, we demonstrated:
Training TSMNet on source session
Observing performance drop on target session
Adapting using SPDBatchNormMeanVar (Test-Time Batch Normalization)
Comparing with SKADA domain adaptation methods:
CORAL: Correlation Alignment
Subspace Alignment: Linear subspace mapping
Entropic Optimal Transport: Sample-to-sample mapping
Key insights:
SPDBatchNormMeanVar provides a native Riemannian approach that operates directly on SPD matrices
SKADA methods operate on Euclidean tangent space features and can complement or outperform SPDBatchNormMeanVar depending on the domain shift
Combining multiple approaches allows practitioners to select the best method for their specific use case
Cross-session non-stationarity is a key challenge in BCI. Domain adaptation methods compensate for these shifts by aligning feature distributions between source and target domains.
This unsupervised domain adaptation is particularly valuable in BCI applications where:
Calibration time should be minimized
Users return for multiple sessions
Signal properties drift over time
Total running time of the script: (4 minutes 29.176 seconds)