Sleep staging on the Sleep Physionet dataset using Eldele2021#

This tutorial shows how to train and test a sleep staging neural network with Braindecode. We use the attention-based model from [1] with the time distributed approach of [2] to learn on sequences of EEG windows using the openly accessible Sleep Physionet dataset [3] [4].

# Authors: Divyesh Narayanan <divyesh.narayanan@gmail.com>
#
# License: BSD (3-clause)

Loading and preprocessing the dataset#

Loading#

First, we load the data using the braindecode.datasets.sleep_physionet.SleepPhysionet class. We load six subjects with two recordings each. Subjects 0-3 are used for training and subjects 4-5 for validation.

from numbers import Integral

from braindecode.datasets import SleepPhysionet

subject_ids = [0, 1, 2, 3, 4, 5]
train_subject_ids = [0, 1, 2, 3]
valid_subject_ids = [4, 5]
dataset = SleepPhysionet(
    subject_ids=subject_ids, recording_ids=[1, 2], crop_wake_mins=30
)

Preprocessing#

Next, we preprocess the raw data. We convert the data to microvolts and apply a lowpass filter.

from numpy import multiply

from braindecode.preprocessing import Preprocessor, preprocess

high_cut_hz = 30
# Factor to convert from V to uV
factor = 1e6

preprocessors = [
    Preprocessor(
        lambda data: multiply(data, factor), apply_on_array=True
    ),  # Convert from V to uV
    Preprocessor("filter", l_freq=None, h_freq=high_cut_hz),
]

# Transform the data
preprocess(dataset, preprocessors)
BaseConcatDataset
TypeBaseConcatDataset of RawDataset
Recordings12
Total samples37269012
Sfreq*100.0 Hz
Channels*2 (2 EEG)
Ch. names*Fpz-Cz, Pz-Oz
Duration*25080.0 s
* from first recording
Description12 recordings × 2 columns [subject, recording]


Extract windows#

We extract 30-s windows to be used in the classification task. The braindecode.models.AttnSleep model takes a single channel as input. Here, the Fpz-Cz channel is used as it was found to give better performance than using the Pz-Oz channel

from braindecode.preprocessing import create_windows_from_events

mapping = {  # We merge stages 3 and 4 following AASM standards.
    "Sleep stage W": 0,
    "Sleep stage 1": 1,
    "Sleep stage 2": 2,
    "Sleep stage 3": 3,
    "Sleep stage 4": 3,
    "Sleep stage R": 4,
}

window_size_s = 30
sfreq = 100
window_size_samples = window_size_s * sfreq

windows_dataset = create_windows_from_events(
    dataset,
    trial_start_offset_samples=0,
    trial_stop_offset_samples=0,
    window_size_samples=window_size_samples,
    window_stride_samples=window_size_samples,
    picks="Fpz-Cz",  # the other option is Pz-Oz,
    preload=True,
    mapping=mapping,
)

Window preprocessing#

We also preprocess the windows by applying channel-wise z-score normalization in each window.

from sklearn.preprocessing import scale as standard_scale

preprocess(windows_dataset, [Preprocessor(standard_scale, channel_wise=True)])
BaseConcatDataset
TypeBaseConcatDataset of WindowsDataset
Recordings12
Total samples12426
Sfreq*100.0 Hz
Channels*1 (1 EEG)
Ch. names*Fpz-Cz
* from first recording
Description12 recordings × 2 columns [subject, recording]
Window3000 samples (30.000 s)
Targets5 unique ({0: 1953, 1: 1163, 2: 5644, 3: 1516, 4: 2150})


Split dataset into train and valid#

We split the dataset into training and validation sets. Subjects 0-3 are used for training and subjects 4-5 for held-out validation.

Create sequence samplers#

Following the time distributed approach of [2], we need to provide our neural network with sequences of windows, such that the embeddings of multiple consecutive windows can be concatenated and provided to a final classifier. We can achieve this by defining Sampler objects that return sequences of window indices. To simplify the example, we train the whole model end-to-end on sequences, rather than using the two-step approach of [2] (i.e. training the feature extractor on single windows, then freezing its weights and training the classifier).

from braindecode.samplers import SequenceSampler

n_windows = 3  # Sequences of 3 consecutive windows
n_windows_stride = 3  # Maximally overlapping sequences

train_sampler = SequenceSampler(
    train_set.get_metadata(), n_windows, n_windows_stride, randomize=True
)
valid_sampler = SequenceSampler(valid_set.get_metadata(), n_windows, n_windows_stride)

# Print number of examples per class
print("Training examples: ", len(train_sampler))
print("Validation examples: ", len(valid_sampler))
Training examples:  1397
Validation examples:  676

We also implement a transform to extract the label of the center window of a sequence to use it as target.

import numpy as np


