Repository: mne-tools/mne-python

Issue:
Pairing arrange of CSP filters removed
I have a question about the next lines: 

https://github.com/mne-tools/mne-python/pull/3485/commits/f4b39c7cdf71d58d8f8b950b5ee33aaa4d453b15#diff-12d11fc13bc79dc930808d5394b788b2L148-L150

Currently (from maint 0.13 until now) the pairing arrange of the columns (first, last, second, second last...) has been removed. Have you done this for a reason? 

If I understand correctly, in the current implementation the arrange of the filters is based only in the eigenvalues (descending order), so one is not able to identify the CSP pairs.  

@kingjr  @cbrnr  @mbillingr  

Thanks in advance. 



Discussion:
ping @cbrnr or @mbillingr maybe ?
cc @alexandrebarachant

On Fri, 31 Jul 2020 at 13:47, KarnBaca <notifications@github.com> wrote:

> I have a question about the next lines:
>
> f4b39c7#diff-12d11fc13bc79dc930808d5394b788b2L148-L150
> <https://github.com/mne-tools/mne-python/commit/f4b39c7cdf71d58d8f8b950b5ee33aaa4d453b15#diff-12d11fc13bc79dc930808d5394b788b2L148-L150>
>
> Currently (from maint 0.13 until now) the pairing arrange of the columns
> (first, last, second, second last...) has been removed. Have you done this
> for a reason?
>
> If I understand correctly, in the current implementation the arrange of
> the filters is based only in the eigenvalues (descending order), so one is
> not able to identify the CSP pairs.
>
> @kingjr <https://github.com/kingjr> @cbrnr <https://github.com/cbrnr>
> @mbillingr <https://github.com/mbillingr>
>
> Thanks in advance.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/mne-tools/mne-python/issues/8074>, or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABFHWDA7LKUYRQ3RBDUSW5DR6KVLPANCNFSM4PPXEM5A>
> .
>

I think this line has the same effect:
`ix = np.argsort(np.abs(eigen_values - 0.5))[::-1]`

It seems a bit brittle because it relies on eigenvalues being pairwise symmetric around 0.5.
> It seems a bit brittle because it relies on eigenvalues being pairwise symmetric around 0.5.

