diff --git a/CHANGELOG.md b/CHANGELOG.md index b84970b8f..cb711ae6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - - -- +- Unify errors, warnings and checks in models ([#1312](https://github.com/tinkoff-ai/etna/pull/1312)) - ### Fixed diff --git a/etna/models/deadline_ma.py b/etna/models/deadline_ma.py index 6890587b4..fd32d2ea0 100644 --- a/etna/models/deadline_ma.py +++ b/etna/models/deadline_ma.py @@ -91,6 +91,15 @@ def get_model(self) -> "DeadlineMovingAverageModel": """ return self + def _check_not_used_columns(self, ts: TSDataset): + columns = set(ts.columns.get_level_values("feature")) + columns_not_used = columns.difference({"target"}) + if columns_not_used: + warnings.warn( + message=f"This model doesn't work with exogenous features. " + f"Columns {columns_not_used} won't be used." + ) + def fit(self, ts: TSDataset) -> "DeadlineMovingAverageModel": """Fit model. @@ -109,14 +118,8 @@ def fit(self, ts: TSDataset) -> "DeadlineMovingAverageModel": if freq not in self._freqs_available: raise ValueError(f"Freq {freq} is not supported! Use daily or hourly frequency!") + self._check_not_used_columns(ts) self._freq = freq - - columns = set(ts.columns.get_level_values("feature")) - if columns != {"target"}: - warnings.warn( - message=f"{type(self).__name__} does not work with any exogenous series or features. " - f"It uses only target series for predict/\n " - ) return self @staticmethod diff --git a/etna/models/holt_winters.py b/etna/models/holt_winters.py index 6f9d7aeb5..2e0a7c015 100644 --- a/etna/models/holt_winters.py +++ b/etna/models/holt_winters.py @@ -201,6 +201,15 @@ def __init__( self._last_train_timestamp: Optional[pd.Timestamp] = None self._train_freq: Optional[str] = None + def _check_not_used_columns(self, df: pd.DataFrame): + columns = df.columns + columns_not_used = set(columns).difference({"target", "timestamp"}) + if columns_not_used: + warnings.warn( + message=f"This model doesn't work with exogenous features. " + f"Columns {columns_not_used} won't be used." + ) + def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_HoltWintersAdapter": """ Fit Holt-Winters' model. @@ -217,8 +226,7 @@ def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_HoltWintersAdapter": Fitted model """ self._train_freq = determine_freq(timestamps=df["timestamp"]) - - self._check_df(df) + self._check_not_used_columns(df) targets = df["target"] targets.index = df["timestamp"] @@ -268,21 +276,11 @@ def predict(self, df: pd.DataFrame) -> np.ndarray: """ if self._result is None or self._model is None: raise ValueError("This model is not fitted! Fit the model before calling predict method!") - self._check_df(df) forecast = self._result.predict(start=df["timestamp"].min(), end=df["timestamp"].max()) y_pred = forecast.values return y_pred - def _check_df(self, df: pd.DataFrame): - columns = df.columns - columns_not_used = set(columns).difference({"target", "timestamp"}) - if columns_not_used: - warnings.warn( - message=f"This model does not work with exogenous features and regressors.\n " - f"{columns_not_used} will be dropped" - ) - def get_model(self) -> HoltWintersResultsWrapper: """Get :py:class:`statsmodels.tsa.holtwinters.results.HoltWintersResultsWrapper` model that was fitted inside etna class. @@ -303,7 +301,7 @@ def _check_mul_components(self): if (model.trend is not None and model.trend == "mul") or ( model.seasonal is not None and model.seasonal == "mul" ): - raise ValueError("Forecast decomposition is only supported for additive components!") + raise NotImplementedError("Forecast decomposition is only supported for additive components!") def _rescale_components(self, components: pd.DataFrame) -> pd.DataFrame: """Rescale components when Box-Cox transform used.""" @@ -335,7 +333,10 @@ def forecast_components(self, df: pd.DataFrame) -> pd.DataFrame: raise ValueError("This model is not fitted!") if df["timestamp"].min() <= self._last_train_timestamp: - raise ValueError("To estimate in-sample prediction decomposition use `predict` method.") + raise NotImplementedError( + "This model can't make forecast decomposition on history data! " + "Use method predict for in-sample prediction decomposition." + ) horizon = determine_num_steps( start_timestamp=self._last_train_timestamp, end_timestamp=df["timestamp"].max(), freq=self._train_freq @@ -343,7 +344,6 @@ def forecast_components(self, df: pd.DataFrame) -> pd.DataFrame: horizon_steps = np.arange(1, horizon + 1) self._check_mul_components() - self._check_df(df) level = fit_result.level.values trend = fit_result.trend.values @@ -404,10 +404,12 @@ def predict_components(self, df: pd.DataFrame) -> pd.DataFrame: raise ValueError("This model is not fitted!") if df["timestamp"].min() < self._first_train_timestamp or df["timestamp"].max() > self._last_train_timestamp: - raise ValueError("To estimate out-of-sample prediction decomposition use `forecast` method.") + raise NotImplementedError( + "This model can't make prediction decomposition on future out-of-sample data! " + "Use method forecast for future out-of-sample prediction decomposition." + ) self._check_mul_components() - self._check_df(df) level = fit_result.level.values trend = fit_result.trend.values diff --git a/etna/models/nn/utils.py b/etna/models/nn/utils.py index be61c57c9..37ddc4415 100644 --- a/etna/models/nn/utils.py +++ b/etna/models/nn/utils.py @@ -270,12 +270,13 @@ def _is_prediction_with_gap(self, ts: TSDataset, horizon: int) -> bool: def _make_target_prediction(self, ts: TSDataset, horizon: int) -> Tuple[TSDataset, DataLoader]: if self._is_in_sample_prediction(ts=ts, horizon=horizon): raise NotImplementedError( - "It is not possible to make in-sample predictions with DeepAR model! " - "In-sample predictions aren't supported by current implementation." + "This model can't make forecast on history data! " + "In-sample forecast isn't supported by current implementation." ) elif self._is_prediction_with_gap(ts=ts, horizon=horizon): first_prediction_timestamp = self._get_first_prediction_timestamp(ts=ts, horizon=horizon) raise NotImplementedError( + "This model can't make forecast on out-of-sample data that goes after training data with a gap! " "You can only forecast from the next point after the last one in the training dataset: " f"last train timestamp: {self._last_train_timestamp}, first prediction timestamp is {first_prediction_timestamp}" ) diff --git a/etna/models/prophet.py b/etna/models/prophet.py index 0e2d8a3fd..2a7f720c1 100644 --- a/etna/models/prophet.py +++ b/etna/models/prophet.py @@ -1,3 +1,4 @@ +import warnings from copy import deepcopy from datetime import datetime from typing import Dict @@ -98,6 +99,47 @@ def _create_model(self) -> "Prophet": return model + def _check_not_used_columns(self, df: pd.DataFrame): + if self.regressor_columns is None: + raise ValueError("Something went wrong, regressor_columns is None!") + + columns_not_used = [col for col in df.columns if col not in ["target", "timestamp"] + self.regressor_columns] + if columns_not_used: + warnings.warn( + message=f"This model doesn't work with exogenous features unknown in future. " + f"Columns {columns_not_used} won't be used." + ) + + def _select_regressors(self, df: pd.DataFrame) -> Optional[pd.DataFrame]: + """Select data with regressors. + + During fit there can't be regressors with NaNs, they are removed at higher level. + Look at the issue: https://github.com/tinkoff-ai/etna/issues/557 + + During prediction without validation NaNs in regressors lead to exception from the underlying model. + + This model requires data to be in numeric dtype. + """ + if self.regressor_columns is None: + raise ValueError("Something went wrong, regressor_columns is None!") + + regressors_with_nans = [regressor for regressor in self.regressor_columns if df[regressor].isna().sum() > 0] + if regressors_with_nans: + raise ValueError( + f"Regressors {regressors_with_nans} contain NaN values. " + "Try to lower horizon value, or drop these regressors." + ) + + if self.regressor_columns: + try: + result = df[self.regressor_columns].apply(pd.to_numeric) + except ValueError as e: + raise ValueError(f"Only convertible to numeric features are allowed! Error: {str(e)}") + else: + result = None + + return result + def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_ProphetAdapter": """ Fits a Prophet model. @@ -110,6 +152,8 @@ def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_ProphetAdapter": List of the columns with regressors """ self.regressor_columns = regressors + self._check_not_used_columns(df) + prophet_df = self._prepare_prophet_df(df=df) for regressor in self.regressor_columns: if regressor not in self.predefined_regressors_names: @@ -159,7 +203,11 @@ def _prepare_prophet_df(self, df: pd.DataFrame) -> pd.DataFrame: prophet_df = pd.DataFrame() prophet_df["y"] = df["target"] prophet_df["ds"] = df["timestamp"] - prophet_df[self.regressor_columns] = df[self.regressor_columns] + + regressors_data = self._select_regressors(df) + if regressors_data is not None: + prophet_df[self.regressor_columns] = regressors_data[self.regressor_columns] + return prophet_df @staticmethod diff --git a/etna/models/sarimax.py b/etna/models/sarimax.py index e9bce8953..9de9e5f67 100644 --- a/etna/models/sarimax.py +++ b/etna/models/sarimax.py @@ -61,9 +61,7 @@ def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_SARIMAXBaseAdapter": Fitted model """ self.regressor_columns = regressors - - self._encode_categoricals(df) - self._check_df(df) + self._check_not_used_columns(df) exog_train = self._select_regressors(df) self._fit_results = self._get_fit_results(endog=df["target"], exog=exog_train) @@ -81,11 +79,8 @@ def _make_prediction( if self._fit_results is None: raise ValueError("Model is not fitted! Fit the model before calling predict method!") - horizon = len(df) - self._encode_categoricals(df) - self._check_df(df, horizon) - exog_future = self._select_regressors(df) + start_timestamp = df["timestamp"].min() end_timestamp = df["timestamp"].max() # determine index of start_timestamp if counting from first timestamp of train @@ -174,41 +169,48 @@ def predict(self, df: pd.DataFrame, prediction_interval: bool, quantiles: Sequen def _get_fit_results(self, endog: pd.Series, exog: pd.DataFrame) -> SARIMAXResultsWrapper: pass - def _check_df(self, df: pd.DataFrame, horizon: Optional[int] = None): + def _check_not_used_columns(self, df: pd.DataFrame): if self.regressor_columns is None: raise ValueError("Something went wrong, regressor_columns is None!") - column_to_drop = [col for col in df.columns if col not in ["target", "timestamp"] + self.regressor_columns] - if column_to_drop: + + columns_not_used = [col for col in df.columns if col not in ["target", "timestamp"] + self.regressor_columns] + if columns_not_used: warnings.warn( - message=f"SARIMAX model does not work with exogenous features (features unknown in future).\n " - f"{column_to_drop} will be dropped" + message=f"This model doesn't work with exogenous features unknown in future. " + f"Columns {columns_not_used} won't be used." ) - if horizon: - short_regressors = [regressor for regressor in self.regressor_columns if df[regressor].count() < horizon] - if short_regressors: - raise ValueError( - f"Regressors {short_regressors} are too short for chosen horizon value.\n " - "Try lower horizon value, or drop this regressors." - ) def _select_regressors(self, df: pd.DataFrame) -> Optional[pd.DataFrame]: - if self.regressor_columns: - exog_future = df[self.regressor_columns] - exog_future.index = df["timestamp"] - else: - exog_future = None - return exog_future - - def _encode_categoricals(self, df: pd.DataFrame) -> None: - categorical_cols = df.select_dtypes(include=["category"]).columns.tolist() - try: - df.loc[:, categorical_cols] = df[categorical_cols].astype(int) - except ValueError: + """Select data with regressors. + + During fit there can't be regressors with NaNs, they are removed at higher level. + Look at the issue: https://github.com/tinkoff-ai/etna/issues/557 + + During prediction without validation NaNs in regressors lead to exception from the underlying model. + + This model requires data to be in numeric dtype, but doesn't support boolean, so it was decided to use float. + """ + if self.regressor_columns is None: + raise ValueError("Something went wrong, regressor_columns is None!") + + regressors_with_nans = [regressor for regressor in self.regressor_columns if df[regressor].isna().sum() > 0] + if regressors_with_nans: raise ValueError( - f"Categorical columns {categorical_cols} can not be converted to int.\n " - "Try to encode this columns manually." + f"Regressors {regressors_with_nans} contain NaN values. " + "Try to lower horizon value, or drop these regressors." ) + if self.regressor_columns: + try: + result = df[self.regressor_columns].astype(float) + except ValueError as e: + raise ValueError(f"Only convertible to float features are allowed! Error: {str(e)}") + result.index = df["timestamp"] + else: + result = None + + return result + def get_model(self) -> SARIMAXResultsWrapper: """Get :py:class:`statsmodels.tsa.statespace.sarimax.SARIMAXResultsWrapper` that is used inside etna class. @@ -303,14 +305,22 @@ def predict_components(self, df: pd.DataFrame) -> pd.DataFrame: : dataframe with prediction components """ - if df["timestamp"].min() < self._first_train_timestamp or df["timestamp"].max() > self._last_train_timestamp: - raise ValueError("To estimate out-of-sample prediction decomposition use `forecast` method.") + if self._fit_results is None: + raise ValueError("Model is not fitted! Fit the model before estimating forecast components!") + + if self._last_train_timestamp < df["timestamp"].max() or self._first_train_timestamp > df["timestamp"].min(): + raise NotImplementedError( + "This model can't make prediction decomposition on future out-of-sample data! " + "Use method forecast for future out-of-sample prediction decomposition." + ) fit_results = self._fit_results model = fit_results.model if model.hamilton_representation: - raise ValueError("Prediction decomposition is not implemented for Hamilton representation of ARMA!") + raise NotImplementedError( + "Prediction decomposition is not implemented for Hamilton representation of ARMA!" + ) state = fit_results.predicted_state[:, :-1] @@ -345,21 +355,41 @@ def forecast_components(self, df: pd.DataFrame) -> pd.DataFrame: : dataframe with forecast components """ - if df["timestamp"].min() <= self._last_train_timestamp: - raise ValueError("To estimate in-sample prediction decomposition use `predict` method.") + if self._fit_results is None: + raise ValueError("Model is not fitted! Fit the model before estimating forecast components!") + + start_timestamp = df["timestamp"].min() + end_timestamp = df["timestamp"].max() - horizon = determine_num_steps( - start_timestamp=self._last_train_timestamp, end_timestamp=df["timestamp"].max(), freq=self._freq + if start_timestamp < self._last_train_timestamp: + raise NotImplementedError( + "This model can't make forecast decomposition on history data! " + "Use method predict for in-sample prediction decomposition." + ) + + # determine index of start_timestamp if counting from last timestamp of train + start_idx = determine_num_steps( + start_timestamp=self._last_train_timestamp, end_timestamp=start_timestamp, freq=self._freq # type: ignore + ) + # determine index of end_timestamp if counting from last timestamp of train + end_idx = determine_num_steps( + start_timestamp=self._last_train_timestamp, end_timestamp=end_timestamp, freq=self._freq # type: ignore ) + if start_idx > 1: + raise NotImplementedError( + "This model can't make forecast decomposition on out-of-sample data that goes after training data with a gap! " + "You can only forecast from the next point after the last one in the training dataset." + ) + + horizon = end_idx fit_results = self._fit_results model = fit_results.model if model.hamilton_representation: - raise ValueError("Prediction decomposition is not implemented for Hamilton representation of ARMA!") - - self._encode_categoricals(df) - self._check_df(df, horizon) + raise NotImplementedError( + "Prediction decomposition is not implemented for Hamilton representation of ARMA!" + ) exog_future = self._select_regressors(df) @@ -389,7 +419,7 @@ def forecast_components(self, df: pd.DataFrame) -> pd.DataFrame: class _SARIMAXAdapter(_SARIMAXBaseAdapter): """ - Class for holding Sarimax model. + Class for holding SARIMAX model. Notes ----- @@ -509,6 +539,8 @@ def __init__( If 'raise', an error is raised. Default is 'none'. validate_specification: If True, validation of hyperparameters is performed. + **kwargs: + Additional parameters for :py:class:`statsmodels.tsa.sarimax.SARIMAX`. """ self.order = order self.seasonal_order = seasonal_order @@ -563,7 +595,7 @@ class SARIMAXModel( PerSegmentModelMixin, PredictionIntervalContextIgnorantModelMixin, PredictionIntervalContextIgnorantAbstractModel ): """ - Class for holding Sarimax model. + Class for holding SARIMAX model. Method ``predict`` can use true target values only on train data on future data autoregression forecasting will be made even if targets are known. @@ -687,6 +719,8 @@ def __init__( If 'raise', an error is raised. Default is 'none'. validate_specification: If True, validation of hyperparameters is performed. + **kwargs: + Additional parameters for :py:class:`statsmodels.tsa.sarimax.SARIMAX`. """ self.order = order self.seasonal_order = seasonal_order diff --git a/etna/models/seasonal_ma.py b/etna/models/seasonal_ma.py index a8277604b..8bc383291 100644 --- a/etna/models/seasonal_ma.py +++ b/etna/models/seasonal_ma.py @@ -57,6 +57,15 @@ def get_model(self) -> "SeasonalMovingAverageModel": """ return self + def _check_not_used_columns(self, ts: TSDataset): + columns = set(ts.columns.get_level_values("feature")) + columns_not_used = columns.difference({"target"}) + if columns_not_used: + warnings.warn( + message=f"This model doesn't work with exogenous features. " + f"Columns {columns_not_used} won't be used." + ) + def fit(self, ts: TSDataset) -> "SeasonalMovingAverageModel": """Fit model. @@ -72,12 +81,7 @@ def fit(self, ts: TSDataset) -> "SeasonalMovingAverageModel": : Model after fit """ - columns = set(ts.columns.get_level_values("feature")) - if columns != {"target"}: - warnings.warn( - message=f"{type(self).__name__} does not work with any exogenous series or features. " - f"It uses only target series for predict/\n " - ) + self._check_not_used_columns(ts) return self def _validate_context(self, df: pd.DataFrame, prediction_size: int): diff --git a/etna/models/sklearn.py b/etna/models/sklearn.py index e2388cd02..25d35f43f 100644 --- a/etna/models/sklearn.py +++ b/etna/models/sklearn.py @@ -1,3 +1,4 @@ +import warnings from typing import List from typing import Optional @@ -17,6 +18,41 @@ def __init__(self, regressor: RegressorMixin): self.model = regressor self.regressor_columns: Optional[List[str]] = None + def _check_not_used_columns(self, df: pd.DataFrame): + if self.regressor_columns is None: + raise ValueError("Something went wrong, regressor_columns is None!") + + columns_not_used = [col for col in df.columns if col not in ["target", "timestamp"] + self.regressor_columns] + if columns_not_used: + warnings.warn( + message=f"This model doesn't work with exogenous features unknown in future. " + f"Columns {columns_not_used} won't be used." + ) + + def _select_regressors(self, df: pd.DataFrame) -> Optional[pd.DataFrame]: + """Select data with regressors. + + During fit there can't be regressors with NaNs, they are removed at higher level. + Look at the issue: https://github.com/tinkoff-ai/etna/issues/557 + + During prediction without validation NaNs in regressors can lead to exception from the underlying model, + but it depends on the model, so it was decided to not validate this. + + This model requires data to be in numeric dtype. + """ + if self.regressor_columns is None: + raise ValueError("Something went wrong, regressor_columns is None!") + + if self.regressor_columns: + try: + result = df[self.regressor_columns].apply(pd.to_numeric) + except ValueError as e: + raise ValueError(f"Only convertible to numeric features are allowed! Error: {str(e)}") + else: + raise ValueError("There are not features for fitting the model!") + + return result + def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_SklearnAdapter": """ Fit Sklearn model. @@ -34,10 +70,8 @@ def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_SklearnAdapter": Fitted model """ self.regressor_columns = regressors - try: - features = df[self.regressor_columns].apply(pd.to_numeric) - except ValueError: - raise ValueError("Only convertible to numeric features are accepted!") + self._check_not_used_columns(df) + features = self._select_regressors(df) target = df["target"] self.model.fit(features, target) return self @@ -56,10 +90,7 @@ def predict(self, df: pd.DataFrame) -> np.ndarray: : Array with predictions """ - try: - features = df[self.regressor_columns].apply(pd.to_numeric) - except ValueError: - raise ValueError("Only convertible to numeric features are accepted!") + features = self._select_regressors(df) pred = self.model.predict(features) return pred diff --git a/etna/models/statsforecast.py b/etna/models/statsforecast.py index e38b0213d..2683553dd 100644 --- a/etna/models/statsforecast.py +++ b/etna/models/statsforecast.py @@ -49,39 +49,46 @@ def __init__(self, model: StatsForecastModel, support_prediction_intervals: bool self._model = model self._support_prediction_intervals = support_prediction_intervals - def _encode_categoricals(self, df: pd.DataFrame) -> None: - categorical_cols = df.select_dtypes(include=["category"]).columns.tolist() - try: - df.loc[:, categorical_cols] = df[categorical_cols].astype(int) - except ValueError: - raise ValueError( - f"Categorical columns {categorical_cols} can not be converted to int.\n " - "Try to encode this columns manually." - ) - - def _check_df(self, df: pd.DataFrame, horizon: Optional[int] = None): + def _check_not_used_columns(self, df: pd.DataFrame): if self.regressor_columns is None: raise ValueError("Something went wrong, regressor_columns is None!") - column_to_drop = [col for col in df.columns if col not in ["target", "timestamp"] + self.regressor_columns] - if column_to_drop: + + columns_not_used = [col for col in df.columns if col not in ["target", "timestamp"] + self.regressor_columns] + if columns_not_used: warnings.warn( - message=f"Model from statsforecast does not work with exogenous features (features unknown in future).\n " - f"{column_to_drop} will be dropped" + message=f"This model doesn't work with exogenous features unknown in future. " + f"Columns {columns_not_used} won't be used." ) - if horizon: - short_regressors = [regressor for regressor in self.regressor_columns if df[regressor].count() < horizon] - if short_regressors: - raise ValueError( - f"Regressors {short_regressors} are too short for chosen horizon value.\n " - "Try lower horizon value, or drop this regressors." - ) def _select_regressors(self, df: pd.DataFrame) -> Optional[np.ndarray]: + """Select data with regressors. + + During fit there can't be regressors with NaNs, they are removed at higher level. + Look at the issue: https://github.com/tinkoff-ai/etna/issues/557 + + During prediction without validation NaNs in regressors lead to NaNs in the answer. + + This model requires data to be in float dtype. + """ + if self.regressor_columns is None: + raise ValueError("Something went wrong, regressor_columns is None!") + + regressors_with_nans = [regressor for regressor in self.regressor_columns if df[regressor].isna().sum() > 0] + if regressors_with_nans: + raise ValueError( + f"Regressors {regressors_with_nans} contain NaN values. " + "Try to lower horizon value, or drop these regressors." + ) + if self.regressor_columns: - exog_future = df[self.regressor_columns].values.astype(float) + try: + result = df[self.regressor_columns].values.astype(float) + except ValueError as e: + raise ValueError(f"Only convertible to float features are allowed! Error: {str(e)}") else: - exog_future = None - return exog_future + result = None + + return result def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_StatsForecastBaseAdapter": """Fit statsforecast adapter. @@ -99,12 +106,11 @@ def fit(self, df: pd.DataFrame, regressors: List[str]) -> "_StatsForecastBaseAda Fitted adapter """ self.regressor_columns = regressors - - self._encode_categoricals(df) - self._check_df(df) + self._check_not_used_columns(df) endog_data = df["target"].values exog_data = self._select_regressors(df) + self._model.fit(y=endog_data, X=exog_data) self._freq = determine_freq(timestamps=df["timestamp"]) @@ -137,11 +143,6 @@ def forecast( if self._freq is None: raise ValueError("Model is not fitted! Fit the model before calling predict method!") - horizon = len(df) - self._encode_categoricals(df) - self._check_df(df, horizon) - - exog_data = self._select_regressors(df) start_timestamp = df["timestamp"].min() end_timestamp = df["timestamp"].max() @@ -166,6 +167,7 @@ def forecast( ) h = end_idx + exog_data = self._select_regressors(df) if prediction_interval and self._support_prediction_intervals: levels = [] for quantile in quantiles: @@ -214,10 +216,6 @@ def predict( if self._freq is None: raise ValueError("Model is not fitted! Fit the model before calling predict method!") - horizon = len(df) - self._encode_categoricals(df) - self._check_df(df, horizon) - start_timestamp = df["timestamp"].min() end_timestamp = df["timestamp"].max() @@ -525,10 +523,6 @@ class StatsForecastAutoARIMAModel( Class for holding :py:class:`statsforecast.models.AutoARIMA`. `Documentation for the underlying model `_. - - Method ``forecast`` only works on ouf-of-sample data that goes right after training data. - - Method ``predict`` only works on in-sample data. """ def __init__( @@ -627,10 +621,6 @@ class StatsForecastARIMAModel( Class for holding :py:class:`statsforecast.models.ARIMA`. `Documentation for the underlying model `_. - - Method ``forecast`` only works on ouf-of-sample data that goes right after training data. - - Method ``predict`` only works on in-sample data. """ def __init__( @@ -706,10 +696,6 @@ class StatsForecastAutoThetaModel( Class for holding :py:class:`statsforecast.models.AutoTheta`. `Documentation for the underlying model `_. - - Method ``forecast`` only works on ouf-of-sample data that goes right after training data. - - Method ``predict`` only works on in-sample data. """ def __init__( @@ -748,10 +734,6 @@ class StatsForecastAutoCESModel( Class for holding :py:class:`statsforecast.models.AutoCES`. `Documentation for the underlying model `_. - - Method ``forecast`` only works on ouf-of-sample data that goes right after training data. - - Method ``predict`` only works on in-sample data. """ def __init__(self, season_length: int = 1, model: str = "Z"): @@ -778,10 +760,6 @@ class StatsForecastAutoETSModel( Class for holding :py:class:`statsforecast.models.AutoETS`. `Documentation for the underlying model `_. - - Method ``forecast`` only works on ouf-of-sample data that goes right after training data. - - Method ``predict`` only works on in-sample data. """ def __init__(self, season_length: int = 1, model: str = "ZZZ", damped: Optional[bool] = None): diff --git a/etna/models/tbats.py b/etna/models/tbats.py index 0acc5d61a..a35ff4970 100644 --- a/etna/models/tbats.py +++ b/etna/models/tbats.py @@ -28,8 +28,18 @@ def __init__(self, model: Estimator): self._last_train_timestamp = None self._freq: Optional[str] = None + def _check_not_used_columns(self, df: pd.DataFrame): + columns = df.columns + columns_not_used = set(columns).difference({"target", "timestamp"}) + if columns_not_used: + warn( + message=f"This model doesn't work with exogenous features. " + f"Columns {columns_not_used} won't be used." + ) + def fit(self, df: pd.DataFrame, regressors: Iterable[str]): self._freq = determine_freq(timestamps=df["timestamp"]) + self._check_not_used_columns(df) target = df["target"] self._fitted_model = self._model.fit(target) @@ -74,7 +84,10 @@ def predict(self, df: pd.DataFrame, prediction_interval: bool, quantiles: Iterab ) if not (set(df["timestamp"]) <= set(train_timestamp)): - raise NotImplementedError("Method predict isn't currently implemented for out-of-sample prediction!") + raise NotImplementedError( + "This model can't make predict on future out-of-sample data! " + "Use forecast method for this type of prediction." + ) y_pred = pd.DataFrame() y_pred["target"] = self._fitted_model.y_hat @@ -125,7 +138,10 @@ def forecast_components(self, df: pd.DataFrame) -> pd.DataFrame: raise ValueError("Model is not fitted! Fit the model before estimating forecast components!") if df["timestamp"].min() <= self._last_train_timestamp: - raise ValueError("To estimate in-sample prediction decomposition use `predict` method.") + raise NotImplementedError( + "This model can't make forecast decomposition on history data! " + "Use method predict for in-sample prediction decomposition." + ) self._check_components() @@ -156,7 +172,10 @@ def predict_components(self, df: pd.DataFrame) -> pd.DataFrame: raise ValueError("Model is not fitted! Fit the model before estimating forecast components!") if self._last_train_timestamp < df["timestamp"].max() or self._first_train_timestamp > df["timestamp"].min(): - raise ValueError("To estimate out-of-sample prediction decomposition use `forecast` method.") + raise NotImplementedError( + "This model can't make prediction decomposition on future out-of-sample data! " + "Use method forecast for future out-of-sample prediction decomposition." + ) self._check_components() @@ -179,7 +198,7 @@ def _get_steps_to_forecast(self, df: pd.DataFrame) -> int: if df["timestamp"].min() <= self._last_train_timestamp: raise NotImplementedError( - "It is not possible to make in-sample predictions using current method implementation!" + "This model can't make forecast on history data! Use method predict for in-sample prediction." ) steps_to_forecast = determine_num_steps( diff --git a/tests/test_models/conftest.py b/tests/test_models/conftest.py index 5ee3368d8..129e1a2be 100644 --- a/tests/test_models/conftest.py +++ b/tests/test_models/conftest.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import numpy as np import pytest @@ -29,3 +31,43 @@ def dfs_w_exog(): train = df.iloc[:-5] test = df.iloc[-5:] return train, test + + +@pytest.fixture +def ts_with_non_convertable_category_regressor(example_tsds) -> TSDataset: + ts = example_tsds + df = ts.to_pandas(flatten=True) + df_exog = deepcopy(df) + df_exog["cat"] = "a" + df_exog["cat"] = df_exog["cat"].astype("category") + df_exog.drop(columns=["target"], inplace=True) + df_wide = TSDataset.to_dataset(df).iloc[:-10] + df_exog_wide = TSDataset.to_dataset(df_exog) + ts = TSDataset(df=df_wide, df_exog=df_exog_wide, freq=ts.freq, known_future="all") + return ts + + +@pytest.fixture +def ts_with_short_regressor(example_tsds) -> TSDataset: + ts = example_tsds + df = ts.to_pandas(flatten=True) + df_exog = deepcopy(df) + df_exog["exog"] = 1 + df_exog.drop(columns=["target"], inplace=True) + df_wide = TSDataset.to_dataset(df).iloc[:-3] + df_exog_wide = TSDataset.to_dataset(df_exog) + ts = TSDataset(df=df_wide, df_exog=df_exog_wide, freq=ts.freq, known_future="all") + return ts + + +@pytest.fixture +def ts_with_non_regressor_exog(example_tsds) -> TSDataset: + ts = example_tsds + df = ts.to_pandas(flatten=True) + df_exog = deepcopy(df) + df_exog["exog"] = 1 + df_exog.drop(columns=["target"], inplace=True) + df_wide = TSDataset.to_dataset(df) + df_exog_wide = TSDataset.to_dataset(df_exog) + ts = TSDataset(df=df_wide, df_exog=df_exog_wide, freq=ts.freq) + return ts diff --git a/tests/test_models/test_autoarima_model.py b/tests/test_models/test_autoarima_model.py index 9b5800ecc..736c17238 100644 --- a/tests/test_models/test_autoarima_model.py +++ b/tests/test_models/test_autoarima_model.py @@ -15,7 +15,7 @@ def _check_forecast(ts, model, horizon): res = model.forecast(future_ts) res = res.to_pandas(flatten=True) - assert not res.isnull().values.any() + assert not res["target"].isnull().values.any() assert len(res) == horizon * 2 @@ -24,13 +24,22 @@ def _check_predict(ts, model): res = model.predict(ts) res = res.to_pandas(flatten=True) - assert not res.isnull().values.any() + assert not res["target"].isnull().values.any() assert len(res) == len(ts.index) * 2 -def test_prediction(example_tsds): - _check_forecast(ts=deepcopy(example_tsds), model=AutoARIMAModel(), horizon=7) - _check_predict(ts=deepcopy(example_tsds), model=AutoARIMAModel()) +def test_fit_with_exogs_warning(ts_with_non_regressor_exog): + ts = ts_with_non_regressor_exog + model = AutoARIMAModel() + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features unknown in future"): + model.fit(ts) + + +def test_fit_str_category_fail(ts_with_non_convertable_category_regressor): + model = AutoARIMAModel() + ts = ts_with_non_convertable_category_regressor + with pytest.raises(ValueError, match="Only convertible to float features are allowed"): + model.fit(ts) def test_save_regressors_on_fit(example_reg_tsds): @@ -50,11 +59,22 @@ def test_select_regressors_correctly(example_reg_tsds): assert (segment_regressors == segment_regressors_expected).all().all() +def test_prediction(example_tsds): + _check_forecast(ts=deepcopy(example_tsds), model=AutoARIMAModel(), horizon=7) + _check_predict(ts=deepcopy(example_tsds), model=AutoARIMAModel()) + + def test_prediction_with_reg(example_reg_tsds): _check_forecast(ts=deepcopy(example_reg_tsds), model=AutoARIMAModel(), horizon=7) _check_predict(ts=deepcopy(example_reg_tsds), model=AutoARIMAModel()) +def test_forecast_with_short_regressors_fail(ts_with_short_regressor): + ts = ts_with_short_regressor + with pytest.raises(ValueError, match="Regressors .* contain NaN values"): + _check_forecast(ts=deepcopy(ts), model=AutoARIMAModel(), horizon=20) + + def test_prediction_with_params(example_reg_tsds): horizon = 7 model = AutoARIMAModel( @@ -105,7 +125,7 @@ def test_forecast_prediction_interval_infuture(example_tsds): @pytest.mark.parametrize("method_name", ["forecast", "predict"]) -def test_prediction_raise_error_if_not_fitted_autoarima(example_tsds, method_name): +def test_prediction_raise_error_if_not_fitted(example_tsds, method_name): """Test that AutoARIMA raise error when calling prediction without being fit.""" model = AutoARIMAModel() with pytest.raises(ValueError, match="model is not fitted!"): @@ -113,7 +133,7 @@ def test_prediction_raise_error_if_not_fitted_autoarima(example_tsds, method_nam _ = method(ts=example_tsds) -def test_get_model_before_training_autoarima(): +def test_get_model_before_training(): """Check that get_model method throws an error if per-segment model is not fitted yet.""" etna_model = AutoARIMAModel() with pytest.raises(ValueError, match="Can not get the dict with base models, the model is not fitted!"): diff --git a/tests/test_models/test_holt_winters_model.py b/tests/test_models/test_holt_winters_model.py index 8fb2c79f6..e0c724a9f 100644 --- a/tests/test_models/test_holt_winters_model.py +++ b/tests/test_models/test_holt_winters_model.py @@ -33,16 +33,11 @@ def const_ts(): SimpleExpSmoothingModel(), ], ) -def test_holt_winters_simple(model, example_tsds): - """Test that Holt-Winters' models make predictions in simple case.""" - horizon = 7 - model.fit(example_tsds) - future_ts = example_tsds.make_future(future_steps=horizon) - res = model.forecast(future_ts) - res = res.to_pandas(flatten=True) - - assert not res.isnull().values.any() - assert len(res) == 14 +def test_holt_winters_fit_with_exog_warning(model, example_reg_tsds): + """Test that Holt-Winters' models fits with exog with warning.""" + ts = example_reg_tsds + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features"): + model.fit(ts) @pytest.mark.parametrize( @@ -53,13 +48,12 @@ def test_holt_winters_simple(model, example_tsds): SimpleExpSmoothingModel(), ], ) -def test_holt_winters_with_exog_warning(model, example_reg_tsds): - """Test that Holt-Winters' models make predictions with exog with warning.""" +def test_holt_winters_simple(model, example_tsds): + """Test that Holt-Winters' models make predictions in simple case.""" horizon = 7 - model.fit(example_reg_tsds) - future_ts = example_reg_tsds.make_future(future_steps=horizon) - with pytest.warns(UserWarning, match="This model does not work with exogenous features and regressors"): - res = model.forecast(future_ts) + model.fit(example_tsds) + future_ts = example_tsds.make_future(future_steps=horizon) + res = model.forecast(future_ts) res = res.to_pandas(flatten=True) assert not res.isnull().values.any() @@ -213,7 +207,7 @@ def test_check_mul_components(seasonal_dfs, trend, seasonal, components_method_n components_method = getattr(model, components_method_name) pred_df = test if use_future else train - with pytest.raises(ValueError, match="Forecast decomposition is only supported for additive components!"): + with pytest.raises(NotImplementedError, match="Forecast decomposition is only supported for additive components!"): components_method(df=pred_df) @@ -299,7 +293,7 @@ def test_forecast_decompose_timestamp_error(seasonal_dfs): model = _HoltWintersAdapter() model.fit(train, []) - with pytest.raises(ValueError, match="To estimate in-sample prediction decomposition use `predict` method."): + with pytest.raises(NotImplementedError, match="This model can't make forecast decomposition on history data."): model.forecast_components(df=train) @@ -315,7 +309,9 @@ def test_predict_decompose_timestamp_error(outliers_df, train_slice, decompose_s model = _HoltWintersAdapter() model.fit(outliers_df.iloc[train_slice], []) - with pytest.raises(ValueError, match="To estimate out-of-sample prediction decomposition use `forecast` method."): + with pytest.raises( + NotImplementedError, match="This model can't make prediction decomposition on future out-of-sample data" + ): model.predict_components(df=outliers_df.iloc[decompose_slice]) diff --git a/tests/test_models/test_inference/test_forecast.py b/tests/test_models/test_inference/test_forecast.py index d4ace18da..9c810e8da 100644 --- a/tests/test_models/test_inference/test_forecast.py +++ b/tests/test_models/test_inference/test_forecast.py @@ -149,12 +149,17 @@ def test_forecast_in_sample_full_no_target_failed_nans_nn(self, model, transform with pytest.raises(ValueError, match="There are NaNs in features"): self._test_forecast_in_sample_full_no_target(example_tsds, model, transforms) - @to_be_fixed(raises=NotImplementedError, match="It is not possible to make in-sample predictions") + @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") @pytest.mark.parametrize( "model, transforms", [ (BATSModel(use_trend=True), []), (TBATSModel(use_trend=True), []), + (StatsForecastARIMAModel(), []), + (StatsForecastAutoARIMAModel(), []), + (StatsForecastAutoCESModel(), []), + (StatsForecastAutoETSModel(), []), + (StatsForecastAutoThetaModel(), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -190,22 +195,6 @@ def test_forecast_in_sample_full_no_target_failed_nans_nn(self, model, transform def test_forecast_in_sample_full_no_target_failed_not_implemented_in_sample(self, model, transforms, example_tsds): self._test_forecast_in_sample_full_no_target(example_tsds, model, transforms) - @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") - @pytest.mark.parametrize( - "model, transforms", - [ - (StatsForecastARIMAModel(), []), - (StatsForecastAutoARIMAModel(), []), - (StatsForecastAutoCESModel(), []), - (StatsForecastAutoETSModel(), []), - (StatsForecastAutoThetaModel(), []), - ], - ) - def test_forecast_in_sample_full_no_target_failed_not_implemented_in_sample_2( - self, model, transforms, example_tsds - ): - self._test_forecast_in_sample_full_no_target(example_tsds, model, transforms) - class TestForecastInSampleFull: """Test forecast on full train dataset. @@ -281,12 +270,17 @@ def test_forecast_in_sample_full_failed_not_enough_context(self, model, transfor with pytest.raises(ValueError, match="Given context isn't big enough"): _test_prediction_in_sample_full(example_tsds, model, transforms, method_name="forecast") - @to_be_fixed(raises=NotImplementedError, match="It is not possible to make in-sample predictions") + @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") @pytest.mark.parametrize( "model, transforms", [ (BATSModel(use_trend=True), []), (TBATSModel(use_trend=True), []), + (StatsForecastARIMAModel(), []), + (StatsForecastAutoARIMAModel(), []), + (StatsForecastAutoCESModel(), []), + (StatsForecastAutoETSModel(), []), + (StatsForecastAutoThetaModel(), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -322,20 +316,6 @@ def test_forecast_in_sample_full_failed_not_enough_context(self, model, transfor def test_forecast_in_sample_full_not_implemented(self, model, transforms, example_tsds): _test_prediction_in_sample_full(example_tsds, model, transforms, method_name="forecast") - @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") - @pytest.mark.parametrize( - "model, transforms", - [ - (StatsForecastARIMAModel(), []), - (StatsForecastAutoARIMAModel(), []), - (StatsForecastAutoCESModel(), []), - (StatsForecastAutoETSModel(), []), - (StatsForecastAutoThetaModel(), []), - ], - ) - def test_forecast_in_sample_full_not_implemented_2(self, model, transforms, example_tsds): - _test_prediction_in_sample_full(example_tsds, model, transforms, method_name="forecast") - class TestForecastInSampleSuffixNoTarget: """Test forecast on suffix of train dataset where target is filled with NaNs. @@ -404,12 +384,17 @@ def _test_forecast_in_sample_suffix_no_target(ts, model, transforms, num_skip_po def test_forecast_in_sample_suffix_no_target(self, model, transforms, example_tsds): self._test_forecast_in_sample_suffix_no_target(example_tsds, model, transforms, num_skip_points=50) - @to_be_fixed(raises=NotImplementedError, match="It is not possible to make in-sample predictions") + @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") @pytest.mark.parametrize( "model, transforms", [ (BATSModel(use_trend=True), []), (TBATSModel(use_trend=True), []), + (StatsForecastARIMAModel(), []), + (StatsForecastAutoARIMAModel(), []), + (StatsForecastAutoCESModel(), []), + (StatsForecastAutoETSModel(), []), + (StatsForecastAutoThetaModel(), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -447,22 +432,6 @@ def test_forecast_in_sample_suffix_no_target_failed_not_implemented_in_sample( ): self._test_forecast_in_sample_suffix_no_target(example_tsds, model, transforms, num_skip_points=50) - @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") - @pytest.mark.parametrize( - "model, transforms", - [ - (StatsForecastARIMAModel(), []), - (StatsForecastAutoARIMAModel(), []), - (StatsForecastAutoCESModel(), []), - (StatsForecastAutoETSModel(), []), - (StatsForecastAutoThetaModel(), []), - ], - ) - def test_forecast_in_sample_suffix_no_target_failed_not_implemented_in_sample_2( - self, model, transforms, example_tsds - ): - self._test_forecast_in_sample_suffix_no_target(example_tsds, model, transforms, num_skip_points=50) - class TestForecastInSampleSuffix: """Test forecast on suffix of train dataset. @@ -511,12 +480,17 @@ class TestForecastInSampleSuffix: def test_forecast_in_sample_suffix(self, model, transforms, example_tsds): _test_prediction_in_sample_suffix(example_tsds, model, transforms, method_name="forecast", num_skip_points=50) - @to_be_fixed(raises=NotImplementedError, match="It is not possible to make in-sample predictions") + @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") @pytest.mark.parametrize( "model, transforms", [ (BATSModel(use_trend=True), []), (TBATSModel(use_trend=True), []), + (StatsForecastARIMAModel(), []), + (StatsForecastAutoARIMAModel(), []), + (StatsForecastAutoCESModel(), []), + (StatsForecastAutoETSModel(), []), + (StatsForecastAutoThetaModel(), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -552,20 +526,6 @@ def test_forecast_in_sample_suffix(self, model, transforms, example_tsds): def test_forecast_in_sample_suffix_failed_not_implemented_in_sample(self, model, transforms, example_tsds): _test_prediction_in_sample_suffix(example_tsds, model, transforms, method_name="forecast", num_skip_points=50) - @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") - @pytest.mark.parametrize( - "model, transforms", - [ - (StatsForecastARIMAModel(), []), - (StatsForecastAutoARIMAModel(), []), - (StatsForecastAutoCESModel(), []), - (StatsForecastAutoETSModel(), []), - (StatsForecastAutoThetaModel(), []), - ], - ) - def test_forecast_in_sample_suffix_failed_not_implemented_in_sample_2(self, model, transforms, example_tsds): - _test_prediction_in_sample_suffix(example_tsds, model, transforms, method_name="forecast", num_skip_points=50) - class TestForecastOutSamplePrefix: """Test forecast on prefix of future dataset. @@ -808,11 +768,16 @@ def test_forecast_out_sample_suffix_failed_nbeats(self, model, transforms, examp @to_be_fixed( raises=NotImplementedError, - match="You can only forecast from the next point after the last one in the training dataset", + match="This model can't make forecast on out-of-sample data that goes after training data with a gap", ) @pytest.mark.parametrize( "model, transforms", [ + (StatsForecastARIMAModel(), []), + (StatsForecastAutoARIMAModel(), []), + (StatsForecastAutoCESModel(), []), + (StatsForecastAutoETSModel(), []), + (StatsForecastAutoThetaModel(), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -848,23 +813,6 @@ def test_forecast_out_sample_suffix_failed_nbeats(self, model, transforms, examp def test_forecast_out_sample_suffix_failed_not_implemented(self, model, transforms, example_tsds): self._test_forecast_out_sample_suffix(example_tsds, model, transforms) - @to_be_fixed( - raises=NotImplementedError, - match="This model can't make forecast on out-of-sample data that goes after training data with a gap", - ) - @pytest.mark.parametrize( - "model, transforms", - [ - (StatsForecastARIMAModel(), []), - (StatsForecastAutoARIMAModel(), []), - (StatsForecastAutoCESModel(), []), - (StatsForecastAutoETSModel(), []), - (StatsForecastAutoThetaModel(), []), - ], - ) - def test_forecast_out_sample_suffix_failed_not_implemented_2(self, model, transforms, example_tsds): - self._test_forecast_out_sample_suffix(example_tsds, model, transforms) - class TestForecastMixedInOutSample: """Test forecast on mixture of in-sample and out-sample. @@ -936,12 +884,17 @@ def _test_forecast_mixed_in_out_sample(ts, model, transforms, num_skip_points=50 def test_forecast_mixed_in_out_sample(self, model, transforms, example_tsds): self._test_forecast_mixed_in_out_sample(example_tsds, model, transforms) - @to_be_fixed(raises=NotImplementedError, match="It is not possible to make in-sample predictions") + @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") @pytest.mark.parametrize( "model, transforms", [ (BATSModel(use_trend=True), []), (TBATSModel(use_trend=True), []), + (StatsForecastARIMAModel(), []), + (StatsForecastAutoARIMAModel(), []), + (StatsForecastAutoCESModel(), []), + (StatsForecastAutoETSModel(), []), + (StatsForecastAutoThetaModel(), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -977,20 +930,6 @@ def test_forecast_mixed_in_out_sample(self, model, transforms, example_tsds): def test_forecast_mixed_in_out_sample_failed_not_implemented_in_sample(self, model, transforms, example_tsds): self._test_forecast_mixed_in_out_sample(example_tsds, model, transforms) - @to_be_fixed(raises=NotImplementedError, match="This model can't make forecast on history data") - @pytest.mark.parametrize( - "model, transforms", - [ - (StatsForecastARIMAModel(), []), - (StatsForecastAutoARIMAModel(), []), - (StatsForecastAutoCESModel(), []), - (StatsForecastAutoETSModel(), []), - (StatsForecastAutoThetaModel(), []), - ], - ) - def test_forecast_mixed_in_out_sample_failed_not_implemented_in_sample_2(self, model, transforms, example_tsds): - self._test_forecast_mixed_in_out_sample(example_tsds, model, transforms) - class TestForecastSubsetSegments: """Test forecast on subset of segments. diff --git a/tests/test_models/test_inference/test_predict.py b/tests/test_models/test_inference/test_predict.py index 1753cc71d..aa1c1cc6d 100644 --- a/tests/test_models/test_inference/test_predict.py +++ b/tests/test_models/test_inference/test_predict.py @@ -332,8 +332,6 @@ def test_predict_out_sample(self, model, transforms, example_tsds): @pytest.mark.parametrize( "model, transforms", [ - (BATSModel(use_trend=True), []), - (TBATSModel(use_trend=True), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -390,6 +388,8 @@ def test_predict_out_sample_failed_not_implemented_predict(self, model, transfor @pytest.mark.parametrize( "model, transforms", [ + (BATSModel(use_trend=True), []), + (TBATSModel(use_trend=True), []), (StatsForecastARIMAModel(), []), (StatsForecastAutoARIMAModel(), []), (StatsForecastAutoCESModel(), []), @@ -462,8 +462,6 @@ def test_predict_out_sample_prefix(self, model, transforms, example_tsds): @pytest.mark.parametrize( "model, transforms", [ - (BATSModel(use_trend=True), []), - (TBATSModel(use_trend=True), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -520,6 +518,8 @@ def test_predict_out_sample_prefix_failed_not_implemented_predict(self, model, t @pytest.mark.parametrize( "model, transforms", [ + (BATSModel(use_trend=True), []), + (TBATSModel(use_trend=True), []), (StatsForecastARIMAModel(), []), (StatsForecastAutoARIMAModel(), []), (StatsForecastAutoCESModel(), []), @@ -593,8 +593,6 @@ def test_predict_out_sample_suffix(self, model, transforms, example_tsds): @pytest.mark.parametrize( "model, transforms", [ - (BATSModel(use_trend=True), []), - (TBATSModel(use_trend=True), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -661,6 +659,8 @@ def test_predict_out_sample_suffix_failed_not_implemented_predict(self, model, t @pytest.mark.parametrize( "model, transforms", [ + (BATSModel(use_trend=True), []), + (TBATSModel(use_trend=True), []), (StatsForecastARIMAModel(), []), (StatsForecastAutoARIMAModel(), []), (StatsForecastAutoCESModel(), []), @@ -748,8 +748,6 @@ def test_predict_mixed_in_out_sample(self, model, transforms, example_tsds): @pytest.mark.parametrize( "model, transforms", [ - (BATSModel(use_trend=True), []), - (TBATSModel(use_trend=True), []), ( DeepARModel( dataset_builder=PytorchForecastingDatasetBuilder( @@ -806,6 +804,8 @@ def test_predict_mixed_in_out_sample_failed_not_implemented_predict(self, model, @pytest.mark.parametrize( "model, transforms", [ + (BATSModel(use_trend=True), []), + (TBATSModel(use_trend=True), []), (StatsForecastARIMAModel(), []), (StatsForecastAutoARIMAModel(), []), (StatsForecastAutoCESModel(), []), diff --git a/tests/test_models/test_linear_model.py b/tests/test_models/test_linear_model.py index fcf9cf39c..bfa4b1078 100644 --- a/tests/test_models/test_linear_model.py +++ b/tests/test_models/test_linear_model.py @@ -21,33 +21,6 @@ from tests.test_models.utils import assert_sampling_is_valid -@pytest.fixture -def ts_with_categoricals(random_seed) -> TSDataset: - periods = 100 - df1 = pd.DataFrame({"timestamp": pd.date_range("2020-01-01", periods=periods)}) - df1["segment"] = "segment_1" - df1["target"] = np.random.uniform(10, 20, size=periods) - - df2 = pd.DataFrame({"timestamp": pd.date_range("2020-01-01", periods=periods)}) - df2["segment"] = "segment_2" - df2["target"] = np.random.uniform(-15, 5, size=periods) - - df_exog1 = pd.DataFrame({"timestamp": pd.date_range("2020-01-01", periods=periods * 2)}) - df_exog1["segment"] = "segment_1" - df_exog1["cat_feature"] = "x" - - df_exog2 = pd.DataFrame({"timestamp": pd.date_range("2020-01-01", periods=periods * 2)}) - df_exog2["segment"] = "segment_2" - df_exog2["cat_feature"] = "y" - - df = pd.concat([df1, df2]).reset_index(drop=True) - df_exog = pd.concat([df_exog1, df_exog2]).reset_index(drop=True) - - ts = TSDataset(df=TSDataset.to_dataset(df), freq="D", df_exog=TSDataset.to_dataset(df_exog), known_future="all") - - return ts - - @pytest.fixture def df_with_regressors(example_tsds) -> Tuple[pd.DataFrame, List[str]]: lags = LagTransform(in_column="target", lags=[7], out_column="lag") @@ -226,16 +199,38 @@ def test_no_warning_on_categorical_features(example_tsds, model): @pytest.mark.parametrize("model", [LinearPerSegmentModel()]) -def test_raise_error_on_unconvertable_features(ts_with_categoricals, model): +def test_raise_error_on_unconvertable_features(ts_with_non_convertable_category_regressor, model): """Check that SklearnModel raises error working with dataset with categorical features which can't be converted to numeric""" + ts = ts_with_non_convertable_category_regressor horizon = 7 num_lags = 5 lags = LagTransform(in_column="target", lags=[i + horizon for i in range(1, num_lags + 1)]) dateflags = DateFlagsTransform() - ts_with_categoricals.fit_transform([lags, dateflags]) + ts.fit_transform([lags, dateflags]) + + with pytest.raises(ValueError, match="Only convertible to numeric features are allowed"): + _ = model.fit(ts) - with pytest.raises(ValueError, match="Only convertible to numeric features are accepted!"): - _ = model.fit(ts_with_categoricals) + +@pytest.mark.parametrize("model", [LinearPerSegmentModel()]) +def test_raise_error_on_no_features(example_tsds, model): + ts = example_tsds + + with pytest.raises(ValueError, match="There are not features for fitting the model"): + _ = model.fit(ts) + + +@pytest.mark.parametrize("model", [LinearPerSegmentModel()]) +def test_prediction_with_exogs_warning(ts_with_non_regressor_exog, model): + ts = ts_with_non_regressor_exog + horizon = 7 + num_lags = 5 + lags = LagTransform(in_column="target", lags=[i + horizon for i in range(1, num_lags + 1)]) + dateflags = DateFlagsTransform() + ts.fit_transform([lags, dateflags]) + + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features unknown in future"): + model.fit(ts) @pytest.mark.parametrize( @@ -285,6 +280,17 @@ def test_save_load(model, example_tsds): assert_model_equals_loaded_original(model=model, ts=example_tsds, transforms=transforms, horizon=horizon) +@pytest.mark.parametrize("fit_intercept", (True, False)) +@pytest.mark.parametrize("regressor_constructor", (LinearRegression, ElasticNet)) +def test_linear_adapter_predict_components_raise_error_if_not_fitted( + df_with_regressors, regressor_constructor, fit_intercept +): + df, regressors = df_with_regressors + adapter = _LinearAdapter(regressor=regressor_constructor(fit_intercept=fit_intercept)) + with pytest.raises(ValueError, match="Model is not fitted"): + _ = adapter.predict_components(df) + + @pytest.mark.parametrize( "fit_intercept, expected_component_names", [ diff --git a/tests/test_models/test_prophet.py b/tests/test_models/test_prophet.py index 52d72085d..77e2de9d7 100644 --- a/tests/test_models/test_prophet.py +++ b/tests/test_models/test_prophet.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import numpy as np import pandas as pd import pytest @@ -13,45 +15,50 @@ from tests.test_models.utils import assert_sampling_is_valid -def test_run(new_format_df): - df = new_format_df +def _check_forecast(ts, model, horizon): + model.fit(ts) + future_ts = ts.make_future(future_steps=horizon) + res = model.forecast(future_ts) + res = res.to_pandas(flatten=True) + + assert not res["target"].isnull().values.any() + assert len(res) == horizon * 2 - ts = TSDataset(df, "1d") - model = ProphetModel() +def _check_predict(ts, model): model.fit(ts) - future_ts = ts.make_future(3) - model.forecast(future_ts) - if not future_ts.isnull().values.any(): - assert True - else: - assert False + res = model.predict(ts) + res = res.to_pandas(flatten=True) + assert not res["target"].isnull().values.any() + assert len(res) == len(ts.index) * 2 -def test_run_with_reg(new_format_df, new_format_exog): - df = new_format_df - regressors = new_format_exog.copy() - regressors.columns.set_levels(["regressor_exog"], level="feature", inplace=True) - regressors_floor = new_format_exog.copy() - regressors_floor.columns.set_levels(["floor"], level="feature", inplace=True) - regressors_cap = regressors_floor.copy() + 1 - regressors_cap.columns.set_levels(["cap"], level="feature", inplace=True) - exog = pd.concat([regressors, regressors_floor, regressors_cap], axis=1) +def test_fit_str_category_fail(ts_with_non_convertable_category_regressor): + model = ProphetModel() + ts = ts_with_non_convertable_category_regressor + with pytest.raises(ValueError, match="Only convertible to numeric features are allowed"): + model.fit(ts) - ts = TSDataset(df, "1d", df_exog=exog, known_future="all") - model = ProphetModel(growth="logistic") - model.fit(ts) - future_ts = ts.make_future(3) - model.forecast(future_ts) - if not future_ts.isnull().values.any(): - assert True - else: - assert False +def test_fit_with_exogs_warning(ts_with_non_regressor_exog): + ts = ts_with_non_regressor_exog + model = ProphetModel() + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features unknown in future"): + model.fit(ts) + + +def test_prediction(example_tsds): + _check_forecast(ts=deepcopy(example_tsds), model=ProphetModel(), horizon=7) + _check_predict(ts=deepcopy(example_tsds), model=ProphetModel()) + +def test_prediction_with_reg(example_reg_tsds): + _check_forecast(ts=deepcopy(example_reg_tsds), model=ProphetModel(), horizon=7) + _check_predict(ts=deepcopy(example_reg_tsds), model=ProphetModel()) -def test_run_with_cap_floor(): + +def test_prediction_with_cap_floor(): cap = 101 floor = -1 @@ -82,6 +89,12 @@ def test_run_with_cap_floor(): assert np.all(df_future["target"] < cap) +def test_forecast_with_short_regressors_fail(ts_with_short_regressor): + ts = ts_with_short_regressor + with pytest.raises(ValueError, match="Regressors .* contain NaN values"): + _check_forecast(ts=deepcopy(ts), model=ProphetModel(), horizon=20) + + def test_prediction_interval_run_insample(example_tsds): model = ProphetModel() model.fit(example_tsds) @@ -103,7 +116,7 @@ def test_prediction_interval_run_infuture(example_tsds): assert (segment_slice["target_0.975"] - segment_slice["target_0.025"] >= 0).all() -def test_prophet_save_regressors_on_fit(example_reg_tsds): +def test_save_regressors_on_fit(example_reg_tsds): model = ProphetModel() model.fit(ts=example_reg_tsds) for segment_model in model._models.values(): diff --git a/tests/test_models/test_sarimax_model.py b/tests/test_models/test_sarimax_model.py index dd6e4baee..0dc1a1bb8 100644 --- a/tests/test_models/test_sarimax_model.py +++ b/tests/test_models/test_sarimax_model.py @@ -18,7 +18,7 @@ def _check_forecast(ts, model, horizon): res = model.forecast(future_ts) res = res.to_pandas(flatten=True) - assert not res.isnull().values.any() + assert not res["target"].isnull().values.any() assert len(res) == horizon * 2 @@ -27,13 +27,22 @@ def _check_predict(ts, model): res = model.predict(ts) res = res.to_pandas(flatten=True) - assert not res.isnull().values.any() + assert not res["target"].isnull().values.any() assert len(res) == len(ts.index) * 2 -def test_prediction(example_tsds): - _check_forecast(ts=deepcopy(example_tsds), model=SARIMAXModel(), horizon=7) - _check_predict(ts=deepcopy(example_tsds), model=SARIMAXModel()) +def test_fit_with_exogs_warning(ts_with_non_regressor_exog): + ts = ts_with_non_regressor_exog + model = SARIMAXModel() + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features unknown in future"): + model.fit(ts) + + +def test_fit_str_category_fail(ts_with_non_convertable_category_regressor): + model = SARIMAXModel() + ts = ts_with_non_convertable_category_regressor + with pytest.raises(ValueError, match="Only convertible to float features are allowed"): + model.fit(ts) def test_save_regressors_on_fit(example_reg_tsds): @@ -53,6 +62,11 @@ def test_select_regressors_correctly(example_reg_tsds): assert (segment_regressors == segment_regressors_expected).all().all() +def test_prediction(example_tsds): + _check_forecast(ts=deepcopy(example_tsds), model=SARIMAXModel(), horizon=7) + _check_predict(ts=deepcopy(example_tsds), model=SARIMAXModel()) + + def test_prediction_with_simple_differencing(example_tsds): _check_forecast(ts=deepcopy(example_tsds), model=SARIMAXModel(simple_differencing=True), horizon=7) _check_predict(ts=deepcopy(example_tsds), model=SARIMAXModel(simple_differencing=True)) @@ -68,6 +82,12 @@ def test_prediction_with_reg_custom_order(example_reg_tsds): _check_predict(ts=deepcopy(example_reg_tsds), model=SARIMAXModel(order=(3, 1, 0))) +def test_forecast_with_short_regressors_fail(ts_with_short_regressor): + ts = ts_with_short_regressor + with pytest.raises(ValueError, match="Regressors .* contain NaN values"): + _check_forecast(ts=deepcopy(ts), model=SARIMAXModel(), horizon=20) + + @pytest.mark.parametrize("method_name", ["forecast", "predict"]) def test_prediction_interval_insample(example_tsds, method_name): model = SARIMAXModel() @@ -139,6 +159,20 @@ def test_save_load(example_tsds): assert_model_equals_loaded_original(model=model, ts=example_tsds, transforms=[], horizon=3) +@pytest.mark.parametrize( + "components_method_name,in_sample", (("predict_components", True), ("forecast_components", False)) +) +def test_decomposition_raise_error_if_not_fitted(dfs_w_exog, components_method_name, in_sample): + train, test = dfs_w_exog + pred_df = train if in_sample else test + + model = _SARIMAXAdapter(order=(2, 0, 0), seasonal_order=(1, 0, 0, 3), hamilton_representation=True) + components_method = getattr(model, components_method_name) + + with pytest.raises(ValueError, match="Model is not fitted"): + _ = components_method(df=pred_df) + + @pytest.mark.parametrize( "components_method_name,in_sample", (("predict_components", True), ("forecast_components", False)) ) @@ -152,7 +186,7 @@ def test_decomposition_hamiltonian_repr_error(dfs_w_exog, components_method_name components_method = getattr(model, components_method_name) with pytest.raises( - ValueError, match="Prediction decomposition is not implemented for Hamilton representation of ARMA!" + NotImplementedError, match="Prediction decomposition is not implemented for Hamilton representation of ARMA!" ): _ = components_method(df=pred_df) @@ -263,7 +297,10 @@ def test_forecast_components_of_subset_error(dfs_w_exog): model = _SARIMAXAdapter() model.fit(train, ["f1", "f2"]) - with pytest.raises(ValueError, match="Regressors .* are too short for chosen horizon value"): + with pytest.raises( + NotImplementedError, + match="This model can't make forecast decomposition on out-of-sample data that goes after training data with a gap", + ): _ = model.forecast_components(df=test.iloc[1:-1]) @@ -273,7 +310,7 @@ def test_forecast_decompose_timestamp_error(dfs_w_exog): model = _SARIMAXAdapter() model.fit(train, []) - with pytest.raises(ValueError, match="To estimate in-sample prediction decomposition use `predict` method."): + with pytest.raises(NotImplementedError, match="This model can't make forecast decomposition on history data"): model.forecast_components(df=train) @@ -288,7 +325,9 @@ def test_predict_decompose_timestamp_error(outliers_df, train_slice, decompose_s model = _SARIMAXAdapter() model.fit(outliers_df.iloc[train_slice], []) - with pytest.raises(ValueError, match="To estimate out-of-sample prediction decomposition use `forecast` method."): + with pytest.raises( + NotImplementedError, match="This model can't make prediction decomposition on future out-of-sample data" + ): model.predict_components(df=outliers_df.iloc[decompose_slice]) diff --git a/tests/test_models/test_simple_models.py b/tests/test_models/test_simple_models.py index 463eb6296..a54ee012f 100644 --- a/tests/test_models/test_simple_models.py +++ b/tests/test_models/test_simple_models.py @@ -77,6 +77,13 @@ def long_periodic_ts(): return ts +def test_sma_model_fit_with_exogs_warning(example_reg_tsds): + ts = example_reg_tsds + model = SeasonalMovingAverageModel() + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features"): + model.fit(ts) + + @pytest.mark.parametrize("model", [SeasonalMovingAverageModel, NaiveModel, MovingAverageModel]) def test_sma_model_forecast(simple_df, model): _check_forecast(ts=simple_df, model=model(), horizon=7) @@ -119,6 +126,13 @@ def test_sma_model_predict_fail_nans_in_context(simple_df): _ = sma_model.predict(simple_df, prediction_size=7) +def test_deadline_model_fit_with_exogs_warning(example_reg_tsds): + ts = example_reg_tsds + model = DeadlineMovingAverageModel(window=1) + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features"): + model.fit(ts) + + @pytest.mark.parametrize( "freq, periods, start, prediction_size, seasonality, window, expected", [ diff --git a/tests/test_models/test_statsforecast.py b/tests/test_models/test_statsforecast.py index 4f4a0b065..8bc274c3e 100644 --- a/tests/test_models/test_statsforecast.py +++ b/tests/test_models/test_statsforecast.py @@ -6,7 +6,6 @@ from statsforecast.models import AutoETS from statsforecast.models import AutoTheta -from etna.datasets import TSDataset from etna.libs.statsforecast import ARIMA from etna.models import StatsForecastARIMAModel from etna.models import StatsForecastAutoARIMAModel @@ -18,44 +17,36 @@ from tests.test_models.utils import assert_sampling_is_valid -@pytest.fixture -def ts_with_non_convertable_category(example_tsds) -> TSDataset: - ts = example_tsds - df = ts.to_pandas(flatten=True) - df_exog = deepcopy(df) - df_exog["cat"] = "a" - df_exog["cat"] = df_exog["cat"].astype("category") - df_exog.drop(columns=["target"], inplace=True) - df_wide = TSDataset.to_dataset(df).iloc[:-10] - df_exog_wide = TSDataset.to_dataset(df_exog) - ts = TSDataset(df=df_wide, df_exog=df_exog_wide, freq=ts.freq, known_future="all") - return ts - - -@pytest.fixture -def ts_with_short_category(example_tsds) -> TSDataset: - ts = example_tsds - df = ts.to_pandas(flatten=True) - df_exog = deepcopy(df) - df_exog["cat"] = 1 - df_exog.drop(columns=["target"], inplace=True) - df_wide = TSDataset.to_dataset(df).iloc[:-10] - df_exog_wide = TSDataset.to_dataset(df_exog) - ts = TSDataset(df=df_wide, df_exog=df_exog_wide, freq=ts.freq, known_future="all") - return ts - - -@pytest.fixture -def ts_with_exog(example_tsds) -> TSDataset: - ts = example_tsds - df = ts.to_pandas(flatten=True) - df_exog = deepcopy(df) - df_exog["cat"] = 1 - df_exog.drop(columns=["target"], inplace=True) - df_wide = TSDataset.to_dataset(df).iloc[:-10] - df_exog_wide = TSDataset.to_dataset(df_exog) - ts = TSDataset(df=df_wide, df_exog=df_exog_wide, freq=ts.freq) - return ts +@pytest.mark.parametrize( + "model", + [ + StatsForecastARIMAModel(), + StatsForecastAutoARIMAModel(), + StatsForecastAutoCESModel(), + StatsForecastAutoETSModel(), + StatsForecastAutoThetaModel(), + ], +) +def test_save_regressors_on_fit(model, example_reg_tsds): + model.fit(ts=example_reg_tsds) + for segment_model in model._models.values(): + assert sorted(segment_model.regressor_columns) == example_reg_tsds.regressors + + +@pytest.mark.parametrize( + "model", + [ + StatsForecastARIMAModel(), + StatsForecastAutoARIMAModel(), + StatsForecastAutoCESModel(), + StatsForecastAutoETSModel(), + StatsForecastAutoThetaModel(), + ], +) +def test_fit_with_exogs_warning(model, ts_with_non_regressor_exog): + ts = ts_with_non_regressor_exog + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features unknown in future"): + model.fit(ts) @pytest.mark.parametrize( @@ -68,9 +59,9 @@ def ts_with_exog(example_tsds) -> TSDataset: StatsForecastAutoThetaModel(), ], ) -def test_fit_str_category_fail(model, ts_with_non_convertable_category): - ts = ts_with_non_convertable_category - with pytest.raises(ValueError, match="Categorical columns .* can not be converted to int"): +def test_fit_str_category_fail(model, ts_with_non_convertable_category_regressor): + ts = ts_with_non_convertable_category_regressor + with pytest.raises(ValueError, match="Only convertible to float features are allowed"): model.fit(ts) @@ -163,23 +154,6 @@ def test_predict_train_with_regressors(model, example_reg_tsds): assert len(res) == len(example_reg_tsds.index) * 2 -@pytest.mark.parametrize( - "model", - [ - StatsForecastARIMAModel(), - StatsForecastAutoARIMAModel(), - StatsForecastAutoCESModel(), - StatsForecastAutoETSModel(), - StatsForecastAutoThetaModel(), - ], -) -def test_predict_train_with_exogs_warn(model, ts_with_exog): - ts = ts_with_exog - model.fit(ts) - with pytest.warns(UserWarning, match="Model from statsforecast does not work with exogenous features"): - _ = model.predict(ts) - - @pytest.mark.parametrize( "model", [ @@ -270,32 +244,13 @@ def test_forecast_future_with_regressors(model, example_reg_tsds): StatsForecastAutoThetaModel(), ], ) -def test_forecast_future_with_exogs_warn(model, ts_with_exog): - horizon = 7 - ts = ts_with_exog - model.fit(ts) - future_ts = ts.make_future(future_steps=horizon) - with pytest.warns(UserWarning, match="Model from statsforecast does not work with exogenous features"): - _ = model.forecast(future_ts) - - -@pytest.mark.parametrize( - "model", - [ - StatsForecastARIMAModel(), - StatsForecastAutoARIMAModel(), - StatsForecastAutoCESModel(), - StatsForecastAutoETSModel(), - StatsForecastAutoThetaModel(), - ], -) -def test_forecast_future_with_short_regressors_fail(model, ts_with_short_category): +def test_forecast_future_with_short_regressors_fail(model, ts_with_short_regressor): horizon = 20 - ts = ts_with_short_category + ts = ts_with_short_regressor model.fit(ts) future_ts = ts.make_future(future_steps=horizon) - with pytest.raises(ValueError, match="Regressors .* are too short for chosen horizon value"): + with pytest.raises(ValueError, match="Regressors .* contain NaN values"): _ = model.forecast(future_ts) diff --git a/tests/test_models/test_tbats.py b/tests/test_models/test_tbats.py index 802b1f9bb..c4991e49f 100644 --- a/tests/test_models/test_tbats.py +++ b/tests/test_models/test_tbats.py @@ -132,6 +132,13 @@ def test_repr(model_class, model_class_repr): assert model_repr == true_repr +@pytest.mark.parametrize("model", [TBATSModel(), BATSModel()]) +def test_with_exog_warning(model, example_reg_tsds): + ts = example_reg_tsds + with pytest.warns(UserWarning, match="This model doesn't work with exogenous features"): + model.fit(ts) + + @pytest.mark.parametrize("model", (TBATSModel(), BATSModel())) @pytest.mark.parametrize("method", ("forecast", "predict")) def test_not_fitted(model, method, linear_segments_ts_unique): @@ -473,7 +480,9 @@ def test_predict_decompose_timestamp_error(outliers_df, train_slice, decompose_s model = _TBATSAdapter(model=BATS()) model.fit(outliers_df.iloc[train_slice], []) - with pytest.raises(ValueError, match="To estimate out-of-sample prediction decomposition use `forecast` method."): + with pytest.raises( + NotImplementedError, match="This model can't make prediction decomposition on future out-of-sample data" + ): model.predict_components(df=outliers_df.iloc[decompose_slice]) @@ -483,7 +492,7 @@ def test_forecast_decompose_timestamp_error(periodic_dfs): model = _TBATSAdapter(model=BATS()) model.fit(train, []) - with pytest.raises(ValueError, match="To estimate in-sample prediction decomposition use `predict` method."): + with pytest.raises(NotImplementedError, match="This model can't make forecast decomposition on history data"): model.forecast_components(df=train)