Inspect an auditory oddball response in real EEG#

Estimated reading time:6 minutes

Use EEGDashDataset to load one auditory-P300 run from OpenNeuro ds003061, inspect its actual labels and plot standard/oddball ERPs. Subject 001, run 2 contains about 63.4 MB of signal data. Selecting the run avoids downloading the other two recordings of this participant. See the dataset. CPU is sufficient; set EEGDASH_CACHE_DIR to retain downloads between runs.

The goal is to trace an observed stimulus marker into a measured response: load the recording, select the two documented event types, create aligned trials, and average each condition. An ERP is an average voltage time course; it does not assign a predicted label to a new trial. This page therefore ends with a descriptive comparison rather than a fitted classifier.

With EEGDash installed, run the blocks in order. Basic MNE Raw concepts from the first-recording page are sufficient. You will inspect the retained trial counts and array shape, a Cz waveform plot and a mean difference in microvolts.

1. Load exactly one recording and inspect its annotations#

Subject identifiers are strings: retain the leading zeros in 001. The task and run filters bound acquisition before any samples are read. Printing annotation counts makes the source vocabulary visible and avoids accidentally treating responses or unrelated markers as standard stimuli.

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 eegdash import EEGDashDataset
from eegdash.features import signal_mean

cache_dir = Path(os.environ.get("EEGDASH_CACHE_DIR", ".eegdash_cache"))
dataset = EEGDashDataset(
    cache_dir=cache_dir,
    dataset="ds003061",
    subject="001",
    task="P300",
    run="2",
    n_jobs=1,
)
assert len(dataset.datasets) == 1
raw = dataset.datasets[0].raw.copy().load_data().pick("eeg")
print(pd.Series(raw.annotations.description).value_counts())
# Preserve the source marker spelling below, including its typo.
# These literal names are the label source, including the response qualification
# on the oddball class. Consequently the comparison concerns the selected
# response-associated oddballs, not every possible rare-stimulus outcome.
mapping = {"stimulus/standard": 1, "stimulus/oddball_with_reponse": 2}
assert set(mapping) <= set(raw.annotations.description)
assert "Cz" in raw.ch_names, "This ERP comparison requires Cz"
[09/16/26 20:46:44] INFO     Auto-corrected misrouted             dataset.py:561
                             storage.base for dataset on003061:
                             s3://nemar/on003061 ->
                             s3://openneuro.org/ds003061

