Coverage for src/chebpy/quasimatrix.py: 100%

234 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-22 21:50 +0000

1"""Quasimatrix: a matrix with one continuous dimension. 

2 

3A quasimatrix is an inf x n matrix whose columns are Chebfun objects defined on 

4the same domain. This enables continuous analogues of linear algebra operations 

5such as QR factorization, SVD, least-squares, and more. 

6 

7Reference: Trefethen, "Householder triangularization of a quasimatrix," 

8IMA Journal of Numerical Analysis, 30 (2010), 887-897. 

9""" 

10 

11from __future__ import annotations 

12 

13from collections.abc import Iterator 

14from typing import Any, cast 

15 

16import matplotlib.pyplot as plt 

17import numpy as np 

18from matplotlib.axes import Axes 

19from matplotlib.patches import Rectangle 

20 

21from .chebfun import Chebfun 

22 

23 

24class Quasimatrix: 

25 """An inf x n column quasimatrix whose columns are Chebfun objects. 

26 

27 A quasimatrix generalises the idea of a matrix so that one of its 

28 dimensions is continuous. Here the rows are indexed by points in an 

29 interval and the columns are Chebfun objects. 

30 

31 Attributes: 

32 columns: list of Chebfun objects forming the columns. 

33 """ 

34 

35 # ------------------------------------------------------------------ 

36 # Construction 

37 # ------------------------------------------------------------------ 

38 def __init__(self, columns: list[Any]) -> None: 

39 """Initialise from a list of Chebfun objects, callables, or scalars.""" 

40 cols: list[Chebfun] = [] 

41 for c in columns: 

42 if isinstance(c, Chebfun): 

43 cols.append(c) 

44 elif callable(c): 

45 cols.append(Chebfun.initfun_adaptive(c, cols[0].domain if cols else None)) 

46 else: 

47 # scalar → constant chebfun on the domain of the first column 

48 if cols: 

49 cols.append(Chebfun.initconst(float(c), cols[0].domain)) 

50 else: 

51 from .settings import _preferences as prefs 

52 

53 cols.append(Chebfun.initconst(float(c), prefs.domain)) 

54 if len(cols) > 1: 

55 # Verify all columns share the same support 

56 ref = cols[0].support 

57 for k, col in enumerate(cols[1:], 1): 

58 if col.support != ref: 

59 msg = f"Column {k} support {col.support} does not match column 0 support {ref}" 

60 raise ValueError(msg) 

61 self.columns: list[Chebfun] = cols 

62 

63 # ------------------------------------------------------------------ 

64 # Properties 

65 # ------------------------------------------------------------------ 

66 @property 

67 def shape(self) -> tuple[float, int]: 

68 """Return (∞, n) where n is the number of columns.""" 

69 return (np.inf, len(self.columns)) 

70 

71 @property 

72 def T(self) -> _TransposedQuasimatrix: 

73 """Return the transpose (an n x inf row quasimatrix).""" 

74 return _TransposedQuasimatrix(self) 

75 

76 @property 

77 def domain(self) -> Any: 

78 """Domain of the quasimatrix columns.""" 

79 if not self.columns: 

80 return None 

81 return self.columns[0].domain 

82 

83 @property 

84 def support(self) -> tuple[float, float]: 

85 """Support interval of the quasimatrix.""" 

86 if not self.columns: 

87 return (0.0, 0.0) 

88 return cast("tuple[float, float]", self.columns[0].support) 

89 

90 @property 

91 def isempty(self) -> bool: 

92 """Return True if the quasimatrix has no columns.""" 

93 return len(self.columns) == 0 

94 

95 # ------------------------------------------------------------------ 

96 # Indexing A[:, k], A(x, k) 

97 # ------------------------------------------------------------------ 

98 def __getitem__(self, key: Any) -> Any: 

99 """Column indexing: ``A[:, k]`` returns column k as a Chebfun.""" 

100 if isinstance(key, tuple): 

101 row, col = key 

102 if isinstance(col, slice): 

103 return Quasimatrix(self.columns[col]) 

104 # A[x, k] - evaluate column k at point x 

105 if isinstance(row, slice) and row == slice(None): 

106 return self.columns[col] 

107 return self.columns[col](row) 

108 # A[k] - return column k 

109 if isinstance(key, (int, np.integer)): 

110 return self.columns[key] 

111 if isinstance(key, slice): 

112 return Quasimatrix(self.columns[key]) 

113 raise TypeError(key) 

114 

115 def __len__(self) -> int: 

116 """Return the number of columns.""" 

117 return len(self.columns) 

118 

119 def __iter__(self) -> Iterator[Chebfun]: 

120 """Iterate over the columns.""" 

121 return iter(self.columns) 

122 

123 # ------------------------------------------------------------------ 

