PSG Preprocessing Pipeline

mrpheus provides a full PSG preprocessing pipeline through preprocess_psg(). This vignette walks through the standard workflow for a Sleep-EDF recording, then shows how to use the individual filter functions independently when you need finer control.

Note — All code blocks use eval = FALSE because they require a local EDF file. For a fully-executed walkthrough see the companion article: Preprocessing: Live Walkthrough


Data

The examples use SC4001E0-PSG.edf from the Sleep-EDF Cassette study (Kemp et al., 2000), a 22-hour ambulatory PSG of a healthy young adult. Freely available from PhysioNet under CC BY 1.0 (free account required).

# Download via MNE Python (handles authentication automatically)
import mne
files = mne.datasets.sleep_physionet.age.fetch_data(subjects=[0], recording=[1])
print(files[0])  # path to SC4001E0-PSG.edf

Standard pipeline

The typical workflow is four steps:

library(mrpheus)

# 1. Read EDF
rec <- read_edf("SC4001E0-PSG.edf")

# 2. Epoch and classify channels
psg <- prepare_psg(rec)
psg$channel_map

The Sleep-EDF file uses linked-ear channel names (EEG Fpz-Cz, EEG Pz-Oz). These are already matched by prepare_psg()’s default EEG pattern, but if your recording uses notation like C3-A2 you can pass a rename map at the preprocessing step:

# 3. Preprocess: rename, remove DC, notch, bandpass
psg_clean <- preprocess_psg(
  psg,
  channel_rename = c(
    "C3-A2" = "C3", "C4-A1" = "C4",
    "O1-A2" = "O1", "O2-A1" = "O2"
  )
)

preprocess_psg() does the following in order, on the full continuous signal before re-epoching (this avoids filter discontinuities at epoch boundaries):

Step Default behaviour
Channel renaming Optional; updates channel_map and all internal references
DC removal Subtracts per-channel mean
Powerline detection Compares PSD power at 50 and 60 Hz; picks the dominant one
Notch filter 2nd-order Butterworth bandstop at powerline frequency + harmonics
Bandpass filter EEG 0.3–35 Hz · EOG 0.3–15 Hz · EMG 10–99 Hz · ECG 0.5–40 Hz
# 4. Automatic AASM sleep staging on the cleaned PSG
stages <- stage_epochs(psg_clean)
head(stages)

Customising the pipeline

All parameters have sensible defaults but can be overridden:

psg_clean <- preprocess_psg(
  psg,
  powerline_freq  = 50L,          # skip auto-detection
  notch_harmonics = TRUE,         # also notch 100, 150 Hz, …
  notch_bw_hz     = 3,            # wider notch bandwidth
  eeg_bandpass    = c(0.5, 40),   # broader EEG range
  eog_bandpass    = c(0.3, 15),
  emg_bandpass    = c(10, 99),
  ecg_bandpass    = c(0.5, 40),
  dc              = TRUE,
  verbose         = TRUE
)

To skip channel-type-specific filtering entirely, set a bandpass to the full range for that type:

# Keep raw EMG (no bandpass)
psg_clean <- preprocess_psg(psg, emg_bandpass = c(0, Inf))

Using the signal-level functions independently

Each step in preprocess_psg() delegates to an exported function that works on a plain numeric vector. These are useful when:

  • You want to filter a single channel without building a full PSG object.
  • You are building your own pipeline outside the mrpheus_psg structure.
  • You need to apply filtering to signals loaded from another source (e.g. CSV, MATLAB .mat, or a custom reader).

DC removal

eeg <- rec$signals[["EEG Fpz-Cz"]]$signal
eeg <- remove_dc(eeg)

Powerline detection

sr   <- rec$channels$sample_rate[rec$channels$label == "EEG Fpz-Cz"]
freq <- detect_powerline(eeg, sr)
#> ℹ Detected powerline frequency: 50 Hz

Notch filtering

# Remove 50 Hz and harmonics (100, 150 Hz, …)
eeg_notched <- notch_filter(eeg, sr = sr, freq = 50)

# Remove 60 Hz only, no harmonics
eeg_notched <- notch_filter(eeg, sr = sr, freq = 60, harmonics = FALSE)

