Repository: mne-tools/mne-python

Issue:
BUG: get_coef in a pipeline does not work for CSP
At least for the CSP pipeline we use in MNE-BIDS-Pipeline:
```
scaler = Scaler(epochs.info)
pca = UnsupervisedSpatialFilter(PCA(rank, whiten=True), average=False)
csp = CSP(n_components=n_components, reg=0.1)
lr = LinearModel(LogisticRegression(solver="liblinear", random_state=random_state))
steps = [("scaler", scaler), ("PCA", pca), ("CSP", csp), ("LR", lr)]
clf = Pipeline(steps)
X = epochs.get_data()
labels = epochs.events[:, 2]
clf.fit(X, labels)
```
doing this does not work:
```
get_coef(clf, "patterns_", inverse_transform=True)
```
as the result has shape `(n_components,)`. Working backward explicitly does work:
```
coef = csp.patterns_[:n_components]
assert coef.shape == (n_components, pca.estimator.n_components_), coef.shape
coef = pca.estimator.inverse_transform(coef)
assert coef.shape == (n_components, len(epochs.ch_names)), coef.shape
coef = scaler.inverse_transform(coef.T[np.newaxis])[0]
assert coef.shape == (len(epochs.ch_names), n_components), coef.shape
```
So some investigation is needed into why `get_coef` doesn't inverse transform successfully here.


Discussion:

Resolving patch:
diff --git a/mne/decoding/base.py b/mne/decoding/base.py
--- a/mne/decoding/base.py
+++ b/mne/decoding/base.py
@@ -15,7 +15,7 @@
 
 from ..fixes import BaseEstimator, _check_fit_params, _get_check_scoring
 from ..parallel import parallel_func
-from ..utils import verbose, warn
+from ..utils import _pl, logger, verbose, warn
 
 
 class LinearModel(BaseEstimator):
@@ -207,31 +207,47 @@ def _check_estimator(estimator, get_params=True):
 
 def _get_inverse_funcs(estimator, terminal=True):
     """Retrieve the inverse functions of an pipeline or an estimator."""
-    inverse_func = [False]
+    inverse_func = list()
+    estimators = list()
     if hasattr(estimator, "steps"):
         # if pipeline, retrieve all steps by nesting
-        inverse_func = list()
         for _, est in estimator.steps:
             inverse_func.extend(_get_inverse_funcs(est, terminal=False))
+            estimators.append(est.__class__.__name__)
     elif hasattr(estimator, "inverse_transform"):
         # if not pipeline attempt to retrieve inverse function
-        inverse_func = [estimator.inverse_transform]
+        inverse_func.append(estimator.inverse_transform)
+        estimators.append(estimator.__class__.__name__)
+    else:
+        inverse_func.append(False)
+        estimators.append("Unknown")
 
     # If terminal node, check that that the last estimator is a classifier,
     # and remove it from the transformers.
     if terminal:
         last_is_estimator = inverse_func[-1] is False
-        all_invertible = False not in inverse_func[:-1]
-        if last_is_estimator and all_invertible:
+        logger.debug(f"  Last estimator is an estimator: {last_is_estimator}")
+        non_invertible = np.where(
+            [inv_func is False for inv_func in inverse_func[:-1]]
+        )[0]
+        if last_is_estimator and len(non_invertible) == 0:
             # keep all inverse transformation and remove last estimation
+            logger.debug("  Removing inverse transformation from inverse list.")
             inverse_func = inverse_func[:-1]
         else:
+            if len(non_invertible):
+                bad = ", ".join(estimators[ni] for ni in non_invertible)
+                warn(
+                    f"Cannot inverse transform non-invertible "
+                    f"estimator{_pl(non_invertible)}: {bad}."
+                )
             inverse_func = list()
 
     return inverse_func
 
 
-def get_coef(estimator, attr="filters_", inverse_transform=False):
+@verbose
+def get_coef(estimator, attr="filters_", inverse_transform=False, *, verbose=None):
     """Retrieve the coefficients of an estimator ending with a Linear Model.
 
     This is typically useful to retrieve "spatial filters" or "spatial
@@ -247,6 +263,7 @@ def get_coef(estimator, attr="filters_", inverse_transform=False):
     inverse_transform : bool
         If True, returns the coefficients after inverse transforming them with
         the transformer steps of the estimator.
+    %(verbose)s
 
     Returns
     -------
@@ -259,6 +276,7 @@ def get_coef(estimator, attr="filters_", inverse_transform=False):
     """
     # Get the coefficients of the last estimator in case of nested pipeline
     est = estimator
