Repository: mne-tools/mne-python

Issue:
CSP transform doesn't comply with sklearn API
`CSP.transform` currently returns the CSP features averaged over time. 

IMO this is not a transform in the sklearn sense, it's a feature extraction. The transform should be the time course of each CSP i.e. return an array of `shape (n_epochs, n_filters, n_times)`.

Typically, if I want to do a dim reduction by CSP e.g. `make_pipeline(CSP(), TimeFrequency())` the current API can't work.

I don't think there's a way to do a nice deprecation cycle for this :/, except by redoing another object like we did for the Xdawn...

cc @alexandrebarachant @agramfort 



Discussion:
I would add a parameter to CSP to average over time or not.

the averaging is the standard use of CSP

ok


Resolving patch:
diff --git a/mne/decoding/csp.py b/mne/decoding/csp.py
--- a/mne/decoding/csp.py
+++ b/mne/decoding/csp.py
@@ -37,14 +37,19 @@ class CSP(TransformerMixin, BaseEstimator):
         if float, shrinkage covariance is used (0 <= shrinkage <= 1).
         if str, optimal shrinkage using Ledoit-Wolf Shrinkage ('ledoit_wolf')
         or Oracle Approximating Shrinkage ('oas').
-    log : bool, defaults to True
-        If true, apply log to standardize the features.
-        If false, features are just z-scored.
+    log : None | bool, defaults to None
+        If transform_into == 'average_power' and log is None or True, then
+        applies a log transform to standardize the features, else the features
+        are z-scored. If transform_into == 'csp_space', then log must be None.
     cov_est : 'concat' | 'epoch', defaults to 'concat'
         If 'concat', covariance matrices are estimated on concatenated epochs
         for each class.
         If 'epoch', covariance matrices are estimated on each epoch separately
         and then averaged over each class.
+    transform_into : {'average_power', 'csp_space'}
+        If 'average_power' then self.transform will return the average power of
+        each spatial filter. If 'csp_space' self.transform will return the data
+        in CSP space. Defaults to 'average_power'.
 
     Attributes
     ----------
@@ -71,11 +76,15 @@ class CSP(TransformerMixin, BaseEstimator):
         Transactions on Biomedical Engineering, Vol 55, no. 8, 2008.
     """
 
-    def __init__(self, n_components=4, reg=None, log=True, cov_est="concat"):
+    def __init__(self, n_components=4, reg=None, log=None, cov_est="concat",
+                 transform_into='average_power'):
         """Init of CSP."""
+        # Init default CSP
         if not isinstance(n_components, int):
             raise ValueError('n_components must be an integer.')
         self.n_components = n_components
+
+        # Init default regularization
         if (
             (reg is not None) and
             (reg not in ['oas', 'ledoit_wolf']) and
@@ -85,11 +94,29 @@ def __init__(self, n_components=4, reg=None, log=True, cov_est="concat"):
             raise ValueError('reg must be None, "oas", "ledoit_wolf" or a '
                              'float in between 0. and 1.')
         self.reg = reg
-        self.log = log
+
+        # Init default cov_est
         if not (cov_est == "concat" or cov_est == "epoch"):
             raise ValueError("unknown covariance estimation method")
         self.cov_est = cov_est
 
+        # Init default transform_into
+        if transform_into not in ('average_power', 'csp_space'):
+            raise ValueError('transform_into must be "average_power" or '
+                             '"csp_space".')
+        self.transform_into = transform_into
+
+        # Init default log
+        if transform_into == 'average_power':
+            if log is not None and not isinstance(log, bool):
+                raise ValueError('log must be a boolean if transform_into == '
+                                 '"average_power".')
+        else:
+            if log is not None:
+                raise ValueError('log must be a None if transform_into == '
+                                 '"csp_space".')
+        self.log = log
+
     def _check_Xy(self, X, y=None):
         """Aux. function to check input data."""
         if y is not None:
@@ -208,8 +235,11 @@ def transform(self, X, epochs_data=None):
 
         Returns
         -------
-        X : ndarray of shape (n_epochs, n_sources)
-            The CSP features averaged over time.
+        X : ndarray
+            If self.transform_into == 'average_power' then returns the power of
+            CSP features averaged over time and shape (n_epochs, n_sources)
+            If self.transform_into == 'csp_space' then returns the data in CSP
+            space and shape is (n_epochs, n_sources, n_times)
         """
         X = _check_deprecate(epochs_data, X)
         if not isinstance(X, np.ndarray):
@@ -222,12 +252,14 @@ def transform(self, X, epochs_data=None):
         X = np.asarray([np.dot(pick_filters, epoch) for epoch in X])
 
         # compute features (mean band power)
-        X = (X ** 2).mean(axis=-1)
-        if self.log:
-            X = np.log(X)
-        else:
-            X -= self.mean_
-            X /= self.std_
+        if self.transform_into == 'average_power':
+            X = (X ** 2).mean(axis=-1)
+            log = True if self.log is None else self.log
+            if log:
+                X = np.log(X)
+            else:
+                X -= self.mean_
+                X /= self.std_
         return X
 
     def plot_patterns(self, info, components=None, ch_type=None, layout=None,


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
@@ -103,6 +103,35 @@ def test_csp():
         assert_array_equal(csp.filters_.shape, [n_channels, n_channels])
         assert_array_equal(csp.patterns_.shape, [n_channels, n_channels])
 
+    # Test average power transform
+    n_components = 2
+    assert_true(csp.transform_into == 'average_power')
+    feature_shape = [len(epochs_data), n_components]
+    X_trans = dict()
+    for log in (None, True, False):
+        csp = CSP(n_components=n_components, log=log)
+        assert_true(csp.log is log)
+        Xt = csp.fit_transform(epochs_data, epochs.events[:, 2])
+        assert_array_equal(Xt.shape, feature_shape)
+        X_trans[str(log)] = Xt
+    # log=None => log=True
+    assert_array_almost_equal(X_trans['None'], X_trans['True'])
+    # Different normalization return different transform
+    assert_true(np.sum((X_trans['True'] - X_trans['False']) ** 2) > 1.)
+    # Check wrong inputs
+    assert_raises(ValueError, CSP, transform_into='average_power', log='foo')
+
+    # Test csp space transform
+    csp = CSP(transform_into='csp_space')
+    assert_true(csp.transform_into == 'csp_space')
+    for log in ('foo', True, False):
+        assert_raises(ValueError, CSP, transform_into='csp_space', log=log)
+    n_components = 2
+    csp = CSP(n_components=n_components, transform_into='csp_space')
+    Xt = csp.fit(epochs_data, epochs.events[:, 2]).transform(epochs_data)
+    feature_shape = [len(epochs_data), n_components, epochs_data.shape[2]]
+    assert_array_equal(Xt.shape, feature_shape)
+
 
 @requires_sklearn
 def test_regularized_csp():