Repository: sktime/sktime
Pull request #8133 (2025-04-04 13:15:53)

[BUG] fix `scitype` return in case `coerce_to_list=False` and `force_single_scitype=False` if two or more types are detected
If `scitype` was called with `coerce_to_list=False` and `force_single_scitype=False`, if two or more types were detected, a single scitype would be returned - which is unexpected and inconsistent with the docstring.

The test `test_scitype` also expected the single scitype output.

Given the strong expectation to the contrary, I think this should be considered a bug.

However, since this is potentially breaking, we should release only with a minor version, e.g., 0.37.0.

Depends on https://github.com/sktime/sktime/pull/8131 which is a pure refactor.


Patch:
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 80c78156d..182f142c4 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -290,8 +290,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
       <td align="center" valign="top" width="11.11%"><a href="https://www.imes.uni-hannover.de/de/institut/team/m-sc-karl-philipp-kortmann/"><img src="https://avatars.githubusercontent.com/u/20466981?v=4?s=100" width="100px;" alt="Philipp Kortmann"/><br /><sub><b>Philipp Kortmann</b></sub></a><br /><a href="https://github.com/sktime/sktime/commits?author=MrPr3ntice" title="Code">💻</a> <a href="https://github.com/sktime/sktime/commits?author=MrPr3ntice" title="Documentation">📖</a></td>
       <td align="center" valign="top" width="11.11%"><a href="https://github.com/Piyush1729"><img src="https://avatars2.githubusercontent.com/u/64950012?v=4?s=100" width="100px;" alt="Piyush Gade"/><br /><sub><b>Piyush Gade</b></sub></a><br /><a href="https://github.com/sktime/sktime/commits?author=Piyush1729" title="Code">💻</a> <a href="https://github.com/sktime/sktime/pulls?q=is%3Apr+reviewed-by%3APiyush1729" title="Reviewed Pull Requests">👀</a></td>
       <td align="center" valign="top" width="11.11%"><a href="https://github.com/rahulporuri"><img src="https://avatars.githubusercontent.com/u/1926457?v=4?s=100" width="100px;" alt="Poruri Sai Rahul"/><br /><sub><b>Poruri Sai Rahul</b></sub></a><br /><a href="https://github.com/sktime/sktime/commits?author=rahulporuri" title="Documentation">📖</a></td>
+      <td align="center" valign="top" width="11.11%"><a href="https://github.com/PranavBhatP"><img src="https://avatars.githubusercontent.com/PranavBhatP?s=100" width="100px;" alt="Pranav Bhat"/><br /><sub><b>Pranav Bhat</b></sub></a><br /><a href="https://github.com/sktime/sktime/commits?author=PranavBhatP" title="Documentation">📖</a> <a href="https://github.com/sktime/sktime/commits?author=PranavBhatP" title="Code">💻</a> <a href="https://github.com/sktime/sktime/commits?author=PranavBhatP" title="Tests">⚠️</a></td>
       <td align="center" valign="top" width="11.11%"><a href="https://www.linkedin.com/in/pranav-prajapati-a5b413226/"><img src="https://avatars.githubusercontent.com/u/94780581?v=4?s=100" width="100px;" alt="Pranav Prajapati"/><br /><sub><b>Pranav Prajapati</b></sub></a><br /><a href="https://github.com/sktime/sktime/commits?author=pranavvp16" title="Code">💻</a> <a href="https://github.com/sktime/sktime/commits?author=pranavvp16" title="Tests">⚠️</a> <a href="https://github.com/sktime/sktime/issues?q=author%3Apranavvp16" title="Bug reports">🐛</a></td>
-      <td align="center" valign="top" width="11.11%"><a href="https://github.com/PranavBhatP"><img src="https://avatars.githubusercontent.com/PranavBhatP?s=100" width="100px;" alt="PranavBhatP"/><br /><sub><b>PranavBhatP</b></sub></a><br /><a href="https://github.com/sktime/sktime/commits?author=PranavBhatP" title="Documentation">📖</a></td>
     </tr>
     <tr>
       <td align="center" valign="top" width="11.11%"><a href="https://github.com/Prtm2110"><img src="https://avatars.githubusercontent.com/u/139000226?s=400&u=a2ef29514ad2780d1263ba9d724898132c8d18fb&v=4?s=100" width="100px;" alt="Pratham Hole"/><br /><sub><b>Pratham Hole</b></sub></a><br /><a href="#maintenance-Prtm2110" title="Maintenance">🚧</a></td>
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
index 5f2736c05..770bb4045 100644
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -12,7 +12,7 @@ from sktime.datatypes import ALL_TIME_SERIES_MTYPES
 from sktime.forecasting.base._base import BaseForecaster
 from sktime.forecasting.base._delegate import _DelegatedForecaster
 from sktime.forecasting.base._fh import ForecastingHorizon
