Repository: sktime/sktime
Pull request #6118 (2024-03-13 19:10:44)

[ENH] Time Series Regression GridSearchCV
#### Reference Issues/PRs

Related to issue #5655, and PRs #5986  #6075

#### What does this implement/fix? Explain your changes.
In working on the docstring for the MulitplexRegressor, there was not an Sktime implementation of Sklearn's GridSearchCV for regression module. This PR is an initial implementation.

#### Does your contribution introduce a new dependency? If yes, which one?
No

#### What should a reviewer concentrate their feedback on?
I wasn't sure what test to use to validate this implementation, please let me know which I can refer to to resolve any bugs. Thank you so much

#### Did you add any tests for the change?
No, please let me know if I need to


yarnabrina: Probably a dumb question: is it necessary to have separate grid/random/etc. search functionalities for forecasting/classification/regression/etc.? Can there not be a single implementation to reduce significant code duplication?

Or at least one base class and slight modification by inheritance per component?
ksharma6: hi @yarnabrina, that's not a dumb question at all.

@fkiraly had briefly mentioned implementing something similar to me not too long ago to help remove redundancies in the code base. I am pretty new to  sktime and am still learning the code base, happy to try implementing a base Cross validator and we can iterate over it? I'm also happy to wait on starting on something like this as well until I am a little less green with the code base, thoughts?
fkiraly: > Probably a dumb question: is it necessary to have separate grid/random/etc. search functionalities for forecasting/classification/regression/etc.? Can there not be a single implementation to reduce significant code duplication?

> Or at least one base class and slight modification by inheritance per component?

@yarnabrina, I have tried recently, and I think this is the right idea, but it is quite difficult to implement.

For this abstraction, one needs:

* an abstraction of task specific evaluator
* a structure that does not interfer with parallelization backends. This is very fiddly, as for example it is not without problems possible to dispatch to class methods
ksharma6: @fkiraly yes please :)
yarnabrina: @ksharma6 can you please add some tests? Given @fkiraly's explanation above, it makes sense to approve the testing of the current changes, but without tests it won't really do anything I suppose. May be you can add few simitests as it's present for classification/forecasting etc.?

(Also, please resolve the conflict.)
ksharma6: @fkiraly it looks like there isn't a test for `_tune.py` for classification (i don't see a `tests` folder, are the tests somehwere else?). Should I implement this in this PR or create it as a new issue and addres it in a seperate PR?
ksharma6: > @fkiraly it looks like there isn't a test for `_tune.py` for classification (i don't see a `tests` folder, perhaps the tests somehwere else?). Should I implement this in this PR or create it as a new issue and addres it in a seperate PR?

Hi @fkiraly returning back to this conversation. Do tests exist somewhere for the GridSearchCV implementations for forecasting and classification?

Additionally I am stumped about what to do with the failed test. There seems to be a `TypeError: NoneType takes no arguments` which is cause by a `RuntimeWarning: invalid value encountered in divide` in this line here from sktime/transformations/panel/channel_selection:
 `line_vec_norm = line_vec / np.sqrt(np.sum(line_vec**2))`

Not sure if there is something about using the `ComposableTimeSeriesForestRegressor` for testing but please let me know  your thoughts when you have a moment. 

fkiraly: @ksharma6, apologies for missing this.

> Do tests exist somewhere for the GridSearchCV implementations for forecasting and classification?

Yes, for forecasting, you have `forecasting.model_selection.tests.test_tune`.
For classification, there are no dedicated tests, only the general API tests from the `TestAllClassifiers`, `TestAllEstimators` classes, the same you get when applying `check_estimator`. This is, perhaps, a situation that should be improved.
fkiraly: The failure, I think, come from `TimeSeriesForestRegressor`, it seems like a bug of `TimeSeriesForestRegressor`.

The architecture of `TimeSeriesForestRegressor` is very unsafe, it inherits from a `sklearn` base class which should not be directly used imo. The base class must have changed, and its initialization now assumes that a parameter, `self.criterion` is set.

This should be reported as a bug.

For now, I would suggest that you use another regressor.

It is also strange why the tets suite does not pick up the issue with `TimeSeriesForestRegressor`. I would guess, either the issue is new, or it is specific to the parameter combination you are using in the tuning, and not covered by `get_test_params` of it. Perhaps that is something to explore based on the bug report.
ksharma6: > @ksharma6, apologies for missing this.
> 
> > Do tests exist somewhere for the GridSearchCV implementations for forecasting and classification?
> 
> Yes, for forecasting, you have `forecasting.model_selection.tests.test_tune`. For classification, there are no dedicated tests, only the general API tests from the `TestAllClassifiers`, `TestAllEstimators` classes, the same you get when applying `check_estimator`. This is, perhaps, a situation that should be improved.

No worries at all @fkiraly Thank you so much, I'll get started on this :)
fkiraly: > Would we want to do our own `TimeSeriesForestRegressor` implementation at some point?

Perhaps - at least we should clean the inheritance structure up to use only public interfaces.
fkiraly: let me know if you need a review or advice!
ksharma6: Still getting the following value error when running `check_estimator`:
`ValueError: Data seen by KNeighborsTimeSeriesRegressor instance has unequal length series, but this KNeighborsTimeSeriesRegressor instance cannot handle unequal length series. Calls with unequal length series may result in error or unreliable results.`

Let's see how changes perform against the test
ksharma6: Hi @fkiraly looks like I am getting the following error i mentioned earlier:
 `ValueError: Data seen by KNeighborsTimeSeriesRegressor instance has unequal length series, but this KNeighborsTimeSeriesRegressor instance cannot handle unequal length series. Calls with unequal length series may result in error or unreliable results.`

I didn't have this error before and I am not entirely sure what is causing it. Any ideas on what I may have done incorrectly?

Patch:
diff --git a/docs/source/api_reference/regression.rst b/docs/source/api_reference/regression.rst
index 2d9cb278d..0ef5e424e 100644
--- a/docs/source/api_reference/regression.rst
+++ b/docs/source/api_reference/regression.rst
@@ -14,6 +14,30 @@ Composition
 
 .. currentmodule:: sktime.regression.compose
 
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    RegressorPipeline
+    SklearnRegressorPipeline
+    MultiplexRegressor
+
+Model selection and tuning
+--------------------------
+
+.. currentmodule:: sktime.regression.model_selection
+
+.. autosummary::
+    :toctree: auto_generated/
+    :template: class.rst
+
+    TSRGridSearchCV
+
+Ensembles
+---------
+
+.. currentmodule:: sktime.regression.compose
+
 .. autosummary::
     :toctree: auto_generated/
     :template: class.rst
