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

57 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 05:23 +0000

1"""Linear algebra helpers used by the Basanos optimizer. 

2 

3This private module provides a small set of NumPy-based utilities for 

4working with symmetric (correlation-like) matrices in a robust way: 

5- valid(matrix): mask out rows/cols with non-finite diagonal entries and 

6 return the corresponding sub-matrix. 

7- is_positive_definite(matrix): explicitly test whether a matrix is 

8 positive-definite using Cholesky decomposition. 

9- inv_a_norm(vector, matrix=None, cond_threshold=...): compute the inverse 

10 A-norm of a vector with respect to a (possibly masked) positive-definite 

11 matrix; defaults to the Euclidean norm when no matrix is given. Emits 

12 IllConditionedMatrixWarning when the condition number exceeds the threshold. 

13- solve(matrix, rhs, cond_threshold=...): solve a linear system on the valid 

14 subset indicated by finite diagonal entries, returning NaNs for invalid 

15 positions. Emits IllConditionedMatrixWarning when the condition number 

16 exceeds the threshold. 

17 

18Both solve and inv_a_norm use Cholesky decomposition as a numerically stable 

19first attempt; they fall back to standard LU-based solving for matrices that 

20are not positive-definite. 

21 

22These routines are intentionally lightweight and raise domain-specific 

23exceptions to guard against shape mismatches and numerical errors. 

24They are internal implementation details and may change without notice. 

25""" 

26 

27from __future__ import annotations 

28 

29import warnings 

30 

31import numpy as np 

32from scipy.linalg import cho_factor, cho_solve 

33 

34from basanos.exceptions import ( 

35 DimensionMismatchError, 

36 IllConditionedMatrixWarning, 

37 NonSquareMatrixError, 

38 SingularMatrixError, 

39) 

40 

41_DEFAULT_COND_THRESHOLD: float = 1e12 

42"""Default condition-number threshold above which a warning is emitted.""" 

43 

44 

45def valid(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]: 

46 """Validate a square matrix and return mask and valid sub-matrix. 

47 

48 Checks that the input is square and that diagonal entries are finite. 

49 Returns a boolean mask for valid diagonal entries and the sub-matrix 

50 consisting of valid rows/columns. 

51 

52 Args: 

53 matrix (np.ndarray): Input square matrix. 

54 

55 Returns: 

56 tuple[np.ndarray, np.ndarray]: 

57 - mask: Boolean array indicating which diagonal elements are finite. 

58 - submatrix: Matrix containing only rows/columns with finite diagonal. 

59 

60 Raises: 

61 NonSquareMatrixError: If the input matrix is not square. 

62 

63 Examples: 

64 >>> import numpy as np 

65 >>> mask, sub = valid(np.eye(2)) 

66 >>> mask.tolist() 

67 [True, True] 

68 >>> sub.shape 

69 (2, 2) 

70 """ 

71 # make sure matrix is quadratic 

72 if matrix.shape[0] != matrix.shape[1]: 

73 raise NonSquareMatrixError(matrix.shape[0], matrix.shape[1]) 

74 

75 v = np.isfinite(np.diag(matrix)) 

76 return v, matrix[:, v][v] 

77 

78 

79def is_positive_definite(matrix: np.ndarray) -> bool: 

80 """Return True if *matrix* is symmetric positive-definite, False otherwise. 

81 

82 The check is performed via an attempted Cholesky decomposition — the most 

83 numerically reliable way to test positive-definiteness for symmetric 

84 matrices. A matrix is positive-definite if and only if its Cholesky 

85 decomposition exists and all diagonal entries of the Cholesky factor are 

86 strictly positive. 

87 

88 This function is intentionally side-effect-free: it raises no exceptions 

89 and emits no warnings. It is suitable for use as a guard before passing a 

90 shrunk correlation matrix to a linear solver. 

91 

92 Args: 

93 matrix (np.ndarray): Square matrix to test. 

94 

95 Returns: 

96 bool: ``True`` if the matrix is positive-definite, ``False`` otherwise. 

97 

98 Examples: 

99 >>> import numpy as np 

100 >>> is_positive_definite(np.eye(3)) 

101 True 

102 >>> is_positive_definite(np.array([[1.0, 2.0], [2.0, 1.0]])) 

103 False 

104 >>> is_positive_definite(np.array([[1.0, 0.5], [0.5, 1.0]])) 

105 True 

106 """ 

107 try: 

108 cho_factor(matrix, check_finite=False) 

109 except np.linalg.LinAlgError: 

110 return False 

111 else: 

112 return True 

113 

114 

115def _check_and_warn_condition(matrix: np.ndarray, threshold: float) -> None: 

116 """Emit IllConditionedMatrixWarning when the condition number exceeds threshold. 

117 

118 Args: 

119 matrix (np.ndarray): Square matrix whose condition number is checked. 

120 threshold (float): Upper bound before a warning is issued. 

121 """ 

122 cond = float(np.linalg.cond(matrix)) 

123 if cond > threshold: 

124 warnings.warn( 

125 f"Matrix condition number {cond:.3e} exceeds threshold {threshold:.3e}; " 

126 "results may be numerically unreliable.", 

127 IllConditionedMatrixWarning, 

128 stacklevel=3, 

129 ) 

130 

131 

132def _cholesky_solve(matrix: np.ndarray, rhs: np.ndarray) -> np.ndarray: 

