Repository: mne-tools/mne-python

Issue:
[BUG] Computation of noise in NAI beamformer
The noise computation in the NAI beamformer is flawed. In case of full rank matrices, it throws the following error now (as discovered by @MadsJensen):

```
'Cannot compute noise subspace with a full-rank  covariance matrix and no 
regularization. Try  manually specifying the rank of the covariance matrix or 
using regularization.'
```
However, in this case, the beamformer should just rely on the smallest eigenvalue of the covariance matrix SVD, which represents the noise level.

@agramfort @wmvanvliet @sarangnemo


Discussion:
maybe we can look at this after the completion of the rank refactoring ? I would suggest to address this during the sprint too.
@agramfort not sure of the timeline of the rank refactoring, but this bug is a blocker for us locally, we have several users affected.
I can take a look to see if the fix is simple enough. This can become one more thing to consider when unifying later.
ok fair enough

can we have a code snippet to replicate / fix?

@sarangnemo @britta-wstnr I tried changing a test in `test_lcmv.py` that computes:
```
filters = make_lcmv(evoked.info, fwd, data_cov, reg=0.,
                    noise_cov=noise_cov, pick_ori='max-power',
                    weight_norm='nai')
```
But I did not hit this error. I also had to remove the three active projectors. That's because this failure mode occurs when:

1. `reg=0` (which causes `loading_factor=0` to be returned by `_reg_pinv`), *and*
2. `rank >= len(Cm)`

Perhaps this conditional needs to be `rank > len(Cm)`? I'll implement it and modify the tests unless someone knows it's a bad idea.

Resolving patch:
diff --git a/mne/beamformer/_compute_beamformer.py b/mne/beamformer/_compute_beamformer.py
--- a/mne/beamformer/_compute_beamformer.py
+++ b/mne/beamformer/_compute_beamformer.py
@@ -427,7 +427,7 @@ def _compute_beamformer(G, Cm, reg, n_orient, weight_norm, pick_ori,
             # Estimate noise level based on covariance matrix, taking the
             # first eigenvalue that falls outside the signal subspace or the
             # loading factor used during regularization, whichever is largest.
-            if rank >= len(Cm):
+            if rank > len(Cm):
                 # Covariance matrix is full rank, no noise subspace!
                 # Use the loading factor as noise ceiling.
                 if loading_factor == 0:


Test patch:
diff --git a/mne/beamformer/tests/test_lcmv.py b/mne/beamformer/tests/test_lcmv.py
--- a/mne/beamformer/tests/test_lcmv.py
+++ b/mne/beamformer/tests/test_lcmv.py
@@ -41,7 +41,7 @@ def _read_forward_solution_meg(*args, **kwargs):
 
 
 def _get_data(tmin=-0.1, tmax=0.15, all_forward=True, epochs=True,
-              epochs_preload=True, data_cov=True):
+              epochs_preload=True, data_cov=True, proj=True):
     """Read in data used in tests."""
     label = mne.read_label(fname_label)
     events = mne.read_events(fname_event)
