Repository: mne-tools/mne-python

Issue:
ENH: Specify noise covariance rank
Allows specifying the ncov rank when computing inverse operators and also when whitening evoked responses.



Discussion:

Resolving patch:
diff --git a/mne/cov.py b/mne/cov.py
--- a/mne/cov.py
+++ b/mne/cov.py
@@ -464,7 +464,7 @@ def write_cov(fname, cov):
 ###############################################################################
 # Prepare for inverse modeling
 
-def rank(A, tol=1e-8):
+def _compute_rank(A, tol=1e-8):
     s = linalg.svd(A, compute_uv=0)
     return np.sum(np.where(s > s[0] * tol, 1, 0))
 
@@ -480,9 +480,12 @@ def _unpack_epochs(epochs):
 
 
 @verbose
-def _get_whitener(A, pca, ch_type, verbose=None):
+def _get_whitener(A, pca, ch_type, rank=None, verbose=None):
     # whitening operator
-    rnk = rank(A)
+    if rank is None:
+        rnk = _compute_rank(A)
+    else:
+        rnk = rank
     eig, eigvec = linalg.eigh(A, overwrite_a=True)
     eigvec = eigvec.T
     eig[:-rnk] = 0.0
@@ -498,7 +501,8 @@ def _get_whitener(A, pca, ch_type, verbose=None):
 
 
 @verbose
-def prepare_noise_cov(noise_cov, info, ch_names, verbose=None):
+def prepare_noise_cov(noise_cov, info, ch_names, rank=None,
+                      verbose=None):
     """Prepare noise covariance matrix
 
     Parameters
@@ -509,6 +513,11 @@ def prepare_noise_cov(noise_cov, info, ch_names, verbose=None):
         The measurement info (used to get channel types and bad channels).
     ch_names : list
         The channel names to be considered.
+    rank : None | int | dict
+        Specified rank of the noise covariance matrix. If None, the rank is
+        detected automatically. If int, the rank is specified for the MEG
+        channels. A dictionary with entries 'eeg' and/or 'meg' can be used
+        to specify the rank for each modality.
     verbose : bool, str, int, or None
         If not None, override default verbose level (see mne.verbose).
     """
@@ -537,13 +546,26 @@ def prepare_noise_cov(noise_cov, info, ch_names, verbose=None):
     has_meg = len(C_meg_idx) > 0
     has_eeg = len(C_eeg_idx) > 0
 
+    # Get the specified noise covariance rank
+    if rank is not None:
+        if isinstance(rank, dict):
+            rank_meg = rank.get('meg', None)
+            rank_eeg = rank.get('eeg', None)
+        else:
+            rank_meg = int(rank)
+            rank_eeg = None
+    else:
+        rank_meg, rank_eeg = None, None
+
     if has_meg:
         C_meg = C[C_meg_idx][:, C_meg_idx]
-        C_meg_eig, C_meg_eigvec = _get_whitener(C_meg, False, 'MEG')
+        C_meg_eig, C_meg_eigvec = _get_whitener(C_meg, False, 'MEG',
+                                                rank_meg)
 
     if has_eeg:
         C_eeg = C[C_eeg_idx][:, C_eeg_idx]