124 # Calling A(x) - evaluate all columns at x, return array 

125 # ------------------------------------------------------------------ 

126 def __call__(self, x: Any) -> np.ndarray: 

127 """Evaluate every column at *x* and return the results as an array. 

128 

129 If *x* is a scalar the result has shape ``(n,)``. 

130 If *x* is an array of length *m* the result has shape ``(m, n)``. 

131 """ 

132 vals = [col(x) for col in self.columns] 

133 return np.column_stack(vals) if np.ndim(x) else np.array(vals) 

134 

135 # ------------------------------------------------------------------ 

136 # Arithmetic 

137 # ------------------------------------------------------------------ 

138 def __matmul__(self, other: Any) -> Any: 

139 """Matrix-vector product: ``A @ c`` returns a Chebfun. 

140 

141 *other* must be a 1-D array-like of length n. 

142 """ 

143 c = np.asarray(other, dtype=float) 

144 if c.ndim != 1 or len(c) != len(self.columns): 

145 msg = f"Cannot multiply {self.shape} quasimatrix by vector of length {len(c)}" 

146 raise ValueError(msg) 

147 result = c[0] * self.columns[0] 

148 for coeff, col in zip(c[1:], self.columns[1:], strict=True): 

149 result = result + coeff * col 

150 return result 

151 

152 def __mul__(self, other: Any) -> Quasimatrix: 

153 """Element-wise scalar multiplication.""" 

154 return Quasimatrix([c * other for c in self.columns]) 

155 

156 def __rmul__(self, other: Any) -> Quasimatrix: 

157 """Right scalar multiplication.""" 

158 return self.__mul__(other) 

159 

160 # ------------------------------------------------------------------ 

161 # Integrals and inner products 

162 # ------------------------------------------------------------------ 

163 def sum(self) -> np.ndarray: 

164 """Definite integral of each column (column sums).""" 

165 return np.array([col.sum() for col in self.columns]) 

166 

167 def inner(self, other: Quasimatrix | None = None) -> np.ndarray: 

168 """Gram matrix ``self.T @ other`` (or ``self.T @ self``).""" 

169 other = other if other is not None else self 

170 m = len(self.columns) 

171 n = len(other.columns) 

172 G = np.empty((m, n)) 

173 for i in range(m): 

174 for j in range(n): 

175 G[i, j] = self.columns[i].dot(other.columns[j]) 

176 return G 

177 

178 # ------------------------------------------------------------------ 

179 # QR factorization (modified Gram-Schmidt) 

180 # ------------------------------------------------------------------ 

181 def qr(self) -> tuple[Quasimatrix, np.ndarray]: 

182 """Compute the reduced QR factorization ``A = Q R``. 

183 

184 Uses modified Gram-Schmidt orthogonalisation in function space. 

185 

186 Returns: 

187 Q: Quasimatrix with orthonormal columns. 

188 R: Upper-triangular n x n NumPy array. 

189 """ 

190 n = len(self.columns) 

191 Q = [col.copy() for col in self.columns] 

192 R = np.zeros((n, n)) 

193 for k in range(n): 

194 for j in range(k): 

195 R[j, k] = Q[j].dot(Q[k]) 

196 Q[k] = Q[k] - R[j, k] * Q[j] 

197 R[k, k] = Q[k].norm(2) 

198 if R[k, k] == 0: 

199 msg = "Rank-deficient quasimatrix: QR factorization failed" 

200 raise np.linalg.LinAlgError(msg) 

201 Q[k] = (1.0 / R[k, k]) * Q[k] 

202 return Quasimatrix(Q), R 

203 

204 # ------------------------------------------------------------------ 

205 # SVD 

206 # ------------------------------------------------------------------ 

207 def svd(self) -> tuple[Quasimatrix, np.ndarray, np.ndarray]: 

208 """Compute the reduced SVD ``A = U S V^T``. 

209 

210 Returns: 

211 U: inf x n quasimatrix with orthonormal columns. 

212 S: 1-D array of singular values (length n). 

213 V: n x n orthogonal NumPy matrix. 

214 """ 

215 Q, R = self.qr() 

216 # Economy SVD of the n x n matrix R 

217 U_r, S, Vt = np.linalg.svd(R, full_matrices=False) 

218 # U = Q @ U_r (linear combinations of orthonormal columns) 

219 U_cols = [] 

220 for j in range(U_r.shape[1]): 

221 U_cols.append(Q @ U_r[:, j]) 

222 return Quasimatrix(U_cols), S, Vt.T # V = Vt.T 

223 

224 # ------------------------------------------------------------------ 

225 # Least-squares (backslash) 

226 # ------------------------------------------------------------------ 

227 def solve(self, f: Chebfun) -> np.ndarray: 

