Repository: mne-tools/mne-python

Issue:
ENH: decoding robust option in Scaler
Currently the `mne.decoding.Scaler` takes the mean and std across channels to normalize the data.

We should have an option to either accept and sklearn.transformer, a dict a string, so that we can get 
- default values for meeg, eeg etc e.g: `Scaler(info, method='default')` would apply `mne.cov._check_scalings_user()`
- data-estimated standard scaling: i.e. current behavior i.e. `X/std-mean` over time
- data-estimated robust scaling : e.g. `Scaler(method=sklearn.preprocessing.RobustScaler())`

@kaichogami that should be an easy one if you're interested



Discussion:
Here s a function for the first case ('default'):

```

def scale(X, info, scalings=None):
    scalings = dict() if scalings is None else scalings
    from mne.io.pick import pick_info, _pick_data_channels, _picks_by_type
    from mne.cov import _check_scalings_user, _apply_scaling_array
    picks = _pick_data_channels(info)
    apply_picks_list = _picks_by_type(pick_info(info, picks))
    scalings = _check_scalings_user(scalings)
    n_epochs, n_channels, n_times = X.shape
    X = X.transpose(1, 0, 2)
    _apply_scaling_array(X, apply_picks_list, scalings)
    X = X.reshape(n_channels, n_epochs, n_times).transpose(1, 0, 2)
    return X
```

in terms of API I would prefer:

Scaler(method='robust')

I don't like composition like :

Scaler(method=sklearn.preprocessing.RobustScaler())

it makes API too complex. Let's avoid it if possible

Sure, I will work on this.

>  Scaler(method='robust')

+10

Here is a gist

``` python

class Scaler(TransformerMixin, BaseEstimator):
    def __init__(self, info, scaler=None):
        from sklearn.preprocessing import RobustScaler
        from mne.io.pick import pick_info, _pick_data_channels, _picks_by_type
        self.scaler = RobustScaler() if scaler is None else scaler
        self.info = info
        picks = _pick_data_channels(self.info)
        self._pick_types = _picks_by_type(pick_info(self.info, picks))

    def fit(self, X, y=None):
        from sklearn.base import clone
        self.scalers_ = list()
        n_epochs, n_channels, n_times = X.shape
        X = X.transpose(1, 0, 2).reshape(n_channels, -1)
        for (_, picks) in self._pick_types:
            scaler = clone(self.scaler)
            scaler.fit(X[picks].T)
            self.scalers_.append(scaler)
        return self

    def transform(self, X):
        n_epochs, n_channels, n_times = X.shape
        X = X.transpose(1, 0, 2).reshape(n_channels, -1)
        for scaler, (_, picks) in zip(self.scalers_, self._pick_types):
            X[picks] = scaler.transform(X[picks].T).T
        X = X.reshape(n_channels, n_epochs, n_times).transpose(1, 0, 2)
        return X

    def fit_transform(self, X, y=None):
        return self.fit(X, y).transform(X)
```


Resolving patch:
diff --git a/mne/decoding/transformer.py b/mne/decoding/transformer.py
--- a/mne/decoding/transformer.py
+++ b/mne/decoding/transformer.py
@@ -13,42 +13,122 @@
 from .. import pick_types
 from ..filter import filter_data, _triage_filter_params
 from ..time_frequency.psd import psd_array_multitaper
