Level 2 L2.1 pipeline thread

Loading data and BIDS

Read every common format with MNE, set channel types and montage, convert to EEG-BIDS with mne-bids, and read events back.

~45 min Notebook: nb-2-1-bids

Prerequisites: L0.6 · Data hygiene and metadata

2 claims on this page are unverified. TODO(confirm) marks a specific statement the author has not yet checked against a primary source. Everything else on this page has been reviewed. Treat a marked claim as provisional and go to the cited source rather than quoting the sentence.

Objectives

  • Read every common format with MNE
  • Set channel types and montage
  • Convert a dataset to EEG-BIDS with mne-bids
  • Read events and annotations back

Why this matters

Everything downstream assumes the file was read correctly, and nothing in a pipeline tells you when it was not. A montage applied to mis-spelled channel names silently leaves half the cap without positions; an EOG channel typed as EEG quietly joins the average reference; an event table read from the wrong column shifts every epoch by a constant. Getting the load right, and writing the result out in a layout someone else can read, is the cheapest reproducibility you will ever buy.

Concepts

One reader per format, one object afterwards

MNE has a reader per file format — mne.io.read_raw_edf (EDF/EDF+), read_raw_bdf (Biosemi BDF, the ERP CORE format family), read_raw_brainvision (.vhdr + .vmrk + .eeg), read_raw_eeglab (.set/.fdt), read_raw_fif, read_raw_egi (.mff), read_raw_curry, read_raw_nihon, and more (TODO(confirm) the complete list and the exact spelling of the less common readers for your MNE version). They all return the same Raw object, so the rest of your code does not care which one ran.

What differs is the metadata each format carries. FIF and BrainVision store channel types, units and a measurement date; EDF stores a physical dimension string per channel and a 16-bit digital range; EEGLAB .set stores whatever the exporting script put in EEG.chanlocs. A format that cannot represent something does not warn you that it dropped it — it simply never had it. So after every load, before anything else:

raw = mne.io.read_raw_edf(fname, preload=True)
print(raw.info)                       # sfreq, n_channels, highpass/lowpass as recorded
print(raw.get_channel_types())        # are EOG/ECG/stim channels typed?
print(raw.ch_names)                   # spelling, case, trailing punctuation
print(raw.annotations)                # or raw.find_events() for a stim channel
print(raw.times[-1])                  # duration against what the protocol says

preload=True reads the data into memory; without it MNE memory-maps and many operations (filtering, re-referencing) will refuse or re-read. For large files, preload=False plus raw.crop() first is the frugal pattern.

The info['highpass'] and info['lowpass'] fields report what the amplifier did, when the format records it. They are not a promise: ds-eegbci was recorded with no hardware filters at all, which is why it is the site’s source for drift and line noise (L1.6, L2.4), while a dataset with a hardware low-pass near 30 Hz has a bandwidth ceiling far below its Nyquist frequency whatever its sampling rate says (pf-hardware-bandwidth-ceiling).

Channel types are a decision, not a fact in the file

MNE types every channel as eeg unless the file says otherwise, and most files do not say otherwise. The types that matter are eeg, eog, ecg, emg, misc and stim, and they change behaviour:

  • an average reference (L2.3) is computed over channels typed eeg — a mistyped EOG channel puts the blink into every trace;
  • ICA (L2.6) fits on eeg picks by default and uses eog/ecg channels to score components, which is only possible if they are typed;
  • filtering and rejection thresholds are applied per channel type;
  • a stim channel is a trigger line, not a signal: never filter it, never include it in a reference.
raw.set_channel_types({'HEOG': 'eog', 'VEOG': 'eog', 'EKG': 'ecg', 'Status': 'stim'})

ERP CORE, for example, ships 30 EEG channels plus 3 EOG channels per recording; typing those three is the difference between an ICA that can name its own eye components and one that cannot.

Montage: names first, positions second

A montage is a mapping from channel name to a 3-D position (L0.2). raw.set_montage('standard_1005') matches by name, and the match is literal: a name the montage does not know gets no position, and a channel with no position is invisible to interpolation (L2.2), to topographic plots (L2.3) and to anything spatial. Two habits save hours:

  1. Print the names before you set the montage and compare them with the montage’s own list. ds-eegbci’s EDF headers use Sharbrough-style labels with trailing dots (Fp1., Fpz., Af7.) that must be stripped and re-cased before standard_1005 recognises them (§10.9). ds-hbn names its channels E1E128 with a separate Cz, so no standard montage matches at all and the manufacturer’s layout must be supplied.
  2. Use on_missing='raise' while you are developing. The default is to warn, and a warning in a loop over 100 subjects is a warning you will not read.

Digitized positions (a per-subject 3-D scan) are better than a template when you have them: ds-lemon ships digitized positions for a large subset of its subjects. A template is a reasonable default for group work and a poor one for source analysis (L5.4).

Events versus annotations

