Source code for skfolio.uncertainty_set._model._uncertainty_set
"""Uncertainty Set dataclasses."""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from skfolio.typing import FloatArray
__all__ = ["CompactCovarianceUncertaintySet", "UncertaintySet"]
# frozen=True with eq=False will lead to an id-based hashing which is needed for
# caching CVX models in Optimization without impacting performance
[docs]
@dataclass(frozen=True, eq=False)
class UncertaintySet:
r"""Norm-ball uncertainty set.
A norm-ball uncertainty set represents deviations of a parameter vector :math:`z`
from an estimate :math:`\hat{z}` as
.. math::
z - \hat{z} = L u,
\quad
\lVert u \rVert_p \le \kappa.
Equivalently, the uncertainty set is
.. math::
\mathcal{U}
=
\left\{
\hat{z} + L u :
\lVert u \rVert_p \le \kappa
\right\}.
All common uncertainty sets, including ellipsoidal, box and diamond sets, can be
represented by choosing the radius :math:`\kappa`, the norm :math:`p` and the
linear map :math:`L`.
The radius :math:`\kappa` controls the size of the normalized uncertainty ball.
The norm :math:`p` selects its canonical shape:
* :math:`p = 2`: Euclidean ball
* :math:`p = \infty`: Box
* :math:`p = 1`: Diamond / Cross-polytope
The `geometry` parameter stores the linear geometry map :math:`L`. It maps the
normalized ball into parameter space by scaling and mixing uncertainty directions to
form deviations of :math:`z` from :math:`\hat{z}`. The estimator precomputes this
map so the optimizer can work directly with :math:`L`, which may be low-rank
:math:`(n \times r)` with :math:`r \ll n`.
For a linear exposure vector :math:`e`, the worst-case deviation over
:math:`\mathcal{U}` is
.. math::
\sup_{z \in \mathcal{U}}
e^\top(z - \hat{z})
=
\kappa \,
\lVert L^\top e \rVert_q,
where :math:`q` is the dual norm of :math:`p`.
Downstream optimizers use this support-function as the uncertainty penalty.
For expected-return uncertainty, :math:`z` is :math:`\mu` and :math:`e` is the
portfolio weight vector. For covariance uncertainty, :math:`z` is
:math:`\operatorname{vec}(\Sigma)` (the vector obtained by stacking the columns of
:math:`\Sigma`) and :math:`e` has the same vectorized shape.
Standard choices are:
* **Ellipsoidal set:** Use `norm=2`. For a full-rank shape matrix :math:`S`, set
`geometry` to a square-root factor :math:`L` satisfying :math:`S = L L^\top`. This
gives :math:`(z - \hat{z})^\top S^{-1} (z - \hat{z}) \le \kappa^2`. For a low-rank
representation :math:`S = G \Lambda G^\top`, set `geometry` to :math:`G \Lambda^{1/2}`.
* **Box set:** Use `norm=np.inf`. With axis widths :math:`\delta_i`, set `geometry`
to :math:`\operatorname{diag}(\delta)`. This gives
:math:`|z_i - \hat{z}_i| \le \kappa \delta_i` for each coordinate and the dual
norm is :math:`1`.
* **Diamond set:** Use `norm=1`. With axis scales :math:`\delta_i`, set `geometry`
to :math:`\operatorname{diag}(\delta)`. This gives
:math:`\sum_i |z_i - \hat{z}_i| / \delta_i \le \kappa` and the dual norm is
:math:`\infty`.
Parameters
----------
radius : float
Radius :math:`\kappa` of the normalized uncertainty ball
:math:`\lVert u \rVert_p \le \kappa`.
geometry : ndarray of shape (n_parameters, n_uncertainties)
Linear geometry map :math:`L` mapping normalized uncertainty coordinates into
deviations from :math:`\hat{z}`:
.. math::
z - \hat{z} = L u.
For ellipsoidal uncertainty with shape matrix :math:`S`, `geometry` is a
square-root factor :math:`L` satisfying :math:`S = L L^\top`.
For axis-aligned box or diamond uncertainty with widths `delta`, `geometry` is
typically :math:`\operatorname{diag}(\delta)`.
norm : float or int, default=2
Norm :math:`p` defining the normalized uncertainty ball. Must be greater than or
equal to 1. Common choices are `2` for ellipsoidal uncertainty, `np.inf` for box
uncertainty, and `1` for diamond uncertainty.
Attributes
----------
dual_norm : float
Dual norm :math:`q` associated with `norm`. This is the norm used in the
support-function penalty :math:`\kappa \lVert L^\top e \rVert_q`.
"""
radius: float
geometry: FloatArray
norm: float | int
def __post_init__(self) -> None:
"""Validate the norm-ball parameters."""
geometry = np.asarray(self.geometry)
if geometry.ndim != 2:
raise ValueError("`geometry` must be a 2D array.")
radius = float(self.radius)
if radius < 0:
raise ValueError("`radius` must be non-negative.")
norm = float(self.norm)
if norm < 1.0:
raise ValueError("`norm` must be greater than or equal to 1.")
object.__setattr__(self, "radius", radius)
object.__setattr__(self, "geometry", geometry)
object.__setattr__(self, "norm", norm)
@property
def dual_norm(self) -> float:
"""Dual norm associated with `norm`."""
if self.norm == 1.0:
return np.inf
if self.norm == np.inf:
return 1.0
return self.norm / (self.norm - 1)
[docs]
@dataclass(frozen=True, eq=False)
class CompactCovarianceUncertaintySet:
r"""Compact representation of a quadratic covariance uncertainty penalty.
This object stores the data needed to evaluate a worst-case variance penalty in
reduced projection form, without materializing the equivalent dense positive
semidefinite matrix.
Let :math:`C` be a diagonal metric square root and let :math:`Q` be an orthonormal
basis. For portfolio weights :math:`w`, the optimizer evaluates
.. math::
\kappa \min_z \lVert C w - Q z \rVert_2^2.
This is equivalent to adding the following positive semidefinite matrix to the
quadratic variance term:
.. math::
\kappa C^\top (I - Q Q^\top) C.
The compact representation avoids building this dense matrix. The optimizer only
needs the diagonal entries of :math:`C` and the basis :math:`Q`.
Parameters
----------
radius : float
Non-negative multiplier :math:`\kappa` applied to the quadratic covariance
penalty.
metric_sqrt : ndarray of shape (n_assets,)
Diagonal of the metric square root :math:`C`.
basis : ndarray of shape (n_assets, rank)
Orthonormal basis :math:`Q` of the subspace projected out by the quadratic
penalty.
Attributes
----------
radius : float
Non-negative multiplier :math:`\kappa`.
metric_sqrt : ndarray of shape (n_assets,)
Diagonal of the metric square root :math:`C`.
basis : ndarray of shape (n_assets, rank)
Orthonormal basis :math:`Q`.
"""
radius: float
metric_sqrt: FloatArray
basis: FloatArray
def __post_init__(self) -> None:
"""Validate parameters."""
radius = float(self.radius)
if radius < 0:
raise ValueError("`radius` must be non-negative.")
metric_sqrt = np.asarray(self.metric_sqrt, dtype=float)
if metric_sqrt.ndim != 1:
raise ValueError("`metric_sqrt` must be a 1D array.")
if np.any(~np.isfinite(metric_sqrt)) or np.any(metric_sqrt < 0):
raise ValueError("`metric_sqrt` must contain finite non-negative values.")
basis = np.asarray(self.basis, dtype=float)
if basis.ndim != 2:
raise ValueError("`basis` must be a 2D array.")
if basis.shape[0] != metric_sqrt.shape[0]:
raise ValueError(
"`basis` and `metric_sqrt` must have the same number of assets."
)
if np.any(~np.isfinite(basis)):
raise ValueError("`basis` must contain finite values.")
object.__setattr__(self, "radius", radius)
object.__setattr__(self, "metric_sqrt", metric_sqrt)
object.__setattr__(self, "basis", basis)