228 r"""Least-squares solution ``c`` to ``A c ~ f``. 

229 

230 Equivalent to MATLAB ``A\f``. Computed via QR factorisation. 

231 """ 

232 Q, R = self.qr() 

233 # b = Q' * f (inner products) 

234 b = np.array([col.dot(f) for col in Q.columns]) 

235 # Solve R c = b (back-substitution) 

236 return np.linalg.solve(R, b) 

237 

238 # ------------------------------------------------------------------ 

239 # Norms 

240 # ------------------------------------------------------------------ 

241 def norm(self, p: Any = "fro") -> float: 

242 """Compute the norm of the quasimatrix. 

243 

244 Args: 

245 p: Norm type. 

246 - 2: the 2-norm (largest singular value). 

247 - 1: max column 1-norm. 

248 - np.inf: max row-sum := max_x sum_j |A_j(x)|. 

249 - 'fro': Frobenius norm (default). 

250 """ 

251 if p == 2: 

252 _, S, _ = self.svd() 

253 return float(S[0]) 

254 if p == 1: 

255 return float(max(col.norm(1) for col in self.columns)) 

256 if p == np.inf: 

257 abssum = self.columns[0].absolute() 

258 for col in self.columns[1:]: 

259 abssum = abssum + col.absolute() 

260 return float(abssum.norm(np.inf)) 

261 if p == "fro": 

262 _, S, _ = self.svd() 

263 return float(np.sqrt(np.sum(S**2))) 

264 raise ValueError(f"Unsupported norm type: {p}") # noqa: TRY003 

265 

266 # ------------------------------------------------------------------ 

267 # Condition number 

268 # ------------------------------------------------------------------ 

269 def cond(self) -> float: 

270 """2-norm condition number (ratio of largest to smallest singular value).""" 

271 _, S, _ = self.svd() 

272 return float(S[0] / S[-1]) 

273 

274 # ------------------------------------------------------------------ 

275 # Rank 

276 # ------------------------------------------------------------------ 

277 def rank(self, tol: float | None = None) -> int: 

278 """Numerical rank (number of significant singular values).""" 

279 _, S, _ = self.svd() 

280 if tol is None: 

281 tol = max(self.shape[1], 20) * np.finfo(float).eps * S[0] 

282 return int(np.sum(tol < S)) 

283 

284 # ------------------------------------------------------------------ 

285 # Null space 

286 # ------------------------------------------------------------------ 

287 def null(self, tol: float | None = None) -> np.ndarray: 

288 """Orthonormal basis for the null space of the quasimatrix. 

289 

290 Returns an n x k NumPy array whose columns span ``null(A)``. 

291 """ 

292 _, S, V = self.svd() 

293 if tol is None: 

294 tol = max(self.shape[1], 20) * np.finfo(float).eps * S[0] 

295 mask = tol >= S 

296 return V[:, mask] 

297 

298 # ------------------------------------------------------------------ 

299 # Orth (orthonormal basis for range) 

300 # ------------------------------------------------------------------ 

301 def orth(self, tol: float | None = None) -> Quasimatrix: 

302 """Orthonormal basis for the column space (range) of the quasimatrix.""" 

303 U, S, _ = self.svd() 

304 if tol is None: 

305 tol = max(self.shape[1], 20) * np.finfo(float).eps * S[0] 

306 mask = tol < S 

307 return Quasimatrix([U.columns[j] for j in range(len(S)) if mask[j]]) 

308 

309 # ------------------------------------------------------------------ 

310 # Pseudoinverse 

311 # ------------------------------------------------------------------ 

312 def pinv(self) -> _TransposedQuasimatrix: 

313 """Moore-Penrose pseudoinverse (returned as an n x inf row quasimatrix). 

314 

315 ``pinv(A) @ f`` gives the same result as ``A.solve(f)``. 

316 """ 

317 U, S, V = self.svd() 

318 # pinv(A) = V S^{-1} U^T 

319 # The rows of pinv(A) are: sum_k V[i,k] / S[k] * U_k 

320 n = len(S) 

321 pinv_cols: list[Chebfun] = [] 

322 for i in range(n): 

323 col = (V[i, 0] / S[0]) * U.columns[0] 

324 for k in range(1, n): 

325 col = col + (V[i, k] / S[k]) * U.columns[k] 

326 pinv_cols.append(col) 

327 return _TransposedQuasimatrix(Quasimatrix(pinv_cols)) 

328 

329 # ------------------------------------------------------------------ 

330 # Plotting 

331 # ------------------------------------------------------------------ 

332 def plot(self, ax: Axes | None = None, **kwds: Any) -> Axes: 

333 """Plot all columns on the same axes.""" 

334 ax = ax or plt.gca() 

335 for col in self.columns: 

336 col.plot(ax=ax, **kwds) 

337 return ax 

338 

