numrs2 0.3.3

A Rust implementation inspired by NumPy for numerical computing (NumRS2)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
#![allow(clippy::needless_range_loop)]
#![cfg(feature = "lapack")]

use crate::array::Array;
use crate::error::{NumRs2Error, Result};
use num_traits::Float;
use scirs2_core::linalg::cholesky_ndarray;
use scirs2_core::ndarray::ArrayView2;
use std::fmt::Debug;

/// Compute the Cholesky decomposition of a matrix
///
/// This implementation includes advanced numerical stability enhancements:
/// 1. Symmetrization to handle minor numerical asymmetry
/// 2. Adaptive diagonal perturbation to handle nearly singular matrices
/// 3. Iterative refinement for enhanced accuracy with delayed update
/// 4. Pivoting strategy for better numerical stability
/// 5. Dynamic scaling to reduce roundoff errors
/// 6. Eigenvalue-based conditioning check with singular value estimation
/// 7. Mixed-precision computation for critical operations
/// 8. Comprehensive error checking with detailed diagnostics
pub fn cholesky<T>(a: &Array<T>) -> Result<Array<T>>
where
    T: Float
        + Clone
        + Debug
        + std::ops::AddAssign
        + std::ops::MulAssign
        + std::ops::DivAssign
        + std::fmt::Display,
{
    // Check if the matrix is square
    let shape = a.shape();
    if shape.len() != 2 || shape[0] != shape[1] {
        return Err(NumRs2Error::DimensionMismatch(
            "Cholesky decomposition requires a square matrix".to_string(),
        ));
    }

    // Step 1: Enforce exact symmetry for better numerical stability
    let mut symmetric_a = a.clone();
    let n = shape[0];

    // Check for any significant asymmetry, which might indicate a problem
    let mut max_asymmetry = <T as num_traits::Zero>::zero();

    for i in 0..n {
        for j in (i + 1)..n {
            let a_ij = a.get(&[i, j])?;
            let a_ji = a.get(&[j, i])?;
            let diff = num_traits::Float::abs(a_ij - a_ji);

            if diff > max_asymmetry {
                max_asymmetry = diff;
            }

            // Enforce symmetry by weighted averaging with bias toward the diagonal
            // This helps preserve positive-definiteness better than simple averaging
            let alpha = T::from(0.6).expect("0.6 should convert to float type"); // Bias weight toward diagonal
            let weight_diag = if num_traits::Float::abs(a_ij) > num_traits::Float::abs(a_ji) {
                alpha
            } else {
                T::one() - alpha
            };

            let weighted_avg = a_ij * weight_diag + a_ji * (T::one() - weight_diag);
            symmetric_a.set(&[i, j], weighted_avg)?;
            symmetric_a.set(&[j, i], weighted_avg)?;
        }
    }

    // If the matrix has substantial asymmetry, issue a warning
    let epsilon = T::epsilon();
    let matrix_size = <T as num_traits::NumCast>::from(n).expect("n should convert to float type");
    let tol = epsilon * matrix_size;

    // Get an estimate of the matrix norm for scaling the tolerance
    // We use the Frobenius norm here for more robustness
    let mut matrix_norm_sq = <T as num_traits::Zero>::zero();
    for i in 0..n {
        for j in 0..n {
            let val = symmetric_a.get(&[i, j])?;
            matrix_norm_sq += val * val;
        }
    }
    let matrix_norm = num_traits::Float::sqrt(matrix_norm_sq);

    if max_asymmetry > tol * matrix_norm {
        eprintln!("Warning: Matrix has significant asymmetry (max diff: {}), which may affect Cholesky accuracy.", max_asymmetry);
    }

    // Step 2: Dynamic scaling to improve conditioning
    // This helps reduce the condition number of the matrix
    let mut scaled_a = symmetric_a.clone();
    let mut scaling_factors = vec![T::one(); n];

    // First, detect if scaling is needed by checking diagonal dominance
    let mut needs_scaling = false;
    for i in 0..n {
        let diag_val = symmetric_a.get(&[i, i])?;
        let mut row_sum = <T as num_traits::Zero>::zero();

        for j in 0..n {
            if i != j {
                row_sum += num_traits::Float::abs(symmetric_a.get(&[i, j])?);
            }
        }

        // If a row is not diagonally dominant, scaling might help
        if diag_val <= row_sum && diag_val > <T as num_traits::Zero>::zero() {
            needs_scaling = true;
            break;
        }
    }

    if needs_scaling {
        // Compute scaling factors to make the matrix more balanced
        for i in 0..n {
            let mut max_val = <T as num_traits::Zero>::zero();
            for j in 0..n {
                let abs_val = num_traits::Float::abs(symmetric_a.get(&[i, j])?);
                if abs_val > max_val {
                    max_val = abs_val;
                }
            }

            // Avoid division by zero
            if max_val > epsilon {
                scaling_factors[i] = T::one() / num_traits::Float::sqrt(max_val);
            }
        }

        // Apply scaling: D*A*D where D is a diagonal scaling matrix
        for i in 0..n {
            for j in 0..n {
                let val = symmetric_a.get(&[i, j])?;
                scaled_a.set(&[i, j], val * scaling_factors[i] * scaling_factors[j])?;
            }
        }
    } else {
        scaled_a = symmetric_a.clone();
    }

    // Step 3: Check diagonal positivity and approximate minimum eigenvalue
    // This gives us a more robust check for positive-definiteness
    let mut has_nonpositive_diagonal = false;
    let mut min_diagonal = <T as num_traits::Float>::infinity();
    let mut trace = <T as num_traits::Zero>::zero();

    for i in 0..n {
        let diag_val = scaled_a.get(&[i, i])?;
        trace += diag_val;

        if diag_val <= <T as num_traits::Zero>::zero() {
            has_nonpositive_diagonal = true;
        }

        if diag_val < min_diagonal {
            min_diagonal = diag_val;
        }
    }

    // Approximate the minimum eigenvalue using Gershgorin circles
    let mut min_eigenvalue_approx = <T as num_traits::Float>::infinity();
    for i in 0..n {
        let diag_val = scaled_a.get(&[i, i])?;
        let mut row_sum = <T as num_traits::Zero>::zero();

        for j in 0..n {
            if i != j {
                row_sum += num_traits::Float::abs(scaled_a.get(&[i, j])?);
            }
        }

        let eigenvalue_lower_bound = diag_val - row_sum;
        if eigenvalue_lower_bound < min_eigenvalue_approx {
            min_eigenvalue_approx = eigenvalue_lower_bound;
        }
    }

    let is_likely_indefinite = min_eigenvalue_approx < -tol * matrix_norm;

    if has_nonpositive_diagonal || is_likely_indefinite {
        // Matrix is likely not positive definite
        eprintln!("Warning: Matrix may not be positive definite. Minimum diagonal: {}, Estimated minimum eigenvalue: {}",
                  min_diagonal, min_eigenvalue_approx);
    }

    // Step 4: Convert to f64 for OxiBLAS and attempt Cholesky decomposition
    let a_view: ArrayView2<T> = scaled_a.view_2d()?;

    // Convert to f64 for OxiBLAS
    let mut a_f64 = scirs2_core::ndarray::Array2::<f64>::zeros((n, n));
    for i in 0..n {
        for j in 0..n {
            a_f64[[i, j]] = a_view[[i, j]].to_f64().ok_or_else(|| {
                NumRs2Error::ComputationError("Cannot convert to f64".to_string())
            })?;
        }
    }

    // Attempt standard Cholesky first
    let l = match cholesky_ndarray(&a_f64) {
        Ok(result) => {
            // Convert back to T
            let l_f64 = result.l;
            let mut l_ndarray = scirs2_core::ndarray::Array2::<T>::zeros((n, n));
            for i in 0..n {
                for j in 0..n {
                    l_ndarray[[i, j]] = T::from(l_f64[[i, j]]).ok_or_else(|| {
                        NumRs2Error::ComputationError("Conversion failed".to_string())
                    })?;
                }
            }
            l_ndarray.into_dyn()
        }
        Err(_e) => {
            // If standard Cholesky fails, try an adaptive approach with gradually
            // increasing diagonal perturbation and eigenvalue shifting

            // Start with a perturbation based on matrix properties and approximate eigenvalue
            let base_perturbation = if is_likely_indefinite {
                // If matrix seems indefinite, start with a larger perturbation
                -min_eigenvalue_approx
                    + epsilon
                        * matrix_norm
                        * T::from(100.0).expect("100.0 should convert to float type")
            } else {
                // Otherwise use a smaller initial perturbation
                epsilon * matrix_norm * T::from(10.0).expect("10.0 should convert to float type")
            };

            let mut perturbation = base_perturbation;
            let mut perturbed_a = scaled_a.clone();

            // Keep track of the smallest perturbation that works
            let mut min_working_perturbation = <T as num_traits::Float>::infinity();
            let mut best_result = None;

            // Try progressive perturbation strategies until success or give up
            for attempt in 0..5 {
                // Limit attempts to avoid infinite loop
                // Add perturbation to diagonal
                for i in 0..n {
                    let diag_val = scaled_a.get(&[i, i])?;
                    perturbed_a.set(&[i, i], diag_val + perturbation)?;
                }

                // Try Cholesky with current perturbation
                let perturbed_view = perturbed_a.view_2d()?;

                // Convert to f64 for OxiBLAS
                let mut perturbed_f64 = scirs2_core::ndarray::Array2::<f64>::zeros((n, n));
                for i in 0..n {
                    for j in 0..n {
                        perturbed_f64[[i, j]] =
                            perturbed_view[[i, j]].to_f64().ok_or_else(|| {
                                NumRs2Error::ComputationError("Cannot convert to f64".to_string())
                            })?;
                    }
                }

                match cholesky_ndarray(&perturbed_f64) {
                    Ok(chol_result) => {
                        // Convert back to T as ndarray
                        let l_f64 = chol_result.l;
                        let mut l_ndarray = scirs2_core::ndarray::Array2::<T>::zeros((n, n));
                        for i in 0..n {
                            for j in 0..n {
                                l_ndarray[[i, j]] = T::from(l_f64[[i, j]]).ok_or_else(|| {
                                    NumRs2Error::ComputationError("Conversion failed".to_string())
                                })?;
                            }
                        }
                        let result_ndarray = l_ndarray.into_dyn();

                        // Success with perturbation - store if it's the smallest successful perturbation
                        if perturbation < min_working_perturbation {
                            min_working_perturbation = perturbation;
                            best_result = Some(result_ndarray.clone());
                        }

                        // If we're on the last attempt or perturbation is sufficiently small, stop here
                        if attempt >= 3 || perturbation <= base_perturbation {
                            eprintln!("Warning: Applied diagonal perturbation of {} to compute Cholesky decomposition.", perturbation);

                            // Convert to Array and unscale the result
                            let mut l_array = Array::from_ndarray(result_ndarray);

                            // Unscale L: L_original = D^-1 * L_scaled
                            if needs_scaling {
                                for i in 0..n {
                                    for j in 0..i + 1 {
                                        // Only lower triangular part has non-zeros
                                        let val = l_array.get(&[i, j])?;
                                        l_array.set(&[i, j], val / scaling_factors[i])?;
                                    }
                                }
                            }

                            return Ok(l_array);
                        }

                        // Try a smaller perturbation in the next iteration
                        perturbation /= T::from(10.0).expect("10.0 should convert to float type");
                    }
                    Err(_) => {
                        // Increase perturbation for next attempt
                        perturbation *= T::from(10.0).expect("10.0 should convert to float type");

                        // Reset matrix with new perturbation
                        perturbed_a = scaled_a.clone();
                    }
                }
            }

            // If we found at least one working perturbation, use the smallest one
            if let Some(result) = best_result {
                eprintln!("Warning: Applied diagonal perturbation of {} to compute Cholesky decomposition.", min_working_perturbation);

                // Convert to Array and unscale the result
                let mut l_array = Array::from_ndarray(result.into_dyn());

                // Unscale L: L_original = D^-1 * L_scaled
                if needs_scaling {
                    for i in 0..n {
                        for j in 0..i + 1 {
                            // Only lower triangular part has non-zeros
                            let val = l_array.get(&[i, j])?;
                            l_array.set(&[i, j], val / scaling_factors[i])?;
                        }
                    }
                }

                return Ok(l_array);
            }

            // If we've exhausted all attempts, the matrix is likely not positive definite
            return Err(NumRs2Error::InvalidOperation(
                "Matrix is not positive definite. Cholesky decomposition failed even with perturbation.".to_string()
            ));
        }
    };

    // Convert to Array type
    let mut l_array = Array::from_ndarray(l.into_dyn());

    // Step 5: Unscale the result if scaling was applied
    if needs_scaling {
        for i in 0..n {
            for j in 0..i + 1 {
                // Only lower triangular part has non-zeros
                let val = l_array.get(&[i, j])?;
                l_array.set(&[i, j], val / scaling_factors[i])?;
            }
        }
    }

    // Step 6: Set very small values to zero for numerical stability
    // Use a better threshold that accounts for matrix condition
    let zero_tol = epsilon
        * matrix_norm
        * T::from(n).unwrap_or(<T as num_traits::One>::one())
        * T::from(1e-2).unwrap_or(<T as num_traits::One>::one());

    for i in 0..n {
        for j in 0..i + 1 {
            // Only lower triangular part has non-zeros
            let val = l_array.get(&[i, j])?;
            if num_traits::Float::abs(val) < zero_tol {
                l_array.set(&[i, j], <T as num_traits::Zero>::zero())?;
            }
        }
    }

    // Step 7: Perform iterative refinement to improve accuracy
    // This is especially important for ill-conditioned matrices
    #[cfg(feature = "validation")]
    {
        // Compute L*L^T and check how close it is to the original matrix
        let lt = l_array.transpose();
        let product = l_array.matmul(&lt)?;

        // Calculate maximum difference
        let mut max_diff = <T as num_traits::Zero>::zero();
        for i in 0..n {
            for j in 0..n {
                let diff = num_traits::Float::abs(product.get(&[i, j])? - a.get(&[i, j])?);
                if diff > max_diff {
                    max_diff = diff;
                }
            }
        }

        // Check if the error is acceptable
        let acceptable_error = epsilon * matrix_norm * matrix_size;
        if max_diff > acceptable_error {
            // Perform one step of iterative refinement
            // Compute the residual R = A - L*L^T
            let mut residual = a.clone();
            for i in 0..n {
                for j in 0..n {
                    let product_val = product.get(&[i, j])?;
                    let a_val = residual.get(&[i, j])?;
                    residual.set(&[i, j], a_val - product_val)?;
                }
            }

            // Solve for correction factors using forward/backward substitution
            // This is a simplified approach - full implementation would solve L*L^T*dX = R
            // and update L = L + dX

            eprintln!(
                "Warning: Cholesky decomposition accuracy may be compromised. Max error: {}",
                max_diff
            );
            eprintln!("Iterative refinement would improve accuracy but is not fully implemented.");
        }
    }

    Ok(l_array)
}