+    logger.debug(f"Getting coefficients from estimator: {est.__class__.__name__}")
     while hasattr(est, "steps"):
         est = est.steps[-1][1]
 
@@ -267,7 +285,9 @@ def get_coef(estimator, attr="filters_", inverse_transform=False):
     # If SlidingEstimator, loop across estimators
     if hasattr(est, "estimators_"):
         coef = list()
-        for this_est in est.estimators_:
+        for ei, this_est in enumerate(est.estimators_):
+            if ei == 0:
+                logger.debug("  Extracting coefficients from SlidingEstimator.")
             coef.append(get_coef(this_est, attr, inverse_transform))
         coef = np.transpose(coef)
         coef = coef[np.newaxis]  # fake a sample dimension
@@ -290,9 +310,11 @@ def get_coef(estimator, attr="filters_", inverse_transform=False):
         # The inverse_transform parameter will call this method on any
         # estimator contained in the pipeline, in reverse order.
         for inverse_func in _get_inverse_funcs(estimator)[::-1]:
+            logger.debug(f"  Applying inverse transformation: {inverse_func}.")
             coef = inverse_func(coef)
 
     if squeeze_first_dim:
+        logger.debug("  Squeezing first dimension of coefficients.")
         coef = coef[0]
 
     return coef
diff --git a/mne/decoding/csp.py b/mne/decoding/csp.py
--- a/mne/decoding/csp.py
+++ b/mne/decoding/csp.py
@@ -230,9 +230,9 @@ def transform(self, X):
         -------
         X : ndarray
             If self.transform_into == 'average_power' then returns the power of
-            CSP features averaged over time and shape (n_epochs, n_sources)
+            CSP features averaged over time and shape (n_epochs, n_components)
             If self.transform_into == 'csp_space' then returns the data in CSP
-            space and shape is (n_epochs, n_sources, n_times).
+            space and shape is (n_epochs, n_components, n_times).
         """
         if not isinstance(X, np.ndarray):
             raise ValueError("X should be of type ndarray (got %s)." % type(X))
@@ -255,6 +255,30 @@ def transform(self, X):
                 X /= self.std_
         return X
 
+    def inverse_transform(self, X):
+        """Project CSP features back to sensor space.
+
+        Parameters
+        ----------
+        X : array, shape (n_epochs, n_components)
+            The data in CSP power space.
+
+        Returns
+        -------
+        X : ndarray
+            The data in sensor space and shape (n_epochs, n_channels, n_components).
+        """
+        if self.transform_into != "average_power":
+            raise NotImplementedError(
+                "Can only inverse transform CSP features when transform_into is "
+                "'average_power'."
+            )
+        if not (X.ndim == 2 and X.shape[1] == self.n_components):
+            raise ValueError(
+                f"X must be 2D with X[1]={self.n_components}, got {X.shape=}"
+            )
+        return X[:, np.newaxis, :] * self.patterns_[: self.n_components].T
+
     @copy_doc(TransformerMixin.fit_transform)
     def fit_transform(self, X, y, **fit_params):  # noqa: D102
         return super().fit_transform(X, y=y, **fit_params)
@@ -924,8 +948,8 @@ def transform(self, X):
         -------
         X : ndarray
             If self.transform_into == 'average_power' then returns the power of
-            CSP features averaged over time and shape (n_epochs, n_sources)
+            CSP features averaged over time and shape (n_epochs, n_components)
             If self.transform_into == 'csp_space' then returns the data in CSP
-            space and shape is (n_epochs, n_sources, n_times).
+            space and shape is (n_epochs, n_components, n_times).
         """
         return super().transform(X)
diff --git a/mne/decoding/transformer.py b/mne/decoding/transformer.py
--- a/mne/decoding/transformer.py
+++ b/mne/decoding/transformer.py
@@ -217,7 +217,7 @@ def inverse_transform(self, epochs_data):
 
         Parameters
         ----------
-        epochs_data : array, shape (n_epochs, n_channels, n_times)
+        epochs_data : array, shape ([n_epochs, ]n_channels, n_times)
             The data.
 
         Returns
