"""Orthogonal Mu and Covariance Uncertainty Set estimators."""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import numbers
import numpy as np
import scipy.linalg as sla
import scipy.stats as st
from skfolio.prior import ReturnDistribution
from skfolio.typing import ArrayLike
from skfolio.uncertainty_set._base import (
BaseCovarianceUncertaintySet,
BaseMuUncertaintySet,
)
from skfolio.uncertainty_set._model import (
CompactCovarianceUncertaintySet,
UncertaintySet,
)
from skfolio.utils.stats import CSWeighting
[docs]
class OrthogonalMuUncertaintySet(BaseMuUncertaintySet):
r"""Expected return uncertainty set estimator for directions outside the factor span.
This estimator builds a norm-ball uncertainty set for expected returns that are in
the subspace orthogonal to the factor-model loading matrix, under the selected
cross-sectional weighting metric.
It is intended for cases where orthogonal expected returns are considered less
reliable than spanned expected returns and is designed to reduce the tendency of
optimizers to overallocate in these directions. Rather than shrinking the orthogonal
expected returns in the prior, this estimator keeps the point estimate
:math:`\hat{\mu}` unchanged and adds a portfolio-dependent worst-case penalty that
grows with exposure to the orthogonal subspace [1]_ [2]_ [3]_.
Under this uncertainty set, the worst-case expected return for a portfolio with
weights :math:`w` is
.. math::
\inf_{\mu \in U_\mu} w^\top \mu
\;=\;
w^\top \hat{\mu}
-
\kappa \, \lVert L^\top w \rVert_2,
where the low-rank geometry factor is
.. math::
L = G \Lambda^{1/2}.
Here, :math:`G` is a basis for the subspace orthogonal to the factor-model span and
:math:`\Lambda` is a positive semidefinite scaling matrix that controls the
uncertainty assigned to each orthogonal direction.
Equivalently, the ellipsoidal shape matrix is [4]_:
.. math::
S_\mu = L L^\top = G \Lambda G^\top.
If the factor model uses basket-neutral constraints, the loading matrix is first
reduced to its effective full-rank basis before the orthogonal subspace is computed.
Parameters
----------
confidence_level : float, default=0.95
Confidence level :math:`\beta` used to set the uncertainty size
.. math::
\kappa = \sqrt{\chi^2_{\mathrm{rank}}(\beta)}.
cs_weighting : CSWeighting, default=CSWeighting.INVERSE_IDIO_VARIANCE
Cross-sectional weighting used to define the orthogonality metric.
uncertainty_shape : {"identity", "idio_variance"}, default="identity"
Shape used inside the orthogonal subspace.
* `"identity"` assigns the same uncertainty to all orthogonal directions.
* `"idio_variance"` scales uncertainty using projected idiosyncratic variance
in the orthogonal subspace.
Attributes
----------
uncertainty_set_ : UncertaintySet
Fitted solver-ready uncertainty set with:
* `uncertainty_set_.radius = \kappa`
* `uncertainty_set_.geometry = L = G \Lambda^{1/2}`
* `uncertainty_set_.norm = 2`
Notes
-----
This estimator requires a factor model in the fitted return distribution. When used
inside :class:`~skfolio.optimization.MeanRisk`, the `return_distribution` metadata
is passed automatically by `fit` and `partial_fit`.
References
----------
.. [1] "Robustness properties of mean-variance portfolios",
Optimization: A Journal of Mathematical Programming and Operations Research,
Schöttle & Werner (2009).
.. [2] "Portfolio Optimization: Theory and Application", Chapter 14,
Daniel P. Palomar (2025)
.. [3] "Robust Portfolio Selection Problems", Mathematics of Operations Research,
Goldfarb and Iyengar (2003).
.. [4] "Robust Convex Optimization", Mathematics of Operations Research,
Ben-Tal and Nemirovski (1998).
"""
def __init__(
self,
confidence_level: float = 0.95,
cs_weighting: CSWeighting = CSWeighting.INVERSE_IDIO_VARIANCE,
uncertainty_shape: str = "identity",
):
super().__init__(prior_estimator=None)
self.confidence_level = confidence_level
self.cs_weighting = cs_weighting
self.uncertainty_shape = uncertainty_shape
[docs]
def fit(
self,
X: ArrayLike,
y: ArrayLike | None = None,
*,
return_distribution: ReturnDistribution | None = None,
**fit_params,
) -> OrthogonalMuUncertaintySet:
r"""Fit the orthogonal mu uncertainty set.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns of the assets.
y : array-like of shape (n_observations, n_factors), optional
Price returns of factors. The default is `None`.
return_distribution : ReturnDistribution, optional
The fitted return distribution from the prior estimator.
Passed internally by :class:`~skfolio.optimization.MeanRisk`. Must contain a
`factor_model` with `loading_matrix` and `idio_covariance`.
**fit_params : dict
Additional parameters (unused).
Returns
-------
self : OrthogonalMuUncertaintySet
Fitted estimator.
"""
return self._fit(
X,
y,
return_distribution=return_distribution,
**fit_params,
)
[docs]
def partial_fit(
self,
X: ArrayLike,
y: ArrayLike | None = None,
*,
return_distribution: ReturnDistribution | None = None,
**fit_params,
) -> OrthogonalMuUncertaintySet:
r"""Update the orthogonal mu uncertainty set.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns of the assets.
y : array-like of shape (n_observations, n_factors), optional
Price returns of factors. The default is `None`.
return_distribution : ReturnDistribution, optional
The fitted return distribution from the prior estimator.
Passed internally by :class:`~skfolio.optimization.MeanRisk`. Must contain a
`factor_model` with `loading_matrix` and `idio_covariance`.
**fit_params : dict
Additional parameters (unused).
Returns
-------
self : OrthogonalMuUncertaintySet
Updated estimator.
"""
return self._fit(
X,
y,
return_distribution=return_distribution,
**fit_params,
)
def _fit(
self,
X: ArrayLike,
y: ArrayLike | None = None,
*,
return_distribution: ReturnDistribution | None = None,
**fit_params,
) -> OrthogonalMuUncertaintySet:
"""Fit the estimator from a fitted factor-model return distribution."""
self._validate_params()
if return_distribution is None:
raise ValueError(
f"{self.__class__.__name__} requires `return_distribution` "
"from a fitted factor model prior. Use it with a "
"factor-model-based prior_estimator in MeanRisk."
)
factor_model = return_distribution.factor_model
if factor_model is None:
raise ValueError(
f"{self.__class__.__name__} requires a factor model in "
"return_distribution. Use a factor-model-based prior."
)
weights = factor_model._resolve_cs_weighting(self.cs_weighting, latest=True)
n_assets = factor_model.loading_matrix.shape[0]
if weights is None:
weights_sqrt = np.ones(n_assets)
else:
if weights.ndim != 1 or weights.shape[0] != n_assets:
raise ValueError("`cs_weighting` produced invalid weights.")
if np.any(~np.isfinite(weights)) or np.any(weights <= 0):
raise ValueError("Cross-sectional weights must be finite and positive.")
weights_sqrt = np.sqrt(weights)
weighted_loading = (
weights_sqrt[:, np.newaxis] * factor_model.effective_loading_matrix
)
factor_basis, singular_values, _ = sla.svd(
weighted_loading, full_matrices=False, check_finite=False
)
if singular_values.size != 0:
tolerance = (
max(weighted_loading.shape) * np.finfo(float).eps * singular_values[0]
)
rank = int(np.sum(singular_values > tolerance))
factor_basis = factor_basis[:, :rank]
projector = np.eye(n_assets) - factor_basis @ factor_basis.T
if weights is None:
ortho = projector
else:
ortho = projector / weights_sqrt[:, np.newaxis]
eigvals, eigvecs = np.linalg.eigh(ortho.T @ ortho)
abs_tol = n_assets * np.finfo(float).eps
rel_tol = n_assets * np.max(np.abs(eigvals)) * np.finfo(float).eps
keep = eigvals > max(abs_tol, rel_tol)
if not np.any(keep):
ortho_basis = np.zeros((n_assets, 0))
else:
ortho_basis = ortho @ eigvecs[:, keep]
ortho_basis, _ = np.linalg.qr(ortho_basis, mode="reduced")
rank = ortho_basis.shape[1]
if rank == 0:
self.uncertainty_set_ = UncertaintySet(
radius=0.0, geometry=np.zeros((n_assets, 1)), norm=2
)
return self
# Scale each orthogonal direction.
idio_covariance = factor_model.idio_covariance
if self.uncertainty_shape == "identity":
scaling_sqrt = np.eye(rank)
elif self.uncertainty_shape == "idio_variance":
if idio_covariance.ndim == 1:
projected_var = ortho_basis.T @ np.diag(idio_covariance) @ ortho_basis
else:
projected_var = ortho_basis.T @ idio_covariance @ ortho_basis
scaling_sqrt = sla.sqrtm(projected_var).real
geometry = ortho_basis @ scaling_sqrt
radius = np.sqrt(st.chi2.ppf(q=self.confidence_level, df=rank))
self.uncertainty_set_ = UncertaintySet(radius=radius, geometry=geometry, norm=2)
return self
def _validate_params(self) -> None:
"""Validate estimator parameters."""
if (
isinstance(self.confidence_level, bool)
or not isinstance(self.confidence_level, numbers.Real)
or not 0 < self.confidence_level < 1
):
raise ValueError("`confidence_level` must be a float in (0, 1).")
if not isinstance(self.cs_weighting, CSWeighting):
raise TypeError("`cs_weighting` must be a `CSWeighting`.")
if self.uncertainty_shape not in {"identity", "idio_variance"}:
raise ValueError(
"`uncertainty_shape` must be one of {'identity', 'idio_variance'}."
)
[docs]
class OrthogonalCovarianceUncertaintySet(BaseCovarianceUncertaintySet):
r"""Covariance uncertainty set estimator for directions outside the factor span.
This estimator builds a compact covariance uncertainty set for robust portfolio
optimization [1]_ [2]_ [3]_. The robust penalty assigns additional covariance uncertainty to
portfolio directions that are in the subspace orthogonal to the factor-model
loading matrix, under the selected cross-sectional weighting metric.
The base covariance is assumed to have the factor structure
.. math::
\Sigma = B F B^\top + D
where :math:`B` is the loading matrix, :math:`F` is the factor covariance matrix
and :math:`D` is the idiosyncratic covariance matrix.
Under this uncertainty set, the worst-case variance for a portfolio with weights
:math:`w` is
.. math::
\sup_{\Sigma \in U} w^\top \Sigma\, w
=
w^\top \hat{\Sigma} w
+
\kappa
\min_z
\lVert C w - Q z \rVert_2^2.
Here, :math:`Q` is an orthonormal basis of the weighted factor span
:math:`\operatorname{col}(W^{1/2} B)` and :math:`C = W^{-1/2}`.
The expression is the compact form of the quadratic penalty
.. math::
\kappa C^\top (I - Q Q^\top) C.
This structured form avoids the lifted SDP formulation used for fully generic
covariance uncertainty sets and avoids materializing the dense matrix [4]_
:math:`C^\top (I - Q Q^\top) C`.
If the factor model uses basket-neutral constraints, the loading matrix is first
reduced to its effective full-rank basis before the orthogonal subspace is computed.
Parameters
----------
radius : float, default=1.0
Penalty radius :math:`\kappa`. Controls the magnitude of the orthogonal
covariance penalty. Must be non-negative.
cs_weighting : CSWeighting, default=CSWeighting.INVERSE_IDIO_VARIANCE
Cross-sectional weighting used to define the orthogonality metric.
Attributes
----------
uncertainty_set_ : CompactCovarianceUncertaintySet
Fitted solver-ready uncertainty set containing the radius, diagonal metric
square root and basis.
Notes
-----
This estimator requires a factor model in the return distribution. When used inside
:class:`~skfolio.optimization.MeanRisk`, the `return_distribution` metadata is
passed automatically by `fit` and `partial_fit`. Covariance uncertainty is applied
when `risk_measure=RiskMeasure.VARIANCE` or when `max_variance` is set.
References
----------
.. [1] "Robustness properties of mean-variance portfolios",
Optimization: A Journal of Mathematical Programming and Operations Research,
Schöttle & Werner (2009).
.. [2] "Portfolio Optimization: Theory and Application", Chapter 14,
Daniel P. Palomar (2025)
.. [3] "Robust Portfolio Selection Problems", Mathematics of Operations Research,
Goldfarb and Iyengar (2003).
.. [4] "Robust Convex Optimization", Mathematics of Operations Research,
Ben-Tal and Nemirovski (1998).
"""
def __init__(
self,
radius: float = 1.0,
cs_weighting: CSWeighting = CSWeighting.INVERSE_IDIO_VARIANCE,
):
super().__init__(prior_estimator=None)
self.radius = radius
self.cs_weighting = cs_weighting
[docs]
def fit(
self,
X: ArrayLike,
y: ArrayLike | None = None,
*,
return_distribution: ReturnDistribution | None = None,
**fit_params,
) -> OrthogonalCovarianceUncertaintySet:
r"""Fit the orthogonal covariance uncertainty set.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns of the assets.
y : array-like of shape (n_observations, n_factors), optional
Price returns of factors. The default is `None`.
return_distribution : ReturnDistribution, optional
The fitted return distribution from the prior estimator.
Passed internally by :class:`~skfolio.optimization.MeanRisk`. Must contain a
`factor_model` with `loading_matrix` and `idio_covariance`.
**fit_params : dict
Additional parameters (unused).
Returns
-------
self : OrthogonalCovarianceUncertaintySet
Fitted estimator.
"""
return self._fit(
X,
y,
return_distribution=return_distribution,
**fit_params,
)
[docs]
def partial_fit(
self,
X: ArrayLike,
y: ArrayLike | None = None,
*,
return_distribution: ReturnDistribution | None = None,
**fit_params,
) -> OrthogonalCovarianceUncertaintySet:
r"""Update the orthogonal covariance uncertainty set.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns of the assets.
y : array-like of shape (n_observations, n_factors), optional
Price returns of factors. The default is `None`.
return_distribution : ReturnDistribution, optional
The fitted return distribution from the prior estimator.
Passed internally by :class:`~skfolio.optimization.MeanRisk`. Must contain a
`factor_model` with `loading_matrix` and `idio_covariance`.
**fit_params : dict
Additional parameters (unused).
Returns
-------
self : OrthogonalCovarianceUncertaintySet
Updated estimator.
"""
return self._fit(
X,
y,
return_distribution=return_distribution,
**fit_params,
)
def _fit(
self,
X: ArrayLike,
y: ArrayLike | None = None,
*,
return_distribution: ReturnDistribution | None = None,
**fit_params,
) -> OrthogonalCovarianceUncertaintySet:
"""Fit the estimator from a fitted factor-model return distribution."""
self._validate_params()
if return_distribution is None:
raise ValueError(
f"{self.__class__.__name__} requires `return_distribution` "
"from a fitted factor model prior. Use it with a "
"factor-model-based prior_estimator in MeanRisk."
)
factor_model = return_distribution.factor_model
if factor_model is None:
raise ValueError(
f"{self.__class__.__name__} requires a factor model in "
"return_distribution. Use a factor-model-based prior."
)
weights = factor_model._resolve_cs_weighting(self.cs_weighting, latest=True)
n_assets = factor_model.loading_matrix.shape[0]
if weights is None:
weights_sqrt = np.ones(n_assets)
else:
if weights.ndim != 1 or weights.shape[0] != n_assets:
raise ValueError("`cs_weighting` produced invalid weights.")
if np.any(~np.isfinite(weights)) or np.any(weights <= 0):
raise ValueError("Cross-sectional weights must be finite and positive.")
weights_sqrt = np.sqrt(weights)
metric_sqrt = 1.0 / weights_sqrt
weighted_loading = (
weights_sqrt[:, np.newaxis] * factor_model.effective_loading_matrix
)
# The effective loading matrix removes known structural dependencies, but the
# selected asset universe can still make factor exposures numerically rank
# deficient. Keep only stable singular directions so numerical noise does not
# expand the unpenalized factor span.
basis, singular_values, _ = sla.svd(
weighted_loading, full_matrices=False, check_finite=False
)
if singular_values.size != 0:
tolerance = (
max(weighted_loading.shape) * np.finfo(float).eps * singular_values[0]
)
rank = int(np.sum(singular_values > tolerance))
basis = basis[:, :rank]
self.uncertainty_set_ = CompactCovarianceUncertaintySet(
radius=self.radius, metric_sqrt=metric_sqrt, basis=basis
)
return self
def _validate_params(self) -> None:
"""Validate estimator parameters."""
if (
isinstance(self.radius, bool)
or not isinstance(self.radius, numbers.Real)
or not np.isfinite(self.radius)
or self.radius < 0
):
raise ValueError("`radius` must be a non-negative float.")
if not isinstance(self.cs_weighting, CSWeighting):
raise TypeError("`cs_weighting` must be a `CSWeighting`.")