Downloading sub-001_task-P300_run-2_eeg.set:   0%|          | 0.00/60.5M [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_eeg.set:   0%|          | 0.00/60.5M [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_eeg.set:   2%|▏         | 1.00M/60.5M [00:00<00:13, 4.70MB/s]
Downloading sub-001_task-P300_run-2_eeg.set:  30%|██▉       | 18.0M/60.5M [00:00<00:01, 33.4MB/s]
Downloading sub-001_task-P300_run-2_eeg.set:  60%|█████▉    | 36.0M/60.5M [00:00<00:00, 49.3MB/s]
Downloading sub-001_task-P300_run-2_eeg.set:  89%|████████▉ | 54.0M/60.5M [00:01<00:00, 58.5MB/s]
Downloading sub-001_task-P300_run-2_eeg.set: 100%|██████████| 60.5M/60.5M [00:01<00:00, 57.4MB/s]

Downloading sub-001_task-P300_run-2_channels.tsv:   0%|          | 0.00/1.12k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_channels.tsv:   0%|          | 0.00/1.12k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_channels.tsv: 100%|██████████| 1.12k/1.12k [00:00<00:00, 24.3kB/s]

Downloading sub-001_task-P300_run-2_events.tsv:   0%|          | 0.00/44.2k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_events.tsv:   0%|          | 0.00/44.2k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_events.tsv: 100%|██████████| 44.2k/44.2k [00:00<00:00, 879kB/s]

Downloading sub-001_task-P300_run-2_events.json:   0%|          | 0.00/1.85k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_events.json:   0%|          | 0.00/1.85k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_events.json: 100%|██████████| 1.85k/1.85k [00:00<00:00, 36.9kB/s]

Downloading sub-001_task-P300_run-2_electrodes.tsv:   0%|          | 0.00/1.68k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_electrodes.tsv:   0%|          | 0.00/1.68k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_electrodes.tsv: 100%|██████████| 1.68k/1.68k [00:00<00:00, 55.8kB/s]

Downloading sub-001_task-P300_run-2_coordsystem.json:   0%|          | 0.00/97.0 [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_coordsystem.json:   0%|          | 0.00/97.0 [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_coordsystem.json: 100%|██████████| 97.0/97.0 [00:00<00:00, 3.31kB/s]

Downloading sub-001_task-P300_run-2_eeg.json:   0%|          | 0.00/1.34k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_eeg.json:   0%|          | 0.00/1.34k [00:00<?, ?B/s]
Downloading sub-001_task-P300_run-2_eeg.json: 100%|██████████| 1.34k/1.34k [00:00<00:00, 34.1kB/s]
Reading 0 ... 193791  =      0.000 ...   756.996 secs...
stimulus/standard                527
response                         112
stimulus/noise                   111
stimulus/oddball_with_reponse    111
stimulus/noise_with_reponse        1
Name: count, dtype: int64

2. Filter and epoch relative to the actual stimulus onset#

Keep the pre-stimulus baseline. We use MNE Epochs so the stated interval is independent of the annotation duration; resample after event extraction to preserve event timing on the original recording’s sample grid. EEGPrep removes the per-channel median offset and applies its common average reference. Neither operation changes sample count or rate. We verify that grid and restore the original event annotations to avoid rounding their latencies through the MNE↔EEGLAB conversion. Filtering to 0.5–30 Hz attenuates drift and faster activity while keeping a broad ERP time course. These settings affect the measured waveform; hold them fixed when comparing recordings.

Each epoch includes 100 ms before and 600 ms after the stimulus. Baseline correction subtracts the pre-stimulus channel mean within each trial. get_data() returns (trials, channels, time samples), with voltages in volts. Resampling to 128 Hz changes the last dimension, not trial identity. The printed condition counts refer to retained epochs, whereas the earlier counts describe all annotations. Epochs can be lost at recording boundaries or existing bad spans; missing conditions stop the example rather than silently producing an empty average.

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, "oddball": 2},
    tmin=-0.1,
    tmax=0.6,
    baseline=(-0.1, 0),
    preload=True,
    reject_by_annotation=True,
)
epochs.resample(128)
assert np.isfinite(epochs.get_data()).all()
assert all(len(epochs[name]) > 1 for name in epochs.event_id)
print({name: len(epochs[name]) for name in epochs.event_id})
print("Epoch shape:", epochs.get_data().shape)
/tmp/tmpuku7b8_3.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.
/tmp/tmp_w8sbeq2.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: 1691 samples (6.605 s)

Used Annotations descriptions: ['stimulus/oddball_with_reponse', 'stimulus/standard']
Not setting metadata
638 matching events found
Applying baseline correction (mode: mean)
0 projection items activated
Using data from preloaded Raw for 638 events and 181 original time points ...
1 bad epochs dropped
{'standard': 526, 'oddball': 111}
Epoch shape: (637, 64, 90)

3. Plot the measured response at Cz#

Compare conditions within this recording. A difference in this small sample does not by itself distinguish MMN, P3a and P3b generators. average() reduces the trial axis separately for each condition. MNE retains the time and channel information in an Evoked object and displays EEG amplitudes in microvolts. More trials can make an average less noisy; the two conditions need not have equal counts.

EEGDash’s signal_mean averages the oddball-minus-standard waveform from 250 to 400 ms at the preselected Cz channel. Positive values mean the oddball average is more positive in that interval under this reference; negative values are valid too. This interval mean is not a peak latency, a significance test or evidence that a new participant will show the effect.

evokeds = {name: epochs[name].average() for name in epochs.event_id}
mne.viz.plot_compare_evokeds(evokeds, picks="Cz", show=False)
difference = mne.combine_evoked([evokeds["oddball"], evokeds["standard"]], [1, -1])
interval = (difference.times >= 0.25) & (difference.times <= 0.4)
cz = difference.ch_names.index("Cz")
print(
    "Mean oddball-minus-standard at Cz, 250–400 ms (µV):",
    signal_mean(difference.data[cz, interval]) * 1e6,
)
plt.show()
Cz
Mean oddball-minus-standard at Cz, 250–400 ms (µV): 3.950679283942988

4. Decide what evidence the next analysis needs#

Inspect bad channels and artifacts before treating the difference as a physiological result: existing bad annotations are respected, but no new artifact detector is fitted here. To estimate between-participant variation, repeat the same fixed analysis for additional real participants and compare their interval means; individual trials are not independent participants. For a descriptive auditory/visual comparison, run the visual tutorial with matched reference, units and time axes. Different tasks and participants prevent that plot alone from establishing a modality effect.

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