Repository: sktime/sktime

Issue:
[BUG]  `test_fit_does_not_overwrite_hyper_params[FeatureUnion]` failing
Update: the failure has been silenced in the tests to enable refactor work on CI/CD, but the bug is still there.
To reproduce, the test should be run manually, or with the silencing disabled (`tests._config.EXCLUDED_TESTS`)

---

**Describe the bug**

In the refactored CI pipeline based on github actions #1620, and is blocking the PR.

The test `test_fit_does_not_overwrite_hyper_params[FeatureUnion]` from `tests/test_all_estimators.py` fails on linux with python3.6-3.9 and macos 3.6-3.9 with the error below.

Curiously the test are passing in CI pipelines currently on `main` branch.

```
____________ test_fit_does_not_overwrite_hyper_params[FeatureUnion] ____________
[gw0] darwin -- Python 3.7.12 /Users/runner/hostedtoolcache/Python/3.7.12/x64/bin/python

estimator_instance = FeatureUnion(n_jobs=None, preserve_dataframe=True,
             transformer_list=[('transformer1',
                   ...                                                              with_std=True)))],
             transformer_weights=None)

    def test_fit_does_not_overwrite_hyper_params(estimator_instance):
        """Check that we do not overwrite hyper-parameters in fit."""
        estimator = estimator_instance
        set_random_state(estimator)
    
        # Make a physical copy of the original estimator parameters before fitting.
        params = estimator.get_params()
        original_params = deepcopy(params)
    
        # Fit the model
        fit_args = _make_args(estimator, "fit")
        estimator.fit(*fit_args)
    
        # Compare the state of the model parameters with the original parameters
        new_params = estimator.get_params()
        for param_name, original_value in original_params.items():
            new_value = new_params[param_name]
    
            # We should never change or mutate the internal state of input
            # parameters by default. To check this we use the joblib.hash function
            # that introspects recursively any subobjects to compute a checksum.
            # The only exception to this rule of immutable constructor parameters
            # is possible RandomState instance but in this check we explicitly
            # fixed the random_state params recursively to be integer seeds.
>           assert joblib.hash(new_value) == joblib.hash(original_value), (
                "Estimator %s should not change or mutate "
                " the parameter %s from %s to %s during fit."
                % (estimator.__class__.__name__, param_name, original_value, new_value)
            )
E           AssertionError: Estimator FeatureUnion should not change or mutate  the parameter transformer_list from [('transformer1', SeriesToSeriesRowTransformer(check_transformer=False,
E                                          transformer=StandardScaler(copy=True,
E                                                                     with_mean=True,
E                                                                     with_std=True))), ('transformer2', SeriesToSeriesRowTransformer(check_transformer=False,
E                                          transformer=StandardScaler(copy=True,
E                                                                     with_mean=True,
E                                                                     with_std=True)))] to [('transformer1', SeriesToSeriesRowTransformer(check_transformer=False,
E                                          transformer=StandardScaler(copy=True,
E                                                                     with_mean=True,
E                                                                     with_std=True))), ('transformer2', SeriesToSeriesRowTransformer(check_transformer=False,
E                                          transformer=StandardScaler(copy=True,
E                                                                     with_mean=True,
E                                                                     with_std=True)))] during fit.
E           assert '7f94d1fc7e1f...888be251ce7b2' == 'b03f493febd2...c60681b4af6e4'
E             - b03f493febd2f1d6da1c60681b4af6e4
E             + 7f94d1fc7e1f285e1e5888be251ce7b2

estimator  = FeatureUnion(n_jobs=None, preserve_dataframe=True,
             transformer_list=[('transformer1',
                   ...                                                              with_std=True)))],
             transformer_weights=None)
estimator_instance = FeatureUnion(n_jobs=None, preserve_dataframe=True,
             transformer_list=[('transformer1',
                   ...                                                              with_std=True)))],
             transformer_weights=None)
fit_args   = (                                                var_0
0   0    -0.116020
1     0.343339
2    -0.464066
3...
1   0    ...0
7     1
8     0
9     0
10    0
11    0
12    1
13    1
14    1
15    1
16    0
17    0
18    1
19    1
dtype: int64)
new_params = {'n_jobs': None, 'preserve_dataframe': True, 'transformer1': SeriesToSeriesRowTransformer(check_transformer=False,
   ...                                                        with_std=True)), 'transformer1__check_transformer': False, ...}
new_value  = [('transformer1', SeriesToSeriesRowTransformer(check_transformer=False,
                             transformer=Stand...                              with_mean=True,
                                                        with_std=True)))]
original_params = {'n_jobs': None, 'preserve_dataframe': True, 'transformer1': SeriesToSeriesRowTransformer(check_transformer=False,
   ...                                                        with_std=True)), 'transformer1__check_transformer': False, ...}
original_value = [('transformer1', SeriesToSeriesRowTransformer(check_transformer=False,
                             transformer=Stand...                              with_mean=True,
                                                        with_std=True)))]
param_name = 'transformer_list'
params     = {'n_jobs': None, 'preserve_dataframe': True, 'transformer1': SeriesToSeriesRowTransformer(check_transformer=False,
   ...                                                        with_std=True)), 'transformer1__check_transformer': False, ...}
```

