# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/modules/04_prob_forecast.ipynb.

# %% ../nbs/modules/04_prob_forecast.ipynb #8a50b621
# Generate conformal quantiles for future time steps
from __future__ import annotations
from typing import List, Dict, Optional, Callable, Tuple, Any, Union
import numpy as np
import pandas as pd
from .model_selection import SplitTimeSeries
from scipy.stats import gaussian_kde
import copy
from .helpers import get_conformal_quantiles, get_bootstrap_quantiles
# ─────────────────────────────────────────────────────────────────────────────
# Main class
# ─────────────────────────────────────────────────────────────────────────────
 
from pyexpat import model


class prob_forecasts: 
    def __init__(
        self,
        model,
        n_calibration: int,
        H: int,
        step_size: int = 1,
        random_state: int = 42,
        n_iter: Union[int, None] = None,
        verbose: bool = False,
    ):
        
        """
        Probabilistic forecasting wrapper for any point-forecasting model.
    
        Parameters
        ----------
        model : fitted-model-like
            Any model with ``.target_col``, ``.fit(df)``, and ``.forecast(H, exog)`` attributes.
        n_calibration : int
            Number of calibration windows for cross-validated residual estimation.
        H : int
            Forecast horizon.
        step_size : int, default 1
            Step size between consecutive calibration windows.
        random_state : int, default 42
            Seed for all internal random-number generators.
        n_iter : int or None, default None
            Number of EM iterations during each calibration window.  Only relevant for Markov-switching Autoregressive model (`ms_arr`). A smaller value than the model's default speeds up calibration at the cost of convergence quality per fold — typically a value of 3–10 is sufficient for calibration windows where the model is already close to the solution.
        verbose : bool, default False
            Print progress during calibration.
        """
        
        self.model         = model
        self.n_calib       = n_calibration
        self.H             = H
        self.step_size = step_size
        self.verbose       = verbose
        self.n_iter        = n_iter
        self._rng          = np.random.default_rng(seed=random_state)
        self._random_state = random_state
        
    # ── private ──────────────────────────────────────────────────────────────
 
    def _compute_residuals(self, df: pd.DataFrame) -> None:
        """
        Run calibration-window cross-validation and store residuals.
 
        Populates ``self.resid`` (shape ``H × n_calib``),
        ``self.non_conform`` (absolute residuals), ``self.c_actuals``,
        and ``self.c_forecasts``.
 
        This method is called automatically by ``calibrate`` and ``sample``
        when residuals have not yet been computed.  It can also be called
        directly if only residuals are needed.
 
        Parameters
        ----------
        df : pd.DataFrame
            Full dataset used for rolling-window cross-validation.
        """

        if hasattr(self.model, "N") and hasattr(self.model, "iter"):
            if not self.model.is_fitted:
                self.model.fit(df)
                self.model.iter = self.n_iter
            else:
                self.model.iter = self.n_iter  # Temporarily set n_iter for calibration
            
        tscv = SplitTimeSeries(
            n_splits=self.n_calib, test_size=self.H,
            step_size=self.step_size,
        )
        c_actuals, c_forecasts = [], []
 
        for fold, (train_idx, test_idx) in enumerate(tscv.split(df)):
            train, test = df.iloc[train_idx], df.iloc[test_idx]
            x_test = test.drop(columns=[self.model.target_col])
            y_test = np.array(test[self.model.target_col])

            self.model.fit(train)
            y_hat = self.model.forecast(self.H, x_test) if x_test is not None else self.model.forecast(self.H)
            # print(y_hat)

            c_actuals.append(y_test)
            c_forecasts.append(y_hat)
 
            if self.verbose:
                print(f"Calibration fold {fold + 1}/{self.n_calib} complete.")
 
        self.c_actuals   = np.column_stack(c_actuals)    # H × n_calib
        self.c_forecasts = np.column_stack(c_forecasts)  # H × n_calib
        self.resid       = self.c_actuals - self.c_forecasts
        self.non_conform = np.abs(self.resid)
 
    def _require_residuals(self, df: pd.DataFrame) -> None:
        """Compute residuals lazily — only if not already available."""
        if not hasattr(self, "resid"):
            self._compute_residuals(df)
 
    def _require_calibration(self) -> None:
        """Raise an informative error when conformal calibration is missing."""
        if not hasattr(self, "q_hat"):
            raise RuntimeError(
                "Conformal calibration has not been run yet. "
                "Call .calibrate(df_calibration, delta) first."
            )
 
    def _require_sample_paths(self) -> None:
        """Raise an informative error when sample() has not been called."""
        if not hasattr(self, "sample_paths"):
            raise RuntimeError(
                "No sample paths available. "
                "Call .sample(df, method=...) first."
            )
 
    # ── public: conformal prediction ──────────────────────────────────────────
 
    def calibrate(
        self,
        df: pd.DataFrame,
        delta: Union[float, List[float]] = 0.5,
    ) -> "prob_forecasts":
        
        """
        Calibrate the conformal predictor.
 
        Runs rolling-window cross-validation (if not already done) to
        collect non-conformity scores, then computes the per-horizon
        conformal quantile ``q_hat`` for each requested ``delta`` level.
 
        Parameters
        ----------
        df : pd.DataFrame
            Calibration dataset.
        delta : float or list of float, default 0.5
            Coverage level(s).  A single float produces one symmetric interval; a list produces one interval per level.  For example,``delta=0.9`` produces a 90 % prediction interval.
 
        Returns
        -------
        self : prob_forecasts
             The fitted object, with ``self.q_hat`` set to the calibrated
        """
        self.delta = delta
        self._require_residuals(df)
 
        deltas = [delta] if isinstance(delta, float) else delta
 
        # q_hat shape: (H, len(deltas))
        q_hat = np.empty((self.H, len(deltas)))
        for i in range(self.H):
            for j, d in enumerate(deltas):
                q_which = np.ceil(d * (self.n_calib + 1)) / self.n_calib
                q_hat[i, j] = np.quantile(
                    self.non_conform[i], q_which, method="higher"
                )
        self.q_hat = q_hat   # (H, len(deltas))
        return self
 
    def predict_intervals(
        self,
        df: pd.DataFrame,
        future_exog: Union[pd.DataFrame, None] = None,
    ) -> pd.DataFrame:
        """
        Generate prediction intervals for the forecast horizon at the calibrated delta levels without sampling from the errors generated for each horizon.
        Requires ``calibrate`` to have been called first.
 
        Parameters
        ----------
        df : pd.DataFrame
            Training data for the final model fit.
        future_exog : pd.DataFrame or None, default None
            Future exogenous variables.
 
        Returns
        -------
        pd.DataFrame
            Columns: ``point_forecast``, ``lower_<d>``, ``upper_<d>`` for every delta level.  Also stores ``self.dist`` — a ``(n_calib × H)`` DataFrame of residual-shifted distributions, one column per forecast horizon.
        """
        self._require_calibration()
 
        self.model.fit(df)
        y_hat = np.array(
            self.model.forecast(self.H, future_exog)
            if future_exog is not None
            else self.model.forecast(self.H)
        )
 
        deltas = [self.delta] if isinstance(self.delta, float) else self.delta
 
        result_cols = {"point_forecast": y_hat}
        for j, d in enumerate(deltas):
            q = self.q_hat[:, j]
            result_cols[f"lower_{int(d * 100)}"] = y_hat - q
            result_cols[f"upper_{int(d * 100)}"] = y_hat + q
 
        # Residual-shifted empirical distribution (H × n_calib), clipped ≥ 0
        dist = y_hat[:, None] + self.resid             # H × n_calib
        self.dist = pd.DataFrame(
            dist.T,
            columns=[f"h_{i + 1}" for i in range(self.H)],
        ).clip(lower=0)
 
        return pd.DataFrame(result_cols)
 
    def conformal_quantiles(
        self,
        df: pd.DataFrame,
        quantiles: Union[float, List[float]],
        future_exog: Union[pd.DataFrame, None] = None,
    ) -> pd.DataFrame:
        """
        Generate conformal prediction quantiles.
 
        Requires ``calibrate`` to have been called first.
 
        Parameters
        ----------
        df : pd.DataFrame
            Training data for the final model fit.
        quantiles : float or list of float
            Desired quantile levels (e.g. ``[0.1, 0.5, 0.9]``).
        future_exog : pd.DataFrame or None, default None
            Future exogenous variables.
 
        Returns
        -------
        pd.DataFrame
            Columns: ``point_forecast``, ``q_<level>`` for each level.
        """
        self._require_calibration()
 
        self.model.fit(df)
        y_hat = np.array(
            self.model.forecast(self.H, future_exog)
            if future_exog is not None
            else self.model.forecast(self.H)
        )
        return get_conformal_quantiles(self.non_conform, self.n_calib,
                                       quantiles, y_hat)
 
    # ── public: sampling methods ──────────────────────────────────────────────
 
    def sample(
        self,
        df: pd.DataFrame,
        n_samples: int = 1000,
        method: str = "empirical",
        future_exog: Union[pd.DataFrame, None] = None,
    ) -> "prob_forecasts":
    
        """
        Draw sample paths from the predictive distribution.
 
        Three methods are available:
 
        * ``"empirical"`` — residuals are resampled with replacement
          independently at each horizon.
        * ``"kde"`` — a Gaussian KDE is fitted to each horizon's residuals;
          samples are drawn from the smoothed distribution.
        * ``"correlated"`` — a multivariate normal is fitted to the full
          ``H``-dimensional residual vectors, preserving cross-horizon
          correlation.  Samples are drawn jointly.
 
        Results are stored on ``self``:
 
        * ``self.sample_paths`` — ``(n_samples, H)`` array of sampled trajectories centred on the point forecast.
        * ``self.point_forecast`` — ``(H,)`` point forecast array.
        * ``self.sample_paths_df`` — the same data as a DataFrame with columns ``h_1, …, h_H``.
 
        Parameters
        ----------
        df : pd.DataFrame
            Training data.  Residuals are computed via cross-validation if not yet available.
        n_samples : int, default 1000
            Number of sample paths to draw.
        method : {"empirical", "kde", "correlated"}, default "empirical"
            Sampling strategy (see above).
        future_exog : pd.DataFrame or None, default None
            Future exogenous variables passed to ``forecast``.
 
        Returns
        -------
        self
        """
        if method not in {"empirical", "kde", "correlated"}:
            raise ValueError(
                f"method='{method}' is not recognised. "
                "Choose 'empirical', 'kde', or 'correlated'."
            )
 
        self._require_residuals(df)

        self._rng = np.random.default_rng(seed=self._random_state)
 
        self.model.fit(df)
        y_hat = self.model.forecast(self.H, future_exog) if future_exog is not None else self.model.forecast(self.H)
 
        # ── sample residuals according to chosen method ───────────────────────
        if method == "empirical":
            # shape: (n_samples, H)  — independent resampling per horizon
            draws = np.column_stack([
                self._rng.choice(self.resid[h], size=n_samples, replace=True)
                for h in range(self.H)
            ])
 
        elif method == "kde":
            # shape: (n_samples, H)  — KDE per horizon
            draws = np.column_stack([
                gaussian_kde(self.resid[h]).resample(
                    size=n_samples,
                    seed=self._random_state,
                )[0]
                for h in range(self.H)
            ])
 
        else:  # "correlated"
            mu    = self.resid.mean(axis=1)          # (H,)
            sigma = np.cov(self.resid, rowvar=True)  # (H, H)
            # draws shape: (n_samples, H)
            draws = self._rng.multivariate_normal(mu, sigma, size=n_samples)

         # ✅ Create a deep copy so that we don’t overwrite self
        new_instance = copy.deepcopy(self)
        # ── centre on point forecast ──────────────────────────────────────────
        new_instance.sample_paths  = draws + y_hat           # (n_samples, H)
        new_instance.point_forecast = y_hat
        new_instance.sample_paths_df = pd.DataFrame(
            new_instance.sample_paths,
            columns=[f"h_{i + 1}" for i in range(self.H)],
        )
        return new_instance
    
 
    def sample_quantiles(
        self,
        quantiles: Union[float, List[float]],
    ) -> pd.DataFrame:
        """
        Compute quantiles from the sample paths generated by ``sample``.
 
        Works identically regardless of which ``method`` was passed to ``sample``.
 
        Parameters
        ----------
        quantiles : float or list of float
            Desired quantile levels (e.g. ``[0.1, 0.5, 0.9]``).
 
        Returns
        -------
        pd.DataFrame
            Columns: ``point_forecast``, ``q_<level>`` for each level.
        """
        self._require_sample_paths()
        return get_bootstrap_quantiles(
            self.sample_paths,   # (n_samples, H) — rows are samples
            self.n_calib,
            quantiles,
            self.point_forecast,
        )
 
    def copy(self):
        return copy.deepcopy(self)

