Repository: mne-tools/mne-python

Issue:
ENH: Return residual in `apply_inverse`
Most solvers give you the residual so you can check the data fit quality. We should also provide this for minimum norm solvers. For backward compat we probably need a `return_residual` parameter.

In theory we could also compute the goodness-of-fit by looking at the variance explained in the whitened data. This could at least be in a `logger.info`.


Discussion:

Resolving patch:
diff --git a/mne/cov.py b/mne/cov.py
--- a/mne/cov.py
+++ b/mne/cov.py
@@ -1265,31 +1265,33 @@ def _get_ch_whitener(A, pca, ch_type, rank):
     return eig, eigvec
 
 
-def _get_whitener(noise_cov, info, ch_names, rank, pca, scalings=None):
+def _get_whitener(noise_cov, info=None, ch_names=None, rank=None,
+                  pca=False, scalings=None, prepared=False):
     #
     #   Handle noise cov
     #
-    noise_cov = prepare_noise_cov(noise_cov, info, ch_names, rank)
-    n_chan = len(ch_names)
+    if not prepared:
+        noise_cov = prepare_noise_cov(noise_cov, info, ch_names, rank)
+    n_chan = len(noise_cov['eig'])
 
     #   Omit the zeroes due to projection
-    eig = noise_cov['eig']
+    eig = noise_cov['eig'].copy()
     nzero = (eig > 0)
+    eig[~nzero] = 0.  # get rid of numerical noise (negative) ones
     n_nzero = np.sum(nzero)
 
+    whitener = np.zeros((n_chan, 1), dtype=np.float)
+    whitener[nzero, 0] = 1.0 / np.sqrt(eig[nzero])
+    #   Rows of eigvec are the eigenvectors
+    whitener = whitener * noise_cov['eigvec']  # C ** -0.5
+    colorer = np.sqrt(eig) * noise_cov['eigvec'].T  # C ** 0.5
     if pca:
-        #   Rows of eigvec are the eigenvectors
-        whitener = noise_cov['eigvec'][nzero] / np.sqrt(eig[nzero])[:, None]
-        logger.info('Reducing data rank to %d' % n_nzero)
-    else:
-        whitener = np.zeros((n_chan, n_chan), dtype=np.float)
-        whitener[nzero, nzero] = 1.0 / np.sqrt(eig[nzero])
-        #   Rows of eigvec are the eigenvectors
-        whitener = np.dot(whitener, noise_cov['eigvec'])
-
-    logger.info('Total rank is %d' % n_nzero)
-
-    return whitener, noise_cov, n_nzero
+        whitener = whitener[nzero]
+        colorer = colorer[:, nzero]
+    logger.info('    Created the whitener using a noise covariance matrix '
+                'with rank %d (%d small eigenvalues omitted)'
+                % (n_nzero, noise_cov['dim'] - np.sum(nzero)))
+    return whitener, colorer, noise_cov, n_nzero
 
 
 @verbose
@@ -1649,8 +1651,8 @@ def compute_whitener(noise_cov, info, picks=None, rank=None,
                            % missing)
 
     scalings = _handle_default('scalings_cov_rank', scalings)
-    W, noise_cov, n_nzero = _get_whitener(noise_cov, info, ch_names, rank,
-                                          pca=False, scalings=scalings)
+    W, _, noise_cov, n_nzero = _get_whitener(
+        noise_cov, info, ch_names, rank, pca=False, scalings=scalings)
 
     # Do the back projection
     W = np.dot(noise_cov['eigvec'].T, W)
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
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 # Authors: Alexandre Gramfort <alexandre.gramfort@telecom-paristech.fr>
 #          Matti Hamalainen <msh@nmr.mgh.harvard.edu>
 #          Teon Brooks <teon.brooks@gmail.com>
