Exploratory eye-state decoding in an outcome-selected participant#

Estimated reading time:4 minutes

Train ShallowFBCSPNet on real two-second EEG windows from HBN participant NDARAC589YMB (ds005514, about 90 MB). Install EEGDash, Braindecode, PyTorch, MNE, scikit-learn and Matplotlib. CPU training uses 30 epochs; wall time depends on hardware. EEGDASH_CACHE_DIR retains the recording.

This participant was selected after inspecting outcomes across twelve people and several pipeline settings. Neither a fresh block split nor scoring once removes that selection bias. The result is an exploratory workflow illustration, not unbiased evidence of within-person or population performance. Prespecify participants, preprocessing and development/validation blocks for a new study.

We retain original instruction-block identities, reject BAD-annotated epochs, and score reserved blocks only after a fixed training budget. Filtering is offline, not a causal real-time deployment pipeline. The labels describe the instructions, not verified compliance with eye opening/closing.

Load one recording and retain its actual instruction boundaries#

import matplotlib.pyplot as plt
import mne
import numpy as np
import torch
from braindecode.models import ShallowFBCSPNet
from sklearn.metrics import balanced_accuracy_score, ConfusionMatrixDisplay
from sklearn.model_selection import train_test_split
from torch.utils.data import DataLoader, TensorDataset

from eegdash import EEGDashDataset
from eegdash.paths import get_default_cache_dir

dataset = EEGDashDataset(
    cache_dir=get_default_cache_dir(),
    dataset="ds005514",
    task="RestingState",
    subject="NDARAC589YMB",
)
raw = dataset.datasets[0].raw.copy().load_data()
channels = [
    "E22",
    "E9",
    "E33",
    "E24",
    "E11",
    "E124",
    "E122",
    "E29",
    "E6",
    "E111",
    "E45",
    "E36",
    "E104",
    "E108",
    "E42",
    "E55",
    "E93",
    "E58",
    "E52",
    "E62",
    "E92",
    "E96",
    "E70",
    "Cz",
]
raw.pick(channels).reorder_channels(channels).resample(128).filter(1, 55)
# Keep the original annotations, including BAD spans. Each new epoch carries
# the index of its original instruction, rather than inferring blocks by // 7.
cue_specs = {"instructed_toCloseEyes": (0, 15), "instructed_toOpenEyes": (1, 5)}
events, block_ids = [], []
for block_id, annotation in enumerate(raw.annotations):
    if annotation["description"] not in cue_specs:
        continue
    label, delay = cue_specs[annotation["description"]]
    onset = annotation["onset"] - raw.first_time
    for offset in np.arange(delay, delay + 14, 2):
        sample = int(round((onset + offset) * raw.info["sfreq"])) + raw.first_samp
        events.append([sample, 0, label + 1])
        block_ids.append(block_id)