-from sktime.registry import is_scitype
+from sktime.registry import is_scitype, scitype
 from sktime.utils.validation.series import check_series
 from sktime.utils.warnings import warn
 
@@ -33,6 +33,9 @@ class _Pipeline(_HeterogenousMetaEstimator, BaseForecaster):
 
     def _get_forecaster_index(self, estimators):
         """Get the index of the first forecaster in the list."""
+        if hasattr(self, "_forecaster_index"):
+            return self._forecaster_index
+        # fallback logic
         for i, est in enumerate(estimators):
             if is_scitype(est[1], "forecaster"):
                 return i
@@ -59,6 +62,8 @@ class _Pipeline(_HeterogenousMetaEstimator, BaseForecaster):
         TypeError if there is not exactly one forecaster in ``estimators``
         TypeError if not allow_postproc and forecaster is not last estimator
         """
+        from sktime.registry._scitype_coercion import all_coercible_to, coerce_scitype
+
         self_name = type(self).__name__
         if not isinstance(estimators, list):
             msg = (
@@ -82,19 +87,28 @@ class _Pipeline(_HeterogenousMetaEstimator, BaseForecaster):
 
         # validate names
         self._check_names(names)
-        if not all([is_scitype(x, ["forecaster", "transformer"]) for x in estimators]):
+
+        ALLOWED_SCITYPES = ["forecaster", "transformer"]
+        COERCIBLE_SCITYPES = all_coercible_to("transformer")
+        COERCIBLE_SCITYPES = set(COERCIBLE_SCITYPES) - set(ALLOWED_SCITYPES)
+        ACCEPTED_SCITYPES = ALLOWED_SCITYPES + list(COERCIBLE_SCITYPES)
+
+        est_scitypes = [scitype(x) for x in estimators]
+
+        if not all([x in ACCEPTED_SCITYPES for x in est_scitypes]):
             raise TypeError(
                 f"estimators passed to {self_name} "
                 f"must be either transformer or forecaster"
             )
-        scitypes = [is_scitype(x, ["forecaster"]) for x in estimators]
-        if sum(scitypes) != 1:
+        forecaster_indicator = [x == "forecaster" for x in est_scitypes]
+        if sum(forecaster_indicator) != 1:
             raise TypeError(
                 f"exactly one forecaster must be contained in the chain, "
-                f"but found {scitypes.count('forecaster')}"
+                f"but found {forecaster_indicator.count('forecaster')}"
             )
 
-        forecaster_ind = self._get_forecaster_index(estimator_tuples)
+        forecaster_ind = forecaster_indicator.index(True)
+        self._forecaster_index = forecaster_ind
 
         if not allow_postproc and forecaster_ind != len(estimators) - 1:
             TypeError(
@@ -102,6 +116,18 @@ class _Pipeline(_HeterogenousMetaEstimator, BaseForecaster):
                 f"but found a transformer"
             )
 
+        # coerce to transformer if needed
+
+        def _coerce_to_trafo(x, x_st):
+            if x not in ["forecaster", "transformer"]:
+                return coerce_scitype(x, to_scitype="transformer", from_scitype=x_st)
+            return x
+
+        if not all([x in ALLOWED_SCITYPES for x in est_scitypes]):
+            est_plus_scitype = zip(estimators, est_scitypes)
+            coerced_estimators = [_coerce_to_trafo(*x) for x in est_plus_scitype]
+            estimator_tuples = list(zip(names, coerced_estimators))
+
         # Shallow copy
         return estimator_tuples
 
@@ -264,6 +290,13 @@ class _Pipeline(_HeterogenousMetaEstimator, BaseForecaster):
         ]
         params1 = {"steps": STEPS1}
 
+        # without TabularToSeriesAdaptor
+        STEPS1_no_adapter = [
+            ("transformer", StandardScaler()),
+            ("forecaster", NaiveForecaster()),
+        ]
+        params4 = {"steps": STEPS1_no_adapter}
+
         # ARIMA has probabilistic methods, ExponentTransformer skips fit
         STEPS2 = [
             ("transformer", ExponentTransformer()),
@@ -273,7 +306,7 @@ class _Pipeline(_HeterogenousMetaEstimator, BaseForecaster):
 
         params3 = {"steps": [Detrender(), YfromX.create_test_instance()]}
 
-        return [params1, params2, params3]
+        return [params1, params2, params3, params4]
 
 
 # we ensure that internally we convert to pd.DataFrame for now
@@ -347,7 +380,6 @@ class ForecastingPipeline(_Pipeline):
     >>> from sktime.datasets import load_longley
     >>> from sktime.forecasting.naive import NaiveForecaster
     >>> from sktime.forecasting.compose import ForecastingPipeline
-    >>> from sktime.transformations.series.adapt import TabularToSeriesAdaptor
     >>> from sktime.transformations.series.impute import Imputer
     >>> from sktime.forecasting.base import ForecastingHorizon
     >>> from sktime.split import temporal_train_test_split
@@ -359,7 +391,7 @@ class ForecastingPipeline(_Pipeline):
         Example 1: string/estimator pairs
     >>> pipe = ForecastingPipeline(steps=[
     ...     ("imputer", Imputer(method="mean")),
-    ...     ("minmaxscaler", TabularToSeriesAdaptor(MinMaxScaler())),
+    ...     ("minmaxscaler", MinMaxScaler()),
     ...     ("forecaster", NaiveForecaster(strategy="drift")),
     ... ])
     >>> pipe.fit(y_train, X_train)
@@ -369,7 +401,7 @@ class ForecastingPipeline(_Pipeline):
         Example 2: without strings
     >>> pipe = ForecastingPipeline([
     ...     Imputer(method="mean"),
-    ...     TabularToSeriesAdaptor(MinMaxScaler()),
+    ...     MinMaxScaler(),
     ...     NaiveForecaster(strategy="drift"),
     ... ])
 
@@ -1018,8 +1050,6 @@ class TransformedTargetForecaster(_Pipeline):
         -------
         self : returns an instance of self.
         """