-from ..externals import six
-from ..utils import _check_type_picks
+from ..externals.six import string_types
+from ..utils import _check_type_picks, check_version
+from ..io.pick import pick_info, _pick_data_channels, _picks_by_type
+from ..cov import _check_scalings_user
 
 
-class Scaler(TransformerMixin):
-    u"""Standardize data across channels.
+class _ConstantScaler():
+    """Scale channel types using constant values."""
 
-    By default, this makes each time point (within each epoch) have
-    μ=0, σ=1.
+    def __init__(self, info, scalings, do_scaling=True):
+        self._scalings = scalings
+        self._info = info
+        self._do_scaling = do_scaling
+
+    def fit(self, X, y=None):
+        scalings = _check_scalings_user(self._scalings)
+        picks_by_type = _picks_by_type(pick_info(
+            self._info, _pick_data_channels(self._info, exclude=())))
+        std = np.ones(sum(len(p[1]) for p in picks_by_type))
+        if X.shape[1] != len(std):
+            raise ValueError('info had %d data channels but X has %d channels'
+                             % (len(std), len(X)))
+        if self._do_scaling:  # this is silly, but necessary for completeness
+            for kind, picks in picks_by_type:
+                std[picks] = 1. / scalings[kind]
+        self.std_ = std
+        self.mean_ = np.zeros_like(std)
+        return self
+
+    def transform(self, X, y=None):
+        return X / self.std_
+
+    def inverse_transform(self, X, y=None):
+        return X * self.std_
+
+    def fit_transform(self, X, y=None):
+        return self.fit(X, y).transform(X)
+
+
+def _sklearn_reshape_apply(func, return_result, X, *args):
+    """Reshape epochs and apply function."""
+    if not isinstance(X, np.ndarray):
+        raise ValueError("data should be an np.ndarray, got %s." % type(X))
+    X = np.atleast_3d(X)
+    orig_shape = X.shape
+    X = np.reshape(X.transpose(0, 2, 1), (-1, orig_shape[1]))
+    X = func(X, *args)
+    if return_result:
+        X.shape = (orig_shape[0], orig_shape[2], orig_shape[1])
+        X = X.transpose(0, 2, 1)
+        return X
+
+
+class Scaler(TransformerMixin, BaseEstimator):
+    u"""Standardize channel data.
+
+    This class scales data for each channel. It differs from scikit-learn_
+    classes (e.g., :class:`sklearn.preprocessing.StandardScaler`) in that
+    it scales each *channel* by estimating μ and σ using data from all
+    time points and epochs, as opposed to standardizing each *feature*
+    (i.e., each time point for each channel) by estimating using μ and σ
+    using data from all epochs.
 
     Parameters
     ----------
-    info : instance of Info
-        The measurement info
+    info : instance of Info | None
+        The measurement info. Only necessary if ``scalings`` is a dict or
+        None.
+    scalings : dict, string, defaults to None.
+        Scaling method to be applied to data channel wise.
+
+        * if scalings is None (default), scales mag by 1e15, grad by 1e13,
+          and eeg by 1e6.
+        * if scalings is :class:`dict`, keys are channel types and values
+          are scale factors.
+        * if ``scalings=='median'``,
+          :class:`sklearn.preprocessing.RobustScaler`
+          is used (requires sklearn version 0.17+).
+        * if ``scalings=='mean'``,
+          :class:`sklearn.preprocessing.StandardScaler`
+          is used.
+
     with_mean : boolean, True by default
-        If True, center the data before scaling.
+        If True, center the data using mean (or median) before scaling.
+        Ignored for channel-type scaling.
     with_std : boolean, True by default
-        If True, scale the data to unit variance (or equivalently,
-        unit standard deviation).
-
-    Attributes
-    ----------
-    info : instance of Info
-        The measurement info
-    ``ch_mean_`` : dict
-        The mean value for each channel type
-    ``std_`` : dict
-        The standard deviation for each channel type
+        If True, scale the data to unit variance (``scalings='mean'``),
+        quantile range (``scalings='median``), or using channel type
+        if ``scalings`` is a dict or None).
     """
 
-    def __init__(self, info, with_mean=True, with_std=True):  # noqa: D102
+    def __init__(self, info=None, scalings=None, with_mean=True,
+                 with_std=True):  # noqa: D102
         self.info = info
         self.with_mean = with_mean
         self.with_std = with_std
-        self.ch_mean_ = dict()  # TODO rename attribute
-        self.std_ = dict()  # TODO rename attribute
+        self.scalings = scalings
+
+        if not (scalings is None or isinstance(scalings, (dict, str))):
+            raise ValueError('scalings type should be dict, str, or None, '
+                             'got %s' % type(scalings))
+        if isinstance(scalings, string_types) and \
+                scalings not in ('mean', 'median'):
+            raise ValueError('Invalid method for scaling, must be "mean" or '
+                             '"median" but got %s' % scalings)
+        if scalings is None or isinstance(scalings, dict):
+            self._scaler = _ConstantScaler(info, scalings, self.with_std)
+        elif scalings == 'mean':
+            from sklearn.preprocessing import StandardScaler
+            self._scaler = StandardScaler(self.with_mean, self.with_std)
+        else:  # scalings == 'median':
+            if not check_version('sklearn', '0.17'):
+                raise ValueError("median requires version 0.17 of "
+                                 "sklearn library")
+            from sklearn.preprocessing import RobustScaler
+            self._scaler = RobustScaler(self.with_mean, self.with_std)
 
     def fit(self, epochs_data, y):
         """Standardize data across channels.
