Repository: JoaquinAmatRodrigo/skforecast

Issue:
Good First Issue: Allow predict method to accept date values as steps
**Summary**

Currently, the `steps` parameter in all Forecasters' `predict` methods only accepts an integer value. This integer defines how many observations to forecast into the future. We would like to extend this functionality so that `steps` can also accept a date (e.g., `'2020-01-01'`). If a date is provided, the function should calculate the appropriate number of observations corresponding to the time window between the last observation in the last window and the given date.


**Task**  
1. Create an auxiliary function, `_preprocess_steps_as_date(last_window: pd.Series, steps)` in the `utils` module:

- `last_window` is the last window of the series used to forecast the future. This is an argument of the `predict` method in all Forecasters.
- `steps` can be an integer or any datetime format that pandas allows to be passed to a `pd.DatetimeIndex` (e.g., string, pandas timestamp...).
- If the Forecaster was not fitted using a `pd.DatetimeIndex`, raise a `TypeError` with the message: "If the Forecaster was not fitted using a pd.DatetimeIndex, `steps` must be an integer."
- If the Forecaster was fitted using a `pd.DatetimeIndex`, this function will return the length of the time window between the last observation in the last window and the given date as an integer value.
- If the input `steps` is an integer, return the same integer.
- Create unit tests using pytest in the `utils.tests` folder.


```python
# Expected behavior
# ==============================================================================
last_window = pd.Series([1, 2, 3, 4, 5], index=pd.date_range('2020-01-01', periods=5, freq='D'))
_preprocess_steps_as_date(last_window, '2020-01-07') # expected output: 2

last_window = pd.Series([1, 2, 3, 4, 5], index=pd.date_range('2020-01-01', periods=5, freq='D'))
_preprocess_steps_as_date(last_window, 2) # expected output: 2

last_window = pd.Series([1, 2, 3, 4, 5], index=pd.RangeIndex(start=0, stop=5, step=1))
_preprocess_steps_as_date(last_window, '2020-01-07') # expected output: TypeError
```

2. Integrate this function in the `predict` method of the `ForecasterAutoreg` class.


**Acceptance Criteria**
- The `steps` parameter accepts both integer and date formats.
- The function correctly calculates the number of steps when a date is provided.
- Existing tests continue to pass.
- New test cases are added to verify the correct behavior for both int and date inputs.


**Full Example**

```python
# Expected behavior
# ==============================================================================
data = fetch_dataset(name="h2o", kwargs_read_csv={"names": ["y", "datetime"], "header": 0})

steps = 36
data_train = data[:-steps]
data_test  = data[-steps:]

forecaster = ForecasterAutoreg(
                 regressor = LGBMRegressor(random_state=123, verbose=-1),
                 lags      = 15 
             )
forecaster.fit(y=data_train['y'])

predictions = forecaster.predict(steps='2005-09-01') # As steps=3
```
2005-07-01    1.020833
2005-08-01    1.021721
2005-09-01    1.093488
Freq: MS, Name: pred, dtype: float64

Discussion:

Patch:
diff --git a/skforecast/ForecasterAutoreg/ForecasterAutoreg.py b/skforecast/ForecasterAutoreg/ForecasterAutoreg.py
index bde93a71..f93b7626 100644
--- a/skforecast/ForecasterAutoreg/ForecasterAutoreg.py
+++ b/skforecast/ForecasterAutoreg/ForecasterAutoreg.py
@@ -1058,14 +1058,14 @@ class ForecasterAutoreg(ForecasterBase):
 
     def _create_predict_inputs(
         self,
-        steps: int,
+        steps: Union[int, str, pd.Timestamp], 
         last_window: Optional[Union[pd.Series, pd.DataFrame]] = None,
         exog: Optional[Union[pd.Series, pd.DataFrame]] = None,
         predict_boot: bool = False,
         use_in_sample_residuals: bool = True,
         use_binned_residuals: bool = False,
-        check_inputs: bool = True
-    ) -> Tuple[np.ndarray, Optional[np.ndarray], pd.Index]:
+        check_inputs: bool = True,
+    ) -> Tuple[np.ndarray, Optional[np.ndarray], pd.Index, int]:
         """
         Create the inputs needed for the first iteration of the prediction 
         process. As this is a recursive process, the last window is updated at 
@@ -1073,8 +1073,9 @@ class ForecasterAutoreg(ForecasterBase):
         
         Parameters
         ----------
-        steps : int
-            Number of future steps predicted.
+        steps : int, str, pandas Timestamp
+            If steps is int, number of steps to predict. 
+            If str or pandas Datetime, the prediction will be up to that date.
         last_window : pandas Series, pandas DataFrame, default `None`
             Series values used to create the predictors (lags) needed in the 
             first iteration of the prediction (t + 1).
@@ -1112,6 +1113,8 @@ class ForecasterAutoreg(ForecasterBase):
             Exogenous variable/s included as predictor/s.
         prediction_index : pandas Index
             Index of the predictions.
+        steps: int
+            Number of future steps predicted.
         
         """
 
