Note
Go to the end to download the full example code or to run this example in your browser via Binder.
Familiar vs unfamiliar faces with EEGPrep and ShallowFBCSPNet#
Decode from single trials whether a participant saw a famous or an
unfamiliar face, using the real ds002718 face-recognition recordings
(Wakeman & Henson). EEGDashDataset downloads one participant (about 224 MB);
set EEGDASH_CACHE_DIR to reuse it. CPU is sufficient. Install
eegprep[eeglabio]>=0.2.23,<0.3 for the EEGPrep stage.
The pipeline is deliberately short: keep the EEG channels, clean the continuous signal with Braindecode’s EEGPrep (resampling, high-pass filter, artifact subspace reconstruction), cut one-second windows around each face onset, and train ShallowFBCSPNet on 80 % of that participant’s trials. The remaining 20 % are scored once. Binary balanced accuracy has a constant-class reference of 0.5 regardless of retained class counts.
This is an outcome-selected exploratory illustration: participant 018 was chosen after inspecting results across eighteen participants and repeated splits. Consequently the held-out trials below are not unbiased evidence of decoding performance, even with a fresh split. Repeated face identities can occur on both sides: the estimand is within-participant, potentially within-stimulus prediction, not recognition of unseen identities or population generalization. A confirmatory study must prespecify participants and group original image identities using the source event metadata before any outcome inspection.
Before you start#
This project assumes the core tutorials on windows and splits and the eyes-open/closed tutorial for EEGPrep. It runs independently and prints the per-epoch training table, exploratory balanced accuracy and learning curves.
Install dependencies (uncomment when running in Colab or a fresh notebook)
# !pip install eegdash braindecode "eegprep[eeglabio]>=0.2.23,<0.3" scikit-learn torch numpy
Imports
import matplotlib.pyplot as plt
import numpy as np
import torch
from sklearn.model_selection import train_test_split
from sklearn.metrics import balanced_accuracy_score
from skorch.dataset import ValidSplit
from eegdash.paths import get_default_cache_dir
from eegdash import EEGDashDataset
from braindecode import EEGClassifier
from braindecode.models import ShallowFBCSPNet
from braindecode.preprocessing import (
EEGPrep,
Preprocessor,
create_windows_from_events,
preprocess,
)
from braindecode.util import set_random_seeds
# SFREQ is the target sampling rate
SFREQ = 128
1. Load one participant of ds002718#
One subject of ds002718 (Wakeman & Henson face recognition), fetched by EEGDash into a local cache.
ds = EEGDashDataset(
cache_dir=get_default_cache_dir(),
dataset="ds002718",
subject="018",
task="FaceRecognition",
)
[10/09/26 23:15:57] INFO Auto-corrected misrouted dataset.py:561
storage.base for dataset on002718:
s3://nemar/on002718 ->
s3://openneuro.org/ds002718
2. Clean the continuous signal with EEGPrep#
EEGPrep calibrates on this entire unlabelled recording before splitting. This is offline/transductive cleaning, not a train-only or causal protocol. Keep volts through EEGPrep; convert the resulting windows for the network.
preprocess(
ds,
[
Preprocessor("pick", picks="eeg"),
EEGPrep(
resample_to=SFREQ,
highpass_frequencies=(
0.25,
0.75,
), # transition band: full stop at 0.25 Hz, passband from 0.75 Hz
burst_removal_cutoff=10.0, # ASR: reject components beyond 10 SD of the calibration data
bad_window_max_bad_channels=None, # no bad-window removal: time axis intact, every trial survives
),
],
)
/home/runner/work/EEGDash/EEGDash/.venv/lib/python3.12/site-packages/braindecode/preprocessing/preprocess.py:78: UserWarning: apply_on_array can only be True if fn is a callable function. Automatically correcting to apply_on_array=False.
warn(
Downloading sub-018_task-FaceRecognition_eeg.set: 0%| | 0.00/217M [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 0%| | 0.00/217M [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 0%| | 1.00M/217M [00:00<01:33, 2.42MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 3%|▎ | 7.00M/217M [00:00<00:17, 12.3MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 8%|▊ | 18.0M/217M [00:00<00:08, 23.6MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 13%|█▎ | 29.0M/217M [00:01<00:06, 31.2MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 18%|█▊ | 40.0M/217M [00:01<00:05, 35.9MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 25%|██▍ | 54.0M/217M [00:01<00:04, 40.4MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 33%|███▎ | 72.0M/217M [00:01<00:03, 44.1MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 40%|███▉ | 86.0M/217M [00:02<00:02, 47.9MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 42%|████▏ | 90.0M/217M [00:02<00:03, 42.9MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 50%|████▉ | 108M/217M [00:02<00:02, 44.9MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 55%|█████▌ | 120M/217M [00:02<00:02, 46.6MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 58%|█████▊ | 126M/217M [00:03<00:02, 44.2MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 65%|██████▍ | 140M/217M [00:03<00:01, 46.9MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 67%|██████▋ | 145M/217M [00:03<00:01, 44.7MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 72%|███████▏ | 156M/217M [00:03<00:01, 45.6MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 78%|███████▊ | 168M/217M [00:03<00:01, 47.3MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 83%|████████▎ | 180M/217M [00:04<00:00, 48.7MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 91%|█████████▏| 198M/217M [00:04<00:00, 52.2MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 98%|█████████▊| 212M/217M [00:04<00:00, 54.2MB/s]
Downloading sub-018_task-FaceRecognition_eeg.set: 100%|██████████| 217M/217M [00:04<00:00, 47.4MB/s]
Downloading sub-018_task-FaceRecognition_channels.tsv: 0%| | 0.00/1.31k [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_channels.tsv: 0%| | 0.00/1.31k [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_channels.tsv: 100%|██████████| 1.31k/1.31k [00:00<00:00, 16.9kB/s]
Downloading sub-018_task-FaceRecognition_events.tsv: 0%| | 0.00/124k [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_events.tsv: 0%| | 0.00/124k [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_events.tsv: 100%|██████████| 124k/124k [00:00<00:00, 1.13MB/s]
Downloading sub-018_task-FaceRecognition_electrodes.tsv: 0%| | 0.00/1.68k [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_electrodes.tsv: 0%| | 0.00/1.68k [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_electrodes.tsv: 100%|██████████| 1.68k/1.68k [00:00<00:00, 17.4kB/s]
Downloading sub-018_task-FaceRecognition_eeg.json: 0%| | 0.00/1.28k [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_eeg.json: 0%| | 0.00/1.28k [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_eeg.json: 100%|██████████| 1.28k/1.28k [00:00<00:00, 16.6kB/s]
Downloading sub-018_task-FaceRecognition_coordsystem.json: 0%| | 0.00/279 [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_coordsystem.json: 0%| | 0.00/279 [00:00<?, ?B/s]
Downloading sub-018_task-FaceRecognition_coordsystem.json: 100%|██████████| 279/279 [00:00<00:00, 3.27kB/s]
Reading 0 ... 736499 = 0.000 ... 2945.996 secs...
/tmp/tmpf7g1s42j.set
Field 'subject' is missing from the EEG dictionnary, adding it.
Field 'group' is missing from the EEG dictionnary, adding it.
Field 'condition' is missing from the EEG dictionnary, adding it.
Field 'session' is missing from the EEG dictionnary, adding it.
Field 'comments' is missing from the EEG dictionnary, adding it.
Field 'times' is missing from the EEG dictionnary, adding it.
Field 'icaact' is missing from the EEG dictionnary, adding it.
Field 'icachansind' is missing from the EEG dictionnary, adding it.
Field 'urchanlocs' is missing from the EEG dictionnary, adding it.
Field 'urevent' is missing from the EEG dictionnary, adding it.
Field 'eventdescription' is missing from the EEG dictionnary, adding it.
Field 'epoch' is missing from the EEG dictionnary, adding it.
Field 'epochdescription' is missing from the EEG dictionnary, adding it.
Field 'stats' is missing from the EEG dictionnary, adding it.
Field 'specdata' is missing from the EEG dictionnary, adding it.
Field 'specicaact' is missing from the EEG dictionnary, adding it.
Field 'splinefile' is missing from the EEG dictionnary, adding it.
Field 'icasplinefile' is missing from the EEG dictionnary, adding it.
Field 'dipfit' is missing from the EEG dictionnary, adding it.
Field 'history' is missing from the EEG dictionnary, adding it.
Field 'saved' is missing from the EEG dictionnary, adding it.
Field 'etc' is missing from the EEG dictionnary, adding it.
Field 'datfile' is missing from the EEG dictionnary, adding it.
Field 'run' is missing from the EEG dictionnary, adding it.
Field 'roi' is missing from the EEG dictionnary, adding it.
[10/09/26 23:16:06] WARNING EEGPrep event count eegprep_preprocess.py:123
changed during EEGPrep
processing (1768
annotated events, 1763
non-boundary events);
restoring durations in
order for the overlapping
subset only.
[10/09/26 23:16:09] INFO Detecting flat line clean_artifacts.py:182
channels...
INFO Applying high‑pass filter... clean_artifacts.py:191
[10/09/26 23:16:11] INFO Scanning for bad channels... clean_channels.py:79
[10/09/26 23:16:17] INFO 1/589 blocks, 0.2 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:18] INFO 51/589 blocks, 0.2 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:19] INFO 101/589 blocks, 0.2 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:20] INFO 151/589 blocks, 0.2 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:22] INFO 201/589 blocks, 0.1 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:23] INFO 251/589 blocks, 0.1 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:24] INFO 301/589 blocks, 0.1 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:25] INFO 351/589 blocks, 0.1 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:26] INFO 401/589 blocks, 0.1 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:27] INFO 451/589 blocks, 0.1 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:29] INFO 501/589 blocks, 0.0 minutes clean_channels.py:136
remaining.
[10/09/26 23:16:30] INFO 551/589 blocks, 0.0 minutes clean_channels.py:136
remaining.
Removing 6 channel(s)...
warning: erasing dipole information since channels have been removed
[10/09/26 23:16:31] INFO Applying ASR burst repair... clean_artifacts.py:232
INFO Finding a clean section of the clean_asr.py:102
data for calibration...
INFO Determining time window clean_windows.py:115
rejection thresholds...
[10/09/26 23:16:33] INFO done. clean_windows.py:147
INFO Keeping 82.8% (2440 seconds) clean_windows.py:179
of the data.
ERROR Could not select time windows clean_windows.py:204
using EEGLAB's pop_select();
details: Error setting single
item of array.
INFO Falling back to a basic clean_windows.py:207
substitute and dropping signal
meta-data.
INFO Estimating ASR calibration clean_asr.py:138
statistics...
[10/09/26 23:16:34] INFO Calculating blockwise covariances... asr.py:167
[10/09/26 23:16:45] INFO Calculating robust geometric median asr.py:207
covariance...
INFO Determining per-component thresholds... asr.py:227
[10/09/26 23:16:48] INFO Thresholds calculation complete. asr.py:289
INFO Applying ASR processing... clean_asr.py:177
INFO Cleaning data in 1596 blocks asr.py:427
[10/09/26 23:17:10] INFO Finished cleaning. asr.py:546
INFO ASR cleaning finished. clean_asr.py:195
INFO Use vis_artifacts to compare clean_artifacts.py:303
the cleaned data to the
original.
[10/09/26 23:17:11] WARNING EEGPrep event count eegprep_preprocess.py:123
changed during EEGPrep
processing (1768
annotated events, 1763
non-boundary events);
restoring durations in
order for the overlapping
subset only.
3. Epoch familiar vs unfamiliar faces and split the trials#
Epoch -0.2..0.8 s around face onsets, famous = 0 vs unfamiliar = 1, then a within-subject 80/20 split.
mapping = {
"famous_new": 0,
"famous_second_early": 0,
"famous_second_late": 0,
"unfamiliar_new": 1,
"unfamiliar_second_early": 1,
"unfamiliar_second_late": 1,
}
windows = create_windows_from_events(
ds,
trial_start_offset_samples=int(-0.2 * SFREQ),
trial_stop_offset_samples=int(0.8 * SFREQ),
mapping=mapping,
)
X = (np.stack([x for x, _, _ in windows]) * 1e6).astype(np.float32)
y = windows.get_metadata()["target"].to_numpy(dtype=np.int64)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=0
)
print(
f"X {X.shape} | train {len(y_train)} | test {len(y_test)} | classes {np.bincount(y)}"
)
X (592, 70, 127) | train 473 | test 119 | classes [295 297]
4. Train ShallowFBCSPNet#
ShallowFBCSPNet in skorch’s EEGClassifier. The default 20% validation split prints valid_acc per epoch.
set_random_seeds(seed=0, cuda=False)
model = ShallowFBCSPNet(n_chans=X.shape[1], n_outputs=2, n_times=X.shape[2])
clf = EEGClassifier(
model,
optimizer=torch.optim.AdamW,
train_split=ValidSplit(0.2, stratified=True, random_state=0),
batch_size=64,
lr=0.001,
)
print(model)
clf.fit(X_train, y_train, epochs=30)
=================================================================================================================================================
Layer (type (var_name):depth-idx) Input Shape Output Shape Param # Kernel Shape
=================================================================================================================================================
ShallowFBCSPNet (ShallowFBCSPNet) [1, 70, 127] [1, 2] -- --
├─Ensure4d (ensuredims): 1-1 [1, 70, 127] [1, 70, 127, 1] -- --
├─Rearrange (dimshuffle): 1-2 [1, 70, 127, 1] [1, 1, 127, 70] -- --
├─CombinedConv (conv_time_spat): 1-3 [1, 1, 127, 70] [1, 40, 103, 1] 113,040 --
├─BatchNorm2d (bnorm): 1-4 [1, 40, 103, 1] [1, 40, 103, 1] 80 --
├─Square (conv_nonlin_exp): 1-5 [1, 40, 103, 1] [1, 40, 103, 1] -- --
├─AvgPool2d (pool): 1-6 [1, 40, 103, 1] [1, 40, 2, 1] -- [75, 1]
├─SafeLog (pool_nonlin_exp): 1-7 [1, 40, 2, 1] [1, 40, 2, 1] -- --
├─Dropout (drop): 1-8 [1, 40, 2, 1] [1, 40, 2, 1] -- --
├─Sequential (final_layer): 1-9 [1, 40, 2, 1] [1, 2] -- --
│ └─Conv2d (conv_classifier): 2-1 [1, 40, 2, 1] [1, 2, 1, 1] 162 [2, 1]
│ └─SqueezeFinalOutput (squeeze): 2-2 [1, 2, 1, 1] [1, 2] -- --
│ │ └─Rearrange (squeeze): 3-1 [1, 2, 1, 1] [1, 2, 1] -- --
=================================================================================================================================================
Total params: 113,282
Trainable params: 113,282
Non-trainable params: 0
Total mult-adds (Units.MEGABYTES): 0.00
=================================================================================================================================================
Input size (MB): 0.04
Forward/backward pass size (MB): 0.03
Params size (MB): 0.00
Estimated Total Size (MB): 0.07
=================================================================================================================================================
[10/09/26 23:17:12] 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 valid_acc valid_loss dur
------- ------------ ----------- ------------ ------
1 0.8671 0.4842 0.9798 0.2679
2 0.7244 0.5263 0.7957 0.2666
3 0.7088 0.4737 0.7622 0.2634
4 0.6107 0.5474 0.7157 0.2606
5 0.6280 0.5474 0.6937 0.2633
6 0.5807 0.4737 0.6996 0.2674
7 0.5689 0.4947 0.7171 0.2653
8 0.5226 0.4842 0.7434 0.2594
9 0.5197 0.4947 0.7308 0.2653
10 0.4957 0.5474 0.7379 0.2580
11 0.4087 0.5579 0.7369 0.2559
12 0.4142 0.5368 0.7206 0.2569
13 0.3892 0.4842 0.7422 0.2549
14 0.3617 0.5368 0.7417 0.2659
15 0.3526 0.5474 0.7652 0.2484
16 0.3325 0.5053 0.7515 0.2470
17 0.3115 0.5263 0.7493 0.2470
18 0.2623 0.5474 0.7463 0.2476
19 0.2963 0.5053 0.7879 0.2441
20 0.2510 0.5368 0.7511 0.2461
21 0.2361 0.5368 0.7575 0.2532
22 0.2163 0.4947 0.8121 0.2525
23 0.2366 0.5053 0.8245 0.2465
24 0.2056 0.5474 0.7606 0.2509
25 0.1910 0.5579 0.7632 0.2536
26 0.1674 0.5684 0.7687 0.2511
27 0.1635 0.6105 0.8004 0.2501
28 0.1592 0.5684 0.7735 0.2505
29 0.1333 0.5684 0.7760 0.2489
30 0.1415 0.5789 0.7900 0.2559
5. Score the held-out trials and look at the learning curve#
Score once after the fixed training budget; selection bias remains.
predicted = clf.predict(X_test)
print("Exploratory balanced accuracy:", balanced_accuracy_score(y_test, predicted))
print("Constant-class balanced-accuracy reference: 0.5")
history = clf.history
fig, axes = plt.subplots(1, 2, figsize=(9, 3.5), layout="constrained")
axes[0].plot(history[:, "epoch"], history[:, "train_loss"], marker="o")
axes[0].set(xlabel="Epoch", ylabel="Training loss")
axes[1].plot(history[:, "epoch"], history[:, "valid_acc"], marker="s")
axes[1].axhline(0.5, ls="--", color="gray")
axes[1].set(xlabel="Epoch", ylabel="Development validation accuracy", ylim=(0, 1))
plt.show()

Exploratory balanced accuracy: 0.5881355932203389
Constant-class balanced-accuracy reference: 0.5
Total running time of the script: (1 minutes 23.220 seconds)