Skip to content

Commit 4f53a37

Browse files
steinnhauseremma-baileypre-commit-ci[bot]steinnhmlarsoner
authored
New feature for removing heart artifacts from EEG or ESG data using a Principal Component Analysis - Optimal Basis Sets (PCA-OBS) algorithm (#13037)
Co-authored-by: Emma Bailey <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Steinn Magnusson <[email protected]> Co-authored-by: Eric Larson <[email protected]> Co-authored-by: emma-bailey <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel McCloy <[email protected]>
1 parent 8b9fc97 commit 4f53a37

17 files changed

+706
-3
lines changed

Diff for: .circleci/config.yml

+8-1
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ jobs:
218218
- restore_cache:
219219
keys:
220220
- data-cache-phantom-kit
221+
- restore_cache:
222+
keys:
223+
- data-cache-ds004388
221224
- run:
222225
name: Get data
223226
# This limit could be increased, but this is helpful for finding slow ones
@@ -252,7 +255,7 @@ jobs:
252255
name: Check sphinx log for warnings (which are treated as errors)
253256
when: always
254257
command: |
255-
! grep "^.* (WARNING|ERROR): .*$" sphinx_log.txt
258+
! grep "^.*\(WARNING\|ERROR\): " sphinx_log.txt
256259
- run:
257260
name: Show profiling output
258261
when: always
@@ -393,6 +396,10 @@ jobs:
393396
key: data-cache-phantom-kit
394397
paths:
395398
- ~/mne_data/MNE-phantom-KIT-data # (1 G)
399+
- save_cache:
400+
key: data-cache-ds004388
401+
paths:
402+
- ~/mne_data/ds004388 # (1.8 G)
396403

397404

398405
linkcheck:

Diff for: doc/api/datasets.rst

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Datasets
1818
brainstorm.bst_auditory.data_path
1919
brainstorm.bst_resting.data_path
2020
brainstorm.bst_raw.data_path
21+
default_path
2122
eegbci.load_data
2223
eegbci.standardize
2324
fetch_aparc_sub_parcellation

Diff for: doc/api/preprocessing.rst

+1
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ Projections:
116116
read_ica_eeglab
117117
read_fine_calibration
118118
write_fine_calibration
119+
apply_pca_obs
119120

120121
:py:mod:`mne.preprocessing.nirs`:
121122

Diff for: doc/changes/devel/13037.newfeature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add PCA-OBS preprocessing for the removal of heart-artefacts from EEG or ESG datasets via :func:`mne.preprocessing.apply_pca_obs`, by :newcontrib:`Emma Bailey` and :newcontrib:`Steinn Hauser Magnusson`.

Diff for: doc/changes/names.inc

+2
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
.. _Eberhard Eich: https://github.com/ebeich
7474
.. _Eduard Ort: https://github.com/eort
7575
.. _Emily Stephen: https://github.com/emilyps14
76+
.. _Emma Bailey: https://www.cbs.mpg.de/employees/bailey
7677
.. _Enrico Varano: https://github.com/enricovara/
7778
.. _Enzo Altamiranda: https://www.linkedin.com/in/enzoalt
7879
.. _Eric Larson: https://larsoner.com
@@ -284,6 +285,7 @@
284285
.. _Stanislas Chambon: https://github.com/Slasnista
285286
.. _Stefan Appelhoff: https://stefanappelhoff.com
286287
.. _Stefan Repplinger: https://github.com/stfnrpplngr
288+
.. _Steinn Hauser Magnusson: https://github.com/steinnhauser
287289
.. _Steven Bethard: https://github.com/bethard
288290
.. _Steven Bierer: https://github.com/neurolaunch
289291
.. _Steven Gutstein: https://github.com/smgutstein

Diff for: doc/conf.py

+1
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@
355355
"n_frequencies",
356356
"n_tests",
357357
"n_samples",
358+
"n_peaks",
358359
"n_permutations",
359360
"nchan",
360361
"n_points",

Diff for: doc/references.bib

+10
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,16 @@ @inproceedings{NdiayeEtAl2016
13351335
year = {2016}
13361336
}
13371337

1338+
@article{NiazyEtAl2005,
1339+
author = {Niazy, R. K. and Beckmann, C.F. and Iannetti, G.D. and Brady, J. M. and Smith, S. M.},
1340+
title = {Removal of FMRI environment artifacts from EEG data using optimal basis sets},
1341+
journal = {NeuroImage},
1342+
year = {2005},
1343+
volume = {28},
1344+
pages = {720-737},
1345+
doi = {10.1016/j.neuroimage.2005.06.067.}
1346+
}
1347+
13381348
@article{NicholsHolmes2002,
13391349
author = {Nichols, Thomas E. and Holmes, Andrew P.},
13401350
doi = {10.1002/hbm.1058},
+196
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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 <[email protected]>,
23+
# Steinn Hauser Magnusson <[email protected]>
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::

Diff for: mne/datasets/__init__.pyi

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ __all__ = [
66
"epilepsy_ecog",
77
"erp_core",
88
"eyelink",
9+
"default_path",
910
"fetch_aparc_sub_parcellation",
1011
"fetch_dataset",
1112
"fetch_fsaverage",
@@ -70,6 +71,7 @@ from ._infant import fetch_infant_template
7071
from ._phantom.base import fetch_phantom
7172
from .utils import (
7273
_download_all_example_data,
74+
default_path,
7375
fetch_aparc_sub_parcellation,
7476
fetch_hcp_mmp_parcellation,
7577
has_dataset,

Diff for: mne/datasets/utils.py

+29-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# License: BSD-3-Clause
33
# Copyright the MNE-Python contributors.
44

5+
import glob
56
import importlib
67
import inspect
78
import logging
@@ -92,6 +93,22 @@ def _dataset_version(path, name):
9293
return version
9394

9495

96+
@verbose
97+
def default_path(*, verbose=None):
98+
"""Get the default MNE_DATA path.
99+
100+
Parameters
101+
----------
102+
%(verbose)s
103+
104+
Returns
105+
-------
106+
data_path : instance of Path
107+
Path to the default MNE_DATA directory.
108+
"""
109+
return _get_path(None, None, None)
110+
111+
95112
def _get_path(path, key, name):
96113
"""Get a dataset path."""
97114
# 1. Input
@@ -113,7 +130,8 @@ def _get_path(path, key, name):
113130
return path
114131
# 4. ~/mne_data (but use a fake home during testing so we don't
115132
# unnecessarily create ~/mne_data)
116-
logger.info(f"Using default location ~/mne_data for {name}...")
133+
extra = f" for {name}" if name else ""
134+
logger.info(f"Using default location ~/mne_data{extra}...")
117135
path = Path(os.getenv("_MNE_FAKE_HOME_DIR", "~")).expanduser() / "mne_data"
118136
if not path.is_dir():
119137
logger.info(f"Creating {path}")
@@ -319,6 +337,8 @@ def _download_all_example_data(verbose=True):
319337
#
320338
# verbose=True by default so we get nice status messages.
321339
# Consider adding datasets from here to CircleCI for PR-auto-build
340+
import openneuro
341+
322342
paths = dict()
323343
for kind in (
324344
"sample testing misc spm_face somato hf_sef multimodal "
@@ -375,6 +395,14 @@ def _download_all_example_data(verbose=True):
375395
limo.load_data(subject=1, update_path=True)
376396
logger.info("[done limo]")
377397

398+
# for ESG
399+
ds = "ds004388"
400+
target_dir = default_path() / ds
401+
run_name = "sub-001/eeg/*median_run-03_eeg*.set"
402+
if not glob.glob(str(target_dir / run_name)):
403+
target_dir.mkdir(exist_ok=True)
404+
openneuro.download(dataset=ds, target_dir=target_dir, include=run_name[:-4])
405+
378406

379407
@verbose
380408
def fetch_aparc_sub_parcellation(subjects_dir=None, verbose=None):

Diff for: mne/preprocessing/__init__.pyi

+2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ __all__ = [
4444
"realign_raw",
4545
"regress_artifact",
4646
"write_fine_calibration",
47+
"apply_pca_obs",
4748
]
4849
from . import eyetracking, ieeg, nirs
4950
from ._annotate_amplitude import annotate_amplitude
@@ -56,6 +57,7 @@ from ._fine_cal import (
5657
write_fine_calibration,
5758
)
5859
from ._lof import find_bad_channels_lof
60+
from ._pca_obs import apply_pca_obs
5961
from ._peak_finder import peak_finder
6062
from ._regress import EOGRegression, read_eog_regression, regress_artifact
6163
from .artifact_detection import (

0 commit comments

Comments
 (0)