@@ -1176,13 +1179,14 @@ class ForecasterAutoreg(ForecasterBase):
         else:
             exog_values = None
 
-        prediction_index = expand_index(
-                               index = last_window_index,
-                               steps = steps
-                           )
+        prediction_index, steps = expand_index(
+                                      index = last_window_index,
+                                      steps = steps,
+                                  )
+
+        return last_window_values, exog_values, prediction_index, steps
+
 
-        return last_window_values, exog_values, prediction_index
-    
     def _recursive_predict(
         self,
         steps: int,
@@ -1311,9 +1315,8 @@ class ForecasterAutoreg(ForecasterBase):
         
         """
 
-        last_window_values, exog_values, prediction_index = self._create_predict_inputs(
+        last_window_values, exog_values, prediction_index, steps = self._create_predict_inputs(
             steps=steps, last_window=last_window, exog=exog
-        )
         
         predictions = self._recursive_predict(
                           steps              = steps,
@@ -1357,7 +1360,7 @@ class ForecasterAutoreg(ForecasterBase):
 
     def predict(
         self,
-        steps: int,
+        steps: Union[int, str, pd.Timestamp],
         last_window: Optional[Union[pd.Series, pd.DataFrame]] = None,
         exog: Optional[Union[pd.Series, pd.DataFrame]] = None,
         check_inputs: bool = True
@@ -1368,8 +1371,9 @@ class ForecasterAutoreg(ForecasterBase):
         
         Parameters
         ----------
-        steps : int
-            Number of future steps predicted.
+        steps : int, str, pandas Timestamp
+            If steps is int, number of steps to predict. 
+            If str or pandas Datetime, the prediction will be up to that date.
         last_window : pandas Series, pandas DataFrame, default `None`
             Series values used to create the predictors (lags) needed in the 
             first iteration of the prediction (t + 1).
@@ -1390,10 +1394,10 @@ class ForecasterAutoreg(ForecasterBase):
         
         """
 
-        last_window_values, exog_values, prediction_index  = self._create_predict_inputs(
+        last_window_values, exog_values, prediction_index, steps = self._create_predict_inputs(
             steps=steps, last_window=last_window, exog=exog, check_inputs=check_inputs
         )
-        
+
         predictions = self._recursive_predict(
                           steps              = steps,
                           last_window_values = last_window_values,
@@ -1421,7 +1425,7 @@ class ForecasterAutoreg(ForecasterBase):
 
     def predict_bootstrapping(
         self,
-        steps: int,
+        steps: Union[int, str, pd.Timestamp],
         last_window: Optional[pd.Series] = None,
         exog: Optional[Union[pd.Series, pd.DataFrame]] = None,
         n_boot: int = 250,
@@ -1437,8 +1441,9 @@ class ForecasterAutoreg(ForecasterBase):
         
         Parameters
         ----------
-        steps : int
-            Number of future steps predicted.
+        steps : int, str, pandas Timestamp
+            If steps is int, number of steps to predict. 
+            If str or pandas Datetime, the prediction will be up to that date.
         last_window : pandas Series, default `None`
             Series values used to create the predictors (lags) needed in the 
             first iteration of the prediction (t + 1).
@@ -1483,7 +1488,8 @@ class ForecasterAutoreg(ForecasterBase):
         (
             last_window_values,
             exog_values,
-            prediction_index
+            prediction_index,
+            steps
         ) = self._create_predict_inputs(
             steps                   = steps, 
             last_window             = last_window, 
@@ -1563,7 +1569,7 @@ class ForecasterAutoreg(ForecasterBase):
 
     def predict_interval(
         self,
-        steps: int,
+        steps: Union[int, str, pd.Timestamp],
         last_window: Optional[pd.Series] = None,
         exog: Optional[Union[pd.Series, pd.DataFrame]] = None,
         interval: list = [5, 95],
@@ -1579,8 +1585,9 @@ class ForecasterAutoreg(ForecasterBase):
         
         Parameters
         ----------
-        steps : int
-            Number of future steps predicted.
+        steps : int, str, pandas Timestamp
+            If steps is int, number of steps to predict. 
+            If str or pandas Datetime, the prediction will be up to that date.
         last_window : pandas Series, default `None`
             Series values used to create the predictors (lags) needed in the 
             first iteration of the prediction (t + 1).
@@ -1659,7 +1666,7 @@ class ForecasterAutoreg(ForecasterBase):
 
     def predict_quantiles(
         self,
-        steps: int,
+        steps: Union[int, str, pd.Timestamp],
         last_window: Optional[pd.Series] = None,
         exog: Optional[Union[pd.Series, pd.DataFrame]] = None,
         quantiles: list = [0.05, 0.5, 0.95],
@@ -1675,8 +1682,9 @@ class ForecasterAutoreg(ForecasterBase):
         
         Parameters
         ----------
-        steps : int
-            Number of future steps predicted.
+        steps : int, str, pandas Timestamp
+            If steps is int, number of steps to predict. 
+            If str or pandas Datetime, the prediction will be up to that date.
         last_window : pandas Series, default `None`
             Series values used to create the predictors (lags) needed in the 
             first iteration of the prediction (t + 1).
@@ -1742,7 +1750,7 @@ class ForecasterAutoreg(ForecasterBase):
 
     def predict_dist(
         self,
-        steps: int,
+        steps: Union[int, str, pd.Timestamp],
         distribution: object,
         last_window: Optional[pd.Series] = None,
         exog: Optional[Union[pd.Series, pd.DataFrame]] = None,
@@ -1758,8 +1766,9 @@ class ForecasterAutoreg(ForecasterBase):
         
         Parameters
         ----------
-        steps : int
-            Number of future steps predicted.
+        steps : int, str, pandas Timestamp
+            If steps is int, number of steps to predict. 
+            If str or pandas Datetime, the prediction will be up to that date.
         distribution : Object
             A distribution object from scipy.stats.
         last_window : pandas Series, default `None`
diff --git a/skforecast/utils/utils.py b/skforecast/utils/utils.py
index 204475fe..4a99cb51 100644
--- a/skforecast/utils/utils.py
+++ b/skforecast/utils/utils.py
@@ -1472,8 +1472,9 @@ def exog_to_direct_numpy(
 
 def expand_index(
     index: Union[pd.Index, None], 
-    steps: int
-) -> pd.Index:
+    steps: Union[int, str, pd.Timestamp],
+    **kwargs
+) -> Tuple[pd.Index, int]:
     """
     Create a new index of length `steps` starting at the end of the index.
     
@@ -1481,8 +1482,11 @@ def expand_index(
     ----------
     index : pandas Index, None
         Original index.
-    steps : int
-        Number of steps to expand.
+    steps : int, str, pandas Timestamp]
+        If steps is int, number of steps to expand. If str or pandas Datetime, 
+        expand index to this date (str must be convertible to pandas Datetime format).
+    **kwargs
+        Additional keyword arguments to pass to `pd.to_datetime()`.
 
     Returns
     -------
@@ -1490,7 +1494,24 @@ def expand_index(
         New index.
 
     """
-    
+
+    if isinstance(steps, (str, pd.Timestamp)):
+        # Changes `steps` to integer based on datetime expansion
+        if not isinstance(index, pd.DatetimeIndex):
+            raise ValueError( "Argument `index` must be a pandas DatetimeIndex when `steps` is a datetime.")
+        
+        else:
+            target_date = pd.to_datetime(steps, **kwargs)
+            last_date = pd.to_datetime(index[-1])
+            if target_date <= last_date:
+                raise ValueError("The provided date is earlier than or equal to the last observation date.")
+        
+            steps_diff = pd.date_range(start=last_date, end=target_date, freq=index.freq)
+            steps = len(steps_diff) - 1
+
+    elif not isinstance(steps, int):
+        raise TypeError("Argument `steps` must be an integer, string or pandas Timestamp.")
+
     if isinstance(index, pd.Index):
         
         if isinstance(index, pd.DatetimeIndex):
@@ -1514,7 +1535,7 @@ def expand_index(
                         stop  = steps
                     )
     
-    return new_index
+    return new_index, steps
 
 
 def transform_numpy(


Test patch:
diff --git a/skforecast/utils/tests/test_expand_index.py b/skforecast/utils/tests/test_expand_index.py
index 788b84a9..40c36a23 100644
--- a/skforecast/utils/tests/test_expand_index.py
+++ b/skforecast/utils/tests/test_expand_index.py
@@ -5,6 +5,68 @@ import pandas as pd
 from skforecast.utils import expand_index
 
 
+def test_ValueError_expand_index_when_date_is_before_last_index():
+    """
+    Test ValueError is raised when the provided date is earlier than or equal to the last date in index.
+    """
+    index = pd.DatetimeIndex(['1990-01-01', '1990-01-02', '1990-01-03'], 
+                             dtype='datetime64[ns]', freq='D')
+    err_msg = "The provided date is earlier than or equal to the last observation date."
+    
+    with pytest.raises(ValueError, match=err_msg):
+        expand_index(index, steps='1990-01-02')
+
+
+def test_ValueError_expand_index_when_index_is_not_DatetimeIndex():
+    """
+    Test ValueError is raised when `steps` is a date but the index is not a DatetimeIndex.
+    """
+    index = pd.RangeIndex(start=0, stop=3, step=1)
+    err_msg = "Argument `index` must be a pandas DatetimeIndex when `steps` is a datetime."
+    
+    with pytest.raises(ValueError, match=err_msg):
+        expand_index(index, steps='1990-01-10')
+
+
+def test_output_expand_index_when_steps_is_string_date():
+    """
+    Test values returned by expand_index when `steps` is a string date.
+    """
+    index = pd.DatetimeIndex(['1990-01-01', '1990-01-02', '1990-01-03'],
+                             dtype='datetime64[ns]', freq='D')
+    expected = pd.DatetimeIndex(['1990-01-04', '1990-01-05', '1990-01-06', '1990-01-07'],
+                             dtype='datetime64[ns]', freq='D')
+    results, _ = expand_index(index, steps='1990-01-07')
+    
+    assert (results == expected).all()
+
+
+def test_output_expand_index_when_steps_is_Timestamp():
+    """
+    Test values returned by expand_index when `steps` is a pd.Timestamp.
+    """
+    index = pd.DatetimeIndex(['1990-01-01', '1990-01-02', '1990-01-03'],
+                             dtype='datetime64[ns]', freq='D')
+    expected = pd.DatetimeIndex(['1990-01-04', '1990-01-05', '1990-01-06', '1990-01-07'],
+                             dtype='datetime64[ns]', freq='D')
+    results, _ = expand_index(index, steps=pd.Timestamp('1990-01-07'))
+    
+    assert (results == expected).all()
+
+
+def test_output_expand_index_when_steps_is_string_date_with_kwargs():
+    """
+    Test values returned by expand_index when `steps` is a string date and `kwargs` are passed.
+    """
+    index = pd.DatetimeIndex(['1990-01-01', '1990-01-02', '1990-01-03'],
+                             dtype='datetime64[ns]', freq='D')
+    expected = pd.DatetimeIndex(['1990-01-04', '1990-01-05', '1990-01-06', '1990-01-07'],
+                             dtype='datetime64[ns]', freq='D')
+    results, _ = expand_index(index, steps='1990-07-01', format='%Y-%d-%m')
+    
+    assert (results == expected).all()
+
+
 def test_TypeError_expand_index_when_index_is_no_pandas_DatetimeIndex_or_RangeIndex():
     """
     Test TypeError is raised when input is not a pandas DatetimeIndex or RangeIndex.
@@ -24,7 +86,7 @@ def test_output_expand_index_when_index_is_DatetimeIndex():
                              dtype='datetime64[ns]', freq='D')
     expected = pd.DatetimeIndex(['1990-01-04', '1990-01-05', '1990-01-06'],
                              dtype='datetime64[ns]', freq='D')
-    results = expand_index(index, steps=3)
+    results, _ = expand_index(index, steps=3)
     
     assert (results == expected).all()
 
@@ -35,7 +97,7 @@ def test_output_expand_index_when_index_is_RangeIndex():
     """
     index = pd.RangeIndex(start=0, stop=3, step=1)
     expected = pd.RangeIndex(start=3, stop=6, step=1)
-    results = expand_index(index, steps=3)
+    results, _  = expand_index(index, steps=3)
     
     assert (results == expected).all()
 
@@ -46,6 +108,6 @@ def test_output_expand_index_when_index_is_not_pandas_index():
     """
     index = ['0', '1', '2']
     expected = pd.RangeIndex(start=0, stop=3, step=1)
-    results = expand_index(index, steps=3)
+    results, _ = expand_index(index, steps=3)
     
-    assert (results == expected).all()
\ No newline at end of file
+    assert (results == expected).all()