trueno/eigen/mod.rs
1//! Eigendecomposition for symmetric matrices
2//!
3//! Provides SIMD-accelerated eigenvalue and eigenvector computation for symmetric
4//! (Hermitian) matrices, enabling PCA, spectral clustering, and other algorithms
5//! without external dependencies like nalgebra.
6//!
7//! # Algorithm
8//!
9//! Uses the Jacobi eigenvalue algorithm, which is numerically stable and well-suited
10//! for SIMD parallelization. For large matrices (>1000 dimensions), GPU acceleration
11//! is available via wgpu.
12//!
13//! # Example
14//!
15//! ```
16//! use trueno::{Matrix, SymmetricEigen};
17//!
18//! // Create a symmetric positive definite matrix
19//! let cov = Matrix::from_vec(2, 2, vec![
20//! 2.0, 1.0,
21//! 1.0, 2.0,
22//! ])?;
23//!
24//! let eigen = SymmetricEigen::new(&cov)?;
25//!
26//! // Eigenvalues in descending order (PCA convention)
27//! let values = eigen.eigenvalues();
28//! assert!((values[0] - 3.0).abs() < 1e-6); // λ₁ = 3
29//! assert!((values[1] - 1.0).abs() < 1e-6); // λ₂ = 1
30//! # Ok::<(), trueno::TruenoError>(())
31//! ```
32
33mod methods;
34#[cfg(test)]
35mod tests;
36
37pub use methods::EigenIterator;
38
39use crate::{Backend, Matrix, TruenoError};
40
41/// Maximum number of sweeps for Jacobi algorithm convergence
42/// Each sweep processes all n(n-1)/2 off-diagonal elements once
43/// Typically converges in 5-10 sweeps for well-conditioned matrices
44const MAX_JACOBI_SWEEPS: usize = 50;
45
46/// Convergence threshold for off-diagonal elements (relative to Frobenius norm)
47const CONVERGENCE_THRESHOLD: f32 = 1e-7;
48
49/// GPU threshold - use wgpu for matrices larger than this
50#[allow(dead_code)]
51const GPU_THRESHOLD: usize = 1000;
52
53/// Symmetric matrix eigendecomposition
54///
55/// Computes eigenvalues and eigenvectors for symmetric (Hermitian) matrices.
56/// Eigenvalues are returned in descending order (largest first), which is the
57/// convention used in PCA and most dimensionality reduction algorithms.
58///
59/// # Properties
60///
61/// For a symmetric matrix A, the decomposition satisfies:
62/// - `A = V × D × V^T` where D is diagonal with eigenvalues
63/// - Eigenvectors are orthonormal: `V^T × V = I`
64/// - Eigenvalues are real (guaranteed for symmetric matrices)
65///
66/// # Performance
67///
68/// - SIMD-accelerated Jacobi rotations for CPU
69/// - GPU compute shaders for matrices >1000 dimensions (with `gpu` feature)
70/// - O(n³) time complexity, O(n²) space complexity
71#[derive(Debug, Clone)]
72pub struct SymmetricEigen {
73 /// Eigenvalues sorted in descending order
74 pub(crate) eigenvalues: Vec<f32>,
75 /// Eigenvectors as columns (column i corresponds to eigenvalue i)
76 pub(crate) eigenvectors: Matrix<f32>,
77 /// Sorting indices mapping original to sorted order
78 pub(crate) _sort_indices: Vec<usize>,
79 /// Backend used for computation
80 pub(crate) backend: Backend,
81}
82
83impl SymmetricEigen {
84 /// Computes eigendecomposition of a symmetric matrix
85 ///
86 /// # Arguments
87 ///
88 /// * `matrix` - A symmetric square matrix
89 ///
90 /// # Returns
91 ///
92 /// `SymmetricEigen` containing eigenvalues (descending) and eigenvectors
93 ///
94 /// # Errors
95 ///
96 /// - `InvalidInput` if matrix is not square
97 /// - `InvalidInput` if matrix is empty
98 /// - `InvalidInput` if algorithm fails to converge
99 ///
100 /// # Example
101 ///
102 /// ```
103 /// use trueno::{Matrix, SymmetricEigen};
104 ///
105 /// let m = Matrix::from_vec(3, 3, vec![
106 /// 4.0, 2.0, 0.0,
107 /// 2.0, 5.0, 3.0,
108 /// 0.0, 3.0, 6.0,
109 /// ])?;
110 ///
111 /// let eigen = SymmetricEigen::new(&m)?;
112 /// assert_eq!(eigen.eigenvalues().len(), 3);
113 /// # Ok::<(), trueno::TruenoError>(())
114 /// ```
115 pub fn new(matrix: &Matrix<f32>) -> Result<Self, TruenoError> {
116 // Validate input
117 if matrix.rows() != matrix.cols() {
118 return Err(TruenoError::InvalidInput(format!(
119 "Matrix must be square for eigendecomposition, got {}x{}",
120 matrix.rows(),
121 matrix.cols()
122 )));
123 }
124
125 if matrix.rows() == 0 {
126 return Err(TruenoError::InvalidInput(
127 "Cannot compute eigendecomposition of empty matrix".to_string(),
128 ));
129 }
130
131 let backend = Backend::select_best();
132
133 // Dispatch to appropriate implementation based on matrix size and GPU availability
134 #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
135 {
136 let n = matrix.rows();
137 if n >= GPU_THRESHOLD && crate::backends::gpu::GpuBackend::is_available() {
138 return Self::compute_gpu(matrix);
139 }
140 }
141
142 // CPU implementation (SIMD-accelerated) - works on all platforms
143 Self::compute_jacobi(matrix, backend)
144 }
145
146 /// CPU implementation using Jacobi eigenvalue algorithm
147 ///
148 /// The Jacobi algorithm iteratively applies Givens rotations to eliminate
149 /// off-diagonal elements, converging to a diagonal matrix of eigenvalues.
150 fn compute_jacobi(matrix: &Matrix<f32>, backend: Backend) -> Result<Self, TruenoError> {
151 let n = matrix.rows();
152
153 // Copy matrix data for in-place modification
154 let mut a = matrix.as_slice().to_vec();
155
156 // Compute initial Frobenius norm for relative convergence
157 let frobenius_sq: f32 = a.iter().map(|x| x * x).sum();
158 let tolerance = CONVERGENCE_THRESHOLD * frobenius_sq.sqrt().max(1.0);
159
160 // Initialize eigenvectors to identity matrix
161 let mut v = vec![0.0f32; n * n];
162 for i in 0..n {
163 v[i * n + i] = 1.0;
164 }
165
166 // Jacobi iteration with sweep strategy
167 // Each sweep processes all off-diagonal elements once
168 for _sweep in 0..MAX_JACOBI_SWEEPS {
169 // Cyclic Jacobi: process all pairs (i, j) where i < j
170 let mut converged = true;
171
172 for i in 0..n {
173 for j in (i + 1)..n {
174 let aij = a[i * n + j];
175
176 // Skip if already small enough
177 if aij.abs() < tolerance {
178 continue;
179 }
180
181 converged = false;
182 Self::jacobi_rotate(&mut a, &mut v, n, i, j, backend);
183 }
184 }
185
186 if converged {
187 // Extract eigenvalues from diagonal
188 let eigenvalues: Vec<f32> = (0..n).map(|i| a[i * n + i]).collect();
189
190 // Sort eigenvalues in descending order
191 let mut indices: Vec<usize> = (0..n).collect();
192 indices.sort_by(|&i, &j| {
193 eigenvalues[j].partial_cmp(&eigenvalues[i]).unwrap_or(std::cmp::Ordering::Equal)
194 });
195
196 // Reorder eigenvalues
197 let sorted_eigenvalues: Vec<f32> =
198 indices.iter().map(|&i| eigenvalues[i]).collect();
199
200 // Create eigenvector matrix with sorted columns
201 let mut eigenvector_data = vec![0.0f32; n * n];
202 for (new_col, &old_col) in indices.iter().enumerate() {
203 for row in 0..n {
204 eigenvector_data[row * n + new_col] = v[row * n + old_col];
205 }
206 }
207
208 let eigenvectors = Matrix::from_vec(n, n, eigenvector_data)?;
209
210 return Ok(SymmetricEigen {
211 eigenvalues: sorted_eigenvalues,
212 eigenvectors,
213 _sort_indices: indices,
214 backend,
215 });
216 }
217 }
218
219 // Failed to converge - this shouldn't happen for well-conditioned matrices
220 Err(TruenoError::InvalidInput(format!(
221 "Jacobi algorithm failed to converge after {} sweeps",
222 MAX_JACOBI_SWEEPS
223 )))
224 }
225
226 /// Find the largest off-diagonal element (unused in cyclic Jacobi, kept for classic Jacobi)
227 #[inline]
228 fn _find_max_off_diagonal(a: &[f32], n: usize) -> (usize, usize, f32) {
229 let mut max_val = 0.0f32;
230 let mut p = 0;
231 let mut q = 1;
232
233 for i in 0..n {
234 for j in (i + 1)..n {
235 let val = a[i * n + j].abs();
236 if val > max_val {
237 max_val = val;
238 p = i;
239 q = j;
240 }
241 }
242 }
243
244 (p, q, max_val)
245 }
246
247 /// Apply Jacobi rotation to zero out a[p][q] and a[q][p]
248 ///
249 /// Uses the numerically stable formula from:
250 /// Golub & Van Loan, "Matrix Computations", 4th Edition
251 #[inline]
252 fn jacobi_rotate(
253 a: &mut [f32],
254 v: &mut [f32],
255 n: usize,
256 p: usize,
257 q: usize,
258 _backend: Backend,
259 ) {
260 let app = a[p * n + p];
261 let aqq = a[q * n + q];
262 let apq = a[p * n + q];
263
264 // Skip if already zero
265 if apq.abs() < 1e-15 {
266 return;
267 }
268
269 // Compute rotation parameters using numerically stable formula
270 // tau = (aqq - app) / (2 * apq)
271 // t = sign(tau) / (|tau| + sqrt(1 + tau^2)) (avoiding catastrophic cancellation)
272 // c = 1 / sqrt(1 + t^2)
273 // s = t * c
274 let tau = (aqq - app) / (2.0 * apq);
275 let t = if tau >= 0.0 {
276 1.0 / (tau + (1.0 + tau * tau).sqrt())
277 } else {
278 -1.0 / (-tau + (1.0 + tau * tau).sqrt())
279 };
280
281 let c = 1.0 / (1.0 + t * t).sqrt();
282 let s = t * c;
283
284 // Update diagonal elements
285 a[p * n + p] = app - t * apq;
286 a[q * n + q] = aqq + t * apq;
287 a[p * n + q] = 0.0;
288 a[q * n + p] = 0.0;
289
290 // Update off-diagonal elements in rows/columns p and q
291 for k in 0..n {
292 if k != p && k != q {
293 let akp = a[k * n + p];
294 let akq = a[k * n + q];
295 a[k * n + p] = c * akp - s * akq;
296 a[p * n + k] = a[k * n + p];
297 a[k * n + q] = s * akp + c * akq;
298 a[q * n + k] = a[k * n + q];
299 }
300 }
301
302 // Update eigenvector matrix
303 for k in 0..n {
304 let vkp = v[k * n + p];
305 let vkq = v[k * n + q];
306 v[k * n + p] = c * vkp - s * vkq;
307 v[k * n + q] = s * vkp + c * vkq;
308 }
309 }
310
311 /// GPU implementation for large matrices
312 #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
313 fn compute_gpu(matrix: &Matrix<f32>) -> Result<Self, TruenoError> {
314 use crate::backends::gpu::GpuBackend;
315
316 let n = matrix.rows();
317 let mut gpu = GpuBackend::new();
318
319 // Execute eigendecomposition on GPU
320 let (eigenvalues, eigenvector_data) =
321 gpu.symmetric_eigen(matrix.as_slice(), n).map_err(|e| {
322 TruenoError::InvalidInput(format!("GPU eigendecomposition failed: {}", e))
323 })?;
324
325 // Sort eigenvalues in descending order
326 let mut indices: Vec<usize> = (0..n).collect();
327 indices.sort_by(|&i, &j| {
328 eigenvalues[j].partial_cmp(&eigenvalues[i]).unwrap_or(std::cmp::Ordering::Equal)
329 });
330
331 let sorted_eigenvalues: Vec<f32> = indices.iter().map(|&i| eigenvalues[i]).collect();
332
333 // Reorder eigenvectors
334 let mut sorted_eigenvector_data = vec![0.0f32; n * n];
335 for (new_col, &old_col) in indices.iter().enumerate() {
336 for row in 0..n {
337 sorted_eigenvector_data[row * n + new_col] = eigenvector_data[row * n + old_col];
338 }
339 }
340
341 let eigenvectors = Matrix::from_vec(n, n, sorted_eigenvector_data)?;
342
343 Ok(SymmetricEigen {
344 eigenvalues: sorted_eigenvalues,
345 eigenvectors,
346 _sort_indices: indices,
347 backend: Backend::GPU,
348 })
349 }
350}