Repository: sktime/sktime

Issue:
[BUG] Calling predict_residuals() is writing in-sample index to self.fh which should not happen
**Describe the bug**
Calling `forecaster:predict_residuals()` is setting the `forecaster.fh` to the in-sample index. This should not happen.

**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
from sktime.datasets import load_airline
from sktime.forecasting.base import ForecastingHorizon
from sktime.forecasting.model_selection import temporal_train_test_split
from sktime.forecasting.theta import ThetaForecaster
from sktime.performance_metrics.forecasting import mean_absolute_percentage_error
from pandas.testing import assert_series_equal
from sktime.forecasting.naive import NaiveForecaster

y = load_airline()
y_train, y_test = temporal_train_test_split(y)
fh = ForecastingHorizon(y_test.index, is_relative=False)
forecaster = NaiveForecaster(sp=12)
forecaster.fit(y_train, fh=fh)

y_pred_1 = forecaster.predict()
print(forecaster.fh)
y_resid = forecaster.predict_residuals()
print(forecaster.fh)
y_pred_2 = forecaster.predict()
assert_series_equal(y_pred_1, y_pred_2)
```

**Expected behavior**
Above example should work.

**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()
-->

</details>

<!-- Thanks for contributing! -->



Discussion:

Resolving patch:
diff --git a/sktime/forecasting/base/_base.py b/sktime/forecasting/base/_base.py
--- a/sktime/forecasting/base/_base.py
+++ b/sktime/forecasting/base/_base.py
@@ -1041,7 +1041,7 @@ def predict_residuals(self, y=None, X=None):
             self.cutoff, self._is_fitted
 
         Writes to self:
-            Stores y.index to self.fh if has not been passed previously.
+            Nothing.
 
         Parameters
         ----------
@@ -1064,6 +1064,14 @@ def predict_residuals(self, y=None, X=None):
                 Series, Panel, Hierarchical scitype, same format (see above)
         """
         self.check_is_fitted()
+
+        # clone self._fh to avoid any side-effects to self due to calling _check_fh
+        # and predict()
+        if self._fh is not None:
+            fh_orig = deepcopy(self._fh)
+        else:
+            fh_orig = None
+
         # if no y is passed, the so far observed y is used
         if y is None:
             y = self._y
@@ -1096,6 +1104,10 @@ def predict_residuals(self, y=None, X=None):
 
         y_res = y - y_pred
 
+        # write fh back to self that was given before calling predict_residuals to
+        # avoid side-effects
+        self._fh = fh_orig
+
         return y_res
 
     def score(self, y, X=None, fh=None):


Test patch:
diff --git a/sktime/forecasting/base/tests/test_base.py b/sktime/forecasting/base/tests/test_base.py
--- a/sktime/forecasting/base/tests/test_base.py
+++ b/sktime/forecasting/base/tests/test_base.py
@@ -9,6 +9,7 @@
 
 import pandas as pd
 import pytest
+from pandas.testing import assert_series_equal
 
 from sktime.datatypes import check_is_mtype, convert
 from sktime.datatypes._utilities import get_cutoff, get_window
@@ -287,3 +288,22 @@ def test_dynamic_tags_reset_properly():
     # fit should reset the estimator, and set scitype:y tag to "multivariate"
     # the fit will cause an error if this is not happening properly
     f.fit(X_multivariate)
+
+
+def test_predict_residuals():
+    """Test that predict_residuals has no side-effect."""
+    from sktime.forecasting.base import ForecastingHorizon
+    from sktime.forecasting.model_selection import temporal_train_test_split
+    from sktime.forecasting.theta import ThetaForecaster
+
+    y = _make_series(n_columns=1)
+    y_train, y_test = temporal_train_test_split(y)
+    fh = ForecastingHorizon(y_test.index, is_relative=False)
+    forecaster = ThetaForecaster(sp=12)
+    forecaster.fit(y_train, fh=fh)
+
+    y_pred_1 = forecaster.predict()
+    y_resid = forecaster.predict_residuals()
+    y_pred_2 = forecaster.predict()
+    assert_series_equal(y_pred_1, y_pred_2)
+    assert y_resid.index.equals(y_train.index)