Repository: alan-turing-institute/sktime
Pull request #2486 (2022-04-15 13:17:46)

[ENH] Refactoring adjustment to make_reduction via global forecasting
Rewrite refactoring adjustments for the `make_reduction` changes

Main point is to remove the rather clumsy checks date checks

```python
            if isinstance(self.cutoff, pd._libs.tslibs.period.Period):
                dateline = pd.period_range(
                    end=fh.to_absolute(self.cutoff)[-1], periods=fh_max
                )
            elif isinstance(self.cutoff, np.int64) or isinstance(self.cutoff, int):
                dateline = list(
                    range(self.cutoff + 1, fh.to_absolute(self.cutoff)[-1] + 1)
                )
            else:
                dateline = pd.date_range(
                    end=fh.to_absolute(self.cutoff)[-1], periods=fh_max
                )
            y_pred = _create_multiindex(dateline, self._y)
```

with a helper function that draws on the fh functions

```python
 index_range = _index_range(relative, self.cutoff)
```

Also reworked the `create_multiindex` function to for compatibility with hierarchical formats.

The key step is 

```
    mi = (
        target_frame.groupby(x_names, as_index=True)
        .apply(
            lambda df: df.drop(x_names, axis=1)
            .set_index(time_names)
            .asfreq(freq_inferred)
        )
        .index
    )
```

That supports now to have the hierarchical format also for global forecasting

Fixes:
https://github.com/alan-turing-institute/sktime/issues/2628
https://github.com/alan-turing-institute/sktime/issues/2615

review-notebook-app[bot]: Check out this pull request on&nbsp; <a href="https://app.reviewnb.com/alan-turing-institute/sktime/pull/2486"><img align="absmiddle"  alt="ReviewNB" height="28" class="BotMessageButtonImage" src="https://raw.githubusercontent.com/ReviewNB/support/master/images/button_reviewnb.png"/></a> 

 See visual diffs & provide feedback on Jupyter Notebooks. 

---

 <i>Powered by <a href='https://www.reviewnb.com/?utm_source=gh'>ReviewNB</a></i>
fkiraly: please write a descriptive PR summary, and choose a descriptive PR name (see about all other PR)
khrapovs: > @khrapovs, could you kindly look at the changes to forecastinghorizon? I´m not sure whether they would revert your earlier changes.

@fkiraly There is a new argument `consecutive` with default value `False` in `to_absolute`. Given this value the behavior of the method is identical to already existing. So, no complaints from my side.
fkiraly: @khrapovs, are you certain that the behaviour is otherwise identical??

For instance, the case distinction based on `if is_timestamp` seems to be removed.
fkiraly: I would strongly hesitate adding any new arguments in methods of the forecastinghorizon. This needs explanation for a review first...
khrapovs: > For instance, the case distinction based on `if is_timestamp` seems to be removed.

No, a few old lines migrated "as is" to `_index_range` function.

> I would strongly hesitate adding any new arguments in methods of the forecastinghorizon. This needs explanation for a review first...

@danbartl I would tend agree. Is it 100% impossible to achieve the same result without touching ForecastingHorizon interface? Perhaps it is, but it is hard to judge without diving deep into the proposed change.
fkiraly: @danbartl, is this ready for review?
fkiraly: 👀 (reviewing)
danbartl: @fkiraly I added all the doc strings as suggested, and also dropped the requirement to have a truncate_fit attribute in the transformer. 
danbartl: Added FeatureUnion and more doc strings

Patch:
diff --git a/sktime/forecasting/base/_fh.py b/sktime/forecasting/base/_fh.py
index 9431832a9..660ecabd7 100644
--- a/sktime/forecasting/base/_fh.py
+++ b/sktime/forecasting/base/_fh.py
@@ -819,3 +819,21 @@ def _coerce_to_period(x, freq=None):
             )
         else:
             raise
