Repository: mne-tools/mne-python

Issue:
Savitzky-Golay filter
add support of the Savitzky-Golay filter

see : https://github.com/scipy/scipy/pull/304

and : 

http://inst.eecs.berkeley.edu/~ee123/fa12/docs/SGFilter.pdf

could be used to filter Evoked and Epochs.



Discussion:
Sounds very interesting!

On Sun, Jan 6, 2013 at 4:36 PM, Alexandre Gramfort <notifications@github.com

> wrote:
> 
> add support of the Savitzky-Golay filter
> 
> see : scipy/scipy#304 https://github.com/scipy/scipy/issues/304
> 
> and :
> 
> http://inst.eecs.berkeley.edu/~ee123/fa12/docs/SGFilter.pdf
> 
> could be used to filter Evoked and Epochs.
> 
> —
> Reply to this email directly or view it on GitHubhttps://github.com/mne-tools/mne-python/issues/347.

I'd really like to have this as

epochs.filter 
evoked.filter

methods.

the motivation is that such filters do not reduce peak amplitudes of ERPs/ERFs

any volunteer?

I could give it a shot. Sounds doable.

> I could give it a shot. Sounds doable.

great.

The upstream `scipy` function got merged, might have made it to 0.13 or not. Either way, we could implement appropriate version check and throw it in. Should probably go in `filter.py`. @mainakjas feel free to take a stab when you feel like it, or if you don't want to anymore, would make a good starting project for someone else.

+1

we should backport it too

really sorry about this. At the moment, it doesn't look like I'm going to give this shot till December end. If anyone wants to give it a shot before that, that's cool with me. I'll inform when I start working on this.

Isn't some one bored? Also here's an implementation in Python https://code.google.com/p/pyeeg/
could put that in externals or find a better implementation.

I guess we haven't needed this for two years... do you think we still need it @agramfort?

it's in scipy now.

we need to integrate it.

it can be convenient to smooth the evoked data late in the pipeline

you want it for 0.9? it seems like it _should_ be pretty straightforward

let's focus on open PR

conversion between frequency params and sav gol params is maybe not that
trivial.

I wrote a gist suggesting that using a 5th-order polynomial with a window length twice as large as the wavelength of the freq of interest should work okay:

https://gist.github.com/Eric89GXL/bbac101d50176611136b

Plotting the original spectra (yellow) and post-savgol spectra for different filter orders (left labels) and requested "lowpass frequencies" (bottom labels), with the -20 dB point in horizontal dashed line and requested freq in dotted vertical line:

![figure_1](https://cloud.githubusercontent.com/assets/2365790/6629911/3ebfae9c-c8d0-11e4-9255-f6e34ec342bd.png)


Resolving patch:
diff --git a/mne/epochs.py b/mne/epochs.py
--- a/mne/epochs.py
+++ b/mne/epochs.py
@@ -34,7 +34,7 @@
                     _check_pandas_installed, object_hash)
 from .channels.channels import (ContainsMixin, PickDropChannelsMixin,
                                 SetChannelsMixin, InterpolationMixin)
-from .filter import resample, detrend
+from .filter import resample, detrend, FilterMixin
 from .event import _read_events_fif
 from .fixes import in1d
 from .viz import _mutable_defaults, plot_epochs, _drop_log_stats
@@ -45,7 +45,7 @@
 
 
 class _BaseEpochs(ProjMixin, ContainsMixin, PickDropChannelsMixin,
-                  SetChannelsMixin, InterpolationMixin):
+                  SetChannelsMixin, InterpolationMixin, FilterMixin):
     """Abstract base class for Epochs-type classes
 
     This class provides basic functionality and should never be instantiated
diff --git a/mne/evoked.py b/mne/evoked.py
--- a/mne/evoked.py
+++ b/mne/evoked.py
@@ -14,7 +14,7 @@
 from .channels.channels import (ContainsMixin, PickDropChannelsMixin,
                                 SetChannelsMixin, InterpolationMixin,
                                 equalize_channels)
-from .filter import resample, detrend
+from .filter import resample, detrend, FilterMixin
 from .fixes import in1d
 from .utils import (_check_pandas_installed, check_fname, logger, verbose,
                     object_hash, deprecated)
@@ -42,7 +42,7 @@
 
 
 class Evoked(ProjMixin, ContainsMixin, PickDropChannelsMixin,
-             SetChannelsMixin, InterpolationMixin):
+             SetChannelsMixin, InterpolationMixin, FilterMixin):
     """Evoked data
 
     Parameters
diff --git a/mne/filter.py b/mne/filter.py
--- a/mne/filter.py
+++ b/mne/filter.py
@@ -13,7 +13,7 @@
 from .parallel import parallel_func, check_n_jobs
 from .cuda import (setup_cuda_fft_multiply_repeated, fft_multiply_repeated,
                    setup_cuda_fft_resample, fft_resample, _smart_pad)
-from .utils import logger, verbose, sum_squared
+from .utils import logger, verbose, sum_squared, check_scipy_version
 
 
 def is_power2(num):
@@ -1389,3 +1389,66 @@ def _get_filter_length(filter_length, sfreq, min_length=128, len_x=np.inf):
         if not isinstance(filter_length, integer_types):
             raise ValueError('filter_length must be str, int, or None')
     return filter_length