# %% auto #0
__all__ = ['prob_forecasts', 'mv_prob_forecasts']

# %% ../nbs/modules/04_prob_forecast.ipynb #188c2530
class mv_prob_forecasts: 
    def __init__(
        self,
        model,
        target_col: str,
        n_calibration: int,
        H: int,
        step_size: int = 1,
        random_state: int = 42,
        n_iter: Union[int, None] = None,
        verbose: bool = False,
    ):
        
        """
        Probabilistic forecasting wrapper for any point-forecasting model.
    
        Parameters
        ----------
        model : fitted-model-like
            Any model with ``.target_col``, ``.fit(df)``, and ``.forecast(H, exog)`` attributes.
        target_col : str
            Name of the target variable column in the input DataFrames.
        n_calibration : int
            Number of calibration windows for cross-validated residual estimation.
        H : int
            Forecast horizon.
        step_size : int, default 1
            Step size between consecutive calibration windows.
        random_state : int, default 42
            Seed for all internal random-number generators.
        n_iter : int or None, default None
            Number of EM iterations during each calibration window.  Only relevant for Markov-switching Autoregressive model (`ms_arr`). A smaller value than the model's default speeds up calibration at the cost of convergence quality per fold — typically a value of 3–10 is sufficient for calibration windows where the model is already close to the solution.
        verbose : bool, default False
            Print progress during calibration.
        """
        
        self.model         = model
        self.target_col    = target_col
        self.n_calib       = n_calibration
        self.H             = H
        self.step_size = step_size
        self.verbose       = verbose
        self.n_iter        = n_iter
        self._rng          = np.random.default_rng(seed=random_state)
        self._random_state = random_state
        
    # ── private ──────────────────────────────────────────────────────────────
 
    def _compute_residuals(self, df: pd.DataFrame) -> None:
        """
        Run calibration-window cross-validation and store residuals.
 
        Populates ``self.resid`` (shape ``H × n_calib``),
        ``self.non_conform`` (absolute residuals), ``self.c_actuals``,
        and ``self.c_forecasts``.
 
        This method is called automatically by ``calibrate`` and ``sample``
        when residuals have not yet been computed.  It can also be called
        directly if only residuals are needed.
 
        Parameters
        ----------
        df : pd.DataFrame
            Full dataset used for rolling-window cross-validation.
        """

        # if hasattr(self.model, "N") and hasattr(self.model, "iter"):
        #     if not self.model.is_fitted:
        #         self.model.fit(df)
        #         self.model.iter = self.n_iter
        #     else:
        #         self.model.iter = self.n_iter  # Temporarily set n_iter for calibration
            
        tscv = SplitTimeSeries(
            n_splits=self.n_calib, test_size=self.H,
            step_size=self.step_size,
        )
        c_actuals, c_forecasts = [], []
 
        for fold, (train_idx, test_idx) in enumerate(tscv.split(df)):
            train, test = df.iloc[train_idx], df.iloc[test_idx]
            x_test = test.drop(columns=self.model.target_cols)
            y_test = np.array(test[self.target_col])

            self.model.fit(train)
            y_hat = self.model.forecast(self.H, exog=x_test)[self.target_col] if x_test is not None else self.model.forecast(self.H)[self.target_col]

            c_actuals.append(y_test)
            c_forecasts.append(y_hat)
 
            if self.verbose:
                print(f"Calibration fold {fold + 1}/{self.n_calib} complete.")
 
        self.c_actuals   = np.column_stack(c_actuals)    # H × n_calib
        self.c_forecasts = np.column_stack(c_forecasts)  # H × n_calib
        self.resid       = self.c_actuals - self.c_forecasts
        self.non_conform = np.abs(self.resid)
 
    def _require_residuals(self, df: pd.DataFrame) -> None:
        """Compute residuals lazily — only if not already available."""
        if not hasattr(self, "resid"):
            self._compute_residuals(df)
 
    def _require_calibration(self) -> None:
        """Raise an informative error when conformal calibration is missing."""
        if not hasattr(self, "q_hat"):
            raise RuntimeError(
                "Conformal calibration has not been run yet. "
                "Call .calibrate(df_calibration, delta) first."
            )
 
    def _require_sample_paths(self) -> None:
        """Raise an informative error when sample() has not been called."""
        if not hasattr(self, "sample_paths"):
            raise RuntimeError(
                "No sample paths available. "
                "Call .sample(df, method=...) first."
            )
 
    # ── public: conformal prediction ──────────────────────────────────────────
 
    def calibrate(
        self,
        df: pd.DataFrame,
        delta: Union[float, List[float]] = 0.5,
    ) -> "prob_forecasts":
        
        """
        Calibrate the conformal predictor.
 
        Runs rolling-window cross-validation (if not already done) to
        collect non-conformity scores, then computes the per-horizon
        conformal quantile ``q_hat`` for each requested ``delta`` level.
 
        Parameters
        ----------
        df : pd.DataFrame
            Calibration dataset.
        delta : float or list of float, default 0.5
            Coverage level(s).  A single float produces one symmetric interval; a list produces one interval per level.  For example,``delta=0.9`` produces a 90 % prediction interval.
 
        Returns
        -------
        self : prob_forecasts
             The fitted object, with ``self.q_hat`` set to the calibrated
        """
        self.delta = delta
        self._require_residuals(df)
 
        deltas = [delta] if isinstance(delta, float) else delta
 
        # q_hat shape: (H, len(deltas))
        q_hat = np.empty((self.H, len(deltas)))
        for i in range(self.H):
            for j, d in enumerate(deltas):
                q_which = np.ceil(d * (self.n_calib + 1)) / self.n_calib
                q_hat[i, j] = np.quantile(
                    self.non_conform[i], q_which, method="higher"
                )
        self.q_hat = q_hat   # (H, len(deltas))
        return self
 
    def predict_intervals(
        self,
        df: pd.DataFrame,
        future_exog: Union[pd.DataFrame, None] = None,
    ) -> pd.DataFrame:
        """
        Generate prediction intervals for the forecast horizon at the calibrated delta levels without sampling from the errors generated for each horizon.
        Requires ``calibrate`` to have been called first.
 
        Parameters
        ----------
        df : pd.DataFrame
            Training data for the final model fit.
        future_exog : pd.DataFrame or None, default None
            Future exogenous variables.
 
        Returns
        -------
        pd.DataFrame
            Columns: ``point_forecast``, ``lower_<d>``, ``upper_<d>`` for every delta level.  Also stores ``self.dist`` — a ``(n_calib × H)`` DataFrame of residual-shifted distributions, one column per forecast horizon.
        """
        self._require_calibration()
 
        self.model.fit(df)
        y_hat = np.array(
            self.model.forecast(self.H, future_exog)[self.target_col]
            if future_exog is not None
            else self.model.forecast(self.H)[self.target_col]
        )
 
        deltas = [self.delta] if isinstance(self.delta, float) else self.delta
 
        result_cols = {"point_forecast": y_hat}
        for j, d in enumerate(deltas):
            q = self.q_hat[:, j]
            result_cols[f"lower_{int(d * 100)}"] = y_hat - q
            result_cols[f"upper_{int(d * 100)}"] = y_hat + q
 
        # Residual-shifted empirical distribution (H × n_calib), clipped ≥ 0
        dist = y_hat[:, None] + self.resid             # H × n_calib
        self.dist = pd.DataFrame(
            dist.T,
            columns=[f"h_{i + 1}" for i in range(self.H)],
        ).clip(lower=0)
 
        return pd.DataFrame(result_cols)
 
    def conformal_quantiles(
        self,
        df: pd.DataFrame,
        quantiles: Union[float, List[float]],
        future_exog: Union[pd.DataFrame, None] = None,
    ) -> pd.DataFrame:
        """
        Generate conformal prediction quantiles.
 
        Requires ``calibrate`` to have been called first.
 
        Parameters
        ----------
        df : pd.DataFrame
            Training data for the final model fit.
        quantiles : float or list of float
            Desired quantile levels (e.g. ``[0.1, 0.5, 0.9]``).
        future_exog : pd.DataFrame or None, default None
            Future exogenous variables.
 
        Returns
        -------
        pd.DataFrame
            Columns: ``point_forecast``, ``q_<level>`` for each level.
        """
        self._require_calibration()
 
        self.model.fit(df)
        y_hat = np.array(
            self.model.forecast(self.H, future_exog)[self.target_col]
            if future_exog is not None
            else self.model.forecast(self.H)[self.target_col]
        )
        return get_conformal_quantiles(self.non_conform, self.n_calib,
                                       quantiles, y_hat)
 
    # ── public: sampling methods ──────────────────────────────────────────────
 
    def sample(
        self,
        df: pd.DataFrame,
        n_samples: int = 1000,
        method: str = "empirical",
        future_exog: Union[pd.DataFrame, None] = None,
    ) -> "prob_forecasts":
    
        """
        Draw sample paths from the predictive distribution.
 
        Three methods are available:
 
        * ``"empirical"`` — residuals are resampled with replacement
          independently at each horizon.
        * ``"kde"`` — a Gaussian KDE is fitted to each horizon's residuals;
          samples are drawn from the smoothed distribution.
        * ``"correlated"`` — a multivariate normal is fitted to the full
          ``H``-dimensional residual vectors, preserving cross-horizon
          correlation.  Samples are drawn jointly.
 
        Results are stored on ``self``:
 
        * ``self.sample_paths`` — ``(n_samples, H)`` array of sampled trajectories centred on the point forecast.
        * ``self.point_forecast`` — ``(H,)`` point forecast array.
        * ``self.sample_paths_df`` — the same data as a DataFrame with columns ``h_1, …, h_H``.
 
        Parameters
        ----------
        df : pd.DataFrame
            Training data.  Residuals are computed via cross-validation if not yet available.
        n_samples : int, default 1000
            Number of sample paths to draw.
        method : {"empirical", "kde", "correlated"}, default "empirical"
            Sampling strategy (see above).
        future_exog : pd.DataFrame or None, default None
            Future exogenous variables passed to ``forecast``.
 
        Returns
        -------
        self
        """
        if method not in {"empirical", "kde", "correlated"}:
            raise ValueError(
                f"method='{method}' is not recognised. "
                "Choose 'empirical', 'kde', or 'correlated'."
            )
 
        self._require_residuals(df)

        self._rng = np.random.default_rng(seed=self._random_state)
 
        self.model.fit(df)
        y_hat = np.array(
            self.model.forecast(self.H, future_exog)[self.target_col]
            if future_exog is not None
            else self.model.forecast(self.H)[self.target_col]
        )
 
        # ── sample residuals according to chosen method ───────────────────────
        if method == "empirical":
            # shape: (n_samples, H)  — independent resampling per horizon
            draws = np.column_stack([
                self._rng.choice(self.resid[h], size=n_samples, replace=True)
                for h in range(self.H)
            ])
 
        elif method == "kde":
            # shape: (n_samples, H)  — KDE per horizon
            draws = np.column_stack([
                gaussian_kde(self.resid[h]).resample(
                    size=n_samples,
                    seed=self._random_state,
                )[0]
                for h in range(self.H)
            ])
 
        else:  # "correlated"
            mu    = self.resid.mean(axis=1)          # (H,)
            sigma = np.cov(self.resid, rowvar=True)  # (H, H)
            # draws shape: (n_samples, H)
            draws = self._rng.multivariate_normal(mu, sigma, size=n_samples)

         # ✅ Create a deep copy so that we don’t overwrite self
        new_instance = copy.deepcopy(self)
        # ── centre on point forecast ──────────────────────────────────────────
        new_instance.sample_paths  = draws + y_hat           # (n_samples, H)
        new_instance.point_forecast = y_hat
        new_instance.sample_paths_df = pd.DataFrame(
            new_instance.sample_paths,
            columns=[f"h_{i + 1}" for i in range(self.H)],
        )
        return new_instance
    
 
    def sample_quantiles(
        self,
        quantiles: Union[float, List[float]],
    ) -> pd.DataFrame:
        """
        Compute quantiles from the sample paths generated by ``sample``.
 
        Works identically regardless of which ``method`` was passed to ``sample``.
 
        Parameters
        ----------
        quantiles : float or list of float
            Desired quantile levels (e.g. ``[0.1, 0.5, 0.9]``).
 
        Returns
        -------
        pd.DataFrame
            Columns: ``point_forecast``, ``q_<level>`` for each level.
        """
        self._require_sample_paths()
        return get_bootstrap_quantiles(
            self.sample_paths,   # (n_samples, H) — rows are samples
            self.n_calib,
            quantiles,
            self.point_forecast,
        )
 
    def copy(self):
        return copy.deepcopy(self)