-        C_eeg_eig, C_eeg_eigvec = _get_whitener(C_eeg, False, 'EEG')
+        C_eeg_eig, C_eeg_eigvec = _get_whitener(C_eeg, False, 'EEG',
+                                                rank_eeg)
         if not _has_eeg_average_ref_proj(info['projs']):
             warnings.warn('No average EEG reference present in info["projs"], '
                           'covariance may be adversely affected. Consider '
@@ -679,7 +701,8 @@ def regularize(cov, info, mag=0.1, grad=0.1, eeg=0.1, exclude='bads',
     return cov
 
 
-def compute_whitener(noise_cov, info, picks=None, verbose=None):
+def compute_whitener(noise_cov, info, picks=None, rank=None,
+                     verbose=None):
     """Compute whitening matrix
 
     Parameters
@@ -691,6 +714,11 @@ def compute_whitener(noise_cov, info, picks=None, verbose=None):
     picks : array-like of int | None
         The channels indices to include. If None the data
         channels in info, except bad channels, are used.
+    rank : None | int | dict
+        Specified rank of the noise covariance matrix. If None, the rank is
+        detected automatically. If int, the rank is specified for the MEG
+        channels. A dictionary with entries 'eeg' and/or 'meg' can be used
+        to specify the rank for each modality.
     verbose : bool, str, int, or None
         If not None, override default verbose level (see mne.verbose).
 
@@ -708,7 +736,8 @@ def compute_whitener(noise_cov, info, picks=None, verbose=None):
     ch_names = [info['chs'][k]['ch_name'] for k in picks]
 
     noise_cov = cp.deepcopy(noise_cov)
-    noise_cov = prepare_noise_cov(noise_cov, info, ch_names)
+    noise_cov = prepare_noise_cov(noise_cov, info, ch_names,
+                                  rank=rank)
     n_chan = len(ch_names)
 
     W = np.zeros((n_chan, n_chan), dtype=np.float)
@@ -726,7 +755,7 @@ def compute_whitener(noise_cov, info, picks=None, verbose=None):
     return W, ch_names
 
 
-def whiten_evoked(evoked, noise_cov, picks, diag=False):
+def whiten_evoked(evoked, noise_cov, picks, diag=False, rank=None):
     """Whiten evoked data using given noise covariance
 
     Parameters
@@ -738,7 +767,12 @@ def whiten_evoked(evoked, noise_cov, picks, diag=False):
     picks : array-like of int
         The channel indices to whiten
     diag : bool
-        If True, whiten using only the diagonal of the covariance
+        If True, whiten using only the diagonal of the covariancei
+    rank : None | int | dict
+        Specified rank of the noise covariance matrix. If None, the rank is
+        detected automatically. If int, the rank is specified for the MEG
+        channels. A dictionary with entries 'eeg' and/or 'meg' can be used
+        to specify the rank for each modality.
 
     Returns
     -------
@@ -746,31 +780,16 @@ def whiten_evoked(evoked, noise_cov, picks, diag=False):
         The whitened evoked data.
     """
     ch_names = [evoked.ch_names[k] for k in picks]
-    n_chan = len(ch_names)
     evoked = cp.deepcopy(evoked)
-
+    noise_cov = pick_channels_cov(noise_cov, include=ch_names, exclude=[])
     if diag:
         noise_cov = cp.deepcopy(noise_cov)
         noise_cov['data'] = np.diag(np.diag(noise_cov['data']))
 
-    noise_cov = prepare_noise_cov(noise_cov, evoked.info, ch_names)
-
-    W = np.zeros((n_chan, n_chan), dtype=np.float)
-    #
-    #   Omit the zeroes due to projection
-    #
-    eig = noise_cov['eig']
-    nzero = (eig > 0)
-    W[nzero, nzero] = 1.0 / np.sqrt(eig[nzero])
-    #
-    #   Rows of eigvec are the eigenvectors
-    #
-    W = np.dot(W, noise_cov['eigvec'])
-    W = np.dot(noise_cov['eigvec'].T, W)
+    W, _ = compute_whitener(noise_cov, evoked.info, rank=rank)
     evoked.data[picks] = np.sqrt(evoked.nave) * np.dot(W, evoked.data[picks])
     return evoked
 
-
 @verbose
 def _read_cov(fid, node, cov_kind, verbose=None):
     """Read a noise covariance matrix"""
diff --git a/mne/minimum_norm/inverse.py b/mne/minimum_norm/inverse.py
--- a/mne/minimum_norm/inverse.py
+++ b/mne/minimum_norm/inverse.py
@@ -1080,7 +1080,8 @@ def _xyz2lf(Lf_xyz, normals):
 # Assemble the inverse operator
 
 @verbose
-def _prepare_forward(forward, info, noise_cov, pca=False, verbose=None):
+def _prepare_forward(forward, info, noise_cov, pca=False, rank=None,
+                     verbose=None):
     """Util function to prepare forward solution for inverse solvers
     """
     fwd_ch_names = [c['ch_name'] for c in forward['info']['chs']]
@@ -1101,7 +1102,7 @@ def _prepare_forward(forward, info, noise_cov, pca=False, verbose=None):
     #
     #   Handle noise cov
     #
-    noise_cov = prepare_noise_cov(noise_cov, info, ch_names)
+    noise_cov = prepare_noise_cov(noise_cov, info, ch_names, rank)
 
     #   Omit the zeroes due to projection
     eig = noise_cov['eig']
@@ -1132,7 +1133,8 @@ def _prepare_forward(forward, info, noise_cov, pca=False, verbose=None):
 
 @verbose
 def make_inverse_operator(info, forward, noise_cov, loose=0.2, depth=0.8,
-                          fixed=False, limit_depth_chs=True, verbose=None):
+                          fixed=False, limit_depth_chs=True, rank=None,
+                          verbose=None):
     """Assemble inverse operator
 
     Parameters
@@ -1157,6 +1159,11 @@ def make_inverse_operator(info, forward, noise_cov, loose=0.2, depth=0.8,
         If True, use only grad channels in depth weighting (equivalent to MNE
         C code). If grad chanels aren't present, only mag channels will be
         used (if no mag, then eeg). If False, use all channels.
+    rank : None | int | dict
+        Specified rank of the noise covariance matrix. If None, the rank is
+        detected automatically. If int, the rank is specified for the MEG
+        channels. A dictionary with entries 'eeg' and/or 'meg' can be used
+        to specify the rank for each modality.
     verbose : bool, str, int, or None
         If not None, override default verbose level (see mne.verbose).
 
@@ -1250,7 +1257,7 @@ def make_inverse_operator(info, forward, noise_cov, loose=0.2, depth=0.8,
     #
 
     gain_info, gain, noise_cov, whitener, n_nzero = \
-        _prepare_forward(forward, info, noise_cov)
+        _prepare_forward(forward, info, noise_cov, rank=rank)
 
     #
     # 5. Compose the depth-weighting matrix


Test patch:
diff --git a/mne/minimum_norm/tests/test_inverse.py b/mne/minimum_norm/tests/test_inverse.py
--- a/mne/minimum_norm/tests/test_inverse.py
+++ b/mne/minimum_norm/tests/test_inverse.py
@@ -57,6 +57,12 @@ def read_forward_solution_meg(*args, **kwargs):
     return fwd
 
 
+def read_forward_solution_eeg(*args, **kwargs):
+    fwd = read_forward_solution(*args, **kwargs)
+    fwd = pick_types_forward(fwd, meg=False, eeg=True)
+    return fwd
+
+
 def _get_evoked():
     evoked = read_evokeds(fname_data, condition=0, baseline=(None, 0))
     evoked.crop(0, 0.2)
@@ -302,6 +308,22 @@ def test_make_inverse_operator_diag():
     assert_true(compute_rank_inverse(inverse_operator_diag) == 360)
 
 
+@testing.requires_testing_data
+def test_inverse_operator_noise_cov_rank():
+    """Test MNE inverse operator with a specified noise cov rank
+    """
+    fwd_op = read_forward_solution_meg(fname_fwd, surf_ori=True)
+    evoked = _get_evoked()
+    noise_cov = read_cov(fname_cov)
+    inv = make_inverse_operator(evoked.info, fwd_op, noise_cov, rank=64)
+    assert_true(compute_rank_inverse(inv) == 64)
+
+    fwd_op = read_forward_solution_eeg(fname_fwd, surf_ori=True)
+    inv = make_inverse_operator(evoked.info, fwd_op, noise_cov,
+                                rank=dict(eeg=20))
+    assert_true(compute_rank_inverse(inv) == 20)
+
+
 @testing.requires_testing_data
 def test_inverse_operator_volume():
     """Test MNE inverse computation on volume source space