Does resting-state pretraining transfer to reaction time?#

Estimated reading time:8 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. Pretrain an EEGNet on observed eyes-open/closed cues from the two training participants, then adapt its encoder to reaction-time regression. Compare with an identically shaped network trained from scratch. No recording from the test participant enters either training stage. Fixed two-epoch budgets illustrate the operations; this is not evidence for general transfer gains.

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
import copy
import torch
from braindecode.models import EEGNet
from braindecode import EEGClassifier, EEGRegressor
from sklearn.metrics import mean_absolute_error

from eegdash import EEGChallengeDataset
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/16/26 20:48:37] 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

Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_eeg.bdf:   0%|          | 0.00/11.3M [00:00<?, ?B/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_eeg.bdf:   0%|          | 0.00/11.3M [00:00<?, ?B/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_eeg.bdf:   9%|▉         | 1.00M/11.3M [00:00<00:02, 3.84MB/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_eeg.bdf: 100%|██████████| 11.3M/11.3M [00:00<00:00, 29.1MB/s]

Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_channels.tsv: 100%|██████████| 3.34k/3.34k [00:00<00:00, 61.0kB/s]

Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_events.tsv:   0%|          | 0.00/3.02k [00:00<?, ?B/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_events.tsv:   0%|          | 0.00/3.02k [00:00<?, ?B/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_events.tsv: 100%|██████████| 3.02k/3.02k [00:00<00:00, 69.9kB/s]

Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_eeg.json:   0%|          | 0.00/242 [00:00<?, ?B/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_eeg.json:   0%|          | 0.00/242 [00:00<?, ?B/s]
Downloading sub-NDARAP785CTE_task-contrastChangeDetection_run-1_eeg.json: 100%|██████████| 242/242 [00:00<00:00, 6.07kB/s]
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']

Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_eeg.bdf:   0%|          | 0.00/9.29M [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_eeg.bdf:   0%|          | 0.00/9.29M [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_eeg.bdf:  11%|█         | 1.00M/9.29M [00:00<00:02, 3.76MB/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_eeg.bdf: 100%|██████████| 9.29M/9.29M [00:00<00:00, 23.8MB/s]

Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_channels.tsv: 100%|██████████| 3.34k/3.34k [00:00<00:00, 77.8kB/s]

Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_events.tsv:   0%|          | 0.00/2.91k [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_events.tsv:   0%|          | 0.00/2.91k [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_events.tsv: 100%|██████████| 2.91k/2.91k [00:00<00:00, 59.7kB/s]

Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_eeg.json:   0%|          | 0.00/242 [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_eeg.json:   0%|          | 0.00/242 [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-contrastChangeDetection_run-1_eeg.json: 100%|██████████| 242/242 [00:00<00:00, 6.13kB/s]
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']

Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_eeg.bdf:   0%|          | 0.00/16.5M [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_eeg.bdf:   0%|          | 0.00/16.5M [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_eeg.bdf:   6%|▌         | 1.00M/16.5M [00:00<00:03, 4.74MB/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_eeg.bdf: 100%|██████████| 16.5M/16.5M [00:00<00:00, 47.6MB/s]

Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_channels.tsv: 100%|██████████| 3.34k/3.34k [00:00<00:00, 85.4kB/s]

Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_events.tsv:   0%|          | 0.00/2.72k [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_events.tsv:   0%|          | 0.00/2.72k [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_events.tsv: 100%|██████████| 2.72k/2.72k [00:00<00:00, 79.2kB/s]

Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_eeg.json:   0%|          | 0.00/242 [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_eeg.json:   0%|          | 0.00/242 [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-contrastChangeDetection_run-1_eeg.json: 100%|██████████| 242/242 [00:00<00:00, 6.43kB/s]
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()
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])
# Fixed microvolt conversion does not estimate a transform on held-out data.
X = X.astype("float32") * 1e6
/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(

Pretrain on an observed auxiliary task#

Only the two training participants supply resting EEG. Checking their IDs against the held-out target participant closes a common transfer-learning leak: excluding a person from fine-tuning is insufficient if their recording was already used during pretraining.

The source labels encode instructions to open (0) or close (1) the eyes. The one-second offset moves the two-second window away from the instruction onset; these labels reflect the protocol, not an independent measurement of eye position. This is supervised auxiliary-task pretraining, not self-supervision. Both source and target arrays use the identical channel order and 200-sample length. Multiplying volts by 1e6 gives microvolts without fitting any statistics on held-out participants.

source = EEGChallengeDataset(
    release="R5", mini=True, task="RestingState", subject=subjects[:2], cache_dir=cache
)
assert set(source.description.subject).isdisjoint(metadata.subject[test])
for recording in source.datasets:
    recording.raw.pick("eeg")
    assert recording.raw.ch_names == dataset.datasets[0].raw.ch_names
source_windows = create_windows_from_events(
    source,
    mapping={"instructed_toOpenEyes": 0, "instructed_toCloseEyes": 1},
    trial_start_offset_samples=100,
    trial_stop_offset_samples=300,
    window_size_samples=200,
    window_stride_samples=200,
    preload=True,
)
Xs = (
    np.stack([source_windows[i][0] for i in range(len(source_windows))]).astype(
        "float32"
    )
    * 1e6
)
ys = np.asarray([source_windows[i][1] for i in range(len(source_windows))])
assert np.isfinite(Xs).all() and set(ys) == {0, 1}
print(
    "Resting-state windows:",
    Xs.shape,
    "real eye-state counts:",
    np.unique(ys, return_counts=True),
)
torch.manual_seed(71)
torch.set_num_threads(2)
╭────────────────────── 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/16/26 20:48:40] INFO     Auto-corrected misrouted             dataset.py:561
                             storage.base for dataset
                             EEG2025r5mini: None ->
                             s3://nemar/EEG2025r5mini

Downloading sub-NDARDC843HHM_task-RestingState_eeg.bdf:   0%|          | 0.00/13.5M [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-RestingState_eeg.bdf:   0%|          | 0.00/13.5M [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-RestingState_eeg.bdf:  15%|█▍        | 2.00M/13.5M [00:00<00:01, 9.31MB/s]
Downloading sub-NDARDC843HHM_task-RestingState_eeg.bdf: 100%|██████████| 13.5M/13.5M [00:00<00:00, 45.6MB/s]

Downloading sub-NDARDC843HHM_task-RestingState_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-RestingState_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-RestingState_channels.tsv: 100%|██████████| 3.34k/3.34k [00:00<00:00, 70.3kB/s]

Downloading sub-NDARDC843HHM_task-RestingState_events.tsv:   0%|          | 0.00/519 [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-RestingState_events.tsv:   0%|          | 0.00/519 [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-RestingState_events.tsv: 100%|██████████| 519/519 [00:00<00:00, 13.1kB/s]

Downloading sub-NDARDC843HHM_task-RestingState_eeg.json:   0%|          | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-RestingState_eeg.json:   0%|          | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDARDC843HHM_task-RestingState_eeg.json: 100%|██████████| 231/231 [00:00<00:00, 6.02kB/s]

Downloading sub-NDAREC480KFA_task-RestingState_eeg.bdf:   0%|          | 0.00/13.9M [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-RestingState_eeg.bdf:   0%|          | 0.00/13.9M [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-RestingState_eeg.bdf:   7%|▋         | 1.00M/13.9M [00:00<00:03, 4.22MB/s]
Downloading sub-NDAREC480KFA_task-RestingState_eeg.bdf: 100%|██████████| 13.9M/13.9M [00:00<00:00, 38.5MB/s]

Downloading sub-NDAREC480KFA_task-RestingState_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-RestingState_channels.tsv:   0%|          | 0.00/3.34k [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-RestingState_channels.tsv: 100%|██████████| 3.34k/3.34k [00:00<00:00, 93.1kB/s]

Downloading sub-NDAREC480KFA_task-RestingState_events.tsv:   0%|          | 0.00/527 [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-RestingState_events.tsv:   0%|          | 0.00/527 [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-RestingState_events.tsv: 100%|██████████| 527/527 [00:00<00:00, 13.9kB/s]

Downloading sub-NDAREC480KFA_task-RestingState_eeg.json:   0%|          | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-RestingState_eeg.json:   0%|          | 0.00/231 [00:00<?, ?B/s]
Downloading sub-NDAREC480KFA_task-RestingState_eeg.json: 100%|██████████| 231/231 [00:00<00:00, 6.37kB/s]
Resting-state windows: (22, 129, 200) real eye-state counts: (array([0, 1]), array([12, 10]))

Train the source encoder and replace its head#

EEGNet learns temporal and spatial filters directly from the windows. The source head returns two logits for cross-entropy; the downstream head returns one real number for squared-error regression. Adam’s 1e-3 step size, two epochs and 16-example batches (with a shorter final batch) are fixed teaching settings, not hyperparameters selected for this cohort. EEGClassifier and EEGRegressor manage batching, gradients and evaluation mode; train_split=None prevents an additional window-level validation split. Regression targets retain shape (trials, 1) to match the one-output head.

Target standardization uses only training latencies. Predictions are later multiplied by that training standard deviation and shifted by the training mean to return to seconds. Copying all state except final_layer transfers the encoder while keeping the new one-output head. The missing-key assertion ensures that relaxed loading has not silently discarded unrelated parameters. Both downstream conditions begin with the same randomly initialized head.

encoder = EEGNet(n_chans=X.shape[1], n_outputs=2, n_times=200, sfreq=100)
source_trainer = EEGClassifier(
    encoder,
    criterion=torch.nn.CrossEntropyLoss,
    optimizer=torch.optim.Adam,
    lr=1e-3,
    batch_size=16,
    max_epochs=2,
    train_split=None,
    iterator_train__shuffle=False,
    classes=[0, 1],
    device="cpu",
)
source_trainer.fit(Xs, ys)
encoder = source_trainer.module_
mean, scale = y[train].mean(), y[train].std()
assert scale > 0
results = {}
initial = EEGNet(n_chans=X.shape[1], n_outputs=1, n_times=200, sfreq=100)
for regime in ["from scratch", "resting-state transfer"]:
    model = copy.deepcopy(initial)
    if regime == "resting-state transfer":
        state = {
            k: v
            for k, v in encoder.state_dict().items()
            if not k.startswith("final_layer")
        }
        missing, unexpected = model.load_state_dict(state, strict=False)
        assert not unexpected and all(k.startswith("final_layer") for k in missing)
    regressor = EEGRegressor(
        model,
        criterion=torch.nn.MSELoss,
        optimizer=torch.optim.Adam,
        lr=1e-3,
        batch_size=16,
        max_epochs=2,
        train_split=None,
        iterator_train__shuffle=False,
        device="cpu",
    )
    standardized_y = ((y[train] - mean) / scale).astype("float32")[:, None]
    regressor.fit(X[train], standardized_y)
    predicted = regressor.predict(X[test]).reshape(-1) * scale + mean
    results[regime] = mean_absolute_error(y[test], predicted)
print("Held-out reaction-time MAE (s):", results)
fig, ax = plt.subplots(figsize=(6, 4))
ax.bar(list(results), list(results.values()))
ax.set(ylabel="Held-out participant trial MAE (s)")
plt.show()
plot 71 cross task transfer
[09/16/26 20:48:42] INFO     The module passed is already    eegneuralnet.py:193
                             initialized which is not
                             recommended. Instead, you can
                             pass the module class and its
                             parameters separately.
                             For more details, see
                             https://skorch.readthedocs.io/e
                             n/stable/user/neuralnet.html#mo
                             dule
                             Skipping setting signal-related
                             parameters from data.
  epoch    train_loss     dur
-------  ------------  ------
      1        0.7056  0.0582
      2        0.6943  0.0517
[09/16/26 20:48:44] INFO     The module passed is already    eegneuralnet.py:193
                             initialized which is not
                             recommended. Instead, you can
                             pass the module class and its
                             parameters separately.
                             For more details, see
                             https://skorch.readthedocs.io/e
                             n/stable/user/neuralnet.html#mo
                             dule
                             Skipping setting signal-related
                             parameters from data.
  epoch    train_loss     dur
-------  ------------  ------
      1        1.1148  0.1037
      2        1.1118  0.1024
                    INFO     The module passed is already    eegneuralnet.py:193
                             initialized which is not
                             recommended. Instead, you can
                             pass the module class and its
                             parameters separately.
                             For more details, see
                             https://skorch.readthedocs.io/e
                             n/stable/user/neuralnet.html#mo
                             dule
                             Skipping setting signal-related
                             parameters from data.
  epoch    train_loss     dur
-------  ------------  ------
      1        1.3540  0.1033
      2        1.1786  0.1019
Held-out reaction-time MAE (s): {'from scratch': 0.793028268463976, 'resting-state transfer': 1.8783957974210803}

Interpret transfer without selecting on the test participant#

Each bar is mean absolute error over usable trials from the same held-out person; lower is better and an error of 0.1 means 100 ms on average. The transfer condition receives extra source-task training, so the comparison is not matched for total optimization steps. One subject and one initialization cannot show a reliable general transfer benefit. The plot may favour either condition.

To extend the experiment, reserve additional validation participants before either training stage. Select the epoch budget and learning rate there, then repeat the entire comparison over untouched test participants and seeds. Also compare a training-mean latency predictor and assess exclusions for missing responses. Do not tune source tasks after looking at these test bars.

Related worked examples: cross-dataset transfer and relative-positioning pretraining.

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