Coverage for src/basanos/math/_factor_model.py: 100%

75 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-04 07:53 +0000

1r"""Factor risk model decomposition (Section 4.1 of basanos.pdf). 

2 

3This private module provides the `FactorModel` frozen dataclass, which 

4encapsulates the three-component factor model 

5 

6$$ 

7\\bm{\\Sigma} = \\mathbf{B}\\mathbf{F}\\mathbf{B}^\\top + \\mathbf{D} 

8$$ 

9 

10and a class method for fitting the model from a return matrix via the 

11Singular Value Decomposition (Section 4.2). 

12""" 

13 

14from __future__ import annotations 

15 

16import dataclasses 

17from typing import cast 

18 

19import numpy as np 

20 

21# cvx-linalg exposes this constant as a public top-level name since >= 0.7; 

22# the project floor is cvx-linalg>=0.9.0 (see pyproject.toml), so the public 

23# import always resolves and no legacy-layout fallback is required. 

24from cvx.linalg import DEFAULT_COND_THRESHOLD as _DEFAULT_COND_THRESHOLD 

25from cvx.linalg import DimensionMismatchError, SingularMatrixError 

26from cvx.linalg import check_and_warn_condition as _check_and_warn_condition 

27from cvx.linalg import inv as _inv 

28from cvx.linalg import solve as _solve 

29 

30from basanos.exceptions import FactorModelError 

31 

32 

33@dataclasses.dataclass(frozen=True) 

34class FactorModel: 

35 r"""Frozen dataclass for a factor risk model decomposition (Section 4.1). 

36 

37 Encapsulates the three components of the factor model 

38 

39 $$ 

40 \bm{\Sigma} = \mathbf{B}\mathbf{F}\mathbf{B}^\top + \mathbf{D} 

41 $$ 

42 

43 where 

44 

45 - $\mathbf{B} \in \mathbb{R}^{n \times k}$ is the *factor loading 

46 matrix*: column $j$ gives the sensitivity of each asset to 

47 factor $j$. 

48 - $\mathbf{F} \in \mathbb{R}^{k \times k}$ is the *factor covariance 

49 matrix* (positive definite), capturing how the $k$ factors 

50 co-vary. 

51 - $\mathbf{D} = \operatorname{diag}(d_1, \dots, d_n)$ with 

52 $d_i > 0$ is the *idiosyncratic variance* diagonal, capturing 

53 the asset-specific variance unexplained by the common factors. 

54 

55 The central assumption is $k \ll n$: the dominant systematic sources 

56 of risk are captured by a handful of factors while the idiosyncratic 

57 component is, by construction, uncorrelated across assets. 

58 

59 Attributes: 

60 factor_loadings: Factor loading matrix $\mathbf{B}$, 

61 shape ``(n, k)``. 

62 factor_covariance: Factor covariance matrix $\mathbf{F}$, 

63 shape ``(k, k)``. 

64 idiosyncratic_var: Idiosyncratic variance vector 

65 $(d_1, \dots, d_n)$, shape ``(n,)``. All entries must be 

66 strictly positive. 

67 

68 Examples: 

69 >>> import numpy as np 

70 >>> loadings = np.eye(3, 2) 

71 >>> cov = np.eye(2) * 0.5 

72 >>> idio = np.array([0.5, 0.5, 1.0]) 

73 >>> fm = FactorModel(factor_loadings=loadings, factor_covariance=cov, idiosyncratic_var=idio) 

74 >>> fm.n_assets 

75 3 

76 >>> fm.n_factors 

77 2 

78 >>> fm.covariance.shape 

79 (3, 3) 

80 """ 

81 

82 factor_loadings: np.ndarray 

83 factor_covariance: np.ndarray 

84 idiosyncratic_var: np.ndarray 

85 

86 def __post_init__(self) -> None: 

87 """Validate shape consistency and strict positivity after initialization. 

88 

89 Raises: 

90 FactorModelError: If ``factor_loadings`` is not 2-D. 

91 FactorModelError: If ``factor_covariance`` shape does not 

92 match the number of factors inferred from ``factor_loadings``. 

93 FactorModelError: If ``idiosyncratic_var`` length does 

94 not match the number of assets inferred from ``factor_loadings``. 

95 FactorModelError: If any element of 

96 ``idiosyncratic_var`` is not strictly positive. 

97 """ 

