Repository: sktime/sktime

Issue:
[BUG] bug in predict when using recursive reduction with global pooling and tz aware data
**Describe the bug**
<!--
A clear and concise description of what the bug is.
-->

When using `make_reduction` with a recursive strategy and global pooling there is a bug in the predict function when using time-zone aware data

**To Reproduce**
<!--
Add a Minimal, Complete, and Verifiable example (for more details, see e.g. https://stackoverflow.com/help/mcve

If the code is too long, feel free to put it in a public gist and link it in the issue: https://gist.github.com
-->

```python
import numpy as np
from sktime.datasets import load_solar
from sktime.transformations.series.summarize import WindowSummarizer
from sktime.forecasting.compose import make_reduction
from sklearn.linear_model import LinearRegression

y = load_solar(
    start="2021-01-01",
    end="2022-04-01",
)
y_notz = y.copy().tz_localize(None)

window_trafo = WindowSummarizer(n_jobs=1, **{"lag_feature": {"lag": [1, 2, 48]}})
regressor = LinearRegression()
forecaster = make_reduction(
    estimator=regressor,
    strategy="recursive",
    transformers=[window_trafo],
    window_length=None,
    pooling="global",
)

# check coefficients
tzaware = forecaster.clone().fit(y).get_fitted_params()["estimator__coef"]
tznaive = forecaster.clone().fit(y_notz).get_fitted_params()["estimator__coef"]
np.all(tzaware == tznaive)

fh = np.arange(1, 97)
pred_tzaware = forecaster.clone().fit_predict(y, fh=fh)
pred_tznaive = forecaster.clone().fit_predict(y_notz, fh=fh)

# These should give us identical predictions but don't
np.all(pred_tzaware.values == pred_tznaive.values)
```

**Expected behavior**
<!--
A clear and concise description of what you expected to happen.
-->

Identical predictions

**Additional context**
<!--
Add any other context about the problem here.
-->

**Versions**
<details>

<!--
Please run the following code snippet and paste the output here:

from sktime import show_versions; show_versions()
-->

System:
    python: 3.10.13
   machine: Windows-10-10.0.19044-SP0

Python dependencies:
          pip: 23.3
       sktime: 0.24.0
      sklearn: 1.3.1
       skbase: 0.6.0
        numpy: 1.26.1
        scipy: 1.11.3
       pandas: 2.1.1
   matplotlib: None
       joblib: 1.3.2
        numba: None
  statsmodels: None
     pmdarima: None
statsforecast: None
      tsfresh: None
      tslearn: None
        torch: None
   tensorflow: None
tensorflow_probability: None

<!-- Thanks for contributing! -->



Discussion:

Resolving patch:
diff --git a/sktime/forecasting/compose/_reduce.py b/sktime/forecasting/compose/_reduce.py
--- a/sktime/forecasting/compose/_reduce.py
+++ b/sktime/forecasting/compose/_reduce.py
@@ -343,6 +343,9 @@ def _get_shifted_window(self, shift=0, y_update=None, X_update=None):
         # first observation after the window (this is what the window is summarized to).
 
         index_range = _index_range(relative_int, cutoff)
+        if isinstance(cutoff, pd.DatetimeIndex):
+            if cutoff.tzinfo is not None:
+                index_range = index_range.tz_localize(cutoff.tzinfo)
         # index_range will convert the indices to the date format of cutoff
 
         y_raw = _create_fcst_df(index_range, self._y)
@@ -869,6 +872,9 @@ def _predict_last_window(
             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.DatetimeIndex):
+                if self.cutoff.tzinfo is not None:
+                    index_range = index_range.tz_localize(self.cutoff.tzinfo)
 
             y_pred = _create_fcst_df(index_range, self._y)
 


Test patch:
diff --git a/sktime/forecasting/compose/tests/test_reduce_global.py b/sktime/forecasting/compose/tests/test_reduce_global.py
--- a/sktime/forecasting/compose/tests/test_reduce_global.py
+++ b/sktime/forecasting/compose/tests/test_reduce_global.py
@@ -21,7 +21,7 @@
 from sklearn.linear_model import LinearRegression
 from sklearn.pipeline import make_pipeline
 
-from sktime.datasets import load_airline
+from sktime.datasets import load_airline, load_solar
 from sktime.datatypes import get_examples
 from sktime.forecasting.base import ForecastingHorizon
 from sktime.forecasting.compose import make_reduction
@@ -358,3 +358,40 @@ def test_nofreq_pass():
     np.testing.assert_almost_equal(
         y_pred_global["c0"].values, y_pred_nofreq["c0"].values
     )
+
+
+def test_timezoneaware_index():
+    y = load_solar(api_version=None)
+    y_notz = y.copy().tz_localize(None)
+
+    assert y.index.tz is not None
+    assert y_notz.index.tz is None
+
+    window_trafo = WindowSummarizer(n_jobs=1, **{"lag_feature": {"lag": [1, 2, 48]}})
+    regressor = LinearRegression()
+    forecaster = make_reduction(
+        estimator=regressor,
+        strategy="recursive",
+        transformers=[window_trafo],
+        window_length=None,
+        pooling="global",
+    )
+
+    # check coefficients
+    tzaware = forecaster.clone().fit(y)
+    tznaive = forecaster.clone().fit(y_notz)
+    tzaware_coef = tzaware.get_fitted_params()["estimator__coef"]
+    tznaive_coef = tznaive.get_fitted_params()["estimator__coef"]
+
+    np.testing.assert_almost_equal(tzaware_coef, tznaive_coef)
+
+    fh = np.arange(1, 97)
+    pred_tzaware = tzaware.predict(fh=fh)
+    pred_tznaive = tznaive.predict(fh=fh)
+
+    msg = "Time-zone of predictions not consistent with training data."
+    assert pred_tzaware.index.tz == y.index.tz, msg
+    assert pred_tznaive.index.tz == y_notz.index.tz, msg
+
+    # These should give us identical predictions
+    np.testing.assert_almost_equal(pred_tzaware.values, pred_tznaive.values)