Repository: sktime/sktime

Issue:
[BUG] `ForecastingHorizon.to_absolute` conversion for `W-XXX` frequencies
`ForecastingHorizon.to_absolute` can produce the wrong day of the week in `to_absolute` conversions.

This seems to occur only for the `W-XXX` frequency, leading to the error in #1405.

Minimally reproducing code:

```python
train = pd.Series(1, index=pd.date_range("2021-10-06", freq="W-WED", periods=3))
fh = ForecastingHorizon([1, 2, 3])
fh.to_absolute(train.index[-1])
```

results in `ForecastingHorizon(['2021-10-31', '2021-11-07', '2021-11-14'], dtype='datetime64[ns]', freq='W-SUN', is_relative=False)`.

Expected result is `ForecastingHorizon(['2021-10-27', '2021-11-03', '2021-11-10'], dtype='datetime64[ns]', freq='W-WED', is_relative=False)`.

Further unintuitive behaviour happens if the `fh` has length 2 or 1, in that case the resulting frequency is `None` but should be `'W-WED'` too... I think?

We should:

- [x] fix this
- [x] introduce tests for this specific frequency


Discussion:
FYI @asksteph, @aiwalter, @mloning, @xiaobenbenecho.
I will have a look at fixing this
I have found why the bug occurs. Within `to_absolute` the internal function `_get_freq` is used to obtain the frequency which is then given to the conversion. The `_get_freq` function is:

```python
def _get_freq(x):
    """Get unit for conversion of time deltas to integer."""
    if hasattr(x, "freqstr"):
        if x.freqstr is None:
            return None
        elif "-" in x.freqstr:
           return x.freqstr.split("-")[0]
        else:
            return x.freqstr
    else:
        return None
```

So in the case of this bug we have `freqstr = "W-WED"` but the `_get_freq` function only returns "W" which then defaults back to "W-SUN" hence the error. This could be easily fixed by removing the case distinction for "-" in the `freqstr` however I imagine it is there for a reason, @fkiraly @mloning do you know why this case distinction is needed? 
I don't quite know, on superficial glance it seems problematic to do this.
All the function does is "if it is an anchored frequency, cut off the bit after the dash, otherwise do nothing"?

I suspect it would cause a problem in general with the so-called "anchored frequencies" here:
https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#offset-aliases

Does anyone know why we are doing this? @mloning or @aiwalter perhaps?

Resolving patch:
diff --git a/sktime/forecasting/base/_fh.py b/sktime/forecasting/base/_fh.py
--- a/sktime/forecasting/base/_fh.py
+++ b/sktime/forecasting/base/_fh.py
@@ -3,7 +3,7 @@
 # copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
 """Implements functionality for specifying forecast horizons in sktime."""
 
-__author__ = ["mloning", "fkiraly"]
+__author__ = ["mloning", "fkiraly", "eenticott-shell"]
 __all__ = ["ForecastingHorizon"]
 
 from functools import lru_cache
@@ -305,13 +305,13 @@ def to_absolute(self, cutoff):
             if is_timestamp:
                 # coerce to pd.Period for reliable arithmetic operations and
                 # computations of time deltas
-                cutoff = _coerce_to_period(cutoff)
+                cutoff = _coerce_to_period(cutoff, freq=cutoff.freqstr)
 
             absolute = cutoff + relative
 
             if is_timestamp:
                 # coerce back to DatetimeIndex after operation
-                freq = _get_freq(cutoff)
+                freq = cutoff.freqstr
                 absolute = absolute.to_timestamp(freq)
 
             return self._new(absolute, is_relative=False)


Test patch:
diff --git a/sktime/forecasting/base/tests/test_fh.py b/sktime/forecasting/base/tests/test_fh.py
--- a/sktime/forecasting/base/tests/test_fh.py
+++ b/sktime/forecasting/base/tests/test_fh.py
@@ -7,10 +7,12 @@
 import numpy as np
 import pandas as pd
 import pytest
+from numpy.testing._private.utils import assert_array_equal
 from pytest import raises
 
 from sktime.forecasting.base import ForecastingHorizon
 from sktime.forecasting.base._fh import DELEGATED_METHODS
+from sktime.forecasting.ets import AutoETS
 from sktime.forecasting.model_selection import temporal_train_test_split
 from sktime.forecasting.tests._config import (
     INDEX_TYPE_LOOKUP,
@@ -214,3 +216,51 @@ def test_get_duration(n_timepoints, index_type):
     duration = _get_duration(index, coerce_to_int=True)
     assert isinstance(duration, (int, np.integer))
     assert duration == n_timepoints - 1
+
+
+@pytest.mark.parametrize("freqstr", ["W-WED", "W-SUN", "W-SAT"])
+def test_to_absolute_freq(freqstr):
+    """Test conversion when anchorings included in frequency."""
+    train = pd.Series(1, index=pd.date_range("2021-10-06", freq=freqstr, periods=3))
+    fh = ForecastingHorizon([1, 2, 3])
+    abs_fh = fh.to_absolute(train.index[-1])
+    assert abs_fh._values.freqstr == freqstr
+
+
+@pytest.mark.parametrize("freqstr", ["W-WED", "W-SUN", "W-SAT"])
+def test_absolute_to_absolute(freqstr):
+    """Test converting between absolute and relative."""
+    # Converts from absolute to relative and back to absolute
+    train = pd.Series(1, index=pd.date_range("2021-10-06", freq=freqstr, periods=3))
+    fh = ForecastingHorizon([1, 2, 3])
+    abs_fh = fh.to_absolute(train.index[-1])
+
+    converted_abs_fh = abs_fh.to_relative(train.index[-1]).to_absolute(train.index[-1])
+    assert_array_equal(abs_fh, converted_abs_fh)
+    assert converted_abs_fh._values.freqstr == freqstr
+
+
+@pytest.mark.parametrize("freqstr", ["W-WED", "W-SUN", "W-SAT"])
+def test_relative_to_relative(freqstr):
+    """Test converting between relative and absolute."""
+    # Converts from relative to absolute and back to relative
+    train = pd.Series(1, index=pd.date_range("2021-10-06", freq=freqstr, periods=3))
+    fh = ForecastingHorizon([1, 2, 3])
+    abs_fh = fh.to_absolute(train.index[-1])
+
+    converted_rel_fh = abs_fh.to_relative(train.index[-1])
+    assert_array_equal(fh, converted_rel_fh)
+
+
+@pytest.mark.parametrize("freqstr", ["W-WED", "W-SUN", "W-SAT"])
+def test_estimator_fh(freqstr):
+    """Test model fitting with anchored frequency."""
+    train = pd.Series(
+        np.random.uniform(low=2000, high=7000, size=(104,)),
+        index=pd.date_range("2019-01-02", freq=freqstr, periods=104),
+    )
+    forecaster = AutoETS(auto=True, sp=52, n_jobs=-1, restrict=True)
+    forecaster.fit(train)
+    pred = forecaster.predict(np.arange(1, 27))
+    expected_fh = ForecastingHorizon(np.arange(1, 27)).to_absolute(train.index[-1])
+    assert_array_equal(pred.index.to_numpy(), expected_fh.to_numpy())