Visual P300: from real events to held-out predictions#

Estimated reading time:8 minutes

Load three visual-oddball recordings from OpenNeuro ds005863 through EEGDash, inspect their event codes, and decode targets on a new subject. Subjects 054, 119 and 123 require about 69 MB of signal files in total. Set EEGDASH_CACHE_DIR to reuse the download. CPU is sufficient; install eegprep[eeglabio]>=0.2.23,<0.3 for the EEGPrep stages. The dataset contains recorded EEG; every label below comes from its stimulus markers.

An oddball task presents frequent standards and occasional targets. An event-related potential (ERP) averages responses aligned to stimulus onset; a decoder instead predicts the condition of each individual trial. Here you will prepare both from the same recordings, then test whether a simple amplitude-based classifier generalizes to a participant absent from training.

Run the page from top to bottom with EEGDash installed. Familiarity with NumPy arrays and the first-recording tutorial is helpful. The outputs are an ERP plot at Pz, a table of participant scores and a confusion matrix. No GPU or previously prepared feature file is needed.

1. Select the recordings before downloading#

The query describes three recordings, not three classification samples. Each recording contributes many stimulus trials after epoching. Inspect the description before accessing raw, which acquires the signal file; load_data() below then brings its samples into memory.

import os
from pathlib import Path

import matplotlib.pyplot as plt
import mne
import numpy as np
import pandas as pd
from braindecode.preprocessing import RemoveCommonAverageReference, RemoveDCOffset
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import ConfusionMatrixDisplay, 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 signal_mean

cache_dir = Path(os.environ.get("EEGDASH_CACHE_DIR", ".eegdash_cache"))
subjects = ["054", "119", "123"]
dataset = EEGDashDataset(
    cache_dir=cache_dir,
    dataset="ds005863",
    subject=subjects,
    task="visualoddball",
    n_jobs=1,
)
assert len(dataset.datasets) == len(subjects)
print(dataset.description[["subject", "task"]])
[09/16/26 20:46:36] INFO     Auto-corrected misrouted             dataset.py:561
                             storage.base for dataset on005863:
                             s3://nemar/on005863 ->
                             s3://openneuro.org/ds005863
  subject           task
0     054  visualoddball
1     119  visualoddball
2     123  visualoddball

2. Map recorded events and prepare trial features#

In code XY, X is the block’s target letter and Y is the presented letter, both coded 1..5. Matching digits denote targets. Explicitly exclude responses and other markers rather than calling all other annotations standards. Some readers prefix names with Stimulus/. For example, S11 maps to target (2), while S12 maps to standard (1). Subtracting one after epoching gives classifier labels 0 and 1.

MNE epochs align each trial to time zero at the recorded stimulus. The -100..0 ms baseline subtracts each channel’s pre-stimulus mean from that trial. The 0.5–30 Hz filter reduces slow drift and faster activity; changing it can change ERP amplitude and should be decided before comparing scores. EEGPrep removes each channel’s median offset, then its common-average reference subtracts the instantaneous EEG-channel mean. These operations do not remove samples. We verify the grid and restore source annotations explicitly because MNE↔EEGLAB conversions can round event latencies.

Resampling epochs to 128 Hz reduces memory after events have been located on the original sample grid. X has axes (trials, channels, time) in volts. EEGDash’s signal_mean over 300–450 ms produces one amplitude per channel and trial; multiplying by 1e6 expresses it in microvolts. This fixed interval is a compact baseline, not a claim that every participant’s peak lies there. All channel amplitudes enter the decoder; Pz is used only for the illustrative ERP.

reject_by_annotation respects existing bad spans. It does not detect every blink or noisy channel: inspect recordings and define any additional quality-control rules before extending this analysis.

