Repository: mne-tools/mne-python

Issue:
ENH: detect whether dev_t is the same across epochs before compute_covariance
In https://github.com/mne-tools/mne-python/issues/3283, I encountered a difficulty to find what was wrong with my sources. 

This was because the covariance was estimated from trials acquired in different run, and therefore with different `dev_head_t`. 

`compute_covariance` should probably throw a warning if the head alignment don't match.

cc @dengemann 



Discussion:
related to #2109 and #2122. realigning isn't as straightforward as one would think...

started down the road last year with @yousrabk but needed to retreat.

would be happy to see a solution ;)

+1, but I think we should first start with a warning, that should be easy enough ("PR welcome")

Actually an error by default would be better. We can have an argument `misaligned='error' | 'warning'` for now and eventually add `misaligned='interpolate'`. Maybe there is a better name than `misaligned`, though.

I loosely plan to work on reconstruction (#3184) this summer, so I might be able to tackle the interpolation bit.


Resolving patch:
diff --git a/mne/cov.py b/mne/cov.py
--- a/mne/cov.py
+++ b/mne/cov.py
@@ -505,7 +505,7 @@ def compute_raw_covariance(raw, tmin=0, tmax=None, tstep=0.2, reject=None,
 def compute_covariance(epochs, keep_sample_mean=True, tmin=None, tmax=None,
                        projs=None, method='empirical', method_params=None,
                        cv=3, scalings=None, n_jobs=1, return_estimators=False,
-                       verbose=None):
+                       on_mismatch='raise', verbose=None):
     """Estimate noise covariance matrix from epochs.
 
     The noise covariance is typically estimated on pre-stim periods
@@ -613,6 +613,14 @@ def compute_covariance(epochs, keep_sample_mean=True, tmin=None, tmax=None,
     return_estimators : bool (default False)
         Whether to return all estimators or the best. Only considered if
         method equals 'auto' or is a list of str. Defaults to False
+    on_mismatch : str
+        What to do when the MEG<->Head transformations do not match between
+        epochs. If "raise" (default) an error is raised, if "warn" then a
+        warning is emitted, if "ignore" then nothing is printed. Having
+        mismatched transforms can in some cases lead to unexpected or
+        unstable results in covariance calculation, e.g. when data
+        have been processed with Maxwell filtering but not transformed
+        to the same head position.
     verbose : bool | str | int | or None (default None)
         If not None, override default verbose level (see mne.verbose).
 
@@ -689,14 +697,30 @@ def _unpack_epochs(epochs):
         epochs = sum([_unpack_epochs(epoch) for epoch in epochs], [])
 
     # check for baseline correction
-    for epochs_t in epochs:
-        if epochs_t.baseline is None and epochs_t.info['highpass'] < 0.5 and \
-                keep_sample_mean:
-            warn('Epochs are not baseline corrected, covariance '
-                 'matrix may be inaccurate')
-
-    for epoch in epochs:
+    if any(epochs_t.baseline is None and epochs_t.info['highpass'] < 0.5 and
+           keep_sample_mean for epochs_t in epochs):
+        warn('Epochs are not baseline corrected, covariance '
+             'matrix may be inaccurate')
+
+    orig = epochs[0].info['dev_head_t']
+    if not isinstance(on_mismatch, string_types) or \
+            on_mismatch not in ['raise', 'warn', 'ignore']:
+        raise ValueError('on_mismatch must be "raise", "warn", or "ignore", '
+                         'got %s' % on_mismatch)
+    for ei, epoch in enumerate(epochs):
         epoch.info._check_consistency()
+        if (orig is None) != (epoch.info['dev_head_t'] is None) or \
+                (orig is not None and not
+                 np.allclose(orig['trans'],
+                             epoch.info['dev_head_t']['trans'])):
+            msg = ('MEG<->Head transform mismatch between epochs[0]:\n%s\n\n'
+                   'and epochs[%s]:\n%s'
+                   % (orig, ei, epoch.info['dev_head_t']))
+            if on_mismatch == 'raise':
+                raise ValueError(msg)
+            elif on_mismatch == 'warn':
+                warn(msg)
+
     bads = epochs[0].info['bads']
     if projs is None:
         projs = cp.deepcopy(epochs[0].info['projs'])
diff --git a/mne/epochs.py b/mne/epochs.py
--- a/mne/epochs.py
+++ b/mne/epochs.py
@@ -2829,10 +2829,23 @@ def _compare_epochs_infos(info1, info2, ind):
     if any(not _proj_equal(p1, p2) for p1, p2 in
            zip(info2['projs'], info1['projs'])):
         raise ValueError('SSP projectors in epochs files must be the same')
+    if (info1['dev_head_t'] is None) != (info2['dev_head_t'] is None) or \
+            (info1['dev_head_t'] is not None and not
+             np.allclose(info1['dev_head_t']['trans'],
+                         info2['dev_head_t']['trans'], rtol=1e-6)):
+        raise ValueError('epochs[%d][\'info\'][\'dev_head_t\'] must match'
+                         % ind)
 
 
 def _concatenate_epochs(epochs_list, with_data=True):
     """Auxiliary function for concatenating epochs."""
+    if not isinstance(epochs_list, (list, tuple)):
+        raise TypeError('epochs_list must be a list or tuple, got %s'
+                        % (type(epochs_list),))
+    for ei, epochs in enumerate(epochs_list):
+        if not isinstance(epochs, _BaseEpochs):
+            raise TypeError('epochs_list[%d] must be an instance of Epochs, '
+                            'got %s' % (ei, type(epochs)))
     out = epochs_list[0]
     data = [out.get_data()] if with_data else None
     events = [out.events]


Test patch:
diff --git a/mne/tests/test_cov.py b/mne/tests/test_cov.py
--- a/mne/tests/test_cov.py
+++ b/mne/tests/test_cov.py
@@ -23,7 +23,7 @@
                  compute_covariance, read_evokeds, compute_proj_raw,
                  pick_channels_cov, pick_channels, pick_types, pick_info,
                  make_ad_hoc_cov)
-from mne.io import Raw, RawArray
+from mne.io import read_raw_fif, RawArray
 from mne.tests.common import assert_naming, assert_snr
 from mne.utils import (_TempDir, slow_test, requires_sklearn_0_15,
                        run_tests_if_main)
@@ -42,6 +42,37 @@
 hp_fif_fname = op.join(base_dir, 'test_chpi_raw_sss.fif')
 
 
+def test_cov_mismatch():
+    """Test estimation with MEG<->Head mismatch"""
+    raw = read_raw_fif(raw_fname).crop(0, 5).load_data()
+    events = find_events(raw, stim_channel='STI 014')
+    raw.pick_channels(raw.ch_names[:5])
+    raw.add_proj([], remove_existing=True)
+    epochs = Epochs(raw, events, None, tmin=-0.2, tmax=0., preload=True)
+    for kind in ('shift', 'None'):
+        epochs_2 = epochs.copy()
+        # This should be fine
+        with warnings.catch_warnings(record=True) as w:
+            compute_covariance([epochs, epochs_2])
+            assert_equal(len(w), 0)
+            if kind == 'shift':
+                epochs_2.info['dev_head_t']['trans'][:3, 3] += 0.001
+            else:  # None
+                epochs_2.info['dev_head_t'] = None
+            assert_raises(ValueError, compute_covariance, [epochs, epochs_2])
+            assert_equal(len(w), 0)
+            compute_covariance([epochs, epochs_2], on_mismatch='ignore')
+            assert_equal(len(w), 0)
+            compute_covariance([epochs, epochs_2], on_mismatch='warn')
+            assert_raises(ValueError, compute_covariance, epochs,
+                          on_mismatch='x')
+        assert_true(any('transform mismatch' in str(ww.message) for ww in w))
+    # This should work
+    epochs.info['dev_head_t'] = None
+    epochs_2.info['dev_head_t'] = None
+    compute_covariance([epochs, epochs_2])
+
+
 def test_ad_hoc_cov():
     """Test ad hoc cov creation and I/O"""
     tempdir = _TempDir()
@@ -97,7 +128,7 @@ def test_io_cov():
 def test_cov_estimation_on_raw():
     """Test estimation from raw (typically empty room)"""
     tempdir = _TempDir()
-    raw = Raw(raw_fname, preload=True)
+    raw = read_raw_fif(raw_fname, preload=True)
     cov_mne = read_cov(erm_cov_fname)
 
     # The pure-string uses the more efficient numpy-based method, the
@@ -132,7 +163,7 @@ def test_cov_estimation_on_raw():
         cov = compute_raw_covariance(raw_pick, picks=picks, method=method)
         assert_snr(cov.data, cov_mne.data[picks][:, picks], 90)  # cutoff samps
         # make sure we get a warning with too short a segment
-        raw_2 = Raw(raw_fname).crop(0, 1, copy=False)
+        raw_2 = read_raw_fif(raw_fname).crop(0, 1, copy=False)
         with warnings.catch_warnings(record=True) as w:
             warnings.simplefilter('always')
             cov = compute_raw_covariance(raw_2, method=method)
@@ -150,7 +181,7 @@ def test_cov_estimation_on_raw():
 @requires_sklearn_0_15
 def test_cov_estimation_on_raw_reg():
     """Test estimation from raw with regularization"""
-    raw = Raw(raw_fname, preload=True)
+    raw = read_raw_fif(raw_fname, preload=True)
     raw.info['sfreq'] /= 10.
     raw = RawArray(raw._data[:, ::10].copy(), raw.info)  # decimate for speed
     cov_mne = read_cov(erm_cov_fname)
@@ -166,7 +197,7 @@ def test_cov_estimation_on_raw_reg():
 def test_cov_estimation_with_triggers():
     """Test estimation from raw with triggers"""
     tempdir = _TempDir()
-    raw = Raw(raw_fname, preload=False)
+    raw = read_raw_fif(raw_fname, preload=False)
     events = find_events(raw, stim_channel='STI 014')
     event_ids = [1, 2, 3, 4]
     reject = dict(grad=10000e-13, mag=4e-12, eeg=80e-6, eog=150e-6)
@@ -257,7 +288,7 @@ def test_arithmetic_cov():
 
 def test_regularize_cov():
     """Test cov regularization"""
-    raw = Raw(raw_fname, preload=False)
+    raw = read_raw_fif(raw_fname, preload=False)
     raw.info['bads'].append(raw.ch_names[0])  # test with bad channels
     noise_cov = read_cov(cov_fname)
     # Regularize noise cov
@@ -304,9 +335,9 @@ def test_rank():
     assert_true((cov['eig'][1:] > 0).all())  # all else should be > 0
 
     # Now do some more comprehensive tests
-    raw_sample = Raw(raw_fname)
+    raw_sample = read_raw_fif(raw_fname)
 
-    raw_sss = Raw(hp_fif_fname)
+    raw_sss = read_raw_fif(hp_fif_fname)
     raw_sss.add_proj(compute_proj_raw(raw_sss))
 
     cov_sample = compute_raw_covariance(raw_sample)
@@ -462,7 +493,7 @@ def get_data(n_samples, n_features, rank, sigma):
 def test_compute_covariance_auto_reg():
     """Test automated regularization"""
 
-    raw = Raw(raw_fname, preload=True)
+    raw = read_raw_fif(raw_fname, preload=True)
     raw.resample(100, npad='auto')  # much faster estimation
     events = find_events(raw, stim_channel='STI 014')
     event_ids = [1, 2, 3, 4]
diff --git a/mne/tests/test_epochs.py b/mne/tests/test_epochs.py
--- a/mne/tests/test_epochs.py
+++ b/mne/tests/test_epochs.py
@@ -2065,6 +2065,18 @@ def test_concatenate_epochs():
     epochs2.baseline = (-0.1, None)
     assert_raises(ValueError, concatenate_epochs, [epochs, epochs2])
 
+    # check if dev_head_t is same
+    epochs2 = epochs.copy()
+    concatenate_epochs([epochs, epochs2])  # should work
+    epochs2.info['dev_head_t']['trans'][:3, 3] += 0.0001
+    assert_raises(ValueError, concatenate_epochs, [epochs, epochs2])
+    assert_raises(TypeError, concatenate_epochs, 'foo')
+    assert_raises(TypeError, concatenate_epochs, [epochs, 'foo'])
+    epochs2.info['dev_head_t'] = None
+    assert_raises(ValueError, concatenate_epochs, [epochs, epochs2])
+    epochs.info['dev_head_t'] = None
+    concatenate_epochs([epochs, epochs2])  # should work
+
 
 def test_add_channels():
     """Test epoch splitting / re-appending channel types