order = np.argsort(np.asarray(events)[:, 0])
events = np.asarray(events)[order]
block_ids = np.asarray(block_ids)[order]
epochs = mne.Epochs(
    raw,
    events,
    event_id={"eyes_closed": 1, "eyes_open": 2},
    tmin=0,
    tmax=2 - 1 / 128,
    baseline=None,
    preload=True,
    reject_by_annotation=True,
)
X = epochs.get_data().astype(np.float32) * 1e6
y = epochs.events[:, 2] - 1
blocks = block_ids[epochs.selection]
print("Retained / requested epochs:", len(epochs), len(events))
╭────────────────────── EEG 2025 Competition Data Notice ──────────────────────╮
│ This notice is only for users who are participating in the EEG 2025          │
│ Competition.                                                                 │
│                                                                              │
│ EEG 2025 Competition Data Notice!                                            │
│ You are loading one of the datasets that is used in competition, but via     │
│ `EEGDashDataset`.                                                            │
│                                                                              │
│ IMPORTANT:                                                                   │
│ If you download data from `EEGDashDataset`, it is NOT identical to the       │
│ official                                                                     │
│ competition data, which is accessed via `EEGChallengeDataset`. The           │
│ competition data has been downsampled and filtered.                          │
│                                                                              │
│ If you are participating in the competition,                                 │
│ you must use the `EEGChallengeDataset` object to ensure consistency.         │
│                                                                              │
│ If you are not participating in the competition, you can ignore this         │
│ message.                                                                     │
╰─────────────────────────── Source: EEGDashDataset ───────────────────────────╯
[10/09/26 23:15:52] INFO     Auto-corrected misrouted             dataset.py:561
                             storage.base for dataset on005514:
                             s3://nemar/on005514 ->
                             s3://openneuro.org/ds005514

Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:   0%|          | 0.00/90.7M [00:00<?, ?B/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:   0%|          | 0.00/90.7M [00:00<?, ?B/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:   1%|          | 1.00M/90.7M [00:00<00:42, 2.22MB/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:  14%|█▍        | 13.0M/90.7M [00:00<00:03, 21.5MB/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:  37%|███▋      | 34.0M/90.7M [00:00<00:01, 40.4MB/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:  60%|█████▉    | 54.0M/90.7M [00:01<00:00, 53.6MB/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:  71%|███████   | 64.0M/90.7M [00:01<00:00, 53.2MB/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:  85%|████████▍ | 77.0M/90.7M [00:01<00:00, 55.5MB/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set:  98%|█████████▊| 89.0M/90.7M [00:01<00:00, 56.3MB/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.set: 100%|██████████| 90.7M/90.7M [00:01<00:00, 53.3MB/s]

Downloading sub-NDARAC589YMB_task-RestingState_channels.tsv:   0%|          | 0.00/1.42k [00:00<?, ?B/s]
Downloading sub-NDARAC589YMB_task-RestingState_channels.tsv:   0%|          | 0.00/1.42k [00:00<?, ?B/s]
Downloading sub-NDARAC589YMB_task-RestingState_channels.tsv: 100%|██████████| 1.42k/1.42k [00:00<00:00, 22.3kB/s]

Downloading sub-NDARAC589YMB_task-RestingState_events.tsv:   0%|          | 0.00/618 [00:00<?, ?B/s]
Downloading sub-NDARAC589YMB_task-RestingState_events.tsv:   0%|          | 0.00/618 [00:00<?, ?B/s]
Downloading sub-NDARAC589YMB_task-RestingState_events.tsv: 100%|██████████| 618/618 [00:00<00:00, 12.7kB/s]

Downloading sub-NDARAC589YMB_task-RestingState_eeg.json:   0%|          | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.json:   0%|          | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARAC589YMB_task-RestingState_eeg.json: 100%|██████████| 231/231 [00:00<00:00, 3.59kB/s]
Reading 0 ... 181313  =      0.000 ...   362.626 secs...
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 1 - 55 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: 1.00
- Lower transition bandwidth: 1.00 Hz (-6 dB cutoff frequency: 0.50 Hz)
- Upper passband edge: 55.00 Hz
- Upper transition bandwidth: 9.00 Hz (-6 dB cutoff frequency: 59.50 Hz)
- Filter length: 423 samples (3.305 s)

/home/runner/work/EEGDash/EEGDash/examples/applied/project_eyes_open_closed_cnn.py:86: RuntimeWarning: 7 events are outside the data range; the corresponding epochs cannot be created. This can happen if the events were created at a different sampling frequency, or have entries before first_samp.
  epochs = mne.Epochs(
Not setting metadata
77 matching events found
No baseline correction applied
0 projection items activated
Using data from preloaded Raw for 77 events and 256 original time points ...
7 bad epochs dropped
Retained / requested epochs: 70 77

Split complete instructions, including after any epoch rejection#

Amplitudes remain in microvolts: this example does not standardize every window separately. All settings are fixed before this run’s score.

unique_blocks = np.unique(blocks)
block_labels = np.asarray([y[blocks == block][0] for block in unique_blocks])
train_blocks, test_blocks = train_test_split(
    unique_blocks,
    test_size=max(2, int(np.ceil(0.2 * len(unique_blocks)))),
    stratify=block_labels,
    random_state=42,
)
train, test = np.isin(blocks, train_blocks), np.isin(blocks, test_blocks)
print("Training blocks:", train_blocks, "reserved blocks:", test_blocks)
print("Training / reserved windows:", train.sum(), test.sum())
Training blocks: [ 8 11  5  3  4  2  6  7] reserved blocks: [ 9 10]
Training / reserved windows: 56 14

Train for a fixed budget without monitoring reserved blocks#

torch.manual_seed(42)
torch.set_num_threads(2)
loader = DataLoader(
    TensorDataset(torch.from_numpy(X[train]), torch.from_numpy(y[train]).long()),
    batch_size=10,
    shuffle=True,
)
model = ShallowFBCSPNet(len(channels), 2, n_times=X.shape[-1], final_conv_length="auto")
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
for epoch in range(30):
    model.train()
    for signals, targets in loader:
        optimizer.zero_grad()
        loss = torch.nn.functional.cross_entropy(model(signals), targets)
        loss.backward()
        optimizer.step()
model.eval()
with torch.inference_mode():
    predicted = model(torch.from_numpy(X[test])).argmax(dim=1).numpy()
print(
    "Exploratory reserved-block balanced accuracy:",
    balanced_accuracy_score(y[test], predicted),
)
print("Constant-class balanced-accuracy reference: 0.5")
ConfusionMatrixDisplay.from_predictions(
    y[test], predicted, display_labels=["Closed", "Open"]
)
plt.title("Outcome-selected participant: exploratory reserved blocks")
plt.show()
Outcome-selected participant: exploratory reserved blocks
Exploratory reserved-block balanced accuracy: 0.9285714285714286
Constant-class balanced-accuracy reference: 0.5

Total running time of the script: (0 minutes 4.417 seconds)