Coverage for src/cvxcla/operators/factor.py: 100%

76 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-27 06:45 +0000

1"""Diagonal-plus-low-rank covariance backend. 

2 

3``FactorCovariance`` implements the :class:`~cvxcla.operators._core.QuadraticForm` 

4protocol for factor risk models and RMT-cleaned covariances of the form 

5``Sigma = diag(d) + U @ Delta @ U.T``, solving against the free block through the 

6Woodbury identity so no ``n x n`` matrix is ever materialised. 

7""" 

8 

9from __future__ import annotations 

10 

11from dataclasses import dataclass 

12from functools import cached_property 

13from typing import cast 

14 

15import numpy as np 

16from numpy.typing import NDArray 

17 

18 

19@dataclass(frozen=True) 

20class FactorCovariance: 

21 """Diagonal-plus-low-rank covariance ``Sigma = diag(d) + U @ Delta @ U.T``. 

22 

23 This backend implements the ``CovarianceOperator`` protocol for factor 

24 risk models and RMT-cleaned covariances (constant-residual-eigenvalue / 

25 eigenvalue clipping) without ever forming an ``n x n`` matrix. Solves 

26 against the free block go through the Woodbury identity 

27 

28 ``Sigma_FF^{-1} = D_F^{-1} - D_F^{-1} U_F W^{-1} U_F^T D_F^{-1}`` 

29 

30 with the ``k x k`` correction matrix ``W = Delta^{-1} + U_F^T D_F^{-1} U_F``, 

31 so a solve costs ``O(n_F k^2 + k^3)`` instead of ``O(n_F^3)``. The 

32 cross-product against blocked assets only involves the low-rank part, 

33 since the diagonal contributes nothing off the diagonal. 

34 

35 Attributes: 

36 d: Positive idiosyncratic variances of shape ``(n,)``. 

37 u: Factor loadings of shape ``(n, k)``. 

38 delta: Factor covariance, either as eigenvalues/variances of shape 

39 ``(k,)`` (interpreted as a diagonal matrix) or as a symmetric 

40 positive definite matrix of shape ``(k, k)``. 

41 

42 Examples: 

43 >>> import numpy as np 

44 >>> cov = FactorCovariance(d=np.array([1.0, 1.0]), u=np.eye(2), delta=np.array([1.0, 1.0])) 

45 >>> cov.n 

46 2 

47 >>> cov.matvec(np.array([1.0, 2.0])) 

48 array([2., 4.]) 

49 """ 

50 

51 d: NDArray[np.float64] 

52 u: NDArray[np.float64] 

53 delta: NDArray[np.float64] 

54 

55 def __post_init__(self) -> None: 

56 """Validate shapes, positivity of the diagonal, and symmetry of ``delta``. 

57 

58 Raises: 

59 ValueError: If ``d`` is not a positive vector, ``u`` is not an 

60 ``(n, k)`` matrix matching ``d``, or ``delta`` is neither a 

61 ``(k,)`` vector nor a symmetric ``(k, k)`` matrix. 

62 """ 

63 if self.d.ndim != 1 or not np.all(self.d > 0): 

64 msg = "d must be a vector of positive idiosyncratic variances" 

65 raise ValueError(msg) 

66 if self.u.ndim != 2 or self.u.shape[0] != self.d.shape[0]: 

67 msg = f"u must have shape (n, k) with n = {self.d.shape[0]}, got {self.u.shape}" 

68 raise ValueError(msg) 

69 k = self.u.shape[1] 

70 if self.delta.ndim == 1: 

71 if self.delta.shape[0] != k: 

72 msg = f"delta must have {k} entries, got {self.delta.shape[0]}" 

73 raise ValueError(msg) 

74 if not np.all(self.delta > 0): 

75 msg = "A diagonal delta must have positive entries" 

76 raise ValueError(msg) 

77 elif self.delta.ndim == 2: 

78 if self.delta.shape != (k, k): 

79 msg = f"delta must have shape ({k}, {k}), got {self.delta.shape}" 

80 raise ValueError(msg) 

81 if not np.allclose(self.delta, self.delta.T): 

82 msg = "delta must be symmetric" 

83 raise ValueError(msg) 

84 else: 

85 msg = f"delta must be a (k,) vector or (k, k) matrix, got ndim {self.delta.ndim}" 

86 raise ValueError(msg) 

87 

88 @property 

89 def n(self) -> int: 

90 """Number of assets (the dimension of the covariance).""" 

91 return int(self.d.shape[0]) 

92 

93 @property 

94 def k(self) -> int: 

95 """Number of factors (the rank of the low-rank part).""" 

96 return int(self.u.shape[1]) 

97 

98 @cached_property 

99 def _delta_matrix(self) -> NDArray[np.float64]: 

100 """The factor covariance ``Delta`` as a ``(k, k)`` matrix.""" 

101 if self.delta.ndim == 1: 

102 return np.diag(self.delta) 

103 return self.delta 

104 

105 @cached_property 

106 def _delta_inv(self) -> NDArray[np.float64]: 

107 """The inverse of the ``(k, k)`` factor covariance.""" 