# Use label of center window in the sequence
def get_center_label(x):
    if isinstance(x, Integral):
        return x
    return x[np.ceil(len(x) / 2).astype(int)] if len(x) > 1 else x


train_set.target_transform = get_center_label
valid_set.target_transform = get_center_label

Finally, since some sleep stages appear a lot more often than others (e.g. most of the night is spent in the N2 stage), the classes are imbalanced. To avoid overfitting on the more frequent classes, we compute weights that we will provide to the loss function when training.

from sklearn.utils import compute_class_weight

y_train = [train_set[idx][1] for idx in train_sampler]
class_weights = compute_class_weight("balanced", classes=np.unique(y_train), y=y_train)

Create model#

We can now create the deep learning model. In this tutorial, we use the sleep staging architecture introduced in [1], which is an attention-based neural network. We use the time distributed version of the model, where the feature vectors of a sequence of windows are concatenated and passed to a linear layer for classification.

import torch
from torch import nn

from braindecode.models import AttnSleep
from braindecode.modules import TimeDistributed
from braindecode.util import set_random_seeds

cuda = torch.cuda.is_available()  # check if CUDA is available
mps = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
device = "cuda" if cuda else "mps" if mps else "cpu"
if cuda:
    torch.backends.cudnn.benchmark = True
# Set random seed to be able to reproduce results
set_random_seeds(seed=31, cuda=cuda)

n_classes = 5
# Extract number of channels and time steps from dataset
n_channels, input_size_samples = train_set[0][0].shape

feat_extractor = AttnSleep(
    sfreq=sfreq,
    n_outputs=n_classes,
    n_times=input_size_samples,
    drop_prob=0.3,
    return_feats=True,
)

model = nn.Sequential(
    TimeDistributed(feat_extractor),  # apply model on each 30-s window
    nn.Sequential(  # apply linear layer on concatenated feature vectors
        nn.Flatten(start_dim=1),
        nn.Dropout(0.5),
        nn.Linear(feat_extractor.len_last_layer * n_windows, n_classes),
    ),
)

# Send model to the selected accelerator
if device != "cpu":
    model.to(device)

Training#

We can now train our network. braindecode.EEGClassifier is a braindecode object that is responsible for managing the training of neural networks. It inherits from skorch.NeuralNetClassifier, so the training logic is the same as in Skorch.

from skorch.callbacks import (
    EarlyStopping,
    EpochScoring,
    GradientNormClipping,
    LRScheduler,
)
from skorch.helper import predefined_split

from braindecode import EEGClassifier

lr = 1e-3
batch_size = 32
n_epochs = 3

train_bal_acc = EpochScoring(
    scoring="balanced_accuracy",
    on_train=True,
    name="train_bal_acc",
    lower_is_better=False,
)
valid_bal_acc = EpochScoring(
    scoring="balanced_accuracy",
    on_train=False,
    name="valid_bal_acc",
    lower_is_better=False,
)
callbacks = [
    ("train_bal_acc", train_bal_acc),
    ("valid_bal_acc", valid_bal_acc),
    ("lr_scheduler", LRScheduler("CosineAnnealingLR", T_max=max(1, n_epochs - 1))),
    ("grad_clip", GradientNormClipping(gradient_clip_value=1.0)),
    (
        "early_stopping",
        EarlyStopping(
            monitor="valid_bal_acc",
            lower_is_better=False,
            patience=20,
            load_best=True,
        ),
    ),
]

clf = EEGClassifier(
    model,
    criterion=torch.nn.CrossEntropyLoss,
    criterion__weight=torch.Tensor(class_weights).to(device),
    criterion__label_smoothing=0.1,
    optimizer=torch.optim.Adam,
    iterator_train__shuffle=False,
    iterator_train__sampler=train_sampler,
    iterator_valid__sampler=valid_sampler,
    train_split=predefined_split(valid_set),  # using valid_set for validation
    optimizer__lr=lr,
    optimizer__weight_decay=1e-3,
    batch_size=batch_size,
    callbacks=callbacks,
    device=device,
    classes=np.unique(y_train),
)
# Model training for a specified number of epochs. ``y`` is ``None`` as it is already
# supplied in the dataset.
clf.fit(train_set, y=None, epochs=n_epochs)
  epoch    train_bal_acc    train_loss    valid_acc    valid_bal_acc    valid_loss      lr      dur
-------  ---------------  ------------  -----------  ---------------  ------------  ------  -------
      1           0.2808        1.9848       0.3506           0.4564        2.2825  0.0010  21.7782
      2           0.5657        1.4181       0.5695           0.5444        1.7081  0.0005  21.4078
      3           0.6111        1.3269       0.5577           0.5644        1.6578  0.0000  21.4652