diff --git a/pyproject.toml b/pyproject.toml
index 8c0aef5b6..8a9c8133f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -209,7 +209,7 @@ regression = [
 transformations = [
   'esig<0.10,>=0.9.7; python_version < "3.11"',
   "filterpy<1.5,>=1.4.5",
-  "holidays>=0.29,<0.52",
+  "holidays>=0.52,<0.53",
   "mne>=1.5,<1.8",
   'numba<0.61,>=0.53',
   "pycatch22>=0.4,<0.4.6",
@@ -276,6 +276,7 @@ datasets = [
 dl = [
   'FrEIA; python_version < "3.12"',
   'neuralforecast<1.8.0,>=1.6.4; python_version < "3.11"',
+  'peft>=0.10.0; python_version < "3.12"',
   'tensorflow<2.17,>=2; python_version < "3.12"',
   'torch; python_version < "3.12"',
   'transformers[torch]<4.41.0; python_version < "3.12"',
diff --git a/sktime/base/adapters/_pyts.py b/sktime/base/adapters/_pyts.py
index 208744bee..99baab2dd 100644
--- a/sktime/base/adapters/_pyts.py
+++ b/sktime/base/adapters/_pyts.py
@@ -6,6 +6,8 @@ __author__ = ["fkiraly"]
 
 from inspect import signature
 
+from sktime.utils.adapters.forward import _clone_fitted_params
+
 
 class _PytsAdapter:
     """Mixin adapter class for pyts models."""
@@ -87,9 +89,7 @@ class _PytsAdapter:
         self._call_with_y_optional(pyts_est.fit, X, y)
 
         # write fitted params to self
-        pyts_fitted_params = self._get_fitted_params_default(pyts_est)
-        for k, v in pyts_fitted_params.items():
-            setattr(self, f"{k}_", v)
+        _clone_fitted_params(to_obj=self, from_obj=pyts_est)
 
         return self
 
diff --git a/sktime/base/adapters/_tslearn.py b/sktime/base/adapters/_tslearn.py
index 84d493c04..91d9630c1 100644
--- a/sktime/base/adapters/_tslearn.py
+++ b/sktime/base/adapters/_tslearn.py
@@ -6,6 +6,8 @@ __author__ = ["fkiraly"]
 
 from inspect import signature
 
+from sktime.utils.adapters.forward import _clone_fitted_params
+
 
 class _TslearnAdapter:
     """Mixin adapter class for tslearn models."""
@@ -74,9 +76,7 @@ class _TslearnAdapter:
             tslearn_est.fit(X)
 
         # write fitted params to self
-        tslearn_fitted_params = self._get_fitted_params_default(tslearn_est)
-        for k, v in tslearn_fitted_params.items():
-            setattr(self, f"{k}_", v)
+        _clone_fitted_params(to_obj=self, from_obj=tslearn_est)
 
         return self
 
diff --git a/sktime/datatypes/_adapter/gluonts.py b/sktime/datatypes/_adapter/gluonts.py
new file mode 100644
index 000000000..f6e2a0ddc
--- /dev/null
+++ b/sktime/datatypes/_adapter/gluonts.py
@@ -0,0 +1,133 @@
+import pandas as pd
+
+
+def convert_pandas_to_listDataset(pd_dataframe: pd.DataFrame, is_single: bool = False):
+    """Convert a given pandas DataFrame to a gluonTS ListDataset.
+
+    Parameters
+    ----------
+    pd_dataframe : pd.DataFrame
+        A valid pandas DataFrame
+
+    is_single : bool (default=False)
+        True if there is only 1 time series, false if there are multiple series
+        (Allows reusability with _series and _panel implementations)
+
+    Returns
+    -------
+    gluonts.dataset.common.ListDataset
+        A gluonTS ListDataset formed from `pd_dataframe`
+
+    Raises
+    ------
+    ValueError
+        If is_single is True, but multiple rows of entries exist in `pd_dataframe`
+
+    Example
+    --------
+    >>> import pandas as pd
+    >>> cols = ["instances", "timepoints"] + [f"var_{i}" for i in range(2)]
+    >>> Xlist = [
+    ...    pd.DataFrame([[0, 0, 1, 4], [0, 1, 2, 5], [0, 2, 3, 6]], columns=cols),
+    ...    pd.DataFrame([[1, 0, 1, 4], [1, 1, 2, 55], [1, 2, 3, 6]], columns=cols),
+    ...    pd.DataFrame([[2, 0, 1, 42], [2, 1, 2, 5], [2, 2, 3, 6]], columns=cols),
+    ... ]
+
+    >>> X = pd.concat(Xlist)
+
+    # Setting the timepoints in a Pandas acceptable format
+    >>> X['timepoints'] = pd.date_range(start='2023-01-01', periods=len(X), freq='D')
+
+    # Resetting the indexes to create the multiindexed DF
+    >>> X = X.set_index(["instances", "timepoints"])
+
+    # Finally, converting to a GluonTS ListDataset!
+    >>> from sktime.datatypes._adapter.gluonts import convert_pandas_to_listDataset
+    >>> X_list_dataset = convert_pandas_to_listDataset(X)
+    """
+    from gluonts.dataset.common import ListDataset
+    from gluonts.dataset.field_names import FieldName
+
+    # Assert the validity of the is_single parameter
+    if is_single and pd_dataframe.shape[0] > 1:
+        raise ValueError("`is_single` is True but the DataFrame has multiple rows!")
+
+    # This list will store all individual time series
+    dataset = []
+
+    # By maintaining 2 levels in the DataFrame's indices
+    # we can access each series and its timestep values with ease!
+    for _, data in pd_dataframe.groupby(level=0):
+        # Getting the starting time for each series (assumed to be min value)
+        start_datetime = data.index.get_level_values(level=1).min()
+
+        # Isolating multivariate values for each time series
+        target_column = data.columns[:]
+        target_values = data[target_column]
+
+        dataset.append(
+            {
+                FieldName.START: start_datetime,
+                FieldName.TARGET: target_values,
+            }
+        )
+
+    # Obtain the total amount of timesteps to assist with inferring frequency
+    n_steps = pd_dataframe.index.get_level_values(level=1).nunique()
+    fr = pd.infer_freq(pd_dataframe.index.get_level_values(level=1)[:n_steps])
+
+    # Converting the dataset to a GluonTS ListDataset
+    list_dataset = ListDataset(
+        dataset,
+        freq=fr,
+        one_dim_target=False,
+    )
+
+    return list_dataset
+
+
+def convert_listDataset_to_pandas(list_dataset):
+    """Convert a given gluonTS ListDataset to a pandas DataFrame.
+
+    Parameters
+    ----------
+    list_dataset : ListDataset
+        A valid GluonTS ListDataset to be converted to a pd.DataFrame
+
+    Returns
+    -------
+    pd.DataFrame
+        A valid pandas DataFrame
+    """
+    dfs = []
+
+    # Processing each given time series
+    for id, data in enumerate(list_dataset):
+        # Extracting important features
+        target_values = data["target"]
+        start_datetime = data["start"]
+
+        freq = start_datetime.freq
+
+        # Creating new indices based on the start time and the frequency
+        index = pd.date_range(
+            start=start_datetime.to_timestamp(), freq=freq, periods=len(target_values)
+        )
+
+        df = pd.DataFrame(target_values, index=index)
+
+        # Adding in the series_id index to the DataFrame and adding it to `dfs`
+        df.insert(0, "series", [f"series_{id}"] * len(df))
+        dfs.append(df)
+
+    # Concatenating all the given dataframes
+    dfs = pd.concat(dfs, ignore_index=False)
+
+    # Setting the index hierarchy properly
+    dfs = dfs.set_index(["series", dfs.index]).rename_axis(["series", "timestamp"])
+
+    # Adding column values
+    columns = [f"value_{i}" for i in range(1, dfs.shape[1] + 1, 1)]
+    dfs.columns = columns
+
+    return dfs
diff --git a/sktime/datatypes/_panel/_check.py b/sktime/datatypes/_panel/_check.py
index 383a9313f..91e2f9c99 100644
--- a/sktime/datatypes/_panel/_check.py
+++ b/sktime/datatypes/_panel/_check.py
@@ -506,3 +506,77 @@ if _check_soft_dependencies("dask", severity="none"):
         )
 
     check_dict[("dask_panel", "Panel")] = check_dask_panel
+
+if _check_soft_dependencies("gluonts", severity="none"):
+
+    def check_gluonTS_listDataset_panel(obj, return_metadata=False, var_name="obj"):
+        metadata = dict()
+
+        if (
+            not isinstance(obj, list)
+            or not isinstance(obj[0], dict)
+            or "target" not in obj[0]
+            or len(obj[0]["target"]) <= 1
+        ):
+            msg = f"{var_name} must be a gluonts.ListDataset, found {type(obj)}"
+            return _ret(False, msg, None, return_metadata)
+
+        # Check if there are no time series in the ListDataset
+        if _req("is_empty", return_metadata):
+            metadata["is_empty"] = len(obj) < 1
+
+        if _req("is_univariate", return_metadata):
+            # Check first if the ListDataset is empty
+            if len(obj) < 1:
+                metadata["is_univariate"] = True
+
+            # Check the first time-series for total features
+            else:
+                metadata["is_univariate"] = obj[0]["target"].shape[1] == 1
+
+        if _req("n_features", return_metadata):
+            # Check first if the ListDataset is empty
+            if len(obj) < 1:
+                metadata["n_features"] = 0
+
+            else:
+                metadata["n_features"] = obj[0]["target"].shape[1]
+
+        if _req("n_instances", return_metadata):
+            metadata["n_instances"] = len(obj)
+
+        if _req("n_panels", return_metadata):
+            metadata["n_panels"] = len(obj)
+
+        if _req("feature_names", return_metadata):
+            # Check first if the ListDataset is empty
+            if len(obj) < 1:
+                metadata["feature_names"] = []
+
+            else:
+                metadata["feature_names"] = [
+                    f"value_{i}" for i in range(obj[0]["target"].shape[1])
+                ]
+
+        for series in obj:
+            # check that no dtype is object
+            if series["target"].dtype == "object":
+                msg = f"{var_name} should not have column of 'object' dtype"
+                return _ret(False, msg, None, return_metadata)
+
+        # For a GluonTS ListDataset, only a start date and frequency is set
+        # so everything should thus be equally spaced
+        if _req("is_equally_spaced", return_metadata):
+            metadata["is_equally_spaced"] = True
+
+        if _req("has_nans", return_metadata):
+            for series in obj:
+                metadata["has_nans"] = pd.isnull(series["target"]).any()
+
+                # Break out if at least 1 time series has NaN values
+                if metadata["has_nans"]:
+                    break
+
+        return _ret(True, None, metadata, return_metadata)
+
+    check_dict[("gluonts_ListDataset_panel", "Panel")] = check_gluonTS_listDataset_panel
diff --git a/sktime/datatypes/_panel/_convert.py b/sktime/datatypes/_panel/_convert.py
index 27ea50b39..597b6269d 100644
--- a/sktime/datatypes/_panel/_convert.py
+++ b/sktime/datatypes/_panel/_convert.py
@@ -1122,3 +1122,33 @@ if _check_soft_dependencies("dask", severity="none"):
     _extend_conversions(
         "dask_panel", "pd-multiindex", convert_dict, mtype_universe=MTYPE_LIST_PANEL
     )
+
+if _check_soft_dependencies("gluonts", severity="none"):
+    from sktime.datatypes._adapter.gluonts import (
+        convert_listDataset_to_pandas,
+        convert_pandas_to_listDataset,
+    )
+
+    # Utilizing functions defined in _adapter/gluonts.py
+    def convert_gluonts_listDataset_to_pandas(obj, store=None):
+        return convert_listDataset_to_pandas(obj)
+
+    def convert_pandas_to_gluonts_listDataset(obj, store=None):
+        return convert_pandas_to_listDataset(obj, is_single=False)
+
+    # Storing functions in convert_dict
+    convert_dict[
+        ("pd-multiindex", "gluonts_ListDataset_panel", "Panel")
+    ] = convert_pandas_to_gluonts_listDataset
+
+    convert_dict[
+        ("gluonts_ListDataset_panel", "pd-multiindex", "Panel")
+    ] = convert_gluonts_listDataset_to_pandas
+
+    # Extending conversions
+    _extend_conversions(
+        "gluonts_ListDataset_panel",
+        "pd-multiindex",
+        convert_dict,
+        mtype_universe=MTYPE_LIST_PANEL,
+    )
diff --git a/sktime/datatypes/_panel/_examples.py b/sktime/datatypes/_panel/_examples.py
index 5a3168ab9..d4bcb819c 100644
--- a/sktime/datatypes/_panel/_examples.py
+++ b/sktime/datatypes/_panel/_examples.py
@@ -90,6 +90,28 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict[("dask_panel", "Panel", 0)] = df_dask
     example_dict_lossy[("dask_panel", "Panel", 0)] = False
 
+if _check_soft_dependencies("gluonts", severity="none"):
+    from sktime.datatypes._adapter.gluonts import convert_pandas_to_listDataset
+
+    df = example_dict[("pd-multiindex", "Panel", 0)]
+
+    # Updating the DF to have pandas Datetime objects
+    dfs = [
+        df.assign(
+            series_id=series_id,
+            timepoints=pd.date_range(start="2023-01-01", periods=len(df), freq="D"),
+        ).set_index(["series_id", "timepoints"])
+        for series_id, df in enumerate(Xlist)
+    ]
+
+    # Updating the DataFrame
+    df = pd.concat(dfs, names=["series_id", "timestamp"])
+
+    list_dataset = convert_pandas_to_listDataset(df, is_single=False)
+
+    example_dict[("gluonts_ListDataset_panel", "Panel", 0)] = list_dataset
+    example_dict_lossy[("gluonts_ListDataset_panel", "Panel", 0)] = True
+
 example_dict_metadata[("Panel", 0)] = {
     "is_univariate": False,
     "is_one_series": False,
@@ -163,6 +185,28 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict[("dask_panel", "Panel", 1)] = df_dask
     example_dict_lossy[("dask_panel", "Panel", 1)] = False
 
+if _check_soft_dependencies("gluonts", severity="none"):
+    from sktime.datatypes._adapter.gluonts import convert_pandas_to_listDataset
+
+    df = example_dict[("pd-multiindex", "Panel", 1)]
+
+    # Updating the DF to have pandas Datetime objects
+    dfs = [
+        df.assign(
+            series_id=series_id,
+            timepoints=pd.date_range(start="2023-01-01", periods=len(df), freq="D"),
+        ).set_index(["series_id", "timepoints"])
+        for series_id, df in enumerate(Xlist)
+    ]
+
+    # Updating the DataFrame
+    df = pd.concat(dfs, names=["series_id", "timestamp"])
+
+    list_dataset = convert_pandas_to_listDataset(df, is_single=False)
+
+    example_dict[("gluonts_ListDataset_panel", "Panel", 1)] = list_dataset
+    example_dict_lossy[("gluonts_ListDataset_panel", "Panel", 1)] = True
+
 
 example_dict_metadata[("Panel", 1)] = {
     "is_univariate": True,
@@ -231,6 +275,28 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict[("dask_panel", "Panel", 2)] = df_dask
     example_dict_lossy[("dask_panel", "Panel", 2)] = False
 
+if _check_soft_dependencies("gluonts", severity="none"):
+    from sktime.datatypes._adapter.gluonts import convert_pandas_to_listDataset
+
+    df = example_dict[("pd-multiindex", "Panel", 2)]
+
+    # Updating the DF to have pandas Datetime objects
+    dfs = [
+        df.assign(
+            series_id=series_id,
+            timepoints=pd.date_range(start="2023-01-01", periods=len(df), freq="D"),
+        ).set_index(["series_id", "timepoints"])
+        for series_id, df in enumerate(Xlist)
+    ]
+
+    # Updating the DataFrame
+    df = pd.concat(dfs, names=["series_id", "timestamp"])
+
+    list_dataset = convert_pandas_to_listDataset(df, is_single=False)
+
+    example_dict[("gluonts_ListDataset_panel", "Panel", 2)] = list_dataset
+    example_dict_lossy[("gluonts_ListDataset_panel", "Panel", 2)] = True
+
 example_dict_metadata[("Panel", 2)] = {
     "is_univariate": True,
     "is_one_series": True,
diff --git a/sktime/datatypes/_panel/_registry.py b/sktime/datatypes/_panel/_registry.py
index f3353e87c..b2344928f 100644
--- a/sktime/datatypes/_panel/_registry.py
+++ b/sktime/datatypes/_panel/_registry.py
@@ -36,12 +36,16 @@ MTYPE_REGISTER_PANEL = [
     ),
     ("df-list", "Panel", "list of pd.DataFrame"),
     (
-        "dask_panel",
+        "gluonts_ListDataset_panel",
         "Panel",
-        "dask frame with one instance and one time index, as per dask_to_pd convention",
+        "gluonTS representation of univariate and multivariate time series",
     ),
 ]
 
-MTYPE_SOFT_DEPS_PANEL = {"xr.DataArray": "xarray", "dask_panel": "dask"}
+MTYPE_SOFT_DEPS_PANEL = {
+    "xr.DataArray": "xarray",
+    "dask_panel": "dask",
+    "gluonts_ListDataset_panel": "gluonts",
+}
 
 MTYPE_LIST_PANEL = [x[0] for x in MTYPE_REGISTER_PANEL]
diff --git a/sktime/datatypes/_series/_check.py b/sktime/datatypes/_series/_check.py
index 9d47afebc..e9cd5319b 100644
--- a/sktime/datatypes/_series/_check.py
+++ b/sktime/datatypes/_series/_check.py
@@ -359,3 +359,82 @@ if _check_soft_dependencies("dask", severity="none"):
         )
 
     check_dict[("dask_series", "Series")] = check_dask_series
+
+if _check_soft_dependencies("gluonts", severity="none"):
+
+    def check_gluonTS_listDataset_series(obj, return_metadata=False, var_name="obj"):
+        metadata = dict()
+
+        if (
+            not isinstance(obj, list)
+            or not isinstance(obj[0], dict)
+            or "target" not in obj[0]
+            or len(obj[0]["target"]) > 1
+        ):
+            msg = f"{var_name} must be a gluonts.ListDataset, found {type(obj)}"
+            return ret(False, msg, None, return_metadata)
+
+        # Check if there are no time series in the ListDataset
+        if _req("is_empty", return_metadata):
+            metadata["is_empty"] = len(obj) < 1
+
+        if _req("is_univariate", return_metadata):
+            # Check first if the ListDataset is empty
+            if len(obj) < 1:
+                metadata["is_univariate"] = True
+
+            # Check the first time-series for total features
+            else:
+                metadata["is_univariate"] = obj[0]["target"].shape[1] == 1
+
+        if _req("n_features", return_metadata):
+            # Check first if the ListDataset is empty
+            if len(obj) < 1:
+                metadata["n_features"] = 0
+
+            else:
+                metadata["n_features"] = obj[0]["target"].shape[1]
+
+        if _req("n_instances", return_metadata):
+            metadata["n_instances"] = 1
+
+        if _req("feature_names", return_metadata):
+            # Check first if the ListDataset is empty
+            if len(obj) < 1:
+                metadata["feature_names"] = []
+
+            else:
+                metadata["feature_names"] = [
+                    f"value_{i}" for i in range(obj[0]["target"].shape[1])
+                ]
+
+        for series in obj:
+            # check that no dtype is object
+            if series["target"].dtype == "object":
+                msg = f"{var_name} should not have column of 'object' dtype"
+                return ret(False, msg, None, return_metadata)
+
+        # Check if a valid Frequency is set
+        if FREQ_SET_CHECK and len(obj) >= 1:
+            if obj[0].freq is None:
+                msg = f"{var_name} has no freq attribute set."
+                return ret(False, msg, None, return_metadata)
+
+        # For a GluonTS ListDataset, only a start date and frequency is set
+        # so everything should thus be equally spaced
+        if _req("is_equally_spaced", return_metadata):
+            metadata["is_equally_spaced"] = True
+
+        if _req("has_nans", return_metadata):
+            for series in obj:
+                metadata["has_nans"] = pd.isnull(series["target"]).any()
+
+                # Break out if at least 1 time series has NaN values
+                if metadata["has_nans"]:
+                    break
+
+        return ret(True, None, metadata, return_metadata)
+
+    check_dict[
+        ("gluonts_ListDataset_series", "Series")
+    ] = check_gluonTS_listDataset_series
diff --git a/sktime/datatypes/_series/_convert.py b/sktime/datatypes/_series/_convert.py
index fe6005878..d7ca7166d 100644
--- a/sktime/datatypes/_series/_convert.py
+++ b/sktime/datatypes/_series/_convert.py
@@ -258,3 +258,33 @@ if _check_soft_dependencies("dask", severity="none"):
     _extend_conversions(
         "dask_series", "pd.DataFrame", convert_dict, mtype_universe=MTYPE_LIST_SERIES
     )
+
+if _check_soft_dependencies("gluonts", severity="none"):
+    from sktime.datatypes._adapter.gluonts import (
+        convert_listDataset_to_pandas,
+        convert_pandas_to_listDataset,
+    )
+
+    # Utilizing functions defined in _adapter/gluonts.py
+    def convert_gluonts_listDataset_to_pandas(obj, store=None):
+        return convert_listDataset_to_pandas(obj)
+
+    def convert_pandas_to_gluonts_listDataset(obj, store=None):
+        return convert_pandas_to_listDataset(obj, is_single=True)
+
+    # Storing functions in convert_dict
+    convert_dict[
+        ("pd.DataFrame", "gluonts_ListDataset_series", "Series")
+    ] = convert_pandas_to_gluonts_listDataset
+
+    convert_dict[
+        ("gluonts_ListDataset_series", "pd.DataFrame", "Series")
+    ] = convert_gluonts_listDataset_to_pandas
+
+    # Extending conversions
+    _extend_conversions(
+        "gluonts_ListDataset_series",
+        "pd.DataFrame",
+        convert_dict,
+        mtype_universe=MTYPE_LIST_SERIES,
+    )
diff --git a/sktime/datatypes/_series/_examples.py b/sktime/datatypes/_series/_examples.py
index 0d3c5d098..4b713a14e 100644
--- a/sktime/datatypes/_series/_examples.py
+++ b/sktime/datatypes/_series/_examples.py
@@ -75,6 +75,17 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict_lossy[("dask_series", "Series", 0)] = False
 
 
+if _check_soft_dependencies("gluonts", severity="none"):
+    from gluonts.dataset.common import ListDataset
+    from gluonts.dataset.field_names import FieldName
+
+    list_dataset = ListDataset(
+        [{FieldName.TARGET: s, FieldName.START: pd.Timestamp(2023, 1, 1)}], freq="D"
+    )
+
+    example_dict[("gluonts_ListDataset_series", "Series", 0)] = list_dataset
+    example_dict_lossy[("gluonts_ListDataset_series", "Series", 0)] = True
+
 example_dict_metadata[("Series", 0)] = {
     "is_univariate": True,
     "is_equally_spaced": True,
@@ -118,6 +129,24 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict[("dask_series", "Series", 1)] = df_dask
     example_dict_lossy[("dask_series", "Series", 1)] = False
 
+if _check_soft_dependencies("gluonts", severity="none"):
+    from gluonts.dataset.common import ListDataset
+    from gluonts.dataset.field_names import FieldName
+
+    list_dataset = ListDataset(
+        [
+            {
+                FieldName.TARGET: df,
+                FieldName.START: pd.Timestamp(2023, 1, 1),
+            }
+        ],
+        freq="D",
+        one_dim_target=False,  # Must be specified for multivariate datasets
+    )
+
+    example_dict[("gluonts_ListDataset_series", "Series", 1)] = list_dataset
+    example_dict_lossy[("gluonts_ListDataset_series", "Series", 1)] = True
+
 example_dict_metadata[("Series", 1)] = {
     "is_univariate": False,
     "is_equally_spaced": True,
@@ -127,7 +156,6 @@ example_dict_metadata[("Series", 1)] = {
     "feature_names": ["a", "b"],
 }
 
-
 ###
 # example 2: multivariate, positive
 
@@ -163,6 +191,23 @@ if _check_soft_dependencies("dask", severity="none"):
     example_dict[("dask_series", "Series", 2)] = df_dask
     example_dict_lossy[("dask_series", "Series", 2)] = False
 
+if _check_soft_dependencies("gluonts", severity="none"):
+    from gluonts.dataset.common import ListDataset
+    from gluonts.dataset.field_names import FieldName
+
+    list_dataset = ListDataset(
+        [
+            {
+                FieldName.TARGET: df,
+                FieldName.START: pd.Timestamp(2023, 1, 1),
+            }
+        ],
+        freq="D",
+        one_dim_target=False,  # Must be specified for multivariate datasets
+    )
+
+    example_dict[("gluonts_ListDataset_series", "Series", 2)] = list_dataset
+    example_dict_lossy[("gluonts_ListDataset_series", "Series", 2)] = True
 
 example_dict_metadata[("Series", 2)] = {
     "is_univariate": False,
@@ -202,6 +247,25 @@ if _check_soft_dependencies("xarray", severity="none"):
     example_dict[("xr.DataArray", "Series", 3)] = da
     example_dict_lossy[("xr.DataArray", "Series", 3)] = False
 
+if _check_soft_dependencies("gluonts", severity="none"):
+    from gluonts.dataset.common import ListDataset
+    from gluonts.dataset.field_names import FieldName
+
+    list_dataset = ListDataset(
+        [
+            {
+                FieldName.TARGET: df,
+                FieldName.START: pd.Timestamp(2023, 1, 1),
+            }
+        ],
+        freq="D",
+        one_dim_target=False,  # Must be specified for multivariate datasets
+    )
+
+    example_dict[("gluonts_ListDataset_series", "Series", 3)] = list_dataset
+    example_dict_lossy[("gluonts_ListDataset_series", "Series", 3)] = True
+
+
 example_dict_metadata[("Series", 3)] = {
     "is_univariate": True,
     "is_equally_spaced": True,
diff --git a/sktime/datatypes/_series/_registry.py b/sktime/datatypes/_series/_registry.py
index fcce637f5..0d5a0ccf1 100644
--- a/sktime/datatypes/_series/_registry.py
+++ b/sktime/datatypes/_series/_registry.py
@@ -32,8 +32,17 @@ MTYPE_REGISTER_SERIES = [
         "Series",
         "xdas representation of a uni- or multivariate series",
     ),
+    (
+        "gluonts_ListDataset_series",
+        "Series",
+        "gluonTS representation of univariate and multivariate series",
+    ),
 ]
 
-MTYPE_SOFT_DEPS_SERIES = {"xr.DataArray": "xarray", "dask_series": "dask"}
+MTYPE_SOFT_DEPS_SERIES = {
+    "xr.DataArray": "xarray",
+    "dask_series": "dask",
+    "gluonts_ListDataset_series": "gluonts",
+}
 
 MTYPE_LIST_SERIES = [x[0] for x in MTYPE_REGISTER_SERIES]
diff --git a/sktime/datatypes/_table/_convert.py b/sktime/datatypes/_table/_convert.py
index 8a0db5f87..b811d83ee 100644
--- a/sktime/datatypes/_table/_convert.py
+++ b/sktime/datatypes/_table/_convert.py
@@ -26,7 +26,7 @@ ValueError and TypeError, if requested conversion is not possible
                             (depending on conversion logic)
 """
 
-__author__ = ["fkiraly"]
+__author__ = ["fkiraly", "shlok191"]
 
 __all__ = ["convert_dict"]
 
diff --git a/sktime/forecasting/arima/_pmdarima.py b/sktime/forecasting/arima/_pmdarima.py
index c3a58b5d7..67003c59e 100644
--- a/sktime/forecasting/arima/_pmdarima.py
+++ b/sktime/forecasting/arima/_pmdarima.py
@@ -271,7 +271,15 @@ class AutoARIMA(_PmdArimaAdapter):
     _tags = {
         # packaging info
         # --------------
-        "authors": ["mloning", "hyang1996", "fkiraly", "ilkersigirci"],
+        "authors": [
+            "tgsmith61591",  # for pmdarima
+            "charlesdrotar",  # for pmdarima
+            "aaronreidsmith",  # for pmdarima
+            "mloning",
+            "hyang1996",
+            "fkiraly",
+            "ilkersigirci",
+        ],
         "maintainers": ["hyang1996"],
         # python_dependencies: "pmdarima" - inherited from _PmdArimaAdapter
         # estimator type
@@ -676,7 +684,15 @@ class ARIMA(_PmdArimaAdapter):
     """  # noqa: E501
 
     _tags = {
-        "authors": ["mloning", "hyang1996", "fkiraly", "ilkersigirci"],
+        "authors": [
+            "tgsmith61591",  # for pmdarima
+            "charlesdrotar",  # for pmdarima
+            "aaronreidsmith",  # for pmdarima
+            "mloning",
+            "hyang1996",
+            "fkiraly",
+            "ilkersigirci",
+        ],
         "maintainers": ["hyang1996"],
         "handles-missing-data": True,
     }
diff --git a/sktime/forecasting/arima/_statsmodels.py b/sktime/forecasting/arima/_statsmodels.py
index ed80ea544..baf89c56b 100644
--- a/sktime/forecasting/arima/_statsmodels.py
+++ b/sktime/forecasting/arima/_statsmodels.py
@@ -14,10 +14,20 @@ from sktime.forecasting.base.adapters import _StatsModelsAdapter
 
 
 class StatsModelsARIMA(_StatsModelsAdapter):
-    """ARIMA forecaster, from statsmodels package.
+    """(S)ARIMA(X) forecaster, from statsmodels, tsa.arima module.
 
     Direct interface for ``statsmodels.tsa.arima.model.ARIMA``.
 
+    Users should note that statsmodels contains two separate implementations of
+    (S)ARIMA(X), the ARIMA and the SARIMAX class, in different modules:
+    ``tsa.arima.model.ARIMA`` and ``tsa.statespace.SARIMAX``.
+
+    These are implementations of the same underlying model, (S)ARIMA(X),
+    but with different
+    fitting strategies, fitted parameters, and slightly differring behaviour.
+    Users should refer to the statsmodels documentation for further details:
+    https://www.statsmodels.org/dev/examples/notebooks/generated/statespace_sarimax_faq.html  # noqa: E501
+
     Parameters
     ----------
     order : tuple, optional
@@ -154,7 +164,8 @@ class StatsModelsARIMA(_StatsModelsAdapter):
     _tags = {
         # packaging info
         # --------------
-        "authors": ["arnaujc91"],
+        "authors": ["chadfulton", "bashtage", "jbrockmendel", "arnaujc91"],
+        # chadfulton, bashtage, jbrockmendel for statsmodels implementation
         "maintainers": ["arnaujc91"],
         "ignores-exogeneous-X": False,
         "capability:pred_int": True,
diff --git a/sktime/forecasting/base/adapters/_generalised_statsforecast.py b/sktime/forecasting/base/adapters/_generalised_statsforecast.py
index 00b7c968f..5e7861405 100644
--- a/sktime/forecasting/base/adapters/_generalised_statsforecast.py
+++ b/sktime/forecasting/base/adapters/_generalised_statsforecast.py
@@ -7,6 +7,7 @@ from warnings import warn
 import pandas
 
 from sktime.forecasting.base import BaseForecaster
+from sktime.utils.adapters.forward import _clone_fitted_params
 
 __all__ = ["_GeneralisedStatsForecastAdapter", "StatsForecastBackAdapter"]
 __author__ = ["yarnabrina", "arnaujc91", "luca-miniati"]
@@ -168,6 +169,9 @@ class _GeneralisedStatsForecastAdapter(BaseForecaster):
 
         self._forecaster.fit(y_fit_input, X=X_fit_input)
 
+        # clone fitted parameters to self
+        _clone_fitted_params(self, self._forecaster, overwrite=False)
+
         return self
 
     def _predict_in_or_out_of_sample(self, fh, fh_type, X=None, levels=None):
diff --git a/sktime/forecasting/hf_transformers_forecaster.py b/sktime/forecasting/hf_transformers_forecaster.py
index cb50bbd02..e475bcae7 100644
--- a/sktime/forecasting/hf_transformers_forecaster.py
+++ b/sktime/forecasting/hf_transformers_forecaster.py
@@ -23,7 +23,7 @@ if _check_soft_dependencies("transformers", severity="none"):
 
 from sktime.forecasting.base import BaseForecaster, ForecastingHorizon
 
-__author__ = ["benheid"]
+__author__ = ["benheid", "geetu040"]
 
 
 class HFTransformersForecaster(BaseForecaster):
@@ -40,7 +40,17 @@ class HFTransformersForecaster(BaseForecaster):
         Path to the huggingface model to use for forecasting. Currently,
         Informer, Autoformer, and TimeSeriesTransformer are supported.
     fit_strategy : str, default="minimal"
-        Strategy to use for fitting the model. Can be "minimal" or "full"
+        Strategy to use for fitting (fine-tuning) the model. This can be one of
+        the following:
+        - "minimal": Fine-tunes only a small subset of the model parameters,
+          allowing for quick adaptation with limited computational resources.
+        - "full": Fine-tunes all model parameters, which may result in better
+          performance but requires more computational power and time.
+        - "peft": Applies Parameter-Efficient Fine-Tuning (PEFT) techniques to adapt
+          the model with fewer trainable parameters, saving computational resources.
+          Note: If the 'peft' package is not available, a `ModuleNotFoundError` will
+          be raised, indicating that the 'peft' package is required. Please install
+          it using `pip install peft` to use this fit strategy.
     validation_split : float, default=0.2
         Fraction of the data to use for validation
     config : dict, default={}
@@ -57,6 +67,11 @@ class HFTransformersForecaster(BaseForecaster):
         Whether the predictions should be deterministic or not.
     callbacks : list, default=[]
         List of callbacks to use during training. See `transformers.Trainer`
+    peft_config : peft.PeftConfig, default=None
+        Configuration for Parameter-Efficient Fine-Tuning.
+        When `fit_strategy` is set to "peft",
+        this will be used to set up PEFT parameters for the model.
+        See the `peft` documentation for details.
 
     Examples
     --------
@@ -83,6 +98,38 @@ class HFTransformersForecaster(BaseForecaster):
     >>> forecaster.fit(y) # doctest: +SKIP
     >>> fh = [1, 2, 3]
     >>> y_pred = forecaster.predict(fh) # doctest: +SKIP
+
+    >>> from sktime.forecasting.hf_transformers_forecaster import (
+    ...     HFTransformersForecaster,
+    ... )
+    >>> from sktime.datasets import load_airline
+    >>> from peft import LoraConfig
+    >>> y = load_airline()
+    >>> forecaster = HFTransformersForecaster(
+    ...    model_path="huggingface/autoformer-tourism-monthly",
+    ...    fit_strategy="peft",
+    ...    training_args={
+    ...        "num_train_epochs": 20,
+    ...        "output_dir": "test_output",
+    ...        "per_device_train_batch_size": 32,
+    ...    },
+    ...    config={
+    ...         "lags_sequence": [1, 2, 3],
+    ...         "context_length": 2,
+    ...         "prediction_length": 4,
+    ...         "use_cpu": True,
+    ...         "label_length": 2,
+    ...    },
+    ...    peft_config=LoraConfig(
+    ...        r=8,
+    ...        lora_alpha=32,
+    ...        target_modules=["q_proj", "v_proj"],
+    ...        lora_dropout=0.01,
+    ...    )
+    ... ) # doctest: +SKIP
+    >>> forecaster.fit(y) # doctest: +SKIP
+    >>> fh = [1, 2, 3]
+    >>> y_pred = forecaster.predict(fh) # doctest: +SKIP
     """
 
     _tags = {
@@ -109,6 +156,7 @@ class HFTransformersForecaster(BaseForecaster):
         compute_metrics=None,
         deterministic=False,
         callbacks=None,
+        peft_config=None,
     ):
         super().__init__()
         self.model_path = model_path
@@ -124,6 +172,7 @@ class HFTransformersForecaster(BaseForecaster):
         self.deterministic = deterministic
         self.callbacks = callbacks
         self._callbacks = callbacks
+        self.peft_config = peft_config
 
     def _fit(self, y, X, fh):
         # Load model and extract config
@@ -227,6 +276,23 @@ class HFTransformersForecaster(BaseForecaster):
         elif self.fit_strategy == "full":
             for param in self.model.parameters():
                 param.requires_grad = True
+        elif self.fit_strategy == "peft":
+            if _check_soft_dependencies(
+                "peft",
+                severity="error",
+                msg=(
+                    f"Error in {self.__class__.__name__}: 'peft' module not found. "
+                    "'peft' is a soft dependency and not included "
+                    "in the base sktime installation. "
+                    "To use this functionality, please install 'peft' by running: "
+                    "`pip install peft` or `pip install sktime[dl]`. "
+                    "To install all soft dependencies, "
+                    "run: `pip install sktime[all_extras]`"
+                ),
+            ):
+                from peft import get_peft_model
+            peft_config = deepcopy(self.peft_config)
+            self.model = get_peft_model(self.model, peft_config)
         else:
             raise ValueError("Unknown fit strategy")
 
@@ -321,7 +387,7 @@ class HFTransformersForecaster(BaseForecaster):
             `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
             `create_test_instance` uses the first (or only) dictionary in `params`
         """
-        return [
+        test_params = [
             {
                 "model_path": "huggingface/informer-tourism-monthly",
                 "fit_strategy": "minimal",
@@ -355,6 +421,36 @@ class HFTransformersForecaster(BaseForecaster):
             },
         ]
 
+        if _check_soft_dependencies("peft", severity="none"):
+            from peft import LoraConfig
+
+            test_params.append(
+                {
+                    "model_path": "huggingface/autoformer-tourism-monthly",
+                    "fit_strategy": "peft",
+                    "training_args": {
+                        "num_train_epochs": 1,
+                        "output_dir": "test_output",
+                        "per_device_train_batch_size": 32,
+                    },
+                    "config": {
+                        "lags_sequence": [1, 2, 3],
+                        "context_length": 2,
+                        "prediction_length": 4,
+                        "label_length": 2,
+                    },
+                    "peft_config": LoraConfig(
+                        r=2,
+                        lora_alpha=8,
+                        target_modules=["q_proj"],
+                        lora_dropout=0.01,
+                    ),
+                    "deterministic": True,
+                }
+            )
+
+        return test_params
+
 
 class PyTorchDataset(Dataset):
     """Dataset for use in sktime deep learning forecasters."""
diff --git a/sktime/forecasting/sarimax.py b/sktime/forecasting/sarimax.py
index 1ec5cb49f..c6661d6b2 100644
--- a/sktime/forecasting/sarimax.py
+++ b/sktime/forecasting/sarimax.py
@@ -11,9 +11,19 @@ from sktime.forecasting.base.adapters import _StatsModelsAdapter
 
 
 class SARIMAX(_StatsModelsAdapter):
-    """SARIMAX forecaster.
+    """(S)ARIMA(X) forecaster, from statsmodels, tsa.statespace module.
 
-    Direct interface for ``statsmodels.tsa.api.SARIMAX``.
+    Direct interface for ``statsmodels.tsa.statespace.SARIMAX``.
+
+    Users should note that statsmodels contains two separate implementations of
+    (S)ARIMA(X), the ARIMA and the SARIMAX class, in different modules:
+    ``tsa.arima.model.ARIMA`` and ``tsa.statespace.SARIMAX``.
+
+    These are implementations of the same underlying model, (S)ARIMA(X),
+    but with different
+    fitting strategies, fitted parameters, and slightly differring behaviour.
+    Users should refer to the statsmodels documentation for further details:
+    https://www.statsmodels.org/dev/examples/notebooks/generated/statespace_sarimax_faq.html  # noqa: E501
 
     Parameters
     ----------
@@ -215,7 +225,14 @@ class SARIMAX(_StatsModelsAdapter):
     _tags = {
         # packaging info
         # --------------
-        "authors": ["TNTran92", "yarnabrina"],
+        "authors": [
+            "chadfulton",  # for statsmodels
+            "bashtage",  # for statsmodels
+            "jbrockmendel",  # for statsmodels
+            "jackzyliu",  # for statsmodels
+            "TNTran92",
+            "yarnabrina",
+        ],
         "maintainers": ["TNTran92", "yarnabrina"],
         # "python_dependencnies": "statsmodels" - inherited from _StatsModelsAdapter
         # estimator type
diff --git a/sktime/forecasting/var.py b/sktime/forecasting/var.py
index 9bd0f9a77..2650f7530 100644
--- a/sktime/forecasting/var.py
+++ b/sktime/forecasting/var.py
@@ -13,10 +13,12 @@ from sktime.forecasting.base.adapters import _StatsModelsAdapter
 
 
 class VAR(_StatsModelsAdapter):
-    """A VAR model is a generalisation of the univariate autoregressive.
+    """VAR model from statsmodels.
 
-    Direct interface for ``statsmodels.tsa.vector_ar``
-    A model for forecasting a vector of time series[1].
+    Direct interface to ``statsmodels.tsa.vector_ar``.
+
+    A VAR model is a generalisation of the univariate autoregressive model
+    to multivariate time series, see [1]_.
 
     Parameters
     ----------
diff --git a/sktime/regression/model_selection/__init__.py b/sktime/regression/model_selection/__init__.py
new file mode 100644
index 000000000..c73187931
--- /dev/null
+++ b/sktime/regression/model_selection/__init__.py
@@ -0,0 +1,6 @@
+"""Tuning of time series regressors."""
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+
+from sktime.regression.model_selection._tune import TSRGridSearchCV
+
+__all__ = ["TSRGridSearchCV"]
diff --git a/sktime/regression/model_selection/_tune.py b/sktime/regression/model_selection/_tune.py
new file mode 100644
index 000000000..232c01329
--- /dev/null
+++ b/sktime/regression/model_selection/_tune.py
@@ -0,0 +1,440 @@
+"""Tuning for time series regressors."""
+
+__author__ = ["ksharma6"]
+
+import numpy as np
+from sklearn.model_selection import GridSearchCV
+
+from sktime.regression._delegate import _DelegatedRegressor
+
+
+class TSRGridSearchCV(_DelegatedRegressor):
+    """Exhaustive search over specified parameter values for an estimator.
+
+    Adapts sklearn GridSearchCV for sktime time series regressors
+
+    Optimizes hyper-parameters of `estimators` by exhaustive grid search.
+
+    Parameters
+    ----------
+    estimator : estimator object
+        This is assumed to implement the scikit-learn estimator interface.
+        Either estimator needs to provide a ``score`` function,
+        or ``scoring`` must be passed.
+
+    param_grid : dict or list of dictionaries
+        Dictionary with parameters names (`str`) as keys and lists of
+        parameter settings to try as values, or a list of such
+        dictionaries, in which case the grids spanned by each dictionary
+        in the list are explored. This enables searching over any sequence
+        of parameter settings.
+
+    scoring : str, callable, list, tuple or dict, default=None
+        Strategy to evaluate the performance of the cross-validated model on
+        the test set.
+
+        If `scoring` represents a single score, one can use:
+
+        - a single string (see :ref:`scoring_parameter`);
+        - a callable (see :ref:`scoring`) that returns a single value.
+
+        If `scoring` represents multiple scores, one can use:
+
+        - a list or tuple of unique strings;
+        - a callable returning a dictionary where the keys are the metric
+          names and the values are the metric scores;
+        - a dictionary with metric names as keys and callables a values.
+
+    n_jobs : int, default=None
+        Number of jobs to run in parallel.
+        ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context.
+        ``-1`` means using all processors. See :term:`Glossary <n_jobs>`
+        for more details.
+
+    refit : bool, str, or callable, default=True
+        Refit an estimator using the best found parameters on the whole
+        dataset. If ``False``, then ``predict`` will not work.
+
+        For multiple metric evaluation, this needs to be a `str` denoting the
+        scorer that would be used to find the best parameters for refitting
+        the estimator at the end.
+
+        Where there are considerations other than maximum score in
+        choosing a best estimator, ``refit`` can be set to a function which
+        returns the selected ``best_index_`` given ``cv_results_``. In that
+        case, the ``best_estimator_`` and ``best_params_`` will be set
+        according to the returned ``best_index_`` while the ``best_score_``
+        attribute will not be available.
+
+        The refitted estimator is made available at the ``best_estimator_``
+        attribute and permits using ``predict`` directly on this
+        ``GridSearchCV`` instance.
+
+        Also for multiple metric evaluation, the attributes ``best_index_``,
+        ``best_score_`` and ``best_params_`` will only be available if
+        ``refit`` is set and all of them will be determined w.r.t this specific
+        scorer.
+
+        See ``scoring`` parameter to know more about multiple metric
+        evaluation.
+
+    cv : int, cross-validation generator or an iterable, default=None
+        Determines the cross-validation splitting strategy.
+        Possible inputs for cv are:
+
+        - None, to use the default 5-fold cross validation,
+        - integer, to specify the number of folds in a `(Stratified)KFold`,
+        - :term:`CV splitter`,
+        - An iterable yielding (train, test) splits as arrays of indices.
+
+        For integer/None inputs, if the estimator is a regressor and ``y`` is
+        either binary or multiclass, :class:`StratifiedKFold` is used. In all
+        other cases, :class:`KFold` is used. These splitters are instantiated
+        with `shuffle=False` so the splits will be the same across calls.
+
+        Refer :ref:`User Guide <cross_validation>` for the various
+        cross-validation strategies that can be used here.
+
+    verbose : int
+        Controls the verbosity: the higher, the more messages.
+
+        - >1 : the computation time for each fold and parameter candidate is
+          displayed;
+        - >2 : the score is also displayed;
+        - >3 : the fold and candidate parameter indexes are also displayed
+          together with the starting time of the computation.
+
+    pre_dispatch : int, or str, default='2*n_jobs'
+        Controls the number of jobs that get dispatched during parallel
+        execution. Reducing this number can be useful to avoid an
+        explosion of memory consumption when more jobs get dispatched
+        than CPUs can process. This parameter can be:
+
+            - None, in which case all the jobs are immediately
+              created and spawned. Use this for lightweight and
+              fast-running jobs, to avoid delays due to on-demand
+              spawning of the jobs
+
+            - An int, giving the exact number of total jobs that are
+              spawned
+
+            - A str, giving an expression as a function of n_jobs,
+              as in '2*n_jobs'
+
+    error_score : 'raise' or numeric, default=np.nan
+        Value to assign to the score if an error occurs in estimator fitting.
+        If set to 'raise', the error is raised. If a numeric value is given,
+        FitFailedWarning is raised. This parameter does not affect the refit
+        step, which will always raise the error.
+
+    return_train_score : bool, default=False
+        If ``False``, the ``cv_results_`` attribute will not include training
+        scores.
+        Computing training scores is used to get insights on how different
+        parameter settings impact the overfitting/underfitting trade-off.
+        However computing the scores on the training set can be computationally
+        expensive and is not strictly required to select the parameters that
+        yield the best generalization performance.
+
+    tune_by_variable : bool, optional (default=False)
+        Whether to tune parameter by each time series variable separately,
+        in case of multivariate data passed to the tuning estimator.
+        Only applies if time series passed are strictly multivariate.
+        If True, clones of the estimator will be fit to each variable separately,
+        and are available in fields of the regressors_ attribute.
+        Has the same effect as applying ColumnEnsembleRegressor wrapper to self.
+        If False, the same best parameter is selected for all variables.
+
+    Attributes
+    ----------
+    cv_results_ : dict of numpy (masked) ndarrays
+        A dict with keys as column headers and values as columns, that can be
+        imported into a pandas ``DataFrame``.
+
+        For multi-metric evaluation, the scores for all the scorers are
+        available in the ``cv_results_`` dict at the keys ending with that
+        scorer's name (``'_<scorer_name>'``) instead of ``'_score'`` shown
+        above. ('split0_test_precision', 'mean_train_precision' etc.)
+
+    best_estimator_ : estimator
+        Estimator that was chosen by the search, i.e. estimator
+        which gave highest score (or smallest loss if specified)
+        on the left out data. Not available if ``refit=False``.
+
+        See ``refit`` parameter for more information on allowed values.
+
+    best_score_ : float
+        Mean cross-validated score of the best_estimator
+
+        For multi-metric evaluation, this is present only if ``refit`` is
+        specified.
+
+        This attribute is not available if ``refit`` is a function.
+
+    best_params_ : dict
+        Parameter setting that gave the best results on the hold out data.
+
+        For multi-metric evaluation, this is present only if ``refit`` is
+        specified.
+
+    best_index_ : int
+        The index (of the ``cv_results_`` arrays) which corresponds to the best
+        candidate parameter setting.
+
+        The dict at ``search.cv_results_['params'][search.best_index_]`` gives
+        the parameter setting for the best model, that gives the highest
+        mean score (``search.best_score_``).
+
+        For multi-metric evaluation, this is present only if ``refit`` is
+        specified.
+
+    scorer_ : function or a dict
+        Scorer function used on the held out data to choose the best
+        parameters for the model.
+
+        For multi-metric evaluation, this attribute holds the validated
+        ``scoring`` dict which maps the scorer key to the scorer callable.
+
+    n_splits_ : int
+        The number of cross-validation splits (folds/iterations).
+
+    refit_time_ : float
+        Seconds used for refitting the best model on the whole dataset.
+
+        This is present only if ``refit`` is not False.
+
+    multimetric_ : bool
+        Whether or not the scorers compute several metrics.
+
+    n_features_in_ : int
+        Number of features seen during :term:`fit`. Only defined if
+        `best_estimator_` is defined (see the documentation for the `refit`
+        parameter for more details) and that `best_estimator_` exposes
+        `n_features_in_` when fit.
+
+    feature_names_in_ : ndarray of shape (`n_features_in_`,)
+        Names of features seen during :term:`fit`. Only defined if
+        `best_estimator_` is defined (see the documentation for the `refit`
+        parameter for more details) and that `best_estimator_` exposes
+        `feature_names_in_` when fit.
+
+    See Also
+    --------
+    ParameterGrid : Generates all the combinations of a hyperparameter grid.
+    train_test_split : Utility function to split the data into a development
+        set usable for fitting a GridSearchCV instance and an evaluation set
+        for its final evaluation.
+    sklearn.metrics.make_scorer : Make a scorer from a performance metric or
+        loss function.
+    """
+
+    _tags = {
+        # packaging info
+        # --------------
+        "authors": ["ksharma6"],
+        # estimator type
+        # --------------
+        "X_inner_mtype": ["nested_univ", "numpy3D"],
+        "y_inner_mtype": ["numpy2D"],
+        "capability:multivariate": True,
+        "capability:multioutput": True,
+        "capability:unequal_length": True,
+        "capability:missing_values": True,
+        "capability:multithreading": True,
+    }
+
+    def __init__(
+        self,
+        estimator,
+        param_grid,
+        scoring=None,
+        n_jobs=None,
+        refit=True,
+        cv=None,
+        verbose=0,
+        pre_dispatch="2*n_jobs",
+        error_score=np.nan,
+        return_train_score=False,
+        tune_by_variable=False,
+    ):
+        self.estimator = estimator
+        self.param_grid = param_grid
+        self.scoring = scoring
+        self.n_jobs = n_jobs
+        self.refit = refit
+        self.cv = cv
+        self.verbose = verbose
+        self.pre_dispatch = pre_dispatch
+        self.error_score = error_score
+        self.return_train_score = return_train_score
+        self.tune_by_variable = tune_by_variable
+
+        super().__init__()
+
+        DELEGATED_PARAMS = [
+            "estimator",
+            "param_grid",
+            "scoring",
+            "n_jobs",
+            "refit",
+            "cv",
+            "verbose",
+            "pre_dispatch",
+            "error_score",
+            "return_train_score",
+        ]
+
+        gscvargs = {k: getattr(self, k) for k in DELEGATED_PARAMS}
+
+        self.estimator_ = GridSearchCV(**gscvargs)
+
+        if self.tune_by_variable:
+            self.set_tags(**{"capability:multioutput": False})
+
+    def _fit(self, X, y):
+        """Fit time series regressor to training data.
+
+        private _fit containing the core logic, called from fit
+
+        Writes to self:
+            Sets fitted model attributes ending in "_".
+
+        Parameters
+        ----------
+        X : guaranteed to be of a type in self.get_tag("X_inner_mtype")
+            if self.get_tag("X_inner_mtype") = "numpy3D":
+            3D np.ndarray of shape = [n_instances, n_dimensions, series_length]
+            if self.get_tag("X_inner_mtype") = "pd-multiindex:":
+            pd.DataFrame with columns = variables,
+            index = pd.MultiIndex with first level = instance indices,
+            second level = time indices
+            for list of other mtypes, see datatypes.SCITYPE_REGISTER
+            for specifications, see examples/AA_datatypes_and_datasets.ipynb
+        y : guaranteed to be of a type in self.get_tag("y_inner_mtype")
+            1D iterable, of shape [n_instances]
+            or 2D iterable, of shape [n_instances, n_dimensions]
+            class labels for fitting
+            if self.get_tag("capaility:multioutput") = False, guaranteed to be 1D
+            if self.get_tag("capaility:multioutput") = True, guaranteed to be 2D
+
+        Returns
+        -------
+        self : Reference to self.
+        """
+        if y.shape[1] == 1:
+            y = y.flatten()
+
+        estimator = self._get_delegate()
+        estimator.fit(X=X, y=y)
+
+        fitted_param_names = [
+            "cv_results_",
+            "best_estimator_",
+            "best_score_",
+            "best_params_",
+            "best_index_",
+            "scorer_",
+            "n_splits_",
+            "refit_time_",
+            "multimetric_",
+        ]
+
+        for p in fitted_param_names:
+            if hasattr(estimator, p):
+                val = getattr(estimator, p)
+                setattr(self, p, val)
+
+        return self
+
+    def _predict(self, X):
+        """Predict labels for sequences in X.
+
+        private _predict containing the core logic, called from predict
+
+        State required:
+            Requires state to be "fitted".
+
+        Accesses in self:
+            Fitted model attributes ending in "_"
+
+        Parameters
+        ----------
+        X : guaranteed to be of a type in self.get_tag("X_inner_mtype")
+            if self.get_tag("X_inner_mtype") = "numpy3D":
+                3D np.ndarray of shape = [n_instances, n_dimensions, series_length]
+            if self.get_tag("X_inner_mtype") = "nested_univ":
+                pd.DataFrame with each column a dimension, each cell a pd.Series
+            for list of other mtypes, see datatypes.SCITYPE_REGISTER
+            for specifications, see examples/AA_datatypes_and_datasets.ipynb
+
+        Returns
+        -------
+        y : 1D np.array of int, of shape [n_instances] - predicted class labels
+            indices correspond to instance indices in X
+        """
+        estimator = self._get_delegate()
+        y_pred = estimator.predict(X=X)
+        if y_pred.ndim == 1:
+            y_pred = y_pred.reshape(-1, 1)
+        return y_pred
+
+    # the delegate is an sklearn estimator and it does not have get_fitted_params
+    # therefore we have to override _get_fitted_params from the delegator,
+    # which would otherwise call it
+    def _get_fitted_params(self):
+        """Get fitted parameters.
+
+        private _get_fitted_params, called from get_fitted_params
+
+        State required:
+            Requires state to be "fitted".
+
+        Returns
+        -------
+        fitted_params : dict with str keys
+            fitted parameters, keyed by names of fitted parameter
+        """
+        return {}
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+            For regressors, a "default" set of parameters should be provided for
+            general testing, and a "results_comparison" set for comparing against
+            previously recorded results if the general set does not produce suitable
+            probabilities to compare against.
+
+        Returns
+        -------
+        params : dict or list of dict, default={}
+            Parameters to create testing instances of the class.
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`.
+        """
+        from sklearn.metrics import r2_score
+
+        from sktime.dists_kernels import AggrDist, ScipyDist
+        from sktime.regression.distance_based import KNeighborsTimeSeriesRegressor
+
+        mean_eucl_tsdist = AggrDist(ScipyDist(metric="euclidean"))
+        mean_cb_tsdist = AggrDist(ScipyDist(metric="cityblock"))
+
+        param1 = {
+            "estimator": KNeighborsTimeSeriesRegressor(distance=mean_eucl_tsdist),
+            "param_grid": {"n_neighbors": [1, 3, 5]},
+        }
+
+        param2 = {
+            "estimator": KNeighborsTimeSeriesRegressor(distance=mean_cb_tsdist),
+            "param_grid": {"distance__metric": ["euclidean", "cityblock"]},
+            "scoring": r2_score,
+        }
+
+        return [param1, param2]
diff --git a/sktime/transformations/compose/_featureunion.py b/sktime/transformations/compose/_featureunion.py
index 5151f1b2f..5ed5d95a9 100644
--- a/sktime/transformations/compose/_featureunion.py
+++ b/sktime/transformations/compose/_featureunion.py
@@ -113,6 +113,10 @@ class FeatureUnion(_HeterogenousMetaEstimator, BaseTransformer):
         self._anytagis_then_set("handles-missing-data", False, True, ests)
         self._anytagis_then_set("univariate-only", True, False, ests)
 
+        # if any of the components require_X or require_y, set it for the composite
+        self._anytagis_then_set("requires_X", True, False, ests)
+        self._anytagis_then_set("requires_y", True, False, ests)
+
     @property
     def _transformer_list(self):
         return self._get_estimator_tuples(self.transformer_list, clone_ests=False)
diff --git a/sktime/transformations/compose/_pipeline.py b/sktime/transformations/compose/_pipeline.py
index 14d17e121..42618b575 100644
--- a/sktime/transformations/compose/_pipeline.py
+++ b/sktime/transformations/compose/_pipeline.py
@@ -45,6 +45,11 @@ class TransformerPipeline(_HeterogenousMetaEstimator, BaseTransformer):
         where ``trafo[i].update`` and ``trafo[i].transform`` receive as input
         the output of ``trafo[i-1].transform``
 
+    For transformers in the pipeline that use the ``y`` argument, the ``y`` argument
+    passed to ``TransformerPipeline.fit`` or ``transform`` is passed to
+    all such transformers in the pipeline. No transformations or inverse transformations
+    are applied to ``y`` in the pipeline.
+
     The ``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
@@ -149,6 +154,8 @@ class TransformerPipeline(_HeterogenousMetaEstimator, BaseTransformer):
 
         # input mtype and input type are as of the first estimator
         self.clone_tags(first_trafo, ["scitype:transform-input"])
+        # chain requires X if and only if first estimator requires X
+        self.clone_tags(first_trafo, ["requires_X"])
         # output type is that of last estimator, if no "Primitives" occur in the middle
         # if "Primitives" occur in the middle, then output is set to that too
         # this is in a case where "Series-to-Series" is applied to primitive df
@@ -166,6 +173,7 @@ class TransformerPipeline(_HeterogenousMetaEstimator, BaseTransformer):
         self._anytagis_then_set("fit_is_empty", False, True, ests)
         self._anytagis_then_set("transform-returns-same-time-index", False, True, ests)
         self._anytagis_then_set("skip-inverse-transform", False, True, ests)
+        self._anytagis_then_set("requires_y", True, False, ests)
 
         # self can inverse transform if for all est, we either skip or can inv-transform
         skips = [est.get_tag("skip-inverse-transform") for _, est in ests]
diff --git a/sktime/utils/adapters/__init__.py b/sktime/utils/adapters/__init__.py
new file mode 100644
index 000000000..0430ef04c
--- /dev/null
+++ b/sktime/utils/adapters/__init__.py
@@ -0,0 +1,1 @@
+"""Utilities for adapters and dispatchers."""
diff --git a/sktime/utils/adapters/forward.py b/sktime/utils/adapters/forward.py
new file mode 100644
index 000000000..9357eb8a5
--- /dev/null
+++ b/sktime/utils/adapters/forward.py
@@ -0,0 +1,75 @@
+"""Utilities for attribute forwarding of estimators."""
+
+__author__ = ["fkiraly"]
+
+
+def _clone_fitted_params(to_obj, from_obj, overwrite=False):
+    """Clone fitted parameters from one estimator to another.
+
+    Takes all fitted parameters from ``from_obj`` and sets them on ``to_obj``.
+    Fitted parameters are attributes of the estimator that end in "_".
+
+    Mutable or pointer objects are not cloned, only the reference is copied.
+
+    Parameters
+    ----------
+    to_obj : any object
+        estimator to clone fitted parameters to
+    from_obj : any object
+        estimator to clone fitted parameters from
+    overwrite : bool, optional, default = False
+        whether to overwrite existing attributes in ``to_obj``
+
+    Returns
+    -------
+    to_obj : reference to to_obj, with parameters set
+    """
+    fitted_params = _get_fitted_params_safe(from_obj)
+    for key, value in fitted_params.items():
+        if overwrite or not hasattr(to_obj, key):
+            setattr(to_obj, f"{key}_", value)
+    return to_obj
+
+
+def _get_fitted_params_safe(obj):
+    """Obtain fitted params of object, per sklearn convention.
+
+    Fitted parameters are attributes of the estimator that end in "_"
+    and do not start with "_". This is an ``sklearn`` and ``sktime`` API convention.
+
+    Mutable or pointer objects are not cloned, the references are returned.
+
+    Parameters
+    ----------
+    obj : any object
+
+    Returns
+    -------
+    fitted_params : dict with str keys
+        fitted parameter values, keyed by names of fitted parameter.
+        keys are the names of the attributes, without the trailing "_" character.
+        values are the values of the attributes, with the trailing "_" character,
+        in ``obj``.
+    """
+    # default retrieves all self attributes ending in "_"
+    # and returns them with keys that have the "_" removed
+    #
+    # get all attributes ending in "_", exclude any that start with "_" (private)
+    fitted_params = [
+        attr for attr in dir(obj) if attr.endswith("_") and not attr.startswith("_")
+    ]
+
+    def hasattr_safe(obj, attr):
+        try:
+            if hasattr(obj, attr):
+                getattr(obj, attr)
+                return True
+        except Exception:
+            return False
+
+    # remove the "_" at the end
+    fitted_param_dict = {
+        p[:-1]: getattr(obj, p) for p in fitted_params if hasattr_safe(obj, p)
+    }
+
+    return fitted_param_dict


Test patch:
diff --git a/sktime/datatypes/_adapter/tests/test_gluonts.py b/sktime/datatypes/_adapter/tests/test_gluonts.py
new file mode 100644
index 000000000..9cd709b27
--- /dev/null
+++ b/sktime/datatypes/_adapter/tests/test_gluonts.py
@@ -0,0 +1,51 @@
+import numpy as np
+import pandas as pd
+import pytest
+
+from sktime.datatypes._adapter.gluonts import convert_pandas_to_listDataset
+from sktime.utils.dependencies import _check_soft_dependencies
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies("gluonts", severity="none"),
+    reason="skip test if required soft dependency for GluonTS not available",
+)
+@pytest.mark.parametrize(
+    "pandas_df",
+    [
+        (
+            pd.DataFrame(
+                {
+                    "series_id": np.zeros(50, dtype=int),
+                    "time": pd.date_range("2022-01-01", periods=50, freq="D"),
+                    "target": np.random.randn(50),
+                }
+            )
+        ),
+        (
+            pd.DataFrame(
+                {
+                    "series_id": np.repeat(np.arange(3), 50),
+                    "time": np.tile(
+                        pd.date_range("2022-01-01", periods=50, freq="D"), 3
+                    ),
+                    "0": np.random.randn(150),
+                    "1": np.random.randn(150),
+                    "2": np.random.randn(150),
+                }
+            )
+        ),
+    ],
+)
+def test_pandas_to_ListDataset(pandas_df):
+    # Make the pandas DF multiindex
+    pandas_df = pandas_df.set_index(["series_id", "time"])
+
+    generated_list = convert_pandas_to_listDataset(pandas_df)
+    idx = 0
+
+    # Asserting equivalence for each time series in the ListDataset
+    # and the series categories in the DataFrame
+    for _, group_data in pandas_df.groupby(level=0):
+        np.testing.assert_allclose(group_data.values, generated_list[idx]["target"])
+        idx += 1
diff --git a/sktime/regression/model_selection/tests/__init__.py b/sktime/regression/model_selection/tests/__init__.py
new file mode 100644
index 000000000..8174208fe
--- /dev/null
+++ b/sktime/regression/model_selection/tests/__init__.py
@@ -0,0 +1,1 @@
+"""Tests for selecting regression models."""
diff --git a/sktime/regression/model_selection/tests/test_tune.py b/sktime/regression/model_selection/tests/test_tune.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/sktime/tests/_config.py b/sktime/tests/_config.py
index 80da9009b..c4a88be21 100644
--- a/sktime/tests/_config.py
+++ b/sktime/tests/_config.py
@@ -249,6 +249,7 @@ EXCLUDED_TESTS = {
         "test_save_estimators_to_file",
         "test_fit_idempotent",
     ],
+    "TSRGridSearchCV": ["test_multioutput"],  # see 6708
 }
 
 # We use estimator tags in addition to class hierarchies to further distinguish
diff --git a/sktime/transformations/tests/test_compose.py b/sktime/transformations/tests/test_compose.py
index 925649bce..17530c23e 100644
--- a/sktime/transformations/tests/test_compose.py
+++ b/sktime/transformations/tests/test_compose.py
@@ -377,3 +377,36 @@ def test_input_output_series_panel_chain():
     Xt = bootstrap_trafo.fit_transform(X)
     assert isinstance(Xt, pd.DataFrame)
     assert isinstance(Xt.index, pd.MultiIndex)
+
+
+@pytest.mark.skipif(
+    not run_test_module_changed("sktime.transformations"),
+    reason="run test only if anything in sktime.transformations module has changed",
+)
+def test_requires_tags_trafopipe():
+    """Test correct handling of requires_X tag, failure case in ."""
+    from sktime.transformations.compose import TransformerPipeline, YtoX
+    from sktime.transformations.series.fourier import FourierFeatures
+
+    # data with no exogenous features
+    X = load_airline()
+
+    # create a pipeline with Fourier features and ARIMA
+    pipe = TransformerPipeline(
+        steps=[
+            YtoX(),
+            FourierFeatures(
+                sp_list=[24, 24 * 7],
+                fourier_terms_list=[10, 5],
+                keep_original_columns=True,
+            ),
+        ]
+    )
+
+    assert not pipe.get_tags()["requires_X"]
+    # should not requires X as input, because YtoX does not
+
+    assert pipe.get_tags()["requires_y"]
+    # should require y as input, because YtoX does
+
+    pipe.fit_transform(X=None, y=X)