Familiar vs unfamiliar faces with EEGPrep and ShallowFBCSPNet#

Estimated reading time:35 minutes

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.
BaseConcatDataset
TypeBaseConcatDataset of EEGDashRaw
Recordings1
Total samples377088
Sfreq*128.0 Hz
Channels*70 (70 EEG)
Ch. names*EEG001, EEG002, EEG003, EEG004, EEG005, EEG006, EEG007, EEG008, EEG009, EEG010, ... (+60 more)
Montage*head
Duration*2946.0 s
* from first recording
Description1 recordings × 7 columns [subject, task, session, run, age, gender, sex]


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
<class 'braindecode.classifier.EEGClassifier'>[initialized](
  module_==================================================================================================================================================
  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
  =================================================================================================================================================,
)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.


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()
project face familiarity
Exploratory balanced accuracy: 0.5881355932203389
Constant-class balanced-accuracy reference: 0.5

Total running time of the script: (1 minutes 23.220 seconds)