features, labels, groups = [], [], []
first_epochs = None
channel_names = None
for recording in dataset.datasets:
    raw = recording.raw.copy().load_data().pick("eeg")
    mapping = {}
    for name in set(raw.annotations.description):
        code = name.split("/")[-1].replace(" ", "")
        if len(code) == 3 and code[0] == "S" and set(code[1:]) <= set("12345"):
            mapping[name] = 2 if code[1] == code[2] else 1
    assert set(mapping.values()) == {1, 2}, "Missing target or standard markers"
    print(recording.description["subject"], mapping)

    # Preprocess, epoch and baseline-correct
    # Filtering is independent for each recording. Epochs span -0.1..0.8 s
    # relative to stimulus onset, irrespective of annotation duration.
    source_annotations = raw.annotations.copy()
    source_date = raw.info["meas_date"]
    source_grid = (raw.info["sfreq"], raw.n_times, raw.first_samp)
    RemoveDCOffset().apply(raw)
    RemoveCommonAverageReference().apply(raw)
    assert (raw.info["sfreq"], raw.n_times, raw.first_samp) == source_grid
    raw.set_meas_date(source_date)
    raw.set_annotations(source_annotations)
    raw.filter(0.5, 30.0)
    events, _ = mne.events_from_annotations(raw, event_id=mapping)
    epochs = mne.Epochs(
        raw,
        events,
        event_id={"standard": 1, "target": 2},
        tmin=-0.1,
        tmax=0.8,
        baseline=(-0.1, 0),
        preload=True,
        reject_by_annotation=True,
    )
    epochs.resample(128)
    if channel_names is None:
        channel_names = epochs.ch_names
        first_epochs = epochs
    assert epochs.ch_names == channel_names
    assert "Pz" in epochs.ch_names, "This ERP example requires Pz"
    X = epochs.get_data()
    y = epochs.events[:, 2] - 1
    assert set(y) == {0, 1} and np.isfinite(X).all()

    # Use a fixed analysis interval; do not choose it from test accuracy.
    interval = (epochs.times >= 0.3) & (epochs.times <= 0.45)
    features.append(signal_mean(X[:, :, interval]) * 1e6)
    labels.append(y)
    groups.extend([str(recording.description["subject"])] * len(y))
