|
| 1 | +""" |
| 2 | +.. _ex-pcaobs: |
| 3 | +
|
| 4 | +===================================================================================== |
| 5 | +Principal Component Analysis - Optimal Basis Sets (PCA-OBS) removing cardiac artefact |
| 6 | +===================================================================================== |
| 7 | +
|
| 8 | +This script shows an example of how to use an adaptation of PCA-OBS |
| 9 | +:footcite:`NiazyEtAl2005`. PCA-OBS was originally designed to remove |
| 10 | +the ballistocardiographic artefact in simultaneous EEG-fMRI. Here, it |
| 11 | +has been adapted to remove the delay between the detected R-peak and the |
| 12 | +ballistocardiographic artefact such that the algorithm can be applied to |
| 13 | +remove the cardiac artefact in EEG (electroencephalography) and ESG |
| 14 | +(electrospinography) data. We will illustrate how it works by applying the |
| 15 | +algorithm to ESG data, where the effect of removal is most pronounced. |
| 16 | +
|
| 17 | +See: https://www.biorxiv.org/content/10.1101/2024.09.05.611423v1 |
| 18 | +for more details on the dataset and application for ESG data. |
| 19 | +
|
| 20 | +""" |
| 21 | + |
| 22 | +# Authors: Emma Bailey <bailey@cbs.mpg.de>, |
| 23 | +# Steinn Hauser Magnusson <hausersteinn@gmail.com> |
| 24 | +# License: BSD-3-Clause |
| 25 | +# Copyright the MNE-Python contributors. |
| 26 | + |
| 27 | +import glob |
| 28 | + |
| 29 | +import numpy as np |
| 30 | + |
| 31 | +# %% |
| 32 | +# Download sample subject data from OpenNeuro if you haven't already. |
| 33 | +# This will download simultaneous EEG and ESG data from a single run of a |
| 34 | +# single participant after median nerve stimulation of the left wrist. |
| 35 | +import openneuro |
| 36 | +from matplotlib import pyplot as plt |
| 37 | + |
| 38 | +import mne |
| 39 | +from mne import Epochs, events_from_annotations |
| 40 | +from mne.io import read_raw_eeglab |
| 41 | +from mne.preprocessing import find_ecg_events, fix_stim_artifact |
| 42 | + |
| 43 | +# add the path where you want the OpenNeuro data downloaded. Each run is ~2GB of data |
| 44 | +ds = "ds004388" |
| 45 | +target_dir = mne.datasets.default_path() / ds |
| 46 | +run_name = "sub-001/eeg/*median_run-03_eeg*.set" |
| 47 | +if not glob.glob(str(target_dir / run_name)): |
| 48 | + target_dir.mkdir(exist_ok=True) |
| 49 | + openneuro.download(dataset=ds, target_dir=target_dir, include=run_name[:-4]) |
| 50 | +block_files = glob.glob(str(target_dir / run_name)) |
| 51 | +assert len(block_files) == 1 |
| 52 | + |
| 53 | +# %% |
| 54 | +# Define the esg channels (arranged in two patches over the neck and lower back). |
| 55 | + |
| 56 | +esg_chans = [ |
| 57 | + "S35", |
| 58 | + "S24", |
| 59 | + "S36", |
| 60 | + "Iz", |
| 61 | + "S17", |
| 62 | + "S15", |
| 63 | + "S32", |
| 64 | + "S22", |
| 65 | + "S19", |
| 66 | + "S26", |
| 67 | + "S28", |
| 68 | + "S9", |
| 69 | + "S13", |
| 70 | + "S11", |
| 71 | + "S7", |
| 72 | + "SC1", |
| 73 | + "S4", |
| 74 | + "S18", |
| 75 | + "S8", |
| 76 | + "S31", |
| 77 | + "SC6", |
| 78 | + "S12", |
| 79 | + "S16", |
| 80 | + "S5", |
| 81 | + "S30", |
| 82 | + "S20", |
| 83 | + "S34", |
| 84 | + "S21", |
| 85 | + "S25", |
| 86 | + "L1", |
| 87 | + "S29", |
| 88 | + "S14", |
| 89 | + "S33", |
| 90 | + "S3", |
| 91 | + "L4", |
| 92 | + "S6", |
| 93 | + "S23", |
| 94 | +] |
| 95 | + |
| 96 | +# Interpolation window in seconds for ESG data to remove stimulation artefact |
| 97 | +tstart_esg = -7e-3 |
| 98 | +tmax_esg = 7e-3 |
| 99 | + |
| 100 | +# Define timing of heartbeat epochs in seconds relative to R-peaks |
| 101 | +iv_baseline = [-400e-3, -300e-3] |
| 102 | +iv_epoch = [-400e-3, 600e-3] |
| 103 | + |
| 104 | +# %% |
| 105 | +# Next, we perform minimal preprocessing including removing the |
| 106 | +# stimulation artefact, downsampling and filtering. |
| 107 | + |
| 108 | +raw = read_raw_eeglab(block_files[0], verbose="error") |
| 109 | +raw.set_channel_types(dict(ECG="ecg")) |
| 110 | +# Isolate the ESG channels (include the ECG channel for R-peak detection) |
| 111 | +raw.pick(esg_chans + ["ECG"]) |
| 112 | +# Trim duration and downsample (from 10kHz) to improve example speed |
| 113 | +raw.crop(0, 60).load_data().resample(2000) |
| 114 | + |
| 115 | +# Find trigger timings to remove the stimulation artefact |
| 116 | +events, event_dict = events_from_annotations(raw) |
| 117 | +trigger_name = "Median - Stimulation" |
| 118 | + |
| 119 | +fix_stim_artifact( |
| 120 | + raw, |
| 121 | + events=events, |
| 122 | + event_id=event_dict[trigger_name], |
| 123 | + tmin=tstart_esg, |
| 124 | + tmax=tmax_esg, |
| 125 | + mode="linear", |
| 126 | + stim_channel=None, |
| 127 | +) |
| 128 | + |
| 129 | +# %% |
| 130 | +# Find ECG events and add to the raw structure as event annotations. |
| 131 | + |
| 132 | +ecg_events, ch_ecg, average_pulse = find_ecg_events(raw, ch_name="ECG") |
| 133 | +ecg_event_samples = np.asarray( |
| 134 | + [[ecg_event[0] for ecg_event in ecg_events]] |
| 135 | +) # Samples only |
| 136 | + |
| 137 | +qrs_event_time = [ |
| 138 | + x / raw.info["sfreq"] for x in ecg_event_samples.reshape(-1) |
| 139 | +] # Divide by sampling rate to make times |
| 140 | +duration = np.repeat(0.0, len(ecg_event_samples)) |
| 141 | +description = ["qrs"] * len(ecg_event_samples) |
| 142 | + |
| 143 | +raw.annotations.append( |
| 144 | + qrs_event_time, duration, description, ch_names=[esg_chans] * len(qrs_event_time) |
| 145 | +) |
| 146 | + |
| 147 | +# %% |
| 148 | +# Create evoked response around the detected R-peaks |
| 149 | +# before and after cardiac artefact correction. |
| 150 | + |
| 151 | +events, event_ids = events_from_annotations(raw) |
| 152 | +event_id_dict = {key: value for key, value in event_ids.items() if key == "qrs"} |
| 153 | +epochs = Epochs( |
| 154 | + raw, |
| 155 | + events, |
| 156 | + event_id=event_id_dict, |
| 157 | + tmin=iv_epoch[0], |
| 158 | + tmax=iv_epoch[1], |
| 159 | + baseline=tuple(iv_baseline), |
| 160 | +) |
| 161 | +evoked_before = epochs.average() |
| 162 | + |
| 163 | +# Apply function - modifies the data in place. Optionally high-pass filter |
| 164 | +# the data before applying PCA-OBS to remove low frequency drifts |
| 165 | +raw = mne.preprocessing.apply_pca_obs( |
| 166 | + raw, picks=esg_chans, n_jobs=5, qrs_times=raw.times[ecg_event_samples.reshape(-1)] |
| 167 | +) |
| 168 | + |
| 169 | +epochs = Epochs( |
| 170 | + raw, |
| 171 | + events, |
| 172 | + event_id=event_id_dict, |
| 173 | + tmin=iv_epoch[0], |
| 174 | + tmax=iv_epoch[1], |
| 175 | + baseline=tuple(iv_baseline), |
| 176 | +) |
| 177 | +evoked_after = epochs.average() |
| 178 | + |
| 179 | +# %% |
| 180 | +# Compare evoked responses to assess completeness of artefact removal. |
| 181 | + |
| 182 | +fig, axes = plt.subplots(1, 1, layout="constrained") |
| 183 | +data_before = evoked_before.get_data(units=dict(eeg="uV")).T |
| 184 | +data_after = evoked_after.get_data(units=dict(eeg="uV")).T |
| 185 | +hs = list() |
| 186 | +hs.append(axes.plot(epochs.times, data_before, color="k")[0]) |
| 187 | +hs.append(axes.plot(epochs.times, data_after, color="green", label="after")[0]) |
| 188 | +axes.set(ylim=[-500, 1000], ylabel="Amplitude (µV)", xlabel="Time (s)") |
| 189 | +axes.set(title="ECG artefact removal using PCA-OBS") |
| 190 | +axes.legend(hs, ["before", "after"]) |
| 191 | +plt.show() |
| 192 | + |
| 193 | +# %% |
| 194 | +# References |
| 195 | +# ---------- |
| 196 | +# .. footbibliography:: |
0 commit comments