98 if self.factor_loadings.ndim != 2: 

99 raise FactorModelError(f"factor_loadings must be 2-D, got ndim={self.factor_loadings.ndim}.") # noqa: TRY003 

100 n, k = self.factor_loadings.shape 

101 if self.factor_covariance.shape != (k, k): 

102 raise FactorModelError( # noqa: TRY003 

103 f"factor_covariance must have shape ({k}, {k}) to match " 

104 f"factor_loadings columns, got {self.factor_covariance.shape}." 

105 ) 

106 if self.idiosyncratic_var.shape != (n,): 

107 raise FactorModelError( # noqa: TRY003 

108 f"idiosyncratic_var must have shape ({n},) to match factor_loadings rows, " 

109 f"got {self.idiosyncratic_var.shape}." 

110 ) 

111 if not np.all(self.idiosyncratic_var > 0): 

112 raise FactorModelError("All entries of idiosyncratic_var must be strictly positive.") # noqa: TRY003 

113 

114 @property 

115 def n_assets(self) -> int: 

116 """Number of assets *n* (rows of ``factor_loadings``).""" 

117 return int(self.factor_loadings.shape[0]) 

118 

119 @property 

120 def n_factors(self) -> int: 

121 """Number of factors *k* (columns of ``factor_loadings``).""" 

122 return int(self.factor_loadings.shape[1]) 

123 

124 @property 

125 def covariance(self) -> np.ndarray: 

126 r"""Reconstruct the full $n \times n$ covariance matrix. 

127 

128 Computes $\bm{\Sigma} = \mathbf{B}\mathbf{F}\mathbf{B}^\top + 

129 \mathbf{D}$ by combining the low-rank systematic component with the 

130 diagonal idiosyncratic component. 

131 

132 Returns: 

133 np.ndarray: Shape ``(n, n)`` symmetric covariance matrix. 

134 

135 Examples: 

136 >>> import numpy as np 

137 >>> loadings = np.array([[1.0, 0.0], [0.0, 1.0], [0.0, 0.0]]) 

138 >>> cov = np.eye(2) 

139 >>> idio = np.ones(3) 

140 >>> fm = FactorModel(factor_loadings=loadings, factor_covariance=cov, idiosyncratic_var=idio) 

141 >>> fm.covariance.diagonal().tolist() 

142 [2.0, 2.0, 1.0] 

143 """ 

144 return cast( 

145 "np.ndarray", 

146 self.factor_loadings @ self.factor_covariance @ self.factor_loadings.T + np.diag(self.idiosyncratic_var), 

147 ) 

148 

149 @property 

150 def woodbury_condition_number(self) -> float: 

151 r"""Condition number of the inner $k \times k$ Woodbury matrix. 

152 

153 Returns the condition number of the matrix 

154 

155 $$ 

156 \mathbf{M} = \mathbf{F}^{-1} + \mathbf{B}^\top\mathbf{D}^{-1}\mathbf{B} 

157 $$ 

158 

159 which is the matrix actually inverted during `solve`. A large 

160 value (above ``_DEFAULT_COND_THRESHOLD`` ≈ 1e12) indicates that the 

161 Woodbury solve is numerically unreliable. 

162 

163 This property gives callers a way to inspect the numerical health of 

164 the model without performing a full solve. Unlike the condition number 

165 of the full $n \times n$ covariance matrix, this measure is 

166 specific to the $k \times k$ inner system solved inside the 

167 Woodbury identity. 

168 

169 Returns: 

170 float: Condition number $\kappa(\mathbf{M})$. Returns 

171 ``inf`` when $\mathbf{F}$ is not positive-definite (e.g. 

172 singular or indefinite), as the Cholesky decomposition used to 

173 form $\mathbf{F}^{-1}$ fails in that case. 

174 

175 Examples: 

176 >>> import numpy as np 

177 >>> loadings = np.eye(3, 1) 

178 >>> cov = np.eye(1) 

179 >>> idio = np.ones(3) 

180 >>> fm = FactorModel(factor_loadings=loadings, factor_covariance=cov, idiosyncratic_var=idio) 

181 >>> fm.woodbury_condition_number > 0 

182 True 

183 """ 

184 d_inv = 1.0 / self.idiosyncratic_var # (n,) 

185 d_inv_b_mat = d_inv[:, None] * self.factor_loadings # D^{-1} B, shape (n, k) 

