Source code for skfolio.metrics._covariance

"""Metrics for evaluating covariance forecasts."""

# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause

from __future__ import annotations

import numpy as np
import scipy.stats as sst
import sklearn.base as skb

from skfolio.typing import ArrayLike, FloatArray
from skfolio.utils.stats import inverse_volatility_weights, squared_mahalanobis_dist

__all__ = [
    "diagonal_calibration_loss",
    "diagonal_calibration_ratio",
    "exceedance_rate",
    "mahalanobis_calibration_loss",
    "mahalanobis_calibration_ratio",
    "portfolio_variance_calibration_loss",
    "portfolio_variance_calibration_ratio",
    "portfolio_variance_qlike_loss",
    "qlike_loss",
]

_NUMERICAL_THRESHOLD = 1e-12


[docs] def mahalanobis_calibration_ratio( estimator: skb.BaseEstimator, X_test: ArrayLike, y=None, ) -> float: r"""Mahalanobis calibration ratio. Let :math:`r_t` be the one-period realized return vector at time :math:`t`, and let :math:`R^{(h)} = \sum_{t=1}^{h} r_t` be the aggregated return over an evaluation window of :math:`h` observations. This metric compares :math:`R^{(h)}` against the horizon-scaled covariance :math:`h\,\Sigma`: .. math:: s = \frac{{R^{(h)}}^\top (h\,\Sigma)^{-1} R^{(h)}}{n} where :math:`n` is the number of assets. If the forecast covariance is correct and the aggregated return is centered, then :math:`\mathbb{E}[s] = 1` for any horizon :math:`h`. Under multivariate normality, :math:`n s \sim \chi^2(n)`. For financial return series, heavy tails and regime changes can cause departures from the Gaussian reference. In practice, this ratio is often most useful as a relative diagnostic across estimators. When `X_test` contains NaNs (e.g. holidays, pre-listing, or post-delisting periods), only finite observations are used in the aggregated return and the covariance is scaled by the pairwise observation count matrix :math:`H` (Hadamard product :math:`H \odot \Sigma`) so that the same target applies with missing data. In skfolio, NaN diagonal entries in the forecast covariance mark inactive assets, which are excluded from the evaluation. Parameters ---------- estimator : BaseEstimator Fitted estimator, must expose `covariance_` or `return_distribution_.covariance`. X_test : array-like of shape (n_observations, n_assets) Realized returns for the test window. y : Ignored Present for scikit-learn API compatibility. Returns ------- float Calibration ratio. Values near `1.0` indicate that the forecast covariance matches the scale of the realized aggregated return. See Also -------- mahalanobis_calibration_loss : Absolute deviation from the calibration target of `1.0`. diagonal_calibration_ratio : Calibration ratio using only marginal variances. """ cov = _get_covariance(estimator) result = _prepare_active_subset(cov, X_test) if result is None: return float(np.nan) active_cov, active_returns, _, n_valid_assets = result aggregated_return, effective_cov = _aggregated_return_and_effective_covariance( active_returns, active_cov ) d2 = squared_mahalanobis_dist(aggregated_return, effective_cov) return float(d2 / n_valid_assets)
[docs] def diagonal_calibration_ratio( estimator: skb.BaseEstimator, X_test: ArrayLike, y=None, ) -> float: r"""Diagonal calibration ratio based on marginal variances. Let :math:`r_t` be the one-period realized return vector at time :math:`t`, and let :math:`R^{(h)} = \sum_{t=1}^{h} r_t` be the aggregated return over an evaluation window of :math:`h` observations. This metric uses only the diagonal of the covariance matrix, ignoring correlations, and compares each component :math:`R_i^{(h)}` against the horizon-scaled variance :math:`h\,\sigma_i^2`: .. math:: s = \frac{1}{n}\sum_{i=1}^{n} \frac{(R_i^{(h)})^2}{h\,\sigma_i^2} where :math:`n` is the number of assets and :math:`\sigma_i^2` is the forecast variance for asset :math:`i`. If the marginal variance forecasts are correct and the aggregated returns are centered, then :math:`\mathbb{E}[s] = 1` for any horizon :math:`h`. Because correlations are ignored, this metric diagnoses the calibration of marginal scales rather than the full covariance structure. When `X_test` contains NaNs (e.g. holidays, pre-listing, or post-delisting periods), each asset uses its own effective horizon :math:`h_i`, equal to the number of finite observations for that asset, so the ratio retains the same target under missing data. In skfolio, NaN diagonal entries in the forecast covariance mark inactive assets, which are excluded from the evaluation. Parameters ---------- estimator : BaseEstimator Fitted estimator, must expose `covariance_` or `return_distribution_.covariance`. X_test : array-like of shape (n_observations, n_assets) Realized returns for the test window. y : Ignored Present for scikit-learn API compatibility. Returns ------- float Calibration ratio. Values near `1.0` indicate well-calibrated marginal variance forecasts. See Also -------- diagonal_calibration_loss : Absolute deviation from the calibration target of `1.0`. mahalanobis_calibration_ratio : Calibration ratio using the full covariance structure. """ cov = _get_covariance(estimator) result = _prepare_active_subset(cov, X_test) if result is None: return float(np.nan) active_cov, active_returns, _, n_valid_assets = result active_mask = np.isfinite(active_returns) effective_horizon_per_asset = active_mask.sum(axis=0) aggregated_return = np.where(active_mask, active_returns, 0.0).sum(axis=0) scaled_var = effective_horizon_per_asset * np.maximum( np.diag(active_cov), _NUMERICAL_THRESHOLD ) return float(np.sum(aggregated_return**2 / scaled_var) / n_valid_assets)
[docs] def portfolio_variance_calibration_ratio( estimator: skb.BaseEstimator, X_test: ArrayLike, y=None, portfolio_weights: ArrayLike | None = None, ) -> float: r"""Portfolio variance calibration ratio. Let :math:`r_t` be the one-period realized return vector at time :math:`t` and :math:`w^\top r_t` the corresponding one-period portfolio return for weights :math:`w`. This metric compares the sum of squared portfolio returns over an evaluation window of :math:`h` observations to the horizon-scaled forecast portfolio variance: .. math:: s = \frac{\sum_{t=1}^{h} (w^\top r_t)^2} {h\, w^\top \Sigma\, w} If the projected portfolio variance is correctly specified and portfolio returns are centered, then :math:`\mathbb{E}[s] = 1`. When `X_test` contains NaNs (e.g. holidays, pre-listing, or post-delisting periods), NaN returns for active assets contribute zero to the realized portfolio return. The forecast covariance is scaled by the pairwise observation count matrix :math:`H` (Hadamard product :math:`H \odot \Sigma`) so that the realized portfolio variance and forecast variance follow the same missing-data convention. In skfolio, NaN diagonal entries in the forecast covariance mark inactive assets, which are excluded before the score is computed. When multiple portfolios are provided (2D weights), the ratio is computed independently for each and the mean is returned. This produces a more robust diagnostic by averaging across multiple portfolio directions. Parameters ---------- estimator : BaseEstimator Fitted estimator, must expose `covariance_` or `return_distribution_.covariance`. X_test : array-like of shape (n_observations, n_assets) Realized returns for the test window. y : Ignored Present for scikit-learn API compatibility. portfolio_weights : array-like of shape (n_assets,) or (n_portfolios, n_assets), optional Portfolio weights. If `None` (default), inverse-volatility weights are used, which neutralizes volatility dispersion so that high-volatility assets do not dominate the diagnostic. If a 2D array is provided, each row defines a test portfolio and the mean ratio across portfolios is returned. For equal-weight calibration, pass `portfolio_weights=np.ones(n_assets) / n_assets`. Returns ------- float Calibration ratio. Values near `1.0` indicate that the projected portfolio variance is well calibrated on average. See Also -------- portfolio_variance_calibration_loss : Absolute deviation from the calibration target of `1.0`. portfolio_variance_qlike_loss : QLIKE loss for the projected portfolio variance. """ cov = _get_covariance(estimator) result = _prepare_active_subset(cov, X_test) if result is None: return float(np.nan) active_cov, active_returns, active_asset_indices, _ = result w = _resolve_weights(active_cov, portfolio_weights, active_asset_indices) _, effective_cov = _aggregated_return_and_effective_covariance( active_returns, active_cov ) forecast_var = np.maximum( np.sum(w * (w @ effective_cov), axis=1), _NUMERICAL_THRESHOLD ) active_returns_filled = np.where(np.isfinite(active_returns), active_returns, 0.0) realized_ptf = active_returns_filled @ w.T realized_var = np.sum(realized_ptf**2, axis=0) return float(np.mean(realized_var / forecast_var))
[docs] def mahalanobis_calibration_loss( estimator: skb.BaseEstimator, X_test: ArrayLike, y=None, ) -> float: r"""Mahalanobis calibration loss. Computes the absolute deviation of :func:`mahalanobis_calibration_ratio` from its calibration target of `1.0`. Let :math:`r_t` be the one-period realized return vector at time :math:`t`, and let :math:`R^{(h)} = \sum_{t=1}^{h} r_t` be the aggregated return over an evaluation window of :math:`h` observations. .. math:: \ell = \left\lvert \frac{{R^{(h)}}^\top (h\,\Sigma)^{-1} R^{(h)}}{n} - 1 \right\rvert where :math:`n` is the number of assets. As with :func:`mahalanobis_calibration_ratio`, heavy tails and regime changes can weaken the Gaussian reference. This loss is therefore often most useful for relative comparison across covariance estimators. Parameters ---------- estimator : BaseEstimator Fitted estimator, must expose `covariance_` or `return_distribution_.covariance`. X_test : array-like of shape (n_observations, n_assets) Realized returns for the test window. y : Ignored Present for scikit-learn API compatibility. Returns ------- float Calibration loss. Lower values are better and the optimum is `0.0`. See Also -------- mahalanobis_calibration_ratio : The underlying calibration ratio. diagonal_calibration_loss : Loss using only marginal variances. """ return abs(mahalanobis_calibration_ratio(estimator, X_test, y) - 1.0)
[docs] def diagonal_calibration_loss( estimator: skb.BaseEstimator, X_test: ArrayLike, y=None, ) -> float: r"""Diagonal calibration loss. Computes the absolute deviation of :func:`diagonal_calibration_ratio` from its calibration target of `1.0`. Let :math:`r_t` be the one-period realized return vector at time :math:`t`, and let :math:`R^{(h)} = \sum_{t=1}^{h} r_t` be the aggregated return over an evaluation window of :math:`h` observations. .. math:: \ell = \left\lvert \frac{1}{n}\sum_{i=1}^{n} \frac{(R_i^{(h)})^2}{h\,\sigma_i^2} - 1 \right\rvert where :math:`n` is the number of assets. Parameters ---------- estimator : BaseEstimator Fitted estimator, must expose `covariance_` or `return_distribution_.covariance`. X_test : array-like of shape (n_observations, n_assets) Realized returns for the test window. y : Ignored Present for scikit-learn API compatibility. Returns ------- float Calibration loss. Lower values are better and the optimum is `0.0`. See Also -------- diagonal_calibration_ratio : The underlying calibration ratio. mahalanobis_calibration_loss : Loss using the full covariance structure. """ return abs(diagonal_calibration_ratio(estimator, X_test, y) - 1.0)
[docs] def portfolio_variance_calibration_loss( estimator: skb.BaseEstimator, X_test: ArrayLike, y=None, portfolio_weights: ArrayLike | None = None, ) -> float: r"""Portfolio variance calibration loss. Computes the absolute deviation of :func:`portfolio_variance_calibration_ratio` from its calibration target of `1.0`. Let :math:`r_t` be the one-period realized return vector at time :math:`t` and :math:`w^\top r_t` the corresponding one-period portfolio return for weights :math:`w`. .. math:: \ell = \left\lvert \frac{\sum_{t=1}^{h} (w^\top r_t)^2} {h\, w^\top \Sigma\, w} - 1 \right\rvert When multiple portfolios are provided, the loss is the absolute deviation of the mean ratio from `1.0`. Parameters ---------- estimator : BaseEstimator Fitted estimator, must expose `covariance_` or `return_distribution_.covariance`. X_test : array-like of shape (n_observations, n_assets) Realized returns for the test window. y : Ignored Present for scikit-learn API compatibility. portfolio_weights : array-like of shape (n_assets,) or (n_portfolios, n_assets), optional Portfolio weights. If `None` (default), inverse-volatility weights are used, which neutralizes volatility dispersion so that high-volatility assets do not dominate the diagnostic. If a 2D array is provided, each row defines a test portfolio. For equal-weight calibration, pass `portfolio_weights=np.ones(n_assets) / n_assets`. Returns ------- float Calibration loss. Lower values are better and the optimum is `0.0`. See Also -------- portfolio_variance_calibration_ratio : The underlying calibration ratio. portfolio_variance_qlike_loss : QLIKE loss for the projected portfolio variance. """ return abs( portfolio_variance_calibration_ratio(estimator, X_test, y, portfolio_weights) - 1.0 )
[docs] def portfolio_variance_qlike_loss( estimator: skb.BaseEstimator, X_test: ArrayLike, y=None, portfolio_weights: ArrayLike | None = None, ) -> float: r"""QLIKE loss for a projected portfolio variance forecast [1]_. Let :math:`r_t` be the one-period realized return vector at time :math:`t` and :math:`w^\top r_t` the corresponding one-period portfolio return for weights :math:`w`. The loss compares the forecast portfolio variance with the realized sum of squared portfolio returns over the evaluation window of :math:`h` observations: .. math:: \ell = \log\left(h\, w^\top \Sigma\, w\right) + \frac{\sum_{t=1}^{h} (w^\top r_t)^2}{h\, w^\top \Sigma\, w} Lower values are better. In expectation, the loss is minimized by the true conditional portfolio variance forecast. When `X_test` contains NaNs (e.g. holidays, pre-listing, or post-delisting periods), NaN returns for active assets contribute zero to the realized portfolio return. The forecast covariance is scaled by the pairwise observation count matrix :math:`H` (Hadamard product :math:`H \odot \Sigma`) so that the realized portfolio variance and forecast variance follow the same missing-data convention. In skfolio, NaN diagonal entries in the forecast covariance mark inactive assets, which are excluded before the score is computed. When multiple portfolios are provided (2D weights), the QLIKE is computed independently for each and the mean is returned. This lets one summary score evaluate several portfolio directions at once. Parameters ---------- estimator : BaseEstimator Fitted estimator, must expose `covariance_` or `return_distribution_.covariance`. X_test : array-like of shape (n_observations, n_assets) Realized returns for the test window. y : Ignored Present for scikit-learn API compatibility. portfolio_weights : array-like of shape (n_assets,) or (n_portfolios, n_assets), optional Portfolio weights. If `None` (default), inverse-volatility weights are used, which neutralizes volatility dispersion so that high-volatility assets do not dominate the diagnostic. If a 2D array is provided, each row defines a test portfolio and the mean QLIKE across portfolios is returned. Returns ------- float Mean portfolio QLIKE loss. Lower values are better; in expectation, the loss is minimized by the true conditional portfolio variance forecast. See Also -------- portfolio_variance_calibration_ratio : Calibration ratio for the projected portfolio variance. portfolio_variance_calibration_loss : Calibration loss for the projected portfolio variance. qlike_loss : Univariate QLIKE loss. References ---------- .. [1] "Volatility forecast comparison using imperfect volatility proxies" Journal of Econometrics. Patton, A. J. (2011). """ cov = _get_covariance(estimator) result = _prepare_active_subset(cov, X_test) if result is None: return float(np.nan) active_cov, active_returns, active_asset_indices, _ = result w = _resolve_weights(active_cov, portfolio_weights, active_asset_indices) _, effective_cov = _aggregated_return_and_effective_covariance( active_returns, active_cov ) forecast_var = np.maximum( np.sum(w * (w @ effective_cov), axis=1), _NUMERICAL_THRESHOLD ) active_returns_filled = np.where(np.isfinite(active_returns), active_returns, 0.0) realized_ptf = active_returns_filled @ w.T realized_var = np.sum(realized_ptf**2, axis=0) return float(np.mean(np.log(forecast_var) + realized_var / forecast_var))
[docs] def exceedance_rate( squared_distances: ArrayLike, n_features: int, confidence_level: float, ) -> float: r"""Exceedance rate for chi-squared calibration statistics. Computes the fraction of squared distances exceeding the upper `confidence_level` chi-squared quantile. The reference threshold assumes Gaussian standardized returns. In practice, the rate is sensitive not only to covariance misspecification but also to heavy tails, regime shifts, and non-Gaussian standardized returns. It is best used as a comparative metric across estimators rather than as an absolute calibration test. Parameters ---------- squared_distances : array-like of shape (n_observations,) Squared Mahalanobis distances or similar chi-squared statistics. n_features : int Degrees of freedom (number of features/assets). confidence_level : float Coverage confidence level used to define the upper chi-squared threshold. For example, `0.95` corresponds to an expected exceedance rate of `0.05` under calibration. Returns ------- float Observed exceedance rate. It should be close to :math:`1 - \text{confidence\_level}` when the reference chi-squared approximation is appropriate. See Also -------- mahalanobis_calibration_ratio : Calibration ratio based on squared Mahalanobis distances. Notes ----- Under correct calibration and Gaussian standardized returns, :math:`d^2 \sim \chi^2(n_{\text{features}})`, so :math:`P(d^2 > \chi^2_{\text{confidence\_level}}) = 1 - \text{confidence\_level}`. """ squared_distances = np.asarray(squared_distances) if n_features < 1: raise ValueError(f"n_features must be >= 1, got {n_features}.") if not 0 < confidence_level < 1: raise ValueError(f"confidence_level must be in (0, 1), got {confidence_level}.") valid = np.isfinite(squared_distances) if not valid.any(): return float(np.nan) threshold = sst.chi2.ppf(confidence_level, df=n_features) return float(np.mean(squared_distances[valid] > threshold))
[docs] def qlike_loss( returns: ArrayLike, forecast_variance: ArrayLike, ) -> float: r"""QLIKE loss for univariate variance forecasts. .. math:: \text{QLIKE} = \frac{1}{n} \sum_{t=1}^{n} \left( \log(\sigma_t^2) + \frac{r_t^2}{\sigma_t^2} \right) Lower values are better. For numerical stability, forecast variances are clipped below by a small positive constant before evaluating the score. In financial time series, QLIKE is often used as a comparative score when returns are heavy-tailed and realized variance is only an imperfect proxy for latent volatility. Parameters ---------- returns : array-like of shape (n_observations,) Realized returns. forecast_variance : array-like of shape (n_observations,) Forecast variances for the same timestamps, expressed in squared return units. Returns ------- float Mean QLIKE loss. Lower values are better; in expectation, the loss is minimized by the true conditional variance forecast. See Also -------- portfolio_variance_qlike_loss : Multivariate QLIKE loss projected onto portfolio weights. """ returns = np.asarray(returns) forecast_variance = np.asarray(forecast_variance) if returns.shape != forecast_variance.shape: raise ValueError("returns and forecast_variance must have the same shape.") variance = np.clip(forecast_variance, _NUMERICAL_THRESHOLD, None) return float(np.mean(np.log(variance) + (returns**2) / variance))
def _resolve_weights( active_covariance: FloatArray, portfolio_weights: ArrayLike | None, active_asset_indices: FloatArray, ) -> FloatArray: """Compute normalized weights on the active asset subset if portfolio weights are provided; otherwise, compute inverse-volatility weights. Parameters ---------- active_covariance : ndarray of shape (n_active, n_active) Active covariance submatrix. portfolio_weights : array-like of shape (n_assets,) or (n_portfolios, n_assets), optional User-provided portfolio weights on the original asset universe. If `None`, inverse-volatility weights are computed from `active_covariance`. active_asset_indices : ndarray of shape (n_active,) Indices of active assets for subsetting user weights. Returns ------- w : ndarray of shape (n_portfolios, n_active) Row-normalized weight matrix on the active asset subset. """ if portfolio_weights is not None: w = np.atleast_2d(np.asarray(portfolio_weights, dtype=np.float64)) w = w[:, active_asset_indices] row_sums = w.sum(axis=1, keepdims=True) row_sums = np.maximum(row_sums, _NUMERICAL_THRESHOLD) w = w / row_sums else: w = inverse_volatility_weights(active_covariance)[np.newaxis, :] return w def _aggregated_return_and_effective_covariance( active_returns: FloatArray, active_covariance: FloatArray, ) -> tuple[FloatArray, FloatArray]: r"""Compute the aggregated return and effective (Hadamard-scaled) covariance. When all entries are finite this reduces to `(X.sum(0), h * cov)`. Otherwise, each covariance entry is scaled by the number of rows where both assets are observed, yielding the effective covariance :math:`H \odot \Sigma` used by the calibration metrics. This treats NaNs in `active_returns` as missing observations, while assets whose forecast covariance is marked inactive by NaN diagonal entries are handled upstream by subsetting. Parameters ---------- active_returns : ndarray of shape (n_observations, n_active) Returns for active assets (may contain NaN). active_covariance : ndarray of shape (n_active, n_active) Covariance submatrix for active assets. Returns ------- aggregated_return : ndarray of shape (n_active,) Aggregated return over the evaluation window, computed as the sum of finite observations per asset. effective_cov : ndarray of shape (n_active, n_active) Effective covariance :math:`H \odot \Sigma`. """ active_mask = np.isfinite(active_returns) if active_mask.all(): return ( active_returns.sum(axis=0), active_returns.shape[0] * active_covariance, ) active_returns_filled = np.where(active_mask, active_returns, 0.0) aggregated_return = active_returns_filled.sum(axis=0) active_mask_f = active_mask.astype(float) pairwise_obs_count = active_mask_f.T @ active_mask_f return aggregated_return, pairwise_obs_count * active_covariance def _prepare_active_subset( covariance: FloatArray, X_test: ArrayLike, ) -> tuple[FloatArray, FloatArray, FloatArray, int] | None: r"""Subset covariance and returns to jointly active assets. An asset is jointly active when its covariance diagonal is finite (available in the forecast) and it has at least one finite observation in `X_test`. In skfolio, NaN diagonal entries in the forecast covariance mark inactive assets, which are excluded from the evaluation. Assets with no finite observation in `X_test` (e.g. post-delisting periods) are also dropped and portfolio weights are renormalized to the remaining assets. NaN values in `X_test` are otherwise preserved so that callers can apply Hadamard scaling (zero-filled returns together with the pairwise observation count matrix :math:`H \odot \Sigma`). Parameters ---------- covariance : ndarray of shape (n_assets, n_assets) Covariance matrix. In skfolio, NaN diagonal entries mark inactive assets. X_test : array-like of shape (n_observations, n_assets) Realized returns. NaN values are preserved (not zero-filled). Returns ------- tuple of ( ndarray of shape (n_active_assets, n_active_assets), ndarray of shape (n_observations, n_active_assets), ndarray of shape (n_active_assets,), int, ) or None Active covariance submatrix, corresponding return columns (may contain NaN), original column indices, and active asset count. Returns `None` when no assets are jointly active. """ X_test = np.atleast_2d(np.asarray(X_test, dtype=float)) active_mask = np.isfinite(np.diag(covariance)) active_mask &= np.any(np.isfinite(X_test), axis=0) n_active_assets = int(active_mask.sum()) if n_active_assets == 0: return None active_asset_indices = np.where(active_mask)[0] active_cov = covariance[np.ix_(active_asset_indices, active_asset_indices)] active_returns = X_test[:, active_asset_indices] return active_cov, active_returns, active_asset_indices, n_active_assets def _get_covariance(estimator: skb.BaseEstimator) -> FloatArray: """Extract the covariance matrix from a covariance or prior estimator. Supports :class:`~skfolio.moments.BaseCovariance` (`estimator.covariance_`) and :class:`~skfolio.prior.BasePrior` (`estimator.return_distribution_.covariance`). Parameters ---------- estimator : BaseEstimator Fitted covariance estimator or prior estimator. Returns ------- ndarray of shape (n_assets, n_assets) Covariance matrix extracted from the estimator. """ if hasattr(estimator, "covariance_"): return estimator.covariance_ if hasattr(estimator, "return_distribution_"): return estimator.return_distribution_.covariance raise AttributeError( f"{type(estimator).__name__} has neither `covariance_` nor " "`return_distribution_.covariance`." )