--- title: "PSG Preprocessing Pipeline" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{PSG Preprocessing Pipeline} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = FALSE ) ``` **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](https://mrpheus.circadia-lab.uk/articles/preprocessing-demo.html) --- ## 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](https://physionet.org/content/sleep-edfx/1.0.0/) under CC BY 1.0 (free account required). ```python # 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: ```r 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: ```r # 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 | ```r # 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: ```r 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: ```r # 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 ```r eeg <- rec$signals[["EEG Fpz-Cz"]]$signal eeg <- remove_dc(eeg) ``` ### Powerline detection ```r sr <- rec$channels$sample_rate[rec$channels$label == "EEG Fpz-Cz"] freq <- detect_powerline(eeg, sr) #> ℹ Detected powerline frequency: 50 Hz ``` ### Notch filtering ```r # 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 ```r # 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. ```r 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: ```r 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. ```r psg_eog <- correct_eog_ica(psg_clean) ``` Key parameters: ```r 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: ```r 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.