diff --git a/darts/models/__init__.py b/darts/models/__init__.py index a887ea811a..b09470ce74 100644 --- a/darts/models/__init__.py +++ b/darts/models/__init__.py @@ -20,6 +20,7 @@ from darts.models.forecasting.fft import FFT from darts.models.forecasting.kalman_forecaster import KalmanForecaster from darts.models.forecasting.linear_regression_model import LinearRegressionModel +from darts.models.forecasting.neural_prophet_model import NeuralProphet from darts.models.forecasting.random_forest import RandomForest from darts.models.forecasting.regression_ensemble_model import RegressionEnsembleModel from darts.models.forecasting.regression_model import RegressionModel diff --git a/darts/models/forecasting/neural_prophet_model.py b/darts/models/forecasting/neural_prophet_model.py new file mode 100644 index 0000000000..08c3649cf2 --- /dev/null +++ b/darts/models/forecasting/neural_prophet_model.py @@ -0,0 +1,284 @@ +""" +Neural Prophet +------------ +""" + +import warnings +from typing import Dict, List, Optional, Sequence, Tuple, Union + +import neuralprophet +import pandas as pd +from neuralprophet.utils import fcst_df_to_latest_forecast + +from darts.logging import raise_if_not +from darts.models.forecasting.forecasting_model import ForecastingModel +from darts.timeseries import TimeSeries, concatenate + + +class NeuralProphet(ForecastingModel): + def __init__( + self, + n_lags: int = 0, + n_forecasts: int = 1, + add_encoders: Optional[Dict] = None, + **kwargs, + ): + """Neural Prophet + + This class provides a basic wrapper around `NeuralProphet `_. + It extends approach similar to Facebook Prophet model with auto-regressive feed-forward neural network + It supports also supports past and future covariates. For more parameters refer to the original documentation. + + Parameters + ---------- + n_lags + Number of lagged values provided to AR-Net. If equal to 0 then only trend + and seasonality will be used for forecasting. + + n_forecast + Output size chunk of the AR-Net. Limits how far into the future is is possible to forecast. + + add_encoders + A large number of future covariates can be automatically generated with `add_encoders`. + This can be done by adding multiple pre-defined index encoders and/or custom user-made functions that + will be used as index encoders. Additionally, a transformer such as Darts' :class:`Scaler` can be added to + transform the generated covariates. This happens all under one hood and only needs to be specified at + model creation. + Read :meth:`SequentialEncoder ` to find out more about + ``add_encoders``. Default: ``None``. An example showing some of ``add_encoders`` features: + + .. highlight:: python + .. code-block:: python + + add_encoders={ + 'cyclic': {'future': ['month']}, + 'datetime_attribute': {'future': ['hour', 'dayofweek']}, + 'position': {'future': ['relative']}, + 'custom': {'future': [lambda idx: (idx.year - 1950) / 50]}, + 'transformer': Scaler() + } + .. + """ + super().__init__(add_encoders=add_encoders, **kwargs) + # TODO improve passing arguments to the model + + raise_if_not(n_lags >= 0, "Argument n_lags should be a non-negative integer") + + self.n_lags = n_lags + self.n_forecasts = n_forecasts + self.model = neuralprophet.NeuralProphet( + n_lags=n_lags, n_forecasts=n_forecasts, **kwargs + ) + + def fit( + self, + series: TimeSeries, + past_covariates: Optional[TimeSeries] = None, + future_covariates: Optional[TimeSeries] = None, + ) -> "NeuralProphet": + super().fit(series) + + raise_if_not( + series.has_datetime_index, + "NeuralProphet model is limited to TimeSeries indexed with DatetimeIndex", + ) + + raise_if_not( + past_covariates is None or self.n_lags > 0, + "Past covariates are only supported when auto-regression is enabled (n_lags > 0)", + ) + + self.training_series = series + fit_df = self._convert_ts_to_df(series) + + if past_covariates is not None: + fit_df = self._add_past_covariates(self.model, fit_df, past_covariates) + + if future_covariates is not None: + fit_df = self._add_future_covariates(self.model, fit_df, future_covariates) + self.future_components = future_covariates.components + else: + self.future_components = None + + with warnings.catch_warnings(): + self.model.fit(fit_df, freq=series.freq_str) + + self.fit_df = fit_df + return self + + def predict( + self, + n: int, + future_covariates: Optional[TimeSeries] = None, + num_samples: int = 1, + verbose: bool = False, + ) -> Union[TimeSeries, Sequence[TimeSeries]]: + super().predict(n, num_samples) + + raise_if_not( + self.n_lags == 0 or n <= self.n_forecasts, + "Auto-regression has been enabled. `n` must be smaller than or equal to" + "`n_forecasts` parameter in the constructor.", + ) + + self._future_covariates_checks(future_covariates) + + regressors_df = ( + self._future_covariates_df(future_covariates) + if self.future_components is not None + else None + ) + + future_df = self.model.make_future_dataframe( + df=self.fit_df, regressors_df=regressors_df, periods=n + ) + + with warnings.catch_warnings(): + forecast_df = self.model.predict(future_df) + + return self._convert_df_to_ts( + forecast_df, + self.training_series.end_time(), + self.training_series.components, + ) + + def _convert_ts_to_df(self, series: TimeSeries) -> pd.DataFrame: + """Convert TimeSeries to pandas DataFrame format required by Neural Prophet""" + dfs = [] # ID y + + for component in series.components: + component_df = ( + series[component] + .pd_dataframe(copy=False) + .reset_index(names=["ds"]) + .filter(items=["ds", component]) + .rename(columns={component: "y"}) + ) + component_df["ID"] = component + dfs.append(component_df) + + return pd.concat(dfs).copy(deep=True) + + def _add_past_covariates( + self, + model: neuralprophet.NeuralProphet, + df: pd.DataFrame, + covariates: TimeSeries, + ): + df = self._add_covariate(df, covariates) + model.add_lagged_regressor(names=list(covariates.components)) + return df + + def _add_future_covariates( + self, + model: neuralprophet.NeuralProphet, + df: pd.DataFrame, + covariates: TimeSeries, + ): + df = self._add_covariate(df, covariates) + for component in covariates.components: + model.add_future_regressor(name=component) + + return df + + def _add_covariate( + self, + df: pd.DataFrame, + covariates: TimeSeries, + ) -> pd.DataFrame: + """Convert past covariates from TimeSeries and add them to DataFrame""" + + raise_if_not( + self.training_series.freq == covariates.freq, + "Covariate TimeSeries has to have the same frequency as the TimeSeries that model is fitted on.", + ) + + raise_if_not( + covariates.start_time() <= self.training_series.start_time() + and self.training_series.end_time() <= covariates.end_time(), + "Covaraite TimeSeries has to span across all TimeSeries that model is fitted on", + ) + + for component in covariates.components: + covariate_df = ( + covariates[component] + .pd_dataframe(copy=False) + .reset_index(names=["ds"]) + .filter(items=["ds", component]) + ) + + df = df.merge(covariate_df, how="left", on="ds") + + return df + + def _convert_df_to_ts(self, forecast: pd.DataFrame, last_train_date, components): + groups = [] + for component in components: + if self.n_lags == 0: + # output format is different when AR is not enabled + groups.append( + forecast[ + (forecast["ID"] == component) + & (forecast["ds"] > last_train_date) + ] + .filter(items=["ds", "yhat1"]) + .rename(columns={"yhat1": component}) + ) + else: + df = fcst_df_to_latest_forecast( + forecast[(forecast["ID"] == component)], + quantiles=[0.5], + n_last=1, + ) + groups.append( + df[df["ds"] > last_train_date] + .filter(items=["ds", "origin-0"]) + .rename(columns={"origin-0": component}) + ) + + return concatenate( + [TimeSeries.from_dataframe(group, time_col="ds") for group in groups], + axis=1, + ) + + def _future_covariates_df(self, series: TimeSeries) -> pd.DataFrame: + component_dfs = [] + for component in series.components: + component_dfs.append(series[component].pd_dataframe()) + + return pd.concat(component_dfs, axis=1).reset_index(names=["ds"]) + + def _future_covariates_checks(self, future_covariates: Optional[TimeSeries]): + raise_if_not( + self.future_components is None + or ( + future_covariates is not None + and set(self.future_components) == set(future_covariates.components) + ), + f"Missing future covariate TimeSeries. Model was trained with {self.future_components} " + "future components", + ) + + raise_if_not( + self.future_components is None + or future_covariates.freq == self.training_series.freq, + "Invalid frequency in future covariate TimeSeries", + ) + + def uses_future_covariates(self): + return True + + def _model_encoder_settings( + self, + ) -> Tuple[ + Optional[int], + Optional[int], + bool, + bool, + Optional[List[int]], + Optional[List[int]], + ]: + return (None, None, True, True, None, None) + + def __str__(self): + return "Neural Prophet" diff --git a/neural_examples/examples.ipynb b/neural_examples/examples.ipynb new file mode 100644 index 0000000000..a3a67b78d9 --- /dev/null +++ b/neural_examples/examples.ipynb @@ -0,0 +1,467 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "import torch\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from darts import TimeSeries\n", + "from darts.utils.timeseries_generation import (\n", + " sine_timeseries,\n", + ")\n", + "from darts.metrics import mape, smape\n", + "from darts.dataprocessing.transformers import Scaler\n", + "from darts.utils.timeseries_generation import datetime_attribute_timeseries\n", + "from darts.datasets import *\n", + "from darts.models.forecasting.neural_prophet_model import (\n", + " NeuralProphet as NeuralProphetDarts,\n", + ")\n", + "from neuralprophet import NeuralProphet\n", + "import neuralprophet\n", + "\n", + "# for reproducibility\n", + "torch.manual_seed(1)\n", + "np.random.seed(1)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Univariate example" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "series_air = AirPassengersDataset().load()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "series_air.plot(label=\"air\")\n", + "plt.legend()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "train_air, test_air = series_air[:-36], series_air[-36:]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Only trend and seasonality - equivalent to using Prophet" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model = NeuralProphetDarts()\n", + "model.fit(train_air)\n", + "preds_simple = model.predict(36)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With auto-regression" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model = NeuralProphetDarts(n_lags=36, n_forecasts=36, n_changepoints=20)\n", + "model.fit(train_air)\n", + "preds_ar = model.predict(36)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "train_air.plot(label=\"train\")\n", + "test_air.plot(label=\"test\")\n", + "preds_simple.plot(label=\"trend & season\")\n", + "preds_ar.plot(label=\"auto-regression\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multivariate" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "complete_ts = AustralianTourismDataset().load()\n", + "ts = complete_ts[[\"NSW\", \"VIC\", \"QLD\", \"SA\", \"WA\", \"TAS\", \"NT\"]]\n", + "ts.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# model supports only datetime indexing\n", + "ts = TimeSeries.from_times_and_values(\n", + " pd.date_range(start=\"2000-01-01\", periods=len(ts), freq=\"D\"), ts.values()\n", + ")\n", + "train, test = ts[:-4], ts[-4:]\n", + "model = NeuralProphetDarts(\n", + " yearly_seasonality=False,\n", + " weekly_seasonality=True,\n", + " daily_seasonality=False,\n", + " n_lags=len(test),\n", + " n_forecasts=len(test),\n", + ")\n", + "\n", + "model.fit(train)\n", + "preds = model.predict(len(test))\n", + "\n", + "preds.plot()\n", + "test.plot()\n", + "train.plot()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Past covariates" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ice_heater = IceCreamHeaterDataset().load()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "train, test = ice_heater.split_after(split_point=0.8)\n", + "\n", + "year = datetime_attribute_timeseries(train, attribute=\"year\")\n", + "month = datetime_attribute_timeseries(train, attribute=\"month\")\n", + "ice_covariates = month.stack(year)\n", + "\n", + "scaler_dt_air = Scaler()\n", + "ice_covariates = scaler_dt_air.fit_transform(ice_covariates)\n", + "\n", + "ice_covariates.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model = NeuralProphetDarts(n_lags=len(test), n_forecasts=len(test))\n", + "model.fit(train, ice_covariates)\n", + "preds_cov = model.predict(len(test))\n", + "\n", + "preds_cov.plot()\n", + "train.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "model = NeuralProphetDarts(n_lags=len(test), n_forecasts=len(test))\n", + "model.fit(train)\n", + "preds_no_cov = model.predict(len(test))\n", + "\n", + "preds_no_cov.plot()\n", + "train.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(mape(test, preds_cov))\n", + "print(mape(test, preds_no_cov))\n", + "# no improvement in this case but it shows that fitting and training works for multivariate time series and multivariate past covariates" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ice_heater = IceCreamHeaterDataset().load()\n", + "ice_train, ice_test = ice_heater[\"ice cream\"].split_after(0.8)\n", + "heater_cov, _ = ice_heater[\"heater\"].split_after(0.8)\n", + "horizon = len(ice_test)\n", + "\n", + "model = NeuralProphetDarts(n_lags=horizon, n_forecasts=horizon)\n", + "model.fit(ice_train, heater_cov)\n", + "preds_cov = model.predict(horizon)\n", + "\n", + "model = NeuralProphetDarts(n_lags=horizon, n_forecasts=horizon)\n", + "model.fit(ice_train)\n", + "preds_no_cov = model.predict(horizon)\n", + "\n", + "print(\"MAPE with lagged regressor: \", mape(ice_test, preds_cov))\n", + "print(\"MAPE without lagged regressor: \", mape(ice_test, preds_no_cov))\n", + "\n", + "# for some reason results vary a lot here for model with lagged regressors\n", + "\n", + "ice_train.plot(label=\"train\")\n", + "preds_cov.plot(label=\"cov\")\n", + "preds_no_cov.plot(label=\"no cov\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Future Regressors" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Following the example from their website" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO - (NP.df_utils._infer_frequency) - Major frequency MS corresponds to 91.139% of the data.\n", + "INFO - (NP.df_utils._infer_frequency) - Defined frequency is equal to major frequency - MS\n", + "INFO - (NP.config.init_data_params) - Setting normalization to global as only one dataframe provided for training.\n", + "INFO - (NP.utils.set_auto_seasonalities) - Disabling weekly seasonality. Run NeuralProphet with weekly_seasonality=True to override this.\n", + "INFO - (NP.utils.set_auto_seasonalities) - Disabling daily seasonality. Run NeuralProphet with daily_seasonality=True to override this.\n", + "INFO - (NP.config.set_auto_batch_epoch) - Auto-set batch_size to 16\n", + "INFO - (NP.config.set_auto_batch_epoch) - Auto-set epochs to 627\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "f19524ee3e9146f19ad620f792f60592", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + " 0%| | 0/106 [00:00" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ice_heater = IceCreamHeaterDataset().load()\n", + "ice_train, ice_test = ice_heater[\"ice cream\"].split_after(0.8)\n", + "heater_past, heater_future = ice_heater[\"heater\"].split_after(0.8)\n", + "horizon = len(ice_test)\n", + "\n", + "model = NeuralProphetDarts(n_lags=horizon, n_forecasts=horizon)\n", + "model.fit(ice_train, future_covariates=heater_past)\n", + "\n", + "preds = model.predict(horizon, future_covariates=heater_future)\n", + "preds.plot()\n", + "ice_heater.plot()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.9.15 ('prophet')", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.15" + }, + "orig_nbformat": 4, + "vscode": { + "interpreter": { + "hash": "2f14e79e1646dc5b749c3dc6e0dfef5e568c2efea6b930caf0398818dd8806ea" + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/requirements/core.txt b/requirements/core.txt index 7437f5dbba..67d6b99254 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -8,6 +8,7 @@ numpy>=1.19.0 pandas>=1.0.5 pmdarima>=1.8.0 prophet>=1.1.1 +neuralprophet>=0.5.2 pyod>=0.9.5 requests>=2.22.0 scikit-learn>=1.0.1