Note
Go to the end to download the full example code or to run this example in your browser via Binder.
How do I export recorded EEG for NeuroAI interoperability?#
Load one real motor-imagery recording and export voltages plus an event table with timeline, start and duration fields. This is an explicit MNE-to-PyTorch boundary check through NeuralSet Segmenter and EegExtractor, followed by a PyTorch DataLoader. Install neuralset to run it. EEGDash handles discovery; NeuralSet reads the acquired signal file and extracts the event-aligned volts. The example does not run the NeuralBench evaluator. NeuralSet preserves float32 voltages and channel geometry; rich MNE fields such as bad-channel flags and filter history need a separate sidecar. Event masks are not EEG.
The processed BNCI2014-004 subset nm000135 contains C3/Cz/C4 at 250 Hz and costs about 5.6 MB for subject 1, session 0train, run 0.
Before you start#
Use EEGDash, Braindecode, PyTorch and NeuralSet in a compatible installed
environment; NeuralSet is an additional dependency, not supplied by the base
EEGDash interface alone. Its Segmenter, EegExtractor and dataset
collation APIs are used directly. This workflow was exercised with NeuralSet
0.3.1, but that observation does not replace respecting its declared dependency
requirements when creating a new environment.
Keep about 6 MB of signal cache and allow CPU processing. The goal is equality of recorded voltages across library boundaries, not a decoding score. No model or train/test split is needed to verify the adapter. The resulting windows would still require a leakage-safe split before any supervised training.
import os
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import neuralset as ns
from torch.utils.data import DataLoader
from braindecode.preprocessing import create_windows_from_events
from neuralset.events.etypes import Eeg
from neuralset.extractors.neuro import MneTimedArray
from eegdash import EEGDashDataset
Keep recording identity and event time explicit#
The query selects one genuine recording. timeline identifies that
recording throughout the event table; using a unique timeline per file prevents
an event from being matched to another participant’s signal when scaling up.
The event-table start and duration fields are in seconds, while MNE and
Braindecode also expose sample indices.
The zero-first-sample assertion makes the convention explicit for this example. A cropped recording with a nonzero sample origin needs a deliberate conversion; reusing these offsets unchanged would misalign signals and events. The TSV export preserves original BIDS descriptions for inspection, but does not contain voltage samples.
cache = Path(os.environ.get("EEGDASH_CACHE_DIR", "~/.eegdash_cache")).expanduser()
dataset = EEGDashDataset(
dataset="nm000135",
subject="1",
session="0train",
run="0",
task="imagery",
cache_dir=cache,
)
assert len(dataset.datasets) == 1
raw = dataset.datasets[0].raw
print(dataset.description.to_string(index=False))
print(raw.ch_names, raw.info["sfreq"], np.unique(raw.annotations.description))
assert raw.first_samp == 0, "This example uses recording-relative event times"
timeline = "nm000135/sub-1/ses-0train/run-0"
events = pd.DataFrame(
{
"timeline": timeline,
"start": raw.annotations.onset - raw.first_time,
"duration": raw.annotations.duration,
"type": "Stimulus",
"bids_description": raw.annotations.description,
}
)
events.to_csv(cache / "plot_74_events.tsv", sep="\t", index=False)
subject session run task age sex hand group species bci_experience gender
1 0train 0 imagery 21.0 F R healthy human naive None
['C3', 'Cz', 'C4'] 250.0 ['BAD_ACQ_SKIP' 'left_hand' 'right_hand']
Braindecode extracts actual event-aligned two-second voltage windows. %% Construct an independent voltage reference ——————————————
At 250 Hz, a two-second window has 500 samples. Equal size and stride
produce non-overlapping windows within each eligible imagery interval; there
can be more than one window per trial. on_last_window="drop" avoids a
partial final window. Left-hand and right-hand labels come from the recorded
annotations, with explicit zero/one codes.
The expected tensor layout is (windows, channels, samples) in volts.
MneTimedArray checks a second boundary by carrying those recorded values and
channel geometry through NeuralSet’s array representation. This conversion
retains float32 precision; rich MNE metadata are not all preserved. The
round-trip assertion checks values rather than assuming that matching shapes
imply matching EEG.
windows = create_windows_from_events(
dataset,
mapping={"left_hand": 0, "right_hand": 1},
trial_start_offset_samples=0,
trial_stop_offset_samples=0,
window_size_samples=500,
window_stride_samples=500,
on_last_window="drop",
preload=True,
)
metadata = windows.get_metadata()
volts = np.stack([windows[i][0] for i in range(len(windows))])
labels = np.asarray([windows[i][1] for i in range(len(windows))])
assert np.isfinite(volts).all() and set(labels) == {0, 1}
neural_array = MneTimedArray.from_native(raw.copy().pick("eeg"))
reconstructed = neural_array.to_native()
np.testing.assert_allclose(
reconstructed.get_data(), raw.get_data(picks="eeg"), rtol=1e-6, atol=1e-12
)
assert reconstructed.ch_names == raw.copy().pick("eeg").ch_names
Build a NeuralSet event table with the acquired EEG file and window anchors. Every anchor and label comes from the real Braindecode window metadata. EegExtractor reads the signal payload; Stimulus rows only define timing. %% Tell NeuralSet where the voltage lives ————————————–
An Eeg event points to the acquired signal file and its rate. The
Stimulus rows supply anchors and real labels from Braindecode metadata.
Only those rows trigger segments. They do not themselves provide EEG: the
EegExtractor reads the signal named by the recording event.
The extractor uses the native sampling frequency, original channel order and
scaler=None because a boundary check must not introduce a new signal
transform. prepare() prepares the extractor’s data before iteration. Segment
count and trigger-code assertions check that preparation has neither lost nor
reordered the requested labelled windows.
recording_event = Eeg(
filepath=str(raw.filenames[0]),
start=0.0,
duration=raw.n_times / raw.info["sfreq"],
frequency=raw.info["sfreq"],
subject="1",
timeline=timeline,
).to_dict()
anchors = pd.DataFrame(
{
"type": "Stimulus",
"timeline": timeline,
"start": metadata["i_start_in_trial"].to_numpy() / raw.info["sfreq"],
"duration": 2.0,
"code": labels,
}
)
neural_events = ns.events.standardize_events(
pd.concat([pd.DataFrame([recording_event]), anchors], ignore_index=True)
)
segmenter = ns.Segmenter(
trigger_query="type == 'Stimulus'",
start=0.0,
duration=2.0,
extractors={
"eeg": ns.extractors.EegExtractor(
frequency="native",
scaler=None,
channel_order="original",
mne_cpus=1,
)
},
)
neural_dataset = segmenter.apply(neural_events)
neural_dataset.prepare()
assert len(neural_dataset) == len(windows)
np.testing.assert_array_equal(
[segment.trigger.code for segment in neural_dataset.segments], labels
)
2026-09-16 20:55:45 - INFO - neuralset.dataloader:232 - Preparing extractor: EegExtractor
[09/16/26 20:55:45] INFO Preparing extractor: EegExtractor dataloader.py:232
Batch with the dataset collation contract#
NeuralSet items are structured examples rather than plain NumPy arrays,
so the DataLoader uses the dataset’s own collate_fn. Batches of 16 bound
memory; zero workers keep this small demonstration straightforward and
shuffle=False preserves the reference order. Voltages are read from
batch.data["eeg"], not from an event-mask field.
The full-array comparison tests all windows against independently extracted
Braindecode data. A direct MNE sample slice adds an explicit first-window time
check. Relative tolerance 1e-6 and absolute tolerance 1e-12 volts allow
float32 conversion without hiding millivolt/microvolt mistakes, reordered
channels or sample shifts.
loader = DataLoader(
neural_dataset,
batch_size=16,
shuffle=False,
num_workers=0,
collate_fn=neural_dataset.collate_fn,
)
restored = np.concatenate([batch.data["eeg"].numpy() for batch in loader])
np.testing.assert_allclose(restored, volts, rtol=1e-6, atol=1e-12)
# Independently compare the first window with MNE's sample slice.
start = int(metadata.iloc[0]["i_start_in_trial"])
expected = raw.get_data(picks="eeg", start=start, stop=start + 500)
np.testing.assert_allclose(restored[0], expected, rtol=1e-6, atol=1e-12)
print("NeuralSet voltage tensor (windows, channels, samples):", restored.shape)
print("Observed class counts:", np.unique(labels, return_counts=True))
np.savez(
cache / "plot_74_voltages.npz",
volts=restored,
labels=labels,
sfreq=raw.info["sfreq"],
channels=raw.ch_names,
timeline=timeline,
)
NeuralSet voltage tensor (windows, channels, samples): (240, 3, 500)
Observed class counts: (array([0, 1]), array([120, 120]))
fig, axes = plt.subplots(2, 1, figsize=(7, 4), sharex=True)
times = np.arange(500) / raw.info["sfreq"]
axes[0].plot(times, expected[0] * 1e6, label="MNE voltage")
axes[0].plot(times, restored[0, 0] * 1e6, "--", label="NeuralSet → DataLoader")
axes[0].set(ylabel="C3 (µV)")
axes[0].legend()
axes[1].plot(times, (restored[0, 0] - expected[0]) * 1e6)
axes[1].set(xlabel="Time (s)", ylabel="Difference (µV)")
plt.show()