+
+
+def _index_range(relative, cutoff):
+    """Return Index Range relative to cutoff."""
+    _check_cutoff(cutoff, relative)
+    is_timestamp = isinstance(cutoff, pd.Timestamp)
+
+    if is_timestamp:
+        # coerce to pd.Period for reliable arithmetic operations and
+        # computations of time deltas
+        cutoff = _coerce_to_period(cutoff, freq=cutoff.freqstr)
+
+    absolute = cutoff + relative
+
+    if is_timestamp:
+        # coerce back to DatetimeIndex after operation
+        absolute = absolute.to_timestamp(cutoff.freqstr)
+    return absolute
diff --git a/sktime/forecasting/compose/_reduce.py b/sktime/forecasting/compose/_reduce.py
index 0565aeabc..5cf055cb1 100644
--- a/sktime/forecasting/compose/_reduce.py
+++ b/sktime/forecasting/compose/_reduce.py
@@ -31,8 +31,10 @@ from sklearn.base import RegressorMixin, clone
 from sktime.datatypes._utilities import get_time_index
 from sktime.forecasting.base import ForecastingHorizon
 from sktime.forecasting.base._base import DEFAULT_ALPHA
+from sktime.forecasting.base._fh import _index_range
 from sktime.forecasting.base._sktime import _BaseWindowForecaster
 from sktime.regression.base import BaseRegressor
+from sktime.transformations.compose import FeatureUnion
 from sktime.utils.datetime import _shift
 from sktime.utils.validation import check_window_length
 
