Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] NeuralForecastRNN should auto-detect freq #6039

Merged
merged 8 commits into from Mar 20, 2024
1 change: 1 addition & 0 deletions .all-contributorsrc
Expand Up @@ -2613,6 +2613,7 @@
"avatar_url": "https://avatars.githubusercontent.com/u/90601662?s=96&v=4",
"profile": "https://github.com/geetu040",
"contributions": [
"code",
"doc",
"maintenance"
]
Expand Down
30 changes: 25 additions & 5 deletions sktime/forecasting/base/adapters/_neuralforecast.py
Expand Up @@ -17,8 +17,10 @@ class _NeuralForecastAdapter(BaseForecaster):

Parameters
----------
freq : str
freq : str (default="auto")
frequency of the data, see available frequencies [1]_ from ``pandas``

default ("auto") interprets freq from ForecastingHorizon in ``fit``
local_scaler_type : str (default=None)
scaler to apply per-series to all features before fitting, which is inverted
after predicting
Expand Down Expand Up @@ -66,7 +68,7 @@ class _NeuralForecastAdapter(BaseForecaster):

def __init__(
self: "_NeuralForecastAdapter",
freq: str,
freq: str = "auto",
local_scaler_type: typing.Optional[
typing.Literal["standard", "robust", "robust-iqr", "minmax", "boxcox"]
] = None,
Expand All @@ -84,6 +86,9 @@ def __init__(

super().__init__()

# initiate internal variables to avoid AttributeError in future
self._freq = None

self.id_col = "unique_id"
self.time_col = "ds"
self.target_col = "y"
Expand Down Expand Up @@ -143,7 +148,7 @@ def _instantiate_model(self: "_NeuralForecastAdapter", fh: ForecastingHorizon):
from neuralforecast import NeuralForecast

model = NeuralForecast(
[algorithm_instance], self.freq, local_scaler_type=self.local_scaler_type
[algorithm_instance], self._freq, local_scaler_type=self.local_scaler_type
)

return model
Expand Down Expand Up @@ -175,13 +180,28 @@ def _fit(
-------
self : _NeuralForecastAdapter
reference to self

Raises
------
ValueError
When ``freq="auto"`` and cannot be interpreted from ``ForecastingHorizon``
"""
if not fh.is_all_out_of_sample(cutoff=self.cutoff):
raise NotImplementedError("in-sample prediction is currently not supported")

if self.freq == "auto" and fh.freq is None:
# when freq cannot be interpreted from ForecastingHorizon
raise ValueError(
f"Error in {self.__class__.__name__}, "
f"could not interpret freq, "
f"try passing freq in model initialization"
)

self._freq = fh.freq if self.freq == "auto" else self.freq

train_indices = y.index
if isinstance(train_indices, pandas.PeriodIndex):
train_indices = train_indices.to_timestamp(freq=self.freq)
train_indices = train_indices.to_timestamp(freq=self._freq)

train_data = {
self.id_col: 1,
Expand Down Expand Up @@ -252,7 +272,7 @@ def _predict(
if self.futr_exog_list:
predict_indices = X.index
if isinstance(predict_indices, pandas.PeriodIndex):
predict_indices = predict_indices.to_timestamp(freq=self.freq)
predict_indices = predict_indices.to_timestamp(freq=self._freq)

predict_data = {self.id_col: 1, self.time_col: predict_indices.to_numpy()}

Expand Down
12 changes: 8 additions & 4 deletions sktime/forecasting/neuralforecast.py
Expand Up @@ -22,8 +22,10 @@ class NeuralForecastRNN(_NeuralForecastAdapter):

Parameters
----------
freq : str
freq : str (default="auto")
frequency of the data, see available frequencies [4]_ from ``pandas``

default ("auto") interprets freq from ForecastingHorizon in ``fit``
local_scaler_type : str (default=None)
scaler to apply per-series to all features before fitting, which is inverted
after predicting
Expand Down Expand Up @@ -160,7 +162,7 @@ class NeuralForecastRNN(_NeuralForecastAdapter):

def __init__(
self: "NeuralForecastRNN",
freq: str,
freq: str = "auto",
local_scaler_type: typing.Optional[
typing.Literal["standard", "robust", "robust-iqr", "minmax", "boxcox"]
] = None,
Expand Down Expand Up @@ -381,8 +383,10 @@ class NeuralForecastLSTM(_NeuralForecastAdapter):

Parameters
----------
freq : str
freq : str (default="auto")
frequency of the data, see available frequencies [4]_ from ``pandas``

default ("auto") interprets freq from ForecastingHorizon in ``fit``
local_scaler_type : str (default=None)
scaler to apply per-series to all features before fitting, which is inverted
after predicting
Expand Down Expand Up @@ -513,7 +517,7 @@ class NeuralForecastLSTM(_NeuralForecastAdapter):

def __init__(
self: "NeuralForecastLSTM",
freq: str,
freq: str = "auto",
local_scaler_type: typing.Optional[
typing.Literal["standard", "robust", "robust-iqr", "minmax", "boxcox"]
] = None,
Expand Down
110 changes: 104 additions & 6 deletions sktime/forecasting/tests/test_neuralforecast.py
yarnabrina marked this conversation as resolved.
Show resolved Hide resolved
Expand Up @@ -8,7 +8,7 @@
from sktime.split import temporal_train_test_split
from sktime.tests.test_switch import run_test_for_class

__author__ = ["yarnabrina", "pranavvp16"]
__author__ = ["yarnabrina", "pranavvp16", "geetu040"]

y, X = load_longley()
y_train, y_test, X_train, X_test = temporal_train_test_split(y, X, test_size=4)
Expand All @@ -22,7 +22,7 @@
def test_neural_forecast_univariate_y_without_X(model_class) -> None:
"""Test with single endogenous without exogenous."""
# define model
model = model_class("A-DEC", max_steps=5, trainer_kwargs={"logger": False})
model = model_class(freq="A-DEC", max_steps=5, trainer_kwargs={"logger": False})

# attempt fit with negative fh
with pytest.raises(
Expand Down Expand Up @@ -52,7 +52,10 @@ def test_neural_forecast_univariate_y_with_X(model_class) -> None:

# define model
model = model_class(
"A-DEC", futr_exog_list=exog_list, max_steps=5, trainer_kwargs={"logger": False}
freq="A-DEC",
futr_exog_list=exog_list,
max_steps=5,
trainer_kwargs={"logger": False},
)

# attempt fit without X
Expand Down Expand Up @@ -86,7 +89,7 @@ def test_neural_forecast_univariate_y_with_X(model_class) -> None:
def test_neural_forecast_multivariate_y_without_X(model_class) -> None:
"""Test with multiple endogenous without exogenous."""
# define model
model = model_class("A-DEC", max_steps=5, trainer_kwargs={"logger": False})
model = model_class(freq="A-DEC", max_steps=5, trainer_kwargs={"logger": False})

# train model
model.fit(X_train, fh=[1, 2, 3, 4])
Expand All @@ -110,7 +113,7 @@ def test_neural_forecast_with_non_default_loss(model_class) -> None:

# define model
model = model_class(
"A-DEC",
freq="A-DEC",
loss=HuberQLoss(0.5),
valid_loss=MASE(1),
max_steps=5,
Expand Down Expand Up @@ -139,7 +142,7 @@ def test_neural_forecast_fail_with_multiple_predictions(model_class) -> None:

# define model
model = model_class(
"A-DEC",
freq="A-DEC",
loss=MQLoss(quantiles=[0.25, 0.5, 0.75]),
max_steps=5,
trainer_kwargs={"logger": False},
Expand All @@ -153,3 +156,98 @@ def test_neural_forecast_fail_with_multiple_predictions(model_class) -> None:
NotImplementedError, match="Multiple prediction columns are not supported."
):
model.predict()


@pytest.mark.parametrize("model_class", [NeuralForecastLSTM, NeuralForecastRNN])
@pytest.mark.skipif(
not run_test_for_class([NeuralForecastLSTM, NeuralForecastRNN]),
reason="run test only if softdeps are present and incrementally (if requested)",
)
def test_neural_forecast_with_auto_freq(model_class) -> None:
"""Test with freq set to 'auto'."""
# define model
model = model_class(freq="auto", max_steps=5, trainer_kwargs={"logger": False})

# train model
model.fit(y_train, fh=[1, 2, 3, 4])

# predict with trained model
y_pred = model.predict()

# check interpreted freq
assert y_pred.index.freq == "A-DEC"


@pytest.mark.parametrize("model_class", [NeuralForecastLSTM, NeuralForecastRNN])
@pytest.mark.parametrize(
"freq",
[
"B",
"D",
"W",
"M",
"Q",
"A",
"Y",
"H",
"T",
"min",
"S",
"L",
"ms",
"U",
"us",
"N",
],
)
@pytest.mark.skipif(
not run_test_for_class([NeuralForecastLSTM, NeuralForecastRNN]),
reason="run test only if softdeps are present and incrementally (if requested)",
)
def test_neural_forecast_with_auto_against_given_freq(model_class, freq) -> None:
"""Test NeuralForecastRNN with freq set to 'auto' on all freqs."""
# prepare data
y = pandas.Series(
data=range(10),
index=pandas.date_range(start="2024-01-01", periods=10, freq=freq),
)

# define model
model = model_class(freq="auto", max_steps=1, trainer_kwargs={"logger": False})

# attempt train
model.fit(y, fh=[1, 2, 3, 4])

yarnabrina marked this conversation as resolved.
Show resolved Hide resolved
# convert freq str to DateOffset object for comparison
offset_freq = pandas.tseries.frequencies.to_offset(freq)
offset_auto_freq = pandas.tseries.frequencies.to_offset(model._freq)

assert offset_freq == offset_auto_freq


@pytest.mark.parametrize("model_class", [NeuralForecastLSTM, NeuralForecastRNN])
@pytest.mark.skipif(
not run_test_for_class([NeuralForecastLSTM, NeuralForecastRNN]),
reason="run test only if softdeps are present and incrementally (if requested)",
)
def test_neural_forecast_fail_with_auto_freq_on_range_index(model_class) -> None:
"""Test fail with freq set to 'auto' on pd.RangeIndex."""
# prepare data
y = pandas.Series(data=range(10), index=pandas.RangeIndex(start=0, stop=10))

# should fail to interpret auto freq
with pytest.raises(
ValueError,
match="could not interpret freq, try passing freq in model initialization",
):
# define model
model = model_class(freq="auto", max_steps=5, trainer_kwargs={"logger": False})

# attempt train
model.fit(y, fh=[1, 2, 3, 4])

# should work with freq passed as param
model = model_class(freq="W", max_steps=5, trainer_kwargs={"logger": False})

# attempt train
model.fit(y, fh=[1, 2, 3, 4])