@@ -65,36 +145,7 @@ def fit(self, epochs_data, y):
         self : instance of Scaler
             Returns the modified instance.
         """
-        if not isinstance(epochs_data, np.ndarray):
-            raise ValueError("epochs_data should be of type ndarray (got %s)."
-                             % type(epochs_data))
-
-        X = np.atleast_3d(epochs_data)
-
-        picks_list = dict()
-        picks_list['mag'] = pick_types(self.info, meg='mag', ref_meg=False,
-                                       exclude='bads')
-        picks_list['grad'] = pick_types(self.info, meg='grad', ref_meg=False,
-                                        exclude='bads')
-        picks_list['eeg'] = pick_types(self.info, eeg=True, ref_meg=False,
-                                       meg=False, exclude='bads')
-
-        self.picks_list_ = picks_list
-
-        for key, this_pick in picks_list.items():
-            if self.with_mean:
-                if len(this_pick) == 0:
-                    ch_mean = np.nan * np.ones((X.shape[0], 1, X.shape[2]))
-                else:
-                    ch_mean = X[:, this_pick, :].mean(axis=1, keepdims=True)
-                self.ch_mean_[key] = ch_mean  # TODO rename attribute
-            if self.with_std:
-                if len(this_pick) == 0:
-                    ch_std = np.nan * np.ones((X.shape[0], 1, X.shape[2]))
-                else:
-                    ch_std = np.std(X[:, this_pick, :], axis=1, keepdims=True)
-                self.std_[key] = ch_std  # TODO rename attribute
-
+        _sklearn_reshape_apply(self._scaler.fit, False, epochs_data, y)
         return self
 
     def transform(self, epochs_data, y=None):
@@ -118,30 +169,16 @@ def transform(self, epochs_data, y=None):
         This function makes a copy of the data before the operations and the
         memory usage may be large with big data.
         """
-        if not isinstance(epochs_data, np.ndarray):
-            raise ValueError("epochs_data should be of type ndarray (got %s)."
-                             % type(epochs_data))
-
-        X = np.atleast_3d(epochs_data).copy()
-
-        for key, this_pick in six.iteritems(self.picks_list_):
-            if self.with_mean:
-                X[:, this_pick, :] -= self.ch_mean_[key]
-            if self.with_std:
-                X[:, this_pick, :] /= self.std_[key]
+        return _sklearn_reshape_apply(self._scaler.transform, True,
+                                      epochs_data, y)
 
-        return X
-
-    def inverse_transform(self, epochs_data, y=None):
+    def inverse_transform(self, epochs_data):
         """Invert standardization of data across channels.
 
         Parameters
         ----------
         epochs_data : array, shape (n_epochs, n_channels, n_times)
             The data.
-        y : None | array, shape (n_epochs,)
-            The label for each epoch.
-            If None not used. Defaults to None.
 
         Returns
         -------
@@ -153,19 +190,8 @@ def inverse_transform(self, epochs_data, y=None):
         This function makes a copy of the data before the operations and the
         memory usage may be large with big data.
         """
-        if not isinstance(epochs_data, np.ndarray):
-            raise ValueError("epochs_data should be of type ndarray (got %s)."
-                             % type(epochs_data))
-
-        X = np.atleast_3d(epochs_data).copy()
-
-        for key, this_pick in six.iteritems(self.picks_list_):
-            if self.with_std:
-                X[:, this_pick, :] *= self.std_[key]
-            if self.with_mean:
-                X[:, this_pick, :] += self.ch_mean_[key]
-
-        return X
+        return _sklearn_reshape_apply(self._scaler.inverse_transform, True,
+                                      epochs_data)
 
 
 class Vectorizer(TransformerMixin):
@@ -298,6 +324,10 @@ class PSDEstimator(TransformerMixin):
     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).