@@ -101,15 +103,18 @@ def _sliding_window_transform(
     window_length = check_window_length(window_length, n_timepoints)
 
     if transformers is not None:
-        # danbartl: how to implement iteration over all transformers?
-        tf_fit = transformers[0].fit(y)
+        if len(transformers) == 1:
+            tf_fit = transformers[0].fit(y)
+        else:
+            feat = [("trafo_" + str(index), i) for index, i in enumerate(transformers)]
+            tf_fit = FeatureUnion(feat).fit(y)
         X_from_y = tf_fit.transform(y)
 
-        X_from_y_cut = _cut_tail(X_from_y, n_tail=n_timepoints - tf_fit.truncate_start)
-        yt = _cut_tail(y, n_tail=n_timepoints - tf_fit.truncate_start)
+        X_from_y_cut = _cut_tail(X_from_y, n_tail=n_timepoints - window_length)
+        yt = _cut_tail(y, n_tail=n_timepoints - window_length)
 
         if X is not None:
-            X_cut = _cut_tail(X, n_tail=n_timepoints - tf_fit.truncate_start)
+            X_cut = _cut_tail(X, n_tail=n_timepoints - window_length)
             Xt = pd.concat([X_from_y_cut, X_cut], axis=1)
         else:
             Xt = X_from_y_cut
@@ -283,7 +288,21 @@ class _DirectReducer(_Reducer):
     def _predict_last_window(
         self, fh, X=None, return_pred_int=False, alpha=DEFAULT_ALPHA
     ):
-        # Get last window of available data.
+        """Fit to training data.
+
+        Parameters
+        ----------
+        fh : int, list, np.array or ForecastingHorizon
+            Forecasting horizon
+        X : pd.DataFrame, optional (default=None)
+            Exogenous time series
+        return_pred_int : bool
+        alpha : float or array-like
+
+        Returns
+        -------
+        y_pred = pd.Series or pd.DataFrame
+        """
         y_last, X_last = self._get_last_window()
 
         # If we cannot generate a prediction from the available data, return nan.
@@ -372,6 +391,21 @@ class _MultioutputReducer(_Reducer):
     def _predict_last_window(
         self, fh, X=None, return_pred_int=False, alpha=DEFAULT_ALPHA
     ):
+        """Predict to training data.
+
+        Parameters
+        ----------
+        fh : int, list, np.array or ForecastingHorizon
+            Forecasting horizon
+        X : pd.DataFrame, optional (default=None)
+            Exogenous time series
+        return_pred_int : bool
+        alpha : float or array-like
+
+        Returns
+        -------
+        y_pred = pd.Series or pd.DataFrame
+        """
         # Get last window of available data.
         y_last, X_last = self._get_last_window()
 
@@ -451,13 +485,17 @@ class _RecursiveReducer(_Reducer):
             self.transformers = clone(self.transformers)
 
         if self.window_length is None:
-            if isinstance(self.transformers_, list):
-                truncate_start = self.transformers_[0].fit(y).truncate_start
-                self.window_length_ = truncate_start
+            trafo = self.transformers_
+            fit_trafo = [i.fit(y) for i in trafo]
+            ts = [i.truncate_start for i in fit_trafo if hasattr(i, "truncate_start")]
+            if len(ts) > 0:
+                self.window_length_ = max(ts)
             else:
-                truncate_start = self.transformers_.fit(y).truncate_start
-                self.window_length_ = truncate_start
-
+                raise ValueError(
+                    "Reduce must either have window length as argument"
+                    + "or needs to have it passed by transformer via"
+                    + "truncate_start"
+                )
         yt, Xt = self._transform(y, X)
 
         # Make sure yt is 1d array to avoid DataConversion warning from scikit-learn.
@@ -471,30 +509,108 @@ class _RecursiveReducer(_Reducer):
         return self
 
     def _get_shifted_window(self, shift=0, y_update=None, X_update=None):
-        """Select shifted window."""
-        # Get the start and end points of the last window.
+        """Get the start and end points of a shifted window.
+
+        In recursive forecasting, the time based features need to be recalculated for
+        every time step that is forecast. This is done in an iterative fashion over
+        every forecasting horizon step. Shift specifies the timestemp over which the
+        iteration is done, i.e. a shift of 0 will get a window between window_length
+        steps in the past and t=0, shift = 1 will be window_length - 1 steps in the past
+        and t= 1 etc- up to the forecasting horizon.
+
+        Will also apply any transformers passed to the recursive reducer to y. This en
+        bloc approach of directly applying the transformers is more efficient than
+        creating all lags first across the window and then applying the transformers
+        to the lagged data.
+
+        Please see below a graphical representation of the logic using the following
+        symbols:
+
+        ``z`` = first observation to forecast.
+        Not part of the window.
+        ``*`` = (other) time stamps in the window which is summarized
+        ``x`` = observations, past or future, not part of the window
+
+        For`window_length = 7` and `fh = [3]` we get the following windows
+
+        `shift = 0`
+        |--------------------------- |
+        | x x x x * * * * * * * z x x|
+        |----------------------------|
+
+        `shift = 1`
+        |--------------------------- |
+        | x x x x x * * * * * * * z x|
+        |----------------------------|
+
+        `shift = 2`
+        |--------------------------- |
+        | x x x x x x * * * * * * * z|
+        |----------------------------|
+
+        Parameters
+        ----------
+        shift : integer
+            this will be correspond to the shift of the window_length into the future
+        y_update : a pandas Series or Dataframe
+            y values that were obtained in the recursive fashion.
+        X_update : a pandas Series or Dataframe
+            X values also need to be cut based on the into windows, see above.
+
+        Returns
+        -------
+        y, X: A pandas dataframe or series
+            contains the y and X data prepared for the respective windows, see above.
+
+        """
+        # shift and cutoff determine start and end of the window, respectively.
+        start = _shift(self._cutoff, by=shift - self.window_length_)
         cutoff = _shift(self._cutoff, by=shift)
-        start = _shift(self._cutoff, by=shift - self.window_length_ + 1)
 
         if self.transformers_ is not None:
             # Get the last window of the endogenous variable.
             # If X is given, also get the last window of the exogenous variables.
-            if isinstance(cutoff, pd._libs.tslibs.period.Period):
-                dateline = pd.period_range(start=start, end=cutoff)
-            elif isinstance(self.cutoff, np.int64) or isinstance(self.cutoff, int):
-                dateline = list(range(start, cutoff + 1))
-            else:
-                dateline = pd.date_range(start=start, end=cutoff)
-
-            y = _create_multiindex(dateline, self._y)
-            y.update(self._y)
+            # relative _int will give the integer indices of the window defined above
+            relative_int = pd.Index(list(map(int, range(-self.window_length_, 1))))
+            # index_range will give the same indices,
+            # but using the date format of cutoff
+            index_range = _index_range(relative_int, cutoff)
+
+            # y_raw is defined solely for the purpose of deriving a dataframe
+            # window_length forecasting steps into the past in order to calculate the
+            # new X from y features based on the transformer provided
+            y_raw = _create_fcst_df(index_range, self._y)
+
+            # The y_raw dataframe will contain historical and / or recursively
+            # forecast value to calculate the new X features.
+
+            # Historical values are passed here for all time steps of y_raw that lie in
+            # the past .
+            y_raw.update(self._y)
+
+            # Forecast values are passed here for all time steps of y_raw that lie in
+            # the future and were forecast in previous iterations.
             if y_update is not None:
-                y.update(y_update)
-            # Create new X with old values and new features derived from forecasts
-            X_from_y = self.transformers_[0].fit_transform(y)
+                y_raw.update(y_update)
+
+            # After filling the empty y_raw frame with historic / forecast values
+            # X from y features can be calculated based on the passed transformer.
+            if len(self.transformers_) == 1:
+                X_from_y = self.transformers_[0].fit_transform(y_raw)
+            else:
+                ref = self.transformers_
+                feat = [("trafo_" + str(index), i) for index, i in enumerate(ref)]
+                X_from_y = FeatureUnion(feat).fit_transform(y_raw)
+            # We are only interested in the last observations, since only that one
+            # contains relevant value. In recursive forecasting, only one observations
+            # can be forecast at a time.
             X_from_y_cut = _cut_tail(X_from_y)
+
+            # X_from_y_cut is added to X dataframe (unlike y_raw, the X dataframe can
+            # directly be created with one observation from the start,
+            # since no features need to be calculated).
             if self._X is not None:
-                X = _create_multiindex(dateline, self._X)
+                X = _create_fcst_df([index_range[-1]], self._X)
                 X.update(self._X)
                 if X_update is not None:
                     X.update(X_update)
@@ -502,7 +618,7 @@ class _RecursiveReducer(_Reducer):
                 X = pd.concat([X_from_y_cut, X_cut], axis=1)
             else:
                 X = X_from_y_cut
-                y = _cut_tail(y)
+            y = _cut_tail(y_raw)
         else:
             # Get the last window of the endogenous variable.
             y = self._y.loc[start:cutoff].to_numpy()
@@ -513,6 +629,27 @@ class _RecursiveReducer(_Reducer):
     def _predict_last_window(
         self, fh, X=None, return_pred_int=False, alpha=DEFAULT_ALPHA
     ):
+        """.
+
+        In recursive reduction, iteration must be done over the
+        entire forecasting horizon. Specifically, when transformers are
+        applied to y that generate features in X, forecasting must be done step by
+        step to integrate the latest prediction of for the new set of features in
+        X derived from that y.
+
+        Parameters
+        ----------
+        fh : int, list, np.array or ForecastingHorizon
+            Forecasting horizon
+        X : pd.DataFrame, optional (default=None)
+            Exogenous time series
+        return_pred_int : bool
+        alpha : float or array-like
+
+        Returns
+        -------
+        y_return = pd.Series or pd.DataFrame
+        """
         if self._X is not None and X is None:
             raise ValueError(
                 "`X` must be passed to `predict` if `X` is given in `fit`."
@@ -525,7 +662,6 @@ class _RecursiveReducer(_Reducer):
             y_last, X_last = self._get_last_window()
 
         # If we cannot generate a prediction from the available data, return nan.
-        # danbartl: check for window_length fails since transformed data is returned.
         if self.transformers_ is None:
             if not self._is_predictable(y_last):
                 return self._predict_nan(fh)
@@ -536,34 +672,16 @@ class _RecursiveReducer(_Reducer):
 
         if self.transformers_ is not None:
             fh_max = fh.to_relative(self.cutoff)[-1]
+            relative = pd.Index(list(map(int, range(1, fh_max + 1))))
+            index_range = _index_range(relative, self.cutoff)
 
-            if isinstance(self.cutoff, pd._libs.tslibs.period.Period):
-                dateline = pd.period_range(
-                    end=fh.to_absolute(self.cutoff)[-1], periods=fh_max
-                )
-            elif isinstance(self.cutoff, np.int64) or isinstance(self.cutoff, int):
-                dateline = list(
-                    range(self.cutoff + 1, fh.to_absolute(self.cutoff)[-1] + 1)
-                )
-            else:
-                dateline = pd.date_range(
-                    end=fh.to_absolute(self.cutoff)[-1], periods=fh_max
-                )
-            y_pred = _create_multiindex(dateline, self._y)
+            y_pred = _create_fcst_df(index_range, self._y)
 
             for i in range(fh_max):
-                # Slice prediction window.
-                # Collect inputs for predictions
-                # if isinstance(self.cutoff, pd._libs.tslibs.period.Period):
-                #     date_curr = pd.period_range(end=dateline[i], periods=1)
-                # elif isinstance(self.cutoff, np.int64):
-                #     date_curr = list(range(self.cutoff,self.cutoff+fh_max))
-                # else:
-                #     date_curr = pd.date_range(end=dateline[i], periods=1)
                 # Generate predictions.
                 y_pred_vector = self.estimator_.predict(X_last)
-                y_pred_curr = _create_multiindex(
-                    [dateline[i]], self._y, fill=y_pred_vector
+                y_pred_curr = _create_fcst_df(
+                    [index_range[i]], self._y, fill=y_pred_vector
                 )
                 y_pred.update(y_pred_curr)
 
@@ -705,6 +823,21 @@ class _DirRecReducer(_Reducer):
     def _predict_last_window(
         self, fh, X=None, return_pred_int=False, alpha=DEFAULT_ALPHA
     ):
+        """Fit to training data.
+
+        Parameters
+        ----------
+        fh : int, list, np.array or ForecastingHorizon
+            Forecasting horizon
+        X : pd.DataFrame, optional (default=None)
+            Exogenous time series
+        return_pred_int : bool
+        alpha : float or array-like
+
+        Returns
+        -------
+        y_pred = pd.Series or pd.DataFrame
+        """
         # Exogenous variables are not yet support for the dirrec strategy.
         # todo: implement this. For now, we escape.
         if X is not None:
@@ -1021,9 +1154,42 @@ def _get_forecaster(scitype, strategy):
     return registry[scitype][strategy]
 
 
-def _create_multiindex(target_date, origin_df, fill=None):
-    """Create an empty multiindex dataframe from origin dataframe."""
-    # Collect predictions
+def _cut_tail(X, n_tail=1):
+    """Cut input at tail, supports grouping."""
+    if isinstance(X.index, pd.MultiIndex):
+        Xi_grp = X.index.names[0:-1]
+        X = X.groupby(Xi_grp, as_index=False).tail(n_tail)
+    else:
+        X = X.tail(n_tail)
+    return X
+
+
+def _create_fcst_df(target_date, origin_df, fill=None):
+    """Create an empty multiindex dataframe from origin dataframe.
+
+    In recursive forecasting, a new dataframe needs to be created that collects
+    all forecasting steps (even for forecasting horizons other than those of interests).
+    For example for fh =[1,2,12] we need the whole forecasting horizons from 1 to 12.
+
+    Parameters
+    ----------
+    target_date : a list of dates
+        this will be correspond to the new timepoints index to be created in the
+        forecasting dataframe
+    origin_df : a pandas Series or Dataframe
+        the origin_df corresponds to the dataframe with the historic data. Useful
+        information inferred from that dataframe is the index of the historic dataframe
+        as well as the names of the original columns and the type of the object
+        (dataframe or series)
+    fill : a numpy.ndarray (optional)
+        Corresponds to a numpy array of values that is used to fill up the dataframe.
+        Useful when forecasts are returned from a forecasting models that discards
+        the hierarchical structure of the input pandas dataframe
+
+    Returns
+    -------
+    A pandas dataframe or series
+    """
     oi = origin_df.index
     if not isinstance(oi, pd.MultiIndex):
         if isinstance(origin_df, pd.Series):
@@ -1031,6 +1197,7 @@ def _create_multiindex(target_date, origin_df, fill=None):
                 template = pd.Series(np.zeros(len(target_date)), index=target_date)
             else:
                 template = pd.Series(fill, index=target_date)
+            template.name = origin_df.name
             return template
         else:
             if fill is None:
@@ -1044,30 +1211,38 @@ def _create_multiindex(target_date, origin_df, fill=None):
                     fill, index=target_date, columns=origin_df.columns.to_list()
                 )
             return template
-
-    tsids = origin_df.index.get_level_values("instances").unique()
-    mi = pd.MultiIndex.from_product(
-        [tsids, target_date], names=["instances", "timepoints"]
-    )
-
-    if fill is None:
-        template = pd.DataFrame(
-            np.zeros((len(target_date) * len(tsids), len(origin_df.columns))),
-            index=mi,
-            columns=origin_df.columns.to_list(),
-        )
     else:
-        template = pd.DataFrame(fill, index=mi, columns=origin_df.columns.to_list())
-
-    template = template.astype(origin_df.dtypes.to_dict())
-    return template
+        idx = origin_df.index.to_frame(index=False)
+        instance_names = idx.columns[0:-1].to_list()
+        time_names = idx.columns[-1]
+        idx = idx[instance_names].drop_duplicates()
+
+        timeframe = pd.DataFrame(target_date, columns=[time_names])
+
+        target_frame = idx.merge(timeframe, how="cross")
+        freq_inferred = target_date[0].freq
+        mi = (
+            target_frame.groupby(instance_names, as_index=True)
+            .apply(
+                lambda df: df.drop(instance_names, axis=1)
+                .set_index(time_names)
+                .asfreq(freq_inferred)
+            )
+            .index
+        )
 
+        if fill is None:
+            template = pd.DataFrame(
+                np.zeros((len(target_date) * idx.shape[0], len(origin_df.columns))),
+                index=mi,
+                columns=origin_df.columns.to_list(),
+            )
+        else:
+            template = pd.DataFrame(
+                fill,
+                index=mi,
+                columns=origin_df.columns.to_list(),
+            )
 
-def _cut_tail(X, n_tail=1):
-    """Cut input at tail, supports grouping."""
-    if isinstance(X.index, pd.MultiIndex):
-        Xi_grp = X.index.names[0:-1]
-        X = X.groupby(Xi_grp, as_index=False).tail(n_tail)
-    else:
-        X = X.tail(n_tail)
-    return X
+        template = template.astype(origin_df.dtypes.to_dict())
+        return template
diff --git a/sktime/transformations/series/summarize.py b/sktime/transformations/series/summarize.py
index aa50b8c1c..2ef6f6ec6 100644
--- a/sktime/transformations/series/summarize.py
+++ b/sktime/transformations/series/summarize.py
@@ -38,7 +38,7 @@ class WindowSummarizer(BaseTransformer):
         For ease of notation, for the key "lag", only a single integer
         specifying the `lag` argument will be provided.
 
-        Please see blow a graphical representation of the logic using the following
+        Please see below a graphical representation of the logic using the following
         symbols:
 
         ``z`` = time stamp that the window is summarized *to*.
@@ -283,7 +283,7 @@ class WindowSummarizer(BaseTransformer):
         # Convert lags to default list notation with window_length 1
         boost_lag = func_dict.loc[lags, "window"].apply(lambda x: [int(x), 1])
         func_dict.loc[lags, "window"] = boost_lag
-        self.truncate_start = func_dict["window"].apply(lambda x: x[0] + x[1]).max()
+        self.truncate_start = func_dict["window"].apply(lambda x: x[0] + x[1] - 1).max()
         self._func_dict = func_dict
 
     def _transform(self, X, y=None):


Test patch:
diff --git a/sktime/forecasting/compose/tests/test_reduction.py b/sktime/forecasting/compose/tests/test_reduction.py
index c9f4f4bc5..5e6a31add 100644
--- a/sktime/forecasting/compose/tests/test_reduction.py
+++ b/sktime/forecasting/compose/tests/test_reduction.py
@@ -21,7 +21,6 @@ y_multi = get_examples(mtype="pd-multiindex", as_scitype="Panel")[0]
 # y Train will be univariate data set
 y_train, y_test = temporal_train_test_split(y)
 
-
 y_int = y.copy()
 y_int.index = [i for i in range(len(y_int))]
 
@@ -36,6 +35,36 @@ y_group2 = pd.DataFrame(y_train.values, index=mi, columns=["y"])
 
 y_train_grp = pd.concat([y_group1, y_group2])
 
+y_train_hier = get_examples(mtype="pd_multiindex_hier")[0]
+
+X = y_train_hier.reset_index().copy()
+
+X = X[~((X["bar"] == 2) & (X["foo"] == "b"))]
+# X = X.set_index(["foo","bar","timepoints"])
+
+X = X[["foo", "bar"]].drop_duplicates()
+
+time_names = y_train.index.names[-1]
+timeframe = y_train.index.to_frame()
+
+X2 = X.merge(timeframe, how="cross")
+
+freq_inferred = y_train.index.freq
+
+x_names = X.columns
+if not isinstance(x_names, list):
+    x_names = x_names.to_list()
+
+y_train_reset = y_train.reset_index()
+
+X3 = X2.merge(y_train_reset, on="Period")
+
+freq_inferred = y_train.index.freq
+
+y_train_hier_unequal = X3.groupby(x_names, as_index=True).apply(
+    lambda df: df.drop(x_names, axis=1).set_index(time_names).asfreq(freq_inferred)
+)
+
 # Create Train Panel sample data
 mi = pd.MultiIndex.from_product([[0], y_test.index], names=["instances", "timepoints"])
 y_group1 = pd.DataFrame(y_test.values, index=mi, columns=["y"])
@@ -63,9 +92,7 @@ forecaster1 = make_reduction(
 )
 
 # forecaster1.fit(y=y_train_grp, X=y_train_grp)
-
-# # check_estimator(forecaster1, return_exceptions=False)
-# y_pred1 = forecaster1.predict(X=y_test_grp, fh=[1, 2, 12])
+# check_estimator(forecaster1, return_exceptions=False)
 
 forecaster2 = make_reduction(
     regressor,
@@ -75,7 +102,21 @@ forecaster2 = make_reduction(
     strategy="recursive",
 )
 
+y_numeric = y_train.copy()
+y_numeric.index = pd.to_numeric(y_numeric.index)
+
 # forecaster2.fit(y_train, fh=[1, 2])
+# y_pred21 = forecaster2.predict(fh=[1, 2, 12])
+
+# forecaster2.fit(y_numeric, fh=[1, 2])
+# y_pred22 = forecaster2.predict(fh=[1, 2, 12])
+
+# forecaster2.fit(y_train_hier_unequal, fh=[1, 2])
+# y_pred23 = forecaster2.predict(fh=[1, 2, 12])
+
+# Multiindex Series is not supported
+# y_train_series = y_train_grp["y"]
+# forecaster2.fit(y_train_series, fh=[1, 2])
 # y_pred2 = forecaster2.predict(fh=[1, 2, 12])
 
 
@@ -103,6 +144,14 @@ def check_eval(test_input, expected):
             y_train,
             [None],
         ),
+        (
+            y_numeric,
+            [None],
+        ),
+        (
+            y_train_hier_unequal,
+            ["foo", "bar", "Period"],
+        ),
     ],
 )
 def test_reduction(y, index_names):