Note
Go to the end to download the full example code or to run this example in your browser via Binder.
How do I fine-tune a published EEG foundation model (REVE)?#
Difficulty 3 | Compute: CPU; optional accelerator Runtime depends on hardware; full fine-tuning is an explicit opt-in.
Adapt the published REVE-Base checkpoint to a motor-imagery-vs-rest task from
OpenNeuro dataset ds003810, served through EEGDash and trained with
Braindecode’s EEGClassifier. The checkpoint is
https://huggingface.co/brain-bzh/reve-base (documented by Braindecode’s REVE
model). It is gated: accepting the authors’ terms once and logging in to the
Hugging Face Hub is part of this tutorial. It downloads about 280 MB of weights
and 7 MB of EEG (one participant, four runs).
Two ways to adapt a pretrained encoder are shown with the same code path: a linear probe (encoder frozen, only the classification head trains – runs on a laptop CPU) and full fine-tuning (everything trains at a lower learning rate – explicitly opt in before running). Runs 1 and 2 train, run 3 chooses when to stop, and run 4 is scored once per prespecified regime. The small budget demonstrates adaptation, not a foundation-model benchmark.
Keywords: foundation-model, fine-tuning, linear-probe, REVE, motor-imagery, transfer-learning
Before you start#
REVE (Representation for EEG with Versatile Embeddings) is a transformer pretrained with a masked-autoencoder objective on about 60,000 hours of EEG from 92 datasets. Its distinctive part is a 4-D positional encoding: every electrode is described by its 3-D position on a template head plus time, so one model accepts any montage. A 15-channel low-cost headset recording goes straight into a model pretrained mostly on 19- to 128-channel data, with no common-channel subset to pick.
The weights are published under a gated license (the electrode position table,
brain-bzh/reve-positions, is public). One-time setup:
Open https://huggingface.co/brain-bzh/reve-base and accept the terms.
Log in once from a terminal:
hf auth login(or setHF_TOKEN).
import matplotlib.pyplot as plt
import numpy as np
import torch
from skorch.callbacks import EarlyStopping
from skorch.helper import predefined_split
from sklearn.metrics import balanced_accuracy_score
from braindecode import EEGClassifier
from braindecode.datasets import BaseConcatDataset
from braindecode.models import REVE
from braindecode.preprocessing import (
Preprocessor,
create_windows_from_events,
preprocess,
)
from eegdash.paths import get_default_cache_dir
from eegdash import EEGDashDataset
MODEL_ID = "brain-bzh/reve-base"
MODEL_REVISION = (
"fa9a2163a4b7c0a42c8e28b56077ef9c368944dc" # pinned published checkpoint
)
DATASET, SUBJECT, RUNS = "ds003810", "02", ["1", "2", "3", "4"]
SFREQ = 200 # REVE was pretrained at 200 Hz; it does not check, so we must
WINDOW_S = 4 # cue -> end of trial in this paradigm
CACHE_DIR = get_default_cache_dir()
# Changing hardware never changes which experiment runs.
DEVICE = "cpu" # explicitly choose "cuda" or "mps" when available
RUN_FINETUNE = False # opt in before inspecting any test results
PROBE_EPOCHS, FT_EPOCHS = 5, 2 # small workflow budget, not converged performance
PROBE_LR, FINETUNE_LR = 1e-4, 1e-5
torch.manual_seed(0)
print(f"device={DEVICE}; probe epochs={PROBE_EPOCHS}; fine-tune={RUN_FINETUNE}")
# from_pretrained reports gated access/download errors directly. Complete the
# one-time login above before running; no exception is silently treated as offline.
device=cpu; probe epochs=5; fine-tune=False
Match the real signal to the encoder input#
Load one participant’s motor-imagery runs from EEGDash and bring them to the format REVE was pretrained on.
ds003810 (“Motor Imagery vs Rest – Low-Cost EEG System”) has ten
participants, 15 electrodes at 125 Hz and five runs each. Run 0 is a warm-up
in which the participant really squeezes their hand – with the same event
codes as the imagery runs – so loading it would train on executed movement
labelled as imagery. We load runs 1-4 only.
The recording software (OpenViBE) wrote its own cue codes: OVTK_GDF_Right
is the imagery cue and OVTK_GDF_Tongue the rest cue (the dataset’s
events.json documents this). Each cue is an instantaneous marker and the
trial runs 4 s from cue to feedback, so one 4 s window per cue fits exactly.
REVE’s input contract – none of it enforced at runtime: 200 Hz, EEG channels only, and per-channel z-scored input clipped at 15 standard deviations.
ds = EEGDashDataset(cache_dir=CACHE_DIR, dataset=DATASET, subject=SUBJECT, run=RUNS)
print(ds.description[["subject", "run", "task"]].to_string(index=False))
def zscore_clip(x, clip=15.0):
"""Per-channel z-score over time, clipped at ``clip`` SD (REVE's pretraining normalisation).
``x`` is the whole (channels, times) array -- braindecode's ``apply_on_array`` is not
channel-wise by default -- so reduce over the time axis explicitly.
"""
x = (x - x.mean(axis=-1, keepdims=True)) / (x.std(axis=-1, keepdims=True) + 1e-8)
return np.clip(x, -clip, clip)
preprocess(
ds,
[
Preprocessor("pick", picks="eeg", apply_on_array=False),
Preprocessor("filter", l_freq=0.5, h_freq=45.0, apply_on_array=False),
Preprocessor("resample", sfreq=SFREQ, apply_on_array=False),
Preprocessor(zscore_clip, apply_on_array=True),
],
)
chs_info = ds.datasets[0].raw.info["chs"]
print(len(chs_info), "EEG channels:", [c["ch_name"] for c in chs_info])
# The cue markers have zero duration, so the trial would be empty with the
# default stop offset; extend each trial to exactly one window from the cue.
windows = create_windows_from_events(
ds,
mapping={"OVTK_GDF_Right": 0, "OVTK_GDF_Tongue": 1}, # 0 = motor imagery, 1 = rest
trial_start_offset_samples=0,
trial_stop_offset_samples=WINDOW_S * SFREQ,
window_size_samples=WINDOW_S * SFREQ,
window_stride_samples=WINDOW_S * SFREQ,
preload=True,
)
INFO Auto-corrected misrouted dataset.py:561
storage.base for dataset on003810:
s3://nemar/on003810 ->
s3://openneuro.org/ds003810
subject run task
02 1 MIvsRest
02 2 MIvsRest
02 3 MIvsRest
02 4 MIvsRest
Downloading sub-02_task-MIvsRest_run-1_eeg.edf: 0%| | 0.00/1.65M [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-1_eeg.edf: 0%| | 0.00/1.65M [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-1_eeg.edf: 61%|██████ | 1.00M/1.65M [00:00<00:00, 3.46MB/s]
Downloading sub-02_task-MIvsRest_run-1_eeg.edf: 100%|██████████| 1.65M/1.65M [00:00<00:00, 4.94MB/s]
Downloading sub-02_task-MIvsRest_run-1_channels.tsv: 0%| | 0.00/242 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-1_channels.tsv: 0%| | 0.00/242 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-1_channels.tsv: 100%|██████████| 242/242 [00:00<00:00, 4.82kB/s]
Downloading sub-02_task-MIvsRest_run-1_eeg.json: 0%| | 0.00/382 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-1_eeg.json: 0%| | 0.00/382 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-1_eeg.json: 100%|██████████| 382/382 [00:00<00:00, 7.96kB/s]
Reading 0 ... 55874 = 0.000 ... 446.992 secs...
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 0.5 - 45 Hz
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 0.50
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 0.25 Hz)
- Upper passband edge: 45.00 Hz
- Upper transition bandwidth: 11.25 Hz (-6 dB cutoff frequency: 50.62 Hz)
- Filter length: 825 samples (6.600 s)
Downloading sub-02_task-MIvsRest_run-2_eeg.edf: 0%| | 0.00/1.62M [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-2_eeg.edf: 0%| | 0.00/1.62M [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-2_eeg.edf: 62%|██████▏ | 1.00M/1.62M [00:00<00:00, 4.00MB/s]
Downloading sub-02_task-MIvsRest_run-2_eeg.edf: 100%|██████████| 1.62M/1.62M [00:00<00:00, 5.92MB/s]
Downloading sub-02_task-MIvsRest_run-2_channels.tsv: 0%| | 0.00/242 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-2_channels.tsv: 0%| | 0.00/242 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-2_channels.tsv: 100%|██████████| 242/242 [00:00<00:00, 4.87kB/s]
Downloading sub-02_task-MIvsRest_run-2_eeg.json: 0%| | 0.00/382 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-2_eeg.json: 0%| | 0.00/382 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-2_eeg.json: 100%|██████████| 382/382 [00:00<00:00, 7.05kB/s]
Reading 0 ... 54749 = 0.000 ... 437.992 secs...
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 0.5 - 45 Hz
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 0.50
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 0.25 Hz)
- Upper passband edge: 45.00 Hz
- Upper transition bandwidth: 11.25 Hz (-6 dB cutoff frequency: 50.62 Hz)
- Filter length: 825 samples (6.600 s)
Downloading sub-02_task-MIvsRest_run-3_eeg.edf: 0%| | 0.00/1.25M [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-3_eeg.edf: 0%| | 0.00/1.25M [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-3_eeg.edf: 80%|███████▉ | 1.00M/1.25M [00:00<00:00, 3.42MB/s]
Downloading sub-02_task-MIvsRest_run-3_eeg.edf: 100%|██████████| 1.25M/1.25M [00:00<00:00, 3.96MB/s]
Downloading sub-02_task-MIvsRest_run-3_channels.tsv: 0%| | 0.00/242 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-3_channels.tsv: 0%| | 0.00/242 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-3_channels.tsv: 100%|██████████| 242/242 [00:00<00:00, 4.45kB/s]
Downloading sub-02_task-MIvsRest_run-3_eeg.json: 0%| | 0.00/382 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-3_eeg.json: 0%| | 0.00/382 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-3_eeg.json: 100%|██████████| 382/382 [00:00<00:00, 7.88kB/s]
Reading 0 ... 42374 = 0.000 ... 338.992 secs...
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 0.5 - 45 Hz
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 0.50
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 0.25 Hz)
- Upper passband edge: 45.00 Hz
- Upper transition bandwidth: 11.25 Hz (-6 dB cutoff frequency: 50.62 Hz)
- Filter length: 825 samples (6.600 s)
Downloading sub-02_task-MIvsRest_run-4_eeg.edf: 0%| | 0.00/1.59M [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-4_eeg.edf: 0%| | 0.00/1.59M [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-4_eeg.edf: 63%|██████▎ | 1.00M/1.59M [00:00<00:00, 3.83MB/s]
Downloading sub-02_task-MIvsRest_run-4_eeg.edf: 100%|██████████| 1.59M/1.59M [00:00<00:00, 5.21MB/s]
Downloading sub-02_task-MIvsRest_run-4_channels.tsv: 0%| | 0.00/242 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-4_channels.tsv: 0%| | 0.00/242 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-4_channels.tsv: 100%|██████████| 242/242 [00:00<00:00, 4.92kB/s]
Downloading sub-02_task-MIvsRest_run-4_eeg.json: 0%| | 0.00/382 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-4_eeg.json: 0%| | 0.00/382 [00:00<?, ?B/s]
Downloading sub-02_task-MIvsRest_run-4_eeg.json: 100%|██████████| 382/382 [00:00<00:00, 7.89kB/s]
Reading 0 ... 53874 = 0.000 ... 430.992 secs...
Filtering raw data in 1 contiguous segment
Setting up band-pass filter from 0.5 - 45 Hz
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 0.50
- Lower transition bandwidth: 0.50 Hz (-6 dB cutoff frequency: 0.25 Hz)
- Upper passband edge: 45.00 Hz
- Upper transition bandwidth: 11.25 Hz (-6 dB cutoff frequency: 50.62 Hz)
- Filter length: 825 samples (6.600 s)
15 EEG channels: ['Pz', 'Cz', 'T6', 'T4', 'F8', 'P4', 'C4', 'F4', 'Fz', 'T5', 'T3', 'F7', 'P3', 'C3', 'F3']
Reserve runs, not windows#
Windows from the same run share slow drifts, electrode impedance and the participant’s state, so a random window-level split leaks. Split by run: runs 1-2 train, run 3 selects the epoch, run 4 is scored once per prespecified regime and never used to select settings.
This is within-person, across-run evaluation, not generalization to new people. Complete-recording filtering and normalization also use each held-out run’s unlabeled signal: an offline protocol, not causal online prediction. Verify that this dataset/participant was absent from the checkpoint pretraining corpus before claiming a contamination-free transfer estimate; it is not checked here.
by_run = windows.split("run")
train_set = BaseConcatDataset([by_run["1"], by_run["2"]])
valid_set, test_set = by_run["3"], by_run["4"]
print(f"train {len(train_set)} windows | valid {len(valid_set)} | test {len(test_set)}")
train 80 windows | valid 30 | test 40
Load the checkpoint and understand the head#
REVE.from_pretrained reads the checkpoint’s config.json to build
Braindecode’s REVE module at the right depth and width, loads the weights,
and swaps the pretraining head for a fresh classification head with
n_outputs classes. Electrode positions are resolved from channel names
against the published position table (543 standard names -> x, y, z). That
lookup is exact and case-sensitive and unknown names are dropped silently, so
check that every channel resolved before training.
model = REVE.from_pretrained(
MODEL_ID,
revision=MODEL_REVISION,
n_outputs=2,
n_chans=len(chs_info),
n_times=WINDOW_S * SFREQ,
sfreq=SFREQ,
chs_info=chs_info,
)
# Public resolved positions must contain one finite xyz coordinate per input channel.
positions = model.get_positions([channel["ch_name"] for channel in chs_info])
if positions.shape[-2:] != (len(chs_info), 3) or not torch.isfinite(positions).all():
raise ValueError(
"REVE did not resolve all input channels to finite xyz positions; inspect chs_info."
)
n_total = sum(p.numel() for p in model.parameters())
print(
f"REVE-Base: {n_total / 1e6:.1f}M parameters | positions resolved: {tuple(model.default_pos.shape)}"
)
# The head flattens every token: 15 channels x 4 patches (200-sample patches with
# 20 overlap across 800 samples) x 512 dims = 30,720 features -> 2 classes.
REVE-Base: 69.3M parameters | positions resolved: (15, 3)
Two ways to adapt the encoder#
Linear probe: freeze the encoder and train only final_layer. Cheap,
stable with little data, and a fair test of what the pretrained features
already contain.
Full fine-tuning: unfreeze everything and continue from the probed model at a lower learning rate. More capacity, more data needed, and it overfits fast on a few hundred windows – which is why both use early stopping on run 3.
Both use fixed, explicit rates: 1e-4 for the probe and 1e-5 for continuation. Fine-tuning starts from the validation-selected probe, so this is sequential adaptation, not two independently initialized regimes or equal-compute training.
def set_trainable(model, head_only):
# Freezing only touches gradients. That is enough for REVE: it has no dropout or
# batch-norm, so the frozen encoder returns the same features in train and eval mode.
for name, p in model.named_parameters():
p.requires_grad = (not head_only) or name.startswith("final_layer")
def make_classifier(model, lr, patience=5):
stop = EarlyStopping(
monitor="valid_balanced_accuracy",
lower_is_better=False,
patience=patience,
load_best=True,
)
return EEGClassifier(
model,
criterion=torch.nn.CrossEntropyLoss,
optimizer=torch.optim.AdamW,
optimizer__lr=lr,
optimizer__weight_decay=1e-2,
train_split=predefined_split(valid_set), # run 3 is scored after every epoch
batch_size=32,
callbacks=["balanced_accuracy", stop],
classes=[0, 1],
device=DEVICE,
iterator_train__shuffle=True,
)
def score_on_test(clf):
y_true = test_set.get_metadata()["target"].to_numpy()
return balanced_accuracy_score(y_true, clf.predict(test_set))
def valid_curve(clf):
return clf.history[:, "valid_balanced_accuracy"]
Linear probe#
Skorch prints one line per epoch: training loss, validation balanced accuracy on run 3, and timing. Early stopping ends the run five epochs after the last improvement and restores the best epoch’s weights.
results, curves = {}, {}
set_trainable(model, head_only=True)
probe = make_classifier(model, lr=PROBE_LR)
probe.fit(train_set, y=None, epochs=PROBE_EPOCHS)
results["linear probe"] = score_on_test(probe)
curves["linear probe"] = valid_curve(probe)
print(
f"\nlinear probe: test balanced accuracy on run 4 = {results['linear probe']:.3f}"
)
[10/09/26 23:14:58] 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_balanced_accuracy train_loss valid_acc valid_balanced_accuracy valid_loss dur
------- ------------------------- ------------ ----------- ------------------------- ------------ ------
1 0.5000 0.8583 0.5000 0.5000 0.7022 2.9213
2 0.5000 0.7579 0.5000 0.5000 0.6894 2.8715
3 0.7125 0.6457 0.5333 0.5333 0.6699 2.9740
4 0.5000 0.6355 0.5000 0.5000 0.7449 2.8942
5 0.9000 0.6186 0.5667 0.5667 0.6500 2.9075
linear probe: test balanced accuracy on run 4 = 0.550
Optional full fine-tuning#
Continue from the selected probe at a tenfold smaller learning rate. Enable RUN_FINETUNE in the parameter cell before scoring either regime.
if RUN_FINETUNE:
model = probe.module_
set_trainable(model, head_only=False)
finetune = make_classifier(model, lr=FINETUNE_LR, patience=2)
finetune.fit(train_set, y=None, epochs=FT_EPOCHS)
results["fine-tune"] = score_on_test(finetune)
curves["fine-tune"] = valid_curve(finetune)
print(
f"\nfull fine-tune: test balanced accuracy on run 4 = {results['fine-tune']:.3f}"
)
else:
print(
"Probe-only protocol; set RUN_FINETUNE=True before running for sequential adaptation."
)
Probe-only protocol; set RUN_FINETUNE=True before running for sequential adaptation.
Result#
Left: validation balanced accuracy per epoch, with the epoch whose weights were kept. Right: balanced accuracy on run 4, scored once per prespecified regime.
fig, (ax_curve, ax_bar) = plt.subplots(1, 2, figsize=(10, 3.6))
for name, curve in curves.items():
epochs = np.arange(1, len(curve) + 1)
ax_curve.plot(epochs, curve, marker="o", ms=3, label=name)
ax_curve.scatter(
epochs[np.argmax(curve)],
max(curve),
s=90,
facecolors="none",
edgecolors="k",
zorder=3,
)
ax_curve.axhline(0.5, color="grey", ls=":", lw=1)
ax_curve.set(
xlabel="epoch",
ylabel="validation balanced accuracy (run 3)",
title="early stopping on run 3",
)
ax_curve.legend()
ax_bar.bar(
list(results), list(results.values()), color=["#4a8", "#c44"][: len(results)]
)
ax_bar.axhline(0.5, color="grey", ls=":", lw=1)
ax_bar.set(
ylim=(0.0, 1.0),
ylabel="test balanced accuracy (run 4)",
title=f"participant {SUBJECT}, {len(test_set)} retained test trials",
)
fig.tight_layout()
print("Summary -- balanced accuracy on run 4 (chance = 0.50):")
for name, acc in results.items():
print(f" {name:14s} {acc:.3f}")
plt.show()

Summary -- balanced accuracy on run 4 (chance = 0.50):
linear probe 0.550
Read the result and design the next experiment#
Either regime may be better on this participant. The test runs do not select the regime, epoch budget, rate or checkpoint. No external benchmark result is reproduced here, and test-trial variation is not population uncertainty. To study new-person transfer, prespecify participant-disjoint train/validation/ test cohorts and repeated seeds, audit pretraining overlap, and retain a simple baseline. This five-epoch workflow is not evidence of convergence.
References#
El Ouahidi et al. (2025). REVE: A Foundation Model for EEG – Adapting to Any Setup with Large-Scale Pretraining on 25,000 Subjects. NeurIPS 2025. https://arxiv.org/abs/2510.21585
Peterson et al. (2021). Motor Imagery vs Rest – Low-Cost EEG System. OpenNeuro ds003810. https://openneuro.org/datasets/ds003810
Total running time of the script: (0 minutes 35.706 seconds)