Decode eyes open and eyes closed from recorded resting EEG#

Estimated reading time:9 minutes

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()
plot 30 eyes open closed

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)