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:

  1. Normalizes SPD matrices using the Fréchet mean

  2. Maintains running statistics that can be updated on new data

  3. 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:

  1. Temporal Conv: Learns temporal filters

  2. Spatial Conv: Learns spatial combinations

  3. CovLayer: Computes covariance matrices

  4. BiMap + ReEig: SPD dimensionality reduction

  5. SPDBatchNormMeanVar: Riemannian batch normalization (key for adaptation)

  6. LogEig: Projects to tangent space

  7. 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
<class 'braindecode.classifier.EEGClassifier'>[initialized](
  module_=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)
  ),
)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.


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):

  1. Put the model in eval mode (freeze all parameters)

  2. Put SPDBatchNormMeanVar in train mode (update running statistics)

  3. Pass target domain data through the model (no labels needed!)

  4. 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,
}
Riemannian Feature Space - Cross-Session Distribution, Domain Distribution, Source Domain (by class), Target Domain (by class), Feature Distribution (first 3), Class Distribution (Source), Class Distribution (Target)

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()
Domain Adaptation Comparison, Training History, Feature Space (PCA)

Understanding SPDBatchNormMeanVar Adaptation#

The key insight is that session variability manifests as a shift in the distribution of SPD matrices. SPDBatchNormMeanVar counters this by:

  1. Centering: Removes the batch mean (Fréchet mean on SPD manifold)

    \[\tilde{P}_i = G^{-1/2} P_i G^{-1/2}\]
  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:

  1. Training TSMNet on source session

  2. Observing performance drop on target session

  3. Adapting using SPDBatchNormMeanVar (Test-Time Batch Normalization)

  4. 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)