MNE keeps stimulus and marker information in two forms, and the distinction matters when you crop, resample or concatenate.

Annotations are a list of (onset in seconds, duration, description) attached to the Raw. They are time-based, so they survive cropping and resampling, and they are what a reader produces from EDF+ annotations, BrainVision markers or a BIDS _events.tsv. They are also how you mark bad segments (L2.5).

Events are an integer array of shape (n_events, 3): sample index, previous value, event code — plus an event_id dictionary mapping human names to codes. They are sample-based, so they are only valid for the sampling rate they were built at.

events, event_id = mne.events_from_annotations(raw)   # annotations → events
events = mne.find_events(raw, stim_channel='Status')  # decode a trigger line

find_events reads a stim channel and reports the samples where it steps; its shortest_event, min_duration and mask arguments exist because real trigger lines bounce. Whichever route you take, check the counts and the intervals against the protocol before you epoch: the number of events per condition, the first and last onset, and whether the inter-event intervals look like the design. A design with 40 targets that yields 39 events has lost one, and you want to know which.

Nothing in the data tells you about the latency between the trigger and the stimulus the participant actually saw. That offset is a property of the equipment and must be measured (photodiode, audio capture) and corrected; uncorrected, it shifts every latency you report by a constant and makes cross-lab comparison meaningless. That is pf-trigger-offsets, below.

EEG-BIDS: the layout

BIDS (Brain Imaging Data Structure) is a filesystem convention plus sidecar metadata; the EEG extension is specified in (Pernet et al., 2019) . The parts you will actually touch:

dataset_description.json      Name, BIDSVersion, License, Authors
participants.tsv / .json      one row per subject; the .json describes the columns
README, CHANGES
sub-001/
  eeg/
    sub-001_task-rest_eeg.edf         the data, in an allowed format
    sub-001_task-rest_eeg.json        sidecar: SamplingFrequency, PowerLineFrequency,
                                      EEGReference, SoftwareFilters, EEGChannelCount, …
    sub-001_task-rest_channels.tsv    name, type, units, status per channel
    sub-001_task-rest_events.tsv      onset, duration, trial_type, …
    sub-001_task-rest_electrodes.tsv  x, y, z per electrode (with _coordsystem.json)

Three fields in the _eeg.json sidecar carry most of the weight. PowerLineFrequency tells you whether to expect a 50 or 60 Hz line (L1.6) and is a required field precisely because guessing it from the spectrum is a step you should not have to take. EEGReference is free text describing the online reference — “CMS”, “linked mastoids”, “Cz” — and without it, L2.3 is guesswork. SoftwareFilters records filtering already applied; a dataset that says n/a is telling you the amplifier’s own band-pass is all that happened.

The _channels.tsv status column (good/bad) is where bad-channel decisions belong (L2.2), and the units column is where a dataset tells you whether it stores volts or microvolts — a factor-of-a-million error that a plot catches immediately and a script does not.

Writing and reading BIDS with mne-bids

mne-bids does the conversion: you give it a Raw, a BIDSPath and the events, and it writes the data, the sidecars, the channels table and the events table together.

from mne_bids import BIDSPath, write_raw_bids, read_raw_bids
bids_path = BIDSPath(subject='001', task='rest', datatype='eeg', root=bids_root)
write_raw_bids(raw, bids_path, events=events, event_id=event_id,
               overwrite=True)                       # format conversion per its rules
raw2 = read_raw_bids(bids_path)                      # comes back typed and annotated

Two things are worth knowing before you run it. First, write_raw_bids converts formats according to what BIDS allows for EEG, so the file you get out may not be the file you put in; record which. Second, the round trip is the test that matters: read_raw_bids should give you back the same channel names, types, sampling rate and event counts. Run the BIDS validator afterwards and read its output; it catches the missing participants.json and the misspelled entity that you will otherwise discover six months later.

Note

Converting to BIDS is not a formatting chore, it is a forced metadata audit. Every field the writer asks for — line frequency, reference, channel units, task name — is a fact you must now establish about the recording. Most of the value is in answering the questions, not in the folder layout.

Metadata that is true, absent, or misleading

Real public datasets document themselves imperfectly, and reading a directory is a Level 2 skill in its own right. Four documented examples, cited from the site’s dataset directory rather than downloaded here:

  • ds-eegbci uses Sharbrough-style channel labels that must be mapped before a montage will attach, and subjects S088, S089, S092 and S100 carry inconsistent event timestamps (S038 and S104 are also often dropped). A loader that does not exclude them, with the reason written down, will produce a group result nobody can reproduce.
  • ds-dortmund labels recordings pre and post, which are within-session labels around a cognitive battery — not the two longitudinal sessions five years apart. A pipeline that reads them as sessions silently invents a longitudinal design.
  • ds-chbmp stores its event markers in Spanish, and its channel counts vary per subject (58–121). Neither fact is a defect; both break a script that assumes one montage and English condition names.
  • ds-aszed was recorded on two amplifiers at 200 and 256 Hz, with device-default filters, so the channel set and the bandwidth must be verified from the file headers rather than the descriptor.
