Note
Go to the end to download the full example code or to run this example in your browser via Binder.
Decode eyes open and eyes closed from recorded resting EEG#
Load three Healthy Brain Network participants from ds005514, identify
recorded eyes-open/closed instruction markers, and evaluate alpha-power
features on held-out participants. These recordings are approximately
95 MB each; this task-specific example is larger than the SSVEP basics.
CPU is sufficient; retain downloads with EEGDASH_CACHE_DIR. Install
eegprep[eeglabio]>=0.2.23,<0.3. EEGPrep cleaning of the full-density
recordings takes several minutes and is outside the small CI selection.
See the HBN dataset.
The task is to predict the instructed eye condition of a two-second EEG window from a participant absent from training. We use integrated spectral power in a fixed 8–13 Hz band as a small feature set, keeping participant identity through windowing and evaluation. This tests whether those features carry useful condition information in the selected recordings.
Run from top to bottom with EEGDash installed; the preprocessing and leakage-safe split tutorials introduce the Braindecode objects used here. The outputs are a subject-by-condition count table, a descriptive spectrum and one held-out score per participant. Cropping these source recordings would reduce computation but would not avoid their initial download.
1. Query a bounded cohort#
Explicit IDs make the three-recording workload reproducible. Keep the EGI channel names supplied by the source; replacing them with arbitrary 10–20 names would misrepresent electrode identity. This small five-channel subset is fixed before evaluation, and the plot uses E70, its first channel.
import os
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from braindecode.preprocessing import (
EEGPrep,
Preprocessor,
create_windows_from_events,
preprocess,
)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import balanced_accuracy_score
from sklearn.model_selection import LeaveOneGroupOut
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from eegdash import EEGDashDataset
from eegdash.features import spectral_bands_power, spectral_preprocessor
from eegdash.hbn.preprocessing import hbn_ec_ec_reannotation
cache_dir = Path(os.environ.get("EEGDASH_CACHE_DIR", ".eegdash_cache"))
subjects = ["NDARAE710YWG", "NDARAH239PGG", "NDARAL897CYV"]
dataset = EEGDashDataset(
cache_dir=cache_dir,
dataset="ds005514",
task="RestingState",
subject=subjects,
n_jobs=1,
)
assert len(dataset.datasets) == len(subjects)
channels = ["E70", "E62", "E92", "E96", "Cz"]
for recording in dataset.datasets:
raw = recording.raw
assert set(channels) <= set(raw.ch_names)
assert {"instructed_toCloseEyes", "instructed_toOpenEyes"} <= set(
raw.annotations.description
)
print(
recording.description["subject"],
pd.Series(raw.annotations.description).value_counts(),
)
╭────────────────────── 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 ───────────────────────────╯
[09/16/26 20:46:52] INFO Auto-corrected misrouted dataset.py:561
storage.base for dataset on005514:
s3://nemar/on005514 ->
s3://openneuro.org/ds005514
Downloading sub-NDARAE710YWG_task-RestingState_eeg.set: 0%| | 0.00/90.6M [00:00<?, ?B/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.set: 0%| | 0.00/90.6M [00:00<?, ?B/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.set: 9%|▉ | 8.00M/90.6M [00:00<00:02, 41.6MB/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.set: 19%|█▉ | 17.0M/90.6M [00:00<00:03, 23.0MB/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.set: 38%|███▊ | 34.0M/90.6M [00:00<00:01, 37.8MB/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.set: 71%|███████ | 64.0M/90.6M [00:01<00:00, 61.9MB/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.set: 94%|█████████▍| 85.0M/90.6M [00:01<00:00, 62.1MB/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.set: 100%|██████████| 90.6M/90.6M [00:01<00:00, 61.2MB/s]
Downloading sub-NDARAE710YWG_task-RestingState_channels.tsv: 0%| | 0.00/1.42k [00:00<?, ?B/s]
Downloading sub-NDARAE710YWG_task-RestingState_channels.tsv: 0%| | 0.00/1.42k [00:00<?, ?B/s]
Downloading sub-NDARAE710YWG_task-RestingState_channels.tsv: 100%|██████████| 1.42k/1.42k [00:00<00:00, 33.9kB/s]
Downloading sub-NDARAE710YWG_task-RestingState_events.tsv: 0%| | 0.00/616 [00:00<?, ?B/s]
Downloading sub-NDARAE710YWG_task-RestingState_events.tsv: 0%| | 0.00/616 [00:00<?, ?B/s]
Downloading sub-NDARAE710YWG_task-RestingState_events.tsv: 100%|██████████| 616/616 [00:00<00:00, 6.97kB/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.json: 0%| | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.json: 0%| | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARAE710YWG_task-RestingState_eeg.json: 100%|██████████| 231/231 [00:00<00:00, 4.06kB/s]
NDARAE710YWG instructed_toOpenEyes 6
instructed_toCloseEyes 5
break cnt 1
resting_start 1
boundary 1
Name: count, dtype: int64
Downloading sub-NDARAH239PGG_task-RestingState_eeg.set: 0%| | 0.00/90.1M [00:00<?, ?B/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.set: 0%| | 0.00/90.1M [00:00<?, ?B/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.set: 16%|█▌ | 14.0M/90.1M [00:00<00:01, 72.3MB/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.set: 38%|███▊ | 34.0M/90.1M [00:00<00:00, 79.8MB/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.set: 69%|██████▉ | 62.0M/90.1M [00:00<00:00, 101MB/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.set: 91%|█████████ | 82.0M/90.1M [00:00<00:00, 101MB/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.set: 100%|██████████| 90.1M/90.1M [00:00<00:00, 99.3MB/s]
Downloading sub-NDARAH239PGG_task-RestingState_channels.tsv: 0%| | 0.00/1.42k [00:00<?, ?B/s]
Downloading sub-NDARAH239PGG_task-RestingState_channels.tsv: 0%| | 0.00/1.42k [00:00<?, ?B/s]
Downloading sub-NDARAH239PGG_task-RestingState_channels.tsv: 100%|██████████| 1.42k/1.42k [00:00<00:00, 28.0kB/s]
Downloading sub-NDARAH239PGG_task-RestingState_events.tsv: 0%| | 0.00/615 [00:00<?, ?B/s]
Downloading sub-NDARAH239PGG_task-RestingState_events.tsv: 0%| | 0.00/615 [00:00<?, ?B/s]
Downloading sub-NDARAH239PGG_task-RestingState_events.tsv: 100%|██████████| 615/615 [00:00<00:00, 5.63kB/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.json: 0%| | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.json: 0%| | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARAH239PGG_task-RestingState_eeg.json: 100%|██████████| 231/231 [00:00<00:00, 8.56kB/s]
NDARAH239PGG instructed_toOpenEyes 6
instructed_toCloseEyes 5
break cnt 1
resting_start 1
boundary 1
Name: count, dtype: int64
Downloading sub-NDARAL897CYV_task-RestingState_eeg.set: 0%| | 0.00/87.5M [00:00<?, ?B/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.set: 0%| | 0.00/87.5M [00:00<?, ?B/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.set: 8%|▊ | 7.00M/87.5M [00:00<00:02, 35.9MB/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.set: 32%|███▏ | 28.0M/87.5M [00:00<00:00, 73.7MB/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.set: 54%|█████▎ | 47.0M/87.5M [00:00<00:00, 82.9MB/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.set: 74%|███████▍ | 65.0M/87.5M [00:00<00:00, 86.0MB/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.set: 97%|█████████▋| 85.0M/87.5M [00:01<00:00, 85.6MB/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.set: 100%|██████████| 87.5M/87.5M [00:01<00:00, 85.4MB/s]
Downloading sub-NDARAL897CYV_task-RestingState_channels.tsv: 0%| | 0.00/1.42k [00:00<?, ?B/s]
Downloading sub-NDARAL897CYV_task-RestingState_channels.tsv: 0%| | 0.00/1.42k [00:00<?, ?B/s]
Downloading sub-NDARAL897CYV_task-RestingState_channels.tsv: 100%|██████████| 1.42k/1.42k [00:00<00:00, 26.1kB/s]
Downloading sub-NDARAL897CYV_task-RestingState_events.tsv: 0%| | 0.00/616 [00:00<?, ?B/s]
Downloading sub-NDARAL897CYV_task-RestingState_events.tsv: 0%| | 0.00/616 [00:00<?, ?B/s]
Downloading sub-NDARAL897CYV_task-RestingState_events.tsv: 100%|██████████| 616/616 [00:00<00:00, 22.8kB/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.json: 0%| | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.json: 0%| | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARAL897CYV_task-RestingState_eeg.json: 100%|██████████| 231/231 [00:00<00:00, 8.61kB/s]
NDARAL897CYV instructed_toOpenEyes 6
instructed_toCloseEyes 5
break cnt 1
resting_start 1
boundary 1
Name: count, dtype: int64
2. Clean with EEGPrep and retain the recorded instruction times#
EEGPrep performs offset/drift removal, bad-channel detection, ASR burst reconstruction, channel interpolation and common-average referencing. Run it on the complete EEG montage before selecting five predictors: spatial channel checks need the available electrode coverage. Resampling to 128 Hz reduces this stage’s computation. A 40 Hz low-pass follows the cleaning pipeline.
The fixed ASR cutoff of 20 is a demonstration choice, not a guarantee of artifact-free EEG. Cleaning calibrates independently on each entire recording, including an unlabelled held-out recording. This is an offline, per-recording calibration protocol; it is not a fixed cleaner learned only on training subjects. The classifier below still receives labels only from training people.
Whole-window rejection is disabled so cleaning retains the original timeline. EEGPrep’s MNE/EEGLAB conversions can change annotation timing and measurement dates. Save the source annotations, verify that only the sample rate changed, then restore their physical times. Do not use this restoration if enabling any operation that removes time segments.
annotations = [recording.raw.annotations.copy() for recording in dataset.datasets]
measurement_dates = [recording.raw.info["meas_date"] for recording in dataset.datasets]
durations = [
recording.raw.n_times / recording.raw.info["sfreq"]
for recording in dataset.datasets
]
assert all(recording.raw.first_samp == 0 for recording in dataset.datasets)
preprocess(
dataset,
[
EEGPrep(
resample_to=128,
burst_removal_cutoff=20,
bad_window_max_bad_channels=None,
max_mem_mb=128,
),
Preprocessor("filter", l_freq=None, h_freq=40),
],
n_jobs=1,
)
for recording, annotation, measurement_date, duration in zip(
dataset.datasets, annotations, measurement_dates, durations
):
raw = recording.raw
assert raw.first_samp == 0 and abs(raw.n_times / 128 - duration) <= 1 / 128
raw.set_meas_date(measurement_date)
raw.set_annotations(annotation)
np.testing.assert_allclose(raw.annotations.onset, annotation.onset, atol=1e-12)
np.testing.assert_array_equal(raw.annotations.description, annotation.description)
# The HBN helper replaces annotations; retain original BAD spans explicitly.
bad_spans = annotation[
np.char.startswith(np.char.lower(annotation.description), "bad")
]
hbn_ec_ec_reannotation().apply(raw)
raw.set_annotations(raw.annotations + bad_spans)
raw.pick(channels)
/home/runner/work/EEGDash/EEGDash/.venv/lib/python3.12/site-packages/braindecode/preprocessing/preprocess.py:78: UserWarning: apply_on_array can only be True if fn is a callable function. Automatically correcting to apply_on_array=False.
warn(
Reading 0 ... 180962 = 0.000 ... 361.924 secs...
/tmp/tmpsv_qdqqo.set
Field 'subject' is missing from the EEG dictionnary, adding it.
Field 'group' is missing from the EEG dictionnary, adding it.
Field 'condition' is missing from the EEG dictionnary, adding it.
Field 'session' is missing from the EEG dictionnary, adding it.
Field 'comments' is missing from the EEG dictionnary, adding it.
Field 'times' is missing from the EEG dictionnary, adding it.
Field 'icaact' is missing from the EEG dictionnary, adding it.
Field 'icachansind' is missing from the EEG dictionnary, adding it.
Field 'urchanlocs' is missing from the EEG dictionnary, adding it.
Field 'urevent' is missing from the EEG dictionnary, adding it.
Field 'eventdescription' is missing from the EEG dictionnary, adding it.
Field 'epoch' is missing from the EEG dictionnary, adding it.
Field 'epochdescription' is missing from the EEG dictionnary, adding it.
Field 'stats' is missing from the EEG dictionnary, adding it.
Field 'specdata' is missing from the EEG dictionnary, adding it.
Field 'specicaact' is missing from the EEG dictionnary, adding it.
Field 'splinefile' is missing from the EEG dictionnary, adding it.
Field 'icasplinefile' is missing from the EEG dictionnary, adding it.
Field 'dipfit' is missing from the EEG dictionnary, adding it.
Field 'history' is missing from the EEG dictionnary, adding it.
Field 'saved' is missing from the EEG dictionnary, adding it.
Field 'etc' is missing from the EEG dictionnary, adding it.
Field 'datfile' is missing from the EEG dictionnary, adding it.
Field 'run' is missing from the EEG dictionnary, adding it.
Field 'roi' is missing from the EEG dictionnary, adding it.
[09/16/26 20:46:57] WARNING EEGPrep event count eegprep_preprocess.py:123
changed during EEGPrep
processing (14 annotated
events, 13 non-boundary
events); restoring
durations in order for
the overlapping subset
only.
[09/16/26 20:46:58] INFO Detecting flat line clean_artifacts.py:182
channels...
Removing 2 channel(s)...
warning: erasing dipole information since channels have been removed
INFO Applying high‑pass filter... clean_artifacts.py:191
[09/16/26 20:46:59] INFO Scanning for bad channels... clean_channels.py:79
[09/16/26 20:47:02] INFO 1/72 blocks, 0.1 minutes clean_channels.py:136
remaining.
[09/16/26 20:47:05] INFO 51/72 blocks, 0.0 minutes clean_channels.py:136
remaining.
Removing 14 channel(s)...
warning: erasing dipole information since channels have been removed
There was an issue storing removed channels in pop_select
[09/16/26 20:47:06] INFO Applying ASR burst repair... clean_artifacts.py:232
INFO Finding a clean section of the clean_asr.py:102
data for calibration...
INFO Determining time window clean_windows.py:115
rejection thresholds...
[09/16/26 20:47:10] INFO done. clean_windows.py:147
INFO Keeping 26.5% (96 seconds) of clean_windows.py:179
the data.
ERROR Could not select time windows clean_windows.py:204
using EEGLAB's pop_select();
details: Error setting single
item of array.
INFO Falling back to a basic clean_windows.py:207
substitute and dropping signal
meta-data.
INFO Estimating ASR calibration clean_asr.py:138
statistics...
INFO Calculating blockwise covariances... asr.py:167
INFO Calculating robust geometric median asr.py:207
covariance...
INFO Determining per-component thresholds... asr.py:227
[09/16/26 20:47:14] INFO Thresholds calculation complete. asr.py:289
INFO Applying ASR processing... clean_asr.py:177
INFO Cleaning data in 352 blocks asr.py:427
[09/16/26 20:47:21] INFO Finished cleaning. asr.py:546
INFO ASR cleaning finished. clean_asr.py:195
INFO Use vis_artifacts to compare clean_artifacts.py:303
the cleaned data to the
original.
[09/16/26 20:47:22] WARNING EEGPrep event count eegprep_preprocess.py:123
changed during EEGPrep
processing (14 annotated
events, 13 non-boundary
events); restoring
durations in order for
the overlapping subset
only.
Filtering raw data in 1 contiguous segment
Setting up low-pass filter at 40 Hz
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal lowpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Upper passband edge: 40.00 Hz
- Upper transition bandwidth: 10.00 Hz (-6 dB cutoff frequency: 45.00 Hz)
- Filter length: 43 samples (0.336 s)
Reading 0 ... 180116 = 0.000 ... 360.232 secs...
/tmp/tmp8myohb1m.set
Field 'subject' is missing from the EEG dictionnary, adding it.
Field 'group' is missing from the EEG dictionnary, adding it.
Field 'condition' is missing from the EEG dictionnary, adding it.
Field 'session' is missing from the EEG dictionnary, adding it.
Field 'comments' is missing from the EEG dictionnary, adding it.
Field 'times' is missing from the EEG dictionnary, adding it.
Field 'icaact' is missing from the EEG dictionnary, adding it.
Field 'icachansind' is missing from the EEG dictionnary, adding it.
Field 'urchanlocs' is missing from the EEG dictionnary, adding it.
Field 'urevent' is missing from the EEG dictionnary, adding it.
Field 'eventdescription' is missing from the EEG dictionnary, adding it.
Field 'epoch' is missing from the EEG dictionnary, adding it.
Field 'epochdescription' is missing from the EEG dictionnary, adding it.
Field 'stats' is missing from the EEG dictionnary, adding it.
Field 'specdata' is missing from the EEG dictionnary, adding it.
Field 'specicaact' is missing from the EEG dictionnary, adding it.
Field 'splinefile' is missing from the EEG dictionnary, adding it.
Field 'icasplinefile' is missing from the EEG dictionnary, adding it.
Field 'dipfit' is missing from the EEG dictionnary, adding it.
Field 'history' is missing from the EEG dictionnary, adding it.
Field 'saved' is missing from the EEG dictionnary, adding it.
Field 'etc' is missing from the EEG dictionnary, adding it.
Field 'datfile' is missing from the EEG dictionnary, adding it.
Field 'run' is missing from the EEG dictionnary, adding it.
Field 'roi' is missing from the EEG dictionnary, adding it.
[09/16/26 20:47:23] WARNING EEGPrep event count eegprep_preprocess.py:123
changed during EEGPrep
processing (14 annotated
events, 13 non-boundary
events); restoring
durations in order for
the overlapping subset
only.
[09/16/26 20:47:24] INFO Detecting flat line clean_artifacts.py:182
channels...
Removing 2 channel(s)...
warning: erasing dipole information since channels have been removed
INFO Applying high‑pass filter... clean_artifacts.py:191
INFO Scanning for bad channels... clean_channels.py:79
[09/16/26 20:47:27] INFO 1/72 blocks, 0.1 minutes clean_channels.py:136
remaining.
[09/16/26 20:47:30] INFO 51/72 blocks, 0.0 minutes clean_channels.py:136
remaining.
Removing 5 channel(s)...
warning: erasing dipole information since channels have been removed
There was an issue storing removed channels in pop_select
[09/16/26 20:47:31] INFO Applying ASR burst repair... clean_artifacts.py:232
INFO Finding a clean section of the clean_asr.py:102
data for calibration...
INFO Determining time window clean_windows.py:115
rejection thresholds...
[09/16/26 20:47:35] INFO done. clean_windows.py:147
INFO Keeping 25.5% (92 seconds) of clean_windows.py:179
the data.
ERROR Could not select time windows clean_windows.py:204
using EEGLAB's pop_select();
details: Error setting single
item of array.
INFO Falling back to a basic clean_windows.py:207
substitute and dropping signal
meta-data.
INFO Estimating ASR calibration clean_asr.py:138
statistics...
INFO Calculating blockwise covariances... asr.py:167
[09/16/26 20:47:36] INFO Calculating robust geometric median asr.py:207
covariance...
INFO Determining per-component thresholds... asr.py:227
[09/16/26 20:47:40] INFO Thresholds calculation complete. asr.py:289
INFO Applying ASR processing... clean_asr.py:177
INFO Cleaning data in 436 blocks asr.py:427
[09/16/26 20:47:50] INFO Finished cleaning. asr.py:546
INFO ASR cleaning finished. clean_asr.py:195
INFO Use vis_artifacts to compare clean_artifacts.py:303
the cleaned data to the
original.
WARNING EEGPrep event count eegprep_preprocess.py:123
changed during EEGPrep
processing (14 annotated
events, 13 non-boundary
events); restoring
durations in order for
the overlapping subset
only.
Filtering raw data in 1 contiguous segment
Setting up low-pass filter at 40 Hz
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal lowpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Upper passband edge: 40.00 Hz
- Upper transition bandwidth: 10.00 Hz (-6 dB cutoff frequency: 45.00 Hz)
- Filter length: 43 samples (0.336 s)
Reading 0 ... 174811 = 0.000 ... 349.622 secs...
/tmp/tmpzhv_iu29.set
Field 'subject' is missing from the EEG dictionnary, adding it.
Field 'group' is missing from the EEG dictionnary, adding it.
Field 'condition' is missing from the EEG dictionnary, adding it.
Field 'session' is missing from the EEG dictionnary, adding it.
Field 'comments' is missing from the EEG dictionnary, adding it.
Field 'times' is missing from the EEG dictionnary, adding it.
Field 'icaact' is missing from the EEG dictionnary, adding it.
Field 'icachansind' is missing from the EEG dictionnary, adding it.
Field 'urchanlocs' is missing from the EEG dictionnary, adding it.
Field 'urevent' is missing from the EEG dictionnary, adding it.
Field 'eventdescription' is missing from the EEG dictionnary, adding it.
Field 'epoch' is missing from the EEG dictionnary, adding it.
Field 'epochdescription' is missing from the EEG dictionnary, adding it.
Field 'stats' is missing from the EEG dictionnary, adding it.
Field 'specdata' is missing from the EEG dictionnary, adding it.
Field 'specicaact' is missing from the EEG dictionnary, adding it.
Field 'splinefile' is missing from the EEG dictionnary, adding it.
Field 'icasplinefile' is missing from the EEG dictionnary, adding it.
Field 'dipfit' is missing from the EEG dictionnary, adding it.
Field 'history' is missing from the EEG dictionnary, adding it.
Field 'saved' is missing from the EEG dictionnary, adding it.
Field 'etc' is missing from the EEG dictionnary, adding it.
Field 'datfile' is missing from the EEG dictionnary, adding it.
Field 'run' is missing from the EEG dictionnary, adding it.
Field 'roi' is missing from the EEG dictionnary, adding it.
[09/16/26 20:47:51] WARNING EEGPrep event count eegprep_preprocess.py:123
changed during EEGPrep
processing (14 annotated
events, 13 non-boundary
events); restoring
durations in order for
the overlapping subset
only.
[09/16/26 20:47:52] INFO Detecting flat line clean_artifacts.py:182
channels...
Removing 2 channel(s)...
warning: erasing dipole information since channels have been removed
INFO Applying high‑pass filter... clean_artifacts.py:191
[09/16/26 20:47:53] INFO Scanning for bad channels... clean_channels.py:79
[09/16/26 20:47:56] INFO 1/69 blocks, 0.1 minutes clean_channels.py:136
remaining.
[09/16/26 20:47:59] INFO 51/69 blocks, 0.0 minutes clean_channels.py:136
remaining.
Removing 13 channel(s)...
warning: erasing dipole information since channels have been removed
There was an issue storing removed channels in pop_select
INFO Applying ASR burst repair... clean_artifacts.py:232
INFO Finding a clean section of the clean_asr.py:102
data for calibration...
INFO Determining time window clean_windows.py:115
rejection thresholds...
[09/16/26 20:48:03] INFO done. clean_windows.py:147
INFO Keeping 57.4% (201 seconds) of clean_windows.py:179
the data.
ERROR Could not select time windows clean_windows.py:204
using EEGLAB's pop_select();
details: Error setting single
item of array.
INFO Falling back to a basic clean_windows.py:207
substitute and dropping signal
meta-data.
INFO Estimating ASR calibration clean_asr.py:138
statistics...
INFO Calculating blockwise covariances... asr.py:167
[09/16/26 20:48:04] INFO Calculating robust geometric median asr.py:207
covariance...
[09/16/26 20:48:05] INFO Determining per-component thresholds... asr.py:227
[09/16/26 20:48:08] INFO Thresholds calculation complete. asr.py:289
INFO Applying ASR processing... clean_asr.py:177
INFO Cleaning data in 349 blocks asr.py:427
[09/16/26 20:48:17] INFO Finished cleaning. asr.py:546
INFO ASR cleaning finished. clean_asr.py:195
INFO Use vis_artifacts to compare clean_artifacts.py:303
the cleaned data to the
original.
[09/16/26 20:48:18] WARNING EEGPrep event count eegprep_preprocess.py:123
changed during EEGPrep
processing (14 annotated
events, 13 non-boundary
events); restoring
durations in order for
the overlapping subset
only.
Filtering raw data in 1 contiguous segment
Setting up low-pass filter at 40 Hz
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal lowpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Upper passband edge: 40.00 Hz
- Upper transition bandwidth: 10.00 Hz (-6 dB cutoff frequency: 45.00 Hz)
- Filter length: 43 samples (0.336 s)
Used Annotations descriptions: ['boundary', 'break cnt', 'instructed_toCloseEyes', 'instructed_toOpenEyes', 'resting_start']
[09/16/26 20:48:19] INFO Original events found with ids: preprocessing.py:66
{'boundary': 1, 'break cnt': 2,
'instructed_toCloseEyes': 3,
'instructed_toOpenEyes': 4,
'resting_start': 5}
/home/runner/work/EEGDash/EEGDash/eegdash/hbn/preprocessing.py:103: RuntimeWarning: Omitted 7 annotation(s) that were outside data range.
raw.set_annotations(annot_from_events)
Used Annotations descriptions: ['boundary', 'break cnt', 'instructed_toCloseEyes', 'instructed_toOpenEyes', 'resting_start']
INFO Original events found with ids: preprocessing.py:66
{'boundary': 1, 'break cnt': 2,
'instructed_toCloseEyes': 3,
'instructed_toOpenEyes': 4,
'resting_start': 5}
/home/runner/work/EEGDash/EEGDash/eegdash/hbn/preprocessing.py:103: RuntimeWarning: Omitted 7 annotation(s) that were outside data range.
raw.set_annotations(annot_from_events)
Used Annotations descriptions: ['boundary', 'break cnt', 'instructed_toCloseEyes', 'instructed_toOpenEyes', 'resting_start']
INFO Original events found with ids: preprocessing.py:66
{'boundary': 1, 'break cnt': 2,
'instructed_toCloseEyes': 3,
'instructed_toOpenEyes': 4,
'resting_start': 5}
/home/runner/work/EEGDash/EEGDash/eegdash/hbn/preprocessing.py:103: RuntimeWarning: Omitted 7 annotation(s) that were outside data range.
raw.set_annotations(annot_from_events)
3. Window stable periods following the actual instructions#
The HBN helper places starts at 15..27 seconds after a close instruction and 5..17 seconds after an open instruction, every two seconds. The 256-sample stop offset extends these zero-duration markers into two-second windows at 128 Hz, covering 15..29 and 5..19 seconds. Equal size and stride avoid overlap. The target is the instructed condition, without independent verification of compliance. Existing BAD spans are respected when creating windows.
Stacking Braindecode windows creates (windows, 5 channels, 256 samples) in volts. Metadata row order supplies the observed condition and participant group for each window. Inspect the actual retained counts before modelling.
windows = create_windows_from_events(
dataset,
mapping={"eyes_open": 0, "eyes_closed": 1},
trial_start_offset_samples=0,
trial_stop_offset_samples=256,
window_size_samples=256,
window_stride_samples=256,
on_last_window="drop",
use_mne_epochs=True, # MNE rejects epochs overlapping preserved BAD annotations.
preload=True,
)
metadata = windows.get_metadata()
X = np.stack([window[0] for window in windows])
y = metadata["target"].to_numpy(dtype=int)
groups = metadata["subject"].astype(str).to_numpy()
assert X.shape[1:] == (len(channels), 256) and np.isfinite(X).all()
assert set(groups) == set(subjects)
print(pd.crosstab(groups, y, rownames=["subject"], colnames=["condition"]))
condition 0 1
subject
NDARAE710YWG 35 35
NDARAH239PGG 35 35
NDARAL897CYV 35 35
4. Compute alpha power with EEGDash’s spectral functions#
spectral_preprocessor computes a Welch PSD shared by the features and
the plot. A 256-sample Hamming segment at 128 Hz gives 0.5 Hz bins. The
PSD retains shape (windows, channels, frequencies) and units V²/Hz.
EEGDash’s band function sums bins in the half-open interval [8, 13) Hz;
multiplying by the bin width integrates density into power in V².
One alpha-power value per channel yields (windows, 5) features. The log compresses their range, and a small floor avoids log(0). These per-window operations require no fit; the scaler is fitted only within training folds.
frequencies, psd = spectral_preprocessor(
X,
_metadata={"info": dataset.datasets[0].raw.info},
fs=128,
nperseg=256,
noverlap=0,
window="hamming",
f_min=1,
f_max=40,
)
alpha_power = spectral_bands_power(frequencies, psd, bands={"alpha": (8, 13)})["alpha"]
alpha_power *= frequencies[1] - frequencies[0]
features = np.log10(np.maximum(alpha_power, 1e-30))
assert features.shape == (len(metadata), len(channels)) and np.isfinite(features).all()
5. Fit a fresh scaler and classifier in every LOSO fold#
The held-out subject supplies no scaling statistics. Every subject is held out once, and all windows from that participant stay together. Logistic regression combines the five log-band features into a binary decision. Balanced accuracy averages eyes-open and eyes-closed recall, with a 0.5 chance reference when both conditions are present. The assertions verify those conditions and exactly-once test coverage; they do not require the model to beat chance. With two training participants in each fold, there is little support for model selection, so parameters are fixed in advance.
rows = []
counts = np.zeros(len(y), dtype=int)
for train, test in LeaveOneGroupOut().split(features, y, groups):
assert set(groups[train]).isdisjoint(groups[test])
assert set(y[train]) == set(y[test]) == {0, 1}
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
model.fit(features[train], y[train])
rows.append(
{
"subject": groups[test][0],
"balanced_accuracy": balanced_accuracy_score(
y[test], model.predict(features[test])
),
}
)
counts[test] += 1
assert len(rows) == len(subjects) and np.all(counts == 1)
results = pd.DataFrame(rows)
print(results.to_string(index=False))
subject balanced_accuracy
NDARAE710YWG 0.528571
NDARAH239PGG 0.500000
NDARAL897CYV 0.785714
6. Compare the measured spectra and held-out scores#
Average within each subject before averaging across subjects. Differences in this small cohort need not reproduce a textbook effect or exceed chance. PSD is calculated in V²/Hz; multiplying by 1e12 converts the displayed density to µV²/Hz. The spectrum is descriptive and includes all three participants. The bars instead summarize strictly held-out predictions. A difference in the averaged spectrum does not guarantee separation of individual windows, and repeated windows do not increase the number of independent participants beyond three. Check whether the largest peaks lie inside the selected 8–13 Hz band before calling them alpha activity. A large out-of-band peak is a reason to inspect the raw channels and recording quality; its size alone does not identify a neural source.
fig, axes = plt.subplots(1, 2, figsize=(10, 4), layout="constrained")
for label, name in [(0, "eyes open"), (1, "eyes closed")]:
subject_psds = [
psd[(groups == subject) & (y == label), 0].mean(axis=0) for subject in subjects
]
axes[0].semilogy(frequencies, np.mean(subject_psds, axis=0) * 1e12, label=name)
axes[0].set(xlabel="Frequency (Hz)", ylabel="PSD at E70 (µV²/Hz)")
axes[0].legend()
axes[1].bar(range(len(results)), results["balanced_accuracy"])
axes[1].set_xticks(range(len(results)), results["subject"], rotation=45, ha="right")
axes[1].axhline(0.5, color="black", linestyle="--", label="Chance")
axes[1].set(ylabel="Balanced accuracy", xlabel="Held-out subject", ylim=(0, 1))
axes[1].legend()
plt.show()

7. Extend the participant-level evaluation#
Inspect which channels and bursts EEGPrep changes before interpreting the spectra as physiological evidence. Then inspect the timing of retained instruction intervals. Then add participants under the same fixed protocol and examine the distribution of participant scores. If you change channel selection or the frequency band based on performance, choose them using separate validation participants inside each training fold. Randomly mixing this participant’s windows into training would instead measure performance on an already observed person.
Total running time of the script: (1 minutes 27.524 seconds)