-        self.steps_ = self._get_estimator_tuples(self.steps, clone_ests=True)
-
         # transform pre
         yt = y
         for _, t in self.transformers_pre_:
diff --git a/sktime/registry/_scitype.py b/sktime/registry/_scitype.py
index 41837d253..207676e80 100644
--- a/sktime/registry/_scitype.py
+++ b/sktime/registry/_scitype.py
@@ -123,7 +123,7 @@ def scitype(
             detected_scitype = [detected_scitype]
         if force_single_scitype and len(detected_scitype) > 1:
             detected_scitype = [detected_scitype[0]]
-        if not coerce_to_list:
+        if not coerce_to_list and len(detected_scitype) == 1:
             detected_scitype = detected_scitype[0]
         return detected_scitype
 
diff --git a/sktime/registry/_scitype_coercion.py b/sktime/registry/_scitype_coercion.py
index 938f57133..7a390635f 100644
--- a/sktime/registry/_scitype_coercion.py
+++ b/sktime/registry/_scitype_coercion.py
@@ -124,7 +124,13 @@ def coerce_scitype(
             )
 
     # case 1: detected scitype is not assumed scitype
-    if from_scitype is not None and detected_scitype not in from_scitype:
+    mismatch_raise = (
+        need_detect
+        and from_scitype is not None
+        and detected_scitype not in from_scitype
+    )
+
+    if mismatch_raise:
         # if raise_on_mismatch, raise an error
         if raise_on_mismatch:
             raise TypeError(
@@ -137,6 +143,8 @@ def coerce_scitype(
 
     if from_scitype is None or len(from_scitype) >= 2:
         from_scitype = detected_scitype
+    else:
+        from_scitype = from_scitype[0]
 
     if clone_obj:
         obj = _safe_clone(obj)
@@ -152,3 +160,23 @@ def coerce_scitype(
 
     coerced_obj = coerce_func(obj)
     return coerced_obj
+
+
+def all_coercible_to(to_scitype):
+    """List all scitypes that can be coerced to a given scitype.
+
+    Parameters
+    ----------
+    to_scitype : str
+        scitype to coerce to
+
+    Returns
+    -------
+    coercible_scitypes : list of str
+        list of scitypes that can be coerced to the given scitype, excluding to_scitype
+    """
+    coercible_scitypes = []
+    for from_scitype, to_scitype_ in _coerce_register.keys():
+        if to_scitype_ == to_scitype and from_scitype != to_scitype:
+            coercible_scitypes.append(from_scitype)
+    return coercible_scitypes


Test patch:
diff --git a/sktime/registry/tests/test_scitype.py b/sktime/registry/tests/test_scitype.py
index 702f456d0..bf072c52f 100644
--- a/sktime/registry/tests/test_scitype.py
+++ b/sktime/registry/tests/test_scitype.py
@@ -54,10 +54,10 @@ def test_scitype_generic(force_single_scitype, coerce_to_list):
 
     if force_single_scitype and coerce_to_list:
         expected = ["foo"]
-    if not force_single_scitype and coerce_to_list:
-        expected = ["foo", "bar"]
-    if not coerce_to_list:
+    if force_single_scitype and not coerce_to_list:
         expected = "foo"
+    if not force_single_scitype:
+        expected = ["foo", "bar"]
 
     assert scitype_inferred == expected