+
+
+class FilterMixin(object):
+    """Object for Epoch/Evoked filtering"""
+
+    def savgol_filter(self, h_freq):
+        """Filter the data using Savitzky-Golay polynomial method
+
+        Parameters
+        ----------
+        h_freq : float
+            Approximate high cut-off frequency in Hz. Note that this
+            is not an exact cutoff, since Savitzky-Golay filtering [1]_ is
+            done using polynomial fits instead of FIR/IIR filtering.
+            This parameter is thus used to determine the length of the
+            window over which a 5th-order polynomial smoothing is used.
+
+        Notes
+        -----
+        Data are modified in-place.
+
+        For Savitzky-Golay low-pass approximation, see:
+
+            https://gist.github.com/Eric89GXL/bbac101d50176611136b
+
+        Examples
+        --------
+        >>> import mne
+        >>> from os import path as op
+        >>> evoked_fname = op.join(mne.datasets.sample.data_path(), 'MEG', 'sample', 'sample_audvis-ave.fif')  # doctest:+SKIP
+        >>> evoked = mne.read_evokeds(evoked_fname, baseline=(None, 0))[0]  # doctest:+SKIP
+        >>> evoked.savgol_filter(10.)  # low-pass at around 10 Hz # doctest:+SKIP
+        >>> evoked.plot()  # doctest:+SKIP
+
+        References
+        ----------
+        .. [1] Savitzky, A., Golay, M.J.E. (1964). "Smoothing and
+               Differentiation of Data by Simplified Least Squares
+               Procedures". Analytical Chemistry 36 (8): 1627-39.
+        """  # noqa
+        from .evoked import Evoked
+        from .epochs import _BaseEpochs
+        if isinstance(self, Evoked):
+            data = self.data
+            axis = 1
+        elif isinstance(self, _BaseEpochs):
+            if not self.preload:
+                raise RuntimeError('data must be preloaded to filter')
+            data = self._data
+            axis = 2
+
+        h_freq = float(h_freq)
+        if h_freq >= self.info['sfreq'] / 2.:
+            raise ValueError('h_freq must be less than half the sample rate')
+
+        # savitzky-golay filtering
+        if not check_scipy_version('0.14'):
+            raise RuntimeError('scipy >= 0.14 must be installed for savgol')
+        from scipy.signal import savgol_filter
+        window_length = (int(np.round(self.info['sfreq'] /
+                                      h_freq)) // 2) * 2 + 1
+        data[...] = savgol_filter(data, axis=axis, polyorder=5,
+                                  window_length=window_length)


Test patch:
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
@@ -14,6 +14,7 @@
 import numpy as np
 import copy as cp
 import warnings
+from scipy import fftpack
 
 from mne import (io, Epochs, read_events, pick_events, read_epochs,
                  equalize_channels, pick_types, pick_channels, read_evokeds,
@@ -57,6 +58,29 @@ def _get_data():
 clean_warning_registry()  # really clean warning stack
 
 
+def test_filter():
+    """Test savgol filtering
+    """
+    h_freq = 10.
+    raw, events = _get_data()[:2]
+    epochs = Epochs(raw, events, event_id, tmin, tmax)
+    assert_raises(RuntimeError, epochs.savgol_filter, 10.)
+    epochs = Epochs(raw, events, event_id, tmin, tmax, preload=True)
+    freqs = fftpack.fftfreq(len(epochs.times), 1. / epochs.info['sfreq'])
+    data = np.abs(fftpack.fft(epochs.get_data()))
+    match_mask = np.logical_and(freqs >= 0, freqs <= h_freq / 2.)
+    mismatch_mask = np.logical_and(freqs >= h_freq * 2, freqs < 50.)
+    epochs.savgol_filter(h_freq)
+    data_filt = np.abs(fftpack.fft(epochs.get_data()))
+    # decent in pass-band
+    assert_allclose(np.mean(data[:, :, match_mask], 0),
+                    np.mean(data_filt[:, :, match_mask], 0),
+                    rtol=1e-4, atol=1e-2)
+    # suppression in stop-band
+    assert_true(np.mean(data[:, :, mismatch_mask]) >
+                np.mean(data_filt[:, :, mismatch_mask]) * 5)
+
+
 def test_epochs_hash():
     """Test epoch hashing
     """
diff --git a/mne/tests/test_evoked.py b/mne/tests/test_evoked.py
--- a/mne/tests/test_evoked.py
+++ b/mne/tests/test_evoked.py
@@ -10,6 +10,7 @@
 import warnings
 
 import numpy as np
+from scipy import fftpack
 from numpy.testing import (assert_array_almost_equal, assert_equal,
                            assert_array_equal, assert_allclose)
 from nose.tools import assert_true, assert_raises, assert_not_equal
@@ -32,6 +33,27 @@
                    'test-ave.fif.gz')
 
 
+def test_filter():
+    """Test savgol filtering
+    """
+    h_freq = 10.
+    evoked = read_evokeds(fname, 0)
+    freqs = fftpack.fftfreq(len(evoked.times), 1. / evoked.info['sfreq'])
+    data = np.abs(fftpack.fft(evoked.data))
+    match_mask = np.logical_and(freqs >= 0, freqs <= h_freq / 2.)
+    mismatch_mask = np.logical_and(freqs >= h_freq * 2, freqs < 50.)
+    assert_raises(ValueError, evoked.savgol_filter, evoked.info['sfreq'])
+    evoked.savgol_filter(h_freq)
+    data_filt = np.abs(fftpack.fft(evoked.data))
+    # decent in pass-band
+    assert_allclose(np.mean(data[:, match_mask], 0),
+                    np.mean(data_filt[:, match_mask], 0),
+                    rtol=1e-4, atol=1e-2)
+    # suppression in stop-band
+    assert_true(np.mean(data[:, mismatch_mask]) >
+                np.mean(data_filt[:, mismatch_mask]) * 5)
+
+
 def test_hash_evoked():
     """Test evoked hashing
     """