Note
Go to the end to download the full example code or to run this example in your browser via Binder.
Exploratory eye-state decoding in an outcome-selected participant#
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()

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)