diff --git a/darts/models/forecasting/catboost_model.py b/darts/models/forecasting/catboost_model.py index 844f944bae..d3306d487a 100644 --- a/darts/models/forecasting/catboost_model.py +++ b/darts/models/forecasting/catboost_model.py @@ -60,6 +60,7 @@ def __init__( categorical_past_covariates: Optional[Union[str, list[str]]] = None, categorical_future_covariates: Optional[Union[str, list[str]]] = None, categorical_static_covariates: Optional[Union[str, list[str]]] = None, + dir_rec: Optional[bool] = False, **kwargs, ): """CatBoost Model @@ -173,6 +174,10 @@ def encode_year(idx): Optionally, string or list of strings specifying the static covariates that should be treated as categorical by the underlying `CatBoostRegressor`. The components that are specified as categorical must be integer-encoded. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. **kwargs Additional keyword arguments passed to `catboost.CatBoostRegressor`. Native multi-output support can be achieved by using an appropriate `loss_function` ('MultiRMSE', @@ -236,6 +241,7 @@ def encode_year(idx): categorical_future_covariates=categorical_future_covariates, categorical_static_covariates=categorical_static_covariates, random_state=random_state, + dir_rec=dir_rec, ) # if no loss provided, get the default loss from the model @@ -477,6 +483,7 @@ def __init__( categorical_past_covariates: Optional[Union[str, list[str]]] = None, categorical_future_covariates: Optional[Union[str, list[str]]] = None, categorical_static_covariates: Optional[Union[str, list[str]]] = None, + dir_rec: Optional[bool] = False, **kwargs, ): """CatBoost Model for classification forecasting @@ -587,6 +594,10 @@ def encode_year(idx): Optionally, string or list of strings specifying the static covariates that should be treated as categorical by the underlying `CatBoostRegressor`. The components that are specified as categorical must be integer-encoded. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. **kwargs Additional keyword arguments passed to `catboost.CatBoostClassifier`. @@ -637,6 +648,7 @@ def encode_year(idx): categorical_past_covariates=categorical_past_covariates, categorical_future_covariates=categorical_future_covariates, categorical_static_covariates=categorical_static_covariates, + dir_rec=dir_rec, **kwargs, ) diff --git a/darts/models/forecasting/lgbm.py b/darts/models/forecasting/lgbm.py index 515f0c05e6..474435f722 100644 --- a/darts/models/forecasting/lgbm.py +++ b/darts/models/forecasting/lgbm.py @@ -55,6 +55,7 @@ def __init__( categorical_past_covariates: Optional[Union[str, list[str]]] = None, categorical_future_covariates: Optional[Union[str, list[str]]] = None, categorical_static_covariates: Optional[Union[str, list[str]]] = None, + dir_rec: Optional[bool] = False, **kwargs, ): """LGBM Model @@ -165,6 +166,10 @@ def encode_year(idx): Optionally, string or list of strings specifying the static covariates that should be treated as categorical by the underlying `lightgbm.LightGBMRegressor`. The components that are specified as categorical must be integer-encoded. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. **kwargs Additional keyword arguments passed to `lightgbm.LGBRegressor`. @@ -222,6 +227,7 @@ def encode_year(idx): categorical_future_covariates=categorical_future_covariates, categorical_static_covariates=categorical_static_covariates, random_state=random_state, + dir_rec=dir_rec, ) @staticmethod @@ -384,6 +390,7 @@ def __init__( categorical_past_covariates: Optional[Union[str, list[str]]] = None, categorical_future_covariates: Optional[Union[str, list[str]]] = None, categorical_static_covariates: Optional[Union[str, list[str]]] = None, + dir_rec: Optional[bool] = False, **kwargs, ): """LGBM Model for classification forecasting @@ -494,6 +501,10 @@ def encode_year(idx): Optionally, string or list of strings specifying the static covariates that should be treated as categorical by the underlying `lightgbm.LightGBMRegressor`. The components that are specified as categorical must be integer-encoded. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. **kwargs Additional keyword arguments passed to `lightgbm.LGBClassifier`. @@ -545,6 +556,7 @@ def encode_year(idx): categorical_past_covariates=categorical_past_covariates, categorical_future_covariates=categorical_future_covariates, categorical_static_covariates=categorical_static_covariates, + dir_rec=dir_rec, **kwargs, ) diff --git a/darts/models/forecasting/linear_regression_model.py b/darts/models/forecasting/linear_regression_model.py index 95cdab6021..7744aca956 100644 --- a/darts/models/forecasting/linear_regression_model.py +++ b/darts/models/forecasting/linear_regression_model.py @@ -43,6 +43,7 @@ def __init__( random_state: Optional[int] = None, multi_models: Optional[bool] = True, use_static_covariates: bool = True, + dir_rec: Optional[bool] = False, **kwargs, ): """Linear regression model. @@ -140,6 +141,10 @@ def encode_year(idx): Whether the model should use static covariate information in case the input `series` passed to ``fit()`` contain static covariates. If ``True``, and static covariates are available at fitting time, will enforce that all target `series` have the same static covariate dimensionality in ``fit()`` and ``predict()``. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. **kwargs Additional keyword arguments passed to `sklearn.linear_model.LinearRegression` (by default), to `sklearn.linear_model.PoissonRegressor` (if `likelihood="poisson"`), or to @@ -204,6 +209,7 @@ def encode_year(idx): multi_models=multi_models, use_static_covariates=use_static_covariates, random_state=random_state, + dir_rec=dir_rec, ) def fit( diff --git a/darts/models/forecasting/random_forest.py b/darts/models/forecasting/random_forest.py index dc7fcead0f..6c70c04d60 100644 --- a/darts/models/forecasting/random_forest.py +++ b/darts/models/forecasting/random_forest.py @@ -43,6 +43,7 @@ def __init__( multi_models: Optional[bool] = True, use_static_covariates: bool = True, random_state: Optional[int] = None, + dir_rec: Optional[bool] = False, **kwargs, ): """Random Forest Model @@ -139,6 +140,10 @@ def encode_year(idx): that all target `series` have the same static covariate dimensionality in ``fit()`` and ``predict()``. random_state Controls the randomness for reproducible forecasting. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. **kwargs Additional keyword arguments passed to `sklearn.ensemble.RandomForestRegressor`. @@ -190,6 +195,7 @@ def encode_year(idx): model=RandomForestRegressor(**kwargs), use_static_covariates=use_static_covariates, random_state=random_state, + dir_rec=dir_rec, ) diff --git a/darts/models/forecasting/sklearn_model.py b/darts/models/forecasting/sklearn_model.py index 915e12692d..dfaaad5396 100644 --- a/darts/models/forecasting/sklearn_model.py +++ b/darts/models/forecasting/sklearn_model.py @@ -93,7 +93,12 @@ SKLearnLikelihood, _get_likelihood, ) -from darts.utils.multioutput import MultiOutputMixin, get_multioutput_estimator_cls +from darts.utils.multioutput import ( + MultiOutputMixin, + RecurrentMultiOutputMixin, + get_multioutput_estimator_cls, + get_recurrent_multioutput_estimator_cls, +) from darts.utils.ts_utils import get_single_series, seq2series, series2seq from darts.utils.utils import ModelType, random_method @@ -119,6 +124,7 @@ def __init__( multi_models: Optional[bool] = True, use_static_covariates: bool = True, random_state: Optional[int] = None, + dir_rec: Optional[bool] = False, ): """Regression Model Can be used to fit any scikit-learn-like regressor class to predict the target time series from lagged values. @@ -215,6 +221,10 @@ def encode_year(idx): that all target `series` have the same static covariate dimensionality in ``fit()`` and ``predict()``. random_state Controls the randomness for reproducible forecasting. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. Examples -------- @@ -258,6 +268,7 @@ def encode_year(idx): self._static_covariates_shape: Optional[tuple[int, int]] = None self._lagged_feature_names: Optional[list[str]] = None self._lagged_label_names: Optional[list[str]] = None + self.dir_rec = dir_rec # optionally, the model can be wrapped in a likelihood model self._likelihood: Optional[SKLearnLikelihood] = getattr( @@ -730,8 +741,10 @@ def _add_val_set_to_kwargs( last_static_covariates_shape=self._static_covariates_shape, stride=stride, ) - # create validation sets for MultiOutputMixin - if val_labels.ndim == 2 and isinstance(self.model, MultiOutputMixin): + # create validation sets for MultiOutputMixin and RecurrentMultiOutputMixin + if val_labels.ndim == 2 and isinstance( + self.model, (MultiOutputMixin, RecurrentMultiOutputMixin) + ): val_sets, val_weights = [], [] for i in range(val_labels.shape[1]): val_sets.append((val_samples, val_labels[:, i])) @@ -1036,8 +1049,31 @@ def fit( self.output_chunk_length > 1 and self.multi_models ) - # If multi-output required and model doesn't support it natively, wrap it in a MultiOutputMixin - if ( + # If direct-recursive prediction is enabled, wrap it in RecurrentMultiOutputMixin + # Note: dir_rec ALWAYS requires wrapping (even if model supports native multi-output) + if self.dir_rec: + val_set_name, val_weight_name = self.val_set_params + mor_kwargs = { + "eval_set_name": val_set_name, + "eval_weight_name": val_weight_name, + } + mor_kwargs["output_chunk_length"] = self.output_chunk_length + + if ( + n_jobs_multioutput_wrapper is not None + and n_jobs_multioutput_wrapper != 1 + ): + logger.warning( + "Direct-recursive multi-output prediction is sequential by design. " + "`n_jobs_multioutput_wrapper` parameter will be ignored." + ) + + self.model = get_recurrent_multioutput_estimator_cls(self._model_type)( + estimator=self.model, **mor_kwargs + ) + + # Elif multi-output required and model doesn't support it natively, wrap it in a MultiOutputMixin + elif ( requires_multioutput and not isinstance(self.model, MultiOutputMixin) and ( @@ -1059,7 +1095,7 @@ def fit( ) if ( - not isinstance(self.model, MultiOutputMixin) + not isinstance(self.model, (MultiOutputMixin, RecurrentMultiOutputMixin)) and n_jobs_multioutput_wrapper is not None ): logger.warning("Provided `n_jobs_multioutput_wrapper` wasn't used.") @@ -1495,7 +1531,7 @@ def supports_sample_weight(self) -> bool: """Whether the model supports a validation set during training.""" return ( self.model.supports_sample_weight - if isinstance(self.model, MultiOutputMixin) + if isinstance(self.model, (MultiOutputMixin, RecurrentMultiOutputMixin)) else has_fit_parameter(self.model, "sample_weight") ) @@ -1635,6 +1671,7 @@ def __init__( categorical_future_covariates: Optional[Union[str, list[str]]] = None, categorical_static_covariates: Optional[Union[str, list[str]]] = None, random_state: Optional[int] = None, + dir_rec: Optional[bool] = False, ): """ Extension of `SKLearnModel` for regression models that support categorical features. @@ -1738,6 +1775,10 @@ def encode_year(idx): categorical. random_state Controls the randomness for reproducible forecasting. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. """ super().__init__( lags=lags, @@ -1750,6 +1791,7 @@ def encode_year(idx): multi_models=multi_models, use_static_covariates=use_static_covariates, random_state=random_state, + dir_rec=dir_rec, ) if categorical_static_covariates is not None and not use_static_covariates: @@ -1953,6 +1995,7 @@ def __init__( multi_models: Optional[bool] = True, use_static_covariates: bool = True, random_state: Optional[int] = None, + dir_rec: Optional[bool] = False, ): """Regression Model Can be used to fit any scikit-learn-like regressor class to predict the target time series from lagged values. @@ -2052,6 +2095,10 @@ def encode_year(idx): that all target `series` have the same static covariate dimensionality in ``fit()`` and ``predict()``. random_state Controls the randomness for reproducible forecasting. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. Examples -------- @@ -2100,6 +2147,7 @@ def encode_year(idx): multi_models=multi_models, use_static_covariates=use_static_covariates, random_state=random_state, + dir_rec=dir_rec, ) @@ -2210,6 +2258,7 @@ def __init__( multi_models: Optional[bool] = True, use_static_covariates: bool = True, random_state: Optional[int] = None, + dir_rec: Optional[bool] = False, ): """SKLearn Classifier Model @@ -2382,4 +2431,5 @@ def encode_year(idx): multi_models=multi_models, use_static_covariates=use_static_covariates, random_state=random_state, + dir_rec=dir_rec, ) diff --git a/darts/models/forecasting/xgboost.py b/darts/models/forecasting/xgboost.py index bee44e30e1..b44f14e6fb 100644 --- a/darts/models/forecasting/xgboost.py +++ b/darts/models/forecasting/xgboost.py @@ -72,6 +72,7 @@ def __init__( random_state: Optional[int] = None, multi_models: Optional[bool] = True, use_static_covariates: bool = True, + dir_rec: Optional[bool] = False, **kwargs, ): """XGBoost Model @@ -168,6 +169,10 @@ def encode_year(idx): Whether the model should use static covariate information in case the input `series` passed to ``fit()`` contain static covariates. If ``True``, and static covariates are available at fitting time, will enforce that all target `series` have the same static covariate dimensionality in ``fit()`` and ``predict()``. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. **kwargs Additional keyword arguments passed to `xgb.XGBRegressor`. @@ -223,6 +228,7 @@ def encode_year(idx): model=self._create_model(**self.kwargs), use_static_covariates=use_static_covariates, random_state=random_state, + dir_rec=dir_rec, ) @staticmethod @@ -383,6 +389,7 @@ def __init__( random_state: Optional[int] = None, multi_models: Optional[bool] = True, use_static_covariates: bool = True, + dir_rec: Optional[bool] = False, **kwargs, ): """XGBoost Model for classification forecasting @@ -480,6 +487,10 @@ def encode_year(idx): Whether the model should use static covariate information in case the input `series` passed to ``fit()`` contain static covariates. If ``True``, and static covariates are available at fitting time, will enforce that all target `series` have the same static covariate dimensionality in ``fit()`` and ``predict()``. + dir_rec + Whether to use direct-recursive strategy for multi-step forecasting. When True, each forecast + horizon uses predictions from previous horizons as additional input features. This creates a + chained prediction where step t+2 uses the prediction for step t+1 as a feature. Default: False. **kwargs Additional keyword arguments passed to `xgb.XGBClassifier`. @@ -526,6 +537,7 @@ def encode_year(idx): random_state=random_state, multi_models=multi_models, use_static_covariates=use_static_covariates, + dir_rec=dir_rec, **kwargs, ) diff --git a/darts/utils/multioutput.py b/darts/utils/multioutput.py index 9f44b3bdaf..5908cd16e0 100644 --- a/darts/utils/multioutput.py +++ b/darts/utils/multioutput.py @@ -6,7 +6,8 @@ import inspect from typing import Optional -from sklearn.base import is_classifier +import numpy as np +from sklearn.base import clone, is_classifier from sklearn.multioutput import MultiOutputClassifier as sk_MultiOutputClassifier from sklearn.multioutput import MultiOutputRegressor as sk_MultiOutputRegressor from sklearn.multioutput import _fit_estimator @@ -45,34 +46,20 @@ def __init__( self.eval_weight_name = eval_weight_name self.output_chunk_length = output_chunk_length - def fit(self, X, y, sample_weight=None, **fit_params): - """Fit the model to data, separately for each output variable. + def _validate_fit_inputs(self, y, sample_weight): + """Validate inputs for fitting. Shared by MultiOutputMixin and RecurrentMultiOutputMixin. Parameters ---------- - X : {array-like, sparse matrix} of shape (n_samples, n_features) - The input data. - y : {array-like, sparse matrix} of shape (n_samples, n_outputs) - Multi-output targets. An indicator matrix turns on multilabel - estimation. - + Multi-output targets. sample_weight : array-like of shape (n_samples, n_outputs), default=None - Sample weights. If `None`, then samples are equally weighted. - Only supported if the underlying regressor supports sample - weights. - - **fit_params : dict of string -> object - Parameters passed to the ``estimator.fit`` method of each step. - - .. versionadded:: 0.23 + Sample weights. Returns ------- - self : object - Returns a fitted instance. + y : validated y array """ - if not hasattr(self.estimator, "fit"): raise_log( ValueError("The base estimator should implement a fit method"), @@ -104,6 +91,37 @@ def fit(self, X, y, sample_weight=None, **fit_params): logger=logger, ) + return y + + def fit(self, X, y, sample_weight=None, **fit_params): + """Fit the model to data, separately for each output variable. + + Parameters + ---------- + X : {array-like, sparse matrix} of shape (n_samples, n_features) + The input data. + + y : {array-like, sparse matrix} of shape (n_samples, n_outputs) + Multi-output targets. An indicator matrix turns on multilabel + estimation. + + sample_weight : array-like of shape (n_samples, n_outputs), default=None + Sample weights. If `None`, then samples are equally weighted. + Only supported if the underlying regressor supports sample + weights. + + **fit_params : dict of string -> object + Parameters passed to the ``estimator.fit`` method of each step. + + .. versionadded:: 0.23 + + Returns + ------- + self : object + Returns a fitted instance. + """ + y = self._validate_fit_inputs(y, sample_weight) + if ( fit_params.get("verbose") is not None and "verbose" not in inspect.signature(self.estimator.fit).parameters @@ -167,6 +185,162 @@ def fit(self, X, y, sample_weight=None, **fit_params): return self +class RecurrentMultiOutputMixin(MultiOutputMixin): + """ + Mixin for :class:`sklearn.utils.multioutput._MultiOutputEstimator` that implements direct-recursive multi-output + prediction. Each output estimator is trained sequentially, using predictions from all previous estimators as + additional features. + + This approach differs from :class:`MultiOutputRegressor` where all estimators are trained independently + in parallel. Here, estimator i uses predictions from estimators 0, 1, ..., i-1 as additional input features, + creating a chain of dependencies. + """ + + def fit(self, X, y, sample_weight=None, **fit_params): + """Fit the model to data sequentially for each output variable, where each estimator + uses predictions from previous estimators as additional features. + + Parameters + ---------- + X : {array-like, sparse matrix} of shape (n_samples, n_features) + The input data. + + y : {array-like, sparse matrix} of shape (n_samples, n_outputs) + Multi-output targets. An indicator matrix turns on multilabel + estimation. + + sample_weight : array-like of shape (n_samples, n_outputs), default=None + Sample weights. If `None`, then samples are equally weighted. + Only supported if the underlying regressor supports sample + weights. + + **fit_params : dict of string -> object + Parameters passed to the ``estimator.fit`` method of each step. + + .. versionadded:: 0.23 + + Returns + ------- + self : object + Returns a fitted instance. + """ + y = self._validate_fit_inputs(y, sample_weight) + + fit_params_validated = _check_method_params(X, fit_params) + eval_set = fit_params_validated.pop(self.eval_set_name, None) + eval_weight = fit_params_validated.pop(self.eval_weight_name, None) + + self.estimators_ = [] + predictions = [] # Store predictions from previous estimators + + # Sequential training - each estimator depends on predictions from previous ones + for i in range(y.shape[1]): + # Clone the base estimator for this output + estimator = clone(self.estimator) + + # Augment features with predictions from all previous estimators + if predictions: + X_augmented = np.concatenate([X] + predictions, axis=1) + else: + X_augmented = X + + # Prepare evaluation set with augmented features if provided + # not 100% sure about this + eval_set_augmented = None + if eval_set is not None: + eval_X, eval_y = eval_set[i] + if predictions: + # Augment eval_X with predictions from already-trained estimators + eval_predictions = [ + est.predict(eval_X).reshape(-1, 1) for est in self.estimators_ + ] + eval_X_augmented = np.concatenate( + [eval_X] + eval_predictions, axis=1 + ) + else: + eval_X_augmented = eval_X + eval_set_augmented = [(eval_X_augmented, eval_y)] + + # Fit the estimator for this output + estimator.fit( + X_augmented, + y[:, i], + sample_weight=sample_weight[:, i] + if sample_weight is not None + else None, + **( + {self.eval_set_name: eval_set_augmented} + if eval_set_augmented is not None + else {} + ), + **( + {self.eval_weight_name: [eval_weight[i]]} + if eval_weight is not None + else {} + ), + **fit_params_validated, + ) + + self.estimators_.append(estimator) + + # Generate predictions to use as features for the next estimator + y_pred = estimator.predict(X_augmented).reshape(-1, 1) + predictions.append(y_pred) + + # Set sklearn-standard attributes from first estimator + if hasattr(self.estimators_[0], "n_features_in_"): + self.n_features_in_ = self.estimators_[0].n_features_in_ + if hasattr(self.estimators_[0], "feature_names_in_"): + self.feature_names_in_ = self.estimators_[0].feature_names_in_ + + return self + + def predict(self, X): + """Predict multi-output variable sequentially, where each estimator uses predictions + from previous estimators as additional features. + + Parameters + ---------- + X : {array-like, sparse matrix} of shape (n_samples, n_features) + The input data. + + Returns + ------- + y : array of shape (n_samples, n_outputs) + Multi-output targets predicted across multiple predictors. + """ + predictions = [] + + for estimator in self.estimators_: + # Augment features with predictions from previous estimators + if predictions: + X_augmented = np.concatenate([X] + predictions, axis=1) + else: + X_augmented = X + + # Predict for this output + y_pred = estimator.predict(X_augmented).reshape(-1, 1) + predictions.append(y_pred) + + # Stack all predictions horizontally to form (n_samples, n_outputs) + return np.concatenate(predictions, axis=1) + + +class RecurrentMultiOutputRegressor(RecurrentMultiOutputMixin, sk_MultiOutputRegressor): + "wrapper class for recurrent multioutput regressor" + + +class RecurrentMultiOutputClassifier( + RecurrentMultiOutputMixin, sk_MultiOutputRegressor +): + "wrapper class for recurrent multioutput classifier" + + def fit(self, X, y, sample_weight=None, **fit_params): + super().fit(X=X, y=y, sample_weight=sample_weight, **fit_params) + self.classes_ = [estimator.classes_ for estimator in self.estimators_] + return self + + def get_multioutput_estimator_cls(model_type: ModelType) -> type[MultiOutputMixin]: if model_type == ModelType.FORECASTING_REGRESSOR: return MultiOutputRegressor @@ -179,3 +353,19 @@ def get_multioutput_estimator_cls(model_type: ModelType) -> type[MultiOutputMixi f"Received: `{model_type}`." ) ) + + +def get_recurrent_multioutput_estimator_cls( + model_type: ModelType, +) -> type[RecurrentMultiOutputMixin]: + if model_type == ModelType.FORECASTING_REGRESSOR: + return RecurrentMultiOutputRegressor + elif model_type == ModelType.FORECASTING_CLASSIFIER: + return RecurrentMultiOutputClassifier + else: + raise_log( + ValueError( + "Model type must be one of `[ModelType.FORECASTING_REGRESSOR, ModelType.FORECASTING_CLASSIFIER]`. " + f"Received: `{model_type}`." + ) + )