Reuse the export without losing its meaning#
The NPZ stores volts, labels, sampling frequency, channel names and the timeline; the TSV preserves event timing and original descriptions. The plot compares independently extracted first-channel values and their actual difference. It is a numerical interoperability check on this recording, not validation of every file format or every preprocessing configuration.
To extend to multiple recordings, construct a distinct Eeg event and
timeline for each file and carry subject and original-trial identities alongside
windows. Split complete subjects or trials before training, because multiple
windows from one trial share their source. Explicitly decide how to preserve
bad-channel flags, annotations and preprocessing history before treating the
export as a replacement for the original MNE object.
Related data-boundary example: Braindecode training on MNE epochs.
Continue to self-supervised pretraining#
Follow NeuroAI’s Training a model: masked prediction on EEG for a worked pretraining loop, checkpoint export and downstream evaluation. Masked prediction reconstructs hidden portions of recorded EEG; the imagery labels above are not reconstruction targets.
Follow the guide’s installation instructions for the repository’s
ssl_exampleproject and its training dependencies. From itsneuraltrain-repodirectory, start with the real-data debug run:python -m ssl_example.grids.test_runConfigure the studies, subject splits and data/cache/output paths before using the guide’s download and full-training commands. Its default corpus needs roughly 1.1 TB; the debug run uses a small real MNE recording.
Use the printed
encoder.ckptpath in the guide’s downstream evaluation command. Match the encoder configuration and preprocessing to pretraining; a reconstruction loss alone does not establish decoding performance.
Adapting this conversion requires more than passing loader to that script.
Here batches contain eeg at 250 Hz, with 500 samples and stimulus anchors.
The guide constructs input batches, channel positions and recording-strided
windows at 120 Hz for its patch encoder. Adapt its study/extractor configuration
to the acquired recordings, retain subject-level separation, and verify the
resulting batch contract before training. The NPZ is a voltage export, not a
pretrained checkpoint or a registered NeuroAI study.
Total running time of the script: (0 minutes 1.570 seconds)