**To Reproduce**

Run the test with:

```bash
pytest sktime/tests/test_all_estimators.py
```

**Expected behavior**

Test passes

**Additional context**

**Versions**

See github actions under #1620 

<!--
Please run the following code snippet and paste the output here:
 
from sktime import show_versions; show_versions()
-->

</details>

<!-- Thanks for contributing! -->



Discussion:
I think the bug is straightforward, just looking at it (needs to be confirmed).

`FeatureUnion` has a `transformer` attribute which contains the components. Instead of fitting these directly where they are, the transformers should be cloned (using `deepcopy` or a safe `sklearn` `clone`) to a `transformer_` attribute and fitted there, without having side effects on the `transformer` attribute written in the init.
Relates to this section of the extension contract specification in the extension template which is probably violated:

```
        # any model parameters should be written to attributes ending in "_"
        #  attributes set by the constructor must not be overwritten
        #  if used, estimators should be cloned to attributes ending in "_"
        #  the clones, not the originals shoudld be used or fitted if needed
```
don't have time to fix it today, but if someone wants to pick it up quickly, I've attached the "good first issue" label
Thanks for the input @fkiraly! Let me try to make it work.

Resolving patch:
diff --git a/sktime/series_as_features/compose/_pipeline.py b/sktime/series_as_features/compose/_pipeline.py
--- a/sktime/series_as_features/compose/_pipeline.py
+++ b/sktime/series_as_features/compose/_pipeline.py
@@ -1,10 +1,9 @@
 # -*- coding: utf-8 -*-
+
 import numpy as np
 import pandas as pd
-from joblib import Parallel, delayed
 from scipy import sparse
 from sklearn.pipeline import FeatureUnion as _FeatureUnion
-from sklearn.pipeline import _fit_transform_one, _transform_one
 
 from sktime.transformations.base import _PanelToPanelTransformer
 
@@ -57,71 +56,20 @@ def __init__(
         # We need to add is-fitted state when inheriting from scikit-learn
         self._is_fitted = False
 
-    def fit_transform(self, X, y=None, **fit_params):
-        """Fit all transformations, transform the data and concatenate results.
-
-        Parameters
-        ----------
-        X : pandas DataFrame
-            Input data to be transformed.
-        y : pandas Series, shape (n_samples, ...), optional
-            Targets for supervised learning.
-
-        Returns
-        -------
-        Xt : pandas DataFrame
-            hstack of results of transformations. sum_n_components is the
-            sum of n_components (output dimension) over transformations.
-        """
-        self._validate_transformers()
-        result = Parallel(n_jobs=self.n_jobs)(
-            delayed(_fit_transform_one)(trans, X, y, weight, **fit_params)
-            for name, trans, weight in self._iter()
-        )
-
-        if not result:
-            # All transformations are None
-            return np.zeros((X.shape[0], 0))
-
-        Xs, transformers = zip(*result)
-        self._update_transformer_list(transformers)
-
-        Xs = self._hstack(list(Xs))
-        self._is_fitted = True
-        return Xs
-
     def fit(self, X, y=None, **fit_params):
         """Fit parameters."""
-        super(FeatureUnion, self).fit(X, y, **fit_params)
+        super().fit(X, y, **fit_params)
         self._is_fitted = True
         return self
 
     def transform(self, X):
-        """Transform X separately by each transformer, concatenate results.
-
-        Parameters
-        ----------
-        X : pandas DataFrame
-            Input data to be transformed.
-
-        Returns
-        -------
-        Xt : pandas DataFrame
-            hstack of results of transformations. sum_n_components is the
-            sum of n_components (output dimension) over transformations.
-        """
+        """Transform X separately by each transformer, concatenate results."""
         self.check_is_fitted()
-        Xs = Parallel(n_jobs=self.n_jobs)(
-            delayed(_transform_one)(trans, X, None, weight)
-            for name, trans, weight in self._iter()
-        )
-
-        if not Xs:
-            # All transformations are None
-            return np.zeros((X.shape[0], 0))
+        return super().transform(X)
 
-        else:
-            return self._hstack(list(Xs))
+    def fit_transform(self, X, y, **fit_params):
+        """Transform X separately by each transformer, concatenate results."""
+        return self.fit(X, y, **fit_params).transform(X)
 
     def _hstack(self, Xs):
         """
@@ -133,7 +81,7 @@ def _hstack(self, Xs):
         if any(sparse.issparse(f) for f in Xs):
             Xs = sparse.hstack(Xs).tocsr()
 
-        types = set(type(X) for X in Xs)
+        types = {type(X) for X in Xs}
         if self.preserve_dataframe and (pd.Series in types or pd.DataFrame in types):
             return pd.concat(Xs, axis=1)
 


Test patch: