Note
Go to the end to download the full example code or to run this example in your browser via Binder.
EEG2025 Challenge 1: predict observed reaction time#
Load three R5 mini participants and run 1 of contrast-change detection. Predict stimulus-to-response time in seconds from the preceding two seconds of EEG. The split holds out a complete participant; selected samples end at stimulus onset. This compact ridge baseline is an instructional subset, not the official competition split or score.
Before you start#
Use an installed EEGDash environment with Braindecode, MNE, NumPy,
scikit-learn and Matplotlib; this script runs on CPU. Keep a persistent
EEGDASH_CACHE_DIR: the three contrast-change run-1 recordings are downloaded
in full on first access even though each example uses short windows. The
transfer version also needs two resting-state recordings. Both tasks use the
challenge’s 100 Hz, 0.5–50 Hz filtered derivatives.
Reaction time is a continuous observed latency, not a fast/slow category. The window ends at the stimulus anchor. This excludes poststimulus samples from the selected interval, but does not establish a causal online pipeline: the source release has already been filtered and its preprocessing must be audited separately before claiming real-time prediction.
import os
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from braindecode.preprocessing import create_windows_from_events
from sklearn.dummy import DummyRegressor
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error, root_mean_squared_error
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from eegdash import EEGChallengeDataset
from eegdash.features import signal_variance
from eegdash.hbn.windows import (
annotate_trials_with_target,
add_aux_anchors,
add_extras_columns,
)
Load the named participants and observed events#
The explicit run filter prevents a subject query from pulling all three contrast-change runs. The first two subject IDs will train the model; the third is reserved for evaluation. The subject-coverage assertion catches a missing recording instead of silently changing that design.
annotate_trials_with_target reads the recording’s event sidecar and pairs
contrast trials with actual stimulus and response times. Trials without the
required events do not supply an observed latency. add_aux_anchors places
annotations at those actual stimulus times; it does not create response labels.
Inspect the printed annotation names and 100 Hz rate before windowing.
subjects = ["NDARDC843HHM", "NDAREC480KFA", "NDARAP785CTE"]
cache = Path(os.environ.get("EEGDASH_CACHE_DIR", "~/.eegdash_cache")).expanduser()
dataset = EEGChallengeDataset(
release="R5",
mini=True,
task="contrastChangeDetection",
subject=subjects,
run="1",
cache_dir=cache,
)
print(dataset.description.to_string(index=False))
assert set(dataset.description.subject) == set(subjects)
for recording in dataset.datasets:
raw = recording.raw
raw.pick("eeg")
print(
recording.description.subject,
raw.ch_names,
raw.info["sfreq"],
np.unique(raw.annotations.description),
)
assert raw.info["sfreq"] == 100
annotate_trials_with_target(raw, target_field="rt_from_stimulus")
add_aux_anchors(raw)
╭────────────────────── EEG 2025 Competition Data Notice ──────────────────────╮
│ This object loads the HBN dataset that has been preprocessed for the EEG │
│ Challenge: │
│ * Downsampled from 500Hz to 100Hz │
│ * Bandpass filtered (0.5-50 Hz) │
│ │
│ For full preprocessing applied for competition details, see: │
│ https://github.com/eeg2025/downsample-datasets │
│ │
│ The HBN dataset have some preprocessing applied by the HBN team: │
│ * Re-reference (Cz Channel) │
│ │
│ IMPORTANT: The data accessed via `EEGChallengeDataset` is NOT identical to │
│ what you get from EEGDashDataset directly. │
│ If you are participating in the competition, always use │
│ `EEGChallengeDataset` to ensure consistency with the challenge data. │
╰──────────────────────── Source: EEGChallengeDataset ─────────────────────────╯
[09/11/26 17:56:16] INFO Auto-corrected misrouted dataset.py:561
storage.base for dataset
EEG2025r5mini: None ->
s3://nemar/EEG2025r5mini
subject run task age sex release_number ehq_total commercial_use full_pheno p_factor attention internalizing externalizing restingstate despicableme funwithfractals thepresent diaryofawimpykid contrastchangedetection_1 contrastchangedetection_2 contrastchangedetection_3 surroundsupp_1 surroundsupp_2 seqlearning6target seqlearning8target symbolsearch session gender
NDARAP785CTE 1 contrastChangeDetection 8.7157 M R5 100.05 Yes No -0.960 1.556 -0.623 -0.685 available available available available available available available available available available unavailable available available None None
NDARDC843HHM 1 contrastChangeDetection 9.7593 M R5 -40.02 Yes No -0.696 1.086 -1.293 0.574 available available available available available available available available available available unavailable available available None None
NDAREC480KFA 1 contrastChangeDetection 10.4762 M R5 -6.67 Yes Yes -0.834 -0.910 -1.233 1.913 available available available available available available available available available available unavailable available available None None
NDARAP785CTE ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9', 'E10', 'E11', 'E12', 'E13', 'E14', 'E15', 'E16', 'E17', 'E18', 'E19', 'E20', 'E21', 'E22', 'E23', 'E24', 'E25', 'E26', 'E27', 'E28', 'E29', 'E30', 'E31', 'E32', 'E33', 'E34', 'E35', 'E36', 'E37', 'E38', 'E39', 'E40', 'E41', 'E42', 'E43', 'E44', 'E45', 'E46', 'E47', 'E48', 'E49', 'E50', 'E51', 'E52', 'E53', 'E54', 'E55', 'E56', 'E57', 'E58', 'E59', 'E60', 'E61', 'E62', 'E63', 'E64', 'E65', 'E66', 'E67', 'E68', 'E69', 'E70', 'E71', 'E72', 'E73', 'E74', 'E75', 'E76', 'E77', 'E78', 'E79', 'E80', 'E81', 'E82', 'E83', 'E84', 'E85', 'E86', 'E87', 'E88', 'E89', 'E90', 'E91', 'E92', 'E93', 'E94', 'E95', 'E96', 'E97', 'E98', 'E99', 'E100', 'E101', 'E102', 'E103', 'E104', 'E105', 'E106', 'E107', 'E108', 'E109', 'E110', 'E111', 'E112', 'E113', 'E114', 'E115', 'E116', 'E117', 'E118', 'E119', 'E120', 'E121', 'E122', 'E123', 'E124', 'E125', 'E126', 'E127', 'E128', 'Cz'] 100.0 ['9999' 'break cnt' 'contrastChangeB1_start' 'contrastTrial_start'
'left_buttonPress' 'left_target' 'right_buttonPress' 'right_target']
NDARDC843HHM ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9', 'E10', 'E11', 'E12', 'E13', 'E14', 'E15', 'E16', 'E17', 'E18', 'E19', 'E20', 'E21', 'E22', 'E23', 'E24', 'E25', 'E26', 'E27', 'E28', 'E29', 'E30', 'E31', 'E32', 'E33', 'E34', 'E35', 'E36', 'E37', 'E38', 'E39', 'E40', 'E41', 'E42', 'E43', 'E44', 'E45', 'E46', 'E47', 'E48', 'E49', 'E50', 'E51', 'E52', 'E53', 'E54', 'E55', 'E56', 'E57', 'E58', 'E59', 'E60', 'E61', 'E62', 'E63', 'E64', 'E65', 'E66', 'E67', 'E68', 'E69', 'E70', 'E71', 'E72', 'E73', 'E74', 'E75', 'E76', 'E77', 'E78', 'E79', 'E80', 'E81', 'E82', 'E83', 'E84', 'E85', 'E86', 'E87', 'E88', 'E89', 'E90', 'E91', 'E92', 'E93', 'E94', 'E95', 'E96', 'E97', 'E98', 'E99', 'E100', 'E101', 'E102', 'E103', 'E104', 'E105', 'E106', 'E107', 'E108', 'E109', 'E110', 'E111', 'E112', 'E113', 'E114', 'E115', 'E116', 'E117', 'E118', 'E119', 'E120', 'E121', 'E122', 'E123', 'E124', 'E125', 'E126', 'E127', 'E128', 'Cz'] 100.0 ['9999' 'break cnt' 'contrastChangeB1_start' 'contrastTrial_start'
'left_buttonPress' 'left_target' 'right_buttonPress' 'right_target']
NDAREC480KFA ['E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9', 'E10', 'E11', 'E12', 'E13', 'E14', 'E15', 'E16', 'E17', 'E18', 'E19', 'E20', 'E21', 'E22', 'E23', 'E24', 'E25', 'E26', 'E27', 'E28', 'E29', 'E30', 'E31', 'E32', 'E33', 'E34', 'E35', 'E36', 'E37', 'E38', 'E39', 'E40', 'E41', 'E42', 'E43', 'E44', 'E45', 'E46', 'E47', 'E48', 'E49', 'E50', 'E51', 'E52', 'E53', 'E54', 'E55', 'E56', 'E57', 'E58', 'E59', 'E60', 'E61', 'E62', 'E63', 'E64', 'E65', 'E66', 'E67', 'E68', 'E69', 'E70', 'E71', 'E72', 'E73', 'E74', 'E75', 'E76', 'E77', 'E78', 'E79', 'E80', 'E81', 'E82', 'E83', 'E84', 'E85', 'E86', 'E87', 'E88', 'E89', 'E90', 'E91', 'E92', 'E93', 'E94', 'E95', 'E96', 'E97', 'E98', 'E99', 'E100', 'E101', 'E102', 'E103', 'E104', 'E105', 'E106', 'E107', 'E108', 'E109', 'E110', 'E111', 'E112', 'E113', 'E114', 'E115', 'E116', 'E117', 'E118', 'E119', 'E120', 'E121', 'E122', 'E123', 'E124', 'E125', 'E126', 'E127', 'E128', 'Cz'] 100.0 ['9999' 'break cnt' 'contrastChangeB1_start' 'contrastTrial_start'
'left_buttonPress' 'left_target' 'right_buttonPress' 'right_target']
Extract a two-second prestimulus predictor#
At 100 Hz, offsets -200 and 0 select the interval immediately
before the stimulus. The 200-sample size and stride produce one window for
each usable anchor. mapping={"stimulus_anchor": 0} tells the windower
which anchors to use; that zero is a selection code, not the regression target.
add_extras_columns carries the measured rt_from_stimulus into the
window metadata. Use that column for y, in seconds. A finite array with
shape (trials, EEG channels, 200) supplies predictors in volts. Positive,
finite latency assertions expose malformed event pairings; they do not require
a particular prediction error or favourable result.
windows = create_windows_from_events(
dataset,
mapping={"stimulus_anchor": 0},
trial_start_offset_samples=-200,
trial_stop_offset_samples=0,
window_size_samples=200,
window_stride_samples=200,
preload=True,
)
windows = add_extras_columns(
windows,
dataset,
desc="stimulus_anchor",
keys=("target", "rt_from_stimulus", "stimulus_onset"),
)
metadata = windows.get_metadata().reset_index(drop=True)
X = np.stack([windows[i][0] for i in range(len(windows))])
y = metadata.rt_from_stimulus.to_numpy(dtype=float)
assert np.isfinite(X).all() and np.isfinite(y).all() and (y > 0).all()
# Log variance measures channel power; fit scaling on training participants.
/home/runner/work/EEGDash/EEGDash/.venv/lib/python3.12/site-packages/braindecode/preprocessing/windowers.py:889: UserWarning: Dropping extra columns that conflict with windowing metadata: {'target'}
warnings.warn(
/home/runner/work/EEGDash/EEGDash/.venv/lib/python3.12/site-packages/braindecode/preprocessing/windowers.py:889: UserWarning: Dropping extra columns that conflict with windowing metadata: {'target'}
warnings.warn(
/home/runner/work/EEGDash/EEGDash/.venv/lib/python3.12/site-packages/braindecode/preprocessing/windowers.py:889: UserWarning: Dropping extra columns that conflict with windowing metadata: {'target'}
warnings.warn(
Reduce each trial to channel power#
EEGDash’s signal_variance reduces the time axis to one feature per channel. Its
natural logarithm compresses the large range of power values; 1e-30 only
prevents taking the logarithm of zero. It is not an artifact rejection threshold.
This inexpensive representation discards the temporal waveform, which makes it
a useful reference before introducing an EEG encoder.
The Boolean masks split complete participants. Standardizing all rows before
this split would expose the test distribution, so scaling remains inside the
model pipeline. No separate validation set is used: alpha=10 is fixed in
advance and must not be adjusted in response to the final plot.
features = np.log(np.maximum(signal_variance(X), 1e-30))
train = metadata.subject.isin(subjects[:2]).to_numpy()
test = metadata.subject.eq(subjects[2]).to_numpy()
assert train.any() and test.any()
assert set(metadata.subject[train]).isdisjoint(metadata.subject[test])
print(
"Windows:", X.shape, "trials per participant:", metadata.groupby("subject").size()
)
Windows: (58, 129, 200) trials per participant: subject
NDARAP785CTE 20
NDARDC843HHM 21
NDAREC480KFA 17
dtype: int64
Fit a regularized baseline and compare errors#
Ridge penalizes large coefficients, which is helpful when channel-power
predictors are correlated and there are few training trials. StandardScaler
fits each feature’s mean and spread on the training participants only. The dummy
model predicts their mean latency and measures how much a signal-free predictor
already explains.
Mean absolute error averages absolute prediction errors in seconds over the held-out participant’s trials. It weights trials equally, not participants, because this test set contains only one person. The scatter shows whether predictions vary with observed latency; a nearly constant prediction or a model worse than the dummy baseline is a valid outcome.
model = make_pipeline(StandardScaler(), Ridge(alpha=10))
predicted = model.fit(features[train], y[train]).predict(features[test])
baseline = DummyRegressor().fit(features[train], y[train]).predict(features[test])
print("Held-out trial MAE (s):", mean_absolute_error(y[test], predicted))
print("Training-mean MAE (s):", mean_absolute_error(y[test], baseline))
Held-out trial MAE (s): 3.5199360870122876
Training-mean MAE (s): 0.21865263157894227
Report the final starter kit metric on this subset#
The final starter kit normalizes RMSE by the evaluated targets’ population standard deviation (despite its obsolete range-based docstring). This is dimensionless; the small tutorial split is not the competition test cohort. See eeg2025/startkit.
target_spread = y[test].std(ddof=0)
assert target_spread > 0, "NRMSE needs variation in the observed test targets"
print("Subset NRMSE:", root_mean_squared_error(y[test], predicted) / target_spread)
print(
"Training-mean NRMSE:", root_mean_squared_error(y[test], baseline) / target_spread
)
fig, ax = plt.subplots(figsize=(5, 4))
ax.scatter(y[test], predicted)
ax.set(xlabel="Observed reaction time (s)", ylabel="Predicted reaction time (s)")
plt.show()

Subset NRMSE: 13.805281945356406
Training-mean NRMSE: 1.030895020538131
Extend to a defensible challenge evaluation#
The single held-out participant is a workflow check, not a population estimate or the official challenge evaluator. Ridge is unconstrained and can produce negative latencies; inspect such predictions instead of silently clipping them to improve a score. Missing-response exclusions also change the population being predicted.
Add training and validation participants before comparing feature bands or ridge penalties. Keep final test participants out of every choice and report participant-level errors as well as pooled trial errors. For encoder transfer with the same observed target, continue with tutorial 71.
Related evaluation example: Braindecode train, test and tune.
Total running time of the script: (0 minutes 2.020 seconds)