Repository: mne-tools/mne-python

Issue:
Adding Cortical Signal Supression (CSS) to MNE-python
Add the cortical signal suppression (CSS) method as presented here: https://link.springer.com/article/10.1007/s10548-018-00694-5 

This is a method for studying subcortical structures, or parts of cortex in the medial surface of the cerebral hemispheres that are far away from the sensors. Based on the raw data, it removes the part of the signal subspace that comes from cortical sources while sparing subcortical data. Could be useful as a function on a raw object in the preprocessing stage.


Discussion:
Sounds good to me
@johnsam7 don't hesitate to open a PR earlier than later so we can discuss on github how to proceed

Resolving patch:
diff --git a/examples/preprocessing/css.py b/examples/preprocessing/css.py
new file mode 100644
--- /dev/null
+++ b/examples/preprocessing/css.py
@@ -0,0 +1,95 @@
+"""
+=================================================================
+Cortical Signal Suppression (CSS) for removal of cortical signals
+=================================================================
+
+This script shows an example of how to use CSS
+:footcite:`Samuelsson2019` . CSS suppresses the cortical contribution
+to the signal subspace in EEG data using MEG data, facilitating
+detection of subcortical signals. We will illustrate how it works by
+simulating one cortical and one subcortical oscillation at different
+frequencies; 40 Hz and 239 Hz for cortical and subcortical activity,
+respectively, then process it with CSS and look at the power spectral
+density of the raw and processed data.
+
+"""
+# Author: John G Samuelsson <johnsam@mit.edu>
+
+import numpy as np
+import matplotlib.pyplot as plt
+
+import mne
+from mne.datasets import sample
+from mne.simulation import simulate_sparse_stc, simulate_evoked
+
+###############################################################################
+# Load sample subject data
+data_path = sample.data_path()
+subjects_dir = data_path + '/subjects'
+fwd_fname = data_path + '/MEG/sample/sample_audvis-meg-eeg-oct-6-fwd.fif'
+ave_fname = data_path + '/MEG/sample/sample_audvis-no-filter-ave.fif'
+cov_fname = data_path + '/MEG/sample/sample_audvis-cov.fif'
+trans_fname = data_path + '/MEG/sample/sample_audvis_raw-trans.fif'
+bem_fname = subjects_dir + '/sample' + '/bem' + '/sample-5120-bem-sol.fif'
+
+raw = mne.io.read_raw_fif(data_path + '/MEG/sample/sample_audvis_raw.fif')
+fwd = mne.read_forward_solution(fwd_fname)
+fwd = mne.convert_forward_solution(fwd, force_fixed=True, surf_ori=True)
+fwd = mne.pick_types_forward(fwd, meg=True, eeg=True, exclude=raw.info['bads'])
+cov = mne.read_cov(cov_fname)
+
+###############################################################################
+# Find patches (labels) to activate
+all_labels = mne.read_labels_from_annot(subject='sample',
+                                        subjects_dir=subjects_dir)
+labels = []
+for select_label in ['parahippocampal-lh', 'postcentral-rh']:
+    labels.append([lab for lab in all_labels if lab.name in select_label][0])
+hiplab, postcenlab = labels
+
+###############################################################################
+# Simulate one cortical dipole (40 Hz) and one subcortical (239 Hz)
+
+
+def cortical_waveform(times):
+    """Create a cortical waveform."""
+    return 10e-9 * np.cos(times * 2 * np.pi * 40)
+
+
+def subcortical_waveform(times):
+    """Create a subcortical waveform."""
+    return 10e-9 * np.cos(times * 2 * np.pi * 239)
+
+
+times = np.linspace(0, 0.5, int(0.5 * raw.info['sfreq']))
+stc = simulate_sparse_stc(fwd['src'], n_dipoles=2, times=times,
+                          location='center', subjects_dir=subjects_dir,
+                          labels=[postcenlab, hiplab],
+                          data_fun=cortical_waveform)
+stc.data[np.where(np.isin(stc.vertices[0], hiplab.vertices))[0], :] = \
+    subcortical_waveform(times)
+evoked = simulate_evoked(fwd, stc, raw.info, cov, nave=15)
+
+###############################################################################
+# Process with CSS and plot PSD of EEG data before and after processing
+evoked_subcortical = mne.preprocessing.cortical_signal_suppression(evoked,
+                                                                   n_proj=6)
+chs = mne.pick_types(evoked.info, meg=False, eeg=True)
+
+psd = np.mean(np.abs(np.fft.rfft(evoked.data))**2, axis=0)
+psd_proc = np.mean(np.abs(np.fft.rfft(evoked_subcortical.data))**2, axis=0)
+freq = np.arange(0, stop=int(evoked.info['sfreq'] / 2),
+                 step=evoked.info['sfreq'] / (2 * len(psd)))
+
+fig, ax = plt.subplots()
+ax.plot(freq, psd, label='raw')
+ax.plot(freq, psd_proc, label='processed')
+ax.text(.2, .7, 'cortical', transform=ax.transAxes)
+ax.text(.8, .25, 'subcortical', transform=ax.transAxes)
+ax.set(ylabel='EEG Power spectral density', xlabel='Frequency (Hz)')
+ax.legend()
+
+# References
+# ^^^^^^^^^^
+#
+# .. footbibliography::
diff --git a/mne/preprocessing/__init__.py b/mne/preprocessing/__init__.py
--- a/mne/preprocessing/__init__.py
+++ b/mne/preprocessing/__init__.py
@@ -32,3 +32,4 @@
 from .annotate_nan import annotate_nan
 from .interpolate import equalize_bads
 from . import ieeg
