Track 2: a public motor-imagery warm-up for session transfer#

Estimated reading time:7 minutes

Load BNCI2014-004 subject 1, sessions 0train and 1train, run 0 (nm000135). The two recordings total about 11 MB. This CPU baseline predicts observed left-hand and right-hand cues with log channel power. Session 0train fits the model; session 1train is held out without calibration. These are already processed real recordings. This compact motor-imagery example has two classes and its own session split, not the official three-command Graz / BrainHero protocol.

Source: https://nemar.org/dataset/nm000135

Before you start#

Use an installed EEGDash environment with Braindecode, MNE, NumPy, scikit-learn and Matplotlib. This two-recording CPU example needs about 11 MB of cached signal data. EEGDASH_CACHE_DIR chooses the persistent download location; a network connection is needed when either session is absent.

The deployment question is prediction in another recorded session of the same person, without calibration on that session. The two labels are actual left- and right-hand imagery cues. This small BNCI corpus is an accessible example of session transfer; its two classes do not stand in for three mental commands.

What the official track asks, and what this example measures#

The 2026 track description specifies imagery, arithmetic and word-association commands across Graz and BrainHero contexts. The cohort has 20 people and six sessions each. Ten people supply all six labelled sessions; the other ten supply labelled sessions 1–3 for calibration and hidden sessions 4–6 for scoring. Evaluation averages balanced accuracy over subject/session/context cells. Combining every test window into one score could overweight cells with more data.

The NeuralBench Track 2 guide currently uses a public four-class Stieger motor-imagery task with a cross-subject split. It is a separate starter baseline, not the official cross-session protocol. This page uses a smaller two-class BNCI recording pair to exercise session separation through EEGDash. Its single held-out participant/session has no Graz/BrainHero context cells. Keep its score labelled as a local warm-up; neither extra classes nor contexts can be reconstructed by renaming its existing labels.

import matplotlib.pyplot as plt
import numpy as np
from braindecode.preprocessing import create_windows_from_events
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import balanced_accuracy_score, ConfusionMatrixDisplay
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

from eegdash.paths import get_default_cache_dir

from eegdash import EEGDashDataset
from eegdash.features import signal_variance

Inspect both sessions before preprocessing#

The explicit subject, session, run and task query should return exactly two recordings. Keeping session identities intact is essential: relabelling two runs as sessions would not test session generalization. The annotation printout confirms the available class names before defining their numeric codes.

C3, Cz and C4 cover the motor region and are the source’s three EEG channels. The release is already processed; the additional 8–30 Hz filter selects the sensorimotor frequency range for this particular power baseline rather than claiming to redo acquisition preprocessing. MNE’s Raw.resample converts to 100 Hz and reduces the number of samples while retaining that passband. These fixed operations do not learn their parameters from the held-out session, but the offline filtering here should not be mistaken for causal streaming preprocessing.

# Native MNE resampling preserves annotations in seconds. No format conversion
# or manual event restoration is needed for this small three-channel recipe.

dataset = EEGDashDataset(
    dataset="nm000135",
    subject="1",
    session=["0train", "1train"],
    run="0",
    task="imagery",
    cache_dir=get_default_cache_dir(),
)
print(dataset.description.to_string(index=False))
for recording in dataset.datasets:
    raw = recording.raw.pick("eeg")
    # Limit RAM to three motor channels before filtering and resampling.
    raw.pick(["C3", "Cz", "C4"]).load_data().filter(8, 30)
    raw.reorder_channels(["C3", "Cz", "C4"]).resample(100)
subject session run    task  age sex hand   group species bci_experience gender
      1  0train   0 imagery 21.0   F    R healthy   human          naive   None
      1  1train   0 imagery 21.0   F    R healthy   human          naive   None
Reading 0 ... 604999  =      0.000 ...  2419.996 secs...
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 8 - 30 Hz

FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 8.00
- Lower transition bandwidth: 2.00 Hz (-6 dB cutoff frequency: 7.00 Hz)
- Upper passband edge: 30.00 Hz
- Upper transition bandwidth: 7.50 Hz (-6 dB cutoff frequency: 33.75 Hz)
- Filter length: 413 samples (1.652 s)

Reading 0 ... 552499  =      0.000 ...  2209.996 secs...
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 8 - 30 Hz

FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 8.00
- Lower transition bandwidth: 2.00 Hz (-6 dB cutoff frequency: 7.00 Hz)
- Upper passband edge: 30.00 Hz
- Upper transition bandwidth: 7.50 Hz (-6 dB cutoff frequency: 33.75 Hz)
- Filter length: 413 samples (1.652 s)

Window the actual imagery intervals#

left_hand and right_hand map explicitly to zero and one. After resampling, 300 samples correspond to three seconds. Equal stride and window size give non-overlapping complete windows within the labelled intervals; on_last_window="drop" discards an incomplete remainder rather than padding it. Zero start/stop offsets preserve the source event boundaries.

The window metadata carries session and target through preprocessing. Before feature reduction, X has layout (windows, 3, 300) in volts. Natural-log variance over time reduces it to one channel-power feature per channel. The small numerical floor makes zero power finite, but does not perform artifact rejection or rescue a noninformative electrode.

classes = {"left_hand": 0, "right_hand": 1}
windows = create_windows_from_events(
    dataset,
    mapping=classes,
    trial_start_offset_samples=0,
    trial_stop_offset_samples=0,
    window_size_samples=300,
    window_stride_samples=300,
    on_last_window="drop",
    preload=True,
)
metadata = windows.get_metadata()
X = np.stack([windows[i][0] for i in range(len(windows))])
y = metadata.target.to_numpy(dtype=int)
X = np.log(np.maximum(signal_variance(X), 1e-30))

Hold out a genuine session without recalibration#

Only 0train fits the model; 1train is the held-out session. Those strings are source identifiers, not instructions to include both in training. The two sessions share a participant intentionally and contain the same observed two-class task.

Feature standardization belongs inside the pipeline so the test-session mean and variance remain unseen during fitting. Logistic regression uses its default regularization; max_iter=1000 gives the optimizer room to converge and is not a score-tuning parameter. This page has no validation set and therefore makes no data-driven hyperparameter selection.

train = metadata.session.eq("0train").to_numpy()
test = metadata.session.eq("1train").to_numpy()
print("Features:", X.shape, "classes:", np.unique(y, return_counts=True))
Features: (240, 3) classes: (array([0, 1]), array([120, 120]))
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
predicted = model.fit(X[train], y[train]).predict(X[test])
print(
    "Held-out session balanced accuracy:", balanced_accuracy_score(y[test], predicted)
)
print("Constant-class balanced-accuracy reference: 0.5")
ConfusionMatrixDisplay.from_predictions(
    y[test], predicted, display_labels=list(classes)
)
plt.xticks(rotation=25)
plt.tight_layout()
plt.show()
tutorial track 2 bci
Held-out session balanced accuracy: 0.5333333333333333
Constant-class balanced-accuracy reference: 0.5

Interpret session transfer and extend it#

Balanced accuracy is the mean of left-hand and right-hand recall, with 0.5 as the uniform two-class reference. The confusion matrix displays actual counts: inspect whether errors concentrate in one class instead of relying on a single score. A near-chance result can reflect a weak feature representation or real session differences; it should not trigger a favourable-score assertion.

One person’s two sessions do not establish population performance. To tune the band range, regularization or an EEGNet model, add a separate validation session and preserve the present test session. Then repeat the design across additional people where the catalogue actually supplies the required sessions. Compare pipelines using identical held-out windows and retain original trial IDs if extracting several windows from each trial.

To move to the official task, use the released command labels and session assignments from the competition source, retain participant/context metadata, and compute the per-cell metric before averaging. Keep later sessions out of calibration and hyperparameter selection. Changing the classifier output size alone would not make this two-class dataset an official evaluation.

Related worked example: Braindecode cross-session motor imagery.