Skip to main content

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}