Repository: sktime/sktime (python)

Issue:
[BUG] `FourierFeatures` does not work as expected in `ForecastingPipeline`
**Describe the bug**
<!--
A clear and concise description of what the bug is.
-->

Using FourierFeatures in a forecasting pipeline does not produce the same results as using FourierFeatures separately then passing the features to a forecaster. The pipeline produces a flat forecast while the result should not be flat. 

**To Reproduce**
<!--
Add a Minimal, Complete, and Verifiable example (for more details, see e.g. https://stackoverflow.com/help/mcve

If the code is too long, feel free to put it in a public gist and link it in the issue: https://gist.github.com
-->

```python
import pandas as pd
import numpy as np

from sktime.forecasting.base import ForecastingHorizon
from sktime.forecasting.compose import ForecastingPipeline, TransformedTargetForecaster
from sktime.forecasting.statsforecast import StatsForecastAutoARIMA
from sktime.transformations.compose import YtoX
from sktime.transformations.series.exponent import SqrtTransformer
from sktime.transformations.series.fourier import FourierFeatures
from sktime.utils.plotting import plot_series
import matplotlib.pyplot as plt


dates = pd.date_range(start="2024-01-01", end="2024-04-01", freq="1H")
calls_per_minute_low = 100 * 60
calls_per_minute_high = 500 * 60
calls = pd.Series(
    index=dates,
    data=np.random.randint(low=calls_per_minute_low, high=calls_per_minute_high, size=len(dates))
)


fh = ForecastingHorizon(values=range(1, 168+1), is_relative=True, freq="H")

arima = StatsForecastAutoARIMA(sp=1,d=0,D=0)

fourier = FourierFeatures(sp_list=[24, 24*7], fourier_terms_list=[10, 5])
fourier.fit(calls)
X_train = fourier.transform(calls)
future = pd.Series(data=1, index=fh.to_absolute_index(calls.index.max()))
X_pred = fourier.transform(future)
pred_arima = arima.fit_predict(y=calls, X=X_train, X_pred=X_pred, fh=fh)

fourierArimax = ForecastingPipeline([
    YtoX(),
    FourierFeatures(sp_list=[24, 24*7], fourier_terms_list=[10, 5]),
    StatsForecastAutoARIMA(sp=1,d=0,D=0),
])

pred_fourierArimax = fourierArimax.fit_predict(y=calls, fh=fh)

fig, ax = plot_series(calls.tail(168), pred_arima, pred_fourierArimax, labels=["calls", "Arima", "FourierArimaxPipeline"])
plt.show()
```

**Expected behavior**
<!--
A clear and concise description of what you expected to happen.
-->

pipeline should produce the same forecast as no pipeline code.

**Additional context**
<!--
Add any other context about the problem here.
-->

Here is a plot for clarity
![image](https://github.com/sktime/sktime/assets/24213087/39930d58-3be4-4e7e-b8d1-75d910d64dee)


**Versions**
<details>

<!--
Please run the following code snippet and paste the output here:

from sktime import show_versions; show_versions()
-->

</details>

<!-- Thanks for contributing! -->


Pull request description:
& albahhar [ENH] in `ForecastingPipeline`, allow `None` `X` to be passed to transformers
Fixes #5975, see there for description of the edge case bug where `X` is `None`, but passing to transformers is valid.

Changes included:

* added tag `requires_X` for transformers, because passing `X` always causes problems with other examples. This is turned off for, e.g., `YfromX`.
* replaced custom scitype inference utility in `_pipeline.py` module with the central entry point in `registry` (that was created later)
* better docstring formatting in `TransformedTargetForecaster` and `ForecastingPipeline`

Patch:
diff --git a/extension_templates/transformer.py b/extension_templates/transformer.py
index 5fccc7288..ead2e5157 100644
--- a/extension_templates/transformer.py
+++ b/extension_templates/transformer.py
@@ -149,10 +149,15 @@ class MyTransformer(BaseTransformer):
         # valid values: True = inner _fit, _transform receive only univariate series
         #   False = uni- and multivariate series are passed to inner methods
         #
+        # requires_X = does X need to be passed in fit?
+        "requires_X": True,
+        # valid values: False (no), True = exception is raised if no X is seen in _fit
+        #   requires_y setting is independent of requires_X
+        #
         # requires_y = does y need to be passed in fit?
         "requires_y": False,
         # valid values: False (no), True = exception is raised if no y is seen in _fit
-        #   y can be passed or not in _transform for either value of requires_y
+        #   requires_X setting is independent of requires_y
         #
         # remember_data = whether all data seen is remembered as self._X
         "remember_data": False,
diff --git a/sktime/forecasting/compose/_pipeline.py b/sktime/forecasting/compose/_pipeline.py
index 1e7aab108..66ef6f233 100644
--- a/sktime/forecasting/compose/_pipeline.py
+++ b/sktime/forecasting/compose/_pipeline.py
@@ -10,7 +10,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.transformations.base import BaseTransformer
+from sktime.registry import scitype
 from sktime.utils.validation._dependencies import _check_soft_dependencies
 from sktime.utils.validation.series import check_series
 
@@ -31,17 +31,7 @@ class _Pipeline(_HeterogenousMetaEstimator, BaseForecaster):
 
     def _get_pipeline_scitypes(self, estimators):
         """Get list of scityes (str) from names/estimator list."""
-
-        def est_scitype(tpl):
-            est = tpl[1]
-            if isinstance(est, BaseForecaster):
-                return "forecaster"
-            elif isinstance(est, BaseTransformer):
-                return "transformer"
-            else:
-                return "other"
-
-        return [est_scitype(x) for x in estimators]
+        return [scitype(x[1], raise_on_unknown=False) for x in estimators]
 
     def _get_forecaster_index(self, estimators):
         """Get the index of the first forecaster in the list."""
@@ -297,56 +287,60 @@ class ForecastingPipeline(_Pipeline):
     to X. The forecaster can also be a TransformedTargetForecaster containing
     transformers to transform y.
 
-    For a list `t1`, `t2`, ..., `tN`, `f`
-        where `t[i]` are transformers, and `f` is an sktime forecaster,
+    For a list ``t1``, ``t2``, ..., ``tN``, ``f``
+        where ``t[i]`` are transformers, and ``f`` is an ``sktime`` forecaster,
         the pipeline behaves as follows:
 
-    `fit(y, X, fh)` - changes state by running `t1.fit_transform` with `X=X`, `y=y`
-        then `t2.fit_transform` on `X=` the output of `t1.fit_transform`, `y=y`, etc
-        sequentially, with `t[i]` receiving the output of `t[i-1]` as `X`,
-        then running `f.fit` with `X` being the output of `t[N]`, and `y=y`
-    `predict(X, fh)` - result is of executing `f.predict`, with `fh=fh`, and `X`
+    ``fit(y, X, fh)`` changes state by running ``t1.fit_transform`` with ``X=X`, ``y=y``
+        then ``t2.fit_transform`` on ``X=`` the output of ``t1.fit_transform``, ``y=y``,
+        etc, sequentially, with ``t[i]`` receiving the output of ``t[i-1]`` as ``X``,
+        then running ``f.fit`` with ``X`` being the output of ``t[N]``, and ``y=y``
+
+    ``predict(X, fh)`` - result is of executing ``f.predict``, with ``fh=fh``, and ``X``
         being the result of the following process:
-        running `t1.fit_transform` with `X=X`,
-        then `t2.fit_transform` on `X=` the output of `t1.fit_transform`, etc
-        sequentially, with `t[i]` receiving the output of `t[i-1]` as `X`,
-        and returning th output of `tN` to pass to `f.predict` as `X`.
-    `predict_interval(X, fh)`, `predict_quantiles(X, fh)` - as `predict(X, fh)`,
-        with `predict_interval` or `predict_quantiles` substituted for `predict`
-    `predict_var`, `predict_proba` - uses base class default to obtain
-        crude estimates from `predict_quantiles`.
-
-    `get_params`, `set_params` uses `sklearn` compatible nesting interface
-        if list is unnamed, names are generated as names of classes
-        if names are non-unique, `f"_{str(i)}"` is appended to each name string
-            where `i` is the total count of occurrence of a non-unique string
-            inside the list of names leading up to it (inclusive)
-
-    `ForecastingPipeline` can also be created by using the magic multiplication
-        on any forecaster, i.e., if `my_forecaster` inherits from `BaseForecaster`,
-            and `my_t1`, `my_t2`, inherit from `BaseTransformer`,
-            then, for instance, `my_t1 ** my_t2 ** my_forecaster`
-            will result in the same object as  obtained from the constructor
-            `ForecastingPipeline([my_t1, my_t2, my_forecaster])`
-        magic multiplication can also be used with (str, transformer) pairs,
-            as long as one element in the chain is a transformer
+        running ``t1.fit_transform`` with ``X=X``,
+        then ``t2.fit_transform`` on ``X=`` the output of ``t1.fit_transform``, etc
+        sequentially, with ``t[i]`` receiving the output of ``t[i-1]`` as ``X``,
+        and returning th output of ``tN`` to pass to ``f.predict`` as ``X``.
+
+    ``predict_interval(X, fh)``, ``predict_quantiles(X, fh)`` - as ``predict(X, fh)``,
+        with ``predict_interval`` or ``predict_quantiles`` substituted for ``predict``
+
+    ``predict_var``, ``predict_proba`` - uses base class default to obtain
+        crude normal estimates from ``predict_quantiles``.
+
+    ``get_params``, ``set_params`` uses ``sklearn`` compatible nesting interface:
+
+        * if list is unnamed, names are generated as names of classes
+        * if names are non-unique, ``f"_{str(i)}"`` is appended to each name string
+          where ``i`` is the total count of occurrence of a non-unique string
+          inside the list of names leading up to it (inclusive)
+
+    ``ForecastingPipeline`` can also be created by using the magic multiplication
+        on any forecaster, i.e., if ``my_forecaster`` inherits from ``BaseForecaster``,
+        and ``my_t1``, ``my_t2``, inherit from ``BaseTransformer``,
+        then, for instance, ``my_t1 ** my_t2 ** my_forecaster``
+        will result in the same object as  obtained from the constructor
+        ``ForecastingPipeline([my_t1, my_t2, my_forecaster])``.
+        Magic multiplication can also be used with (str, transformer) pairs,
+        as long as one element in the chain is a transformer.
 
     Parameters
     ----------
     steps : list of sktime transformers and forecasters, or
-        list of tuples (str, estimator) of sktime transformers or forecasters
-            the list must contain exactly one forecaster
-        these are "blueprint" transformers resp forecasters,
-            forecaster/transformer states do not change when `fit` is called
+        list of tuples (str, estimator) of ``sktime`` transformers or forecasters.
+        The list must contain exactly one forecaster.
+        These are "blueprint" transformers resp forecasters,
+        forecaster/transformer states do not change when ``fit`` is called.
 
     Attributes
     ----------
-    steps_ : list of tuples (str, estimator) of sktime transformers or forecasters
+    steps_ : list of tuples (str, estimator) of ``sktime`` transformers or forecasters
         clones of estimators in `steps` which are fitted in the pipeline
-        is always in (str, estimator) format, even if `steps` is just a list
-        strings not passed in `steps` are replaced by unique generated strings
-        i-th transformer in `steps_` is clone of i-th in `steps`
-    forecaster_ : estimator, reference to the unique forecaster in steps_
+        is always in (str, estimator) format, even if ``steps`` is just a list
+        strings not passed in ``steps`` are replaced by unique generated strings
+        i-th transformer in ``steps_`` is clone of i-th in ``steps``
+    forecaster_ : estimator, reference to the unique forecaster in ``steps_``
 
     Examples
     --------
@@ -430,17 +424,18 @@ class ForecastingPipeline(_Pipeline):
     def __rpow__(self, other):
         """Magic ** method, return (left) concatenated ForecastingPipeline.
 
-        Implemented for `other` being a transformer, otherwise returns `NotImplemented`.
+        Implemented for ``other`` being a transformer,
+        otherwise returns ``NotImplemented``.
 
         Parameters
         ----------
-        other: `sktime` transformer, must inherit from BaseTransformer
-            otherwise, `NotImplemented` is returned
+        other: ``sktime`` transformer, must inherit from ``BaseTransformer``
+            otherwise, ``NotImplemented`` is returned
 
         Returns
         -------
         ForecastingPipeline object,
-            concatenation of `other` (first) with `self` (last).
+            concatenation of ``other`` (first) with ``self`` (last).
             not nested, contains only non-TransformerPipeline `sktime` steps
         """
         from sktime.transformations.base import BaseTransformer
@@ -486,8 +481,22 @@ class ForecastingPipeline(_Pipeline):
         -------
         self : returns an instance of self.
         """
-        # If X is not given or ignored, just passthrough the data without transformation
-        if self._X is not None and not self.get_tag("ignores-exogeneous-X"):
+        # skip transformers if X is ignored
+        # condition 1 for ignoring X: X is None and required in fit of 1st transformer
+        first_trafo = self.steps_[0][1]
+        cond1 = len(self.steps_) > 1 and first_trafo.get_tag("requires_X")
+        cond1 = cond1 and X is None
+
+        # condition 2 for ignoring X: tag "ignores-exogeneous-X" is True
+        # in this case the forecaster at the end ignores what comes out of the trafos
+        cond2 = self.get_tag("ignores-exogeneous-X")
+
+        # X ignored = condition 1 or condition 2
+        skip_trafos = cond1 or cond2
+        self.skip_trafos_ = skip_trafos
+
+        # If X is ignored, just ignore the transformers and pass through to forecaster
+        if not skip_trafos:
             # transform X
             for step_idx, name, transformer in self._iter_transformers():
                 t = transformer.clone()
@@ -497,7 +506,7 @@ class ForecastingPipeline(_Pipeline):
         # fit forecaster
         name, forecaster = self.steps_[-1]
         f = forecaster.clone()
-        f.fit(y, X, fh)
+        f.fit(y=y, X=X, fh=fh)
         self.steps_[-1] = (name, f)
 
         return self
@@ -667,8 +676,7 @@ class ForecastingPipeline(_Pipeline):
         -------
         self : an instance of self
         """
-        # If X is not given, just passthrough the data without transformation
-        if X is not None:
+        if not self.skip_trafos_:
             for _, _, transformer in self._iter_transformers():
                 if hasattr(transformer, "update"):
                     transformer.update(X=X, y=y, update_params=update_params)
@@ -680,7 +688,7 @@ class ForecastingPipeline(_Pipeline):
 
     def _transform(self, X=None, y=None):
         # If X is not given or ignored, just passthrough the data without transformation
-        if self._X is not None and not self.get_tag("ignores-exogeneous-X"):
+        if not self.skip_trafos_:
             for _, _, transformer in self._iter_transformers():
                 # if y is required but not passed,
                 # we create a zero-column y from the forecasting horizon
@@ -714,68 +722,76 @@ class ForecastingPipeline(_Pipeline):
 class TransformedTargetForecaster(_Pipeline):
     """Meta-estimator for forecasting transformed time series.
 
-    Pipeline functionality to apply transformers to the target series. The
-    X data is not transformed. If you want to transform X, please use the
-    ForecastingPipeline.
+    Pipeline functionality to apply transformers to endogeneous time series, ``y``.
+    The exogeneous data, ``X``, is not transformed.
+    To transform ``X``, the ``ForecastingPipeline`` can be used.
 
-    For a list `t1`, `t2`, ..., `tN`, `f`, `tp1`, `tp2`, ..., `tpM`
-        where `t[i]` and `tp[i]` are transformers (`t` to pre-, `tp` to post-process),
-        and `f` is an sktime forecaster,
+    For a list ``t1``, ``t2``, ..., ``tN``, ``f``, ``tp1``, ``tp2``, ..., ``tpM``,
+        where ``t[i]`` and ``tp[i]`` are transformers
+        (``t`` to pre-, ``tp`` to post-process),
+        and ``f`` is an sktime forecaster,
         the pipeline behaves as follows:
-    `fit(y, X, fh)` - changes state by running `t1.fit_transform` with `X=y`, `y=X`
-        then `t2.fit_transform` on `X=` the output of `t1.fit_transform`, `y=X`, etc
-        sequentially, with `t[i]` receiving the output of `t[i-1]` as `X`,
-        then running `f.fit` with `y` being the output of `t[N]`, and `X=X`,
-        then running `tp1.fit_transform`  with `X=y`, `y=X`,
-        then `tp2.fit_transform` on `X=` the output of `tp1.fit_transform`, etc
-        sequentially, with `tp[i]` receiving the output of `tp[i-1]`,
-    `predict(X, fh)` - result is of executing `f.predict`, with `X=X`, `fh=fh`,
-        then running `tp1.inverse_transform` with `X=` the output of `f`, `y=X`,
-        then `t2.inverse_transform` on `X=` the output of `t1.inverse_transform`, etc
-        sequentially, with `t[i]` receiving the output of `t[i-1]` as `X`,
-        then running `tp1.fit_transform` with `X=` the output of `t[N]s`, `y=X`,
-        then `tp2.fit_transform` on `X=` the output of `tp1.fit_transform`, etc
-        sequentially, with `tp[i]` receiving the output of `tp[i-1]`,
-    `predict_interval(X, fh)`, `predict_quantiles(X, fh)` - as `predict(X, fh)`,
-        with `predict_interval` or `predict_quantiles` substituted for `predict`
-    `predict_var`, `predict_proba` - uses base class default to obtain
-        crude estimates from `predict_quantiles`.
-
-    `get_params`, `set_params` uses `sklearn` compatible nesting interface
-        if list is unnamed, names are generated as names of classes
-        if names are non-unique, `f"_{str(i)}"` is appended to each name string
-            where `i` is the total count of occurrence of a non-unique string
-            inside the list of names leading up to it (inclusive)
-
-    `TransformedTargetForecaster` can also be created by using the magic multiplication
-        on any forecaster, i.e., if `my_forecaster` inherits from `BaseForecaster`,
-            and `my_t1`, `my_t2`, `my_tp` inherit from `BaseTransformer`,
-            then, for instance, `my_t1 * my_t2 * my_forecaster * my_tp`
-            will result in the same object as  obtained from the constructor
-            `TransformedTargetForecaster([my_t1, my_t2, my_forecaster, my_tp])`
-        magic multiplication can also be used with (str, transformer) pairs,
-            as long as one element in the chain is a transformer
+
+    ``fit(y, X, fh)`` - changes state by running ``t1.fit_transform``
+        with ``X=y``, ``y=X``,
+        then ``t2.fit_transform`` on ``X=`` the output of ``t1.fit_transform``, ``y=X``,
+        etc, sequentially, with ``t[i]`` receiving the output of ``t[i-1]`` as ``X``,
+        then running ``f.fit`` with ``y`` being the output of ``t[N]``, and ``X=X``,
+        then running ``tp1.fit_transform``  with ``X=y``, ``y=X``,
+        then ``tp2.fit_transform`` on ``X=`` the output of ``tp1.fit_transform``, etc
+        sequentially, with ``tp[i]`` receiving the output of ``tp[i-1]``,
+
+    ``predict(X, fh)`` - result is of executing ``f.predict``, with ``X=X``, ``fh=fh``,
+        then running ``tp1.inverse_transform`` with ``X=`` the output of ``f``, ``y=X``,
+        then ``t2.inverse_transform`` on ``X=`` the output of ``t1.inverse_transform``,
+        etc, sequentially, with ``t[i]`` receiving the output of ``t[i-1]`` as ``X``,
+        then running ``tp1.fit_transform`` with ``X=`` the output of ``t[N]s``, ``y=X``,
+        then ``tp2.fit_transform`` on ``X=`` the output of ``tp1.fit_transform``, etc,
+        sequentially, with ``tp[i]`` receiving the output of ``tp[i-1]``,
+
+    ``predict_interval(X, fh)``, ``predict_quantiles(X, fh)`` - as ``predict(X, fh)``,
+        with ``predict_interval`` or ``predict_quantiles`` substituted for ``predict``
+
+    ``predict_var``, ``predict_proba`` - uses base class default to obtain
+        crude normal estimates from ``predict_quantiles``.
+
+    ``get_params``, ``set_params`` uses ``sklearn`` compatible nesting interface:
+
+        * if list is unnamed, names are generated as names of classes
+        * if names are non-unique, ``f"_{str(i)}"`` is appended to each name string
+          where ``i`` is the total count of occurrence of a non-unique string
+          inside the list of names leading up to it (inclusive)
+
+    ``TransformedTargetForecaster`` can also be created by using the magic
+        multiplication
+        on any forecaster, i.e., if ``my_forecaster`` inherits from ``BaseForecaster``,
+        and ``my_t1``, ``my_t2``, ``my_tp`` inherit from ``BaseTransformer``,
+        then, for instance, ``my_t1 * my_t2 * my_forecaster * my_tp``
+        will result in the same object as  obtained from the constructor
+        ``TransformedTargetForecaster([my_t1, my_t2, my_forecaster, my_tp])``.
+        Magic multiplication can also be used with (str, transformer) pairs,
+        as long as one element in the chain is a transformer.
 
     Parameters
     ----------
-    steps : list of sktime transformers and forecasters, or
-        list of tuples (str, estimator) of sktime transformers or forecasters
-            the list must contain exactly one forecaster
-        these are "blueprint" transformers resp forecasters,
-            forecaster/transformer states do not change when `fit` is called
+    steps : list of ``sktime`` transformers and forecasters, or
+        list of tuples (str, estimator) of ``sktime`` transformers or forecasters.
+        The list must contain exactly one forecaster.
+        These are "blueprint" transformers resp forecasters,
+        forecaster/transformer states do not change when ``fit`` is called.
 
     Attributes
     ----------
-    steps_ : list of tuples (str, estimator) of sktime transformers or forecasters
-        clones of estimators in `steps` which are fitted in the pipeline
-        is always in (str, estimator) format, even if `steps` is just a list
-        strings not passed in `steps` are replaced by unique generated strings
-        i-th transformer in `steps_` is clone of i-th in `steps`
-    forecaster_ : estimator, reference to the unique forecaster in steps_
+    steps_ : list of tuples (str, estimator) of ``sktime`` transformers or forecasters
+        clones of estimators in ``steps`` which are fitted in the pipeline
+        is always in (str, estimator) format, even if ``steps`` is just a list
+        strings not passed in ``steps`` are replaced by unique generated strings
+        i-th transformer in ``steps_`` is clone of i-th in ``steps``
+    forecaster_ : estimator, reference to the unique forecaster in ``steps_``
     transformers_pre_ : list of tuples (str, transformer) of sktime transformers
-        reference to pairs in steps_ that precede forecaster_
+        reference to pairs in ``steps_`` that precede ``forecaster_``
     transformers_ost_ : list of tuples (str, transformer) of sktime transformers
-        reference to pairs in steps_ that succeed forecaster_
+        reference to pairs in ``steps_`` that succeed ``forecaster_``
 
     Examples
     --------
diff --git a/sktime/registry/_tags.py b/sktime/registry/_tags.py
index be634385c..2cd82addd 100644
--- a/sktime/registry/_tags.py
+++ b/sktime/registry/_tags.py
@@ -191,6 +191,12 @@ ESTIMATOR_TAG_REGISTER = [
         ("list", ["None", "Series", "Primitives", "Panel"]),
         "what is the scitype of y: None (not needed), Primitives, Series, Panel?",
     ),
+    (
+        "requires_X",
+        "transformer",
+        "bool",
+        "does this transformer require X to be passed in fit and transform?",
+    ),
     (
         "requires_y",
         "transformer",
diff --git a/sktime/transformations/base.py b/sktime/transformations/base.py
index f4c39e59d..edf286258 100644
--- a/sktime/transformations/base.py
+++ b/sktime/transformations/base.py
@@ -118,6 +118,7 @@ class BaseTransformer(BaseEstimator):
         "X_inner_mtype": "pd.DataFrame",  # which mtypes do _fit/_predict support for X?
         # this can be a Panel mtype even if transform-input is Series, vectorized
         "y_inner_mtype": "None",  # which mtypes do _fit/_predict support for y?
+        "requires_X": True,  # does X need to be passed in fit?
         "requires_y": False,  # does y need to be passed in fit?
         "enforce_index_type": None,  # index type that needs to be enforced in X/y
         "fit_is_empty": True,  # is fit empty and can be skipped? Yes = True
@@ -468,6 +469,10 @@ class BaseTransformer(BaseEstimator):
             self._is_fitted = True
             return self
 
+        # if requires_y is set, y is required in fit and update
+        if self.get_tag("requires_X") and X is None:
+            raise ValueError(f"{self.__class__.__name__} requires `X` in `fit`.")
+
         # if requires_y is set, y is required in fit and update
         if self.get_tag("requires_y") and y is None:
             raise ValueError(f"{self.__class__.__name__} requires `y` in `fit`.")
diff --git a/sktime/transformations/compose/_column.py b/sktime/transformations/compose/_column.py
index 5e09e2a71..bf99f7020 100644
--- a/sktime/transformations/compose/_column.py
+++ b/sktime/transformations/compose/_column.py
@@ -158,6 +158,7 @@ class ColumnEnsembleTransformer(
         if isinstance(transformers, BaseTransformer):
             tags_to_clone = [
                 "fit_is_empty",
+                "requires_X",
                 "requires_y",
                 "X-y-must-have-same-index",
                 "transform-returns-same-time-index",
@@ -172,6 +173,7 @@ class ColumnEnsembleTransformer(
         else:
             l_transformers = [(x[0], x[1]) for x in transformers]
             # self._anytagis_then_set("fit_is_empty", False, True, l_transformers)
+            self._anytagis_then_set("requires_X", True, False, l_transformers)
             self._anytagis_then_set("requires_y", True, False, l_transformers)
             self._anytagis_then_set(
                 "X-y-must-have-same-index", True, False, l_transformers
diff --git a/sktime/transformations/compose/_ytox.py b/sktime/transformations/compose/_ytox.py
index 2f50b8c57..17902bd6a 100644
--- a/sktime/transformations/compose/_ytox.py
+++ b/sktime/transformations/compose/_ytox.py
@@ -30,6 +30,7 @@ class YtoX(BaseTransformer):
         "y_inner_mtype": ["pd.DataFrame", "pd-multiindex", "pd_multiindex_hier"],
         "scitype:y": "both",
         "fit_is_empty": True,
+        "requires_X": False,
         "requires_y": True,
     }
 


Test patch:
diff --git a/sktime/forecasting/compose/tests/test_pipeline.py b/sktime/forecasting/compose/tests/test_pipeline.py
index 225dbf96f..d912a48bc 100644
--- a/sktime/forecasting/compose/tests/test_pipeline.py
+++ b/sktime/forecasting/compose/tests/test_pipeline.py
@@ -18,6 +18,7 @@ from sktime.forecasting.arima import ARIMA
 from sktime.forecasting.compose import (
     ForecastingPipeline,
     TransformedTargetForecaster,
+    YfromX,
     make_reduction,
 )
 from sktime.forecasting.model_selection import ForecastingGridSearchCV
@@ -25,7 +26,7 @@ from sktime.forecasting.naive import NaiveForecaster
 from sktime.forecasting.sarimax import SARIMAX
 from sktime.forecasting.trend import PolynomialTrendForecaster
 from sktime.split import ExpandingWindowSplitter, temporal_train_test_split
-from sktime.transformations.compose import OptionalPassthrough
+from sktime.transformations.compose import OptionalPassthrough, YtoX
 from sktime.transformations.hierarchical.aggregate import Aggregator
 from sktime.transformations.series.adapt import TabularToSeriesAdaptor
 from sktime.transformations.series.boxcox import LogTransformer
@@ -522,3 +523,36 @@ def test_pipeline_exogenous_none():
     pipe.fit(y_train, X_train, fh=[1, 2, 3])
     y_pred = pipe.predict(X=X_test)
     assert np.all(y_pred.index == y_test.index)
+
+
+def test_pipeline_featurizer_noexog():
+    """Test that ForecastingPipeline works with featurizer transformers without exog
+
+    Tests for failure case in #5975.
+    Compared to test_pipeline_exogenous_none,
+    this tests that transformers are executed properly even if X=None.
+    """
+    from sktime.transformations.series.fourier import FourierFeatures
+
+    calls_per_min_low = 100 * 60
+    calls_per_min_high = 500 * 60
+    data = np.random.randint(low=calls_per_min_low, high=calls_per_min_high, size=100)
+    calls = pd.Series(data)
+
+    fh = range(1, 10)
+
+    fcst = YfromX.create_test_instance()
+
+    pipe = ForecastingPipeline(
+        [
+            YtoX(),
+            FourierFeatures(sp_list=[24, 24 * 7], fourier_terms_list=[10, 5]),
+            fcst,
+        ]
+    )
+
+    y_pred = pipe.fit_predict(y=calls, fh=fh)
+
+    # if the pipeline skips the FourierFeatures step,
+    # then the predictions would be all constant, we test that this is not the case
+    assert not np.allclose(y_pred.diff()[1:], np.zeros_like(y_pred[1:]))