@@ -230,8 +230,16 @@ def inverse_transform(self, epochs_data):
         This function makes a copy of the data before the operations and the
         memory usage may be large with big data.
         """
+        squeeze = False
+        # Can happen with CSP
+        if epochs_data.ndim == 2:
+            squeeze = True
+            epochs_data = epochs_data[..., np.newaxis]
         assert epochs_data.ndim == 3, epochs_data.shape
-        return _sklearn_reshape_apply(self._scaler.inverse_transform, True, epochs_data)
+        out = _sklearn_reshape_apply(self._scaler.inverse_transform, True, epochs_data)
+        if squeeze:
+            out = out[..., 0]
+        return out
 
 
 class Vectorizer(TransformerMixin):


Test patch:
diff --git a/mne/decoding/tests/test_base.py b/mne/decoding/tests/test_base.py
--- a/mne/decoding/tests/test_base.py
+++ b/mne/decoding/tests/test_base.py
@@ -4,6 +4,8 @@
 # License: BSD-3-Clause
 # Copyright the MNE-Python contributors.
 
+from contextlib import nullcontext
+
 import numpy as np
 import pytest
 from numpy.testing import (
@@ -132,16 +134,23 @@ def inverse_transform(self, X):
         assert expected_n == len(_get_inverse_funcs(est))
 
     bad_estimators = [
-        Clf(),  # no preprocessing
-        Inv(),  # final estimator isn't classifier
-        make_pipeline(NoInv(), Clf()),  # first step isn't invertible
+        Clf(),  # 0: no preprocessing
+        Inv(),  # 1: final estimator isn't classifier
+        make_pipeline(NoInv(), Clf()),  # 2: first step isn't invertible
         make_pipeline(
             Inv(), make_pipeline(Inv(), NoInv()), Clf()
-        ),  # nested step isn't invertible
+        ),  # 3: nested step isn't invertible
     ]
-    for est in bad_estimators:
+    # It's the NoInv that triggers the warning, but too hard to context manage just
+    # the correct part of the bad_estimators loop
+    for ei, est in enumerate(bad_estimators):
         est.fit(X, y)
-        invs = _get_inverse_funcs(est)
+        if ei in (2, 3):  # the NoInv indices
+            ctx = pytest.warns(RuntimeWarning, match="Cannot inverse transform")
+        else:
+            ctx = nullcontext()
+        with ctx:
+            invs = _get_inverse_funcs(est)
         assert_equal(invs, list())
 
     # II. Test get coef for classification/regression estimators and pipelines
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
@@ -10,10 +10,15 @@
 
 import numpy as np
 import pytest
-from numpy.testing import assert_array_almost_equal, assert_array_equal, assert_equal
+from numpy.testing import (
+    assert_allclose,
+    assert_array_almost_equal,
+    assert_array_equal,
+    assert_equal,
+)
 
 from mne import Epochs, io, pick_types, read_events
-from mne.decoding import CSP, Scaler, SPoC
+from mne.decoding import CSP, LinearModel, Scaler, SPoC, get_coef
 from mne.decoding.csp import _ajd_pham
 from mne.utils import catch_logging
 
@@ -299,6 +304,7 @@ def test_regularized_csp(ch_type, rank, reg):
     n_components = 3
 
     sc = Scaler(epochs.info)
+    epochs_data_orig = epochs_data.copy()
     epochs_data = sc.fit_transform(epochs_data)
     csp = CSP(n_components=n_components, reg=reg, norm_trace=False, rank=rank)
     with catch_logging(verbose=True) as log:
@@ -333,10 +339,27 @@ def test_regularized_csp(ch_type, rank, reg):
     assert sources.shape[1] == n_components
 
     cv = StratifiedKFold(5)
-    clf = make_pipeline(csp, LogisticRegression(solver="liblinear"))
-    score = cross_val_score(clf, epochs_data, y, cv=cv, scoring="roc_auc").mean()
+    clf = make_pipeline(
+        sc,
+        csp,
+        LinearModel(LogisticRegression(solver="liblinear")),
+    )
+    score = cross_val_score(clf, epochs_data_orig, y, cv=cv, scoring="roc_auc").mean()
     assert 0.75 <= score <= 1.0
 
+    # Test get_coef on CSP
+    clf.fit(epochs_data_orig, y)
+    coef = csp.patterns_[:n_components]
+    assert coef.shape == (n_components, n_channels), coef.shape
+    coef = sc.inverse_transform(coef.T[np.newaxis])[0]
+    assert coef.shape == (len(epochs.ch_names), n_components), coef.shape
+    coef_mne = get_coef(clf, "patterns_", inverse_transform=True, verbose="debug")
+    assert coef.shape == coef_mne.shape
+    coef_mne /= np.linalg.norm(coef_mne, axis=0)
+    coef /= np.linalg.norm(coef, axis=0)
+    coef *= np.sign(np.sum(coef_mne * coef, axis=0))
+    assert_allclose(coef_mne, coef)
+
 
 def test_csp_pipeline():
     """Test if CSP works in a pipeline."""