108 if self.delta.ndim == 1: 

109 return np.diag(1.0 / self.delta) 

110 return cast("NDArray[np.float64]", np.linalg.inv(self.delta)) 

111 

112 def matvec(self, x: NDArray[np.float64]) -> NDArray[np.float64]: 

113 """Compute the matrix-vector product ``Sigma @ x``. 

114 

115 Args: 

116 x: Vector of shape ``(n,)`` or matrix of shape ``(n, r)``. 

117 

118 Returns: 

119 ``Sigma @ x`` with the same trailing shape as ``x``, computed in 

120 ``O(n k)`` per column. 

121 """ 

122 low_rank = self.u @ (self._delta_matrix @ (self.u.T @ x)) 

123 diagonal = self.d * x if x.ndim == 1 else self.d[:, None] * x 

124 return diagonal + low_rank 

125 

126 def solve_free(self, free: NDArray[np.bool_], rhs: NDArray[np.float64]) -> NDArray[np.float64]: 

127 """Solve the free-block system ``Sigma[free][:, free] @ y = rhs`` via Woodbury. 

128 

129 Forms the ``k x k`` correction matrix 

130 ``W = Delta^{-1} + U_F^T D_F^{-1} U_F`` and solves against it, so the 

131 cost is ``O(n_F k^2 + k^3 + n_F k r)`` for ``r`` right-hand sides. 

132 

133 Args: 

134 free: Boolean mask of shape ``(n,)`` selecting the free assets. 

135 rhs: Right-hand side of shape ``(n_free,)`` or ``(n_free, r)``. 

136 

137 Returns: 

138 The solution ``y`` with the same shape as ``rhs``. 

139 """ 

140 d_free = self.d[free] 

141 u_free = self.u[free] 

142 

143 rhs_2d = rhs if rhs.ndim == 2 else rhs[:, None] 

144 dinv_rhs = rhs_2d / d_free[:, None] 

145 dinv_u = u_free / d_free[:, None] 

146 

147 w = self._delta_inv + u_free.T @ dinv_u 

148 solution = dinv_rhs - dinv_u @ np.linalg.solve(w, u_free.T @ dinv_rhs) 

149 return solution if rhs.ndim == 2 else solution[:, 0] 

150 

151 def cross(self, free: NDArray[np.bool_], x: NDArray[np.float64]) -> NDArray[np.float64]: 

152 """Compute the free-to-blocked cross product ``Sigma[free][:, ~free] @ x[~free]``. 

153 

154 Only the low-rank part contributes, since the diagonal of ``Sigma`` 

155 has no off-diagonal block: the result is 

156 ``U_F @ Delta @ (U_out^T @ x_out)``, an ``O(n k)`` computation. 

157 

158 Args: 

159 free: Boolean mask of shape ``(n,)`` selecting the free assets. 

160 x: Full-length vector of shape ``(n,)``; only the blocked entries 

161 ``x[~free]`` enter the product. 

162 

163 Returns: 

164 Vector of shape ``(n_free,)``. 

165 """ 

166 blocked = ~free 

167 return self.u[free] @ (self._delta_matrix @ (self.u[blocked].T @ x[blocked])) 

168 

169 def rcond_free(self, free: NDArray[np.bool_]) -> float: 

170 """Lower bound on the free block's reciprocal condition number. 

171 

172 The free block ``Sigma_FF = diag(d_F) + U_F @ Delta @ U_F^T`` is 

173 positive definite by construction (the positive idiosyncratic floor 

174 ``d`` keeps it full rank), which is exactly why this backend is the 

175 documented remedy for rank deficiency. Rather than form the 

176 ``n_F x n_F`` block, we bound the conditioning from the structure via 

177 Weyl's inequalities: 

178 

179 * ``lambda_min(Sigma_FF) >= min(d_F)`` (the low-rank part is PSD), and 

180 * ``lambda_max(Sigma_FF) <= max(d_F) + ||U_F||_2^2 * lambda_max(Delta)``. 

181 

182 Their ratio is a guaranteed *lower* bound on the true reciprocal 

183 condition number, computed in ``O(n_F k^2 + k^3)`` without ever 

184 materialising an ``n x n`` matrix. For a well-floored factor model it 

185 sits far above the CLA's singularity threshold, so the guard never 

186 trips; a pathologically tiny floor correctly drives it toward zero. 

187 

188 Args: 

189 free: Boolean mask of shape ``(n,)`` selecting the free assets. 

190 

191 Returns: 

192 A lower bound on the reciprocal 2-norm condition number, in ``[0, 1]``. 

193 """ 

194 if not np.any(free): 

195 return 1.0 

196 d_free = self.d[free] 

197 u_free = self.u[free] 

198 u_spectral_norm = float(np.linalg.svd(u_free, compute_uv=False)[0]) 

199 delta_max = float(np.linalg.eigvalsh(self._delta_matrix)[-1]) 

200 lam_max_upper = float(np.max(d_free)) + u_spectral_norm**2 * max(delta_max, 0.0) 

201 return float(np.min(d_free)) / lam_max_upper