Downloading sub-054_task-visualoddball_eeg.vhdr:   0%|          | 0.00/6.22k [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.vhdr:   0%|          | 0.00/6.22k [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.vhdr: 100%|██████████| 6.22k/6.22k [00:00<00:00, 131kB/s]

Downloading sub-054_task-visualoddball_events.tsv:   0%|          | 0.00/8.51k [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_events.tsv:   0%|          | 0.00/8.51k [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_events.tsv: 100%|██████████| 8.51k/8.51k [00:00<00:00, 52.3kB/s]

Downloading sub-054_task-visualoddball_events.json:   0%|          | 0.00/2.22k [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_events.json:   0%|          | 0.00/2.22k [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_events.json: 100%|██████████| 2.22k/2.22k [00:00<00:00, 39.9kB/s]

Downloading sub-054_task-visualoddball_eeg.json:   0%|          | 0.00/793 [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.json:   0%|          | 0.00/793 [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.json: 100%|██████████| 793/793 [00:00<00:00, 27.9kB/s]

Downloading sub-054_task-visualoddball_eeg.eeg:   0%|          | 0.00/22.2M [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.eeg:   0%|          | 0.00/22.2M [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.eeg:   5%|▍         | 1.00M/22.2M [00:00<00:04, 4.95MB/s]
Downloading sub-054_task-visualoddball_eeg.eeg:  63%|██████▎   | 14.0M/22.2M [00:00<00:00, 36.8MB/s]
Downloading sub-054_task-visualoddball_eeg.eeg: 100%|██████████| 22.2M/22.2M [00:00<00:00, 42.7MB/s]

Downloading sub-054_task-visualoddball_eeg.vmrk:   0%|          | 0.00/13.0k [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.vmrk:   0%|          | 0.00/13.0k [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.vmrk: 100%|██████████| 13.0k/13.0k [00:00<00:00, 367kB/s]

Downloading sub-054_task-visualoddball_eeg.dat:   0%|          | 0.00/? [00:00<?, ?B/s]
Downloading sub-054_task-visualoddball_eeg.dat:   0%|          | 0.00/? [00:00<?, ?B/s]
[09/16/26 20:46:37] WARNING  File not found on S3, skipping:   downloader.py:286
                             s3://openneuro.org/ds005863/sub-0
                             54/eeg/sub-054_task-visualoddball
                             _eeg.dat
[09/16/26 20:46:38] INFO     Auto-repairing                            io.py:219
                             sub-054_task-visualoddball_eeg.vhdr:
                             DataFile=COCOA_066_VO.eeg ->
                             sub-054_task-visualoddball_eeg.eeg
                    INFO     Auto-repairing                            io.py:219
                             sub-054_task-visualoddball_eeg.vhdr:
                             MarkerFile=COCOA_066_VO.vmrk ->
                             sub-054_task-visualoddball_eeg.vmrk
Reading 0 ... 187519  =      0.000 ...   375.038 secs...
054 {'S 33': 2, 'S 32': 1, 'S 15': 1, 'S 22': 2, 'S 14': 1, 'S 35': 1, 'S 12': 1, 'S 11': 2, 'S 51': 1, 'S 43': 1, 'S 21': 1, 'S 13': 1, 'S 55': 2, 'S 31': 1, 'S 53': 1, 'S 54': 1, 'S 23': 1, 'S 45': 1, 'S 44': 2, 'S 52': 1, 'S 34': 1, 'S 24': 1, 'S 25': 1, 'S 42': 1, 'S 41': 1}
/tmp/tmphibss_ti.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.
                    WARNING  EEGPrep event count       eegprep_preprocess.py:123
                             changed during
                             RemoveDCOffset processing
                             (412 annotated events,
                             411 non-boundary events);
                             restoring durations in
                             order for the overlapping
                             subset only.
                    WARNING  EEGPrep event count       eegprep_preprocess.py:123
                             changed during
                             RemoveDCOffset processing
                             (412 annotated events,
                             411 non-boundary events);
                             restoring durations in
                             order for the overlapping
                             subset only.
/tmp/tmp2smdzfo0.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.
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 0.5 - 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: 0.50
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 0.25 Hz)
- Upper passband edge: 30.00 Hz
- Upper transition bandwidth: 7.50 Hz (-6 dB cutoff frequency: 33.75 Hz)
- Filter length: 3301 samples (6.602 s)

Used Annotations descriptions: ['S 11', 'S 12', 'S 13', 'S 14', 'S 15', 'S 21', 'S 22', 'S 23', 'S 24', 'S 25', 'S 31', 'S 32', 'S 33', 'S 34', 'S 35', 'S 41', 'S 42', 'S 43', 'S 44', 'S 45', 'S 51', 'S 52', 'S 53', 'S 54', 'S 55']
Not setting metadata
200 matching events found
Applying baseline correction (mode: mean)
0 projection items activated
Using data from preloaded Raw for 200 events and 451 original time points ...
0 bad epochs dropped

Downloading sub-119_task-visualoddball_eeg.vhdr:   0%|          | 0.00/6.22k [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.vhdr:   0%|          | 0.00/6.22k [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.vhdr: 100%|██████████| 6.22k/6.22k [00:00<00:00, 107kB/s]

Downloading sub-119_task-visualoddball_events.tsv:   0%|          | 0.00/8.37k [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_events.tsv:   0%|          | 0.00/8.37k [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_events.tsv: 100%|██████████| 8.37k/8.37k [00:00<00:00, 190kB/s]

Downloading sub-119_task-visualoddball_events.json:   0%|          | 0.00/2.22k [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_events.json:   0%|          | 0.00/2.22k [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_events.json: 100%|██████████| 2.22k/2.22k [00:00<00:00, 51.9kB/s]

Downloading sub-119_task-visualoddball_eeg.json:   0%|          | 0.00/793 [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.json:   0%|          | 0.00/793 [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.json: 100%|██████████| 793/793 [00:00<00:00, 26.0kB/s]

Downloading sub-119_task-visualoddball_eeg.eeg:   0%|          | 0.00/22.5M [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.eeg:   0%|          | 0.00/22.5M [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.eeg:  22%|██▏       | 5.00M/22.5M [00:00<00:00, 20.4MB/s]
Downloading sub-119_task-visualoddball_eeg.eeg:  93%|█████████▎| 21.0M/22.5M [00:00<00:00, 49.8MB/s]
Downloading sub-119_task-visualoddball_eeg.eeg: 100%|██████████| 22.5M/22.5M [00:00<00:00, 50.6MB/s]

Downloading sub-119_task-visualoddball_eeg.vmrk:   0%|          | 0.00/12.9k [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.vmrk:   0%|          | 0.00/12.9k [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.vmrk: 100%|██████████| 12.9k/12.9k [00:00<00:00, 189kB/s]

Downloading sub-119_task-visualoddball_eeg.dat:   0%|          | 0.00/? [00:00<?, ?B/s]
Downloading sub-119_task-visualoddball_eeg.dat:   0%|          | 0.00/? [00:00<?, ?B/s]
[09/16/26 20:46:40] WARNING  File not found on S3, skipping:   downloader.py:286
                             s3://openneuro.org/ds005863/sub-1
                             19/eeg/sub-119_task-visualoddball
                             _eeg.dat
                    INFO     Auto-repairing                            io.py:219
                             sub-119_task-visualoddball_eeg.vhdr:
                             DataFile=SASA_023_VO.eeg ->
                             sub-119_task-visualoddball_eeg.eeg
                    INFO     Auto-repairing                            io.py:219
                             sub-119_task-visualoddball_eeg.vhdr:
                             MarkerFile=SASA_023_VO.vmrk ->
                             sub-119_task-visualoddball_eeg.vmrk
Reading 0 ... 189959  =      0.000 ...   379.918 secs...
119 {'S 33': 2, 'S 32': 1, 'S 15': 1, 'S 22': 2, 'S 14': 1, 'S 35': 1, 'S 12': 1, 'S 11': 2, 'S 51': 1, 'S 43': 1, 'S 21': 1, 'S 13': 1, 'S 55': 2, 'S 31': 1, 'S 53': 1, 'S 45': 1, 'S 54': 1, 'S 23': 1, 'S 44': 2, 'S 52': 1, 'S 34': 1, 'S 24': 1, 'S 25': 1, 'S 42': 1, 'S 41': 1}
/tmp/tmpmlzk6609.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.
                    WARNING  EEGPrep event count       eegprep_preprocess.py:123
                             changed during
                             RemoveDCOffset processing
                             (410 annotated events,
                             409 non-boundary events);
                             restoring durations in
                             order for the overlapping
                             subset only.
                    WARNING  EEGPrep event count       eegprep_preprocess.py:123
                             changed during
                             RemoveDCOffset processing
                             (410 annotated events,
                             409 non-boundary events);
                             restoring durations in
                             order for the overlapping
                             subset only.
/tmp/tmprp6m9pxg.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.
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 0.5 - 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: 0.50
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 0.25 Hz)
- Upper passband edge: 30.00 Hz
- Upper transition bandwidth: 7.50 Hz (-6 dB cutoff frequency: 33.75 Hz)
- Filter length: 3301 samples (6.602 s)

Used Annotations descriptions: ['S 11', 'S 12', 'S 13', 'S 14', 'S 15', 'S 21', 'S 22', 'S 23', 'S 24', 'S 25', 'S 31', 'S 32', 'S 33', 'S 34', 'S 35', 'S 41', 'S 42', 'S 43', 'S 44', 'S 45', 'S 51', 'S 52', 'S 53', 'S 54', 'S 55']
Not setting metadata
200 matching events found
Applying baseline correction (mode: mean)
0 projection items activated
Using data from preloaded Raw for 200 events and 451 original time points ...
0 bad epochs dropped

Downloading sub-123_task-visualoddball_eeg.vhdr:   0%|          | 0.00/6.22k [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.vhdr:   0%|          | 0.00/6.22k [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.vhdr: 100%|██████████| 6.22k/6.22k [00:00<00:00, 88.8kB/s]

Downloading sub-123_task-visualoddball_events.tsv:   0%|          | 0.00/8.41k [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_events.tsv:   0%|          | 0.00/8.41k [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_events.tsv: 100%|██████████| 8.41k/8.41k [00:00<00:00, 230kB/s]

Downloading sub-123_task-visualoddball_events.json:   0%|          | 0.00/2.22k [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_events.json:   0%|          | 0.00/2.22k [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_events.json: 100%|██████████| 2.22k/2.22k [00:00<00:00, 66.3kB/s]

Downloading sub-123_task-visualoddball_eeg.json:   0%|          | 0.00/793 [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.json:   0%|          | 0.00/793 [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.json: 100%|██████████| 793/793 [00:00<00:00, 19.9kB/s]

Downloading sub-123_task-visualoddball_eeg.eeg:   0%|          | 0.00/21.2M [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.eeg:   0%|          | 0.00/21.2M [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.eeg:  61%|██████▏   | 13.0M/21.2M [00:00<00:00, 68.1MB/s]
Downloading sub-123_task-visualoddball_eeg.eeg: 100%|██████████| 21.2M/21.2M [00:00<00:00, 64.7MB/s]

Downloading sub-123_task-visualoddball_eeg.vmrk:   0%|          | 0.00/13.0k [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.vmrk:   0%|          | 0.00/13.0k [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.vmrk: 100%|██████████| 13.0k/13.0k [00:00<00:00, 352kB/s]

Downloading sub-123_task-visualoddball_eeg.dat:   0%|          | 0.00/? [00:00<?, ?B/s]
Downloading sub-123_task-visualoddball_eeg.dat:   0%|          | 0.00/? [00:00<?, ?B/s]
[09/16/26 20:46:42] WARNING  File not found on S3, skipping:   downloader.py:286
                             s3://openneuro.org/ds005863/sub-1
                             23/eeg/sub-123_task-visualoddball
                             _eeg.dat
                    INFO     Auto-repairing                            io.py:219
                             sub-123_task-visualoddball_eeg.vhdr:
                             DataFile=SASA_027_VO.eeg ->
                             sub-123_task-visualoddball_eeg.eeg
                    INFO     Auto-repairing                            io.py:219
                             sub-123_task-visualoddball_eeg.vhdr:
                             MarkerFile=SASA_027_VO.vmrk ->
                             sub-123_task-visualoddball_eeg.vmrk
Reading 0 ... 179259  =      0.000 ...   358.518 secs...
123 {'S 33': 2, 'S 32': 1, 'S 15': 1, 'S 22': 2, 'S 14': 1, 'S 35': 1, 'S 12': 1, 'S 11': 2, 'S 51': 1, 'S 43': 1, 'S 21': 1, 'S 13': 1, 'S 55': 2, 'S 31': 1, 'S 53': 1, 'S 54': 1, 'S 45': 1, 'S 23': 1, 'S 44': 2, 'S 52': 1, 'S 34': 1, 'S 24': 1, 'S 25': 1, 'S 42': 1, 'S 41': 1}
/tmp/tmp73f4_1jh.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.
                    WARNING  EEGPrep event count       eegprep_preprocess.py:123
                             changed during
                             RemoveDCOffset processing
                             (412 annotated events,
                             411 non-boundary events);
                             restoring durations in
                             order for the overlapping
                             subset only.
                    WARNING  EEGPrep event count       eegprep_preprocess.py:123
                             changed during
                             RemoveDCOffset processing
                             (412 annotated events,
                             411 non-boundary events);
                             restoring durations in
                             order for the overlapping
                             subset only.
/tmp/tmpa5_tjamq.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.
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 0.5 - 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: 0.50
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 0.25 Hz)
- Upper passband edge: 30.00 Hz
- Upper transition bandwidth: 7.50 Hz (-6 dB cutoff frequency: 33.75 Hz)
- Filter length: 3301 samples (6.602 s)

Used Annotations descriptions: ['S 11', 'S 12', 'S 13', 'S 14', 'S 15', 'S 21', 'S 22', 'S 23', 'S 24', 'S 25', 'S 31', 'S 32', 'S 33', 'S 34', 'S 35', 'S 41', 'S 42', 'S 43', 'S 44', 'S 45', 'S 51', 'S 52', 'S 53', 'S 54', 'S 55']
Not setting metadata
200 matching events found
Applying baseline correction (mode: mean)
0 projection items activated
Using data from preloaded Raw for 200 events and 451 original time points ...
0 bad epochs dropped

3. Evaluate one held-out subject per fold#

StandardScaler is fitted inside each training fold. Balanced accuracy gives targets and standards equal weight despite the oddball imbalance. Concatenation stacks trials while groups keeps their participant identity. Read the class-count table before training: an always-standard prediction may have high ordinary accuracy, but binary balanced accuracy would be 0.5.

Logistic regression learns a linear combination of channel amplitudes. Training class weights give the rarer targets more influence on its loss. This affects fitting; balanced accuracy separately averages the two class recalls at evaluation. Each LOSO fold trains on two complete participants and tests on the third. A random split of trials would answer a different question by allowing the same participant into training and test sets.

X = np.concatenate(features)
y = np.concatenate(labels)
groups = np.asarray(groups)
print(pd.crosstab(groups, y, rownames=["subject"], colnames=["class"]))
predictions = np.full(len(y), -1)
counts = np.zeros(len(y), dtype=int)
rows = []
for train, test in LeaveOneGroupOut().split(X, y, groups):
    assert set(groups[train]).isdisjoint(groups[test])
    model = make_pipeline(
        StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=1000)
    )
    model.fit(X[train], y[train])
    predictions[test] = model.predict(X[test])
    counts[test] += 1
    rows.append(
        {
            "subject": groups[test][0],
            "balanced_accuracy": balanced_accuracy_score(y[test], predictions[test]),
        }
    )
assert np.all(counts == 1)
print(pd.DataFrame(rows).to_string(index=False))
class      0   1
subject
054      160  40
119      160  40
123      160  40
subject  balanced_accuracy
    054           0.621875
    119           0.612500
    123           0.481250

4. Inspect the measured ERP and decoding errors#

The ERP describes the first recording in the printed cohort order; the confusion matrix uses held-out predictions from all three. A visible P300 or high score is not a test requirement. This small cohort demonstrates the workflow, not a benchmark. In the normalized confusion matrix, each row sums to one: the target-row diagonal is target recall and its off-diagonal cell represents missed targets. Compare both rows, since good standard recall can hide missed targets in an unbalanced task. An averaged ERP difference also need not imply that single-trial responses are reliably separable.

mne.viz.plot_compare_evokeds(
    {name: first_epochs[name].average() for name in ["standard", "target"]},
    picks="Pz",
    show=False,
)
ConfusionMatrixDisplay.from_predictions(
    y, predictions, display_labels=["standard", "target"], normalize="true"
)
plt.show()
  • Pz
  • plot 20 visual p300 oddball

5. Extend the analysis without selecting on test results#

Add participants while retaining one complete participant per test fold. If you want to select channels, intervals or regularization strength, make those choices using additional validation participants inside the training fold. Keep the held-out participant untouched until the choice is fixed. The auditory oddball page uses the same Raw → Epochs → Evoked sequence for a descriptive response comparison; the P300 transfer project adds a separate adaptation participant to study a different deployment question.

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