+from ._css import cortical_signal_suppression
diff --git a/mne/preprocessing/_css.py b/mne/preprocessing/_css.py
new file mode 100644
--- /dev/null
+++ b/mne/preprocessing/_css.py
@@ -0,0 +1,91 @@
+# Author: John Samuelsson <johnsam@mit.edu>
+
+import numpy as np
+
+from ..evoked import Evoked
+from ..io.pick import _picks_to_idx
+from ..utils import verbose, _validate_type, _ensure_int
+
+
+def _temp_proj(ref_2, ref_1, raw_data, n_proj=6):
+    # Orthonormalize gradiometer and magnetometer data by a QR decomposition
+    ref_1_orth = np.linalg.qr(ref_1.T)[0]
+    ref_2_orth = np.linalg.qr(ref_2.T)[0]
+
+    # Calculate cross-correlation
+    cross_corr = np.dot(ref_1_orth.T, ref_2_orth)
+
+    # Channel weights for common temporal subspace by SVD of cross-correlation
+    ref_1_ch_weights, _, _ = np.linalg.svd(cross_corr)
+
+    # Get temporal signals from channel weights
+    proj_mat = ref_1_orth @ ref_1_ch_weights
+
+    # Project out common subspace
+    filtered_data = raw_data
+    proj_vec = proj_mat[:, :n_proj]
+    weights = filtered_data @ proj_vec
+    filtered_data -= weights @ proj_vec.T
+
+
+@verbose
+def cortical_signal_suppression(evoked, picks=None, mag_picks=None,
+                                grad_picks=None, n_proj=6, *, verbose=None):
+    """Apply cortical signal suppression (CSS) to evoked data.
+
+    Parameters
+    ----------
+    evoked : instance of Evoked
+        The evoked object to use for CSS. Must contain magnetometer,
+        gradiometer, and EEG channels.
+    %(picks_good_data)s
+    mag_picks : array-like of int
+        Array of the magnetometer channel indices that will be used to find
+        the reference data. If None (default), all magnetometers will
+        be used.
+    grad_picks : array-like of int
+        Array of the gradiometer channel indices that will be used to find
+        the reference data. If None (default), all gradiometers will
+        be used.
+    n_proj : int
+        The number of projection vectors.
+    %(verbose)s
+
+    Returns
+    -------
+    evoked_subcortical : instance of Evoked
+        The evoked object with cortical contributions to the EEG data
+        suppressed.
+
+    Notes
+    -----
+    This method removes the common signal subspace between the magnetometer
+    data and the gradiometer data from the EEG data. This is done by a temporal
+    projection using ``n_proj`` number of projection vectors. For reference,
+    see :footcite:`Samuelsson2019`.
+
+    References
+    ----------
+    .. footbibliography::
+    """
+    _validate_type(evoked, Evoked, 'evoked')
+    n_proj = _ensure_int(n_proj, 'n_proj')
+    picks = _picks_to_idx(
+        evoked.info, picks, none='data', exclude='bads')
+    mag_picks = _picks_to_idx(
+        evoked.info, mag_picks, none='mag', exclude='bads')
+    grad_picks = _picks_to_idx(
+        evoked.info, grad_picks, none='grad', exclude='bads')
+    evoked_subcortical = evoked.copy()
+
+    # Get data
+    all_data = evoked.data
+    mag_data = all_data[mag_picks]
+    grad_data = all_data[grad_picks]
+
+    # Process data with temporal projection algorithm
+    data = all_data[picks]
+    _temp_proj(mag_data, grad_data, data, n_proj=n_proj)
+    evoked_subcortical.data[picks, :] = data
+
+    return evoked_subcortical


