sparse_ir/
tsvd.rs

1//! High-precision truncated SVD implementation using nalgebra
2//!
3//! This module provides QR + SVD based truncated SVD decomposition
4//! with support for extended precision arithmetic.
5
6use crate::Df64;
7use crate::col_piv_qr::ColPivQR;
8use crate::numeric::CustomNumeric;
9use mdarray::DTensor;
10use nalgebra::{ComplexField, DMatrix, DVector, RealField};
11use num_traits::{One, ToPrimitive, Zero};
12
13/// Result of SVD decomposition
14#[derive(Debug, Clone)]
15pub struct SVDResult<T> {
16    /// Left singular vectors (m × rank)
17    pub u: DMatrix<T>,
18    /// Singular values (rank)
19    pub s: DVector<T>,
20    /// Right singular vectors (n × rank)
21    pub v: DMatrix<T>,
22    /// Effective rank
23    pub rank: usize,
24}
25
26/// Configuration for TSVD computation
27#[derive(Debug, Clone)]
28pub struct TSVDConfig<T> {
29    /// Relative tolerance for rank determination
30    pub rtol: T,
31}
32
33impl<T> TSVDConfig<T> {
34    pub fn new(rtol: T) -> Self {
35        Self { rtol }
36    }
37}
38
39/// Error types for TSVD computation
40#[derive(Debug, thiserror::Error)]
41pub enum TSVDError {
42    #[error("Matrix is empty")]
43    EmptyMatrix,
44    #[error("Invalid tolerance: {0}")]
45    InvalidTolerance(String),
46}
47
48/// Get appropriate epsilon value for SVD convergence based on type
49///
50/// Returns the machine epsilon (EPSILON constant) for the given type.
51/// This is preferred over approx::AbsDiffEq::default_epsilon() because
52/// the latter may return MIN_POSITIVE for Df64, which is too small and
53/// causes excessive iterations in SVD.
54#[inline]
55fn get_epsilon_for_svd<T: RealField + Copy>() -> T {
56    use std::any::TypeId;
57
58    if TypeId::of::<T>() == TypeId::of::<f64>() {
59        // f64::EPSILON ≈ 2.22e-16
60        unsafe { std::ptr::read(&f64::EPSILON as *const f64 as *const T) }
61    } else if TypeId::of::<T>() == TypeId::of::<crate::Df64>() {
62        // Df64::EPSILON ≈ 2.465e-32
63        unsafe { std::ptr::read(&crate::Df64::EPSILON as *const crate::Df64 as *const T) }
64    } else {
65        // Fallback: use a reasonable default
66        T::from_f64(1e-15).unwrap_or(T::one() * T::from_f64(1e-15).unwrap_or(T::one()))
67    }
68}
69
70/// Perform SVD decomposition using nalgebra with sorted singular values
71///
72/// Note: This computes ALL singular values, not a truncated SVD.
73/// The truncation happens after SVD computation based on rtol.
74///
75/// # Arguments
76/// * `matrix` - Input matrix (m × n)
77/// * `rtol` - Relative tolerance for rank determination (used for rank calculation, not SVD convergence)
78///
79/// # Returns
80/// * `SVDResult` - Truncated SVD result with U, S, V matrices and rank
81pub fn svd_decompose<T>(matrix: &DMatrix<T>, rtol: f64) -> SVDResult<T>
82where
83    T: ComplexField + RealField + Copy + nalgebra::RealField + ToPrimitive,
84{
85    // Use type-appropriate epsilon for SVD convergence
86    // For f64: f64::EPSILON (約 2.22e-16)
87    // For Df64: Df64::EPSILON (約 2.465e-32)
88    // Note: We use EPSILON (machine epsilon) instead of default_epsilon() (from approx trait)
89    // because default_epsilon() may return MIN_POSITIVE for Df64, which is too small.
90    let eps = get_epsilon_for_svd::<T>();
91
92    // Perform FULL SVD decomposition with explicit epsilon
93    // try_svd automatically sorts singular values in descending order
94    // max_niter = 0 means iterate indefinitely until convergence
95    let svd = matrix
96        .clone()
97        .try_svd(true, true, eps, 0)
98        .expect("SVD computation failed");
99
100    // Extract U, S, V matrices (already sorted by nalgebra)
101    let u_matrix = svd.u.unwrap();
102    let s_vector = svd.singular_values; // Sorted in descending order
103    let v_t_matrix = svd.v_t.unwrap();
104
105    // Calculate effective rank from sorted singular values
106    // Early termination is possible in rank calculation because values are sorted
107    let rank = calculate_rank_from_vector(&s_vector, rtol);
108
109    // Convert to thin SVD (truncate to effective rank)
110    let u = DMatrix::from(u_matrix.columns(0, rank));
111    let s = DVector::from(s_vector.rows(0, rank));
112    let v = DMatrix::from(v_t_matrix.rows(0, rank).transpose());
113
114    SVDResult { u, s, v, rank }
115}
116
117/// Calculate effective rank from sorted singular values
118///
119/// # Arguments
120/// * `singular_values` - Vector of singular values (sorted in descending order)
121/// * `rtol` - Relative tolerance for rank determination
122///
123/// # Returns
124/// * `usize` - Effective rank
125///
126/// # Note
127/// Since singular values are sorted in descending order by try_svd,
128/// this function can terminate early when a value below the threshold is found.
129fn calculate_rank_from_vector<T>(singular_values: &DVector<T>, rtol: f64) -> usize
130where
131    T: RealField + Copy + ToPrimitive,
132{
133    if singular_values.is_empty() {
134        return 0;
135    }
136
137    // First element is the maximum (sorted in descending order)
138    let max_sv = singular_values[0];
139    let threshold = max_sv * T::from_f64(rtol).unwrap_or(T::zero());
140
141    let mut rank = 0;
142    for &sv in singular_values.iter() {
143        if sv > threshold {
144            rank += 1;
145        } else {
146            // Early termination: since values are sorted, all remaining values are also below threshold
147            break;
148        }
149    }
150
151    rank
152}
153
154/// Calculate rank from R matrix diagonal elements
155fn calculate_rank_from_r<T: RealField>(r_matrix: &DMatrix<T>, rtol: T) -> usize
156where
157    T: ComplexField + RealField + Copy,
158{
159    let dim = r_matrix.nrows().min(r_matrix.ncols());
160    let mut rank = dim;
161
162    // Find the maximum diagonal element
163    let mut max_diag_abs = Zero::zero();
164    for i in 0..dim {
165        let diag_abs = ComplexField::abs(r_matrix[(i, i)]);
166        if diag_abs > max_diag_abs {
167            max_diag_abs = diag_abs;
168        }
169    }
170
171    // If max_diag_abs is zero, rank is zero
172    if max_diag_abs == Zero::zero() {
173        return 0;
174    }
175
176    // Check each diagonal element
177    for i in 0..dim {
178        let diag_abs = ComplexField::abs(r_matrix[(i, i)]);
179
180        // Check if the diagonal element is too small relative to the maximum diagonal element
181        if diag_abs < rtol * max_diag_abs {
182            rank = i;
183            break;
184        }
185    }
186
187    rank
188}
189
190/// Main TSVD function using QR + SVD approach
191///
192/// Computes the truncated SVD using the algorithm:
193/// 1. Apply QR decomposition to A to get Q and R
194/// 2. Compute SVD of R
195/// 3. Reconstruct final U and V matrices
196///
197/// # Arguments
198/// * `matrix` - Input matrix (m × n)
199/// * `config` - TSVD configuration
200///
201/// # Returns
202/// * `SVDResult` - Truncated SVD result
203pub fn tsvd<T>(matrix: &DMatrix<T>, config: TSVDConfig<T>) -> Result<SVDResult<T>, TSVDError>
204where
205    T: ComplexField
206        + RealField
207        + Copy
208        + nalgebra::RealField
209        + std::fmt::Debug
210        + ToPrimitive
211        + CustomNumeric,
212{
213    let (m, n) = matrix.shape();
214
215    if m == 0 || n == 0 {
216        return Err(TSVDError::EmptyMatrix);
217    }
218
219    if config.rtol <= Zero::zero() || config.rtol >= One::one() {
220        return Err(TSVDError::InvalidTolerance(format!(
221            "Tolerance must be in (0, 1), got {:?}",
222            config.rtol
223        )));
224    }
225
226    // Step 1: Apply QR decomposition to A using nalgebra with early termination
227    // Convert config.rtol (T) to T::RealField for QR decomposition
228    let qr_rtol = Some(config.rtol.clone().modulus());
229    let qr = ColPivQR::new_with_rtol(matrix.clone(), qr_rtol);
230    let q_matrix = qr.q();
231    let r_matrix = qr.r();
232    let permutation = qr.p();
233
234    // Step 2: Apply QR-based rank estimation first
235    // Use type-specific epsilon for QR diagonal elements (more conservative than rtol)
236    let qr_rank = calculate_rank_from_r(
237        &r_matrix,
238        T::from_f64_unchecked(2.0) * get_epsilon_for_svd::<T>(),
239    );
240
241    if qr_rank == 0 {
242        // Matrix has zero rank
243        return Ok(SVDResult {
244            u: DMatrix::zeros(m, 0),
245            s: DVector::zeros(0),
246            v: DMatrix::zeros(n, 0),
247            rank: 0,
248        });
249    }
250
251    // Step 3: Truncate R to estimated rank and apply SVD
252    let r_truncated: DMatrix<T> = r_matrix.rows(0, qr_rank).into();
253    // Use rtol directly as T
254    let rtol_t = config.rtol;
255    let rtol_f64 = rtol_t.to_f64();
256    let svd_result = svd_decompose(&r_truncated, rtol_f64);
257
258    if svd_result.rank == 0 {
259        // Matrix has zero rank
260        return Ok(SVDResult {
261            u: DMatrix::zeros(m, 0),
262            s: DVector::zeros(0),
263            v: DMatrix::zeros(n, 0),
264            rank: 0,
265        });
266    }
267
268    // Step 4: Reconstruct full SVD
269    // U = Q * U_R (Q is (m, qr_rank), U_R is (qr_rank, svd_result.rank))
270    let q_truncated: DMatrix<T> = q_matrix.columns(0, qr_rank).into();
271    let u_full = &q_truncated * &svd_result.u;
272
273    // V = P^T * V_R (apply inverse permutation matrix)
274    // Since A*P = Q*R, we have A = Q*R*P^T
275    // After SVD of R: A = Q*U_R*S_R*V_R^T*P^T = U*S*V^T
276    // where V^T = V_R^T*P^T, so V = P^(-1)*V_R = P^T*V_R
277    let mut v_full = svd_result.v.clone();
278    permutation.inv_permute_rows(&mut v_full);
279
280    // S_full = S_R (already correct size)
281    let s_full = svd_result.s.clone();
282
283    Ok(SVDResult {
284        u: u_full,
285        s: s_full,
286        v: v_full,
287        rank: svd_result.rank,
288    })
289}
290
291/// Convenience function for f64 TSVD
292pub fn tsvd_f64(matrix: &DMatrix<f64>, rtol: f64) -> Result<SVDResult<f64>, TSVDError> {
293    tsvd(matrix, TSVDConfig::new(rtol))
294}
295
296/// Convenience function for Df64 TSVD
297pub fn tsvd_df64(matrix: &DMatrix<Df64>, rtol: Df64) -> Result<SVDResult<Df64>, TSVDError> {
298    tsvd(matrix, TSVDConfig::new(rtol))
299}
300
301/// Convenience function for Df64 TSVD from f64 matrix
302pub fn tsvd_df64_from_f64(matrix: &DMatrix<f64>, rtol: f64) -> Result<SVDResult<Df64>, TSVDError> {
303    let matrix_df64 = DMatrix::from_fn(matrix.nrows(), matrix.ncols(), |i, j| {
304        Df64::from(matrix[(i, j)])
305    });
306    let rtol_df64 = Df64::from(rtol);
307    tsvd(&matrix_df64, TSVDConfig::new(rtol_df64))
308}
309
310/// Compute SVD for DTensor using nalgebra-based TSVD
311///
312/// Supports both f64 and Df64 types. Uses nalgebra TSVD backend for both.
313pub fn compute_svd_dtensor<T: CustomNumeric + 'static>(
314    matrix: &DTensor<T, 2>,
315) -> (DTensor<T, 2>, Vec<T>, DTensor<T, 2>) {
316    use nalgebra::DMatrix;
317    use std::any::TypeId;
318
319    // Dispatch based on type: convert to appropriate DMatrix type
320    if TypeId::of::<T>() == TypeId::of::<f64>() {
321        // Convert to DMatrix<f64>
322        let matrix_f64 = DMatrix::from_fn(matrix.shape().0, matrix.shape().1, |i, j| {
323            CustomNumeric::to_f64(matrix[[i, j]])
324        });
325
326        // Use TSVD with appropriate tolerance for f64
327        let rtol = 2.0 * f64::EPSILON;
328        let result = tsvd(&matrix_f64, TSVDConfig::new(rtol)).expect("TSVD computation failed");
329
330        // Convert back to DTensor<T>
331        let u = DTensor::<T, 2>::from_fn([result.u.nrows(), result.u.ncols()], |idx| {
332            let [i, j] = [idx[0], idx[1]];
333            T::from_f64_unchecked(result.u[(i, j)])
334        });
335
336        let s: Vec<T> = result.s.iter().map(|x| T::from_f64_unchecked(*x)).collect();
337
338        let v = DTensor::<T, 2>::from_fn([result.v.nrows(), result.v.ncols()], |idx| {
339            let [i, j] = [idx[0], idx[1]];
340            T::from_f64_unchecked(result.v[(i, j)])
341        });
342
343        (u, s, v)
344    } else if TypeId::of::<T>() == TypeId::of::<Df64>() {
345        // Convert to DMatrix<Df64> without going through f64 to preserve precision
346        // TypeId check ensures T == Df64 at runtime, so we can safely cast
347        let matrix_df64: DMatrix<Df64> =
348            DMatrix::from_fn(matrix.shape().0, matrix.shape().1, |i, j| {
349                // Safe: TypeId check guarantees T == Df64
350                unsafe { std::mem::transmute_copy(&matrix[[i, j]]) }
351            });
352
353        // Use TSVD with appropriate tolerance for Df64
354        let rtol = Df64::from(2.0) * Df64::epsilon();
355        let result = tsvd_df64(&matrix_df64, rtol).expect("TSVD computation failed");
356
357        // Convert back to DTensor<T> without going through f64 to preserve Df64 precision
358        let u = DTensor::<T, 2>::from_fn([result.u.nrows(), result.u.ncols()], |idx| {
359            let [i, j] = [idx[0], idx[1]];
360            T::convert_from(result.u[(i, j)])
361        });
362
363        let s: Vec<T> = result.s.iter().map(|x| T::convert_from(*x)).collect();
364
365        let v = DTensor::<T, 2>::from_fn([result.v.nrows(), result.v.ncols()], |idx| {
366            let [i, j] = [idx[0], idx[1]];
367            T::convert_from(result.v[(i, j)])
368        });
369
370        (u, s, v)
371    } else {
372        panic!("SVD is only implemented for f64 and Df64");
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use nalgebra::DMatrix;
380    use num_traits::cast::ToPrimitive;
381
382    #[test]
383    fn test_svd_identity_matrix() {
384        let matrix = DMatrix::<f64>::identity(3, 3);
385        let result = svd_decompose(&matrix, 1e-12);
386
387        assert_eq!(result.rank, 3);
388        assert_eq!(result.s.len(), 3);
389        assert_eq!(result.u.nrows(), 3);
390        assert_eq!(result.u.ncols(), 3);
391        assert_eq!(result.v.nrows(), 3);
392        assert_eq!(result.v.ncols(), 3);
393    }
394
395    #[test]
396    fn test_tsvd_identity_matrix() {
397        let matrix = DMatrix::<f64>::identity(3, 3);
398        let result = tsvd_f64(&matrix, 1e-12).unwrap();
399
400        assert_eq!(result.rank, 3);
401        assert_eq!(result.s.len(), 3);
402    }
403
404    #[test]
405    fn test_tsvd_rank_one() {
406        let matrix = DMatrix::<f64>::from_fn(3, 3, |i, j| (i + 1) as f64 * (j + 1) as f64);
407        let result = tsvd_f64(&matrix, 1e-12).unwrap();
408
409        assert_eq!(result.rank, 1);
410    }
411
412    #[test]
413    fn test_tsvd_empty_matrix() {
414        let matrix = DMatrix::<f64>::zeros(0, 0);
415        let result = tsvd_f64(&matrix, 1e-12);
416
417        assert!(matches!(result, Err(TSVDError::EmptyMatrix)));
418    }
419
420    /// Create Hilbert matrix of size n x n with generic type
421    /// H[i,j] = 1 / (i + j + 1)
422    fn create_hilbert_matrix_generic<T>(n: usize) -> DMatrix<T>
423    where
424        T: nalgebra::RealField + From<f64> + Copy + std::ops::Div<Output = T>,
425    {
426        DMatrix::from_fn(n, n, |i, j| {
427            // For high precision types like Df64, we need to do the division in type T
428            // to preserve precision, not in f64
429            T::one() / T::from((i + j + 1) as f64)
430        })
431    }
432
433    /// Reconstruct matrix from SVD with generic type: A = U * S * V^T
434    fn reconstruct_matrix_generic<T>(
435        u: &DMatrix<T>,
436        s: &nalgebra::DVector<T>,
437        v: &DMatrix<T>,
438    ) -> DMatrix<T>
439    where
440        T: nalgebra::RealField + Copy,
441    {
442        // A = U * S * V^T
443        // U: (m × k), S: (k), V: (n × k)
444        // Result: (m × n)
445        u * &DMatrix::from_diagonal(s) * &v.transpose()
446    }
447
448    /// Calculate Frobenius norm of matrix with generic type
449    fn frobenius_norm_generic<T>(matrix: &DMatrix<T>) -> f64
450    where
451        T: nalgebra::RealField + Copy + ToPrimitive,
452    {
453        let mut sum = 0.0;
454        for i in 0..matrix.nrows() {
455            for j in 0..matrix.ncols() {
456                let val = matrix[(i, j)].to_f64().unwrap_or(0.0);
457                sum += val * val;
458            }
459        }
460        sum.sqrt()
461    }
462
463    /// Generic Hilbert matrix reconstruction test
464    fn test_hilbert_reconstruction_generic<T>(n: usize, rtol: f64, expected_max_error: f64)
465    where
466        T: nalgebra::RealField
467            + From<f64>
468            + Copy
469            + ToPrimitive
470            + std::fmt::Debug
471            + crate::numeric::CustomNumeric,
472    {
473        let h = create_hilbert_matrix_generic::<T>(n);
474
475        // Compute TSVD with specified tolerance
476        let config = TSVDConfig::new(T::from(rtol));
477        let result = tsvd(&h, config).unwrap();
478
479        // Reconstruct matrix
480        let h_reconstructed = reconstruct_matrix_generic(&result.u, &result.s, &result.v);
481
482        // Calculate reconstruction error (in the same type T to preserve precision)
483        let error_matrix = &h - &h_reconstructed;
484        let error_norm = frobenius_norm_generic(&error_matrix);
485        let relative_error = error_norm / frobenius_norm_generic(&h);
486
487        // Check that reconstruction error is within expected bounds
488        assert!(
489            relative_error <= expected_max_error,
490            "Relative reconstruction error {} exceeds expected maximum {}",
491            relative_error,
492            expected_max_error
493        );
494    }
495
496    #[test]
497    fn test_hilbert_5x5_f64_reconstruction() {
498        test_hilbert_reconstruction_generic::<f64>(5, 1e-12, 1e-14);
499    }
500
501    #[test]
502    fn test_hilbert_5x5_df64_reconstruction() {
503        test_hilbert_reconstruction_generic::<Df64>(5, 1e-28, 1e-28);
504    }
505
506    #[test]
507    fn test_hilbert_10x10_f64_reconstruction() {
508        test_hilbert_reconstruction_generic::<f64>(10, 1e-12, 1e-12);
509    }
510
511    #[test]
512    fn test_hilbert_10x10_df64_reconstruction() {
513        // Note: 10x10 Hilbert matrix has very large condition number (~1e13)
514        // Even with Df64, reconstruction is limited by nalgebra's matrix operations
515        // which may not fully utilize Df64's precision in intermediate calculations
516        test_hilbert_reconstruction_generic::<Df64>(10, 1e-28, 1e-30);
517    }
518
519    #[test]
520    fn test_hilbert_100x100_f64_reconstruction() {
521        // Large matrix test with f64 - expect reasonable performance
522        test_hilbert_reconstruction_generic::<f64>(100, 1e-12, 1e-12);
523    }
524
525    #[test]
526    fn test_hilbert_100x100_df64_reconstruction() {
527        // Large matrix test with Df64 - expect high precision but longer execution time
528        test_hilbert_reconstruction_generic::<Df64>(100, 1e-28, 1e-28);
529    }
530}