Does this mean you assume that the eigenvalues are normalized? And why 0.5? Is this documented somewhere (https://docs.scipy.org/doc/scipy/reference/generated/scipy.linalg.eigh.html)?
> Does this mean you assume that the eigenvalues are normalized?

They are, in a way. `eigh` normalized the eigenvectors and the covariance matrices are normalized to a trace of 1.

> And why 0.5?

The eigenvectors are all exactly 0.5 if you plug in identity covariance matrices. I don't know if this transfer to the general case.


Regardless if the new code is correct, I think the old code is better because it is more explicit about what it does.
I agree that the old code is better because it is more explicit. This question keeps coming up again and again, and we should either revert to the old code with explicit sorting, or at least include detailed comments explaining what is going on in the current code. Do you have time to implement this in a PR?
If it's not urgent I can do it but it will take me a few weeks at least.

This may be an opportunity to review if the multi-class implementation could handle two classes without a special case.
There's no hurry, it'd be great if you could go over the existing code, because I don't really understand what's going on in many places, e.g. https://github.com/mne-tools/mne-python/blob/master/mne/decoding/csp.py#L235 - some comments would have been great. If the two-class and multi-class branches can be unified this would also make the code a bit easier to understand (probably).

Resolving patch:
diff --git a/examples/decoding/plot_decoding_csp_eeg.py b/examples/decoding/plot_decoding_csp_eeg.py
--- a/examples/decoding/plot_decoding_csp_eeg.py
+++ b/examples/decoding/plot_decoding_csp_eeg.py
@@ -5,8 +5,8 @@
 Motor imagery decoding from EEG data using the Common Spatial Pattern (CSP)
 ===========================================================================
 
-Decoding of motor imagery applied to EEG data decomposed using CSP.
-Here the classifier is applied to features extracted on CSP filtered signals.
+Decoding of motor imagery applied to EEG data decomposed using CSP. A
+classifier is then applied to features extracted on CSP-filtered signals.
 
 See https://en.wikipedia.org/wiki/Common_spatial_pattern and [1]_. The EEGBCI
 dataset is documented in [2]_. The data set is available at PhysioNet [3]_.
diff --git a/examples/decoding/plot_decoding_csp_timefreq.py b/examples/decoding/plot_decoding_csp_timefreq.py
--- a/examples/decoding/plot_decoding_csp_timefreq.py
+++ b/examples/decoding/plot_decoding_csp_timefreq.py
@@ -1,7 +1,7 @@
 """
-============================================================================
-Decoding in time-frequency space data using the Common Spatial Pattern (CSP)
-============================================================================
+====================================================================
+Decoding in time-frequency space using Common Spatial Patterns (CSP)
+====================================================================
 
 The time-frequency decomposition is estimated by iterating over raw data that
 has been band-passed at different frequencies. This is used to compute a
@@ -49,9 +49,9 @@
 clf = make_pipeline(CSP(n_components=4, reg=None, log=True, norm_trace=False),
                     LinearDiscriminantAnalysis())
 n_splits = 5  # how many folds to use for cross-validation
-cv = StratifiedKFold(n_splits=n_splits, shuffle=True)
+cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
 
-# Classification & Time-frequency parameters
+# Classification & time-frequency parameters
 tmin, tmax = -.200, 2.000
 n_cycles = 10.  # how many complete cycles: used to define window size
 min_freq = 5.
diff --git a/mne/decoding/csp.py b/mne/decoding/csp.py
--- a/mne/decoding/csp.py
+++ b/mne/decoding/csp.py
@@ -12,52 +12,52 @@
 import numpy as np
 from scipy import linalg
 
-from .mixin import TransformerMixin
 from .base import BaseEstimator
+from .mixin import TransformerMixin
 from ..cov import _regularized_covariance
-from ..utils import fill_doc, _check_option
+from ..utils import fill_doc, _check_option, _validate_type
 
 
 @fill_doc
 class CSP(TransformerMixin, BaseEstimator):
-    u"""M/EEG signal decomposition using the Common Spatial Patterns (CSP).
+    """M/EEG signal decomposition using the Common Spatial Patterns (CSP).
 
-    This object can be used as a supervised decomposition to estimate
-    spatial filters for feature extraction in a 2 class decoding problem.
-    CSP in the context of EEG was first described in [1]; a comprehensive
-    tutorial on CSP can be found in [2]. Multiclass solving is implemented
-    from [3].
+    This class can be used as a supervised decomposition to estimate spatial
+    filters for feature extraction. CSP in the context of EEG was first
+    described in :footcite:`KolesEtAl1990`; a comprehensive tutorial on CSP can
+    be found in :footcite:`BlankertzEtAl2008`. Multi-class solving is
+    implemented from :footcite:`Grosse-WentrupBuss2008`.
 
     Parameters
     ----------
-    n_components : int, default 4
-        The number of components to decompose M/EEG signals.
-        This number should be set by cross-validation.
+    n_components : int (default 4)
+        The number of components to decompose M/EEG signals. This number should
+        be set by cross-validation.
     reg : float | str | None (default None)
-        If not None (same as ``'empirical'``, default), allow
-        regularization for covariance estimation.
-        If float, shrinkage is used (0 <= shrinkage <= 1).
-        For str options, ``reg`` will be passed to ``method`` to
+        If not None (same as ``'empirical'``, default), allow regularization
+        for covariance estimation. If float (between 0 and 1), shrinkage is
+        used. For str values, ``reg`` will be passed as ``method`` to
         :func:`mne.compute_covariance`.
     log : None | bool (default 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', default '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'.
-    norm_trace : bool
-        Normalize class covariance by its trace. Defaults to False. Trace
-        normalization is a step of the original CSP algorithm [1]_ to eliminate
+        If ``transform_into`` equals ``'average_power'`` and ``log`` is None or
+        True, then apply a log transform to standardize features, else features
+        are z-scored. If ``transform_into`` is ``'csp_space'``, ``log`` must be
+        None.
+    cov_est : 'concat' | 'epoch' (default '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' (default 'average_power')
+        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.
+    norm_trace : bool (default False)
+        Normalize class covariance by its trace. Trace normalization is a step
+        of the original CSP algorithm :footcite:`KolesEtAl1990` to eliminate
         magnitude variations in the EEG between individuals. It is not applied
-        in more recent work [2]_, [3]_ and can have a negative impact on
-        patterns ordering.
+        in more recent work :footcite:`BlankertzEtAl2008`,
+        :footcite:`Grosse-WentrupBuss2008` and can have a negative impact on
+        pattern order.
     cov_method_params : dict | None
         Parameters to pass to :func:`mne.compute_covariance`.
 
@@ -65,6 +65,16 @@ class CSP(TransformerMixin, BaseEstimator):
     %(rank_None)s
 
         .. versionadded:: 0.17
+    component_order : 'mutual_info' | 'alternate' (default 'mutual_info')
+        If ``'mutual_info'`` order components by decreasing mutual information
+        (in the two-class case this uses a simplification which orders
+        components by decreasing absolute deviation of the eigenvalues from 0.5
+        :footcite:`BarachantEtAl2010`). For the two-class case, ``'alternate'``
+        orders components by starting with the largest eigenvalue, followed by
+        the smallest, the second-to-largest, the second-to-smallest, and so on
+        :footcite:`BlankertzEtAl2008`.
+
+        .. versionadded:: 0.21
 
     Attributes
     ----------
@@ -83,28 +93,18 @@ class CSP(TransformerMixin, BaseEstimator):
 
     References
     ----------
-    .. [1] Zoltan J. Koles, Michael S. Lazar, Steven Z. Zhou. Spatial Patterns
-           Underlying Population Differences in the Background EEG. Brain
-           Topography 2(4), 275-284, 1990.
-    .. [2] Benjamin Blankertz, Ryota Tomioka, Steven Lemm, Motoaki Kawanabe,
-           Klaus-Robert Müller. Optimizing Spatial Filters for Robust EEG
-           Single-Trial Analysis. IEEE Signal Processing Magazine 25(1), 41-56,
-           2008.
-    .. [3] Grosse-Wentrup, Moritz, and Martin Buss. Multiclass common spatial
-           patterns and information theoretic feature extraction. IEEE
-           Transactions on Biomedical Engineering, Vol 55, no. 8, 2008.
+    .. footbibliography::
     """
 
-    def __init__(self, n_components=4, reg=None, log=None, cov_est="concat",
+    def __init__(self, n_components=4, reg=None, log=None, cov_est='concat',
                  transform_into='average_power', norm_trace=False,
-                 cov_method_params=None, rank=None):
-        """Init of CSP."""
+                 cov_method_params=None, rank=None,
+                 component_order='mutual_info'):
         # Init default CSP
         if not isinstance(n_components, int):
             raise ValueError('n_components must be an integer.')
         self.n_components = n_components
         self.rank = rank
-
         self.reg = reg
 
         # Init default cov_est
@@ -128,13 +128,18 @@ def __init__(self, n_components=4, reg=None, log=None, cov_est="concat",
                                  '"csp_space".')
         self.log = log
 
-        if not isinstance(norm_trace, bool):
-            raise ValueError('norm_trace must be a bool.')
+        _validate_type(norm_trace, bool, 'norm_trace')
         self.norm_trace = norm_trace
         self.cov_method_params = cov_method_params
+        _check_option('component_order', component_order,
+                      ('mutual_info', 'alternate'))
+        self.component_order = component_order
 
     def _check_Xy(self, X, y=None):
-        """Aux. function to check input data."""
+        """Check input data."""
+        if not isinstance(X, np.ndarray):
+            raise ValueError("X should be of type ndarray (got %s)."
+                             % type(X))
         if y is not None:
             if len(X) != len(y) or len(y) < 1:
                 raise ValueError('X and y must have the same length.')
@@ -156,87 +161,24 @@ def fit(self, X, y):
         self : instance of CSP
             Returns the modified instance.
         """
-        if not isinstance(X, np.ndarray):
-            raise ValueError("X should be of type ndarray (got %s)."
-                             % type(X))
         self._check_Xy(X, y)
-        n_channels = X.shape[1]
 
         self._classes = np.unique(y)
         n_classes = len(self._classes)
         if n_classes < 2:
             raise ValueError("n_classes must be >= 2.")
+        if n_classes > 2 and self.component_order == 'alternate':
+            raise ValueError("component_order='alternate' requires two "
+                             "classes, but data contains {} classes; use "
+                             "component_order='mutual_info' "
+                             "instead.".format(n_classes))
+
+        covs, sample_weights = self._compute_covariance_matrices(X, y)
+        eigen_vectors, eigen_values = self._decompose_covs(covs,
+                                                           sample_weights)
+        ix = self._order_components(covs, sample_weights, eigen_vectors,
+                                    eigen_values, self.component_order)
 
-        covs = np.zeros((n_classes, n_channels, n_channels))
-        sample_weights = list()
-        for class_idx, this_class in enumerate(self._classes):
-            if self.cov_est == "concat":  # concatenate epochs
-                class_ = np.transpose(X[y == this_class], [1, 0, 2])
-                class_ = class_.reshape(n_channels, -1)
-                cov = _regularized_covariance(
-                    class_, reg=self.reg, method_params=self.cov_method_params,
-                    rank=self.rank)
-                weight = sum(y == this_class)
-            elif self.cov_est == "epoch":
-                class_ = X[y == this_class]
-                cov = np.zeros((n_channels, n_channels))
-                for this_X in class_:
-                    cov += _regularized_covariance(
-                        this_X, reg=self.reg,
-                        method_params=self.cov_method_params,
-                        rank=self.rank)
-                cov /= len(class_)
-                weight = len(class_)
-
-            covs[class_idx] = cov
-            if self.norm_trace:
-                # Append covariance matrix and weight. Prior to version 0.15,
-                # trace normalization was applied, but was breaking results for
-                # some usecases by changing the apparent ranking of patterns.
-                # Trace normalization of the covariance matrix was removed
-                # without signigificant effect on patterns or performances.
-                # If the user interested in this feature, we suggest trace
-                # normalization of the epochs prior to the CSP.
-                covs[class_idx] /= np.trace(cov)
-
-            sample_weights.append(weight)
-
-        if n_classes == 2:
-            eigen_values, eigen_vectors = linalg.eigh(covs[0], covs.sum(0))
-            # sort eigenvectors
-            ix = np.argsort(np.abs(eigen_values - 0.5))[::-1]
-        else:
-            # The multiclass case is adapted from
-            # http://github.com/alexandrebarachant/pyRiemann
-            eigen_vectors, D = _ajd_pham(covs)
-
-            # Here we apply an euclidean mean. See pyRiemann for other metrics
-            mean_cov = np.average(covs, axis=0, weights=sample_weights)
-            eigen_vectors = eigen_vectors.T
-
-            # normalize
-            for ii in range(eigen_vectors.shape[1]):
-                tmp = np.dot(np.dot(eigen_vectors[:, ii].T, mean_cov),
-                             eigen_vectors[:, ii])
-                eigen_vectors[:, ii] /= np.sqrt(tmp)
-
-            # class probability
-            class_probas = [np.mean(y == _class) for _class in self._classes]
-
-            # mutual information
-            mutual_info = []
-            for jj in range(eigen_vectors.shape[1]):
-                aa, bb = 0, 0
-                for (cov, prob) in zip(covs, class_probas):
-                    tmp = np.dot(np.dot(eigen_vectors[:, jj].T, cov),
-                                 eigen_vectors[:, jj])
-                    aa += prob * np.log(np.sqrt(tmp))
-                    bb += prob * (tmp ** 2 - 1)
-                mi = - (aa + (3.0 / 16) * (bb ** 2))
-                mutual_info.append(mi)
-            ix = np.argsort(mutual_info)[::-1]
-
-        # sort eigenvectors
         eigen_vectors = eigen_vectors[:, ix]
 
         self.filters_ = eigen_vectors.T
@@ -245,7 +187,7 @@ def fit(self, X, y):
         pick_filters = self.filters_[:self.n_components]
         X = np.asarray([np.dot(pick_filters, epoch) for epoch in X])
 
-        # compute features (mean band power)
+        # compute features (mean power)
         X = (X ** 2).mean(axis=2)
 
         # To standardize features
@@ -545,6 +487,106 @@ def plot_filters(self, info, components=None, ch_type=None,
             mask=mask, outlines=outlines, contours=contours,
             image_interp=image_interp, show=show, average=average)
 
+    def _compute_covariance_matrices(self, X, y):
+        _, n_channels, _ = X.shape
+
+        if self.cov_est == "concat":
+            cov_estimator = self._concat_cov
+        elif self.cov_est == "epoch":
+            cov_estimator = self._epoch_cov
+
+        covs = []
+        sample_weights = []
+        for this_class in self._classes:
+            cov, weight = cov_estimator(X[y == this_class])
+
+            if self.norm_trace:
+                cov /= np.trace(cov)
+
+            covs.append(cov)
+            sample_weights.append(weight)
+
+        return np.stack(covs), np.array(sample_weights)
+
+    def _concat_cov(self, x_class):
+        """Concatenate epochs before computing the covariance."""
+        _, n_channels, _ = x_class.shape
+
+        x_class = np.transpose(x_class, [1, 0, 2])
+        x_class = x_class.reshape(n_channels, -1)
+        cov = _regularized_covariance(
+            x_class, reg=self.reg, method_params=self.cov_method_params,
+            rank=self.rank)
+        weight = x_class.shape[0]
+
+        return cov, weight
+
+    def _epoch_cov(self, x_class):
+        """Mean of per-epoch covariances."""
+        cov = sum(_regularized_covariance(
+            this_X, reg=self.reg,
+            method_params=self.cov_method_params,
+            rank=self.rank) for this_X in x_class)
+        cov /= len(x_class)
+        weight = len(x_class)
+
+        return cov, weight
+
+    def _decompose_covs(self, covs, sample_weights):
+        n_classes = len(covs)
+        if n_classes == 2:
+            eigen_values, eigen_vectors = linalg.eigh(covs[0], covs.sum(0))
+        else:
+            # The multiclass case is adapted from
+            # http://github.com/alexandrebarachant/pyRiemann
+            eigen_vectors, D = _ajd_pham(covs)
+            eigen_vectors = self._normalize_eigenvectors(eigen_vectors.T, covs,
+                                                         sample_weights)
+            eigen_values = None
+        return eigen_vectors, eigen_values
+
+    def _compute_mutual_info(self, covs, sample_weights, eigen_vectors):
+        class_probas = sample_weights / sample_weights.sum()
+
+        mutual_info = []
+        for jj in range(eigen_vectors.shape[1]):
+            aa, bb = 0, 0
+            for (cov, prob) in zip(covs, class_probas):
+                tmp = np.dot(np.dot(eigen_vectors[:, jj].T, cov),
+                             eigen_vectors[:, jj])
+                aa += prob * np.log(np.sqrt(tmp))
+                bb += prob * (tmp ** 2 - 1)
+            mi = - (aa + (3.0 / 16) * (bb ** 2))
+            mutual_info.append(mi)
+
+        return mutual_info
+
+    def _normalize_eigenvectors(self, eigen_vectors, covs, sample_weights):
+        # Here we apply an euclidean mean. See pyRiemann for other metrics
+        mean_cov = np.average(covs, axis=0, weights=sample_weights)
+
+        for ii in range(eigen_vectors.shape[1]):
+            tmp = np.dot(np.dot(eigen_vectors[:, ii].T, mean_cov),
+                         eigen_vectors[:, ii])
+            eigen_vectors[:, ii] /= np.sqrt(tmp)
+        return eigen_vectors
+
+    def _order_components(self, covs, sample_weights, eigen_vectors,
+                          eigen_values, component_order):
+        n_classes = len(self._classes)
+        if component_order == 'mutual_info' and n_classes > 2:
+            mutual_info = self._compute_mutual_info(covs, sample_weights,
+                                                    eigen_vectors)
+            ix = np.argsort(mutual_info)[::-1]
+        elif component_order == 'mutual_info' and n_classes == 2:
+            ix = np.argsort(np.abs(eigen_values - 0.5))[::-1]
+        elif component_order == 'alternate' and n_classes == 2:
+            i = np.argsort(eigen_values)
+            ix = np.empty_like(i)
+            ix[1::2] = i[:len(i) // 2]
+            ix[0::2] = i[len(i) // 2:][::-1]
+        return ix
+
 
 def _ajd_pham(X, eps=1e-6, max_iter=15):
     """Approximate joint diagonalization based on Pham's algorithm.
@@ -722,9 +764,6 @@ def fit(self, X, y):
         self : instance of SPoC
             Returns the modified instance.
         """
-        if not isinstance(X, np.ndarray):
-            raise ValueError("X should be of type ndarray (got %s)."
-                             % type(X))
         self._check_Xy(X, y)
 
         if len(np.unique(y)) < 2:


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
@@ -7,8 +7,8 @@
 
 import os.path as op
 
-import pytest
 import numpy as np
+import pytest
 from numpy.testing import (assert_array_almost_equal, assert_array_equal,
                            assert_equal)
 
@@ -47,6 +47,44 @@ def simulate_data(target, n_trials=100, n_channels=10, random_state=42):
     return X, mixing_mat
 
 
+def deterministic_toy_data(classes=('class_a', 'class_b')):
+    """Generate a small deterministic toy data set.
+
+    Four independent sources are modulated by the target class and mixed
+    into signal space.
+    """
+    sources_a = np.array([[0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
+                          [0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1],
+                          [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1],
+                          [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1]],
+                         dtype=float) * 2 - 1
+
+    sources_b = np.array([[0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
+                          [0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1],
+                          [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1],
+                          [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1]],
+                         dtype=float) * 2 - 1
+
+    sources_a[0, :] *= 1
+    sources_a[1, :] *= 2
+
+    sources_b[2, :] *= 3
+    sources_b[3, :] *= 4
+
+    mixing = np.array([[1.0, 0.8, 0.6, 0.4],
+                       [0.8, 1.0, 0.8, 0.6],
+                       [0.6, 0.8, 1.0, 0.8],
+                       [0.4, 0.6, 0.8, 1.0]])
+
+    x_class_a = mixing @ sources_a
+    x_class_b = mixing @ sources_b
+
+    x = np.stack([x_class_a, x_class_b])
+    y = np.array(classes)
+
+    return x, y
+
+
 @pytest.mark.slowtest
 def test_csp():
     """Test Common Spatial Patterns algorithm on epochs."""
@@ -71,7 +109,8 @@ def test_csp():
         CSP(reg=reg, norm_trace=False)
     for cov_est in ['foo', None]:
         pytest.raises(ValueError, CSP, cov_est=cov_est, norm_trace=False)
-    pytest.raises(ValueError, CSP, norm_trace='foo')
+    with pytest.raises(TypeError, match='instance of bool'):
+        CSP(norm_trace='foo')
     for cov_est in ['concat', 'epoch']:
         CSP(cov_est=cov_est, norm_trace=False)
 
@@ -280,3 +319,40 @@ def test_spoc():
     out = spoc.transform(X)
     corr = np.abs(np.corrcoef(out[:, 0], y)[0, 1])
     assert np.abs(corr) > 0.85
+
+
+def test_csp_twoclass_symmetry():
+    """Test that CSP is symmetric when swapping classes."""
+    x, y = deterministic_toy_data(['class_a', 'class_b'])
+    csp = CSP(norm_trace=False, transform_into='average_power', log=True)
+    log_power = csp.fit_transform(x, y)
+    log_power_ratio_ab = log_power[0] - log_power[1]
+
+    x, y = deterministic_toy_data(['class_b', 'class_a'])
+    csp = CSP(norm_trace=False, transform_into='average_power', log=True)
+    log_power = csp.fit_transform(x, y)
+    log_power_ratio_ba = log_power[0] - log_power[1]
+
+    assert_array_almost_equal(log_power_ratio_ab,
+                              log_power_ratio_ba)
+
+
+def test_csp_component_ordering():
+    """Test that CSP component ordering works as expected."""
+    x, y = deterministic_toy_data(['class_a', 'class_b'])
+
+    pytest.raises(ValueError, CSP, component_order='invalid')
+
+    # component_order='alternate' only works with two classes
+    csp = CSP(component_order='alternate')
+    with pytest.raises(ValueError):
+        csp.fit(np.zeros((3, 0, 0)), ['a', 'b', 'c'])
+
+    p_alt = CSP(component_order='alternate').fit(x, y).patterns_
+    p_mut = CSP(component_order='mutual_info').fit(x, y).patterns_
+
+    # This permutation of p_alt and p_mut is explained by the particular
+    # eigenvalues of the toy data: [0.06, 0.1,   0.5,  0.8].
+    # p_alt arranges them to [0.8, 0.06, 0.5, 0.1]
+    # p_mut arranges them to [0.06, 0.1, 0.8, 0.5]
+    assert_array_almost_equal(p_alt, p_mut[[2, 0, 3, 1]])