Test patch:
diff --git a/mne/preprocessing/tests/test_css.py b/mne/preprocessing/tests/test_css.py
new file mode 100644
--- /dev/null
+++ b/mne/preprocessing/tests/test_css.py
@@ -0,0 +1,37 @@
+# Author: John G Samuelsson <johnsam@mit.edu>
+
+import numpy as np
+
+from mne.preprocessing._css import cortical_signal_suppression
+from mne import pick_types, read_evokeds
+from mne.datasets import testing
+
+data_path = testing.data_path(download=False)
+fname_evoked = data_path + '/MEG/sample/sample_audvis-ave.fif'
+
+
+@testing.requires_testing_data
+def test_cortical_signal_suppression():
+    """Test that CSS is dampening the cortical signal and has right shape."""
+    ave = read_evokeds(fname_evoked)[0]
+    eeg_ind = pick_types(ave.info, eeg=True)
+    mag_ind = pick_types(ave.info, meg='mag')
+    grad_ind = pick_types(ave.info, meg='grad')
+    ave.data[mag_ind][0, :] = np.sin(2 * np.pi * 40 * ave.times) * \
+        np.mean(np.abs(ave.data[mag_ind][0, :]))
+    ave.data[mag_ind][1, :] = np.sin(2 * np.pi * 239 * ave.times) * \
+        np.mean(np.abs(ave.data[mag_ind][1, :]))
+    ave.data[grad_ind][0, :] = np.sin(2 * np.pi * 40 * ave.times) * \
+        np.mean(np.abs(ave.data[grad_ind][0, :]))
+    ave.data[eeg_ind][0, :] = np.sin(2 * np.pi * 40 * ave.times) * \
+        np.mean(np.abs(ave.data[eeg_ind][0, :]))
+    ave.data[eeg_ind][1, :] = np.sin(2 * np.pi * 239 * ave.times) * \
+        np.mean(np.abs(ave.data[eeg_ind][1, :]))
+    ave_f = cortical_signal_suppression(ave)
+    cort_power = np.sum(np.abs(ave.data[eeg_ind][0, :]))
+    deep_power = np.sum(np.abs(ave.data[eeg_ind][1, :]))
+    cort_power_f = np.sum(np.abs(ave_f.data[eeg_ind][0, :]))
+    deep_power_f = np.sum(np.abs(ave_f.data[eeg_ind][1, :]))
+    rel_SNR_gain = (deep_power_f / deep_power) / (cort_power_f / cort_power)
+    assert rel_SNR_gain > 0
+    assert ave_f.data.shape == ave.data.shape