<class 'braindecode.classifier.EEGClassifier'>[initialized](
  module_=Sequential(
    (0): TimeDistributed(
      (module): AttnSleep(
        (feature_extractor): Sequential(
          (0): _MRCNN(
            (GELU): GELU(approximate='none')
            (features1): Sequential(
              (0): Conv1d(1, 64, kernel_size=(50,), stride=(6,), padding=(24,), bias=False)
              (1): BatchNorm1d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
              (2): GELU(approximate='none')
              (3): MaxPool1d(kernel_size=8, stride=2, padding=4, dilation=1, ceil_mode=False)
              (4): Dropout(p=0.5, inplace=False)
              (5): Conv1d(64, 128, kernel_size=(8,), stride=(1,), padding=(4,), bias=False)
              (6): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
              (7): GELU(approximate='none')
              (8): Conv1d(128, 128, kernel_size=(8,), stride=(1,), padding=(4,), bias=False)
              (9): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
              (10): GELU(approximate='none')
              (11): MaxPool1d(kernel_size=4, stride=4, padding=2, dilation=1, ceil_mode=False)
            )
            (features2): Sequential(
              (0): Conv1d(1, 64, kernel_size=(400,), stride=(50,), padding=(200,), bias=False)
              (1): BatchNorm1d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
              (2): GELU(approximate='none')
              (3): MaxPool1d(kernel_size=4, stride=2, padding=2, dilation=1, ceil_mode=False)
              (4): Dropout(p=0.5, inplace=False)
              (5): Conv1d(64, 128, kernel_size=(7,), stride=(1,), padding=(3,), bias=False)
              (6): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
              (7): GELU(approximate='none')
              (8): Conv1d(128, 128, kernel_size=(7,), stride=(1,), padding=(3,), bias=False)
              (9): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
              (10): GELU(approximate='none')
              (11): MaxPool1d(kernel_size=2, stride=2, padding=1, dilation=1, ceil_mode=False)
            )
            (dropout): Dropout(p=0.5, inplace=False)
            (AFR): Sequential(
              (0): _SEBasicBlock(
                (conv1): Conv1d(128, 30, kernel_size=(1,), stride=(1,))
                (bn1): BatchNorm1d(30, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
                (relu): ReLU(inplace=True)
                (conv2): Conv1d(30, 30, kernel_size=(1,), stride=(1,))
                (bn2): BatchNorm1d(30, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
                (se): _SELayer(
                  (avg_pool): AdaptiveAvgPool1d(output_size=1)
                  (fc): Sequential(
                    (0): Linear(in_features=30, out_features=1, bias=False)
                    (1): ReLU(inplace=True)
                    (2): Linear(in_features=1, out_features=30, bias=False)
                    (3): Sigmoid()
                  )
                )
                (downsample): Sequential(
                  (0): Conv1d(128, 30, kernel_size=(1,), stride=(1,), bias=False)
                  (1): BatchNorm1d(30, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
                )
                (features): Sequential(
                  (0): Conv1d(128, 30, kernel_size=(1,), stride=(1,))
                  (1): BatchNorm1d(30, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
                  (2): ReLU(inplace=True)
                  (3): Conv1d(30, 30, kernel_size=(1,), stride=(1,))
                  (4): BatchNorm1d(30, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
                  (5): _SELayer(
                    (avg_pool): AdaptiveAvgPool1d(output_size=1)
                    (fc): Sequential(
                      (0): Linear(in_features=30, out_features=1, bias=False)
                      (1): ReLU(inplace=True)
                      (2): Linear(in_features=1, out_features=30, bias=False)
                      (3): Sigmoid()
                    )
                  )
                )
              )
            )
          )
          (1): _TCE(
            (layers): ModuleList(
              (0-1): 2 x _EncoderLayer(
                (self_attn): _MultiHeadedAttention(
                  (convs): ModuleList(
                    (0-2): 3 x CausalConv1d(30, 30, kernel_size=(7,), stride=(1,), padding=(6,))
                  )
                  (linear): Linear(in_features=80, out_features=80, bias=True)
                  (dropout): Dropout(p=0.1, inplace=False)
                )
                (feed_forward): _PositionwiseFeedForward(
                  (w_1): Linear(in_features=80, out_features=120, bias=True)
                  (w_2): Linear(in_features=120, out_features=80, bias=True)
                  (dropout): Dropout(p=0.3, inplace=False)
                  (activate): ReLU()
                )
                (residual_self_attn): _ResidualLayerNormAttn(
                  (norm): LayerNorm((80,), eps=1e-06, elementwise_affine=True, bias=True)
                  (dropout): Dropout(p=0.3, inplace=False)
                  (fn_attn): _MultiHeadedAttention(
                    (convs): ModuleList(
                      (0-2): 3 x CausalConv1d(30, 30, kernel_size=(7,), stride=(1,), padding=(6,))
                    )
                    (linear): Linear(in_features=80, out_features=80, bias=True)
                    (dropout): Dropout(p=0.1, inplace=False)
                  )
                )
                (residual_ff): _ResidualLayerNormFF(
                  (norm): LayerNorm((80,), eps=1e-06, elementwise_affine=True, bias=True)
                  (dropout): Dropout(p=0.3, inplace=False)
                  (fn_ff): _PositionwiseFeedForward(
                    (w_1): Linear(in_features=80, out_features=120, bias=True)
                    (w_2): Linear(in_features=120, out_features=80, bias=True)
                    (dropout): Dropout(p=0.3, inplace=False)
                    (activate): ReLU()
                  )
                )
                (conv): CausalConv1d(30, 30, kernel_size=(7,), stride=(1,), padding=(6,))
              )
            )
            (norm): LayerNorm((80,), eps=1e-06, elementwise_affine=True, bias=True)
          )
        )
      )
    )
    (1): Sequential(
      (0): Flatten(start_dim=1, end_dim=-1)
      (1): Dropout(p=0.5, inplace=False)
      (2): Linear(in_features=7200, out_features=5, bias=True)
    )
  ),
)
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.


Training for longer#

The gallery build above uses only n_epochs = 3. When trained offline for up to 100 epochs with early stopping, the same setup reaches 74.6 % balanced accuracy on the held-out recordings (chance = 20 %).

The interactive training dashboard is on Weights & Biases, and the offline trainer used to produce all tutorial checkpoints is available as a public gist.

We can load the pretrained checkpoint directly from the Hugging Face Hub and inspect the full training curves. This checkpoint uses params.pt (not safetensors) due to the TimeDistributed wrapper:

import warnings

repo_id = "braindecode/plot_sleep_staging_eldele2021"
try:
    from huggingface_hub import hf_hub_download

    clf.initialize()
    clf.load_params(
        f_params=hf_hub_download(repo_id, "params.pt"),
        f_history=hf_hub_download(repo_id, "history.json"),
        use_safetensors=False,
    )
except Exception as exc:
    warnings.warn(
        f"Could not load pretrained checkpoint from {repo_id} ({exc}); "
        "continuing with the locally trained short-run model.",
        stacklevel=2,
    )
Re-initializing module.
Re-initializing criterion because the following parameters were re-set: label_smoothing, weight.
Re-initializing optimizer.

Plot training curves#

The loaded history contains the full offline training run. We plot loss and balanced accuracy over all epochs.

import matplotlib.pyplot as plt
import pandas as pd

df = pd.DataFrame(clf.history.to_list())
df.index.name = "Epoch"
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 7), sharex=True)
df[["train_loss", "valid_loss"]].plot(color=["r", "b"], ax=ax1)
df[["train_bal_acc", "valid_bal_acc"]].plot(color=["r", "b"], ax=ax2)
ax1.set_ylabel("Loss")
ax2.set_ylabel("Balanced accuracy")
ax1.legend(["Train", "Valid"])
ax2.legend(["Train", "Valid"])
ax1.grid(alpha=0.3)
ax2.grid(alpha=0.3)
fig.tight_layout()
plt.show()
plot sleep staging eldele2021

Confusion matrix and classification report#

Using the pretrained weights we can evaluate on the held-out validation subjects and display the confusion matrix and per-class metrics.

from sklearn.metrics import ConfusionMatrixDisplay, classification_report

y_true = [valid_set[i][1] for i in valid_sampler]
y_pred = clf.predict(valid_set)

ConfusionMatrixDisplay.from_predictions(
    y_true,
    y_pred,
    labels=[0, 1, 2, 3, 4],
    display_labels=["Wake", "N1", "N2", "N3", "REM"],
)

print(classification_report(y_true, y_pred))
plot sleep staging eldele2021
              precision    recall  f1-score   support

           0       0.80      0.93      0.86        84
           1       0.43      0.39      0.41        89
           2       0.87      0.74      0.80       317
           3       0.58      0.91      0.71        69
           4       0.74      0.76      0.75       117

    accuracy                           0.74       676
   macro avg       0.68      0.75      0.71       676
weighted avg       0.75      0.74      0.74       676

Hypnogram#

Finally we overlay the predicted sleep stages on the expert annotations for one of the validation recordings.

fig, ax = plt.subplots(figsize=(15, 5))
ax.plot(y_true, color="b", label="Expert annotations")
ax.plot(y_pred.flatten(), color="r", label="Predicted", alpha=0.5)
ax.set_xlabel("Time (epochs)")
ax.set_ylabel("Sleep stage")
ax.legend()
plot sleep staging eldele2021
<matplotlib.legend.Legend object at 0x7f5f9ff5eb40>

References#

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

Estimated memory usage: 1513 MB

Gallery generated by Sphinx-Gallery