Repository: sktime/sktime

Issue:
[BUG] Can't create usable ForecastHorizon with timedeltas
**Describe the bug**
I'm unsuccessfully trying to use sktime's ForecastingGridSearchCV with timedeltas.  

I am using timedeltas---not timestamps---because I have disconnected data which only includes 8:00am - 12:00am daily.  I wrote a class that clips out all other times so that the data is datatime.timedelta where 0:00-4:00 is for day 1, 4:00-8:00 is for day 2, 8:00-12:00 is for day 3, etc. 

Here is a simple test case that shows the problem.  I expect both versions ===1=== and ===2=== should work.  I don't care if only one works; I 'm just trying to find a version that works.  (Note: there may well be bugs in the code after these statements).

**To Reproduce**
Run the code below.  There are 2 versions.  Run the code with just ===1=== uncommented, OR just ===2=== uncomment.  I expect both versions should work. 

```python
	from datetime import timedelta

	import numpy as np
	import pandas as pd
	from sktime.forecasting.base import ForecastingHorizon
	from sktime.forecasting.compose import MultiplexForecaster
	from sktime.forecasting.compose import TransformedTargetForecaster
	from sktime.forecasting.model_selection import ForecastingGridSearchCV, SlidingWindowSplitter
	from sktime.forecasting.naive import NaiveForecaster
	from sktime.performance_metrics.forecasting import mean_absolute_percentage_error

	agg_window = 300        # 5 minutes
	horizon_window = 3

	pdf = pd.DataFrame(
		index=[timedelta(seconds=s * agg_window) for s in range(24)],
		data={"y": np.random.random(24), "X": np.random.random(24)}
	)

	print(pdf.dtypes)
	print(pdf)

	test_len = 6
	X_train = pdf["X"][0:-test_len]
	y_train = pdf["y"][0:-test_len]
	X_test = pdf["X"][-test_len:]
	y_test = pdf["y"][-test_len:]

	print(X_train)
	print(y_train)

	print([np.timedelta64(s * agg_window, 's') for s in range(1, horizon_window + 1)])
	print("isinstance", isinstance(np.timedelta64(300, 's'), (int, np.integer)))

	# Only use NaiveForecaster to keep test case as simple as possible
	param_grid = [
		{
			'forecast__selected_forecaster': ["naive"],
		}
	]

	forecaster = TransformedTargetForecaster([
		# ("deseasonalize", Deseasonalizer(model="additive", sp=sp)),
		# ("detrend", Detrender(forecaster=PolynomialTrendForecaster(degree=1))),
		("forecast", MultiplexForecaster(forecasters=[
			("naive", NaiveForecaster(strategy="last")),
			# Other Forecasts removed for simplicity
		]))
	])
	fh = ForecastingHorizon(
		[datetime.timedelta(seconds=s * agg_window) for s in range(1, horizon_window + 1)], # ===1===
		# [np.timedelta64(s * agg_window, 's') for s in range(1, horizon_window + 1)],      # ===2===
		is_relative=True
	)
	splitter_window_length = timedelta(seconds=horizon_window * agg_window)                 # ===1===
	# splitter_window_length = np.timedelta64(horizon_window * agg_window, "s")             # ===2===

	# Print out types for ===1===
	print(type(y_train.index.dtype), type(X_train.index.dtype), type(fh), type(splitter_window_length))
	print([type(x) for x in fh])

	cv = SlidingWindowSplitter(fh=fh, window_length=splitter_window_length)
	gscv = ForecastingGridSearchCV(
		forecaster, strategy="refit", cv=cv, param_grid=param_grid
	)

	gscv.fit(y_train, X=X_train)
	y_pred = gscv.predict(fh, X=X_test)
	# plot_series(y_train, y_test, y_pred, labels=["y_train", "y_test", "y_pred"])
	mean_absolute_percentage_error(y_test, y_pred, symmetric=False)
```

**Expected behavior**
I expect both versions ===1=== and ===2=== should work. 

**Additional context**
Approach ===1===: 

This version attempts to use datetime.timedelta types. When I use just the ===1=== lines, I get the following error:

	on SlidingWindowSplitter statement 
	
	File ".../python3.7/site-packages/sktime/forecasting/model_selection/_split.py", line 272
	TypeError: Unsupported combination of types
	
The printout of the types of the various fields shows the elements of `fh` get converted to pandas Timedelta.  Overall, I would think this combination should be allowed, but the "bad combination" message is not completely unreasonable since the types are different.  But, I don't want to covert everything to Pandas Timedeltas (this code is part of a PySpark application).  So this is a dead end. 

	
Approach ===2===: 