339 def spy(self, ax: Axes | None = None, **kwds: Any) -> Axes: 

340 """Visualise the shape of the quasimatrix. 

341 

342 Draws a rectangle representing the inf x n structure, with a dot for 

343 each column to indicate nonzero content. 

344 """ 

345 ax = ax or plt.gca() 

346 n = len(self.columns) 

347 # Draw the bounding rectangle 

348 rect = Rectangle((0.5, 0.5), n, 10, fill=False, edgecolor="black", linewidth=1.5) 

349 ax.add_patch(rect) 

350 # A dot for each column 

351 for j in range(n): 

352 ax.plot(j + 1, 5.5, "bs", markersize=8, **kwds) 

353 ax.set_xlim(0, n + 1) 

354 ax.set_ylim(0, 11) 

355 ax.set_aspect("equal") 

356 ax.set_xlabel(f"n = {n}") 

357 ax.set_ylabel("∞") 

358 ax.set_xticks(range(1, n + 1)) 

359 ax.set_yticks([]) 

360 return ax 

361 

362 # ------------------------------------------------------------------ 

363 # Representation 

364 # ------------------------------------------------------------------ 

365 def __repr__(self) -> str: 

366 """Return a string representation.""" 

367 n = len(self.columns) 

368 if n == 0: 

369 return "Quasimatrix(empty)" 

370 sup = self.support 

371 return f"Quasimatrix(inf x {n} on [{sup[0]}, {sup[1]}])" 

372 

373 def __str__(self) -> str: 

374 """Return a string representation.""" 

375 return self.__repr__() 

376 

377 

378class _TransposedQuasimatrix: 

379 """An n x inf row quasimatrix (transpose of a column quasimatrix). 

380 

381 This is a thin wrapper that enables ``A.T @ f`` and ``A.T @ B`` 

382 with the correct semantics. 

383 """ 

384 

385 def __init__(self, qm: Quasimatrix) -> None: 

386 """Wrap a column quasimatrix as its transpose.""" 

387 self._qm = qm 

388 

389 @property 

390 def shape(self) -> tuple[int, float]: 

391 """Return (n, inf).""" 

392 return (len(self._qm.columns), np.inf) 

393 

394 @property 

395 def T(self) -> Quasimatrix: 

396 """Return the original column quasimatrix.""" 

397 return self._qm 

398 

399 def __matmul__(self, other: Any) -> Any: 

400 """Compute inner products: ``A.T @ f`` or ``A.T @ B``.""" 

401 if isinstance(other, Quasimatrix): 

402 return self._qm.inner(other) 

403 if isinstance(other, Chebfun): 

404 return np.array([col.dot(other) for col in self._qm.columns]) 

405 raise TypeError(f"Cannot multiply _TransposedQuasimatrix by {type(other)}") # noqa: TRY003 

406 

407 def spy(self, ax: Axes | None = None, **kwds: Any) -> Axes: 

408 """Visualise the shape of the transposed quasimatrix.""" 

409 ax = ax or plt.gca() 

410 n = len(self._qm.columns) 

411 rect = Rectangle((0.5, 0.5), 10, n, fill=False, edgecolor="black", linewidth=1.5) 

412 ax.add_patch(rect) 

413 for j in range(n): 

414 ax.plot(5.5, j + 1, "bs", markersize=8, **kwds) 

415 ax.set_xlim(0, 11) 

416 ax.set_ylim(0, n + 1) 

417 ax.set_aspect("equal") 

418 ax.set_ylabel(f"n = {n}") 

419 ax.set_xlabel("∞") 

420 ax.set_yticks(range(1, n + 1)) 

421 ax.set_xticks([]) 

422 return ax 

423 

424 def __repr__(self) -> str: 

425 """Return a string representation.""" 

426 n = len(self._qm.columns) 

427 if n == 0: 

428 return "_TransposedQuasimatrix(empty)" 

429 sup = self._qm.support 

430 return f"_TransposedQuasimatrix({n}x inf on [{sup[0]}, {sup[1]}])" 

431 

432 

433# ------------------------------------------------------------------ 

434# Module-level convenience functions 

435# ------------------------------------------------------------------ 

436def polyfit(f: Chebfun, n: int) -> Chebfun: 

437 """Least-squares polynomial fit of degree *n* to a Chebfun *f*. 

438 

439 Returns a Chebfun representing the best degree-*n* polynomial 

440 approximation to *f* in the L²-norm. 

441 """ 

442 x = Chebfun.initidentity(f.domain) 

443 cols: list[Chebfun] = [Chebfun.initconst(1.0, f.domain)] 

444 xk = cols[0] 

445 for _ in range(n): 

446 xk = xk * x 

447 cols.append(xk) 

448 A = Quasimatrix(cols) 

449 c = A.solve(f) 

450 return cast("Chebfun", A @ c)