Repository: mne-tools/mne-python

Issue:
Q: Epochs processing order
Right our Epochs processing happens in two separate steps:
1. Detrending, baseline correction, offset application, decimation
2. Projection

Looking through the code, I think that sometimes (1) comes before (2), and other times, (2) comes before (1) (e.g., potentially depending on delayed projection. I think we need to pick and order and document it somewhere. I propose the order above is fine and we should use it. Is that okay @dengemann @agramfort?



Discussion:
I noticed this because I was trying to use delayed projection, and that caused me to look into how to decrease memory usage in `epochs.average()`, which caused me to look into how this is done... and it doesn't seem to be consistent.


Resolving patch:
diff --git a/mne/epochs.py b/mne/epochs.py
--- a/mne/epochs.py
+++ b/mne/epochs.py
@@ -307,12 +307,12 @@ def __init__(self, info, data, events, event_id, tmin, tmax,
                                    'but no rejection parameters are present. '
                                    'Please add rejection parameters before '
                                    'using this option.')
-            self._delayed_proj = True
+            self._do_delayed_proj = True
             logger.info('Entering delayed SSP mode.')
         else:
-            self._delayed_proj = False
+            self._do_delayed_proj = False
 
-        activate = False if self._delayed_proj else proj
+        activate = False if self._do_delayed_proj else proj
         self._projector, self.info = setup_proj(self.info, add_eeg_ref,
                                                 activate=activate)
 
@@ -342,6 +342,7 @@ def preload_data(self):
         self._decim_slice = slice(None, None, None)
         self._decim = 1
         self._raw_times = self.times
+        assert self._data.shape[-1] == len(self.times)
         return self
 
     def decimate(self, decim, copy=False):
@@ -488,11 +489,14 @@ def _is_good_epoch(self, data, verbose=None):
                             ignore_chs=self.info['bads'])
 
     @verbose
-    def _preprocess(self, epoch, verbose=None):
+    def _detrend_offset_decim(self, epoch, verbose=None):
         """Aux Function: detrend, baseline correct, offset, decim
 
         Note: operates inplace
         """