@@ -75,7 +75,10 @@ def _get_data(tmin=-0.1, tmax=0.15, all_forward=True, epochs=True,
     del picks
 
     raw.info['bads'] = bads  # add more bads
-    raw.info.normalize_proj()  # avoid projection warnings
+    if proj:
+        raw.info.normalize_proj()  # avoid projection warnings
+    else:
+        raw.del_proj()
 
     if epochs:
         # Read epochs
@@ -185,13 +188,15 @@ def test_lcmv_vector():
 @pytest.mark.slowtest
 @requires_h5py
 @testing.requires_testing_data
-def test_make_lcmv(tmpdir):
+@pytest.mark.parametrize('reg', (0.01, 0.))
+@pytest.mark.parametrize('proj', (True, False))
+def test_make_lcmv(tmpdir, reg, proj):
     """Test LCMV with evoked data and single trials."""
     raw, epochs, evoked, data_cov, noise_cov, label, forward,\
-        forward_surf_ori, forward_fixed, forward_vol = _get_data()
+        forward_surf_ori, forward_fixed, forward_vol = _get_data(proj=proj)
 
     for fwd in [forward, forward_vol]:
-        filters = make_lcmv(evoked.info, fwd, data_cov, reg=0.01,
+        filters = make_lcmv(evoked.info, fwd, data_cov, reg=reg,
                             noise_cov=noise_cov)
         stc = apply_lcmv(evoked, filters, max_ori_out='signed')
         stc.crop(0.02, None)
@@ -207,7 +212,7 @@ def test_make_lcmv(tmpdir):
         if fwd is forward:
             # Test picking normal orientation (surface source space only).
             filters = make_lcmv(evoked.info, forward_surf_ori, data_cov,
-                                reg=0.01, noise_cov=noise_cov,
+                                reg=reg, noise_cov=noise_cov,
                                 pick_ori='normal', weight_norm=None)
             stc_normal = apply_lcmv(evoked, filters, max_ori_out='signed')
             stc_normal.crop(0.02, None)
@@ -217,8 +222,10 @@ def test_make_lcmv(tmpdir):
             max_stc = stc_normal.data[idx]
             tmax = stc_normal.times[np.argmax(max_stc)]
 
-            assert 0.04 < tmax < 0.13, tmax
-            assert 3e-7 < np.max(max_stc) < 5e-7, np.max(max_stc)
+            lower = 0.04 if proj else 0.025
+            assert lower < tmax < 0.13, tmax
+            lower = 3e-7 if proj else 2e-7
+            assert lower < np.max(max_stc) < 5e-7, np.max(max_stc)
 
             # No weight normalization was applied, so the amplitude of normal
             # orientation results should always be smaller than free
@@ -226,7 +233,7 @@ def test_make_lcmv(tmpdir):
             assert (np.abs(stc_normal.data) <= stc.data).all()
 
         # Test picking source orientation maximizing output source power
-        filters = make_lcmv(evoked.info, fwd, data_cov, reg=0.01,
+        filters = make_lcmv(evoked.info, fwd, data_cov, reg=reg,
                             noise_cov=noise_cov, pick_ori='max-power')
         stc_max_power = apply_lcmv(evoked, filters, max_ori_out='signed')
         stc_max_power.crop(0.02, None)
@@ -235,7 +242,8 @@ def test_make_lcmv(tmpdir):
         max_stc = np.abs(stc_max_power.data[idx])
         tmax = stc.times[np.argmax(max_stc)]
 
-        assert 0.08 < tmax < 0.12, tmax
+        lower = 0.08 if proj else 0.04
+        assert lower < tmax < 0.12, tmax
         assert 0.8 < np.max(max_stc) < 3., np.max(max_stc)
 
         stc_max_power.data[:, :] = np.abs(stc_max_power.data)
@@ -253,7 +261,7 @@ def test_make_lcmv(tmpdir):
             assert_array_less(np.abs(mean_stc - mean_stc_max_pow), 0.6)
 
         # Test NAI weight normalization:
-        filters = make_lcmv(evoked.info, fwd, data_cov, reg=0.01,
+        filters = make_lcmv(evoked.info, fwd, data_cov, reg=reg,
                             noise_cov=noise_cov, pick_ori='max-power',
                             weight_norm='nai')
         stc_nai = apply_lcmv(evoked, filters, max_ori_out='signed')
@@ -294,18 +302,23 @@ def test_make_lcmv(tmpdir):
     max_stc = stc_sphere.data[idx]
     tmax = stc_sphere.times[np.argmax(max_stc)]
 
-    assert 0.08 < tmax < 0.15, tmax
+    lower = 0.08 if proj else 0.04
+    assert lower < tmax < 0.15, tmax
     assert 0.4 < np.max(max_stc) < 2., np.max(max_stc)
 
     # Test if spatial filter contains src_type
     assert 'src_type' in filters
 
     # __repr__
+    assert len(evoked.ch_names) == 22
+    assert len(evoked.info['projs']) == (4 if proj else 0)
+    assert len(evoked.info['bads']) == 2
+    rank = 17 if proj else 20
     assert 'LCMV' in repr(filters)
     assert 'unknown subject' not in repr(filters)
     assert '484' in repr(filters)
     assert '20' in repr(filters)
-    assert 'rank 17' in repr(filters)
+    assert 'rank %s' % rank in repr(filters)
 
     # I/O
     fname = op.join(str(tmpdir), 'filters.h5')
@@ -368,10 +381,11 @@ def test_make_lcmv(tmpdir):
     assert_array_almost_equal(stc.data, stc_ch.data)
 
     # Test if non-matching SSP projection is detected in application of filter
-    raw_proj = deepcopy(raw)
-    raw_proj.del_proj()
-    pytest.raises(ValueError, apply_lcmv_raw, raw_proj, filters,
-                  max_ori_out='signed')
+    if proj:
+        raw_proj = deepcopy(raw)
+        raw_proj.del_proj()
+        with pytest.raises(ValueError, match='do not match the projections'):
+            apply_lcmv_raw(raw_proj, filters, max_ori_out='signed')
 
     # Test if setting reduce_rank to True returns a NotImplementedError
     # when no orientation selection is done or pick_ori='normal'