186 try: 

187 f_inv = _inv(self.factor_covariance) 

188 mid = f_inv + self.factor_loadings.T @ d_inv_b_mat # (k, k) 

189 except (np.linalg.LinAlgError, SingularMatrixError): 

190 return float("inf") 

191 return float(np.linalg.cond(mid)) 

192 

193 def solve( 

194 self, 

195 rhs: np.ndarray, 

196 cond_threshold: float = _DEFAULT_COND_THRESHOLD, 

197 ) -> np.ndarray: 

198 r"""Solve $\bm{\Sigma}\,\mathbf{x} = \mathbf{b}$ via the Woodbury identity. 

199 

200 Applies the Sherman--Morrison--Woodbury formula (Section 4.3 of 

201 basanos.pdf) to avoid forming or factorising the full 

202 $n \times n$ covariance matrix: 

203 

204 $$ 

205 (\mathbf{D} + \mathbf{B}\mathbf{F}\mathbf{B}^\top)^{-1} 

206 = \mathbf{D}^{-1} 

207 - \mathbf{D}^{-1}\mathbf{B} 

208 \bigl(\mathbf{F}^{-1} + \mathbf{B}^\top\mathbf{D}^{-1}\mathbf{B}\bigr)^{-1} 

209 \mathbf{B}^\top\mathbf{D}^{-1}. 

210 $$ 

211 

212 Because $\mathbf{D}$ is diagonal, $\mathbf{D}^{-1}$ is 

213 free. The inner matrix is $k \times k$ with cost 

214 $O(k^3)$, and the surrounding multiplications cost 

215 $O(kn)$. Total cost is $O(k^3 + kn)$ rather than 

216 $O(n^3)$. 

217 

218 Args: 

219 rhs: Right-hand side vector $\mathbf{b}$, shape ``(n,)``. 

220 cond_threshold: Condition-number threshold above which an 

221 `IllConditionedMatrixWarning` is 

222 emitted. The check is applied to both ``factor_covariance`` 

223 ($\mathbf{F}$) and to the inner $k \times k$ 

224 Woodbury matrix $\mathbf{F}^{-1} + \mathbf{B}^\top 

225 \mathbf{D}^{-1}\mathbf{B}$. Defaults to ``1e12``. 

226 

227 Returns: 

228 np.ndarray: Solution vector $\mathbf{x}$, shape ``(n,)``. 

229 

230 Raises: 

231 DimensionMismatchError: If ``rhs`` length does not match 

232 ``n_assets``. 

233 SingularMatrixError: If the inner $k \times k$ matrix is 

234 singular. 

235 

236 Examples: 

237 >>> import numpy as np 

238 >>> loadings = np.eye(3, 1) 

239 >>> cov = np.eye(1) 

240 >>> idio = np.ones(3) 

241 >>> fm = FactorModel(factor_loadings=loadings, factor_covariance=cov, idiosyncratic_var=idio) 

242 >>> rhs = np.array([1.0, 2.0, 3.0]) 

243 >>> x = fm.solve(rhs) 

244 >>> np.allclose(fm.covariance @ x, rhs) 

245 True 

246 """ 

247 n = self.n_assets 

248 if rhs.shape != (n,): 

249 raise DimensionMismatchError(rhs.size, n) 

250 

251 # D^{-1} is free because D is diagonal 

252 d_inv = 1.0 / self.idiosyncratic_var # (n,) 

253 d_inv_rhs = d_inv * rhs # D^{-1} b, shape (n,) 

254 d_inv_b_mat = d_inv[:, None] * self.factor_loadings # D^{-1} B, shape (n, k) 

255 

256 # Solve mid * w = B^T D^{-1} b, where mid = F^{-1} + B^T D^{-1} B. 

257 # F^{-1} is obtained via a Cholesky solve rather than an explicit 

258 # inversion, consistent with the Cholesky-first discipline in _linalg.py. 

259 # A condition-number check on factor_covariance is applied first so 

260 # that ill-conditioned F is flagged before its inverse enters mid. 

261 rhs_k = self.factor_loadings.T @ d_inv_rhs # (k,) 

262 try: 

263 _check_and_warn_condition(self.factor_covariance, cond_threshold) 

264 mid = _inv(self.factor_covariance, cond_threshold) + self.factor_loadings.T @ d_inv_b_mat # (k, k) 