This version attempts to use np.timedelta64 types.  When I use just the ===2=== lines, I get the following error;

	on ForecastingHorizon statement
	
	File ".../python3.7/site-packages/pandas/core/dtypes/cast.py", line 2042
	TypeError: int() argument must be a string, a bytes-like object or a number, not 'datetime.timedelta'

The problem occurs in method maybe_cast_to_integer_array in pandas/core/dtypes/cast.py.  

	casted = arr.astype(dtype, copy=copy)

This statement raises an exception where: 
- arr is the ndarray [numpy.timedelta64(300,'s') numpy.timedelta64(600,'s'), numpy.timedelta64(900,'s')]
- dtype=int32 

It is method method _check_values where the first elif branch list below is taken, and dtype is fixed to int.  But it should fall thru to the second listed elif branch.  

	def _check_values(...): 

		... 
	
		# convert np.array or list to pandas index
		elif is_array(values) and array_is_int(values):
			values = pd.Index(values, dtype=int)

		elif is_array(values) and array_is_timedelta_or_date_offset(values):
			values = pd.Index(values)

The ultimate problem is array_is_int in the first elif branch which ultimately calls array_is_int.  

	def is_int(x) -> bool:
		"""Check if x is of integer type, but not boolean."""
		# boolean are subclasses of integers in Python, so explicitly exclude them
		return isinstance(x, (int, np.integer)) and not isinstance(x, bool)
	
A simple test shows 

	isinstance(np.timedelta64(300, 's'), (int, np.integer)) is TRUE 

This method returns True when it should return False.  So either 
1 - Method is_int needs to include a separate check for not np.timedelta64 in addition to bool, or  
2 - Switch the two elif clauses above would probably work. 


**Versions**
<details>

System:
    python: 3.7.13 (default, Mar 28 2022, 07:24:34)  [Clang 12.0.0 ]
executable: /Users/gerard/biz/client/Detelytics/2022/repo/LivliMobility/env/bin/python3.7
   machine: Darwin-21.6.0-x86_64-i386-64bit

Python dependencies:
          pip: 22.2.2
   setuptools: 63.4.1
      sklearn: 1.0.2
       sktime: 0.12.1
  statsmodels: 0.13.2
        numpy: 1.21.5
        scipy: 1.7.3
       pandas: 1.3.5
   matplotlib: 3.5.2
       joblib: 1.1.0
        numba: 0.55.1
     pmdarima: 1.8.5
      tsfresh: None

</details>

<!-- Thanks for contributing! -->



Discussion:
@gerarddet Thanks a lot for reporting the issue and providing so many helpful details.

Regarding approach 1. I fixed it by simply converting the index of `pdf` to absolute time index:
```
pdf.index = pdf.index + datetime.datetime(2000, 1, 1)
```
Would this be an option that you may consider? I doubt that sktime supports relative data indices.

Regarding approach 2. Indeed, you are right `np.timedelta64` is very counterintuitively a subclass of `np.integer`. Here is the corresponding issue in NumPy: https://github.com/numpy/numpy/issues/10685. Unfortunately, it seems to be stalled for 2 years. Just to move you forward with your own project, could you please tell me why converting to `pd.Timedelta` is a dead end for you? Why is PySpark to blame? In your script pandas is already imported, so why not add one conversion line? I am genuinely interested in your answer as I had some minimal experience with PySpark in my previous job.

Resolving patch:
diff --git a/sktime/utils/validation/__init__.py b/sktime/utils/validation/__init__.py
--- a/sktime/utils/validation/__init__.py
+++ b/sktime/utils/validation/__init__.py
@@ -40,7 +40,11 @@ def is_array(x) -> bool:
 def is_int(x) -> bool:
     """Check if x is of integer type, but not boolean."""
     # boolean are subclasses of integers in Python, so explicitly exclude them
-    return isinstance(x, (int, np.integer)) and not isinstance(x, bool)
+    return (
+        isinstance(x, (int, np.integer))
+        and not isinstance(x, bool)
+        and not isinstance(x, np.timedelta64)
+    )
 
 
 def is_float(x) -> bool:


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
@@ -4,6 +4,8 @@
 
 __author__ = ["mloning", "khrapovs"]
 
+from datetime import timedelta
+
 import numpy as np
 import pandas as pd
 import pytest
@@ -202,7 +204,9 @@ def test_check_fh_absolute_values_input_conversion_to_pandas_index(arg):
 
 
 GOOD_RELATIVE_INPUT_ARGS = [
-    pd.timedelta_range(pd.to_timedelta(1, unit="D"), periods=3, freq="D")
+    pd.timedelta_range(pd.to_timedelta(1, unit="D"), periods=3, freq="D"),
+    [np.timedelta64(x, "D") for x in range(3)],
+    [timedelta(days=x) for x in range(3)],
 ]