# SPDX-FileCopyrightText: skforecast team
# SPDX-FileCopyrightText: 2026 bartzbeielstein
# SPDX-License-Identifier: AGPL-3.0-or-later AND BSD-3-Clause

from __future__ import annotations
from typing import Callable
import warnings
import sys
import numpy as np
import pandas as pd
import inspect
from copy import copy, deepcopy
from sklearn.base import clone
from sklearn.exceptions import NotFittedError
from sklearn.linear_model._base import LinearModel
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
from sklearn.svm._base import BaseLibSVM

# from spotforecast2_safe import __version__ as spotforecast_version
from spotforecast2_safe.forecaster.base import ForecasterBase
from spotforecast2_safe.exceptions import (
    DataTransformationWarning,
    IgnoredArgumentWarning,
    MissingExogWarning,
    MissingValuesWarning,
    ResidualsUsageWarning,
    UnknownLevelWarning,
)
from spotforecast2_safe.forecaster.utils import (
    initialize_lags,
    initialize_window_features,
    initialize_weights,
    initialize_transformer_series,
    initialize_differentiator_multiseries,
    check_select_fit_kwargs,
    check_preprocess_series,
    check_preprocess_exog_multiseries,
    align_series_and_exog_multiseries,
    prepare_levels_multiseries,
    preprocess_levels_self_last_window_multiseries,
    check_exog,
    get_exog_dtypes,
    check_exog_dtypes,
    check_predict_input,
    check_residuals_input,
    check_interval,
    input_to_frame,
    expand_index,
    transform_numpy,
    transform_dataframe,
    set_skforecast_warnings,
    get_style_repr_html,
    set_cpu_gpu_device,
    initialize_estimator,
)
from spotforecast2_safe.preprocessing import TimeSeriesDifferentiator, QuantileBinner
from spotforecast2_safe.model_selection.utils_common import (
    _extract_data_folds_multiseries,
)