133 """Solve *matrix* x = *rhs* using Cholesky, falling back to LU if needed. 

134 

135 Cholesky decomposition is attempted first as it is numerically more stable 

136 for positive-definite matrices. If the decomposition fails (the matrix is 

137 not positive-definite), a standard LU-based solve is used instead. 

138 

139 Args: 

140 matrix (np.ndarray): Square coefficient matrix. 

141 rhs (np.ndarray): Right-hand side vector. 

142 

143 Returns: 

144 np.ndarray: Solution vector. 

145 

146 Raises: 

147 np.linalg.LinAlgError: If both Cholesky and LU-based solves fail. 

148 """ 

149 try: 

150 c, lower = cho_factor(matrix, check_finite=False) 

151 return cho_solve((c, lower), rhs, check_finite=False) 

152 except np.linalg.LinAlgError: 

153 # Matrix is not positive-definite; fall back to general LU solve. 

154 return np.linalg.solve(matrix, rhs) 

155 

156 

157def inv_a_norm( 

158 vector: np.ndarray, 

159 matrix: np.ndarray | None = None, 

160 cond_threshold: float = _DEFAULT_COND_THRESHOLD, 

161) -> float: 

162 """Compute inverse A-norm of a vector with an optional metric matrix. 

163 

164 If ``matrix`` is None, compute the Euclidean norm of finite entries. 

165 Otherwise, validate the matrix is square and dimensionally compatible, 

166 then return sqrt(v^T A^{-1} v) on the valid subset. 

167 

168 Cholesky decomposition is attempted first for numerical stability; the 

169 solver falls back to LU decomposition for non-positive-definite matrices. 

170 If the condition number of the valid sub-matrix exceeds *cond_threshold*, 

171 an :class:`~basanos.exceptions.IllConditionedMatrixWarning` is emitted. 

172 

173 Args: 

174 vector (np.ndarray): Input vector. 

175 matrix (np.ndarray | None, optional): Positive-definite metric matrix. 

176 cond_threshold (float, optional): Condition-number threshold above which 

177 a warning is emitted. Defaults to ``1e12``. 

178 

179 Returns: 

180 float: The computed norm. Returns np.nan if no valid entries exist. 

181 

182 Raises: 

183 NonSquareMatrixError: If ``matrix`` is not square. 

184 DimensionMismatchError: If ``vector`` length does not match the matrix dimension. 

185 SingularMatrixError: If the valid sub-matrix is singular. 

186 

187 Examples: 

188 >>> import numpy as np 

189 >>> inv_a_norm(np.array([3.0, 4.0])) 

190 5.0 

191 """ 

192 if matrix is None: 

193 return float(np.linalg.norm(vector[np.isfinite(vector)], 2)) 

194 

195 # make sure matrix is quadratic 

196 if matrix.shape[0] != matrix.shape[1]: 

197 raise NonSquareMatrixError(matrix.shape[0], matrix.shape[1]) 

198 

199 # make sure the vector has the right number of entries 

200 if vector.size != matrix.shape[0]: 

201 raise DimensionMismatchError(vector.size, matrix.shape[0]) 

202 

203 v, mat = valid(matrix) 

204 

205 if v.any(): 

206 _check_and_warn_condition(mat, cond_threshold) 

207 try: 

208 return float(np.sqrt(np.dot(vector[v], _cholesky_solve(mat, vector[v])))) 

209 except np.linalg.LinAlgError as exc: 

210 raise SingularMatrixError(str(exc)) from exc 

211 return float(np.nan) 

212 

213 

214def solve( 

215 matrix: np.ndarray, 

216 rhs: np.ndarray, 

217 cond_threshold: float = _DEFAULT_COND_THRESHOLD, 

218) -> np.ndarray: 

219 """Solve Ax=b on the valid subset indicated by finite diagonal entries. 

220 

221 Cholesky decomposition is attempted first for numerical stability; the 

222 solver falls back to LU decomposition for non-positive-definite matrices. 

223 If the condition number of the valid sub-matrix exceeds *cond_threshold*, 

224 an :class:`~basanos.exceptions.IllConditionedMatrixWarning` is emitted. 

225 

226 Args: 

227 matrix (np.ndarray): Square coefficient matrix A. 

228 rhs (np.ndarray): Right-hand side vector b with matching size. 

229 cond_threshold (float, optional): Condition-number threshold above which 

230 a warning is emitted. Defaults to ``1e12``. 

231 

232 Returns: 

233 np.ndarray: Solution vector with NaN for invalid positions. 

234 

235 Raises: 

236 NonSquareMatrixError: If matrix is not square. 

237 DimensionMismatchError: If rhs size does not match the matrix dimension. 

238 SingularMatrixError: If the valid sub-matrix is singular. 

239 

240 Examples: 

241 >>> import numpy as np 

242 >>> solve(np.eye(2), np.array([1.0, 2.0])).tolist() 

243 [1.0, 2.0] 

244 """ 

245 # make sure matrix is quadratic 

246 if matrix.shape[0] != matrix.shape[1]: 

247 raise NonSquareMatrixError(matrix.shape[0], matrix.shape[1]) 

248 

249 # make sure the vector rhs has the right number of entries 

250 if rhs.size != matrix.shape[0]: 

251 raise DimensionMismatchError(rhs.size, matrix.shape[0]) 

252 

253 x = np.nan * np.ones(rhs.size) 

254 v, mat = valid(matrix) 

255 

256 if v.any(): 

257 _check_and_warn_condition(mat, cond_threshold) 

258 try: 

259 x[v] = _cholesky_solve(mat, rhs[v]) 

260 except np.linalg.LinAlgError as exc: 

261 raise SingularMatrixError(str(exc)) from exc 

262 

263 return x