# Wider notch bandwidth (default is 2 Hz)
eeg_notched <- notch_filter(eeg, sr = sr, freq = 50, bandwidth_hz = 4)

Bandpass filtering

# Standard EEG passband
eeg_bp <- bandpass_filter(eeg, sr = sr, low_hz = 0.3, high_hz = 35)

# High-pass only (baseline drift removal) — set high_hz beyond Nyquist
eeg_hp <- bandpass_filter(eeg, sr = sr, low_hz = 0.1, high_hz = Inf)

# Low-pass only — set low_hz to 0
eeg_lp <- bandpass_filter(eeg, sr = sr, low_hz = 0, high_hz = 35)

A note on filter design

The notch and bandpass filters both use zero-phase IIR (Butterworth) designs applied via gsignal::filtfilt():

  • Notch: 2nd-order Butterworth bandstop (≈9 coefficients). Safe on long recordings — no memory issues.
  • Bandpass: 4th-order Butterworth. The high_hz cutoff is automatically clamped to 99 % of the Nyquist frequency to prevent instability.

Zero-phase (filtfilt) filtering introduces no phase distortion but doubles the effective filter order. If you prefer a causal (single-pass) filter for any reason, use gsignal::filter() directly with the same Butterworth coefficients.

The staging pipeline (stage_epochs()) applies its own separate MNE-matched FIR bandpass internally — preprocess_psg() filtering and staging filtering are independent.


EOG artefact correction

Ocular artefacts (blinks, saccades, slow drift) contaminate EEG channels through volume conduction from the eyes. mrpheus provides two correction methods, both operating on the continuous signal and returning a new mrpheus_psg with cleaned epochs.

Regression-based correction

correct_eog_regression() regresses each EEG channel on the EOG reference channels and replaces the EEG with the residuals. This removes both transient blinks and slow ocular drift without requiring blink event detection.

psg_eog <- correct_eog_regression(psg_clean)

This is the recommended method when:

  • You have one or two EOG channels and want a fast, deterministic result.
  • The recording has relatively few EEG channels (fewer than 4).
  • Slow drift contamination is a concern alongside blinks.

You can restrict which channels are used:

psg_eog <- correct_eog_regression(
  psg_clean,
  eog_channels = c("EOG LOC", "EOG ROC"),
  eeg_channels = c("EEG C3", "EEG C4", "EEG O1", "EEG O2")
)

ICA-based correction

correct_eog_ica() decomposes the EEG into independent components via FastICA, identifies those whose time courses correlate with the EOG channels above a threshold, and subtracts their contribution.

psg_eog <- correct_eog_ica(psg_clean)

Key parameters:

psg_eog <- correct_eog_ica(
  psg_clean,
  n_components = 6L,    # default: min(6, n_eeg_channels)
  threshold    = 0.35   # default: Pearson |r| above which a component is flagged
)

ICA requires at least 2 EEG channels. For recordings with fewer than 3–4 EEG channels, regression is more reliable. The threshold controls sensitivity: lower values flag more components (more aggressive removal, risk of removing genuine signal); higher values are more conservative.

Which method to choose?

Regression ICA
Minimum EEG channels 1 2 (3+ recommended)
Removes slow drift Yes Partial
Removes blink transients Yes Yes
Deterministic Yes No (seed-dependent)
Computation Fast Slower

For a standard overnight PSG with 4+ EEG channels, ICA generally produces cleaner results. For a 2-channel ambulatory recording, use regression.

Full pipeline

Putting it all together:

rec     <- read_edf("SC4001E0-PSG.edf")
psg     <- prepare_psg(rec)
psg     <- preprocess_psg(psg)
psg     <- correct_eog_regression(psg)   # or correct_eog_ica()
stages  <- stage_epochs(psg)

References

Kemp B, Zwinderman A, Tuk B, Kamphuisen H, Oberyé J (2000). Analysis of a sleep-dependent neuronal feedback loop: the slow-wave microcontinuity of the EEG. IEEE Transactions on Biomedical Engineering, 47(9), 1185–1194.