265 _check_and_warn_condition(mid, cond_threshold) 

266 w = _solve(mid, rhs_k, cond_threshold) # (k,) 

267 except np.linalg.LinAlgError as exc: 

268 raise SingularMatrixError(str(exc)) from exc 

269 

270 # x = D^{-1} b - D^{-1} B w 

271 return cast("np.ndarray", d_inv_rhs - d_inv_b_mat @ w) 

272 

273 @classmethod 

274 def from_returns(cls, returns: np.ndarray, k: int) -> FactorModel: 

275 r"""Fit a rank-*k* factor model from a return matrix via truncated SVD. 

276 

277 Extracts latent factors from the return matrix 

278 $\mathbf{R} \in \mathbb{R}^{T \times n}$ using the Singular 

279 Value Decomposition (SVD). The top-*k* singular triplets define the 

280 factor model components: 

281 

282 $$ 

283 \mathbf{B} = \mathbf{V}_k, \quad 

284 \mathbf{F} = \bm{\Sigma}_k^2 / T, \quad 

285 \hat{d}_i = 1 - \bigl(\mathbf{B}\mathbf{F}\mathbf{B}^\top\bigr)_{ii} 

286 $$ 

287 

288 where $\mathbf{V}_k$ and $\bm{\Sigma}_k$ are the top-*k* 

289 right singular vectors and singular values of $\mathbf{R}$ 

290 respectively. When *returns* contains unit-variance columns (as 

291 produced by `vol_adj`), the sample 

292 covariance has unit diagonal; the idiosyncratic term 

293 $\hat{d}_i = 1 - (\mathbf{B}\mathbf{F}\mathbf{B}^\top)_{ii}$ 

294 absorbs the residual so the full covariance $\hat{\mathbf{C}}^{(k)}$ 

295 also has unit diagonal. Each $\hat{d}_i$ is clamped from below 

296 at machine epsilon to guarantee strict positivity. 

297 

298 Args: 

299 returns: Return matrix of shape ``(T, n)``, typically 

300 volatility-adjusted log returns with rows as timestamps and 

301 columns as assets. 

302 k: Number of factors to retain. Must satisfy 

303 ``1 <= k <= min(T, n)``. 

304 

305 Returns: 

306 FactorModel: Fitted factor model with ``n_assets = n`` and 

307 ``n_factors = k``. 

308 

309 Raises: 

310 FactorModelError: If *returns* is not 2-D. 

311 FactorModelError: If *k* is outside the range ``[1, min(T, n)]``. 

312 

313 Examples: 

314 >>> import numpy as np 

315 >>> rng = np.random.default_rng(0) 

316 >>> ret = rng.standard_normal((50, 5)) 

317 >>> fm = FactorModel.from_returns(ret, k=2) 

318 >>> fm.n_factors 

319 2 

320 >>> fm.n_assets 

321 5 

322 >>> fm.covariance.shape 

323 (5, 5) 

324 """ 

325 if returns.ndim != 2: 

326 raise FactorModelError(f"Return matrix must be 2-D, got ndim={returns.ndim}.") # noqa: TRY003 

327 t_len, n = returns.shape 

328 if not (1 <= k <= min(t_len, n)): 

329 raise FactorModelError(f"k must satisfy 1 <= k <= min(T, n) = {min(t_len, n)}, got k={k}.") # noqa: TRY003 

330 

331 _, s, vt = np.linalg.svd(returns, full_matrices=False) 

332 

333 # Top-k right singular vectors as columns: shape (n, k) 

334 v_k = vt[:k].T 

335 s_k = s[:k] 

336 

337 # Factor covariance: diagonal matrix with entries s_j**2 / T 

338 factor_cov = np.diag(s_k**2 / t_len) 

339 

340 # Diagonal of B*F*B^T = sum_j (s_j**2/T) * B[:,j]**2 

341 factor_diag = (v_k**2) @ (s_k**2 / t_len) 

342 

343 # Idiosyncratic variance: target diagonal is 1.0 (unit-variance columns 

344 # assumed); residual = 1.0 - systematic contribution, clamped to (0, inf) 

345 _unit_variance = 1.0 

346 d = np.maximum(_unit_variance - factor_diag, np.finfo(float).eps) 

347 

348 return cls(factor_loadings=v_k, factor_covariance=factor_cov, idiosyncratic_var=d)