Repository: mne-tools/mne-python

Issue:
TemporalFilter Pipeline transition bandwidth
### Describe the bug

`mne.decoding.TemporalFilter` and `mne.filter.filter_data` behave differently. Specifically, in the `transform` method of `mne.decoding.TemporalFilter`, the [`_triage_filter_params`](https://github.com/mne-tools/mne-python/blob/9e4a0b492299d3638203e2e6d2264ea445b13ac0/mne/decoding/transformer.py#L832) is called immediately preceding the `filter_data`. The default transition bandwidths are calculated and returned from `_traige_filter_params`, but this function seems that it may be called *again* [here](https://github.com/mne-tools/mne-python/blob/9e4a0b492299d3638203e2e6d2264ea445b13ac0/mne/filter.py#L1010) inside `filter_data`'s call to `create_filter`, resulting in applying the transition bandwidth calculations twice.


### Steps to reproduce

[Minimal working example](https://mne.discourse.group/t/temporalfilter-pipeline-transition-bandwidth/5267) provided in the MNE forums.

### Expected results

Results from `filter_data` and `TmpeoralFilter.transform` should match

### Actual results

**When using TemporalFilter.transform**

```
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 8.00
- Lower transition bandwidth: 6.00 Hz (-6 dB cutoff frequency: 5.00 Hz)
- Upper passband edge: 60.00 Hz
- Upper transition bandwidth: 75.00 Hz (-6 dB cutoff frequency: 97.50 Hz)
```

**When using filter_data**

```
Setting up band-pass filter from 8 - 60 Hz
FIR filter parameters
---------------------
Designing a one-pass, zero-phase, non-causal bandpass filter:
- Windowed time-domain design (firwin) method
- Hamming window with 0.0194 passband ripple and 53 dB stopband attenuation
- Lower passband edge: 8.00
- Lower transition bandwidth: 2.00 Hz (-6 dB cutoff frequency: 7.00 Hz)
- Upper passband edge: 60.00 Hz
- Upper transition bandwidth: 15.00 Hz (-6 dB cutoff frequency: 67.50 Hz)
- Filter length: 265 samples (1.656 sec)
```

**Notice the difference in the transition bandwidths**

### Additional information

```
Platform:         macOS-12.4-arm64-arm-64bit
Python:           3.9.12 (main, Jun  1 2022, 06:34:44)  [Clang 12.0.0 ]
Executable:       /opt/homebrew/Caskroom/miniconda/base/envs/f30/bin/python
CPU:              arm: 8 cores
Memory:           16.0 GB

mne:              1.0.3
numpy:            1.23.0 {}
scipy:            1.8.1
matplotlib:       3.5.2 {backend=MacOSX}

sklearn:          1.1.1
numba:            Not found
nibabel:          4.0.1
nilearn:          Not found
dipy:             Not found
cupy:             Not found
pandas:           1.4.3
pyvista:          Not found
pyvistaqt:        Not found
ipyvtklink:       Not found
vtk:              Not found
PyQt5:            Not found
ipympl:           0.9.1
pooch:            v1.6.0

mne_bids:         Not found
mne_nirs:         Not found
mne_features:     Not found
mne_qt_browser:   Not found
mne_connectivity: Not found
```


Discussion:
Hello! 👋 Thanks for opening your first issue here! ❤️ We will try to get back to you soon. 🚴🏽‍♂️

thx @kevincar for the bug report. Can you stab at the fix? 🙏 
It might just be that `filter_data` and `TemporalFilter` have different default values (which we could fix via deprecation) or it could be that the parameters are not passed/used properly, which would be a bugfix.
`filter_data` and `TemporalFilter` have the same default values:
```python
    @verbose
    def __init__(self, l_freq=None, h_freq=None, sfreq=1.0,
                 filter_length='auto', l_trans_bandwidth='auto',
                 h_trans_bandwidth='auto', n_jobs=None, method='fir',
                 iir_params=None, fir_window='hamming', fir_design='firwin',
                 *, verbose=None):  # noqa: D102
```
```python
@verbose
def filter_data(data, sfreq, l_freq, h_freq, picks=None, filter_length='auto',
                l_trans_bandwidth='auto', h_trans_bandwidth='auto',
                n_jobs=None, method='fir', iir_params=None, copy=True,
                phase='zero', fir_window='hamming', fir_design='firwin',
                pad='reflect_limited', *, verbose=None):
 ```
One question that I have concerns the `TemporalFilter` transform function at the very bottom of `transform.py` in decoding:
```python

    def transform(self, X):
        """Filter data along the last dimension.

        Parameters
        ----------
        X : array, shape (n_epochs, n_channels, n_times) or shape (n_channels, n_times)
            The data to be filtered over the last dimension. The channels
            dimension can be zero when passing a 2D array.

        Returns
        -------
        X : array
            The data after filtering.
        """  # noqa: E501
        X = np.atleast_2d(X)

        if X.ndim > 3:
            raise ValueError("Array must be of at max 3 dimensions instead "
                             "got %s dimensional matrix" % (X.ndim))

        shape = X.shape
        X = X.reshape(-1, shape[-1])
        (X, self.sfreq, self.l_freq, self.h_freq, self.l_trans_bandwidth,
         self.h_trans_bandwidth, self.filter_length, _, self.fir_window,
         self.fir_design) = \
            _triage_filter_params(X, self.sfreq, self.l_freq, self.h_freq,
                                  self.l_trans_bandwidth,
                                  self.h_trans_bandwidth, self.filter_length,
                                  self.method, phase='zero',
                                  fir_window=self.fir_window,
                                  fir_design=self.fir_design)
        X = filter_data(X, self.sfreq, self.l_freq, self.h_freq,
                        filter_length=self.filter_length,
                        l_trans_bandwidth=self.l_trans_bandwidth,
                        h_trans_bandwidth=self.h_trans_bandwidth,
                        n_jobs=self.n_jobs, method=self.method,
                        iir_params=self.iir_params, copy=False,
                        fir_window=self.fir_window, fir_design=self.fir_design)
        return X.reshape(shape)
```
` _triage_filter_params` gets called before `filter_data`, yet `filter_data` calls `create_filter` which calls `_triage_filter_params`:
```python
            data, sfreq, f_s1, f_s2, f_p1, f_p2, filter_length, phase, \
                fir_window, fir_design = _triage_filter_params(
                    data, sfreq, h_freq, l_freq, h_trans_bandwidth,
                    l_trans_bandwidth, filter_length, method, phase,
                    fir_window, fir_design, bands='arr', reverse=True)
            if method == 'iir':
```
Why does `transform` call `_triage_filter_params` if `filter_data` implicitly calls `_triage_filter_params`?
> Why does transform call _triage_filter_params if filter_data implicitly calls _triage_filter_params?

I am not sure actually. But there shouldn't be any harm in doing so if `_triage_filter_params` does the right thing. You should be able to call it twice -- as long as you use the outputs of the first call appropriately, it should give the same output on the second call. If it doesn't, there is a bug in `_triage_filter_params`.

So there are two things to solve here I think

1. Figure out why calling it twice doesn't give the same output on the second call (maybe a bug with passing the inputs correctly, maybe a bug with `_triage_filter_params`)
2. Decide if we really need to call it twice given that the internal `filter_data` call might be enough

For (2) it could be something like wanting to see the logging output once at the beginning (to know what transition bandwidth you ended up with for example) but then never again during the actual filtering operations if you did multiple of them or something. Not sure... you could try changing / removing the first `_triage_filter_params` to see if anything breaks.

But either way, we should investigate (1) as well since there is more to understand here
The outputs of the first call to `triage_filter_params` are the inputs to the `filter_data` call, so while I believe you are right that `triage_filter_params` doesn't modify internal state in any way that would affect the final filter parameters, the first call is affecting the input to the second call
> the first call is affecting the input to the second call

This should be okay because calling it again with the updated parameters should be a no-op. In other words, some stuff like `filter_length='10s'` might be converted to a number of samples, but this then should pass through unaffected by another call to `_triage_filter_params`. What has me worried is in your original/top comment the transition bandwidths change:
```
- Lower transition bandwidth: 6.00 Hz (-6 dB cutoff frequency: 5.00 Hz)
...
- Lower transition bandwidth: 2.00 Hz (-6 dB cutoff frequency: 7.00 Hz)
```
suggesting that something is wrong in that calling it twice the values actually change
Well for defaults that is true, but in the `transform` function, the following line takes the outputs of the first call and modifies the internal state: 
```python
(X, self.sfreq, self.l_freq, self.h_freq, self.l_trans_bandwidth,
         self.h_trans_bandwidth, self.filter_length, _, self.fir_window,
         self.fir_design) = \
            _triage_filter_params(X, self.sfreq, self.l_freq, self.h_freq,
                                  self.l_trans_bandwidth,
                                  self.h_trans_bandwidth, self.filter_length,
                                  self.method, phase='zero',
                                  fir_window=self.fir_window,
                                  fir_design=self.fir_design)
```
If the second call then calculates the `h_trans_bandwidth` for example, using self.h_trans_bandwidth, it will be outputting another iteration on top of what was calculated in the first call.
> If the second call then calculates the h_trans_bandwidth for example, using self.h_trans_bandwidth, it will be outputting another iteration on top of what was calculated in the first call.

My point is that these should not change on a second call -- if they do, it's a bug with either how they are passed to `_triage_filter_params`, or a bug with `_triage_filter_params` itself. On the first call any `'auto'` etc. should be converted, and on a second call (using the new/updated/calculated values) you should get the same ones back.
I see, the point of `_triage_filter_params` is to convert the `auto` variables from the `__init__` method into actual values for calculation. With that in mind, I believe the bug is with `_triage_filter_params` itself, since when we commented out the first call in our environment, the code ran without breaking. This indicates that multiple calls to `_triage_filter_params` affect the parameters in a way that it shouldn't.
> This indicates that multiple calls to _triage_filter_params affect the parameters in a way that it shouldn't.

Yes this sounds plausible... that or the parameters are passed incorrectly to it (i.e., in the wrong order). 

Do you want to investigate further and try to fix `_triage_filter_params`? If not, a minimal code snippet that produces the logging outputs you posted in your top comment would be enough for someone else (me probably!) to take a look.
Ahh I see there's a minimal example already posted in the forums... I'll take a look

Resolving patch:
diff --git a/mne/decoding/transformer.py b/mne/decoding/transformer.py
--- a/mne/decoding/transformer.py
+++ b/mne/decoding/transformer.py
@@ -11,7 +11,7 @@
 from .base import BaseEstimator
 
 from .. import pick_types
-from ..filter import filter_data, _triage_filter_params
+from ..filter import filter_data
 from ..time_frequency.psd import psd_array_multitaper
 from ..utils import fill_doc, _check_option, _validate_type, verbose
 from ..io.pick import (pick_info, _pick_data_channels, _picks_by_type,
@@ -828,15 +828,6 @@ def transform(self, X):
 
         shape = X.shape
         X = X.reshape(-1, shape[-1])
-        (X, self.sfreq, self.l_freq, self.h_freq, self.l_trans_bandwidth,
-         self.h_trans_bandwidth, self.filter_length, _, self.fir_window,
-         self.fir_design) = \
-            _triage_filter_params(X, self.sfreq, self.l_freq, self.h_freq,
-                                  self.l_trans_bandwidth,
-                                  self.h_trans_bandwidth, self.filter_length,
-                                  self.method, phase='zero',
-                                  fir_window=self.fir_window,
-                                  fir_design=self.fir_design)
         X = filter_data(X, self.sfreq, self.l_freq, self.h_freq,
                         filter_length=self.filter_length,
                         l_trans_bandwidth=self.l_trans_bandwidth,


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
@@ -14,7 +14,7 @@
 from mne.decoding import (Scaler, FilterEstimator, PSDEstimator, Vectorizer,
                           UnsupervisedSpatialFilter, TemporalFilter)
 from mne.defaults import DEFAULTS
-from mne.utils import requires_sklearn, check_version
+from mne.utils import requires_sklearn, check_version, use_log_level
 
 tmin, tmax = -0.2, 0.5
 event_id = dict(aud_l=1, vis_l=3)
@@ -239,4 +239,13 @@ def test_temporal_filter():
     X = np.random.rand(101, 500)
     filt = TemporalFilter(l_freq=25., h_freq=50., sfreq=1000.,
                           filter_length=150, fir_design='firwin2')
-    assert_equal(filt.fit_transform(X).shape, X.shape)
+    with use_log_level('error'):  # warning about transition bandwidth
+        assert_equal(filt.fit_transform(X).shape, X.shape)
+
+
+def test_bad_triage():
+    """Test for gh-10924."""
+    filt = TemporalFilter(l_freq=8, h_freq=60, sfreq=160.)
+    # Used to fail with "ValueError: Effective band-stop frequency (135.0) is
+    # too high (maximum based on Nyquist is 80.0)"
+    filt.fit_transform(np.zeros((1, 1, 481)))