Judgment call

The question to ask of every file you load is not “did it open?” but “does what I now believe about this recording come from the data, from the documentation, or from my assumptions?” Write the answer into the BIDS sidecar. That is what the sidecar is for.

The data behind this lesson

  • ds-eegbci: 64-channel, 160 Hz, EDF, recorded with no hardware filters, not itself in BIDS — but an official BIDS mirror exists (OpenNeuro ds004362, CC0). That pairing is what makes it the right subject for this lesson: you convert a subset yourself and then compare your conversion with somebody else’s.
  • ds-erpcore is the Level 2 ERP dataset from L2.3 onwards. It is released under CC BY 4.0 with open access, each paradigm is a separate component with per-subject BIDS-compatible folders, and its own sidecars record a Biosemi ActiveTwo system, 30 EEG + 3 EOG channels in a 10-20 placement scheme, 1024 Hz sampling, a CMS reference, 60 Hz mains and no software filters, with 40 participants per paradigm. TODO(confirm): the author still mirrors this entry into the catalog registry (§10.11 item 8) and signs off the dataset page.

Explore

This lesson has no widget. The exploration is a load-and-inspect pass you run yourself, in this order, on any file you have not seen before:

  • the reader’s own warnings — read them, do not scroll past them;
  • raw.info['sfreq'] against samples divided by duration, and info['highpass']/info['lowpass'] against what the descriptor claims;
  • raw.get_channel_types() — which channels are EOG, ECG, stim, and which are mistyped as EEG;
  • raw.ch_names against the montage’s own name list, before and after your renaming step;
  • the event table: counts per condition, first and last onset, inter-event intervals, and whether any interval is impossible;
  • the BIDS sidecar you are about to write: which of its required fields you can fill from the file, and which you can only fill from the paper.

Practice

Loading data and BIDS: read the file as it is stored, BIDS-ify a messy folder with mne-bids, round-trip it, and compare with the official OpenNeuro mirror nb-2-1-bids

Level 2 ~3 min
notebooks/L2/nb-2-1-bids.ipynb

Downloads from ds-eegbci.

Open in Colab Download Read it here

The notebook converts a subset of ds-eegbci to EEG-BIDS with mne-bids, reads it back with read_raw_bids, and compares the result against the official BIDS mirror (OpenNeuro ds004362, CC0) — the same subject, converted by someone else — as the checkable step. Its final cell prints the comparison: channel names, types, sampling rate, event counts and the sidecar fields that differ.

Exercises

Exercise ex-2-1-bidsify-checklist

Checklist (self-graded)

BIDS-ify the messy folder provided in the notebook. Tick each item once it is true of your output and the validator agrees.

Checklist

0 / 10 checked. Self-graded.

Exercise ex-2-1-mirror-diff

Numeric

Compare your conversion of the ds-eegbci subject with the same subject in the official BIDS mirror (OpenNeuro ds004362). How many events does the mirror's _events.tsv contain for that run?

events

Exact answer required, in events.

Pitfalls

Pitfall

Uncorrected stimulus–trigger latency

Symptom
Components shifted by a constant; latencies differ between labs.
Cause

The trigger records when the stimulus computer sent the stimulus, not when it reached the subject. Between the two sit the monitor’s refresh cycle and its response time (a stimulus sent just after a refresh waits for the next one), graphics buffering, audio-driver and amplifier latency for sounds, and the time the acquisition system takes to register a software marker. Each contributes a delay th…

Detect
  • Record a photodiode taped to the screen (or a microphone at the speaker) as an extra channel in a pilot session, and measure the delay from each trigger to the onset of the sensor signal: the mean is the offset, the spread is the jitter. - Check the acquisition metadata and the lab’s documentation for a stated offset; if none is stated, assume the offset is unknown, not zero. - Compare an early…
Fix
  • Measure the offset with a photodiode or microphone for each setup, and store it with the dataset (EEG-BIDS has a place for it: the StimulusOnsetDelay style of metadata in the events sidecar, or a documented correction in the dataset description; TODO(confirm) the exact BIDS field the author wants named). - Shift the event onsets by the measured offset when loading (mne.Annotations or the events…

Full entry with example →

In other tools

In other toolsEEGLAB · FieldTrip — names only

The equivalents of what this lesson does, for a reader who works in another toolbox. Function names only: their own documentation is the place to learn how to call them.

EEGLAB

  • pop_importbidsEEG-BIDS plugin

FieldTrip

  • data2bidsFieldTrip

Names checked 2026-09-18 against EEGLAB 2026.0.0 (plugins at the versions in EEGLAB’s own plugin list) and FieldTrip 20251218.

Reading

  1. Pernet et al. (2019). EEG-BIDS. unverified