diff --git a/CHANGELOG.md b/CHANGELOG.md index af890e306..591bae09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- Target components logic into base classes of models ([#1158](https://github.com/tinkoff-ai/etna/pull/1158)) - Target components logic to TSDataset ([#1153](https://github.com/tinkoff-ai/etna/pull/1153)) - Methods `save` and `load` to HierarchicalPipeline ([#1096](https://github.com/tinkoff-ai/etna/pull/1096)) - New data access methods in `TSDataset` : `update_columns_from_pandas`, `add_columns_from_pandas`, `drop_features` ([#809](https://github.com/tinkoff-ai/etna/pull/809)) diff --git a/etna/models/base.py b/etna/models/base.py index 607b921dd..8e8f0675c 100644 --- a/etna/models/base.py +++ b/etna/models/base.py @@ -93,13 +93,15 @@ def context_size(self) -> int: return 0 @abstractmethod - def forecast(self, ts: TSDataset) -> TSDataset: + def forecast(self, ts: TSDataset, return_components: bool = False) -> TSDataset: """Make predictions. Parameters ---------- ts: Dataset with features + return_components: + If True additionally returns forecast components Returns ------- @@ -109,13 +111,15 @@ def forecast(self, ts: TSDataset) -> TSDataset: pass @abstractmethod - def predict(self, ts: TSDataset) -> TSDataset: + def predict(self, ts: TSDataset, return_components: bool = False) -> TSDataset: """Make predictions with using true values as autoregression context if possible (teacher forcing). Parameters ---------- ts: Dataset with features + return_components: + If True additionally returns prediction components Returns ------- @@ -129,7 +133,7 @@ class NonPredictionIntervalContextRequiredAbstractModel(AbstractModel): """Interface for models that don't support prediction intervals and need context for prediction.""" @abstractmethod - def forecast(self, ts: TSDataset, prediction_size: int) -> TSDataset: + def forecast(self, ts: TSDataset, prediction_size: int, return_components: bool = False) -> TSDataset: """Make predictions. Parameters @@ -139,6 +143,8 @@ def forecast(self, ts: TSDataset, prediction_size: int) -> TSDataset: prediction_size: Number of last timestamps to leave after making prediction. Previous timestamps will be used as a context for models that require it. + return_components: + If True additionally returns forecast components Returns ------- @@ -148,7 +154,7 @@ def forecast(self, ts: TSDataset, prediction_size: int) -> TSDataset: pass @abstractmethod - def predict(self, ts: TSDataset, prediction_size: int) -> TSDataset: + def predict(self, ts: TSDataset, prediction_size: int, return_components: bool = False) -> TSDataset: """Make predictions with using true values as autoregression context if possible (teacher forcing). Parameters @@ -158,6 +164,8 @@ def predict(self, ts: TSDataset, prediction_size: int) -> TSDataset: prediction_size: Number of last timestamps to leave after making prediction. Previous timestamps will be used as a context for models that require it. + return_components: + If True additionally returns prediction components Returns ------- @@ -180,7 +188,11 @@ def context_size(self) -> int: @abstractmethod def forecast( - self, ts: TSDataset, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975) + self, + ts: TSDataset, + prediction_interval: bool = False, + quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions. @@ -192,6 +204,8 @@ def forecast( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns forecast components Returns ------- @@ -202,7 +216,11 @@ def forecast( @abstractmethod def predict( - self, ts: TSDataset, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975) + self, + ts: TSDataset, + prediction_interval: bool = False, + quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions with using true values as autoregression context if possible (teacher forcing). @@ -214,6 +232,8 @@ def predict( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns prediction components Returns ------- @@ -233,6 +253,7 @@ def forecast( prediction_size: int, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions. @@ -247,6 +268,8 @@ def forecast( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns forecast components Returns ------- @@ -262,6 +285,7 @@ def predict( prediction_size: int, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions with using true values as autoregression context if possible (teacher forcing). @@ -276,6 +300,8 @@ def predict( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns prediction components Returns ------- @@ -604,7 +630,7 @@ def raw_predict(self, torch_dataset: "Dataset") -> Dict[Tuple[str, str], np.ndar return predictions_dict @log_decorator - def forecast(self, ts: "TSDataset", prediction_size: int) -> "TSDataset": + def forecast(self, ts: "TSDataset", prediction_size: int, return_components: bool = False) -> "TSDataset": """Make predictions. This method will make autoregressive predictions. @@ -616,12 +642,17 @@ def forecast(self, ts: "TSDataset", prediction_size: int) -> "TSDataset": prediction_size: Number of last timestamps to leave after making prediction. Previous timestamps will be used as a context. + return_components: + If True additionally returns forecast components Returns ------- : Dataset with predictions """ + if return_components: + raise NotImplementedError("This mode isn't currently implemented!") + test_dataset = ts.to_torch_dataset( make_samples=functools.partial( self.net.make_samples, encoder_length=self.encoder_length, decoder_length=prediction_size @@ -636,7 +667,12 @@ def forecast(self, ts: "TSDataset", prediction_size: int) -> "TSDataset": return future_ts @log_decorator - def predict(self, ts: "TSDataset", prediction_size: int) -> "TSDataset": + def predict( + self, + ts: "TSDataset", + prediction_size: int, + return_components: bool = False, + ) -> "TSDataset": """Make predictions. This method will make predictions using true values instead of predicted on a previous step. @@ -649,6 +685,8 @@ def predict(self, ts: "TSDataset", prediction_size: int) -> "TSDataset": prediction_size: Number of last timestamps to leave after making prediction. Previous timestamps will be used as a context. + return_components: + If True additionally returns prediction components Returns ------- diff --git a/etna/models/mixins.py b/etna/models/mixins.py index 22cb1fe3b..6e462167d 100644 --- a/etna/models/mixins.py +++ b/etna/models/mixins.py @@ -28,45 +28,78 @@ def _forecast(self, **kwargs) -> TSDataset: def _predict(self, **kwargs) -> TSDataset: pass + @abstractmethod + def _forecast_components(self, **kwargs) -> pd.DataFrame: + pass + + @abstractmethod + def _predict_components(self, **kwargs) -> pd.DataFrame: + pass + + def _add_target_components( + self, ts: TSDataset, predictions: TSDataset, components_prediction_method: Callable, return_components: bool + ): + if return_components: + target_components_df = components_prediction_method(ts=ts) + predictions.add_target_components(target_components_df=target_components_df) + class NonPredictionIntervalContextIgnorantModelMixin(ModelForecastingMixin): """Mixin for models that don't support prediction intervals and don't need context for prediction.""" - def forecast(self, ts: TSDataset) -> TSDataset: + def forecast(self, ts: TSDataset, return_components: bool = False) -> TSDataset: """Make predictions. Parameters ---------- ts: Dataset with features + return_components: + If True additionally returns forecast components Returns ------- : Dataset with predictions """ - return self._forecast(ts=ts) + forecast = self._forecast(ts=ts) + self._add_target_components( + ts=ts, + predictions=forecast, + components_prediction_method=self._forecast_components, + return_components=return_components, + ) + return forecast - def predict(self, ts: TSDataset) -> TSDataset: + def predict(self, ts: TSDataset, return_components: bool = False) -> TSDataset: """Make predictions with using true values as autoregression context if possible (teacher forcing). Parameters ---------- ts: Dataset with features + return_components: + If True additionally returns prediction components Returns ------- : Dataset with predictions """ - return self._predict(ts=ts) + prediction = self._predict(ts=ts) + self._add_target_components( + ts=ts, + predictions=prediction, + components_prediction_method=self._predict_components, + return_components=return_components, + ) + return prediction class NonPredictionIntervalContextRequiredModelMixin(ModelForecastingMixin): """Mixin for models that don't support prediction intervals and need context for prediction.""" - def forecast(self, ts: TSDataset, prediction_size: int) -> TSDataset: + def forecast(self, ts: TSDataset, prediction_size: int, return_components: bool = False) -> TSDataset: """Make predictions. Parameters @@ -76,15 +109,24 @@ def forecast(self, ts: TSDataset, prediction_size: int) -> TSDataset: prediction_size: Number of last timestamps to leave after making prediction. Previous timestamps will be used as a context for models that require it. + return_components: + If True additionally returns forecast components Returns ------- : Dataset with predictions """ - return self._forecast(ts=ts, prediction_size=prediction_size) + forecast = self._forecast(ts=ts, prediction_size=prediction_size) + self._add_target_components( + ts=ts, + predictions=forecast, + components_prediction_method=self._forecast_components, + return_components=return_components, + ) + return forecast - def predict(self, ts: TSDataset, prediction_size: int) -> TSDataset: + def predict(self, ts: TSDataset, prediction_size: int, return_components: bool = False) -> TSDataset: """Make predictions with using true values as autoregression context if possible (teacher forcing). Parameters @@ -94,20 +136,33 @@ def predict(self, ts: TSDataset, prediction_size: int) -> TSDataset: prediction_size: Number of last timestamps to leave after making prediction. Previous timestamps will be used as a context for models that require it. + return_components: + If True additionally returns prediction components Returns ------- : Dataset with predictions """ - return self._predict(ts=ts, prediction_size=prediction_size) + prediction = self._predict(ts=ts, prediction_size=prediction_size) + self._add_target_components( + ts=ts, + predictions=prediction, + components_prediction_method=self._predict_components, + return_components=return_components, + ) + return prediction class PredictionIntervalContextIgnorantModelMixin(ModelForecastingMixin): """Mixin for models that support prediction intervals and don't need context for prediction.""" def forecast( - self, ts: TSDataset, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975) + self, + ts: TSDataset, + prediction_interval: bool = False, + quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions. @@ -119,16 +174,29 @@ def forecast( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns forecast components Returns ------- : Dataset with predictions """ - return self._forecast(ts=ts, prediction_interval=prediction_interval, quantiles=quantiles) + forecast = self._forecast(ts=ts, prediction_interval=prediction_interval, quantiles=quantiles) + self._add_target_components( + ts=ts, + predictions=forecast, + components_prediction_method=self._forecast_components, + return_components=return_components, + ) + return forecast def predict( - self, ts: TSDataset, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975) + self, + ts: TSDataset, + prediction_interval: bool = False, + quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions with using true values as autoregression context if possible (teacher forcing). @@ -140,13 +208,22 @@ def predict( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns prediction components Returns ------- : Dataset with predictions """ - return self._predict(ts=ts, prediction_interval=prediction_interval, quantiles=quantiles) + prediction = self._predict(ts=ts, prediction_interval=prediction_interval, quantiles=quantiles) + self._add_target_components( + ts=ts, + predictions=prediction, + components_prediction_method=self._predict_components, + return_components=return_components, + ) + return prediction class PredictionIntervalContextRequiredModelMixin(ModelForecastingMixin): @@ -158,6 +235,7 @@ def forecast( prediction_size: int, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions. @@ -172,15 +250,24 @@ def forecast( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns forecast components Returns ------- : Dataset with predictions """ - return self._forecast( + forecast = self._forecast( ts=ts, prediction_size=prediction_size, prediction_interval=prediction_interval, quantiles=quantiles ) + self._add_target_components( + ts=ts, + predictions=forecast, + components_prediction_method=self._forecast_components, + return_components=return_components, + ) + return forecast def predict( self, @@ -188,6 +275,7 @@ def predict( prediction_size: int, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions with using true values as autoregression context if possible (teacher forcing). @@ -202,15 +290,24 @@ def predict( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns prediction components Returns ------- : Dataset with predictions """ - return self._predict( + prediction = self._predict( ts=ts, prediction_size=prediction_size, prediction_interval=prediction_interval, quantiles=quantiles ) + self._add_target_components( + ts=ts, + predictions=prediction, + components_prediction_method=self._predict_components, + return_components=return_components, + ) + return prediction class PerSegmentModelMixin(ModelForecastingMixin): @@ -348,6 +445,33 @@ def _make_predictions(self, ts: TSDataset, prediction_method: Callable, **kwargs ts.df = ts.df.iloc[-prediction_size:] return ts + def _make_component_predictions(self, ts: TSDataset, prediction_method: Callable, **kwargs) -> pd.DataFrame: + """Make target component predictions. + + Parameters + ---------- + ts: + Dataset with features + prediction_method: + Method for making components predictions + + Returns + ------- + : + DataFrame with predicted components + """ + features_df = ts.to_pandas() + result_list = list() + for segment, model in self._get_model().items(): + segment_predict = self._make_predictions_segment( + model=model, segment=segment, df=features_df, prediction_method=prediction_method, **kwargs + ) + result_list.append(segment_predict) + + target_components_df = pd.concat(result_list, ignore_index=True) + target_components_df = TSDataset.to_dataset(target_components_df) + return target_components_df + @log_decorator def _forecast(self, ts: TSDataset, **kwargs) -> TSDataset: if hasattr(self._base_model, "forecast"): @@ -358,6 +482,22 @@ def _forecast(self, ts: TSDataset, **kwargs) -> TSDataset: def _predict(self, ts: TSDataset, **kwargs) -> TSDataset: return self._make_predictions(ts=ts, prediction_method=self._base_model.__class__.predict, **kwargs) + @log_decorator + def _forecast_components(self, ts: TSDataset, **kwargs) -> pd.DataFrame: + if hasattr(self._base_model, "forecast_components"): + return self._make_component_predictions( + ts=ts, prediction_method=self._base_model.__class__.forecast_components, **kwargs + ) + return self._make_component_predictions( + ts=ts, prediction_method=self._base_model.__class__.predict_components, **kwargs + ) + + @log_decorator + def _predict_components(self, ts: TSDataset, **kwargs) -> pd.DataFrame: + return self._make_component_predictions( + ts=ts, prediction_method=self._base_model.__class__.predict_components, **kwargs + ) + class MultiSegmentModelMixin(ModelForecastingMixin): """Mixin for holding methods for multi-segment prediction. @@ -418,6 +558,30 @@ def _make_predictions(self, ts: TSDataset, prediction_method: Callable, **kwargs ts.loc[:, pd.IndexSlice[:, "target"]] = y return ts + def _make_component_predictions(self, ts: TSDataset, prediction_method: Callable, **kwargs) -> pd.DataFrame: + """Make target component predictions. + + Parameters + ---------- + ts: + Dataset with features + prediction_method: + Method for making components predictions + + Returns + ------- + : + DataFrame with predicted components + """ + features_df = ts.to_pandas(flatten=True) + segment_column = features_df["segment"].values + features_df = features_df.drop(["segment"], axis=1) + # TODO: make it work with prediction intervals and context + target_components_df = prediction_method(self=self._base_model, df=features_df, **kwargs) + target_components_df["segment"] = segment_column + target_components_df = TSDataset.to_dataset(target_components_df) + return target_components_df + @log_decorator def _forecast(self, ts: TSDataset, **kwargs) -> TSDataset: if hasattr(self._base_model, "forecast"): @@ -428,6 +592,22 @@ def _forecast(self, ts: TSDataset, **kwargs) -> TSDataset: def _predict(self, ts: TSDataset, **kwargs) -> TSDataset: return self._make_predictions(ts=ts, prediction_method=self._base_model.__class__.predict, **kwargs) + @log_decorator + def _forecast_components(self, ts: TSDataset, **kwargs) -> pd.DataFrame: + if hasattr(self._base_model, "forecast_components"): + return self._make_component_predictions( + ts=ts, prediction_method=self._base_model.__class__.forecast_components, **kwargs + ) + return self._make_component_predictions( + ts=ts, prediction_method=self._base_model.__class__.predict_components, **kwargs + ) + + @log_decorator + def _predict_components(self, ts: TSDataset, **kwargs) -> pd.DataFrame: + return self._make_component_predictions( + ts=ts, prediction_method=self._base_model.__class__.predict_components, **kwargs + ) + def get_model(self) -> Any: """Get internal model that is used inside etna class. diff --git a/etna/models/nn/deepar.py b/etna/models/nn/deepar.py index c0c45ed08..15edc9fae 100644 --- a/etna/models/nn/deepar.py +++ b/etna/models/nn/deepar.py @@ -149,6 +149,7 @@ def forecast( prediction_size: int, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions. @@ -165,12 +166,17 @@ def forecast( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns forecast components Returns ------- TSDataset TSDataset with predictions. """ + if return_components: + raise NotImplementedError("This mode isn't currently implemented!") + ts, prediction_dataloader = self._make_target_prediction(ts, prediction_size) if prediction_interval: @@ -203,6 +209,7 @@ def predict( prediction_size: int, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions. @@ -220,6 +227,8 @@ def predict( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns prediction components Returns ------- diff --git a/etna/models/nn/tft.py b/etna/models/nn/tft.py index 10d771bc2..ce27d448e 100644 --- a/etna/models/nn/tft.py +++ b/etna/models/nn/tft.py @@ -153,6 +153,7 @@ def forecast( prediction_size: int, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions. @@ -169,12 +170,17 @@ def forecast( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns forecast components Returns ------- TSDataset TSDataset with predictions. """ + if return_components: + raise NotImplementedError("This mode isn't currently implemented!") + ts, prediction_dataloader = self._make_target_prediction(ts, prediction_size) if prediction_interval: @@ -232,6 +238,7 @@ def predict( prediction_size: int, prediction_interval: bool = False, quantiles: Sequence[float] = (0.025, 0.975), + return_components: bool = False, ) -> TSDataset: """Make predictions. @@ -249,6 +256,8 @@ def predict( If True returns prediction interval for forecast quantiles: Levels of prediction distribution. By default 2.5% and 97.5% are taken to form a 95% prediction interval + return_components: + If True additionally returns prediction components Returns ------- diff --git a/tests/test_models/nn/test_deepar.py b/tests/test_models/nn/test_deepar.py index 9eb31b75a..7ca8750c7 100644 --- a/tests/test_models/nn/test_deepar.py +++ b/tests/test_models/nn/test_deepar.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import pandas as pd import pytest from pytorch_forecasting.data import GroupNormalizer @@ -184,3 +186,8 @@ def test_repr(): "train_batch_size = 64, test_batch_size = 64, lr = 0.1, cell_type = 'LSTM', hidden_size = 10, rnn_layers = 2, " "dropout = 0.1, loss = NormalDistributionLoss(), trainer_params = {'max_epochs': 2, 'gpus': 0}, quantiles_kwargs = {}, )" ) + + +def test_deepar_forecast_throw_error_on_return_components(): + with pytest.raises(NotImplementedError, match="This mode isn't currently implemented!"): + DeepARModel.forecast(self=Mock(), ts=Mock(), prediction_size=Mock(), return_components=True) diff --git a/tests/test_models/nn/test_tft.py b/tests/test_models/nn/test_tft.py index 636b58ad0..7f0d7b737 100644 --- a/tests/test_models/nn/test_tft.py +++ b/tests/test_models/nn/test_tft.py @@ -1,3 +1,5 @@ +from unittest.mock import Mock + import pandas as pd import pytest @@ -189,3 +191,8 @@ def test_repr(): "attention_head_size = 4, dropout = 0.1, hidden_continuous_size = 8, " "loss = QuantileLoss(), trainer_params = {'max_epochs': 2, 'gpus': 0}, quantiles_kwargs = {}, )" ) + + +def test_tft_forecast_throw_error_on_return_components(): + with pytest.raises(NotImplementedError, match="This mode isn't currently implemented!"): + TFTModel.forecast(self=Mock(), ts=Mock(), prediction_size=Mock(), return_components=True) diff --git a/tests/test_models/test_base.py b/tests/test_models/test_base.py index 400b6a3a1..17612b7b3 100644 --- a/tests/test_models/test_base.py +++ b/tests/test_models/test_base.py @@ -156,3 +156,8 @@ def test_deep_base_model_forecast_loop(simple_df, deep_base_model_mock): np.testing.assert_allclose( future.df.loc[:, pd.IndexSlice["B", "target"]], raw_predict[("B", "target")][:horizon, 0] ) + + +def test_deep_base_model_forecast_throw_error_on_return_components(): + with pytest.raises(NotImplementedError, match="This mode isn't currently implemented!"): + DeepBaseModel.forecast(self=Mock(), ts=Mock(), prediction_size=Mock(), return_components=True) diff --git a/tests/test_models/test_mixins.py b/tests/test_models/test_mixins.py index 0b7f8c9d3..099040720 100644 --- a/tests/test_models/test_mixins.py +++ b/tests/test_models/test_mixins.py @@ -5,18 +5,102 @@ from zipfile import ZipFile import dill +import numpy as np import pytest from etna import SETTINGS +from etna.datasets import TSDataset if SETTINGS.torch_required: import torch +import pandas as pd + +from etna.models.base import BaseAdapter from etna.models.mixins import MultiSegmentModelMixin +from etna.models.mixins import NonPredictionIntervalContextIgnorantModelMixin +from etna.models.mixins import NonPredictionIntervalContextRequiredModelMixin from etna.models.mixins import PerSegmentModelMixin +from etna.models.mixins import PredictionIntervalContextIgnorantModelMixin +from etna.models.mixins import PredictionIntervalContextRequiredModelMixin from etna.models.mixins import SaveNNMixin +class DummyPredictAdapter(BaseAdapter): + def fit(self, df: pd.DataFrame, **kwargs) -> "DummyPredictAdapter": + return self + + def predict(self, df: pd.DataFrame, **kwargs) -> np.ndarray: + df["target"] = 200 + return df["target"].values + + def predict_components(self, df: pd.DataFrame, **kwargs) -> pd.DataFrame: + df["target_component_a"] = 20 + df["target_component_b"] = 180 + df = df.drop(columns=["target"]) + return df + + def get_model(self) -> "DummyPredictAdapter": + return self + + +class DummyForecastPredictAdapter(DummyPredictAdapter): + def fit(self, df: pd.DataFrame, **kwargs) -> "DummyForecastPredictAdapter": + return self + + def forecast(self, df: pd.DataFrame, **kwargs) -> np.ndarray: + df["target"] = 100 + return df["target"].values + + def forecast_components(self, df: pd.DataFrame, **kwargs) -> pd.DataFrame: + df["target_component_a"] = 10 + df["target_component_b"] = 90 + df = df.drop(columns=["target"]) + return df + + +class DummyModelBase: + def _forecast(self, ts: TSDataset, **kwargs) -> TSDataset: + ts.loc[pd.IndexSlice[:], pd.IndexSlice[:, "target"]] = 100 + return ts + + def _predict(self, ts: TSDataset, **kwargs) -> TSDataset: + ts.loc[pd.IndexSlice[:], pd.IndexSlice[:, "target"]] = 200 + return ts + + def _forecast_components(self, ts: TSDataset, **kwargs) -> pd.DataFrame: + df = ts.to_pandas(flatten=True, features=["target"]) + df["target_component_a"] = 10 + df["target_component_b"] = 90 + df = df.drop(columns=["target"]) + df = TSDataset.to_dataset(df) + return df + + def _predict_components(self, ts: TSDataset, **kwargs) -> pd.DataFrame: + df = ts.to_pandas(flatten=True, features=["target"]) + df["target_component_a"] = 20 + df["target_component_b"] = 180 + df = df.drop(columns=["target"]) + df = TSDataset.to_dataset(df) + return df + + +class NonPredictionIntervalContextIgnorantDummyModel(DummyModelBase, NonPredictionIntervalContextIgnorantModelMixin): + pass + + +class NonPredictionIntervalContextRequiredDummyModel(DummyModelBase, NonPredictionIntervalContextRequiredModelMixin): + pass + + +class PredictionIntervalContextIgnorantDummyModel(DummyModelBase, PredictionIntervalContextIgnorantModelMixin): + pass + + +class PredictionIntervalContextRequiredDummyModel(DummyModelBase, PredictionIntervalContextRequiredModelMixin): + pass + + @pytest.fixture() def regression_base_model_mock(): cls = MagicMock() @@ -124,3 +208,114 @@ def test_save_mixin_load_warning(get_version_mock, save_version, load_version, t ): get_version_mock.return_value = load_version _ = DummyNN.load(path) + + +@pytest.mark.parametrize( + "mixin_constructor, call_params", + [ + (NonPredictionIntervalContextIgnorantDummyModel, {}), + (NonPredictionIntervalContextRequiredDummyModel, {"prediction_size": 10}), + (PredictionIntervalContextIgnorantDummyModel, {}), + (PredictionIntervalContextRequiredDummyModel, {"prediction_size": 10}), + ], +) +@pytest.mark.parametrize("method_name, expected_target", [("forecast", 100), ("predict", 200)]) +def test_model_mixins_predict_without_target_components( + example_tsds, + mixin_constructor, + call_params, + method_name, + expected_target, + expected_columns=["timestamp", "segment", "target"], +): + mixin = mixin_constructor() + to_call = getattr(mixin, method_name) + forecast = to_call(ts=example_tsds, return_components=False, **call_params).to_pandas(flatten=True) + assert sorted(forecast.columns) == sorted(expected_columns) + assert (forecast["target"] == expected_target).all() + + +@pytest.mark.parametrize( + "mixin_constructor, call_params", + [ + (NonPredictionIntervalContextIgnorantDummyModel, {}), + (NonPredictionIntervalContextRequiredDummyModel, {"prediction_size": 10}), + (PredictionIntervalContextIgnorantDummyModel, {}), + (PredictionIntervalContextRequiredDummyModel, {"prediction_size": 10}), + ], +) +@pytest.mark.parametrize( + "method_name, expected_target, expected_component_a, expected_component_b", + [("forecast", 100, 10, 90), ("predict", 200, 20, 180)], +) +def test_model_mixins_prediction_methods_with_target_components( + example_tsds, + mixin_constructor, + call_params, + method_name, + expected_target, + expected_component_a, + expected_component_b, + expected_columns=["timestamp", "segment", "target", "target_component_a", "target_component_b"], +): + mixin = mixin_constructor() + to_call = getattr(mixin, method_name) + forecast = to_call(ts=example_tsds, return_components=True, **call_params).to_pandas(flatten=True) + assert sorted(forecast.columns) == sorted(expected_columns) + assert (forecast["target"] == expected_target).all() + assert (forecast["target_component_a"] == expected_component_a).all() + assert (forecast["target_component_b"] == expected_component_b).all() + + +@pytest.mark.parametrize("mixin_constructor", [PerSegmentModelMixin, MultiSegmentModelMixin]) +@pytest.mark.parametrize( + "method_name, adapter_constructor, expected_target", + [ + ("_forecast", DummyForecastPredictAdapter, 100), + ("_predict", DummyForecastPredictAdapter, 200), + ("_forecast", DummyPredictAdapter, 200), + ("_predict", DummyPredictAdapter, 200), + ], +) +def test_mixin_implementations_prediction_methods( + example_tsds, + mixin_constructor, + method_name, + adapter_constructor, + expected_target, + expected_columns=["timestamp", "segment", "target"], +): + mixin = mixin_constructor(base_model=adapter_constructor()) + mixin = mixin.fit(ts=example_tsds) + to_call = getattr(mixin, method_name) + forecast = to_call(ts=example_tsds).to_pandas(flatten=True) + assert sorted(forecast.columns) == sorted(expected_columns) + assert (forecast["target"] == expected_target).all() + + +@pytest.mark.parametrize("mixin_constructor", [PerSegmentModelMixin, MultiSegmentModelMixin]) +@pytest.mark.parametrize( + "method_name, adapter_constructor, expected_component_a, expected_component_b", + [ + ("_forecast_components", DummyForecastPredictAdapter, 10, 90), + ("_predict_components", DummyForecastPredictAdapter, 20, 180), + ("_forecast_components", DummyPredictAdapter, 20, 180), + ("_predict_components", DummyPredictAdapter, 20, 180), + ], +) +def test_mixin_implementations_prediction_components_methods( + example_tsds, + mixin_constructor, + method_name, + adapter_constructor, + expected_component_a, + expected_component_b, + expected_columns=["timestamp", "segment", "target_component_a", "target_component_b"], +): + mixin = mixin_constructor(base_model=adapter_constructor()) + mixin = mixin.fit(ts=example_tsds) + to_call = getattr(mixin, method_name) + forecast = TSDataset.to_flatten(to_call(ts=example_tsds)) + assert sorted(forecast.columns) == sorted(expected_columns) + assert (forecast["target_component_a"] == expected_component_a).all() + assert (forecast["target_component_b"] == expected_component_b).all()