diff --git a/doc/changes/dev/13710.newfeature.rst b/doc/changes/dev/13710.newfeature.rst new file mode 100644 index 00000000000..7c6e73283f0 --- /dev/null +++ b/doc/changes/dev/13710.newfeature.rst @@ -0,0 +1 @@ +Add a preprocessing example showing how to explore epoch quality before rejection using robust statistics (peak-to-peak amplitude, variance, and kurtosis) inspired by FASTER (Nolan et al., 2010) and Delorme et al. (2007), by `Aman Srivastava`_. \ No newline at end of file diff --git a/doc/references.bib b/doc/references.bib index 12d63458bd1..c7eefd9e808 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -25,6 +25,27 @@ @article{GramfortEtAl2013a number = {267} } % everything else +@article{NolanEtAl2010, + author = {Nolan, H. and Whelan, R. and Reilly, R. B.}, + title = {{FASTER}: Fully Automated Statistical Thresholding for {EEG} artifact Rejection}, + journal = {Journal of Neuroscience Methods}, + year = {2010}, + volume = {192}, + number = {1}, + pages = {152--162}, + doi = {10.1016/j.jneumeth.2010.07.015}, +} + +@article{DelormeEtAl2007, + author = {Delorme, A. and Sejnowski, T. and Makeig, S.}, + title = {Enhanced detection of artifacts in {EEG} data using higher-order statistics and independent component analysis}, + journal = {NeuroImage}, + year = {2007}, + volume = {34}, + number = {4}, + pages = {1443--1449}, + doi = {10.1016/j.neuroimage.2006.11.004}, +} @article{AblinEtAl2018, author = {Ablin, Pierre and Cardoso, Jean-Francois and Gramfort, Alexandre}, doi = {10.1109/TSP.2018.2844203}, diff --git a/examples/preprocessing/plot_epoch_quality.py b/examples/preprocessing/plot_epoch_quality.py new file mode 100644 index 00000000000..d37644edbfd --- /dev/null +++ b/examples/preprocessing/plot_epoch_quality.py @@ -0,0 +1,106 @@ +""" +.. _ex-epoch-quality: + +===================================== +Exploring epoch quality before rejection +===================================== + +Before rejecting epochs with :meth:`mne.Epochs.drop_bad`, it can be useful +to get a sense of which epochs are the most likely artifacts. This example +shows how to compute simple per-epoch statistics — peak-to-peak amplitude, +variance, and kurtosis — and use them to rank epochs by their outlier score. + +The approach is inspired by established methods in the EEG artifact detection +literature, namely FASTER :footcite:t:`NolanEtAl2010` and +:footcite:t:`DelormeEtAl2007`, both of which use z-scored kurtosis and +variance across epochs to flag bad trials. +""" +# Authors: Aman Srivastava +# +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +# %% +import matplotlib.pyplot as plt +import numpy as np + +import mne +from mne.datasets import sample + +print(__doc__) + +data_path = sample.data_path() + +# %% +# Load the sample dataset and create epochs +meg_path = data_path / "MEG" / "sample" +raw_fname = meg_path / "sample_audvis_filt-0-40_raw.fif" + +raw = mne.io.read_raw_fif(raw_fname, preload=True) +events = mne.find_events(raw, "STI 014") + +event_id = {"auditory/left": 1, "auditory/right": 2} +tmin, tmax = -0.2, 0.5 +picks = mne.pick_types(raw.info, meg="grad", eeg=False) + +epochs = mne.Epochs( + raw, events, event_id, tmin, tmax, picks=picks, preload=True, baseline=(None, 0) +) + +# %% +# Compute per-epoch statistics +# We compute three features for each epoch: +# - Peak-to-peak amplitude (sensitive to large jumps) +# - Variance (sensitive to sustained high-amplitude noise) +# - Kurtosis (sensitive to spike artifacts) +# +# Each feature is z-scored robustly using median absolute deviation (MAD) +# across epochs, then averaged into a single outlier score per epoch. + +data = epochs.get_data() # (n_epochs, n_channels, n_times) + +# Feature 1: peak-to-peak +ptp = np.ptp(data, axis=-1).mean(axis=-1) + +# Feature 2: variance +var = data.var(axis=-1).mean(axis=-1) + +# Feature 3: kurtosis +from scipy.stats import kurtosis # noqa: E402 + +kurt = np.array([kurtosis(data[i].ravel()) for i in range(len(data))]) + +# Robust z-score using MAD +features = np.column_stack([ptp, var, kurt]) +median = np.median(features, axis=0) +mad = np.median(np.abs(features - median), axis=0) + 1e-10 +z = np.abs((features - median) / mad) + +# Normalize to [0, 1] +raw_score = z.mean(axis=-1) +scores = (raw_score - raw_score.min()) / (raw_score.max() - raw_score.min() + 1e-10) + +# %% +# Plot the scores ranked from cleanest to noisiest +fig, ax = plt.subplots(layout="constrained") +sorted_idx = np.argsort(scores) +ax.bar(np.arange(len(scores)), scores[sorted_idx], color="steelblue") +ax.axhline(0.8, color="red", linestyle="--", label="Example threshold (0.8)") +ax.set( + xlabel="Epoch (sorted by score)", + ylabel="Outlier score", + title="Epoch quality scores (0 = clean, 1 = likely artifact)", +) +ax.legend() + +# %% +# Inspect the worst epochs +# Epochs scoring above 0.8 are worth inspecting manually +bad_epochs = np.where(scores > 0.8)[0] +print(f"Epochs worth inspecting: {bad_epochs}") +print(f"That's {len(bad_epochs)} out of {len(epochs)} total epochs") + +# %% +# References +# ---------- +# .. footbibliography::