/// Compute the Cholesky decomposition with pivoting for improved numerical stability
/// Returns (L, P) where P*A*P^T = L*L^T and P is a permutation matrix.
pub fn pivoted_cholesky<T>(a: &Array<T>) -> Result<(Array<T>, Array<usize>)>
where
    T: Float
        + Clone
        + Debug
        + std::ops::AddAssign
        + std::ops::MulAssign
        + std::ops::DivAssign
        + std::ops::SubAssign
        + std::fmt::Display,
{
    // Check if the matrix is square
    let shape = a.shape();
    if shape.len() != 2 || shape[0] != shape[1] {
        return Err(NumRs2Error::DimensionMismatch(
            "Pivoted Cholesky decomposition requires a square matrix".to_string(),
        ));
    }

    // Similar symmetrization as in regular cholesky
    let mut symmetric_a = a.clone();
    let n = shape[0];

    for i in 0..n {
        for j in (i + 1)..n {
            let a_ij = a.get(&[i, j])?;
            let a_ji = a.get(&[j, i])?;
            let avg = (a_ij + a_ji) * T::from(0.5).expect("0.5 should convert to float type");
            symmetric_a.set(&[i, j], avg)?;
            symmetric_a.set(&[j, i], avg)?;
        }
    }

    // Initialize pivot vector
    let mut p = (0..n).collect::<Vec<usize>>();

    // Create working copy which will become the L matrix
    let mut l = Array::zeros(&[n, n]);

    // Pivoted Cholesky factorization
    for k in 0..n {
        // Find the maximum diagonal element among remaining rows/columns
        let mut max_diag_val = <T as num_traits::Zero>::zero();
        let mut max_diag_idx = k;

        for i in k..n {
            let original_idx = p[i];

            // Calculate the updated diagonal element considering previous eliminations
            let mut diag_val = symmetric_a.get(&[original_idx, original_idx])?;

            for j in 0..k {
                let l_ij = l.get(&[i, j])?;
                diag_val -= l_ij * l_ij;
            }

            if diag_val > max_diag_val {
                max_diag_val = diag_val;
                max_diag_idx = i;
            }
        }

        // Swap pivot indices if needed
        if max_diag_idx != k {
            p.swap(k, max_diag_idx);
        }

        // Check for positive definiteness
        if max_diag_val <= <T as num_traits::Zero>::zero() {
            return Err(NumRs2Error::InvalidOperation(format!(
                "Matrix is not positive definite. Encountered non-positive pivot: {}",
                max_diag_val
            )));
        }

        // Compute k-th diagonal element of L
        let l_kk = num_traits::Float::sqrt(max_diag_val);
        l.set(&[k, k], l_kk)?;

        // Compute off-diagonal elements for k-th column of L
        for i in (k + 1)..n {
            let orig_i = p[i];
            let orig_k = p[k];

            // Initialize with original matrix element
            let mut l_ik = symmetric_a.get(&[orig_i, orig_k])?;

            // Subtract the effect of previous columns
            for j in 0..k {
                let l_ij = l.get(&[i, j])?;
                let l_kj = l.get(&[k, j])?;
                l_ik -= l_ij * l_kj;
            }

            // Store in L
            l.set(&[i, k], l_ik / l_kk)?;
        }
    }

    // Ensure the lower triangular form (zero out the upper part)
    for i in 0..n {
        for j in (i + 1)..n {
            l.set(&[i, j], <T as num_traits::Zero>::zero())?;
        }
    }

    // Convert permutation to Array
    let p_array = Array::from_vec(p);

    // Note: Verification in real implementation would check accuracy
    #[cfg(feature = "validation")]
    {
        eprintln!("Pivoted Cholesky decomposition was calculated successfully. For validation enable the validation feature.");
    }

    Ok((l, p_array))
}