Repository: sktime/sktime
Pull request #8110 (2025-04-02 09:53:51)

[BUG] fix timestamp format change in `fh.to_absolute`
Replay of https://github.com/sktime/sktime/pull/7914

#7914 was reverted to diagnose a bug in `NaiveForecaster`, details to see: https://github.com/sktime/sktime/pull/8091.


Patch:
diff --git a/sktime/forecasting/base/_fh.py b/sktime/forecasting/base/_fh.py
index b1c0e5d2b..2368b18bd 100644
--- a/sktime/forecasting/base/_fh.py
+++ b/sktime/forecasting/base/_fh.py
@@ -10,6 +10,7 @@ from typing import Optional
 
 import numpy as np
 import pandas as pd
+from pandas import Timedelta
 from pandas.tseries.frequencies import to_offset
 
 from sktime.utils.datetime import _coerce_duration_to_int
@@ -846,7 +847,6 @@ def _to_absolute(fh: ForecastingHorizon, cutoff) -> ForecastingHorizon:
         cutoff = cutoff.index
         relative = fh.to_pandas()
         _check_cutoff(cutoff, relative)
-        is_timestamp = isinstance(cutoff, pd.DatetimeIndex)
 
         # remember timezone to restore it later
         if hasattr(cutoff, "tz"):
@@ -854,38 +854,41 @@ def _to_absolute(fh: ForecastingHorizon, cutoff) -> ForecastingHorizon:
         else:
             old_tz = None
 
-        if is_timestamp:
-            # coerce to pd.Period for reliable arithmetic operations and
-            # computations of time deltas
-            cutoff = _coerce_to_period(cutoff, freq=fh._freq)
-        if isinstance(cutoff, pd.Index):
-            cutoff = cutoff[[0] * len(relative)]
+        def _to_offset(r):
+            if isinstance(r, Timedelta):
+                return r
+            else:
+                return r * to_offset(fh.freq)
 
-        # pandas bugfix
-        pandas_version_with_bugfix = _is_pandas_arithmetic_bug_fixed()
-        if not pandas_version_with_bugfix and isinstance(cutoff, pd.PeriodIndex):
-            absolute = pd.PeriodIndex(cutoff.to_list() + relative, freq=fh._freq)
-        elif not pandas_version_with_bugfix and isinstance(cutoff, pd.DatetimeIndex):
-            absolute = pd.DatetimeIndex(cutoff.to_list() + relative, freq=fh._freq)
-        else:
-            absolute = cutoff + relative
+        is_timestamp = isinstance(cutoff, pd.DatetimeIndex)
+        is_timelike = isinstance(cutoff, (pd.PeriodIndex, pd.DatetimeIndex))
 
         if is_timestamp:
             # coerce back to DatetimeIndex after operation
-            try:
-                absolute = absolute.to_timestamp(fh._freq)
+            # preserve the format of DatetimeIndex, see #5186
+            absolute = [cutoff[0] + _to_offset(r) for r in relative]
             # this try-except block is a workaround for what seems like a bug in pandas
             # when trying to convert a PeriodIndex to a DatetimeIndex with a frequency
             # of type month-begin, which should be supported, a ValueError is raised
             # see issue #6752 for details
-            except ValueError as e:
-                if "not supported" in str(e):
-                    absolute = absolute.to_timestamp()
+            try:
+                absolute = pd.DatetimeIndex(absolute, freq=fh.freq)
+            except ValueError as e:  # freq can not be set if missing values exist
+                if "not conform" in str(e):
+                    absolute = pd.DatetimeIndex(absolute)
                 else:
                     raise e
+        else:
+            if isinstance(cutoff, pd.Index):
+                cutoff = cutoff[[0] * len(relative)]
+            # pandas bugfix patch
+            if not _is_pandas_arithmetic_bug_fixed() and is_timelike:
+                absolute = type(cutoff)(cutoff.to_list() + relative, freq=fh._freq)
+            else:
+                absolute = cutoff + relative
 
         if old_tz is not None:
-            absolute = absolute.tz_localize(old_tz)
+            absolute = absolute.tz_convert(old_tz)
 
         return fh._new(absolute, is_relative=False, freq=fh.freq)
 


Test patch:
diff --git a/sktime/forecasting/base/tests/test_fh.py b/sktime/forecasting/base/tests/test_fh.py
index 3d9b75711..f11d46e31 100644
--- a/sktime/forecasting/base/tests/test_fh.py
+++ b/sktime/forecasting/base/tests/test_fh.py
@@ -1066,3 +1066,10 @@ def test_pandas22_freq_roundtrip(ts):
     fh = ForecastingHorizon([0], is_relative=True)
     fh.to_absolute(f.cutoff)
     fh.to_absolute(f.cutoff).to_relative(f.cutoff)
+
+
+def test_timestamp_format_to_absolute():
+    cutoff = pd.Timestamp("2025-03-02 12:00:00")
+    fh = ForecastingHorizon([1, 2, 3], freq="D")
+    y_pred_idx = fh.to_absolute_index(cutoff)
+    assert "12:00:00" in str(y_pred_idx)