+
+    See Also
+    --------
+    psd_multitaper
     """
 
     def __init__(self, sfreq=2 * np.pi, fmin=0, fmax=np.inf, bandwidth=None,
diff --git a/mne/utils.py b/mne/utils.py
--- a/mne/utils.py
+++ b/mne/utils.py
@@ -1089,7 +1089,8 @@ def check_version(library, min_version):
         The library name to import. Must have a ``__version__`` property.
     min_version : str
         The minimum version string. Anything that matches
-        ``'(\d+ | [a-z]+ | \.)'``
+        ``'(\d+ | [a-z]+ | \.)'``. Can also be empty to skip version
+        check (just check for library presence).
 
     Returns
     -------
@@ -1102,9 +1103,10 @@ def check_version(library, min_version):
     except ImportError:
         ok = False
     else:
-        this_version = LooseVersion(library.__version__)
-        if this_version < min_version:
-            ok = False
+        if min_version:
+            this_version = LooseVersion(library.__version__)
+            if this_version < min_version:
+                ok = False
     return ok
 
 


Test patch:
diff --git a/mne/decoding/tests/test_transformer.py b/mne/decoding/tests/test_transformer.py
--- a/mne/decoding/tests/test_transformer.py
+++ b/mne/decoding/tests/test_transformer.py
@@ -7,15 +7,15 @@
 import os.path as op
 import numpy as np
 
-from nose.tools import assert_true, assert_raises
-from numpy.testing import (assert_array_equal, assert_equal,
-                           assert_array_almost_equal, assert_allclose)
+from nose.tools import assert_true, assert_raises, assert_equal
+from numpy.testing import (assert_array_equal, assert_array_almost_equal,
+                           assert_allclose)
 
 from mne import io, read_events, Epochs, pick_types
-from mne.decoding import Scaler, FilterEstimator
-from mne.decoding import (PSDEstimator, Vectorizer,
+from mne.decoding import (Scaler, FilterEstimator, PSDEstimator, Vectorizer,
                           UnsupervisedSpatialFilter, TemporalFilter)
-from mne.utils import requires_sklearn_0_15, run_tests_if_main
+from mne.defaults import DEFAULTS
+from mne.utils import requires_sklearn_0_15, run_tests_if_main, check_version
 
 warnings.simplefilter('always')  # enable b/c these tests throw warnings
 
@@ -39,29 +39,53 @@ def test_scaler():
     epochs = Epochs(raw, events, event_id, tmin, tmax, picks=picks,
                     baseline=(None, 0), preload=True)
     epochs_data = epochs.get_data()
-    scaler = Scaler(epochs.info)
     y = epochs.events[:, -1]
 
-    X = scaler.fit_transform(epochs_data, y)
-    assert_true(X.shape == epochs_data.shape)
-    X2 = scaler.fit(epochs_data, y).transform(epochs_data)
-    assert_array_equal(X2, X)
-    # these should be across time
-    assert_allclose(X.std(axis=-2), 1.)
-    assert_allclose(X.mean(axis=-2), 0., atol=1e-12)
-
-    # Test inverse_transform
-    Xi = scaler.inverse_transform(X, y)
-    assert_array_almost_equal(epochs_data, Xi)
-
-    for kwargs in [{'with_mean': False}, {'with_std': False}]:
-        scaler = Scaler(epochs.info, **kwargs)
-        scaler.fit(epochs_data, y)
-        assert_array_almost_equal(
-            X, scaler.inverse_transform(scaler.transform(X)))
+    methods = (None, dict(mag=5, grad=10, eeg=20), 'mean', 'median')
+    infos = (epochs.info, epochs.info, None, None)
+    epochs_data_t = epochs_data.transpose([1, 0, 2])
+    for method, info in zip(methods, infos):
+        if method == 'median' and not check_version('sklearn', '0.17'):
+            assert_raises(ValueError, Scaler, info, method)
+            continue
+        if method == 'mean' and not check_version('sklearn', ''):
+            assert_raises(ImportError, Scaler, info, method)
+            continue
+        scaler = Scaler(info, method)
+        X = scaler.fit_transform(epochs_data, y)
+        assert_equal(X.shape, epochs_data.shape)
+        if method is None or isinstance(method, dict):
+            sd = DEFAULTS['scalings'] if method is None else method
+            stds = np.zeros(len(picks))
+            for key in ('mag', 'grad'):
+                stds[pick_types(epochs.info, meg=key)] = 1. / sd[key]
+            stds[pick_types(epochs.info, meg=False, eeg=True)] = 1. / sd['eeg']
+            means = np.zeros(len(epochs.ch_names))
+        elif method == 'mean':
+            stds = np.array([np.std(ch_data) for ch_data in epochs_data_t])
+            means = np.array([np.mean(ch_data) for ch_data in epochs_data_t])
+        else:  # median
+            percs = np.array([np.percentile(ch_data, [25, 50, 75])
+                              for ch_data in epochs_data_t])
+            stds = percs[:, 2] - percs[:, 0]
+            means = percs[:, 1]
+        assert_allclose(X * stds[:, np.newaxis] + means[:, np.newaxis],
+                        epochs_data, rtol=1e-12, atol=1e-20, err_msg=method)
+
+        X2 = scaler.fit(epochs_data, y).transform(epochs_data)
+        assert_array_equal(X, X2)
+
+        # inverse_transform
+        Xi = scaler.inverse_transform(X)
+        assert_array_almost_equal(epochs_data, Xi)
+
     # Test init exception
     assert_raises(ValueError, scaler.fit, epochs, y)
     assert_raises(ValueError, scaler.transform, epochs, y)
+    epochs_bad = Epochs(raw, events, event_id, 0, 0.01,
+                        picks=np.arange(len(raw.ch_names)))  # non-data chs
+    scaler = Scaler(epochs_bad.info, None)
+    assert_raises(ValueError, scaler.fit, epochs_bad.get_data(), y)
 
 
 def test_filterestimator():