multicalc/linear_algebra/cholesky.rs
1//! Cholesky factorization for symmetric positive-definite matrices.
2//!
3//! A fixed-size `no_std` implementation of the standard Cholesky–Banachiewicz
4//! algorithm on this crate's own [`Vector`] and [`Matrix`] types; results are checked against
5//! numpy/LAPACK reference values.
6
7use crate::linear_algebra::{Matrix, Vector};
8use crate::scalar::Numeric;
9use crate::utils::error_codes::CalcError;
10
11/// A Cholesky factorization `A = L·Lᵀ`, as produced by [`Matrix::cholesky`].
12///
13/// `L` is lower-triangular with a strictly positive diagonal; the entries above the diagonal are
14/// zero. It exists only for a symmetric positive-definite `A`.
15#[derive(Debug, Clone, Copy)]
16#[must_use]
17pub struct Cholesky<const N: usize, T = f64> {
18 /// Lower-triangular factor `L`, where `A = L·Lᵀ`.
19 pub(crate) l: Matrix<N, N, T>,
20}
21
22impl<const N: usize, T: Numeric> Matrix<N, N, T> {
23 /// Factorizes `self` as `L·Lᵀ` by the Cholesky–Banachiewicz algorithm.
24 ///
25 /// Only the lower triangle is read; `self` is assumed symmetric. Returns
26 /// [`CalcError::NotPositiveDefinite`] if a diagonal radicand is not strictly positive — the
27 /// matrix is not positive definite — rather than taking a root of it.
28 ///
29 /// ```
30 /// use multicalc::linear_algebra::Matrix;
31 /// let a = Matrix::<3, 3>::new([[4.0, 12.0, -16.0], [12.0, 37.0, -43.0], [-16.0, -43.0, 98.0]]);
32 /// let l = a.cholesky().unwrap().l();
33 /// // L·Lᵀ == A.
34 /// let prod = l * l.transpose();
35 /// for r in 0..3 {
36 /// for c in 0..3 {
37 /// assert!((prod[(r, c)] - a[(r, c)]).abs() < 1e-12);
38 /// }
39 /// }
40 /// ```
41 pub fn cholesky(self) -> Result<Cholesky<N, T>, CalcError> {
42 let mut l = Matrix::zeros();
43
44 for j in 0..N {
45 // Diagonal entry: subtract the squares already placed in row j.
46 let mut d = self[(j, j)];
47 for k in 0..j {
48 d -= l[(j, k)] * l[(j, k)];
49 }
50 if d <= T::ZERO {
51 return Err(CalcError::NotPositiveDefinite);
52 }
53 let ljj = d.sqrt();
54 l[(j, j)] = ljj;
55
56 // Below-diagonal entries of column j.
57 for i in (j + 1)..N {
58 let mut s = self[(i, j)];
59 for k in 0..j {
60 s -= l[(i, k)] * l[(j, k)];
61 }
62 l[(i, j)] = s / ljj;
63 }
64 }
65
66 Ok(Cholesky { l })
67 }
68}
69
70impl<const N: usize, T: Numeric> Cholesky<N, T> {
71 /// The lower-triangular factor `L`, where `A = L·Lᵀ`.
72 pub fn l(&self) -> Matrix<N, N, T> {
73 self.l
74 }
75
76 /// The determinant, `Π L[i][i]²`.
77 ///
78 /// ```
79 /// use multicalc::linear_algebra::Matrix;
80 /// let a = Matrix::<2, 2>::new([[4.0, 2.0], [2.0, 3.0]]);
81 /// assert!((a.cholesky().unwrap().determinant() - a.determinant()).abs() < 1e-12);
82 /// ```
83 #[inline]
84 #[must_use]
85 pub fn determinant(&self) -> T {
86 let mut det = T::ONE;
87 for i in 0..N {
88 det *= self.l[(i, i)] * self.l[(i, i)];
89 }
90 det
91 }
92
93 /// Solves `A·x = b` for `x`, reusing this factorization.
94 ///
95 /// Infallible: the factorization already guaranteed every `L` diagonal entry is positive.
96 ///
97 /// ```
98 /// use multicalc::linear_algebra::{Matrix, Vector};
99 /// let a = Matrix::<2, 2>::new([[4.0, 2.0], [2.0, 3.0]]);
100 /// // A·x = b has the exact solution x = [1, 2].
101 /// let x = a.cholesky().unwrap().solve(Vector::new([8.0, 8.0]));
102 /// assert!((x[0] - 1.0).abs() < 1e-12);
103 /// assert!((x[1] - 2.0).abs() < 1e-12);
104 /// ```
105 pub fn solve(&self, b: Vector<N, T>) -> Vector<N, T> {
106 let mut x: [T; N] = core::array::from_fn(|i| b[i]);
107
108 // Forward substitution for L·y = b.
109 for i in 0..N {
110 let mut sum = x[i];
111 for (j, &xj) in x.iter().enumerate().take(i) {
112 sum -= self.l[(i, j)] * xj;
113 }
114 x[i] = sum / self.l[(i, i)];
115 }
116
117 // Back substitution for Lᵀ·x = y, where Lᵀ[i][j] = L[j][i].
118 for i in (0..N).rev() {
119 let mut sum = x[i];
120 for (j, &xj) in x.iter().enumerate().skip(i + 1) {
121 sum -= self.l[(j, i)] * xj;
122 }
123 x[i] = sum / self.l[(i, i)];
124 }
125
126 Vector::new(x)
127 }
128
129 /// Solves `A·X = B` for `X`, one column at a time, reusing this factorization.
130 ///
131 /// ```
132 /// use multicalc::linear_algebra::Matrix;
133 /// let a = Matrix::<2, 2>::new([[4.0, 2.0], [2.0, 3.0]]);
134 /// // Solving A·X = I gives X = A⁻¹.
135 /// let x = a.cholesky().unwrap().solve_matrix(Matrix::<2, 2>::identity());
136 /// let p = a * x;
137 /// assert!((p[(0, 0)] - 1.0).abs() < 1e-12);
138 /// assert!((p[(1, 1)] - 1.0).abs() < 1e-12);
139 /// ```
140 pub fn solve_matrix<const K: usize>(&self, b: Matrix<N, K, T>) -> Matrix<N, K, T> {
141 let mut result = Matrix::zeros();
142 for c in 0..K {
143 let x = self.solve(b.column(c));
144 for r in 0..N {
145 result[(r, c)] = x[r];
146 }
147 }
148 result
149 }
150
151 /// The inverse of the factorized matrix, from solving `A·X = I`.
152 ///
153 /// ```
154 /// use multicalc::linear_algebra::Matrix;
155 /// let a = Matrix::<3, 3>::new([[4.0, 12.0, -16.0], [12.0, 37.0, -43.0], [-16.0, -43.0, 98.0]]);
156 /// let p = a * a.cholesky().unwrap().inverse();
157 /// for r in 0..3 {
158 /// for c in 0..3 {
159 /// let expected = if r == c { 1.0 } else { 0.0 };
160 /// assert!((p[(r, c)] - expected).abs() < 1e-12);
161 /// }
162 /// }
163 /// ```
164 pub fn inverse(&self) -> Matrix<N, N, T> {
165 self.solve_matrix(Matrix::identity())
166 }
167}