class ForecasterRecursiveMultiSeries(ForecasterBase):
    """
    Recursive autoregressive forecaster for multiple time series.

    This class turns any estimator compatible with the scikit-learn API into a
    recursive autoregressive (multi-step) forecaster for multiple series.

    Args:
        estimator (object): An instance of an estimator or pipeline compatible
            with the scikit-learn API.
        lags (int, list, np.ndarray, range, None): Lags used as predictors.
            Index starts at 1, so lag 1 is equal to t-1.
            - `int`: include lags from 1 to `lags` (included).
            - `list`, `1d numpy ndarray` or `range`: include only lags present in
              `lags`, all elements must be int.
            - `None`: no lags are included as predictors.
        window_features (object, list, None): Instance or list of instances used
            to create window features from the original time series.
        encoding (str, None): Encoding used to identify the different series.
            - `'ordinal'`: single column with integer values [0, n_series - 1].
            - `'ordinal_category'`: same as 'ordinal' but with pandas.category dtype.
            - `'onehot'`: binary column for each series.
            - `None`: no series identification column is created.
            Defaults to 'ordinal'.
        transformer_series (transformer, dict, None): Preprocessor compatible with
            scikit-learn (fit/transform/inverse_transform). Applied to each series
            before training. ColumnTransformers are not allowed.
            - Single transformer: cloned and applied to all series.
            - Dict: different transformer per series.
        transformer_exog (transformer, None): Preprocessor for exogenous variables.
            `inverse_transform` is not available for ColumnTransformers.
        weight_func (Callable, dict, None): Function defining weights for each
            sample based on index. Only used if estimator supports `sample_weight`.
            - Single function: applied to all series.
            - Dict: `{'series_name': Callable}`, others get weight 1.
        series_weights (dict, None): Weights per series `{'series_name': float}`.
            Applied if estimator supports `sample_weight`. Others get weight 1.
            If `None`, all levels have weight 1.
        differentiation (int, dict, None): Order of differencing applied to series.
            - `int`: same order for all series.
            - `dict`: different order per series (keys are series names).
            - `None`: no differencing.
        dropna_from_series (bool): If `True`, drops rows with NaNs in X_train/y_train.
            If `False`, leaves NaNs and warns. Defaults to `False`.
        fit_kwargs (dict, None): Additional arguments for the estimator's `fit` method.
        binner_kwargs (dict, None): Arguments for `QuantileBinner` used for residuals.
            Includes `n_bins`, `method`, `subsample`, `random_state`, `dtype`.
        forecaster_id (str, int, None): Identifier for the forecaster.
        regressor (object): **Deprecated**, alias for `estimator`.

    Attributes:
        estimator (object): Estimator or pipeline compatible with scikit-learn.
        lags (np.ndarray): Lags used as predictors.
        lags_names (list): Names of the lags used as predictors.
        max_lag (int): Maximum lag included in `lags`.
        window_features (list): Classes used to create window features.
        window_features_names (list): Names of the window features in `X_train`.
        window_features_class_names (list): Names of classes for window features.
        max_size_window_features (int): Maximum window size for window features.
        window_size (int): Required window size for predictors. Max of `max_lag`
            and `max_size_window_features`, plus differentiation order if applicable.
        encoding (str): Encoding used to identify the different series.
        encoder (sklearn.preprocessing): Scikit-learn encoder for series.
        encoding_mapping_ (dict): Mapping of the encoding for series identities.
        transformer_series (transformer, dict): Transformer(s) for the series.
        transformer_series_ (dict): Internal dictionary of series transformers.
        transformer_exog (transformer): Transformer for exogenous variables.
        weight_func (Callable, dict): Weighting function(s).
        weight_func_ (dict): Internal dictionary of weighting functions.
        source_code_weight_func (str, dict): Source code of weighting functions.
        series_weights (dict): Weights associated with each series.
        series_weights_ (dict): Internal dictionary of series weights.
        differentiation (int, dict): Differencing order applied to series.
        differentiation_max (int): Maximum order of differentiation.
        differentiator (TimeSeriesDifferentiator, dict): Differentiation objects.
        differentiator_ (dict): Internal dictionary of differentiators.
        dropna_from_series (bool): Whether to drop NaNs from training matrices.
        last_window_ (dict): Last training window per series in original scale.
        index_type_ (type): Type of training index.
        index_freq_ (str): Frequency of training index.
        training_range_ (dict): First/last training index values per series.
        series_names_in_ (list): Series names provided during training.
        exog_in_ (bool): `True` if trained with exogenous variables.
        exog_names_in_ (list): Names of exogenous variables used.
        exog_type_in_ (type): Type of exogenous data used.
        exog_dtypes_in_ (dict): Exogenous data types before transformation.
        exog_dtypes_out_ (dict): Exogenous data types after transformation.
        X_train_series_names_in_ (list): Series names in the internal `X_train`.
        X_train_window_features_names_out_ (list): Window feature names in `X_train`.
        X_train_exog_names_out_ (list): Exogenous variable names in `X_train`.
        X_train_features_names_out_ (list): All feature column names in `X_train`.
        fit_kwargs (dict): Arguments passed to estimator's `fit`.
        in_sample_residuals_ (dict): Training residuals (up to 10k per series).
        in_sample_residuals_by_bin_ (dict): Binned in-sample residuals.
        out_sample_residuals_ (dict): Non-training residuals (up to 10k per series).
        out_sample_residuals_by_bin_ (dict): Binned out-of-sample residuals.
        binner (dict): `QuantileBinner` objects per series.
        binner_intervals_ (dict): Binning intervals per series.
        binner_kwargs (dict): Arguments used for `QuantileBinner`.
        creation_date (str): Forecaster creation date.
        is_fitted (bool): `True` if the forecaster has been fitted.
        fit_date (str): Date of last fit.
        spotforecast_version (str): Version of the library used.
        python_version (str): Python version used.
        forecaster_id (str, int): Forecaster identifier.

    Notes:
        Weights are used to control the influence of each observation.
        `ForecasterRecursiveMultiSeries` accepts two types of weights:
        - `series_weights`: Controls relative importance of each series.
        - `weight_func`: Controls relative importance based on index values.
        If both are provided, they are multiplied. Negative weights are not allowed.
    """

    def __init__(
        self,
        estimator: object = None,
        lags: int | list[int] | np.ndarray[int] | range[int] | None = None,
        window_features: object | list[object] | None = None,
        encoding: str | None = "ordinal",
        transformer_series: object | dict[str, object] | None = None,
        transformer_exog: object | None = None,
        weight_func: Callable | dict[str, Callable] | None = None,
        series_weights: dict[str, float] | None = None,
        differentiation: int | dict[str, int | None] | None = None,
        dropna_from_series: bool = False,
        fit_kwargs: dict[str, object] | None = None,
        binner_kwargs: dict[str, object] | None = None,
        forecaster_id: str | int | None = None,
        regressor: object = None,
    ) -> None:

        self.estimator = copy(initialize_estimator(estimator, regressor))
        self.encoding = encoding
        self.encoder = None
        self.encoding_mapping_ = {}
        self.transformer_series = transformer_series
        self.transformer_series_ = None
        self.transformer_exog = transformer_exog
        self.weight_func = weight_func
        self.weight_func_ = None
        self.source_code_weight_func = None
        self.series_weights = series_weights
        self.series_weights_ = None
        self.differentiation = differentiation
        self.differentiation_max = None
        self.differentiator = None
        self.differentiator_ = None
        self.dropna_from_series = dropna_from_series
        self.last_window_ = None
        self.index_type_ = None
        self.index_freq_ = None
        self.training_range_ = None
        self.series_names_in_ = None
        self.exog_in_ = False
        self.exog_names_in_ = None
        self.exog_type_in_ = None
        self.exog_dtypes_in_ = None
        self.exog_dtypes_out_ = None
        self.X_train_series_names_in_ = None
        self.X_train_window_features_names_out_ = None
        self.X_train_exog_names_out_ = None
        self.X_train_features_names_out_ = None
        self.in_sample_residuals_ = None
        self.in_sample_residuals_by_bin_ = None
        self.out_sample_residuals_ = None
        self.out_sample_residuals_by_bin_ = None
        self.creation_date = pd.Timestamp.today().strftime("%Y-%m-%d %H:%M:%S")
        self.is_fitted = False
        self.fit_date = None
        try:
            from spotforecast2_safe import __version__

            self.spotforecast_version = __version__
        except ImportError:
            self.spotforecast_version = "unknown"
        self.python_version = sys.version.split(" ")[0]
        self.forecaster_id = forecaster_id
        self._probabilistic_mode = "binned"

        self.lags, self.lags_names, self.max_lag = initialize_lags(
            type(self).__name__, lags
        )
        (
            self.window_features,
            self.window_features_names,
            self.max_size_window_features,
        ) = initialize_window_features(window_features)
        if self.window_features is None and self.lags is None:
            raise ValueError(
                "At least one of the arguments `lags` or `window_features` "
                "must be different from None. This is required to create the "
                "predictors used in training the forecaster."
            )

        self.window_size = max(
            [
                ws
                for ws in [self.max_lag, self.max_size_window_features]
                if ws is not None
            ]
        )
        self.window_features_class_names = None
        if window_features is not None:
            self.window_features_class_names = [
                type(wf).__name__ for wf in self.window_features
            ]

        if self.encoding not in ["ordinal", "ordinal_category", "onehot", None]:
            raise ValueError(
                f"Argument `encoding` must be one of the following values: 'ordinal', "
                f"'ordinal_category', 'onehot' or None. Got '{self.encoding}'."
            )

        if self.encoding == "onehot":
            self.encoder = OneHotEncoder(
                categories="auto", sparse_output=False, drop=None, dtype=int
            ).set_output(transform="pandas")
        else:
            self.encoder = OrdinalEncoder(categories="auto", dtype=int).set_output(
                transform="pandas"
            )

        if self.transformer_series is None and isinstance(
            estimator, (LinearModel, BaseLibSVM)
        ):
            warnings.warn(
                "When using a linear model, it is recommended to use a transformer_series "
                "to ensure all series are in the same scale. You can use, for example, a "
                "`StandardScaler` from sklearn.preprocessing.",
                DataTransformationWarning,
            )

        if isinstance(self.transformer_series, dict):
            if self.encoding is None:
                raise TypeError(
                    "When `encoding` is None, `transformer_series` must be a single "
                    "transformer (not `dict`) as it is applied to all series."
                )
            if "_unknown_level" not in self.transformer_series.keys():
                raise ValueError(
                    "If `transformer_series` is a `dict`, a transformer must be "
                    "provided to transform series that do not exist during training. "
                    "Add the key '_unknown_level' to `transformer_series`. "
                    "For example: {'_unknown_level': your_transformer}."
                )

        self.weight_func, self.source_code_weight_func, self.series_weights = (
            initialize_weights(
                forecaster_name=type(self).__name__,
                estimator=estimator,
                weight_func=weight_func,
                series_weights=series_weights,
            )
        )

        if differentiation is not None:
            if isinstance(differentiation, int):
                if differentiation < 1:
                    raise ValueError(
                        f"If `differentiation` is an integer, it must be equal "
                        f"to or greater than 1. Got {differentiation}."
                    )
                self.differentiation = differentiation
                self.differentiation_max = differentiation
                self.window_size += self.differentiation_max
                self.differentiator = TimeSeriesDifferentiator(
                    order=differentiation, window_size=self.window_size
                )
            elif isinstance(differentiation, dict):

                if self.encoding is None:
                    raise TypeError(
                        "When `encoding` is None, `differentiation` must be an "
                        "integer equal to or greater than 1. Same differentiation "
                        "must be applied to all series."
                    )
                if "_unknown_level" not in differentiation.keys():
                    raise ValueError(
                        "If `differentiation` is a `dict`, an order must be provided "
                        "to differentiate series that do not exist during training. "
                        "Add the key '_unknown_level' to `differentiation`. "
                        "For example: {'_unknown_level': 1}."
                    )

                differentiation_max = []
                for level, diff in differentiation.items():
                    if diff is not None:
                        if not isinstance(diff, int) or diff < 1:
                            raise ValueError(
                                f"If `differentiation` is a dict, the values must be "
                                f"None or integers equal to or greater than 1. "
                                f"Got {diff} for series '{level}'."
                            )
                        differentiation_max.append(diff)

                if len(differentiation_max) == 0:
                    raise ValueError(
                        "If `differentiation` is a dict, at least one value must be "
                        "different from None. Got all values equal to None. If you "
                        "do not want to differentiate any series, set `differentiation` "
                        "to None."
                    )

                self.differentiation = differentiation
                self.differentiation_max = max(differentiation_max)
                self.window_size += self.differentiation_max
                self.differentiator = {
                    level: (
                        TimeSeriesDifferentiator(
                            order=diff, window_size=self.window_size
                        )
                        if diff is not None
                        else None
                    )
                    for level, diff in differentiation.items()
                }
            else:
                raise TypeError(
                    f"When including `differentiation`, this argument must be "
                    f"an integer (equal to or greater than 1) or a dict of "
                    f"integers. Got {type(differentiation)}."
                )

        self.fit_kwargs = check_select_fit_kwargs(
            estimator=estimator, fit_kwargs=fit_kwargs
        )

        self.binner = {}
        self.binner_intervals_ = {}
        self.binner_kwargs = binner_kwargs
        if binner_kwargs is None:
            self.binner_kwargs = {
                "n_bins": 10,
                "method": "linear",
                "subsample": 200000,
                "random_state": 789654,
                "dtype": np.float64,
            }

        self.__spotforecast_tags__ = {
            "library": "spotforecast",
            "forecaster_name": "ForecasterRecursiveMultiSeries",
            "forecaster_task": "regression",
            "forecasting_scope": "global",  # single-series | global
            "forecasting_strategy": "recursive",  # recursive | direct | deep_learning
            "index_types_supported": ["pandas.RangeIndex", "pandas.DatetimeIndex"],
            "requires_index_frequency": True,
            "allowed_input_types_series": ["pandas.DataFrame", "dict"],
            "supports_exog": True,
            "allowed_input_types_exog": ["pandas.Series", "pandas.DataFrame", "dict"],
            "handles_missing_values_series": True,
            "handles_missing_values_exog": True,
            "supports_lags": True,
            "supports_window_features": True,
            "allowed_encoding": ["ordinal", "ordinal_category", "onehot", None],
            "supports_transformer_series": True,
            "supports_transformer_exog": True,
            "supports_weight_func": True,
            "supports_series_weights": True,
            "supports_differentiation": True,
            "prediction_types": [
                "point",
                "interval",
                "bootstrapping",
                "quantiles",
                "distribution",
            ],
            "supports_probabilistic": True,
            "probabilistic_methods": ["bootstrapping", "conformal"],
            "handles_binned_residuals": True,
        }

    def __repr__(self) -> str:
        """
        Information displayed when a ForecasterRecursiveMultiSeries object is printed.
        """

        (
            params,
            training_range_,
            series_names_in_,
            exog_names_in_,
            transformer_series,
        ) = [
            self._format_text_repr(value)
            for value in self._preprocess_repr(
                estimator=self.estimator,
                training_range_=self.training_range_,
                series_names_in_=self.series_names_in_,
                exog_names_in_=self.exog_names_in_,
                transformer_series=self.transformer_series,
            )
        ]

        info = (
            f"{'=' * len(type(self).__name__)} \n"
            f"{type(self).__name__} \n"
            f"{'=' * len(type(self).__name__)} \n"
            f"Estimator: {type(self.estimator).__name__} \n"
            f"Lags: {self.lags} \n"
            f"Window features: {self.window_features_names} \n"
            f"Window size: {self.window_size} \n"
            f"Series encoding: {self.encoding} \n"
            f"Series names (levels): {series_names_in_} \n"
            f"Exogenous included: {self.exog_in_} \n"
            f"Exogenous names: {exog_names_in_} \n"
            f"Transformer for series: {transformer_series} \n"
            f"Transformer for exog: {self.transformer_exog} \n"
            f"Weight function included: {True if self.weight_func is not None else False} \n"
            f"Series weights: {self.series_weights} \n"
            f"Differentiation order: {self.differentiation} \n"
            f"Training range: {training_range_} \n"
            f"Training index type: {str(self.index_type_).split('.')[-1][:-2] if self.is_fitted else None} \n"
            f"Training index frequency: {self.index_freq_ if self.is_fitted else None} \n"
            f"Estimator parameters: {params} \n"
            f"fit_kwargs: {self.fit_kwargs} \n"
            f"Creation date: {self.creation_date} \n"
            f"Last fit date: {self.fit_date} \n"
            f"Spotforecast version: {self.spotforecast_version} \n"
            f"Python version: {self.python_version} \n"
            f"Forecaster id: {self.forecaster_id} \n"
        )

        return info

    def _repr_html_(self) -> str:
        """
        HTML representation of the object.
        The "General Information" section is expanded by default.
        """

        (
            params,
            training_range_,
            series_names_in_,
            exog_names_in_,
            transformer_series,
        ) = self._preprocess_repr(
            estimator=self.estimator,
            training_range_=self.training_range_,
            series_names_in_=self.series_names_in_,
            exog_names_in_=self.exog_names_in_,
            transformer_series=self.transformer_series,
        )

        style, unique_id = get_style_repr_html(self.is_fitted)

        content = f"""
        <div class="container-{unique_id}">
            <p style="font-size: 1.5em; font-weight: bold; margin-block-start: 0.83em; margin-block-end: 0.83em;">{type(self).__name__}</p>
            <details open>
                <summary>General Information</summary>
                <ul>
                    <li><strong>Estimator:</strong> {type(self.estimator).__name__}</li>
                    <li><strong>Lags:</strong> {self.lags}</li>
                    <li><strong>Window features:</strong> {self.window_features_names}</li>
                    <li><strong>Window size:</strong> {self.window_size}</li>
                    <li><strong>Series encoding:</strong> {self.encoding}</li>
                    <li><strong>Exogenous included:</strong> {self.exog_in_}</li>
                    <li><strong>Weight function included:</strong> {self.weight_func is not None}</li>
                    <li><strong>Series weights:</strong> {self.series_weights}</li>
                    <li><strong>Differentiation order:</strong> {self.differentiation}</li>
                    <li><strong>Creation date:</strong> {self.creation_date}</li>
                    <li><strong>Last fit date:</strong> {self.fit_date}</li>
                    <li><strong>Spotforecast version:</strong> {self.spotforecast_version}</li>
                    <li><strong>Python version:</strong> {self.python_version}</li>
                    <li><strong>Forecaster id:</strong> {self.forecaster_id}</li>
                </ul>
            </details>
            <details>
                <summary>Exogenous Variables</summary>
                <ul>
                    {exog_names_in_}
                </ul>
            </details>
            <details>
                <summary>Data Transformations</summary>
                <ul>
                    <li><strong>Transformer for series:</strong> {transformer_series}</li>
                    <li><strong>Transformer for exog:</strong> {self.transformer_exog}</li>
                </ul>
            </details>
            <details>
                <summary>Training Information</summary>
                <ul>
                    <li><strong>Series names (levels):</strong> {series_names_in_}</li>
                    <li><strong>Training range:</strong> {training_range_}</li>
                    <li><strong>Training index type:</strong> {str(self.index_type_).split('.')[-1][:-2] if self.is_fitted else 'Not fitted'}</li>
                    <li><strong>Training index frequency:</strong> {self.index_freq_ if self.is_fitted else 'Not fitted'}</li>
                </ul>
            </details>
            <details>
                <summary>Estimator Parameters</summary>
                <ul>
                    {params}
                </ul>
            </details>
            <details>
                <summary>Fit Kwargs</summary>
                <ul>
                    {self.fit_kwargs}
                </ul>
        </div>
        """

        return style + content

    def _create_lags(
        self,
        y: np.ndarray,
        X_as_pandas: bool = False,
        train_index: pd.Index | None = None,
    ) -> tuple[np.ndarray | pd.DataFrame | None, np.ndarray]:
        """
        Create the lagged values and their target variable from a time series.

        Note that the returned matrix `X_data` contains the lag 1 in the first
        column, the lag 2 in the second column and so on.

        Args:
            y (np.ndarray): Training time series values.
            X_as_pandas (bool): If `True`, the returned matrix `X_data` is a
                pandas DataFrame. Defaults to `False`.
            train_index (pd.Index, None): Index of the training data. It is used to
                create the pandas DataFrame `X_data` when `X_as_pandas` is `True`.
                Defaults to `None`.

        Returns:
            tuple: A tuple containing:
                - X_data (np.ndarray, pd.DataFrame, None): Lagged values (predictors).
                - y_data (np.ndarray): Values of the time series related to each
                  row of `X_data`.

        Notes:
            Returned matrices are views into the original `y` so care must be taken
            when modifying them.
        """

        X_data = None
        if self.lags is not None:
            y_strided = np.lib.stride_tricks.sliding_window_view(y, self.window_size)[
                :-1
            ]
            X_data = y_strided[:, self.window_size - self.lags]

            if X_as_pandas:
                X_data = pd.DataFrame(
                    data=X_data, columns=self.lags_names, index=train_index
                )

        y_data = y[self.window_size :]

        return X_data, y_data

    def _create_window_features(
        self,
        y: pd.Series,
        train_index: pd.Index,
        X_as_pandas: bool = False,
    ) -> tuple[list[np.ndarray | pd.DataFrame], list[str]]:
        """
        Create window features from a time series.

        Args:
            y (pd.Series): Training time series.
            train_index (pd.Index): Index of the training data. It is used to
                create the pandas DataFrame `X_train_window_features` when
                `X_as_pandas` is `True`.
            X_as_pandas (bool): If `True`, the returned matrix
                `X_train_window_features` is a pandas DataFrame. Defaults to `False`.

        Returns:
            tuple: A tuple containing:
                - X_train_window_features (list): List of numpy ndarrays or pandas
                  DataFrames with the window features.
                - X_train_window_features_names_out_ (list): Names of the window features.
        """

        len_train_index = len(train_index)
        X_train_window_features = []
        X_train_window_features_names_out_ = []
        for wf in self.window_features:
            X_train_wf = wf.transform_batch(y)
            if not isinstance(X_train_wf, pd.DataFrame):
                raise TypeError(
                    f"The method `transform_batch` of {type(wf).__name__} "
                    f"must return a pandas DataFrame."
                )
            X_train_wf = X_train_wf.iloc[-len_train_index:]
            if not len(X_train_wf) == len_train_index:
                raise ValueError(
                    f"The method `transform_batch` of {type(wf).__name__} "
                    f"must return a DataFrame with the same number of rows as "
                    f"the input time series - `window_size`: {len_train_index}."
                )
            if not (X_train_wf.index == train_index).all():
                raise ValueError(
                    f"The method `transform_batch` of {type(wf).__name__} "
                    f"must return a DataFrame with the same index as "
                    f"the input time series - `window_size`."
                )

            X_train_window_features_names_out_.extend(X_train_wf.columns)
            if not X_as_pandas:
                X_train_wf = X_train_wf.to_numpy()
            X_train_window_features.append(X_train_wf)

        return X_train_window_features, X_train_window_features_names_out_

    def _create_train_X_y_single_series(
        self, y: pd.Series, ignore_exog: bool, exog: pd.DataFrame | None = None
    ) -> tuple[pd.DataFrame, list[str], pd.DataFrame, pd.Series]:
        """
        Create training matrices from univariate time series and exogenous
        variables. This method does not transform the exog variables.

        Args:
            y (pd.Series): Training time series.
            ignore_exog (bool): If `True`, `exog` is ignored.
            exog (pd.DataFrame, None): Exogenous variable/s included as predictor/s.
                Defaults to `None`.

        Returns:
            tuple: A tuple containing:
                - X_train_lags (pd.DataFrame): Training values of lags.
                  Shape: (len(y) - self.max_lag, len(self.lags))
                - X_train_window_features_names_out_ (list): Names of the window features.
                - X_train_exog (pd.DataFrame): Training values of exogenous variables.
                  Shape: (len(y) - self.max_lag, len(exog.columns))
                - y_train (pd.Series): Values (target) of the time series related
                  to each row of `X_train`. Shape: (len(y) - self.max_lag, )
        """

        series_name = y.name
        if len(y) <= self.window_size:
            raise ValueError(
                f"Length of '{series_name}' must be greater than the maximum window size "
                f"needed by the forecaster.\n"
                f"    Length '{series_name}': {len(y)}.\n"
                f"    Max window size: {self.window_size}.\n"
                f"    Lags window size: {self.max_lag}.\n"
                f"    Window features window size: {self.max_size_window_features}."
            )

        if self.encoding is None:
            fit_transformer = False
            transformer_series = self.transformer_series_["_unknown_level"]
        else:
            fit_transformer = False if self.is_fitted else True
            transformer_series = self.transformer_series_[series_name]

        y_values = y.to_numpy()
        y_index = y.index

        y_values = transform_numpy(
            array=y_values,
            transformer=transformer_series,
            fit=fit_transformer,
            inverse_transform=False,
        )

        if self.differentiator_[series_name] is not None:
            if not self.is_fitted:
                y_values = self.differentiator_[series_name].fit_transform(y_values)
            else:
                differentiator = copy(self.differentiator_[series_name])
                y_values = differentiator.fit_transform(y_values)

        X_train_autoreg = []
        train_index = y_index[self.window_size :]

        X_train_lags, y_train = self._create_lags(
            y=y_values, X_as_pandas=True, train_index=train_index
        )
        if X_train_lags is not None:
            X_train_autoreg.append(X_train_lags)

        X_train_window_features_names_out_ = None
        if self.window_features is not None:
            # NOTE: The first `self.differentiation_max` positions of `y_values`
            # must be removed to match the length of `y_train` after creating
            # the window features. This is because `y_train` is created using the
            # global window size of the Forecaster, which includes the maximum
            # differentiation (self.differentiation_max).
            n_diff = 0 if self.differentiation is None else self.differentiation_max
            y_window_features = pd.Series(y_values[n_diff:], index=y_index[n_diff:])
            X_train_window_features, X_train_window_features_names_out_ = (
                self._create_window_features(
                    y=y_window_features, X_as_pandas=True, train_index=train_index
                )
            )
            X_train_autoreg.extend(X_train_window_features)

        if len(X_train_autoreg) == 1:
            X_train_autoreg = X_train_autoreg[0]
        else:
            X_train_autoreg = pd.concat(X_train_autoreg, axis=1)

        X_train_autoreg["_level_skforecast"] = series_name

        if ignore_exog:
            X_train_exog = None
        else:
            if exog is not None:
                # The first `self.window_size` positions have to be removed from exog
                # since they are not in X_train_autoreg.
                X_train_exog = exog.iloc[self.window_size :,]
            else:
                # NOTE: This is faster than creating a pandas Series without values.
                X_train_exog = pd.Series(
                    data=np.nan, index=train_index, name="_dummy_exog_col_to_keep_shape"
                )

        y_train = pd.Series(data=y_train, index=train_index, name="y")

        return (
            X_train_autoreg,
            X_train_window_features_names_out_,
            X_train_exog,
            y_train,
        )

    def _create_train_X_y(
        self,
        series: pd.DataFrame | dict[str, pd.Series | pd.DataFrame],
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        store_last_window: bool | list[str] = True,
    ) -> tuple[
        pd.DataFrame,
        pd.Series,
        dict[str, pd.Index],
        list[str],
        list[str],
        list[str],
        list[str],
        list[str],
        dict[str, type],
        dict[str, type],
        dict[str, pd.Series],
    ]:
        """
        Create training matrices from multiple time series and exogenous variables.

        Args:
            series (pd.DataFrame, dict): Training time series.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            store_last_window (bool, list): Whether or not to store the last window
                (`last_window_`) of training data.
                - If `True`, last window is stored for all series.
                - If `list`, last window is stored for the series present in the list.
                - If `False`, last window is not stored. Defaults to `True`.

        Returns:
            tuple: A tuple containing:
                - X_train (pd.DataFrame): Training values (predictors).
                - y_train (pd.Series): Values of the time series related to each
                  row of `X_train`.
                - series_indexes (dict): Dictionary with the index of each series.
                - series_names_in_ (list): Names of the series (levels) provided
                  by the user during training.
                - X_train_series_names_in_ (list): Names of the series (levels)
                  included in the matrix `X_train` created internally for training.
                - exog_names_in_ (list): Names of the exogenous variables used
                  during training.
                - X_train_window_features_names_out_ (list): Names of the window
                  features included in the matrix `X_train`.
                - X_train_exog_names_out_ (list): Names of the exogenous variables
                  included in the matrix `X_train`.
                - exog_dtypes_in_ (dict): Exogenous variable types before transformation.
                - exog_dtypes_out_ (dict): Exogenous variable types after transformation.
                - last_window_ (dict): Last window of training data for each series.

        Notes:
            - If `series` is a wide-format pandas DataFrame, each column represents
              a different time series.
            - If `series` is a long-format pandas DataFrame with a MultiIndex, the
              first level must contain series IDs.
            - If `series` is a dictionary, each key must be a series ID.
            - If `exog` is a wide-format DataFrame, it must share the same index
              type as series.
            - If `exog` is a long-format Series or DataFrame with a MultiIndex, the
              first level contains series IDs.
            - If `exog` is a dictionary, each key must correspond to a series ID.
        """

        series_dict, series_indexes = check_preprocess_series(series=series)
        series_names_in_ = list(series_dict.keys())

        if self.is_fitted and not set(series_names_in_).issubset(
            set(self.series_names_in_)
        ):
            raise ValueError(
                f"Once the Forecaster has been trained, `series` must contain "
                f"the same series names as those used during training:\n"
                f" Got      : {series_names_in_}\n"
                f" Expected : {self.series_names_in_}"
            )

        exog_dict = {serie: None for serie in series_names_in_}
        exog_names_in_ = None
        if exog is not None:
            exog_dict, exog_names_in_ = check_preprocess_exog_multiseries(
                series_names_in_=series_names_in_,
                series_index_type=type(series_indexes[series_names_in_[0]]),
                exog=exog,
                exog_dict=exog_dict,
            )

            if self.is_fitted:
                if self.exog_names_in_ is None:
                    raise ValueError(
                        "Once the Forecaster has been trained, `exog` must be `None` "
                        "because no exogenous variables were added during training."
                    )
                else:
                    if not set(exog_names_in_) == set(self.exog_names_in_):
                        raise ValueError(
                            f"Once the Forecaster has been trained, `exog` must contain "
                            f"the same exogenous variables as those used during training:\n"
                            f" Got      : {exog_names_in_}\n"
                            f" Expected : {self.exog_names_in_}"
                        )

        if not self.is_fitted:
            self.transformer_series_ = initialize_transformer_series(
                forecaster_name=type(self).__name__,
                series_names_in_=series_names_in_,
                encoding=self.encoding,
                transformer_series=self.transformer_series,
            )

            self.differentiator_ = initialize_differentiator_multiseries(
                series_names_in_=series_names_in_, differentiator=self.differentiator
            )

        series_dict, exog_dict = align_series_and_exog_multiseries(
            series_dict=series_dict, exog_dict=exog_dict
        )

        if (
            not self.is_fitted
            and self.transformer_series_["_unknown_level"] is not None
        ):
            self.transformer_series_["_unknown_level"].fit(
                np.concatenate(list(series_dict.values())).reshape(-1, 1)
            )

        ignore_exog = True if exog is None else False
        input_matrices = [
            [series_dict[k], exog_dict[k], ignore_exog] for k in series_dict.keys()
        ]

        X_train_autoreg_buffer = []
        X_train_exog_buffer = []
        y_train_buffer = []
        for matrices in input_matrices:

            (
                X_train_autoreg,
                X_train_window_features_names_out_,
                X_train_exog,
                y_train,
            ) = self._create_train_X_y_single_series(
                y=matrices[0],
                exog=matrices[1],
                ignore_exog=matrices[2],
            )

            X_train_autoreg_buffer.append(X_train_autoreg)
            X_train_exog_buffer.append(X_train_exog)
            y_train_buffer.append(y_train)

        X_train = pd.concat(X_train_autoreg_buffer, axis=0, copy=False)
        y_train = pd.concat(y_train_buffer, axis=0, copy=False)

        if self.is_fitted:
            encoded_values = self.encoder.transform(X_train[["_level_skforecast"]])
        else:
            encoded_values = self.encoder.fit_transform(X_train[["_level_skforecast"]])
            for i, level in enumerate(self.encoder.categories_[0]):
                self.encoding_mapping_[level] = i

        if self.encoding == "onehot":
            encoded_values.columns = encoded_values.columns.str.replace(
                "_level_skforecast_", ""
            )
            X_train = pd.concat(
                [X_train.drop(columns="_level_skforecast"), encoded_values],
                axis=1,
                copy=False,
            )
        else:
            X_train["_level_skforecast"] = encoded_values

        if self.encoding == "ordinal_category":
            X_train["_level_skforecast"] = X_train["_level_skforecast"].astype(
                "category"
            )

        del encoded_values

        exog_dtypes_in_ = None
        exog_dtypes_out_ = None
        X_train_exog_names_out_ = None
        if exog is not None:

            X_train_exog = pd.concat(X_train_exog_buffer, axis=0, copy=False)

            if isinstance(X_train_exog, pd.Series):
                warnings.warn(
                    f"No exogenous variables were found in `exog` that match the "
                    f"series IDs provided in `series`. As a result, no exogenous "
                    f"variables are included in the training matrices. Please "
                    f"review the series IDs in `exog` and ensure they match the "
                    f"following IDs: {series_names_in_}. The forecaster will be "
                    f"considered trained without exogenous variables.",
                    MissingExogWarning,
                )
            else:
                if "_dummy_exog_col_to_keep_shape" in X_train_exog.columns:
                    X_train_exog = X_train_exog.drop(
                        columns=["_dummy_exog_col_to_keep_shape"]
                    )

                exog_names_in_ = X_train_exog.columns.to_list()
                exog_dtypes_in_ = get_exog_dtypes(exog=X_train_exog)

                fit_transformer = False if self.is_fitted else True
                X_train_exog = transform_dataframe(
                    df=X_train_exog,
                    transformer=self.transformer_exog,
                    fit=fit_transformer,
                    inverse_transform=False,
                )

                if not (X_train_exog.index == X_train.index).all():
                    raise ValueError(
                        "Different index for `series` and `exog` after transformation. "
                        "They must be equal to ensure the correct alignment of values."
                    )

                check_exog_dtypes(X_train_exog, call_check_exog=False)
                exog_dtypes_out_ = get_exog_dtypes(exog=X_train_exog)
                X_train_exog_names_out_ = X_train_exog.columns.to_list()
                X_train = pd.concat([X_train, X_train_exog], axis=1, copy=False)

        if y_train.isna().to_numpy().any():
            mask = y_train.notna().to_numpy()
            y_train = y_train.iloc[mask]
            X_train = X_train.iloc[mask,]
            warnings.warn(
                "NaNs detected in `y_train`. They have been dropped because the "
                "target variable cannot have NaN values. Same rows have been "
                "dropped from `X_train` to maintain alignment. This is caused by "
                "series with interspersed NaNs.",
                MissingValuesWarning,
            )

        if self.dropna_from_series:
            if np.any(X_train.isnull().to_numpy()):
                mask = X_train.notna().all(axis=1).to_numpy()
                X_train = X_train.iloc[mask,]
                y_train = y_train.iloc[mask]
                warnings.warn(
                    "NaNs detected in `X_train`. They have been dropped. If "
                    "you want to keep them, set `forecaster.dropna_from_series = False`. "
                    "Same rows have been removed from `y_train` to maintain alignment. "
                    "This caused by series with interspersed NaNs.",
                    MissingValuesWarning,
                )
        else:
            if np.any(X_train.isnull().to_numpy()):
                warnings.warn(
                    "NaNs detected in `X_train`. Some estimators do not allow "
                    "NaN values during training. If you want to drop them, "
                    "set `forecaster.dropna_from_series = True`.",
                    MissingValuesWarning,
                )

        if X_train.empty:
            raise ValueError(
                "All samples have been removed due to NaNs. Set "
                "`forecaster.dropna_from_series = False` or review `exog` values."
            )

        if self.encoding == "onehot":
            X_train_series_names_in_ = [
                col for col in series_names_in_ if X_train[col].sum() > 0
            ]
        else:
            unique_levels = X_train["_level_skforecast"].unique()
            X_train_series_names_in_ = [
                k for k, v in self.encoding_mapping_.items() if v in unique_levels
            ]

        # The last time window of training data is stored so that lags needed as
        # predictors in the first iteration of `predict()` can be calculated.
        last_window_ = None
        if store_last_window:

            series_to_store = (
                X_train_series_names_in_
                if store_last_window is True
                else store_last_window
            )

            series_not_in_series_dict = set(series_to_store) - set(
                X_train_series_names_in_
            )
            if series_not_in_series_dict:
                warnings.warn(
                    f"Series {series_not_in_series_dict} are not present in "
                    f"`series`. No last window is stored for them.",
                    IgnoredArgumentWarning,
                )
                series_to_store = [
                    s for s in series_to_store if s not in series_not_in_series_dict
                ]

            if series_to_store:
                last_window_ = {
                    k: v.iloc[-self.window_size :].copy()
                    for k, v in series_dict.items()
                    if k in series_to_store
                }

        return (
            X_train,
            y_train,
            series_indexes,
            series_names_in_,
            X_train_series_names_in_,
            exog_names_in_,
            X_train_window_features_names_out_,
            X_train_exog_names_out_,
            exog_dtypes_in_,
            exog_dtypes_out_,
            last_window_,
        )

    def create_train_X_y(
        self,
        series: pd.DataFrame | dict[str, pd.Series | pd.DataFrame],
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        suppress_warnings: bool = False,
    ) -> tuple[pd.DataFrame, pd.Series]:
        """
        Create training matrices from multiple time series and exogenous variables.

        Args:
            series (pd.DataFrame, dict): Training time series.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            suppress_warnings (bool): If `True`, skforecast warnings will be
                suppressed during creation. Defaults to `False`.

        Returns:
            tuple: A tuple containing:
                - X_train (pd.DataFrame): Training values (predictors).
                - y_train (pd.Series): Values (target) of the time series related
                  to each row of `X_train`.

        Notes:
            See `_create_train_X_y` for detailed information on input types.
        """

        set_skforecast_warnings(suppress_warnings, action="ignore")

        output = self._create_train_X_y(
            series=series, exog=exog, store_last_window=False
        )

        X_train = output[0]
        y_train = output[1]

        if self.encoding is None:
            X_train = X_train.drop(columns="_level_skforecast")

        set_skforecast_warnings(suppress_warnings, action="default")

        return X_train, y_train

    def _train_test_split_one_step_ahead(
        self,
        series: dict[str, pd.Series],
        initial_train_size: int,
        exog: dict[str, pd.DataFrame | None] | None = None,
    ) -> tuple[pd.DataFrame, pd.Series, pd.DataFrame, pd.Series, pd.Series, pd.Series]:
        """
        Create matrices needed to train and test the forecaster for one-step-ahead
        predictions.

        Args:
            series (dict): Training time series (already checked/preprocessed).
            initial_train_size (int): Initial size of the training set.
            exog (dict, None): Exogenous variable/s (already checked/preprocessed).
                Defaults to `None`.

        Returns:
            tuple: A tuple containing:
                - X_train (pd.DataFrame): Predictor values used to train the model.
                - y_train (pd.Series): Target values related to each row of `X_train`.
                - X_test (pd.DataFrame): Predictor values used to test the model.
                - y_test (pd.Series): Target values related to each row of `X_test`.
                - X_train_encoding (pd.Series): Series identifiers for each row of `X_train`.
                - X_test_encoding (pd.Series): Series identifiers for each row of `X_test`.
        """

        # NOTE: `series` and `exog` are assumed to be an already checked dict as
        # they have gone through the `check_one_step_ahead_input` function.
        min_index = []
        max_index = []
        for v in series.values():
            idx = v.index
            min_index.append(idx[0])
            max_index.append(idx[-1])

        if isinstance(idx, pd.DatetimeIndex):
            span_index = pd.date_range(
                start=min(min_index), end=max(max_index), freq=idx.freq
            )
        else:
            span_index = pd.RangeIndex(
                start=min(min_index), stop=max(max_index) + 1, step=idx.step
            )

        fold = [
            0,
            [0, initial_train_size],
            [initial_train_size - self.window_size, initial_train_size],
            [initial_train_size - self.window_size, len(span_index)],
            [0, 0],  # Dummy value
            True,
        ]
        data_fold = _extract_data_folds_multiseries(
            series=series,
            folds=[fold],
            span_index=span_index,
            window_size=self.window_size,
            exog=exog,
            dropna_last_window=self.dropna_from_series,
            externally_fitted=False,
        )
        series_train, _, levels_last_window, exog_train, exog_test, _ = next(data_fold)

        start_test_date = span_index[initial_train_size - self.window_size]
        series_test = {
            k: v.loc[start_test_date:]
            for k, v in series.items()
            if k in levels_last_window and not v.empty and not v.isna().to_numpy().all()
        }

        forecaster_state = (self.is_fitted, self.series_names_in_, self.exog_names_in_)

        self.is_fitted = False
        X_train, y_train, _, series_names_in_, _, exog_names_in_, *_ = (
            self._create_train_X_y(
                series=series_train, exog=exog_train, store_last_window=False
            )
        )

        self.series_names_in_ = series_names_in_
        if exog is not None:
            self.exog_names_in_ = exog_names_in_
        self.is_fitted = True

        X_test, y_test, *_ = self._create_train_X_y(
            series=series_test, exog=exog_test, store_last_window=False
        )

        self.is_fitted, self.series_names_in_, self.exog_names_in_ = forecaster_state

        if self.encoding in ["ordinal", "ordinal_category"]:
            X_train_encoding = self.encoder.inverse_transform(
                X_train[["_level_skforecast"]]
            ).ravel()
            X_test_encoding = self.encoder.inverse_transform(
                X_test[["_level_skforecast"]]
            ).ravel()
        elif self.encoding == "onehot":
            encoding_keys = self.encoding_mapping_.keys()
            X_train_encoding = self.encoder.inverse_transform(
                X_train.loc[:, encoding_keys]
            ).ravel()
            X_test_encoding = self.encoder.inverse_transform(
                X_test.loc[:, encoding_keys]
            ).ravel()
        else:
            X_train_encoding = self.encoder.inverse_transform(
                X_train[["_level_skforecast"]]
            ).ravel()
            X_test_encoding = self.encoder.inverse_transform(
                X_test[["_level_skforecast"]]
            ).ravel()
            X_train = X_train.drop(columns="_level_skforecast")
            X_test = X_test.drop(columns="_level_skforecast")

        X_train_encoding = pd.Series(data=X_train_encoding, index=X_train.index).fillna(
            "_unknown_level"
        )
        X_test_encoding = pd.Series(data=X_test_encoding, index=X_test.index).fillna(
            "_unknown_level"
        )

        return X_train, y_train, X_test, y_test, X_train_encoding, X_test_encoding

    def _weight_func_all_1(self, index: pd.Index) -> np.ndarray:
        """
        Weight function that assigns a weight of 1 to all observations.

        Args:
            index (pd.Index): Index of the series.

        Returns:
            np.ndarray: Weights to use in `fit` method.
        """

        weights = np.ones(len(index), dtype=float)

        return weights

    def create_sample_weights(
        self, series_names_in_: list, X_train: pd.DataFrame
    ) -> np.ndarray:
        """
        Create weights for each observation according to the forecaster's attributes
        `series_weights` and `weight_func`. The resulting weights are product of both
        types of weights.

        Args:
            series_names_in_ (list): Names of the series (levels) used during training.
            X_train (pd.DataFrame): Dataframe created with the `create_train_X_y` method.

        Returns:
            np.ndarray: Weights to use in `fit` method.
        """

        weights = None
        weights_samples = None
        series_weights = None

        if self.series_weights is not None:
            # Series not present in series_weights have a weight of 1 in all their samples.
            # Keys in series_weights not present in series are ignored.
            series_not_in_series_weights = set(series_names_in_) - set(
                self.series_weights.keys()
            )
            if series_not_in_series_weights:
                warnings.warn(
                    f"{series_not_in_series_weights} not present in `series_weights`. "
                    f"A weight of 1 is given to all their samples.",
                    IgnoredArgumentWarning,
                )
            self.series_weights_ = {col: 1.0 for col in series_names_in_}
            self.series_weights_.update(
                {
                    k: v
                    for k, v in self.series_weights.items()
                    if k in self.series_weights_
                }
            )

            if self.encoding == "onehot":
                series_weights = [
                    np.repeat(self.series_weights_[serie], sum(X_train[serie]))
                    for serie in series_names_in_
                ]
            else:
                series_weights = [
                    np.repeat(
                        self.series_weights_[serie],
                        sum(
                            X_train["_level_skforecast"]
                            == self.encoding_mapping_[serie]
                        ),
                    )
                    for serie in series_names_in_
                ]

            series_weights = np.concatenate(series_weights)

        if self.weight_func is not None:
            if isinstance(self.weight_func, Callable):
                self.weight_func_ = {
                    col: copy(self.weight_func) for col in series_names_in_
                }
            else:
                # Series not present in weight_func have a weight of 1 in all their samples
                series_not_in_weight_func = set(series_names_in_) - set(
                    self.weight_func.keys()
                )
                if series_not_in_weight_func:
                    warnings.warn(
                        f"{series_not_in_weight_func} not present in `weight_func`. "
                        f"A weight of 1 is given to all their samples.",
                        IgnoredArgumentWarning,
                    )
                self.weight_func_ = {
                    col: self._weight_func_all_1 for col in series_names_in_
                }
                self.weight_func_.update(
                    {
                        k: v
                        for k, v in self.weight_func.items()
                        if k in self.weight_func_
                    }
                )

            weights_samples = []
            for key in self.weight_func_.keys():
                if self.encoding == "onehot":
                    idx = X_train.index[X_train[key] == 1.0]
                else:
                    idx = X_train.index[
                        X_train["_level_skforecast"] == self.encoding_mapping_[key]
                    ]
                weights_samples.append(self.weight_func_[key](idx))
            weights_samples = np.concatenate(weights_samples)

        if series_weights is not None:
            weights = series_weights
            if weights_samples is not None:
                weights = weights * weights_samples
        else:
            if weights_samples is not None:
                weights = weights_samples

        if weights is not None:
            if np.isnan(weights).any():
                raise ValueError("The resulting `weights` cannot have NaN values.")
            if np.any(weights < 0):
                raise ValueError("The resulting `weights` cannot have negative values.")
            if np.sum(weights) == 0:
                raise ValueError(
                    "The resulting `weights` cannot be normalized because "
                    "the sum of the weights is zero."
                )

        return weights

    def fit(
        self,
        series: pd.DataFrame | dict[str, pd.Series | pd.DataFrame],
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        store_last_window: bool | list[str] = True,
        store_in_sample_residuals: bool = False,
        random_state: int = 123,
        suppress_warnings: bool = False,
    ) -> None:
        """
        Training Forecaster.

        Args:
            series (pd.DataFrame, dict): Training time series.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            store_last_window (bool, list): Whether or not to store the last window
                (`last_window_`) of training data. Defaults to `True`.
            store_in_sample_residuals (bool): If `True`, in-sample residuals will
                be stored in the forecaster object after fitting. Defaults to `False`.
            random_state (int): Set a seed for the random generator so that the
                stored sample residuals are always deterministic. Defaults to `123`.
            suppress_warnings (bool): If `True`, skforecast warnings will be
                suppressed during training. Defaults to `False`.

        Returns:
            None

        Notes:
            - If `series` is a wide-format pandas DataFrame, each column represents
              a different time series.
            - If `series` is a long-format pandas DataFrame with a MultiIndex, the
              first level of the index must contain the series IDs.
            - If series is a dictionary, each key must be a series ID.
            - If `exog` is a wide-format pandas DataFrame, it must share the same
              index type as series.
            - If `exog` is a long-format pandas Series or DataFrame with a MultiIndex,
              the first level contains the series IDs.
            - If `exog` is a dictionary, each key must correspond to a series ID.
        """

        set_skforecast_warnings(suppress_warnings, action="ignore")

        # TODO: create a method reset_forecaster() to reset all attributes
        # Reset values in case the forecaster has already been fitted.
        self.last_window_ = None
        self.index_type_ = None
        self.index_freq_ = None
        self.training_range_ = None
        self.series_names_in_ = None
        self.exog_in_ = False
        self.exog_names_in_ = None
        self.exog_type_in_ = None
        self.exog_dtypes_in_ = None
        self.exog_dtypes_out_ = None
        self.X_train_series_names_in_ = None
        self.X_train_window_features_names_out_ = None
        self.X_train_exog_names_out_ = None
        self.X_train_features_names_out_ = None
        self.encoding_mapping_ = {}
        self.in_sample_residuals_ = None
        self.in_sample_residuals_by_bin_ = None
        self.binner = {}
        self.binner_intervals_ = {}
        self.is_fitted = False
        self.fit_date = None

        (
            X_train,
            y_train,
            series_indexes,
            series_names_in_,
            X_train_series_names_in_,
            exog_names_in_,
            X_train_window_features_names_out_,
            X_train_exog_names_out_,
            exog_dtypes_in_,
            exog_dtypes_out_,
            last_window_,
        ) = self._create_train_X_y(
            series=series, exog=exog, store_last_window=store_last_window
        )

        sample_weight = self.create_sample_weights(
            series_names_in_=series_names_in_, X_train=X_train
        )

        X_train_estimator = (
            X_train
            if self.encoding is not None
            else X_train.drop(columns="_level_skforecast")
        )
        if sample_weight is not None:
            self.estimator.fit(
                X=X_train_estimator,
                y=y_train,
                sample_weight=sample_weight,
                **self.fit_kwargs,
            )
        else:
            self.estimator.fit(X=X_train_estimator, y=y_train, **self.fit_kwargs)

        self.series_names_in_ = series_names_in_
        self.X_train_series_names_in_ = X_train_series_names_in_
        self.X_train_window_features_names_out_ = X_train_window_features_names_out_
        self.X_train_features_names_out_ = X_train_estimator.columns.to_list()

        self.is_fitted = True
        self.fit_date = pd.Timestamp.today().strftime("%Y-%m-%d %H:%M:%S")
        self.training_range_ = {k: v[[0, -1]] for k, v in series_indexes.items()}
        self.index_type_ = type(series_indexes[series_names_in_[0]])
        if isinstance(series_indexes[series_names_in_[0]], pd.DatetimeIndex):
            self.index_freq_ = series_indexes[series_names_in_[0]].freq
        else:
            self.index_freq_ = series_indexes[series_names_in_[0]].step

        # NOTE: When `exog` doesn't match series IDs, exogs are not included in the
        # training matrices, X_train_exog_names_out_ is None and the forecaster
        # will be considered trained without exogenous variables.
        if exog is not None and X_train_exog_names_out_ is not None:
            self.exog_in_ = True
            self.exog_names_in_ = exog_names_in_
            self.exog_type_in_ = type(exog)
            self.exog_dtypes_in_ = exog_dtypes_in_
            self.exog_dtypes_out_ = exog_dtypes_out_
            self.X_train_exog_names_out_ = X_train_exog_names_out_

        self.in_sample_residuals_ = {}
        self.in_sample_residuals_by_bin_ = {}
        if self._probabilistic_mode is not False:
            y_train = y_train.to_numpy()
            y_pred = self.estimator.predict(X_train_estimator)
            if self.encoding is not None:
                for level in X_train_series_names_in_:
                    if self.encoding == "onehot":
                        mask = X_train[level].to_numpy() == 1.0
                    else:
                        encoded_value = self.encoding_mapping_[level]
                        mask = X_train["_level_skforecast"].to_numpy() == encoded_value

                    self._binning_in_sample_residuals(
                        level=level,
                        y_true=y_train[mask],
                        y_pred=y_pred[mask],
                        store_in_sample_residuals=store_in_sample_residuals,
                        random_state=random_state,
                    )

            # NOTE: the _unknown_level is a random sample of 10_000 residuals of all levels.
            self._binning_in_sample_residuals(
                level="_unknown_level",
                y_true=y_train,
                y_pred=y_pred,
                store_in_sample_residuals=store_in_sample_residuals,
                random_state=random_state,
            )

        if not store_in_sample_residuals:
            # NOTE: create empty dictionaries to avoid errors when calling predict()
            if self.encoding is not None:
                for level in X_train_series_names_in_:
                    self.in_sample_residuals_[level] = None
                    self.in_sample_residuals_by_bin_[level] = None
            self.in_sample_residuals_["_unknown_level"] = None
            self.in_sample_residuals_by_bin_["_unknown_level"] = None

        if store_last_window:
            self.last_window_ = last_window_

        set_skforecast_warnings(suppress_warnings, action="default")

    def _binning_in_sample_residuals(
        self,
        level: str,
        y_true: np.ndarray,
        y_pred: np.ndarray,
        store_in_sample_residuals: bool = False,
        random_state: int = 123,
    ) -> None:
        """
        Bin residuals according to the predicted value each residual is associated with.

        Args:
            level (str): Name of the series (level) to store the residuals.
            y_true (np.ndarray): True values of the time series.
            y_pred (np.ndarray): Predicted values of the time series.
            store_in_sample_residuals (bool): If `True`, in-sample residuals will
                be stored in the forecaster object. Defaults to `False`.
            random_state (int): Set a seed for the random generator. Defaults to `123`.

        Returns:
            None
        """

        y_true = np.asarray(y_true)
        y_pred = np.asarray(y_pred)
        residuals = y_true - y_pred

        if self._probabilistic_mode == "binned":
            data = pd.DataFrame({"prediction": y_pred, "residuals": residuals})
            self.binner[level] = QuantileBinner(**self.binner_kwargs)
            self.binner[level].fit(y_pred)
            self.binner_intervals_[level] = self.binner[level].intervals_

        if store_in_sample_residuals:
            rng = np.random.default_rng(seed=random_state)
            if self._probabilistic_mode == "binned":
                data["bin"] = self.binner[level].transform(y_pred).astype(int)
                self.in_sample_residuals_by_bin_[level] = (
                    data.groupby("bin")["residuals"].apply(np.array).to_dict()
                )

                max_sample = 10_000 // self.binner[level].n_bins_
                for k, v in self.in_sample_residuals_by_bin_[level].items():
                    if len(v) > max_sample:
                        sample = v[rng.integers(low=0, high=len(v), size=max_sample)]
                        self.in_sample_residuals_by_bin_[level][k] = sample

            if len(residuals) > 10_000:
                residuals = residuals[
                    rng.integers(low=0, high=len(residuals), size=10_000)
                ]
            self.in_sample_residuals_[level] = residuals

    def _create_predict_inputs(
        self,
        steps: int,
        levels: str | list[str] | None = None,
        last_window: pd.DataFrame | None = None,
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        predict_probabilistic: bool = False,
        use_in_sample_residuals: bool = True,
        use_binned_residuals: bool = True,
        check_inputs: bool = True,
    ) -> tuple[pd.DataFrame, dict[str, np.ndarray] | None, list[str], pd.Index]:
        """
        Create the inputs needed for the first iteration of the prediction process.

        Args:
            steps (int): Number of steps to predict.
            levels (str, list, None): Time series to be predicted. Defaults to `None`.
            last_window (pd.DataFrame, None): Series values used to create predictors.
                Defaults to `None`.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            predict_probabilistic (bool): If `True`, perform checks for
                probabilistic predictions. Defaults to `False`.
            use_in_sample_residuals (bool): If `True`, use residuals from training
                data. Defaults to `True`.
            use_binned_residuals (bool): If `True`, residuals are selected based
                on predicted values. Defaults to `True`.
            check_inputs (bool): If `True`, check input for warnings and errors.
                Defaults to `True`.

        Returns:
            tuple: A tuple containing:
                - last_window (pd.DataFrame): Series values for predictors.
                - exog_values_dict (dict, None): Exogenous variable/s for each step.
                - levels (list): Names of the series to be predicted.
                - prediction_index (pd.Index): Index of the predictions.
        """

        input_levels_is_None = True if levels is None else False
        levels, input_levels_is_list = prepare_levels_multiseries(
            X_train_series_names_in_=self.X_train_series_names_in_, levels=levels
        )

        if self.is_fitted:
            if last_window is None:
                levels, last_window = preprocess_levels_self_last_window_multiseries(
                    levels=levels,
                    input_levels_is_list=input_levels_is_list,
                    last_window_=self.last_window_,
                )
            else:
                if isinstance(last_window, pd.DataFrame) and isinstance(
                    last_window.index, pd.MultiIndex
                ):
                    raise ValueError(
                        "`last_window` must be a pandas DataFrame with one column "
                        "per series and a single-level index (MultiIndex is not "
                        "supported)."
                    )
                if input_levels_is_None and isinstance(last_window, pd.DataFrame):
                    levels = last_window.columns.to_list()

        if isinstance(exog, (pd.Series, pd.DataFrame)) and isinstance(
            exog.index, pd.MultiIndex
        ):
            if not isinstance(exog.index.levels[1], pd.DatetimeIndex):
                raise TypeError(
                    f"When `exog` is a pandas MultiIndex DataFrame, its index "
                    f"must be a pandas DatetimeIndex. If you want to use a pandas "
                    f"RangeIndex, use a dictionary instead. Found `exog` index "
                    f"type: {type(exog.index.levels[1])}."
                )

            exog = (
                exog.copy().to_frame() if isinstance(exog, pd.Series) else exog.copy()
            )
            exog = {
                series_id: exog.loc[series_id]
                for series_id in exog.index.levels[0]
                if series_id in levels
            }

        if check_inputs:
            check_predict_input(
                forecaster_name=type(self).__name__,
                steps=steps,
                is_fitted=self.is_fitted,
                exog_in_=self.exog_in_,
                index_type_=self.index_type_,
                index_freq_=self.index_freq_,
                window_size=self.window_size,
                last_window=last_window,
                exog=exog,
                exog_names_in_=self.exog_names_in_,
                interval=None,
                levels=levels,
                series_names_in_=self.series_names_in_,
                encoding=self.encoding,
            )

            if predict_probabilistic:
                check_residuals_input(
                    forecaster_name=type(self).__name__,
                    use_in_sample_residuals=use_in_sample_residuals,
                    in_sample_residuals_=self.in_sample_residuals_,
                    out_sample_residuals_=self.out_sample_residuals_,
                    use_binned_residuals=use_binned_residuals,
                    in_sample_residuals_by_bin_=self.in_sample_residuals_by_bin_,
                    out_sample_residuals_by_bin_=self.out_sample_residuals_by_bin_,
                    levels=levels,
                    encoding=self.encoding,
                )

        last_window = last_window.iloc[
            -self.window_size :, last_window.columns.get_indexer(levels)
        ].copy()
        prediction_index = expand_index(index=last_window.index, steps=steps)

        if exog is not None:
            if isinstance(exog, dict):
                # Empty dataframe to be filled with the exog values of each level
                empty_exog = pd.DataFrame(
                    data={
                        col: pd.Series(dtype=dtype)
                        for col, dtype in self.exog_dtypes_in_.items()
                    },
                    index=prediction_index,
                )
            else:
                exog = input_to_frame(data=exog, input_name="exog")
                if exog.columns.tolist() != self.exog_names_in_:
                    exog = exog[self.exog_names_in_]
                exog = transform_dataframe(
                    df=exog,
                    transformer=self.transformer_exog,
                    fit=False,
                    inverse_transform=False,
                )
                # NOTE: Only check dtypes if they are not the same as seen in training
                if not exog.dtypes.to_dict() == self.exog_dtypes_out_:
                    check_exog_dtypes(exog=exog)
                else:
                    check_exog(exog=exog, allow_nan=False)
                exog_values = exog.iloc[:steps, :]
        else:
            exog_values = None

        # NOTE: This needs to be done to ensure that the last window dtype is float.
        last_window_values = last_window.to_numpy()
        last_window_matrix = np.full(
            shape=last_window.shape, fill_value=np.nan, order="F", dtype=float
        )
        exog_values_all_levels = []
        for idx_level, level in enumerate(levels):
            last_window_level = last_window_values[:, idx_level]
            last_window_level = transform_numpy(
                array=last_window_level,
                transformer=self.transformer_series_.get(
                    level, self.transformer_series_["_unknown_level"]
                ),
                fit=False,
                inverse_transform=False,
            )

            if self.differentiation is not None:
                if level not in self.differentiator_.keys():
                    self.differentiator_[level] = copy(
                        self.differentiator_["_unknown_level"]
                    )
                if self.differentiator_[level] is not None:
                    last_window_level = self.differentiator_[level].fit_transform(
                        last_window_level
                    )

            last_window_matrix[:, idx_level] = last_window_level

            if isinstance(exog, dict):
                # Fill the empty dataframe with the exog values of each level
                # and transform them if necessary
                exog_values = exog.get(level, None)
                if exog_values is not None:
                    if isinstance(exog_values, pd.Series):
                        exog_values = exog_values.to_frame()

                    exog_values = exog_values.reindex_like(empty_exog)
                else:
                    exog_values = empty_exog.copy()

            exog_values_all_levels.append(exog_values)

        last_window = pd.DataFrame(
            data=last_window_matrix, columns=levels, index=last_window.index
        )

        if exog is not None:
            # Exog is transformed into a dict where each key is a step and each value
            # is a numpy array where each column is an exog and each row a series
            exog_values_all_levels = pd.concat(exog_values_all_levels)
            if isinstance(exog, dict):
                exog_values_all_levels = transform_dataframe(
                    df=exog_values_all_levels,
                    transformer=self.transformer_exog,
                    fit=False,
                    inverse_transform=False,
                )

                # NOTE: Only check dtypes if they are not the same as seen in training
                if not exog_values_all_levels.dtypes.to_dict() == self.exog_dtypes_out_:
                    check_exog_dtypes(exog=exog_values_all_levels)
                else:
                    check_exog(exog=exog_values_all_levels, allow_nan=False)

            exog_values_all_levels = exog_values_all_levels.to_numpy()
            exog_values_dict = {
                i + 1: exog_values_all_levels[i::steps, :] for i in range(steps)
            }
        else:
            exog_values_dict = None

        return last_window, exog_values_dict, levels, prediction_index

    def _recursive_predict(
        self,
        steps: int,
        levels: list,
        last_window: pd.DataFrame,
        exog_values_dict: dict[str, np.ndarray] | None = None,
    ) -> np.ndarray:
        """
        Predict n steps for one or multiple levels.

        Args:
            steps (int): Number of steps to predict.
            levels (list): Time series to be predicted.
            last_window (pd.DataFrame): Series values for predictors.
            exog_values_dict (dict, None): Exogenous variable/s for each step.
                Defaults to `None`.

        Returns:
            np.ndarray: Predicted values.
        """

        original_device = set_cpu_gpu_device(estimator=self.estimator, device="cpu")

        n_levels = len(levels)
        n_lags = len(self.lags) if self.lags is not None else 0
        n_window_features = (
            len(self.X_train_window_features_names_out_)
            if self.window_features is not None
            else 0
        )
        n_autoreg = n_lags + n_window_features
        n_exog = (
            len(self.X_train_exog_names_out_) if exog_values_dict is not None else 0
        )

        if self.encoding is not None:
            if self.encoding == "onehot":
                levels_encoded = np.zeros(
                    (n_levels, len(self.X_train_series_names_in_)), dtype=float
                )
                for i, level in enumerate(levels):
                    if level in self.X_train_series_names_in_:
                        levels_encoded[
                            i, self.X_train_series_names_in_.index(level)
                        ] = 1.0
            else:
                levels_encoded = np.array(
                    [self.encoding_mapping_.get(level, np.nan) for level in levels],
                    dtype="float64",
                ).reshape(-1, 1)
            levels_encoded_shape = levels_encoded.shape[1]
        else:
            levels_encoded_shape = 0

        features_shape = n_autoreg + levels_encoded_shape + n_exog
        features = np.full(
            shape=(n_levels, features_shape), fill_value=np.nan, order="F", dtype=float
        )
        if self.encoding is not None:
            features[:, n_autoreg : n_autoreg + levels_encoded_shape] = levels_encoded

        predictions = np.full(
            shape=(steps, n_levels), fill_value=np.nan, order="C", dtype=float
        )
        last_window = np.concatenate((last_window.to_numpy(), predictions), axis=0)

        estimator_name = type(self.estimator).__name__
        is_linear = isinstance(self.estimator, LinearModel)
        is_lightgbm = estimator_name == "LGBMRegressor"
        is_xgboost = estimator_name == "XGBRegressor"

        if is_linear:
            coef = self.estimator.coef_
            intercept = self.estimator.intercept_
        elif is_lightgbm:
            booster = self.estimator.booster_
        elif is_xgboost:
            booster = self.estimator.get_booster()

        has_lags = self.lags is not None
        has_window_features = self.window_features is not None
        has_exog = exog_values_dict is not None

        for i in range(steps):

            if has_lags:
                features[:, :n_lags] = last_window[
                    -self.lags - (steps - i), :
                ].transpose()
            if has_window_features:
                window_data = last_window[i : -(steps - i), :]
                features[:, n_lags:n_autoreg] = np.concatenate(
                    [wf.transform(window_data) for wf in self.window_features], axis=1
                )
            if has_exog:
                features[:, -n_exog:] = exog_values_dict[i + 1]

            if is_linear:
                pred = features.dot(coef) + intercept
            elif is_lightgbm:
                pred = booster.predict(features)
            elif is_xgboost:
                pred = booster.inplace_predict(features)
            else:
                pred = self.estimator.predict(features)

            predictions[i, :] = pred

            # NOTE: CatBoost may make the input array read-only after predict
            if not features.flags.writeable:
                features.flags.writeable = True

            # Update `last_window` values. The first position is discarded and
            # the new prediction is added at the end.
            last_window[-(steps - i), :] = pred

        set_cpu_gpu_device(estimator=self.estimator, device=original_device)

        return predictions

    def _recursive_predict_bootstrapping(
        self,
        steps: int,
        levels: list,
        last_window: pd.DataFrame,
        n_boot: int,
        sampled_residuals: np.ndarray,
        use_binned_residuals: bool,
        exog_values_dict: dict[str, np.ndarray] | None = None,
    ) -> np.ndarray:
        """
        Vectorized bootstrap prediction for multiple series.

        Args:
            steps (int): Number of steps to predict.
            levels (list): Names of the time series (levels) to be predicted.
            last_window (pd.DataFrame): Series values for predictors.
            n_boot (int): Number of bootstrap iterations.
            sampled_residuals (np.ndarray): Pre-sampled residuals.
            use_binned_residuals (bool): If `True`, use binned selection.
            exog_values_dict (dict, None): Exogenous variables by step.
                Defaults to `None`.

        Returns:
            np.ndarray: Bootstrap predictions with shape (steps, n_levels, n_boot).
        """

        original_device = set_cpu_gpu_device(estimator=self.estimator, device="cpu")

        n_levels = len(levels)
        n_lags = len(self.lags) if self.lags is not None else 0
        n_window_features = (
            len(self.X_train_window_features_names_out_)
            if self.window_features is not None
            else 0
        )
        n_autoreg = n_lags + n_window_features
        n_exog = (
            len(self.X_train_exog_names_out_) if exog_values_dict is not None else 0
        )

        # Total samples per step: n_boot × n_levels
        # Row ordering: [level0_boot0, level1_boot0, ..., levelN_boot0, level0_boot1, ...]
        n_samples = n_levels * n_boot

        # Build level encoding (repeated for all bootstrap samples)
        if self.encoding is not None:
            if self.encoding == "onehot":
                levels_encoded = np.zeros(
                    (n_levels, len(self.X_train_series_names_in_)), dtype=float
                )
                for i, level in enumerate(levels):
                    if level in self.X_train_series_names_in_:
                        levels_encoded[
                            i, self.X_train_series_names_in_.index(level)
                        ] = 1.0
            else:
                levels_encoded = np.array(
                    [self.encoding_mapping_.get(level, np.nan) for level in levels],
                    dtype="float64",
                ).reshape(-1, 1)
            levels_encoded_shape = levels_encoded.shape[1]
            # Tile to (n_boot × n_levels, encoded_shape): pattern repeats n_boot times
            levels_encoded = np.tile(levels_encoded, (n_boot, 1))
        else:
            levels_encoded_shape = 0

        features_shape = n_autoreg + levels_encoded_shape + n_exog
        features = np.full(
            shape=(n_samples, features_shape), fill_value=np.nan, order="C", dtype=float
        )
        if self.encoding is not None:
            features[:, n_autoreg : n_autoreg + levels_encoded_shape] = levels_encoded

        boot_predictions = np.full(
            shape=(steps, n_levels, n_boot), fill_value=np.nan, order="C", dtype=float
        )

        # Expand last_window to 3D: (window_size + steps, n_boot, n_levels)
        # All bootstrap samples start with identical last_window values
        last_window = last_window.to_numpy()
        window_size = last_window.shape[0]
        last_window_boot = np.empty(
            (window_size + steps, n_boot, n_levels), dtype=float, order="C"
        )
        last_window_boot[:window_size, :, :] = last_window[:, np.newaxis, :]
        last_window_boot[window_size:, :, :] = np.nan

        estimator_name = type(self.estimator).__name__
        is_linear = isinstance(self.estimator, LinearModel)
        is_lightgbm = estimator_name == "LGBMRegressor"
        is_xgboost = estimator_name == "XGBRegressor"

        if is_linear:
            coef = self.estimator.coef_
            intercept = self.estimator.intercept_
        elif is_lightgbm:
            booster = self.estimator.booster_
        elif is_xgboost:
            booster = self.estimator.get_booster()

        has_lags = self.lags is not None
        has_window_features = self.window_features is not None
        has_exog = exog_values_dict is not None

        for step in range(steps):

            if has_lags:
                lags_indices = window_size + step - self.lags
                # lagged_values shape: (n_lags, n_boot, n_levels)
                lagged_values = last_window_boot[lags_indices, :, :]
                # Reshape to (n_boot x n_levels, n_lags) with correct row ordering
                features[:, :n_lags] = lagged_values.transpose(1, 2, 0).reshape(
                    n_samples, n_lags
                )

            if has_window_features:
                wf_col_offset = n_lags
                for wf in self.window_features:
                    wf_in = last_window_boot[: window_size + step, :, :]
                    # Reshape to (window_length, n_samples)
                    wf_in = wf_in.reshape(window_size + step, n_samples)
                    wf_out = wf.transform(wf_in)
                    n_wf_cols = wf_out.shape[1]
                    features[:, wf_col_offset : wf_col_offset + n_wf_cols] = wf_out
                    wf_col_offset += n_wf_cols

            if has_exog:
                # Reshape (n_levels, n_exog) to (n_boot × n_levels, n_exog)
                features[:, -n_exog:] = np.tile(exog_values_dict[step + 1], (n_boot, 1))

            if is_linear:
                pred = features.dot(coef) + intercept
            elif is_lightgbm:
                pred = booster.predict(features)
            elif is_xgboost:
                pred = booster.inplace_predict(features)
            else:
                pred = self.estimator.predict(features)

            # Reshape from (n_boot × n_levels,) to (n_levels, n_boot)
            pred = pred.reshape(n_boot, n_levels).T

            # NOTE: CatBoost makes the input array read-only.
            if not features.flags.writeable:
                features.flags.writeable = True

            if use_binned_residuals:
                boot_indices = np.arange(n_boot)
                # Vectorized residual lookup for all levels and boots
                # sampled_residuals shape: (n_bins, steps, n_boot, n_levels)
                for j, level in enumerate(levels):
                    binner = self.binner.get(level, self.binner["_unknown_level"])
                    # Transform all predictions for this level at once (n_boot predictions)
                    predicted_bins = binner.transform(pred[j, :]).astype(int)
                    # Vectorized lookup: sampled_residuals[predicted_bins, step, boot_indices, j]
                    pred[j, :] += sampled_residuals[
                        predicted_bins, step, boot_indices, j
                    ]
            else:
                # sampled_residuals shape: (steps, n_levels, n_boot)
                pred += sampled_residuals[step, :, :]

            boot_predictions[step, :, :] = pred

            # Update last_window_boot with new predictions for next step
            # pred is (n_levels, n_boot), transpose for (n_boot, n_levels) layout
            last_window_boot[window_size + step, :, :] = pred.T

        set_cpu_gpu_device(estimator=self.estimator, device=original_device)

        return boot_predictions

    def create_predict_X(
        self,
        steps: int,
        levels: str | list[str] | None = None,
        last_window: pd.DataFrame | None = None,
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        suppress_warnings: bool = False,
        check_inputs: bool = True,
    ) -> pd.DataFrame:
        """
        Create the predictors needed to predict `steps` ahead.

        Args:
            steps (int): Number of steps to predict.
            levels (str, list, None): Time series to be predicted. Defaults to `None`.
            last_window (pd.DataFrame, None): Series values for predictors.
                Defaults to `None`.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            suppress_warnings (bool): If `True`, suppress warnings. Defaults to `False`.
            check_inputs (bool): If `True`, check input for warnings/errors.
                Defaults to `True`.

        Returns:
            pd.DataFrame: Long-format DataFrame with the predictors.
        """

        set_skforecast_warnings(suppress_warnings, action="ignore")

        (last_window, exog_values_dict, levels, prediction_index) = (
            self._create_predict_inputs(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog=exog,
                check_inputs=check_inputs,
            )
        )

        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                message="X does not have valid feature names",
                category=UserWarning,
            )
            predictions = self._recursive_predict(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog_values_dict=exog_values_dict,
            )

        len_X_train_series_names_in_ = len(self.X_train_series_names_in_)
        exog_shape = len(self.X_train_exog_names_out_) if exog is not None else 0

        X_predict = []
        for i, level in enumerate(levels):

            X_predict_level = []
            full_predictors_level = np.concatenate(
                (last_window[level].to_numpy(), predictions[:, i])
            )

            if self.lags is not None:
                idx_lags = np.arange(-steps, 0)[:, None] - self.lags
                # Ensure idx_lags is a valid index array for full_predictors_level
                idx_lags_valid = idx_lags + len(full_predictors_level)
                X_predict_level.append(full_predictors_level[idx_lags_valid])

            if self.window_features is not None:
                X_window_features = np.full(
                    shape=(steps, len(self.X_train_window_features_names_out_)),
                    fill_value=np.nan,
                    order="C",
                    dtype=float,
                )
                for j in range(steps):
                    X_window_features[j, :] = np.concatenate(
                        [
                            wf.transform(full_predictors_level[j : -(steps - j)])
                            for wf in self.window_features
                        ]
                    )
                X_predict_level.append(X_window_features)

            if self.encoding is not None:
                if self.encoding == "onehot":
                    level_encoded = np.zeros(
                        shape=(1, len_X_train_series_names_in_), dtype=float
                    )
                    level_encoded[0][self.X_train_series_names_in_.index(level)] = 1.0
                else:
                    level_encoded = np.array(
                        [self.encoding_mapping_.get(level, None)], dtype="float64"
                    )

                level_encoded = np.tile(level_encoded, (steps, 1))
                X_predict_level.append(level_encoded)

            if exog is not None:
                exog_cols = np.full(
                    shape=(steps, exog_shape), fill_value=np.nan, order="C", dtype=float
                )
                for j in range(steps):
                    exog_cols[j, :] = exog_values_dict[j + 1][i, :]
                X_predict_level.append(exog_cols)

            X_predict.append(np.concatenate(X_predict_level, axis=1))

        X_predict = pd.DataFrame(
            data=np.concatenate(X_predict),
            index=np.tile(prediction_index, len(levels)),
            columns=self.X_train_features_names_out_,
        )
        X_predict.insert(0, "level", np.repeat(levels, steps))

        # NOTE: Order needed to have the same structure as the output of predict methods.
        order_dict = {level: i for i, level in enumerate(levels)}
        X_predict["order"] = X_predict["level"].map(order_dict)
        X_predict = (
            X_predict.reset_index()
            .sort_values(by=["index", "order"])
            .set_index("index")
            .rename_axis(index=None)
            .drop(columns="order")
        )

        if self.exog_in_:
            categorical_features = any(
                not pd.api.types.is_numeric_dtype(dtype)
                or pd.api.types.is_bool_dtype(dtype)
                for dtype in set(self.exog_dtypes_out_.values())
            )
            if categorical_features:
                X_predict = X_predict.astype(self.exog_dtypes_out_)

        if self.transformer_series is not None or self.differentiation is not None:
            warnings.warn(
                "The output matrix is in the transformed scale due to the "
                "inclusion of transformations or differentiation in the Forecaster. "
                "As a result, any predictions generated using this matrix will also "
                "be in the transformed scale. Please refer to the documentation "
                "for more details: "
                "https://skforecast.org/latest/user_guides/training-and-prediction-matrices.html",
                DataTransformationWarning,
            )

        set_skforecast_warnings(suppress_warnings, action="default")

        return X_predict

    def predict(
        self,
        steps: int,
        levels: str | list[str] | None = None,
        last_window: pd.DataFrame | None = None,
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        suppress_warnings: bool = False,
        check_inputs: bool = True,
    ) -> pd.DataFrame:
        """
        Predict n steps ahead.

        Args:
            steps (int): Number of steps to predict.
            levels (str, list, None): Time series to be predicted. Defaults to `None`.
            last_window (pd.DataFrame, None): Series values for predictors.
                Defaults to `None`.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            suppress_warnings (bool): If `True`, suppress warnings. Defaults to `False`.
            check_inputs (bool): If `True`, check input for warnings/errors.
                Defaults to `True`.

        Returns:
            pd.DataFrame: Long-format DataFrame with the predictions.
        """

        set_skforecast_warnings(suppress_warnings, action="ignore")

        (last_window, exog_values_dict, levels, prediction_index) = (
            self._create_predict_inputs(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog=exog,
                check_inputs=check_inputs,
            )
        )

        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                message="X does not have valid feature names",
                category=UserWarning,
            )
            predictions = self._recursive_predict(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog_values_dict=exog_values_dict,
            )

        for i, level in enumerate(levels):
            if (
                self.differentiation is not None
                and self.differentiator_[level] is not None
            ):
                predictions[:, i] = self.differentiator_[
                    level
                ].inverse_transform_next_window(predictions[:, i])

            predictions[:, i] = transform_numpy(
                array=predictions[:, i],
                transformer=self.transformer_series_.get(
                    level, self.transformer_series_["_unknown_level"]
                ),
                fit=False,
                inverse_transform=True,
            )

        n_steps, n_levels = predictions.shape
        predictions = pd.DataFrame(
            {"level": np.tile(levels, n_steps), "pred": predictions.ravel()},
            index=np.repeat(prediction_index, n_levels),
        )

        set_skforecast_warnings(suppress_warnings, action="default")

        return predictions

    def predict_bootstrapping(
        self,
        steps: int,
        levels: str | list[str] | None = None,
        last_window: pd.DataFrame | None = None,
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        n_boot: int = 250,
        use_in_sample_residuals: bool = True,
        use_binned_residuals: bool = True,
        random_state: int = 123,
        suppress_warnings: bool = False,
    ) -> pd.DataFrame:
        """
        Generate multiple forecasting predictions using a bootstrapping process.

        Args:
            steps (int): Number of steps to predict.
            levels (str, list, None): Time series to be predicted. Defaults to `None`.
            last_window (pd.DataFrame, None): Series values used to create predictors.
                Defaults to `None`.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            n_boot (int): Number of bootstrapping iterations. Defaults to `250`.
            use_in_sample_residuals (bool): If `True`, use residuals from training
                data. Defaults to `True`.
            use_binned_residuals (bool): If `True`, residuals are selected based
                on predicted values. Defaults to `True`.
            random_state (int): Seed for reproducibility. Defaults to `123`.
            suppress_warnings (bool): If `True`, suppress warnings. Defaults to `False`.

        Returns:
            pd.DataFrame: Long-format DataFrame with the bootstrapping predictions.

        References:
            [1] Forecasting: Principles and Practice (3rd ed) Rob J Hyndman and George Athanasopoulos.
                https://otexts.com/fpp3/prediction-intervals.html
        """

        set_skforecast_warnings(suppress_warnings, action="ignore")

        (last_window, exog_values_dict, levels, prediction_index) = (
            self._create_predict_inputs(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog=exog,
                predict_probabilistic=True,
                use_in_sample_residuals=use_in_sample_residuals,
                use_binned_residuals=use_binned_residuals,
            )
        )

        if use_in_sample_residuals:
            residuals = self.in_sample_residuals_
            residuals_by_bin = self.in_sample_residuals_by_bin_
        else:
            residuals = self.out_sample_residuals_
            residuals_by_bin = self.out_sample_residuals_by_bin_

        n_levels = len(levels)
        rng = np.random.default_rng(seed=random_state)

        if use_binned_residuals:
            # Pre-allocate 4D array directly: (n_bins, steps, n_boot, n_levels)
            # Loop order must match original to preserve RNG sequence for reproducibility
            n_bins = self.binner_kwargs["n_bins"]
            sampled_residuals = np.empty(
                (n_bins, steps, n_boot, n_levels), order="C", dtype=float
            )
            for bin_idx in range(n_bins):
                for i, level in enumerate(levels):
                    sampled_residuals[bin_idx, :, :, i] = rng.choice(
                        a=residuals_by_bin.get(
                            level, residuals_by_bin["_unknown_level"]
                        )[bin_idx],
                        size=(steps, n_boot),
                        replace=True,
                    )
        else:
            sampled_residuals = np.full(
                shape=(steps, n_levels, n_boot),
                fill_value=np.nan,
                order="C",
                dtype=float,
            )
            for i, level in enumerate(levels):
                sampled_residuals[:, i, :] = rng.choice(
                    a=residuals.get(level, residuals["_unknown_level"]),
                    size=(steps, n_boot),
                    replace=True,
                )

        boot_columns = [f"pred_boot_{i}" for i in range(n_boot)]

        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                message="X does not have valid feature names",
                category=UserWarning,
            )
            boot_predictions = self._recursive_predict_bootstrapping(
                steps=steps,
                levels=levels,
                last_window=last_window,
                n_boot=n_boot,
                sampled_residuals=sampled_residuals,
                use_binned_residuals=use_binned_residuals,
                exog_values_dict=exog_values_dict,
            )

        for i, level in enumerate(levels):

            if (
                self.differentiation is not None
                and self.differentiator_[level] is not None
            ):
                boot_predictions[:, i, :] = self.differentiator_[
                    level
                ].inverse_transform_next_window(boot_predictions[:, i, :])

            transformer_level = self.transformer_series_.get(
                level, self.transformer_series_["_unknown_level"]
            )
            if transformer_level is not None:
                boot_predictions[:, i, :] = transform_numpy(
                    array=boot_predictions[:, i, :],
                    transformer=transformer_level,
                    fit=False,
                    inverse_transform=True,
                )

        boot_predictions = pd.DataFrame(
            data=boot_predictions.reshape(-1, n_boot),
            index=np.repeat(prediction_index, n_levels),
            columns=boot_columns,
        )
        boot_predictions.insert(0, "level", np.tile(levels, steps))

        set_skforecast_warnings(suppress_warnings, action="default")

        return boot_predictions

    def _predict_interval_conformal(
        self,
        steps: int | str | pd.Timestamp,
        levels: str | list[str] | None = None,
        last_window: pd.Series | pd.DataFrame | None = None,
        exog: pd.Series | pd.DataFrame | None = None,
        nominal_coverage: float = 0.95,
        use_in_sample_residuals: bool = True,
        use_binned_residuals: bool = True,
    ) -> pd.DataFrame:
        """
        Generate prediction intervals using the conformal prediction split method.

        Args:
            steps (int, str, pd.Timestamp): Number of steps to predict.
            levels (str, list, None): Time series to be predicted. Defaults to `None`.
            last_window (pd.Series, pd.DataFrame, None): Series values used to
                create predictors. Defaults to `None`.
            exog (pd.Series, pd.DataFrame, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            nominal_coverage (float): Nominal coverage of the prediction intervals.
                Defaults to `0.95`.
            use_in_sample_residuals (bool): If `True`, use residuals from training
                data. Defaults to `True`.
            use_binned_residuals (bool): If `True`, residuals are selected based
                on predicted values. Defaults to `True`.

        Returns:
            pd.DataFrame: Values predicted by the forecaster and their estimated interval.
        """

        (last_window, exog_values_dict, levels, prediction_index) = (
            self._create_predict_inputs(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog=exog,
                predict_probabilistic=True,
                use_in_sample_residuals=use_in_sample_residuals,
                use_binned_residuals=use_binned_residuals,
            )
        )

        if use_in_sample_residuals:
            residuals = self.in_sample_residuals_
            residuals_by_bin = self.in_sample_residuals_by_bin_
        else:
            residuals = self.out_sample_residuals_
            residuals_by_bin = self.out_sample_residuals_by_bin_

        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                message="X does not have valid feature names",
                category=UserWarning,
            )
            predictions = self._recursive_predict(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog_values_dict=exog_values_dict,
            )

        n_levels = len(levels)
        correction_factor = np.full(
            shape=(steps, n_levels), fill_value=np.nan, order="C", dtype=float
        )
        if use_binned_residuals:
            for i, level in enumerate(levels):
                residuals_level = residuals_by_bin.get(
                    level, residuals_by_bin["_unknown_level"]
                )
                correction_factor_by_bin = {
                    k: np.quantile(np.abs(v), nominal_coverage)
                    for k, v in residuals_level.items()
                }
                replace_func = np.vectorize(lambda x: correction_factor_by_bin[x])
                predictions_bin = self.binner.get(
                    level, self.binner["_unknown_level"]
                ).transform(predictions[:, i])
                correction_factor[:, i] = replace_func(predictions_bin)
        else:
            for i, level in enumerate(levels):
                correction_factor[:, i] = np.quantile(
                    np.abs(residuals.get(level, residuals["_unknown_level"])),
                    nominal_coverage,
                )

        lower_bound = predictions - correction_factor
        upper_bound = predictions + correction_factor

        # NOTE: Create a 3D array with shape (n_levels, intervals, steps)
        predictions = np.array([predictions, lower_bound, upper_bound]).swapaxes(0, 2)

        for i, level in enumerate(levels):

            if (
                self.differentiation is not None
                and self.differentiator_[level] is not None
            ):
                predictions[i, :, :] = self.differentiator_[
                    level
                ].inverse_transform_next_window(predictions[i, :, :])

            transformer_level = self.transformer_series_.get(
                level, self.transformer_series_["_unknown_level"]
            )
            if transformer_level is not None:
                predictions[i, :, :] = transform_numpy(
                    array=predictions[i, :, :],
                    transformer=transformer_level,
                    fit=False,
                    inverse_transform=True,
                )

        predictions = pd.DataFrame(
            data=predictions.swapaxes(0, 1).reshape(-1, 3),
            index=np.repeat(prediction_index, n_levels),
            columns=["pred", "lower_bound", "upper_bound"],
        )
        predictions.insert(0, "level", np.tile(levels, steps))

        return predictions

    def predict_interval(
        self,
        steps: int,
        levels: str | list[str] | None = None,
        last_window: pd.DataFrame | None = None,
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        method: str = "conformal",
        interval: float | list[float] | tuple[float] = [5, 95],
        n_boot: int = 250,
        use_in_sample_residuals: bool = True,
        use_binned_residuals: bool = True,
        random_state: int = 123,
        suppress_warnings: bool = False,
    ) -> pd.DataFrame:
        """
        Predict n steps ahead and estimate prediction intervals.

        Args:
            steps (int): Number of steps to predict.
            levels (str, list, None): Time series to be predicted. Defaults to `None`.
            last_window (pd.DataFrame, None): Series values used to create predictors.
                Defaults to `None`.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            method (str): Technique used to estimate prediction intervals.
                Options: 'bootstrapping', 'conformal'. Defaults to `'conformal'`.
            interval (float, list, tuple): Confidence level of the prediction interval.
                Defaults to `[5, 95]`.
            n_boot (int): Number of bootstrapping iterations. Defaults to `250`.
            use_in_sample_residuals (bool): If `True`, use residuals from training
                data. Defaults to `True`.
            use_binned_residuals (bool): If `True`, residuals are selected based
                on predicted values. Defaults to `True`.
            random_state (int): Seed for reproducibility. Defaults to `123`.
            suppress_warnings (bool): If `True`, suppress warnings. Defaults to `False`.

        Returns:
            pd.DataFrame: Long-format DataFrame with predictions and intervals.
        """

        set_skforecast_warnings(suppress_warnings, action="ignore")

        if method == "bootstrapping":

            if isinstance(interval, (list, tuple)):
                check_interval(interval=interval, ensure_symmetric_intervals=False)
                interval = np.array(interval) / 100
            else:
                check_interval(alpha=interval, alpha_literal="interval")
                interval = np.array([0.5 - interval / 2, 0.5 + interval / 2])

            boot_predictions = self.predict_bootstrapping(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog=exog,
                n_boot=n_boot,
                use_in_sample_residuals=use_in_sample_residuals,
                use_binned_residuals=use_binned_residuals,
                random_state=random_state,
                suppress_warnings=suppress_warnings,
            )

            predictions = self.predict(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog=exog,
                suppress_warnings=suppress_warnings,
                check_inputs=False,
            )

            boot_predictions[["lower_bound", "upper_bound"]] = (
                boot_predictions.iloc[:, 1:].quantile(q=interval, axis=1).transpose()
            )
            predictions = pd.concat(
                [predictions, boot_predictions[["lower_bound", "upper_bound"]]], axis=1
            )

        elif method == "conformal":

            if isinstance(interval, (list, tuple)):
                check_interval(interval=interval, ensure_symmetric_intervals=True)
                nominal_coverage = (interval[1] - interval[0]) / 100
            else:
                check_interval(alpha=interval, alpha_literal="interval")
                nominal_coverage = interval

            predictions = self._predict_interval_conformal(
                steps=steps,
                levels=levels,
                last_window=last_window,
                exog=exog,
                nominal_coverage=nominal_coverage,
                use_in_sample_residuals=use_in_sample_residuals,
                use_binned_residuals=use_binned_residuals,
            )
        else:
            raise ValueError(
                f"Invalid `method` '{method}'. Choose 'bootstrapping' or 'conformal'."
            )

        set_skforecast_warnings(suppress_warnings, action="default")

        return predictions

    def predict_quantiles(
        self,
        steps: int,
        levels: str | list[str] | None = None,
        last_window: pd.DataFrame | None = None,
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        quantiles: list[float] | tuple[float] = [0.05, 0.5, 0.95],
        n_boot: int = 250,
        use_in_sample_residuals: bool = True,
        use_binned_residuals: bool = True,
        random_state: int = 123,
        suppress_warnings: bool = False,
    ) -> pd.DataFrame:
        """
        Calculate the specified quantiles for each step.

        Args:
            steps (int): Number of steps to predict.
            levels (str, list, None): Time series to be predicted. Defaults to `None`.
            last_window (pd.DataFrame, None): Series values used to create predictors.
                Defaults to `None`.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            quantiles (list, tuple): Sequence of quantiles to compute (0 to 1).
                Defaults to `[0.05, 0.5, 0.95]`.
            n_boot (int): Number of bootstrapping iterations. Defaults to `250`.
            use_in_sample_residuals (bool): If `True`, use residuals from training
                data. Defaults to `True`.
            use_binned_residuals (bool): If `True`, residuals are selected based
                on predicted values. Defaults to `True`.
            random_state (int): Seed for reproducibility. Defaults to `123`.
            suppress_warnings (bool): If `True`, suppress warnings. Defaults to `False`.

        Returns:
            pd.DataFrame: Long-format DataFrame with the predicted quantiles.
        """

        set_skforecast_warnings(suppress_warnings, action="ignore")

        check_interval(quantiles=quantiles)

        predictions = self.predict_bootstrapping(
            steps=steps,
            levels=levels,
            last_window=last_window,
            exog=exog,
            n_boot=n_boot,
            use_in_sample_residuals=use_in_sample_residuals,
            use_binned_residuals=use_binned_residuals,
            random_state=random_state,
            suppress_warnings=suppress_warnings,
        )

        quantiles_cols = [f"q_{q}" for q in quantiles]
        predictions[quantiles_cols] = (
            predictions.iloc[:, 1:].quantile(q=quantiles, axis=1).transpose()
        )
        predictions = predictions[["level"] + quantiles_cols]

        set_skforecast_warnings(suppress_warnings, action="default")

        return predictions

    def predict_dist(
        self,
        steps: int,
        distribution: object,
        levels: str | list[str] | None = None,
        last_window: pd.DataFrame | None = None,
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        n_boot: int = 250,
        use_in_sample_residuals: bool = True,
        use_binned_residuals: bool = True,
        random_state: int = 123,
        suppress_warnings: bool = False,
    ) -> pd.DataFrame:
        """
        Fit a given probability distribution for each step.

        Args:
            steps (int): Number of steps to predict.
            distribution (object): A distribution object from scipy.stats.
            levels (str, list, None): Time series to be predicted. Defaults to `None`.
            last_window (pd.DataFrame, None): Series values used to create predictors.
                Defaults to `None`.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            n_boot (int): Number of bootstrapping iterations. Defaults to `250`.
            use_in_sample_residuals (bool): If `True`, use residuals from training
                data. Defaults to `True`.
            use_binned_residuals (bool): If `True`, residuals are selected based
                on predicted values. Defaults to `True`.
            random_state (int): Seed for reproducibility. Defaults to `123`.
            suppress_warnings (bool): If `True`, suppress warnings. Defaults to `False`.

        Returns:
            pd.DataFrame: Long-format DataFrame with parameters of the fitted distribution.
        """

        if not hasattr(distribution, "_pdf") or not callable(
            getattr(distribution, "fit", None)
        ):
            raise TypeError(
                "`distribution` must be a valid probability distribution object "
                "from scipy.stats, with methods `_pdf` and `fit`."
            )

        set_skforecast_warnings(suppress_warnings, action="ignore")

        predictions = self.predict_bootstrapping(
            steps=steps,
            levels=levels,
            last_window=last_window,
            exog=exog,
            n_boot=n_boot,
            use_in_sample_residuals=use_in_sample_residuals,
            use_binned_residuals=use_binned_residuals,
            random_state=random_state,
            suppress_warnings=suppress_warnings,
        )

        param_names = [
            p for p in inspect.signature(distribution._pdf).parameters if not p == "x"
        ] + ["loc", "scale"]

        predictions[param_names] = predictions.iloc[:, 1:].apply(
            lambda x: distribution.fit(x), axis=1, result_type="expand"
        )
        predictions = predictions[["level"] + param_names]

        set_skforecast_warnings(suppress_warnings, action="default")

        return predictions

    def set_params(self, params: dict[str, object]) -> None:
        """
        Set new values to the parameters of the scikit-learn model.

        Args:
            params (dict): Parameter values.

        Returns:
            None
        """

        self.estimator = clone(self.estimator)
        self.estimator.set_params(**params)
        self.is_fitted = False

    def set_fit_kwargs(self, fit_kwargs: dict[str, object]) -> None:
        """
        Set new values for the additional keyword arguments passed to the `fit` method.

        Args:
            fit_kwargs (dict): Dict of the form {"argument": new_value}.

        Returns:
            None
        """

        self.fit_kwargs = check_select_fit_kwargs(self.estimator, fit_kwargs=fit_kwargs)

    def set_lags(
        self, lags: int | list[int] | np.ndarray[int] | range[int] | None = None
    ) -> None:
        """
        Set new value to the attribute `lags`.

        Args:
            lags (int, list, np.ndarray, range, None): Lags used as predictors.
                Defaults to `None`.

        Returns:
            None
        """

        if self.window_features is None and lags is None:
            raise ValueError(
                "At least one of the arguments `lags` or `window_features` "
                "must be different from None. This is required to create the "
                "predictors used in training the forecaster."
            )

        self.lags, self.lags_names, self.max_lag = initialize_lags(
            type(self).__name__, lags
        )
        self.window_size = max(
            [
                ws
                for ws in [self.max_lag, self.max_size_window_features]
                if ws is not None
            ]
        )

        if self.differentiation is not None:
            self.window_size += self.differentiation_max
            if isinstance(self.differentiator, dict):
                for series in self.differentiator.keys():
                    if self.differentiator[series] is not None:
                        self.differentiator[series].set_params(
                            window_size=self.window_size
                        )
            else:
                self.differentiator.set_params(window_size=self.window_size)

    def set_window_features(
        self, window_features: object | list[object] | None = None
    ) -> None:
        """
        Set new value to the attribute `window_features`.

        Args:
            window_features (object, list, None): Instance or list of instances
                used to create window features. Defaults to `None`.

        Returns:
            None
        """

        if window_features is None and self.lags is None:
            raise ValueError(
                "At least one of the arguments `lags` or `window_features` "
                "must be different from None. This is required to create the "
                "predictors used in training the forecaster."
            )

        (
            self.window_features,
            self.window_features_names,
            self.max_size_window_features,
        ) = initialize_window_features(window_features)
        self.window_features_class_names = None
        if window_features is not None:
            self.window_features_class_names = [
                type(wf).__name__ for wf in self.window_features
            ]
        self.window_size = max(
            [
                ws
                for ws in [self.max_lag, self.max_size_window_features]
                if ws is not None
            ]
        )

        if self.differentiation is not None:
            self.window_size += self.differentiation_max
            if isinstance(self.differentiator, dict):
                for series in self.differentiator.keys():
                    if self.differentiator[series] is not None:
                        self.differentiator[series].set_params(
                            window_size=self.window_size
                        )
            else:
                self.differentiator.set_params(window_size=self.window_size)

    def set_in_sample_residuals(
        self,
        series: pd.DataFrame | dict[str, pd.Series | pd.DataFrame],
        exog: (
            pd.Series | pd.DataFrame | dict[str, pd.Series | pd.DataFrame] | None
        ) = None,
        random_state: int = 123,
        suppress_warnings: bool = False,
    ) -> None:
        """
        Set in-sample residuals in case they were not calculated during training.

        Args:
            series (pd.DataFrame, dict): Training time series.
            exog (pd.Series, pd.DataFrame, dict, None): Exogenous variable/s
                included as predictor/s. Defaults to `None`.
            random_state (int): Seed for reproducibility. Defaults to `123`.
            suppress_warnings (bool): If `True`, suppress warnings. Defaults to `False`.

        Returns:
            None
        """

        set_skforecast_warnings(suppress_warnings, action="ignore")

        if not self.is_fitted:
            raise NotFittedError(
                "This forecaster is not fitted yet. Call `fit` with appropriate "
                "arguments before using `set_in_sample_residuals()`."
            )

        (X_train, y_train, series_indexes, _, X_train_series_names_in_, *_) = (
            self._create_train_X_y(series=series, exog=exog, store_last_window=False)
        )

        # NOTE: Same series names as training is checked in _create_train_X_y.
        series_index_range = {k: v[[0, -1]] for k, v in series_indexes.items()}
        for level in self.training_range_.keys():
            if not series_index_range[level].equals(self.training_range_[level]):
                raise IndexError(
                    f"The index range for series '{level}' does not match the range "
                    f"used during training. Please ensure the index is aligned "
                    f"with the training data.\n"
                    f"    Expected : {self.training_range_[level]}\n"
                    f"    Received : {series_index_range[level]}"
                )

        X_train_estimator = (
            X_train
            if self.encoding is not None
            else X_train.drop(columns="_level_skforecast")
        )
        X_train_features_names_out_ = X_train_estimator.columns.to_list()
        if not X_train_features_names_out_ == self.X_train_features_names_out_:
            raise ValueError(
                f"Feature mismatch detected after matrix creation. The features "
                f"generated from the provided data do not match those used during "
                f"the training process. To correctly set in-sample residuals, "
                f"ensure that the same data and preprocessing steps are applied.\n"
                f"    Expected output : {self.X_train_features_names_out_}\n"
                f"    Current output  : {X_train_features_names_out_}"
            )

        self.in_sample_residuals_ = {}
        self.in_sample_residuals_by_bin_ = {}
        y_pred = self.estimator.predict(X_train_estimator)
        if self.encoding is not None:
            for level in X_train_series_names_in_:
                if self.encoding == "onehot":
                    mask = X_train[level].to_numpy() == 1.0
                else:
                    encoded_value = self.encoding_mapping_[level]
                    mask = X_train["_level_skforecast"].to_numpy() == encoded_value

                self._binning_in_sample_residuals(
                    level=level,
                    y_true=y_train[mask],
                    y_pred=y_pred[mask],
                    store_in_sample_residuals=True,
                    random_state=random_state,
                )

        # NOTE: the _unknown_level is a random sample of 10_000 residuals of all levels.
        self._binning_in_sample_residuals(
            level="_unknown_level",
            y_true=y_train,
            y_pred=y_pred,
            store_in_sample_residuals=True,
            random_state=random_state,
        )

        set_skforecast_warnings(suppress_warnings, action="default")

    def set_out_sample_residuals(
        self,
        y_true: dict[str, np.ndarray | pd.Series],
        y_pred: dict[str, np.ndarray | pd.Series],
        append: bool = False,
        random_state: int = 123,
    ) -> None:
        """
        Set new values to the attribute `out_sample_residuals_`.

        Args:
            y_true (dict): Dictionary with the true values of the time series.
            y_pred (dict): Dictionary with the predicted values of the time series.
            append (bool): If `True`, new residuals are added to the existing ones.
                Defaults to `False`.
            random_state (int): Seed for reproducibility. Defaults to `123`.

        Returns:
            None

        Notes:
            Out-of-sample residuals can only be stored for series seen during fit.
        """

        if not self.is_fitted:
            raise NotFittedError(
                "This forecaster is not fitted yet. Call `fit` with appropriate "
                "arguments before using `set_out_sample_residuals()`."
            )

        if not isinstance(y_true, dict):
            raise TypeError(
                f"`y_true` must be a dictionary of numpy ndarrays or pandas Series. "
                f"Got {type(y_true)}."
            )

        if not isinstance(y_pred, dict):
            raise TypeError(
                f"`y_pred` must be a dictionary of numpy ndarrays or pandas Series. "
                f"Got {type(y_pred)}."
            )

        if not set(y_true.keys()) == set(y_pred.keys()):
            raise ValueError(
                f"`y_true` and `y_pred` must have the same keys. "
                f"Got {set(y_true.keys())} and {set(y_pred.keys())}."
            )

        for k in y_true.keys():
            if not isinstance(y_true[k], (np.ndarray, pd.Series)):
                raise TypeError(
                    f"Values of `y_true` must be numpy ndarrays or pandas Series. "
                    f"Got {type(y_true[k])} for series '{k}'."
                )
            if not isinstance(y_pred[k], (np.ndarray, pd.Series)):
                raise TypeError(
                    f"Values of `y_pred` must be numpy ndarrays or pandas Series. "
                    f"Got {type(y_pred[k])} for series '{k}'."
                )
            if len(y_true[k]) != len(y_pred[k]):
                raise ValueError(
                    f"`y_true` and `y_pred` must have the same length. "
                    f"Got {len(y_true[k])} and {len(y_pred[k])} for series '{k}'."
                )
            if isinstance(y_true[k], pd.Series) and isinstance(y_pred[k], pd.Series):
                if not y_true[k].index.equals(y_pred[k].index):
                    raise ValueError(
                        f"When containing pandas Series, elements in `y_true` and "
                        f"`y_pred` must have the same index. Error with series '{k}'."
                    )

        # NOTE: Out-of-sample residuals can only be stored for series seen during
        # fit. To save residuals for unseen levels use the key '_unknown_level'.
        series_names_in_ = self.series_names_in_ + ["_unknown_level"]
        series_to_update = set(y_pred.keys()).intersection(set(series_names_in_))
        if not series_to_update:
            raise ValueError(
                "Provided keys in `y_pred` and `y_true` do not match any series "
                "seen during `fit`. Residuals cannot be updated."
            )

        if self.out_sample_residuals_ is None:
            if self.encoding is not None:
                self.out_sample_residuals_ = {level: None for level in series_names_in_}
                self.out_sample_residuals_by_bin_ = {
                    level: {} for level in series_names_in_
                }
            else:
                self.out_sample_residuals_ = {"_unknown_level": None}
                self.out_sample_residuals_by_bin_ = {"_unknown_level": {}}

        for level in series_to_update:
            residuals_level, residuals_by_bin_level = (
                self._binning_out_sample_residuals(
                    level=level,
                    y_true=y_true[level],
                    y_pred=y_pred[level],
                    append=append,
                    random_state=random_state,
                )
            )
            self.out_sample_residuals_[level] = residuals_level
            self.out_sample_residuals_by_bin_[level] = residuals_by_bin_level

        if self.encoding is None or "_unknown_level" not in series_to_update:
            if self.encoding is None and list(y_true.keys()) != ["_unknown_level"]:
                warnings.warn(
                    "As `encoding` is set to `None`, no distinction between levels "
                    "is made. All residuals are stored in the '_unknown_level' key.",
                    UnknownLevelWarning,
                )

            # NOTE: when encoding is None, all levels are combined in '_unknown_level'.
            if list(self.out_sample_residuals_.keys()) != ["_unknown_level"]:
                # To update completely _unknown_level later
                self.out_sample_residuals_.pop("_unknown_level", None)

            residuals_all_levels = np.concatenate(
                [
                    value
                    for value in self.out_sample_residuals_.values()
                    if value is not None
                ]
            )
            rng = np.random.default_rng(seed=random_state)
            if len(residuals_all_levels) > 10_000:
                residuals_all_levels = rng.choice(
                    a=residuals_all_levels, size=10_000, replace=False
                )

            all_bins_keys = set(
                bin_key
                for dict_level_bins in self.out_sample_residuals_by_bin_.values()
                for bin_key in dict_level_bins.keys()
            )
            residuals_by_bin_all_levels = {
                bin_key: np.concatenate(
                    [
                        dict_level_bins.get(bin_key, np.array([]))
                        for dict_level_bins in self.out_sample_residuals_by_bin_.values()
                    ]
                )
                for bin_key in all_bins_keys
            }
            for key in residuals_by_bin_all_levels.keys():
                if len(residuals_by_bin_all_levels[key]) > 10_000:
                    residuals_by_bin_all_levels[key] = rng.choice(
                        a=residuals_by_bin_all_levels[key], size=10_000, replace=False
                    )

            if self.encoding is None:
                self.out_sample_residuals_ = {"_unknown_level": residuals_all_levels}
                self.out_sample_residuals_by_bin_ = {
                    "_unknown_level": residuals_by_bin_all_levels
                }
            else:
                self.out_sample_residuals_["_unknown_level"] = residuals_all_levels
                self.out_sample_residuals_by_bin_["_unknown_level"] = (
                    residuals_by_bin_all_levels
                )

    def _binning_out_sample_residuals(
        self,
        level: str,
        y_true: np.ndarray,
        y_pred: np.ndarray,
        append: bool = False,
        random_state: int = 123,
    ) -> tuple[np.ndarray, dict[int, np.ndarray]]:
        """
        Bin out-of-sample residuals using the already fitted binner.

        Args:
            level (str): Name of the level y_true and y_pred belong to.
            y_true (np.ndarray): True values of the time series.
            y_pred (np.ndarray): Predicted values of the time series.
            append (bool): If `True`, new residuals are added to the existing ones.
                Defaults to `False`.
            random_state (int): Seed for reproducibility. Defaults to `123`.

        Returns:
            tuple: A tuple containing:
                - out_sample_residuals (np.ndarray): Residuals for the level.
                - out_sample_residuals_by_bin (dict): Binned residuals.
        """

        # NOTE: if the level is not known or encoding is None, then transformer,
        # differentiator and binner used are the ones of "_unknown_level"
        transformer = self.transformer_series_.get(
            level, self.transformer_series_["_unknown_level"]
        )
        differentiator = copy(
            self.differentiator_.get(level, self.differentiator_["_unknown_level"])
        )
        if differentiator is not None:
            differentiator.set_params(window_size=None)

        if isinstance(y_true, pd.Series):
            y_true = y_true.to_numpy()
        if isinstance(y_pred, pd.Series):
            y_pred = y_pred.to_numpy()

        if self.transformer_series:
            y_true = transform_numpy(
                array=y_true,
                transformer=transformer,
                fit=False,
                inverse_transform=False,
            )
            y_pred = transform_numpy(
                array=y_pred,
                transformer=transformer,
                fit=False,
                inverse_transform=False,
            )

        if differentiator is not None:
            y_true = differentiator.fit_transform(y_true)[differentiator.order :]
            y_pred = differentiator.fit_transform(y_pred)[differentiator.order :]

        data = pd.DataFrame(
            {"prediction": y_pred, "residuals": y_true - y_pred}
        ).dropna()
        y_pred = data["prediction"].to_numpy()
        residuals = data["residuals"].to_numpy()

        binner = self.binner.get(level, self.binner["_unknown_level"])
        data["bin"] = binner.transform(y_pred).astype(int)
        residuals_by_bin = data.groupby("bin")["residuals"].apply(np.array).to_dict()

        out_sample_residuals = self.out_sample_residuals_.get(level, np.array([]))
        out_sample_residuals_by_bin = deepcopy(
            self.out_sample_residuals_by_bin_.get(level, {})
        )

        out_sample_residuals = (
            np.array([]) if out_sample_residuals is None else out_sample_residuals
        )
        out_sample_residuals_by_bin = (
            {} if out_sample_residuals_by_bin is None else out_sample_residuals_by_bin
        )
        if append:
            out_sample_residuals = np.concatenate([out_sample_residuals, residuals])
            for k, v in residuals_by_bin.items():
                if k in out_sample_residuals_by_bin:
                    out_sample_residuals_by_bin[k] = np.concatenate(
                        (out_sample_residuals_by_bin[k], v)
                    )
                else:
                    out_sample_residuals_by_bin[k] = v
        else:
            out_sample_residuals = residuals
            out_sample_residuals_by_bin = residuals_by_bin

        max_samples = 10_000 // binner.n_bins_
        rng = np.random.default_rng(seed=random_state)
        for k, v in out_sample_residuals_by_bin.items():
            if len(v) > max_samples:
                sample = rng.choice(a=v, size=max_samples, replace=False)
                out_sample_residuals_by_bin[k] = sample

        for k in self.binner_intervals_.get(level, {}).keys():
            if k not in out_sample_residuals_by_bin:
                out_sample_residuals_by_bin[k] = np.array([])

        empty_bins = [k for k, v in out_sample_residuals_by_bin.items() if v.size == 0]
        if empty_bins:
            warnings.warn(
                f"The following bins of level '{level}' have no out of sample residuals: "
                f"{empty_bins}. No predicted values fall in the interval "
                f"{[self.binner_intervals_[level][bin] for bin in empty_bins]}. "
                f"Empty bins will be filled with a random sample of residuals.",
                ResidualsUsageWarning,
            )
            empty_bin_size = min(max_samples, len(out_sample_residuals))
            for k in empty_bins:
                out_sample_residuals_by_bin[k] = rng.choice(
                    a=out_sample_residuals, size=empty_bin_size, replace=False
                )

        if len(out_sample_residuals) > 10_000:
            out_sample_residuals = rng.choice(
                a=out_sample_residuals, size=10_000, replace=False
            )

        return out_sample_residuals, out_sample_residuals_by_bin

    def get_feature_importances(self, sort_importance: bool = True) -> pd.DataFrame:
        """
        Return feature importances of the estimator.

        Args:
            sort_importance (bool): If `True`, sorts the feature importances in
                descending order. Defaults to `True`.

        Returns:
            pd.DataFrame: Feature importances associated with each predictor.
        """

        if not self.is_fitted:
            raise NotFittedError(
                "This forecaster is not fitted yet. Call `fit` with appropriate "
                "arguments before using `get_feature_importances()`."
            )

        if isinstance(self.estimator, Pipeline):
            estimator = self.estimator[-1]
        else:
            estimator = self.estimator

        if hasattr(estimator, "feature_importances_"):
            feature_importances = estimator.feature_importances_
        elif hasattr(estimator, "coef_"):
            feature_importances = estimator.coef_
        else:
            warnings.warn(
                f"Impossible to access feature importances for estimator of type "
                f"{type(estimator)}. This method is only valid when the "
                f"estimator stores internally the feature importances in the "
                f"attribute `feature_importances_` or `coef_`."
            )
            feature_importances = None

        if feature_importances is not None:
            feature_importances = pd.DataFrame(
                {
                    "feature": self.X_train_features_names_out_,
                    "importance": feature_importances,
                }
            )
            if sort_importance:
                feature_importances = feature_importances.sort_values(
                    by="importance", ascending=False
                )

        return feature_importances
