Source code for darts.models.forecasting.ensemble_model

"""
Base Ensemble Model
-------------------
"""

import copy
import os
import sys
from abc import abstractmethod
from collections import defaultdict
from typing import BinaryIO

from darts.models.forecasting.sklearn_model import SKLearnModel
from darts.utils.likelihood_models.base import LikelihoodType

if sys.version_info >= (3, 11):
    from typing import Self
else:
    from typing_extensions import Self

from darts import TimeSeries, concatenate
from darts.logging import get_logger, raise_log
from darts.models.forecasting.forecasting_model import (
    ForecastingModel,
    GlobalForecastingModel,
    LocalForecastingModel,
)
from darts.typing import TimeSeriesLike
from darts.utils.ts_utils import series2seq
from darts.utils.utils import TORCH_AVAILABLE

if TORCH_AVAILABLE:
    from darts.models.forecasting.torch_forecasting_model import (
        TFM_ATTRS_NO_PICKLE,
        TorchForecastingModel,
    )
else:
    TorchForecastingModel, TFM_ATTRS_NO_PICKLE = None, None

logger = get_logger(__name__)


class EnsembleModel(GlobalForecastingModel):
    """
    Abstract base class for ensemble models.
    Ensemble models take in a list of forecasting models and ensemble their predictions
    to make a single one according to the rule defined by their `ensemble()` method.

    If `future_covariates` or `past_covariates` are provided at training or inference time,
    they will be passed only to the models supporting them.

    Parameters
    ----------
    forecasting_models
        List of forecasting models whose predictions to ensemble

        .. note::
                if all the models are probabilistic, the `EnsembleModel` will also be probabilistic.
        ..
    train_num_samples
        Number of prediction samples from each forecasting model for multi-level ensembles. The n_samples
        dimension will be reduced using the `train_samples_reduction` method.
    train_samples_reduction
        If `forecasting_models` are probabilistic and `train_num_samples` > 1, method used to reduce the
        samples dimension to 1. Possible values: "mean", "median" or float value corresponding to the
        desired quantile.
    train_forecasting_models
        If set to `False`, the `forecasting_models` are not retrained when calling `fit()` (only supported
        if all the `forecasting_models` are pretrained `GlobalForecastingModels`). Default: ``True``.
    train_n_points
        The number of points per series to use to train the ensemble model. Can be set to `-1` to use the
        entire series to train the regressor if `forecasting_models` are already fitted and
        `train_forecasting_models=False`.
    show_warnings
        Whether to show warnings related to models covariates support.
    """

    def __init__(
        self,
        forecasting_models: list[ForecastingModel],
        ensemble_model: SKLearnModel | None,
        train_num_samples: int,
        train_samples_reduction: str | float | None,
        train_forecasting_models: bool = True,
        train_n_points: int = 0,
        show_warnings: bool = True,
    ):
        super().__init__()

        if not isinstance(forecasting_models, list) or len(forecasting_models) == 0:
            raise_log(
                ValueError(
                    "`forecasting_models` must be a non-empty list of forecasting models."
                ),
            )

        is_local_model = [
            isinstance(model, LocalForecastingModel) for model in forecasting_models
        ]
        is_global_model = [
            isinstance(model, GlobalForecastingModel) for model in forecasting_models
        ]

        self.is_local_ensemble = all(is_local_model)
        self.is_global_ensemble = all(is_global_model)

        if not all([
            local_model or global_model
            for local_model, global_model in zip(is_local_model, is_global_model)
        ]):
            raise_log(
                ValueError(
                    "All models must be of type `GlobalForecastingModel`, or `LocalForecastingModel`. "
                    "Also, make sure that all `forecasting_models` are instantiated."
                ),
            )

        model_fit_status = [m._fit_called for m in forecasting_models]
        self.all_trained = all(model_fit_status)
        some_trained = any(model_fit_status)

        if not self.is_global_ensemble and some_trained:
            raise_log(
                ValueError(
                    "Some models in `forecasting_models` are already fitted. Using pre-trained models is "
                    "only supported if all models are of type `GlobalForecastingModel`. "
                    "Consider resetting all models with `my_model.untrained_model()`."
                ),
            )
        elif self.is_global_ensemble and not (self.all_trained or not some_trained):
            raise_log(
                ValueError(
                    "All `forecasting_models` are global but there is a mixture of fitted and unfitted models. "
                    "Consider resetting all models with `my_model.untrained_model()` or using only trained "
                    "`GlobalForecastingModel` together with `train_forecasting_models=False`."
                ),
            )

        if train_forecasting_models:
            # prevent issues with pytorch-lightning trainer during retraining
            if some_trained:
                raise_log(
                    ValueError(
                        "`train_forecasting_models=True` but some `forecasting_models` were already fitted. "
                        "Consider resetting all the `forecasting_models` with `my_model.untrained_model()` "
                        "before passing them to the `EnsembleModel`."
                    ),
                )
        else:
            if not (self.is_global_ensemble and self.all_trained):
                raise_log(
                    ValueError(
                        "`train_forecasting_models=False` is supported only if all the `forecasting_models` are "
                        "already trained `GlobalForecastingModels`."
                    ),
                )

        if (
            train_num_samples is not None
            and train_num_samples > 1
            and all([
                not m.supports_probabilistic_prediction for m in forecasting_models
            ])
        ):
            raise_log(
                ValueError(
                    "`train_num_samples` is greater than 1 but the `RegressionEnsembleModel` "
                    "contains only deterministic `forecasting_models`."
                ),
            )

        supported_reduction = ["mean", "median"]
        if train_samples_reduction is None:
            pass
        elif isinstance(train_samples_reduction, float):
            if not (0.0 < train_samples_reduction < 1.0):
                raise_log(
                    ValueError(
                        f"if a float, `train_samples_reduction` must be between "
                        f"0 and 1, received ({train_samples_reduction})"
                    ),
                )
        elif isinstance(train_samples_reduction, str):
            if train_samples_reduction not in supported_reduction:
                raise_log(
                    ValueError(
                        f"if a string, `train_samples_reduction` must be one of {supported_reduction}, "
                        f"received ({train_samples_reduction})"
                    ),
                )
        else:
            raise_log(
                ValueError(
                    f"`train_samples_reduction` type not supported "
                    f"({train_samples_reduction}). Must be `float` "
                    f" or one of {supported_reduction}."
                ),
            )

        if train_n_points == -1 and not (
            self.all_trained and (not train_forecasting_models)
        ):
            raise_log(
                ValueError(
                    "`regression_train_n_points` can only be `-1` if `retrain_forecasting_model=False` and "
                    "all `forecasting_models` are already fitted."
                ),
            )

        # ensemble model checks
        self.forecasting_models = forecasting_models
        self.ensemble_model = ensemble_model
        self.train_num_samples = train_num_samples
        self.train_samples_reduction = train_samples_reduction
        self.train_forecasting_models = train_forecasting_models
        self.show_warnings = show_warnings
        # regression_train_n_points=-1 is converted to actual n points at fitting time
        self.train_n_points: int = train_n_points

        if show_warnings:
            if (
                self.supports_past_covariates
                and not self._full_past_covariates_support()
            ):
                logger.warning(
                    "Some `forecasting_models` in the ensemble do not support past covariates, the past covariates "
                    "will be provided only to the models supporting them when calling fit()` or `predict()`. "
                    "To hide these warnings, set `show_warnings=False`."
                )

            if (
                self.supports_future_covariates
                and not self._full_future_covariates_support()
            ):
                logger.warning(
                    "Some `forecasting_models` in the ensemble do not support future covariates, the future covariates"
                    " will be provided only to the models supporting them when calling `fit()` or `predict()`. "
                    "To hide these warnings, set `show_warnings=False`."
                )

    def untrained_model(self):
        model = self.__class__(**copy.deepcopy(self.model_params))
        if not self.train_forecasting_models:
            # torch models drop the underlying network when calling `untrained_model()`;
            # add them back in case the models are not retrained
            for sub_model, sub_model_orig in zip(
                model.forecasting_models, self.forecasting_models
            ):
                if TORCH_AVAILABLE and isinstance(sub_model, TorchForecastingModel):
                    for attr in TFM_ATTRS_NO_PICKLE:
                        setattr(sub_model, attr, getattr(sub_model_orig, attr))
        return model

    @abstractmethod
    def fit(
        self,
        series: TimeSeriesLike,
        past_covariates: TimeSeriesLike | None = None,
        future_covariates: TimeSeriesLike | None = None,
        verbose: bool | None = None,
    ):
        """
        Fits the model on the provided series.
        Note that `EnsembleModel.fit()` does NOT call `fit()` on each of its constituent forecasting models.
        It is left to classes inheriting from EnsembleModel to do so appropriately when overriding `fit()`
        """

        is_single_series = isinstance(series, TimeSeries)

        # local models OR mix of local and global models
        if not self.is_global_ensemble and not is_single_series:
            raise_log(
                ValueError(
                    "The `forecasting_models` contain at least one LocalForecastingModel, "
                    "which does not support training on multiple series."
                ),
            )

        # check that if timeseries is single series, that covariates are as well and vice versa
        error_past_cov = False
        error_future_cov = False

        if past_covariates is not None:
            error_past_cov = is_single_series != isinstance(past_covariates, TimeSeries)

        if future_covariates is not None:
            error_future_cov = is_single_series != isinstance(
                future_covariates, TimeSeries
            )

        if error_past_cov or error_future_cov:
            raise_log(
                ValueError(
                    "Both series and covariates have to be either single TimeSeries or sequences of TimeSeries."
                ),
            )

        self._verify_past_future_covariates(past_covariates, future_covariates)

        # the minimum train series length includes the training requirements from `forecasting_models` as
        # well as the ones from the ensemble model
        min_train_series_length = self.min_train_series_length
        if is_single_series:
            series_too_short = len(series) < min_train_series_length
        else:
            series_too_short = any([len(s) < min_train_series_length for s in series])

        if series_too_short:
            raise_log(
                ValueError(
                    f"{'All time series in ' if not is_single_series else ''}`series` must have "
                    f"a minimum length of `{min_train_series_length}` to fit the model."
                ),
            )

        super().fit(series, past_covariates, future_covariates, verbose=verbose)
        return self

    def _stack_ts_seq(self, predictions):
        # stacks list of predictions into one multivariate timeseries
        return concatenate(predictions, axis=1)

    def _stack_ts_multiseq(self, predictions_list):
        # stacks multiple sequences of timeseries elementwise
        return [self._stack_ts_seq(ts_list) for ts_list in zip(*predictions_list)]

    @property
    def _model_encoder_settings(self):
        raise NotImplementedError(
            "Encoders are not supported by EnsembleModels. Instead add encoders to the underlying `forecasting_models`."
        )

    def _base_model_predict_n(self, n: int) -> int:
        """Minimum prediction horizon for base models to satisfy the ensemble
        (regression) model's future covariate requirements during predict.

        The base model predictions start at ``series.end_time() + (shift + 1) * freq``
        (shifted output). The regression model needs covariates from
        ``series.end_time() + (min(lags) + 1) * freq``. Since all models share the
        same shift, ``min(lags) >= shift``, and we subtract the shift to avoid
        requesting autoregression the base models cannot perform.
        """
        if self.ensemble_model is None:
            return n
        ens_lags = self.ensemble_model.lags["future"]
        base_shift = self.output_chunk_shift
        return (
            max(ens_lags)
            + 1
            - base_shift
            + max(0, n - self.ensemble_model.output_chunk_length)
        )

    def _make_multiple_predictions(
        self,
        n: int,
        series: TimeSeriesLike | None = None,
        past_covariates: TimeSeriesLike | None = None,
        future_covariates: TimeSeriesLike | None = None,
        num_samples: int = 1,
        predict_likelihood_parameters: bool = False,
        random_state: int | None = None,
        verbose: bool | None = None,
    ) -> TimeSeriesLike:
        is_single_series = isinstance(series, TimeSeries) or series is None
        # maximize covariate usage
        predictions = [
            model._predict_wrapper(
                n=n,
                series=series,
                past_covariates=(
                    past_covariates if model.supports_past_covariates else None
                ),
                future_covariates=(
                    future_covariates if model.supports_future_covariates else None
                ),
                num_samples=(
                    num_samples if model.supports_probabilistic_prediction else 1
                ),
                predict_likelihood_parameters=predict_likelihood_parameters,
                random_state=random_state,
                verbose=verbose,
            )
            for model in self.forecasting_models
        ]

        # reduce the probabilistics series
        if self.train_samples_reduction is not None and self.train_num_samples > 1:
            predictions = [
                self._predictions_reduction(prediction) for prediction in predictions
            ]

        return (
            self._stack_ts_seq(predictions)
            if is_single_series
            else self._stack_ts_multiseq(predictions)
        )

    def predict(
        self,
        n: int,
        series: TimeSeriesLike | None = None,
        past_covariates: TimeSeriesLike | None = None,
        future_covariates: TimeSeriesLike | None = None,
        num_samples: int = 1,
        verbose: bool | None = None,
        predict_likelihood_parameters: bool = False,
        show_warnings: bool = True,
        random_state: int | None = None,
    ) -> TimeSeriesLike:
        # ensure forecasting models all rely on the same series during inference
        if series is None:
            series = self.training_series
        if past_covariates is None:
            past_covariates = self.past_covariate_series
        if future_covariates is None:
            future_covariates = self.future_covariate_series

        super().predict(
            n=n,
            series=series,
            past_covariates=past_covariates,
            future_covariates=future_covariates,
            num_samples=num_samples,
            verbose=verbose,
            predict_likelihood_parameters=predict_likelihood_parameters,
            show_warnings=show_warnings,
            random_state=random_state,
        )

        # for single-level ensemble, probabilistic forecast is obtained directly from forecasting models
        if self.train_samples_reduction is None:
            pred_num_samples = num_samples
            forecast_models_pred_likelihood_params = predict_likelihood_parameters
        # for multi-levels ensemble, forecasting models can generate arbitrary number of samples
        else:
            pred_num_samples = self.train_num_samples
            # second layer model (regression) cannot be trained on likelihood parameters
            forecast_models_pred_likelihood_params = False

        self._verify_past_future_covariates(past_covariates, future_covariates)

        base_n = self._base_model_predict_n(n)
        predictions = self._make_multiple_predictions(
            n=base_n,
            series=series,
            past_covariates=past_covariates,
            future_covariates=future_covariates,
            num_samples=pred_num_samples,
            predict_likelihood_parameters=forecast_models_pred_likelihood_params,
            random_state=random_state,
            verbose=verbose,
        )

        return self.ensemble(
            predictions,
            series=series,
            n=n,
            num_samples=num_samples,
            predict_likelihood_parameters=predict_likelihood_parameters,
            random_state=random_state,
            verbose=verbose,
        )

    @abstractmethod
    def ensemble(
        self,
        predictions: TimeSeriesLike,
        series: TimeSeriesLike,
        n: int,
        num_samples: int = 1,
        predict_likelihood_parameters: bool = False,
        random_state: int | None = None,
        verbose: bool | None = None,
    ) -> TimeSeriesLike:
        """
        Defines how to ensemble the individual models' predictions to produce a single prediction.

        Parameters
        ----------
        predictions
            Individual predictions to ensemble
        series
            Sequence of timeseries to predict on. Optional, since it only makes sense for sequences of timeseries -
            local models retain timeseries for prediction.
        n
            The number of output time steps the ensemble should produce.
        num_samples
            Number of times a prediction is sampled from a probabilistic model. Must be `1` for deterministic models.
        predict_likelihood_parameters
            If set to `True`, the model predicts the parameters of its `likelihood` instead of the target. Only
            supported for probabilistic models with a likelihood, `num_samples = 1` and `n<=output_chunk_length`.
            Default: ``False``
        random_state
            Controls the randomness of probabilistic predictions.
        verbose
            Optionally, set the prediction verbosity. Not effective for all models.

        Returns
        -------
        TimeSeries or Sequence[TimeSeries]
            The predicted ``TimeSeries`` or sequence of ``TimeSeries`` obtained by ensembling the individual predictions
        """
        pass

    def _predictions_reduction(self, predictions: TimeSeriesLike) -> TimeSeriesLike:
        """Reduce the sample dimension of the forecasting models predictions"""
        is_single_series = isinstance(predictions, TimeSeries)
        predictions = series2seq(predictions)
        if self.train_samples_reduction == "median":
            predictions = [pred.median(axis=2) for pred in predictions]
        elif self.train_samples_reduction == "mean":
            predictions = [pred.mean(axis=2) for pred in predictions]
        else:
            predictions = [
                pred.quantile(self.train_samples_reduction) for pred in predictions
            ]
        return predictions[0] if is_single_series else predictions

    def _clean(self) -> Self:
        """Cleans the model and sub-models."""
        cleaned_model = super()._clean()
        cleaned_model.forecasting_models = [
            model._clean() for model in self.forecasting_models
        ]
        return cleaned_model

    def save(
        self,
        path: str | os.PathLike | BinaryIO | None = None,
        clean: bool = False,
        **pkl_kwargs,
    ) -> None:
        """
        Saves the ensemble model under a given path or file handle.

        Additionally, two files are stored for each `TorchForecastingModel` under the forecasting models.

        Example for saving and loading a :class:`RegressionEnsembleModel`:

            .. highlight:: python
            .. code-block:: python

                from darts.models import RegressionEnsembleModel, LinearRegressionModel, TiDEModel

                model = RegressionEnsembleModel(
                    forecasting_models = [
                        LinearRegressionModel(lags=4),
                        TiDEModel(input_chunk_length=4, output_chunk_length=4),
                        ],
                        regression_train_n_points=10,
                )

                model.save("my_ensemble_model.pkl")
                model_loaded = RegressionEnsembleModel.load("my_ensemble_model.pkl")
            ..

        Parameters
        ----------
        path
            Path or file handle under which to save the ensemble model at its current state. If no path is specified,
            the ensemble model is automatically saved under ``"{RegressionEnsembleModel}_{YYYY-mm-dd_HH_MM_SS}.pkl"``.
            If the i-th model of `forecasting_models` is a TorchForecastingModel, two files (model object and
            checkpoint) are saved under ``"{path}.{ithModelClass}_{i}.pt"`` and ``"{path}.{ithModelClass}_{i}.ckpt"``.
        clean
            Whether to store a cleaned version of the model. If `True`, the training series and covariates are removed.
            If the underlying `forecasting_models` contain any `TorchForecastingModel`, will additionally remove all of
            their Lightning Trainer-related parameters.

            Note: After loading a model stored with `clean=True`, a `series` must be passed 'predict()',
            `historical_forecasts()` and other forecasting methods.
        pkl_kwargs
            Keyword arguments passed to `pickle.dump()`
        """

        if path is None:
            # default path
            path = self._default_save_path() + ".pkl"

        super().save(path, clean=clean, **pkl_kwargs)

        for i, m in enumerate(self.forecasting_models):
            if TORCH_AVAILABLE and issubclass(type(m), TorchForecastingModel):
                path_tfm = f"{path}.{type(m).__name__}_{i}.pt"
                m.save(path=path_tfm, clean=clean)

    @staticmethod
    def load(
        path: str | os.PathLike | BinaryIO,
        pl_trainer_kwargs: dict | None = None,
        **kwargs,
    ) -> "EnsembleModel":
        """
        Loads a model from a given path or file handle.

        Parameters
        ----------
        path
            Path or file handle from which to load the model.
        pl_trainer_kwargs
            Only effective if the underlying forecasting models contain a `TorchForecastingModel`.
            Optionally, a set of kwargs to create a new Lightning Trainer used to configure the model for downstream
            tasks (e.g. prediction).
            Some examples include specifying the batch size or moving the model to CPU/GPU(s). Check the
            `Lightning Trainer documentation <https://pytorch-lightning.readthedocs.io/en/stable/common/trainer.html>`__
            for more information about the supported kwargs.
        **kwargs
            Only effective if the underlying forecasting models contain a `TorchForecastingModel`.
            Additional kwargs for PyTorch Lightning's :func:`LightningModule.load_from_checkpoint()` method,
            For more information, read the `official documentation <https://pytorch-lightning.readthedocs.io/en/stable/
            common/lightning_module.html#load-from-checkpoint>`__.
        """
        model: EnsembleModel = GlobalForecastingModel.load(path)

        for i, m in enumerate(model.forecasting_models):
            if TORCH_AVAILABLE and issubclass(type(m), TorchForecastingModel):
                path_tfm = f"{path}.{type(m).__name__}_{i}.pt"
                model.forecasting_models[i] = TorchForecastingModel.load(
                    path_tfm, pl_trainer_kwargs=pl_trainer_kwargs, **kwargs
                )
        return model

    @property
    def min_train_samples(self) -> int:
        train_n_points = abs(self.train_n_points)
        if self.train_forecasting_models:
            # if base models are re-trained, it is the max of the sub-models' min samples + train_n_points
            min_train_samples = (
                max(model.min_train_samples for model in self.forecasting_models)
                + train_n_points
            )
        else:
            # if base models not re-trained, we might already have some training points within the base model's
            # first output chunk; if we need more, we add them as additional required samples
            base_ocl = max(self.extreme_lags[1] + 1, 0)
            min_train_samples = max(train_n_points - base_ocl, 0) + 1
        return min_train_samples

    @property
    def _target_window_lengths(self) -> tuple[int, int]:
        extreme_lags = self.extreme_lags
        input_length = abs(extreme_lags[0]) if extreme_lags[0] is not None else 0
        output_length = max(extreme_lags[1] + 1, 0)
        return input_length, output_length

    @property
    def extreme_lags(
        self,
    ) -> tuple[
        int | None,
        int | None,
        int | None,
        int | None,
        int | None,
        int | None,
        int,
    ]:
        # the extreme lags are:
        # - min target lag
        # - max target lag
        # - min past covariate lag
        # - max past covariate lag
        # - min future covariate lag
        # - max future covariate lag
        # - output shift

        if self.ensemble_model is not None:
            # use the max of the ensemble model's max target lag
            ft_lag = self.ensemble_model.extreme_lags[1]
        else:
            # or simulate a local forecasting model max target lag
            ft_lag = -1

        # adjust the right-bound lags if the ensemble models has a larger max target lag than the submodels
        extreme_lags_adjusted = defaultdict(list)
        for model in self.forecasting_models:
            model_extreme_lags = model.extreme_lags
            model_ft_lag = model_extreme_lags[1]

            # only adjust global models (model_ft_lag >= 0); local models
            # (model_ft_lag < 0) have no fixed output window and their training
            # data requirements should not be inflated
            ft_lag_diff = max(ft_lag - model_ft_lag, 0) if model_ft_lag >= 0 else 0

            extreme_lags_adjusted[0].append(model_extreme_lags[0])
            extreme_lags_adjusted[1].append(model_extreme_lags[1] + ft_lag_diff)
            extreme_lags_adjusted[2].append(model_extreme_lags[2])
            extreme_lags_adjusted[3].append(
                model_extreme_lags[3] + ft_lag_diff
                if model_extreme_lags[3] is not None
                else None
            )
            extreme_lags_adjusted[4].append(model_extreme_lags[4])
            extreme_lags_adjusted[5].append(
                model_extreme_lags[5] + ft_lag_diff
                if model_extreme_lags[5] is not None
                else None
            )
            extreme_lags_adjusted[6].append(model_extreme_lags[6])

        def find_max_lag_or_none(lag_id, aggregator) -> int | None:
            max_lag = None
            for curr_lag in extreme_lags_adjusted[lag_id]:
                if max_lag is None:
                    max_lag = curr_lag
                elif curr_lag is not None:
                    max_lag = aggregator(max_lag, curr_lag)
            return max_lag

        # extreme lags is given by the min or max of the extreme lags of the sub-models
        return (
            find_max_lag_or_none(0, min),
            find_max_lag_or_none(1, max),
            find_max_lag_or_none(2, min),
            find_max_lag_or_none(3, max),
            find_max_lag_or_none(4, min),
            find_max_lag_or_none(5, max),
            find_max_lag_or_none(6, max),
        )

    @property
    def output_chunk_length(self) -> int | None:
        # either it's the ensemble model's output_chunk_length
        if self.ensemble_model is not None:
            return self.ensemble_model.output_chunk_length

        # or the smallest base model output chunk length
        tmp = [
            m.output_chunk_length
            for m in self.forecasting_models
            if m.output_chunk_length is not None
        ]

        if len(tmp) == 0:
            return None
        else:
            return min(tmp)

    @property
    def output_chunk_shift(self) -> int:
        # either use the ensemble model's output shift
        if self.ensemble_model is not None:
            return self.ensemble_model.output_chunk_shift

        # or the output shift of the sub models (enforced to be identical at model creation)
        return self.extreme_lags[6]

    @property
    def _models_are_probabilistic(self) -> bool:
        return all([
            model.supports_probabilistic_prediction for model in self.forecasting_models
        ])

    @property
    def _models_same_likelihood(self) -> bool:
        """Return `True` if all the `forecasting_models` are probabilistic and fit the same distribution."""
        if not self._models_are_probabilistic:
            return False

        models_likelihood = set()
        lkl_same_params = True
        tmp_quantiles = None
        for m in self.forecasting_models:
            likelihood = m.likelihood
            lkl_type = likelihood.type
            models_likelihood.add(lkl_type)

            # check the quantiles
            if lkl_type is LikelihoodType.Quantile:
                quantiles: list[str] = likelihood.quantiles
                if tmp_quantiles is None:
                    tmp_quantiles = quantiles
                elif tmp_quantiles != quantiles:
                    lkl_same_params = False

        return len(models_likelihood) == 1 and lkl_same_params

    @property
    def supports_likelihood_parameter_prediction(self) -> bool:
        """EnsembleModel can predict likelihood parameters if all its forecasting models were fitted with the
        same likelihood.
        """
        return (
            all([
                m.supports_likelihood_parameter_prediction
                for m in self.forecasting_models
            ])
            and self._models_same_likelihood
        )

    @property
    def supports_probabilistic_prediction(self) -> bool:
        return self._models_are_probabilistic

    @property
    def supports_multivariate(self) -> bool:
        return all([model.supports_multivariate for model in self.forecasting_models])

    @property
    def supports_past_covariates(self) -> bool:
        return any([
            model.supports_past_covariates for model in self.forecasting_models
        ])

    @property
    def supports_future_covariates(self) -> bool:
        return any([
            model.supports_future_covariates for model in self.forecasting_models
        ])

    @property
    def supports_optimized_historical_forecasts(self) -> bool:
        """
        Whether the model supports optimized historical forecasts
        """
        return False

    @property
    def _supports_non_retrainable_historical_forecasts(self) -> bool:
        return self.is_global_ensemble

    def _full_past_covariates_support(self) -> bool:
        return all([
            model.supports_past_covariates for model in self.forecasting_models
        ])

    def _full_future_covariates_support(self) -> bool:
        return all([
            model.supports_future_covariates for model in self.forecasting_models
        ])

    def _verify_past_future_covariates(self, past_covariates, future_covariates):
        """
        Verify that any non-None covariates comply with the model type.
        """
        if past_covariates is not None and not self.supports_past_covariates:
            raise_log(
                ValueError(
                    "`past_covariates` were provided to an `EnsembleModel` but none of its "
                    "`forecasting_models` support such covariates."
                ),
            )
        if future_covariates is not None and not self.supports_future_covariates:
            raise_log(
                ValueError(
                    "`future_covariates` were provided to an `EnsembleModel` but none of its "
                    "`forecasting_models` support such covariates."
                ),
            )