@@ -532,7 +533,8 @@ def prepare_inverse_operator(orig, nave, lambda2, method='dSPM',
     #   Create the diagonal matrix for computing the regularized inverse
     #
     sing = np.array(inv['sing'], dtype=np.float64)
-    inv['reginv'] = sing / (sing ** 2 + lambda2)
+    with np.errstate(invalid='ignore'):  # if lambda2==0
+        inv['reginv'] = np.where(sing > 0, sing / (sing ** 2 + lambda2), 0)
     logger.info('    Created the regularized inverter')
     #
     #   Create the projection operator
@@ -549,31 +551,9 @@ def prepare_inverse_operator(orig, nave, lambda2, method='dSPM',
     #
     #   Create the whitener
     #
-    if not inv['noise_cov']['diag']:
-        inv['whitener'] = np.zeros((inv['noise_cov']['dim'],
-                                    inv['noise_cov']['dim']))
-        #
-        #   Omit the zeroes due to projection
-        #
-        eig = inv['noise_cov']['eig']
-        nzero = (eig > 0)
-        inv['whitener'][nzero, nzero] = 1.0 / np.sqrt(eig[nzero])
-        #
-        #   Rows of eigvec are the eigenvectors
-        #
-        inv['whitener'] = np.dot(inv['whitener'], inv['noise_cov']['eigvec'])
-        logger.info('    Created the whitener using a full noise '
-                    'covariance matrix (%d small eigenvalues omitted)'
-                    % (inv['noise_cov']['dim'] - np.sum(nzero)))
-    else:
-        #
-        #   No need to omit the zeroes due to projection
-        #
-        inv['whitener'] = np.diag(1.0 /
-                                  np.sqrt(inv['noise_cov']['data'].ravel()))
-        logger.info('    Created the whitener using a diagonal noise '
-                    'covariance matrix (%d small eigenvalues discarded)'
-                    % ncomp)
+
+    inv['whitener'], inv['colorer'], _, _ = _get_whitener(
+        inv['noise_cov'], pca=False, prepared=True)
 
     #
     #   Finally, compute the noise-normalization factors
@@ -812,7 +792,7 @@ def _subject_from_inverse(inverse_operator):
 @verbose
 def apply_inverse(evoked, inverse_operator, lambda2=1. / 9., method="dSPM",
                   pick_ori=None, prepared=False, label=None,
-                  method_params=None, verbose=None):
+                  method_params=None, return_residual=False, verbose=None):
     """Apply inverse operator to evoked data.
 
     Parameters
@@ -843,6 +823,11 @@ def apply_inverse(evoked, inverse_operator, lambda2=1. / 9., method="dSPM",
         Additional options for eLORETA. See Notes for details.
 
         .. versionadded:: 0.16
+    return_residual : bool
+        If True (default False), return the residual evoked data.
+        Cannot be used with ``method=='eLORETA'``.
+
+        .. versionadded:: 0.17
     verbose : bool, str, int, or None
         If not None, override default verbose level (see :func:`mne.verbose`
         and :ref:`Logging documentation <tut_logging>` for more).
@@ -850,7 +835,9 @@ def apply_inverse(evoked, inverse_operator, lambda2=1. / 9., method="dSPM",
     Returns
     -------
     stc : SourceEstimate | VectorSourceEstimate | VolSourceEstimate
-        The source estimates
+        The source estimates.
+    residual : instance of Evoked
+        The residual evoked data, only returned if return_residual is True.
 
     See Also
     --------
@@ -912,6 +899,8 @@ def apply_inverse(evoked, inverse_operator, lambda2=1. / 9., method="dSPM",
     """
     _check_reference(evoked, inverse_operator['info']['ch_names'])
     _check_method(method)
+    if method == 'eLORETA' and return_residual:
+        raise ValueError('eLORETA does not currently support return_residual')
     _check_ori(pick_ori, inverse_operator['source_ori'])
     #
     #   Set up the inverse according to the parameters
@@ -935,7 +924,22 @@ def apply_inverse(evoked, inverse_operator, lambda2=1. / 9., method="dSPM",
     K, noise_norm, vertno, source_nn = _assemble_kernel(inv, label, method,
                                                         pick_ori)
     sol = np.dot(K, evoked.data[sel])  # apply imaging kernel
-
+    logger.info('    Computing residual...')
+    # x̂(t) = G ĵ(t) = C ** 1/2 U Π w(t)
+    # where the diagonal matrix Π has elements πk = λk γk
+    Pi = inv['sing'] * inv['reginv']
+    data_w = np.dot(inv['whitener'],  # C ** -0.5
+                    np.dot(inv['proj'], evoked.data[sel]))
+    w_t = np.dot(inv['eigen_fields']['data'], data_w)  # U.T @ data
+    data_est = np.dot(inv['colorer'],  # C ** 0.5
+                      np.dot(inv['eigen_fields']['data'].T,  # U
+                             Pi[:, np.newaxis] * w_t))
+    data_est_w = np.dot(inv['whitener'], np.dot(inv['proj'], data_est))
+    var_exp = 1 - ((data_est_w - data_w) ** 2).sum() / (data_w ** 2).sum()
+    logger.info('    Explained %5.1f%% variance' % (100 * var_exp,))
+    if return_residual:
+        residual = evoked.copy()
+        residual.data[sel] -= data_est
     is_free_ori = (inverse_operator['source_ori'] ==
                    FIFF.FIFFV_MNE_FREE_ORI and pick_ori != 'normal')
 
@@ -959,7 +963,7 @@ def apply_inverse(evoked, inverse_operator, lambda2=1. / 9., method="dSPM",
                     src_type=src_type)
     logger.info('[done]')
 
-    return stc
+    return (stc, residual) if return_residual else stc
 
 
 @verbose
@@ -1311,8 +1315,8 @@ def _prepare_forward(forward, info, noise_cov, pca=False, rank=None,
     n_chan = len(ch_names)
     logger.info("Computing inverse operator with %d channels." % n_chan)
 
-    whitener, noise_cov, n_nzero = _get_whitener(noise_cov, info, ch_names,
-                                                 rank, pca)
+    whitener, _, noise_cov, n_nzero = _get_whitener(
+        noise_cov, info, ch_names, rank, pca)
 
     gain = forward['sol']['data']
 
@@ -1481,8 +1485,10 @@ def make_inverse_operator(info, forward, noise_cov, loose='auto', depth=0.8,
     # 4. Load the sensor noise covariance matrix and attach it to the forward
     #
 
+    # For now we always have pca=False. It does not seem to affect calculations
+    # and is also backward-compatible with MNE-C
     gain_info, gain, noise_cov, whitener, n_nzero = \
-        _prepare_forward(forward, info, noise_cov, rank=rank)
+        _prepare_forward(forward, info, noise_cov, pca=False, rank=rank)
     forward['info']._check_consistency()
 
     #
@@ -1511,7 +1517,8 @@ def make_inverse_operator(info, forward, noise_cov, loose='auto', depth=0.8,
                 use_cps=use_cps)
             is_fixed_ori = is_fixed_orient(forward)
             gain_info, gain, noise_cov, whitener, n_nzero = \
-                _prepare_forward(forward, info, noise_cov, verbose=False)
+                _prepare_forward(forward, info, noise_cov, pca=False,
+                                 verbose=False)
 
     logger.info("Computing inverse operator with %d channels."
                 % len(gain_info['ch_names']))
diff --git a/tutorials/plot_mne_dspm_source_localization.py b/tutorials/plot_mne_dspm_source_localization.py
--- a/tutorials/plot_mne_dspm_source_localization.py
+++ b/tutorials/plot_mne_dspm_source_localization.py
@@ -12,7 +12,7 @@
 from mne.datasets import sample
 from mne.minimum_norm import make_inverse_operator, apply_inverse
 
-# sphinx_gallery_thumbnail_number = 9
+# sphinx_gallery_thumbnail_number = 10
 
 ###############################################################################
 # Process MEG data
@@ -88,8 +88,9 @@
 method = "dSPM"
 snr = 3.
 lambda2 = 1. / snr ** 2
-stc = apply_inverse(evoked, inverse_operator, lambda2,
-                    method=method, pick_ori=None)
+stc, residual = apply_inverse(evoked, inverse_operator, lambda2,
+                              method=method, pick_ori=None,
+                              return_residual=True, verbose=True)
 
 ###############################################################################
 # Visualization
@@ -102,6 +103,17 @@
 plt.ylabel('%s value' % method)
 plt.show()
 
+###############################################################################
+# Examine the original data and the residual after fitting:
+
+fig, axes = plt.subplots(2, 1)
+evoked.plot(axes=axes)
+for ax in axes:
+    ax.texts = []
+    for line in ax.lines:
+        line.set_color('#98df81')
+residual.plot(axes=axes)
+
 ###############################################################################
 # Here we use peak getter to move visualization to the time point of the peak
 # and draw a marker at the maximum peak vertex.


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
@@ -1,8 +1,12 @@
 from __future__ import print_function
+
 import os.path as op
+import re
+
 import numpy as np
 from numpy.testing import (assert_array_almost_equal, assert_equal,
-                           assert_allclose, assert_array_equal)
+                           assert_allclose, assert_array_equal,
+                           assert_array_less)
 from scipy import sparse
 
 import pytest
@@ -13,20 +17,22 @@
 from mne.label import read_label, label_sign_flip
 from mne.event import read_events
 from mne.epochs import Epochs
-from mne.forward import restrict_forward_to_stc
+from mne.forward import restrict_forward_to_stc, apply_forward
 from mne.source_estimate import read_source_estimate, VolSourceEstimate
 from mne import (read_cov, read_forward_solution, read_evokeds, pick_types,
                  pick_types_forward, make_forward_solution, EvokedArray,
                  convert_forward_solution, Covariance, combine_evoked,
-                 SourceEstimate, make_sphere_model, make_ad_hoc_cov)
+                 SourceEstimate, make_sphere_model, make_ad_hoc_cov,
+                 pick_channels_forward)
 from mne.io import read_raw_fif, Info
+from mne.io.proj import make_projector
 from mne.minimum_norm.inverse import (apply_inverse, read_inverse_operator,
                                       apply_inverse_raw, apply_inverse_epochs,
                                       make_inverse_operator,
                                       write_inverse_operator,
                                       compute_rank_inverse,
                                       prepare_inverse_operator)
-from mne.utils import _TempDir, run_tests_if_main
+from mne.utils import _TempDir, run_tests_if_main, catch_logging
 from mne.externals import six
 
 test_path = testing.data_path(download=False)
@@ -483,6 +489,49 @@ def test_apply_inverse_operator():
     apply_inverse(evoked, inv_op_meg, 1. / 9.)
 
 
+@testing.requires_testing_data
+def test_inverse_residual():
+    """Test MNE inverse application."""
+    # use fname_inv as it will be faster than fname_full (fewer verts and chs)
+    evoked = _get_evoked().pick_types()
+    inv = read_inverse_operator(fname_inv_fixed_depth)
+    fwd = read_forward_solution(fname_fwd)
+    fwd = convert_forward_solution(fwd, force_fixed=True, surf_ori=True)
+    fwd = pick_channels_forward(fwd, evoked.ch_names)
+    matcher = re.compile('.* ([0-9]?[0-9]?[0-9]?\.[0-9])% variance.*')
+    for method in ('MNE', 'dSPM', 'sLORETA'):
+        with catch_logging() as log:
+            stc, residual = apply_inverse(
+                evoked, inv, method=method, return_residual=True, verbose=True)
+        log = log.getvalue()
+        match = matcher.match(log.replace('\n', ' '))
+        assert match is not None
+        match = float(match.group(1))
+        assert 45 < match < 50
+        if method == 'MNE':  # must be first!
+            recon = apply_forward(fwd, stc, evoked.info)
+            proj_op = make_projector(evoked.info['projs'], evoked.ch_names)[0]
+            recon.data[:] = np.dot(proj_op, recon.data)
+            residual_fwd = evoked.copy()
+            residual_fwd.data -= recon.data
+        corr = np.corrcoef(residual_fwd.data.ravel(),
+                           residual.data.ravel())[0, 1]
+        assert corr > 0.999
+    with catch_logging() as log:
+        _, residual = apply_inverse(
+            evoked, inv, 0., 'MNE', return_residual=True, verbose=True)
+    log = log.getvalue()
+    match = matcher.match(log.replace('\n', ' '))
+    assert match is not None
+    match = float(match.group(1))
+    assert match == 100.
+    assert_array_less(np.abs(residual.data), 1e-15)
+
+    # Degenerate: we don't have the right representation for eLORETA for this
+    with pytest.raises(ValueError, match='eLORETA does not .* support .*'):
+        apply_inverse(evoked, inv, method="eLORETA", return_residual=True)
+
+
 @testing.requires_testing_data
 def test_make_inverse_operator_fixed():
     """Test MNE inverse computation (fixed orientation)."""