EEG2025 Challenge 1: predict observed reaction time#

Estimated reading time:5 minutes

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()
tutorial challenge 1
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)