Skip to main content

comnum_math/
linalg.rs

1//! Dense complex linear algebra for the small matrices of MIMO processing.
2//!
3//! The matrices of a MIMO link are a few antennas wide, so the routines here favour
4//! simplicity and accuracy over asymptotic speed: modified Gram–Schmidt with
5//! re-orthogonalisation for the QR decomposition, Cholesky for Hermitian positive
6//! definite systems, and the one-sided Jacobi method (Hestenes) for the singular value
7//! decomposition, which is accurate to the relative precision of the singular values.
8//! Matrices are `ndarray::Array2<C64>` ([`CMat`]).
9//!
10//! # Model
11//!
12//! - [`qr`]: $A = Q R$ with $Q^{H} Q = I$ and $R$ upper triangular with a real,
13//!   non-negative diagonal ($A$ is $m \times n$, $m \ge n$);
14//! - [`cholesky`]: $A = L L^{H}$ with $L$ lower triangular ($A$ Hermitian positive
15//!   definite), and the systems of [`solve_hpd`], or of [`solve_cholesky`] with the
16//!   factor computed once;
17//! - [`svd`]: $A = U \Sigma V^{H}$ with the singular values in decreasing order;
18//! - [`solve_tridiagonal`]: a tridiagonal system by the Thomas algorithm.
19//!
20//! # Examples
21//! ```
22//! use comnum_math::linalg::{matmul, hermitian, svd};
23//! use comnum_math::C64;
24//! use ndarray::array;
25//! let a = array![[C64::new(1.0, 1.0), C64::new(0.5, 0.0)], [C64::new(0.0, -2.0), C64::new(1.0, 0.0)]];
26//! let (u, s, v) = svd(&a.view());
27//! // U Σ V^H rebuilds A
28//! let mut us = u.clone();
29//! for (j, sj) in s.iter().enumerate() {
30//!     us.column_mut(j).mapv_inplace(|x| x * *sj);
31//! }
32//! let b = matmul(&us.view(), &hermitian(&v.view()).view());
33//! assert!(a.iter().zip(b.iter()).all(|(x, y)| (x - y).norm() < 1e-12));
34//! ```
35//!
36//! # References
37//!
38//! 1. G. H. Golub and C. F. Van Loan, *Matrix Computations*, 4th ed., Johns Hopkins
39//!    University Press, 2013 (Sec. 5.2, 4.2, 8.6).
40
41use crate::{C64, SCALE_FLOOR};
42use comnum_core::{Error, Result};
43use ndarray::{Array2, ArrayView2, ShapeBuilder};
44
45/// The smallest diagonal pivot of [`cholesky`]: at or below it (or NaN) the matrix is not
46/// positive definite.
47const MIN_PIVOT: f64 = 1e-300;
48
49/// A dense complex matrix.
50pub type CMat = Array2<C64>;
51
52/// The identity of size `n`.
53pub fn identity(n: usize) -> CMat {
54    Array2::from_shape_fn((n, n), |(i, j)| if i == j { C64::new(1.0, 0.0) } else { C64::new(0.0, 0.0) })
55}
56
57/// The conjugate transpose $A^{H}$.
58pub fn hermitian(a: &ArrayView2<'_, C64>) -> CMat {
59    a.t().mapv(|x| x.conj())
60}
61
62/// The product $A B$.
63///
64/// # Panics
65/// If the inner dimensions differ.
66pub fn matmul(a: &ArrayView2<'_, C64>, b: &ArrayView2<'_, C64>) -> CMat {
67    a.dot(b)
68}
69
70/// The product $A x$ of a matrix and a vector.
71///
72/// # Panics
73/// If `x` does not have one value per column of $A$ (as [`matmul`]); the callers check
74/// the shapes of their inputs first.
75pub fn matvec(a: &ArrayView2<'_, C64>, x: &[C64]) -> Vec<C64> {
76    assert_eq!(x.len(), a.ncols(), "matvec: {} values for a matrix of {} columns", x.len(), a.ncols());
77    a.rows().into_iter().map(|r| r.iter().zip(x).map(|(u, v)| u * v).sum()).collect()
78}
79
80/// The Gram matrix $A^{H} A$.
81pub fn gram(a: &ArrayView2<'_, C64>) -> CMat {
82    hermitian(a).dot(a)
83}
84
85/// The Cholesky factor $L$ of a Hermitian positive definite matrix, $A = L L^{H}$.
86///
87/// # Errors
88/// [`Error::Shape`] for a non-square matrix, [`Error::Numerical`] if the matrix is not
89/// positive definite.
90pub fn cholesky(a: &ArrayView2<'_, C64>) -> Result<CMat> {
91    let n = a.nrows();
92    if a.ncols() != n {
93        return Err(Error::Shape(format!("Cholesky of a {}×{} matrix", n, a.ncols())));
94    }
95    let mut l = Array2::<C64>::zeros((n, n));
96    for j in 0..n {
97        let mut d = a[[j, j]].re;
98        for k in 0..j {
99            d -= l[[j, k]].norm_sqr();
100        }
101        if d.is_nan() || d <= MIN_PIVOT {
102            return Err(Error::Numerical("the matrix is not positive definite".into()));
103        }
104        let d = d.sqrt();
105        l[[j, j]] = C64::new(d, 0.0);
106        for i in j + 1..n {
107            let mut s = a[[i, j]];
108            for k in 0..j {
109                s -= l[[i, k]] * l[[j, k]].conj();
110            }
111            l[[i, j]] = s / d;
112        }
113    }
114    Ok(l)
115}
116
117/// Solve $A X = B$ for a Hermitian positive definite $A$ (by Cholesky).
118///
119/// # Errors
120/// As [`cholesky`], and [`Error::Shape`] if $B$ has another number of rows.
121pub fn solve_hpd(a: &ArrayView2<'_, C64>, b: &ArrayView2<'_, C64>) -> Result<CMat> {
122    solve_cholesky(&cholesky(a)?.view(), b)
123}
124
125/// Solve $L L^{H} X = B$ from the Cholesky factor $L$ of [`cholesky`] (forward, then
126/// backward substitution): [`solve_hpd`] with the factor computed once, for systems that
127/// share the matrix.
128///
129/// # Errors
130/// [`Error::Shape`] if $B$ has another number of rows than $L$.
131///
132/// # Examples
133/// ```
134/// use comnum_math::C64;
135/// use comnum_math::linalg::{cholesky, solve_cholesky, solve_hpd};
136/// use ndarray::array;
137/// let a = array![[C64::new(4.0, 0.0), C64::new(1.0, 1.0)], [C64::new(1.0, -1.0), C64::new(3.0, 0.0)]];
138/// let b = array![[C64::new(1.0, 0.0)], [C64::new(0.0, 2.0)]];
139/// let l = cholesky(&a.view())?;
140/// assert_eq!(solve_cholesky(&l.view(), &b.view())?, solve_hpd(&a.view(), &b.view())?);
141/// # Ok::<(), comnum_core::Error>(())
142/// ```
143pub fn solve_cholesky(l: &ArrayView2<'_, C64>, b: &ArrayView2<'_, C64>) -> Result<CMat> {
144    let n = l.nrows();
145    if b.nrows() != n {
146        return Err(Error::Shape(format!("system of size {n} with {} right-hand rows", b.nrows())));
147    }
148    let mut x = b.to_owned();
149    for c in 0..x.ncols() {
150        // forward: L y = b
151        for i in 0..n {
152            let mut s = x[[i, c]];
153            for k in 0..i {
154                s -= l[[i, k]] * x[[k, c]];
155            }
156            x[[i, c]] = s / l[[i, i]];
157        }
158        // backward: L^H x = y
159        for i in (0..n).rev() {
160            let mut s = x[[i, c]];
161            for k in i + 1..n {
162                s -= l[[k, i]].conj() * x[[k, c]];
163            }
164            x[[i, c]] = s / l[[i, i]];
165        }
166    }
167    Ok(x)
168}
169
170/// The inverse of a Hermitian positive definite matrix.
171///
172/// # Errors
173/// As [`cholesky`].
174pub fn inverse_hpd(a: &ArrayView2<'_, C64>) -> Result<CMat> {
175    solve_hpd(a, &identity(a.nrows()).view())
176}
177
178/// $\log_{2} \det A$ of a Hermitian positive definite matrix.
179///
180/// # Errors
181/// As [`cholesky`].
182pub fn log2_det_hpd(a: &ArrayView2<'_, C64>) -> Result<f64> {
183    let l = cholesky(a)?;
184    Ok(2.0 * (0..l.nrows()).map(|i| l[[i, i]].re.log2()).sum::<f64>())
185}
186
187/// The thin QR decomposition $A = Q R$ of an $m \times n$ matrix, $m \ge n$.
188///
189/// Modified Gram–Schmidt with one re-orthogonalisation pass ("twice is enough"); the
190/// diagonal of $R$ is real and non-negative. A rank-deficient column gives a zero
191/// diagonal entry and a zero column of $Q$.
192///
193/// # Errors
194/// [`Error::Shape`] if $m < n$.
195pub fn qr(a: &ArrayView2<'_, C64>) -> Result<(CMat, CMat)> {
196    let (m, n) = a.dim();
197    if m < n {
198        return Err(Error::Shape(format!("thin QR of a {m}×{n} matrix needs m ≥ n")));
199    }
200    let mut q = a.to_owned();
201    let mut r = Array2::<C64>::zeros((n, n));
202    let scale = a.iter().map(C64::norm_sqr).sum::<f64>().sqrt().max(SCALE_FLOOR);
203    for j in 0..n {
204        for _pass in 0..2 {
205            for k in 0..j {
206                let c: C64 = (0..m).map(|i| q[[i, k]].conj() * q[[i, j]]).sum();
207                r[[k, j]] += c;
208                for i in 0..m {
209                    let qk = q[[i, k]];
210                    q[[i, j]] -= c * qk;
211                }
212            }
213        }
214        let norm = (0..m).map(|i| q[[i, j]].norm_sqr()).sum::<f64>().sqrt();
215        if norm <= 1e-14 * scale {
216            r[[j, j]] = C64::new(0.0, 0.0);
217            for i in 0..m {
218                q[[i, j]] = C64::new(0.0, 0.0);
219            }
220        } else {
221            r[[j, j]] = C64::new(norm, 0.0);
222            for i in 0..m {
223                q[[i, j]] /= norm;
224            }
225        }
226    }
227    Ok((q, r))
228}
229
230/// The thin singular value decomposition $A = U \Sigma V^{H}$ by one-sided Jacobi.
231///
232/// For an $m \times n$ matrix with $k = \min(m, n)$, returns $U$ ($m \times k$), the
233/// singular values in decreasing order ($k$ of them) and $V$ ($n \times k$). The
234/// columns of $U$ that belong to zero singular values are zero.
235pub fn svd(a: &ArrayView2<'_, C64>) -> (CMat, Vec<f64>, CMat) {
236    let (m, n) = a.dim();
237    if m < n {
238        let (u, s, v) = svd(&hermitian(a).view());
239        return (v, s, u);
240    }
241    // column-major working copies: the sweeps run down the columns (same operations in
242    // the same order as with rows, so the same results, without the strided accesses)
243    let mut w = Array2::<C64>::zeros((m, n).f());
244    w.assign(a);
245    let mut v = Array2::<C64>::zeros((n, n).f());
246    v.assign(&identity(n));
247    for _sweep in 0..60 {
248        let mut rotated = false;
249        for p in 0..n {
250            for q in p + 1..n {
251                let alpha: f64 = w.column(p).iter().map(C64::norm_sqr).sum();
252                let beta: f64 = w.column(q).iter().map(C64::norm_sqr).sum();
253                let gamma: C64 = w.column(p).iter().zip(w.column(q).iter()).map(|(x, y)| x.conj() * y).sum();
254                let g = gamma.norm();
255                if g <= 1e-15 * (alpha * beta).sqrt() || g == 0.0 {
256                    continue;
257                }
258                rotated = true;
259                // make the inner product real by a phase on column q, then rotate
260                let phase = (gamma / g).conj();
261                let zeta = (beta - alpha) / (2.0 * g);
262                let t = zeta.signum() / (zeta.abs() + (1.0 + zeta * zeta).sqrt());
263                let c = 1.0 / (1.0 + t * t).sqrt();
264                let s = c * t;
265                for mat in [&mut w, &mut v] {
266                    for i in 0..mat.nrows() {
267                        let xp = mat[[i, p]];
268                        let xq = mat[[i, q]] * phase;
269                        mat[[i, p]] = xp * c - xq * s;
270                        mat[[i, q]] = xp * s + xq * c;
271                    }
272                }
273            }
274        }
275        if !rotated {
276            break;
277        }
278    }
279    let mut order: Vec<(f64, usize)> =
280        (0..n).map(|j| (w.column(j).iter().map(C64::norm_sqr).sum::<f64>().sqrt(), j)).collect();
281    order.sort_by(|x, y| y.0.total_cmp(&x.0));
282    let mut u = Array2::<C64>::zeros((m, n));
283    let mut vs = Array2::<C64>::zeros((n, n));
284    let mut sv = Vec::with_capacity(n);
285    let top = order.first().map_or(0.0, |o| o.0);
286    for (k, &(sk, j)) in order.iter().enumerate() {
287        sv.push(sk);
288        vs.column_mut(k).assign(&v.column(j));
289        if sk > 1e-14 * top && sk > 0.0 {
290            u.column_mut(k).assign(&w.column(j).mapv(|x| x / sk));
291        }
292    }
293    (u, sv, vs)
294}
295
296/// The solution of the tridiagonal system `lower[i] u[i-1] + diag[i] u[i] + upper[i]
297/// u[i+1] = rhs[i]` (`lower[0]` and the last `upper` unused), by the Thomas algorithm
298/// without pivoting: for diagonally dominant systems, as those of cubic splines.
299///
300/// # Examples
301/// ```
302/// use comnum_math::{C64, linalg::solve_tridiagonal};
303/// // [2 1 0; 1 2 1; 0 1 2] u = [3, 4, 3] has u = [1, 1, 1]
304/// let r = [3.0, 4.0, 3.0].map(|v| C64::new(v, 0.0));
305/// let u = solve_tridiagonal(&[0.0, 1.0, 1.0], &[2.0; 3], &[1.0, 1.0, 0.0], &r);
306/// assert!(u.iter().all(|v| (v - C64::new(1.0, 0.0)).norm() < 1e-15));
307/// ```
308pub fn solve_tridiagonal(lower: &[f64], diag: &[f64], upper: &[f64], rhs: &[C64]) -> Vec<C64> {
309    let n = diag.len();
310    let mut c = vec![0.0; n];
311    let mut d = vec![C64::new(0.0, 0.0); n];
312    for i in 0..n {
313        let den = diag[i] - if i > 0 { lower[i] * c[i - 1] } else { 0.0 };
314        c[i] = upper[i] / den;
315        d[i] = (rhs[i] - if i > 0 { d[i - 1] * lower[i] } else { C64::new(0.0, 0.0) }) / den;
316    }
317    for i in (0..n.saturating_sub(1)).rev() {
318        let next = d[i + 1];
319        d[i] -= next * c[i];
320    }
321    d
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use comnum_core::rng::Rng;
328
329    #[test]
330    #[should_panic(expected = "matvec: 2 values for a matrix of 3 columns")]
331    fn matvec_rejects_a_short_vector_instead_of_truncating() {
332        let a = Array2::from_elem((2, 3), C64::new(1.0, 0.0));
333        let _ = matvec(&a.view(), &[C64::new(1.0, 0.0); 2]);
334    }
335
336    fn random(m: usize, n: usize, seed: u64) -> CMat {
337        let mut r = Rng::new(seed);
338        Array2::from_shape_fn((m, n), |_| r.complex_gaussian())
339    }
340
341    fn close(a: &CMat, b: &CMat, tol: f64) -> bool {
342        a.dim() == b.dim() && a.iter().zip(b.iter()).all(|(x, y)| (x - y).norm() < tol)
343    }
344
345    #[test]
346    fn qr_is_orthonormal_and_triangular() {
347        for (m, n, seed) in [(4, 4, 1), (6, 3, 2), (8, 8, 3)] {
348            let a = random(m, n, seed);
349            let (q, r) = qr(&a.view()).unwrap();
350            assert!(close(&q.dot(&r), &a, 1e-12));
351            assert!(close(&gram(&q.view()), &identity(n), 1e-12));
352            for i in 0..n {
353                assert!(r[[i, i]].im == 0.0 && r[[i, i]].re >= 0.0);
354                for j in 0..i {
355                    assert_eq!(r[[i, j]], C64::new(0.0, 0.0));
356                }
357            }
358        }
359    }
360
361    #[test]
362    fn cholesky_solve_and_inverse() {
363        let h = random(6, 4, 4);
364        let a = gram(&h.view()) + identity(4).mapv(|x| x * 0.1);
365        let l = cholesky(&a.view()).unwrap();
366        assert!(close(&l.dot(&hermitian(&l.view())), &a, 1e-12));
367        let inv = inverse_hpd(&a.view()).unwrap();
368        assert!(close(&a.dot(&inv), &identity(4), 1e-10));
369        assert!(cholesky(&(identity(2).mapv(|x| -x)).view()).is_err());
370        // log2 det of a diagonal matrix
371        let d = Array2::from_diag(&ndarray::arr1(&[C64::new(2.0, 0.0), C64::new(8.0, 0.0)]));
372        assert!((log2_det_hpd(&d.view()).unwrap() - 4.0).abs() < 1e-12);
373    }
374
375    #[test]
376    fn svd_rebuilds_the_matrix_with_orthonormal_factors() {
377        for (m, n, seed) in [(4, 4, 5), (6, 2, 6), (2, 5, 7), (8, 8, 8)] {
378            let a = random(m, n, seed);
379            let (u, s, v) = svd(&a.view());
380            let k = m.min(n);
381            assert_eq!(s.len(), k);
382            assert!(s.windows(2).all(|w| w[0] >= w[1]));
383            let mut us = u.clone();
384            for (j, sj) in s.iter().enumerate() {
385                us.column_mut(j).mapv_inplace(|x| x * *sj);
386            }
387            assert!(close(&us.dot(&hermitian(&v.view())), &a, 1e-11), "{m}×{n}");
388            assert!(close(&gram(&u.view()), &identity(k), 1e-11));
389            assert!(close(&gram(&v.view()), &identity(k), 1e-11));
390            // Σ s² = ‖A‖_F²
391            let f: f64 = a.iter().map(C64::norm_sqr).sum();
392            assert!((s.iter().map(|x| x * x).sum::<f64>() - f).abs() < 1e-10 * f);
393        }
394    }
395
396    #[test]
397    fn svd_of_a_rank_one_matrix() {
398        let x = random(3, 1, 9);
399        let y = random(1, 4, 10);
400        let a = x.dot(&y);
401        let (_, s, _) = svd(&a.view());
402        assert!(s[0] > 1.0 && s[1..].iter().all(|v| *v < 1e-12 * s[0]));
403    }
404}