+        if epoch is None:
+            return None
+
         # Detrend
         if self.detrend is not None:
             picks = pick_types(self.info, meg=True, eeg=True, stim=False,
@@ -650,6 +654,7 @@ def _compute_mean_or_stderr(self, picks, mode='ave'):
 
         n_channels = len(self.ch_names)
         n_times = len(self.times)
+
         if self.preload:
             n_events = len(self.events)
             fun = np.std if _do_std else np.mean
@@ -1080,16 +1085,14 @@ def _get_epoch_from_raw(self, idx, verbose=None):
         """Method to get a given epoch from disk"""
         raise NotImplementedError
 
-    def _process_epoch_raw(self, epoch_raw):
+    def _project_epoch(self, epoch):
         """Helper to process a raw epoch based on the delayed param"""
         # whenever requested, the first epoch is being projected.
-        if epoch_raw is None:  # can happen if t < 0
+        if epoch is None:  # can happen if t < 0
             return None
-        proj = self._delayed_proj or self.proj
+        proj = self._do_delayed_proj or self.proj
         if self._projector is not None and proj is True:
-            epoch = self._preprocess(np.dot(self._projector, epoch_raw))
-        else:
-            epoch = self._preprocess(epoch_raw.copy())
+            epoch = np.dot(self._projector, epoch)
         return epoch
 
     @verbose
@@ -1122,15 +1125,15 @@ def _get_data(self, out=True, verbose=None):
             # we need to load from disk, drop, and return data
             for idx in range(n_events):
                 # faster to pre-allocate memory here
-                epoch_raw = self._get_epoch_from_raw(idx)
-                if self._delayed_proj:
-                    epoch_out = epoch_raw
+                epoch_noproj = self._get_epoch_from_raw(idx)
+                epoch_noproj = self._detrend_offset_decim(epoch_noproj)
+                if self._do_delayed_proj:
+                    epoch_out = epoch_noproj
                 else:
-                    epoch_out = self._process_epoch_raw(epoch_raw)
+                    epoch_out = self._project_epoch(epoch_noproj)
                 if idx == 0:
-                    data = np.empty((n_events, epoch_out.shape[0],
-                                     epoch_out.shape[1]),
-                                    dtype=epoch_raw.dtype)
+                    data = np.empty((n_events, len(self.ch_names),
+                                     len(self.times)), dtype=epoch_out.dtype)
                 data[idx] = epoch_out
         else:
             # bads need to be dropped, this might occur after a preload
@@ -1140,16 +1143,17 @@ def _get_data(self, out=True, verbose=None):
             assert n_events == len(self.selection)
             for idx, sel in enumerate(self.selection):
                 if self.preload:  # from memory
-                    if self._delayed_proj:
-                        epoch_raw = self._data[idx]
-                        epoch = self._process_epoch_raw(epoch_raw)
+                    if self._do_delayed_proj:
+                        epoch_noproj = self._data[idx]
+                        epoch = self._project_epoch(epoch_noproj)
                     else:
-                        epoch_raw = None
+                        epoch_noproj = None
                         epoch = self._data[idx]
                 else:  # from disk
-                    epoch_raw = self._get_epoch_from_raw(idx)
-                    epoch = self._process_epoch_raw(epoch_raw)
-                epoch_out = epoch_raw if self._delayed_proj else epoch
+                    epoch_noproj = self._get_epoch_from_raw(idx)
+                    epoch_noproj = self._detrend_offset_decim(epoch_noproj)
+                    epoch = self._project_epoch(epoch_noproj)
+                epoch_out = epoch_noproj if self._do_delayed_proj else epoch
                 is_good, offenders = self._is_good_epoch(epoch)
                 if not is_good:
                     self.drop_log[sel] += offenders
@@ -1191,14 +1195,7 @@ def get_data(self):
         data : array of shape (n_epochs, n_channels, n_times)
             A copy of the epochs data.
         """
-        data_ = self._get_data()
-        if self._delayed_proj:
-            data = np.zeros_like(data_)
-            for ii, e in enumerate(data_):
-                data[ii] = self._preprocess(e.copy())
-        else:
-            data = data_.copy()
-        return data
+        return self._get_data()
 
     def __len__(self):
         """Number of epochs.
@@ -1237,21 +1234,20 @@ def next(self, return_event_id=False):
             if self._current >= len(self._data):
                 raise StopIteration
             epoch = self._data[self._current]
-            if self._delayed_proj:
-                epoch = self._preprocess(epoch.copy())
             self._current += 1
         else:
             is_good = False
             while not is_good:
                 if self._current >= len(self.events):
                     raise StopIteration
-                epoch_raw = self._get_epoch_from_raw(self._current)
-                epoch = self._process_epoch_raw(epoch_raw)
+                epoch_noproj = self._get_epoch_from_raw(self._current)
+                epoch_noproj = self._detrend_offset_decim(epoch_noproj)
+                epoch = self._project_epoch(epoch_noproj)
                 self._current += 1
                 is_good, _ = self._is_good_epoch(epoch)
             # If delayed-ssp mode, pass 'virgin' data after rejection decision.
-            if self._delayed_proj:
-                epoch = self._preprocess(epoch_raw)
+            if self._do_delayed_proj:
+                epoch = epoch_noproj
 
         if not return_event_id:
             return epoch
@@ -1677,6 +1673,9 @@ class Epochs(_BaseEpochs):
 
     Notes
     -----
+    When accessing data, Epochs are detrended, baseline-corrected, and
+    decimated, then projectors are (optionally) applied.
+
     For indexing and slicing:
 
     epochs[idx] : Epochs
@@ -1838,7 +1837,8 @@ def __init__(self, data, info, events, tmin=0, event_id=None,
             raise ValueError('The events must only contain event numbers from '
                              'event_id')
         for ii, e in enumerate(self._data):
-            self._preprocess(e)
+            # This is safe without assignment b/c there is no decim
+            self._detrend_offset_decim(e)
         self.drop_bad_epochs()
 
 
@@ -2133,7 +2133,7 @@ def _read_one_epoch_file(f, tree, fname, proj, add_eeg_ref, verbose):
         epochs = _BaseEpochs(info, data, events, event_id, tmin, tmax,
                              baseline, name=name, on_missing='ignore',
                              verbose=verbose)
-        activate = False if epochs._delayed_proj else proj
+        activate = False if epochs._do_delayed_proj else proj
         epochs._projector, epochs.info = setup_proj(info, add_eeg_ref,
                                                     activate=activate)
 
diff --git a/mne/io/proj.py b/mne/io/proj.py
--- a/mne/io/proj.py
+++ b/mne/io/proj.py
@@ -128,15 +128,17 @@ def apply_proj(self):
         self : instance of Raw | Epochs | Evoked
             The instance.
         """
+        from ..epochs import _BaseEpochs
+        from .base import _BaseRaw
         if self.info['projs'] is None or len(self.info['projs']) == 0:
             logger.info('No projector specified for this dataset.'
                         'Please consider the method self.add_proj.')
             return self
 
         # Exit delayed mode if you apply proj
-        if getattr(self, '_delayed_proj', False):
+        if isinstance(self, _BaseEpochs) and self._do_delayed_proj:
             logger.info('Leaving delayed SSP mode.')
-            self._delayed_proj = False
+            self._do_delayed_proj = False
 
         if all(p['active'] for p in self.info['projs']):
             logger.info('Projections have already been applied. '
@@ -152,29 +154,18 @@ def apply_proj(self):
             return self
 
         self._projector, self.info = _projector, info
-        # handle different data / preload attrs and create reference
-        # this also helps avoiding circular imports
-        for attr in ('get_data', '_data', 'data'):
-            data = getattr(self, attr, None)
-            if data is None:
-                continue
-            elif callable(data):
-                if self.preload:
-                    data = np.empty_like(self._data)
-                    for ii, e in enumerate(self._data):
-                        data[ii] = self._preprocess(np.dot(self._projector, e),
-                                                    self.verbose)
-                else:  # get data knows what to do.
-                    data = data()
+        if isinstance(self, _BaseRaw):
+            if self.preload:
+                self._data = np.dot(self._projector, self._data)
+        elif isinstance(self, _BaseEpochs):
+            if self.preload:
+                for ii, e in enumerate(self._data):
+                    self._data[ii] = self._project_epoch(e)
             else:
-                data = np.dot(self._projector, data)
-            break
+                self.preload_data()  # will automatically apply
+        else:  # Evoked
+            self.data = np.dot(self._projector, self.data)
         logger.info('SSP projectors applied...')
-        if hasattr(self, '_data'):
-            self._data = data
-        else:
-            self.data = data
-
         return self
 
     def del_proj(self, idx):
diff --git a/mne/realtime/epochs.py b/mne/realtime/epochs.py
--- a/mne/realtime/epochs.py
+++ b/mne/realtime/epochs.py
@@ -395,16 +395,11 @@ def _append_epoch_to_queue(self, epoch, event_samp, event_id):
         # select the channels
         epoch = epoch[self.picks, :]
 
-        # handle offset
-        if self._offset is not None:
-            epoch += self._offset
+        # Detrend, baseline correct, decimate
+        epoch = self._detrend_offset_decim(epoch, verbose='ERROR')
 
         # apply SSP
-        if self.proj and self._projector is not None:
-            epoch = np.dot(self._projector, epoch)
-
-        # Detrend, baseline correct, decimate
-        epoch = self._preprocess(epoch, verbose='ERROR')
+        epoch = self._project_epoch(epoch)
 
         # Decide if this is a good epoch
         is_good, _ = self._is_good_epoch(epoch, verbose='ERROR')


Test patch:
diff --git a/mne/decoding/tests/test_csp.py b/mne/decoding/tests/test_csp.py
--- a/mne/decoding/tests/test_csp.py
+++ b/mne/decoding/tests/test_csp.py
@@ -11,7 +11,7 @@
 
 from mne import io, Epochs, read_events, pick_types
 from mne.decoding.csp import CSP
-from mne.utils import requires_sklearn
+from mne.utils import requires_sklearn, slow_test
 
 data_dir = op.join(op.dirname(__file__), '..', '..', 'io', 'tests', 'data')
 raw_fname = op.join(data_dir, 'test_raw.fif')
@@ -23,6 +23,7 @@
 start, stop = 0, 8
 
 
+@slow_test
 def test_csp():
     """Test Common Spatial Patterns algorithm on epochs
     """
diff --git a/mne/decoding/tests/test_time_gen.py b/mne/decoding/tests/test_time_gen.py
--- a/mne/decoding/tests/test_time_gen.py
+++ b/mne/decoding/tests/test_time_gen.py
@@ -279,6 +279,8 @@ def predict(self, x):
 
 @requires_sklearn
 def test_decoding_time():
+    """Test TimeDecoding
+    """
     epochs = make_epochs()
     tg = TimeDecoding()
     assert_equal("<TimeDecoding | no fit, no prediction, no score>", '%s' % tg)
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
@@ -179,22 +179,22 @@ def test_decim():
         # at init
         epochs = Epochs(raw, events, event_id, tmin, tmax, decim=decim,
                         preload=preload)
-        assert_array_equal(epochs.get_data(), expected_data)
-        assert_array_equal(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
         assert_equal(epochs.info['sfreq'], sfreq_new)
         assert_array_equal(epochs.times, expected_times)
 
         # split between init and afterward
         epochs = Epochs(raw, events, event_id, tmin, tmax, decim=dec_1,
                         preload=preload).decimate(dec_2)
-        assert_array_equal(epochs.get_data(), expected_data)
-        assert_array_equal(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
         assert_equal(epochs.info['sfreq'], sfreq_new)
         assert_array_equal(epochs.times, expected_times)
         epochs = Epochs(raw, events, event_id, tmin, tmax, decim=dec_2,
                         preload=preload).decimate(dec_1)
-        assert_array_equal(epochs.get_data(), expected_data)
-        assert_array_equal(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
         assert_equal(epochs.info['sfreq'], sfreq_new)
         assert_array_equal(epochs.times, expected_times)
 
@@ -203,24 +203,24 @@ def test_decim():
                         preload=preload)
         epochs.preload_data()
         epochs = epochs.decimate(dec_2)
-        assert_array_equal(epochs.get_data(), expected_data)
-        assert_array_equal(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
         assert_equal(epochs.info['sfreq'], sfreq_new)
         assert_array_equal(epochs.times, expected_times)
         epochs = Epochs(raw, events, event_id, tmin, tmax, decim=dec_2,
                         preload=preload)
         epochs.preload_data()
         epochs = epochs.decimate(dec_1)
-        assert_array_equal(epochs.get_data(), expected_data)
-        assert_array_equal(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
         assert_equal(epochs.info['sfreq'], sfreq_new)
         assert_array_equal(epochs.times, expected_times)
 
         # decimate afterward
         epochs = Epochs(raw, events, event_id, tmin, tmax,
                         preload=preload).decimate(decim)
-        assert_array_equal(epochs.get_data(), expected_data)
-        assert_array_equal(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
         assert_equal(epochs.info['sfreq'], sfreq_new)
         assert_array_equal(epochs.times, expected_times)
 
@@ -229,8 +229,8 @@ def test_decim():
                         preload=preload)
         epochs.preload_data()
         epochs.decimate(decim)
-        assert_array_equal(epochs.get_data(), expected_data)
-        assert_array_equal(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
+        assert_allclose(epochs.get_data(), expected_data)
         assert_equal(epochs.info['sfreq'], sfreq_new)
         assert_array_equal(epochs.times, expected_times)
 
@@ -414,11 +414,12 @@ def test_read_write_epochs():
         raw.info['lowpass'] = lowpass
 
     data_dec = epochs_dec.get_data()
-    assert_array_equal(data[:, :, epochs_dec._decim_slice], data_dec)
+    assert_allclose(data[:, :, epochs_dec._decim_slice], data_dec, rtol=1e-7,
+                    atol=1e-12)
 
     evoked_dec = epochs_dec.average()
-    assert_array_equal(evoked.data[:, epochs_dec._decim_slice],
-                       evoked_dec.data)
+    assert_allclose(evoked.data[:, epochs_dec._decim_slice],
+                    evoked_dec.data, rtol=1e-12)
 
     n = evoked.data.shape[1]
     n_dec = evoked_dec.data.shape[1]
@@ -1018,7 +1019,7 @@ def test_subtract_evoked():
     # if we compute the evoked response after subtracting it we get zero
     zero_evoked = epochs.average()
     data = zero_evoked.data
-    assert_array_almost_equal(data, np.zeros_like(data), decimal=20)
+    assert_allclose(data, np.zeros_like(data), atol=1e-15)
 
 
 def test_epoch_eq():
@@ -1253,7 +1254,7 @@ def test_epochs_proj_mixin():
                          add_eeg_ref=True, reject=reject)
 
         assert_allclose(epochs.copy().apply_proj().get_data()[0],
-                        epochs2.get_data()[0])
+                        epochs2.get_data()[0], rtol=1e-10, atol=1e-25)
 
         # make sure data output is constant across repeated calls
         # e.g. drop bads
@@ -1278,6 +1279,37 @@ def test_epochs_proj_mixin():
     assert_allclose(np.dot(epochs._projector, data[0]), epochs._data[0])
 
 
+def test_delayed_epochs():
+    """Test delayed projection
+    """
+    raw, events, picks = _get_data()
+    events = events[:10]
+    picks = np.concatenate([pick_types(raw.info, meg=True, eeg=True)[::22],
+                            pick_types(raw.info, meg=False, eeg=False,
+                                       ecg=True, eog=True)])
+    picks = np.sort(picks)
+    raw.info['lowpass'] = 40.  # fake the LP info so no warnings
+    for preload in (True, False):
+        for proj in (True, False, 'delayed'):
+            for decim in (1, 3):
+                for ii in range(2):
+                    epochs = Epochs(raw, events, event_id, tmin, tmax,
+                                    picks=picks, proj=proj, reject=reject,
+                                    preload=preload, decim=decim)
+                    if ii == 1:
+                        epochs.preload_data()
+                    picks_data = pick_types(epochs.info, meg=True, eeg=True)
+                    evoked = epochs.average(picks=picks_data)
+                    if proj is True:
+                        evoked.apply_proj()
+                    epochs_data = epochs.get_data().mean(axis=0)[picks_data]
+                    assert_array_equal(evoked.ch_names,
+                                       np.array(epochs.ch_names)[picks_data])
+                    assert_allclose(evoked.times, epochs.times)
+                    assert_allclose(evoked.data, epochs_data,
+                                    rtol=1e-5, atol=1e-15)
+
+
 def test_drop_epochs():
     """Test dropping of epochs.
     """