Skip to main content

oxicuda_sparse/eig/
shift_invert.rs

1//! Shift-invert power iteration for the eigenvalue nearest a target shift.
2//!
3//! Ordinary power iteration converges to the eigenvalue of largest magnitude.
4//! The **shift-invert** spectral transformation turns the *interior* eigenvalue
5//! search into a dominant-eigenvalue search: for a shift `σ` the operator
6//!
7//! ```text
8//!     B = (A − σI)^{-1}
9//! ```
10//!
11//! has eigenvalues `μ_i = 1 / (λ_i − σ)`, so the eigenvalue `λ_i` *closest* to
12//! `σ` becomes the one of largest magnitude `|μ_i|`. Power iteration on `B`
13//! therefore homes in on the eigenpair nearest `σ` -- precisely the eigenvalue
14//! ordinary power iteration would *miss*.
15//!
16//! Each iteration solves `(A − σI) w = v_k` rather than forming `B` explicitly.
17//! The solve uses a single dense LU factorisation of the (small) shifted matrix
18//! `A − σI`, computed once and reused across all iterations (the crate carries
19//! no general *sparse* direct factorisation; a dense LU on the assembled shifted
20//! matrix is the documented fallback and is exact for the small problems this
21//! routine targets). From the Rayleigh quotient of the inverted operator,
22//!
23//! ```text
24//!     μ = v_kᵀ B v_k / v_kᵀ v_k = v_kᵀ w   (v_k unit-norm),
25//! ```
26//!
27//! the eigenvalue of `A` is recovered as `λ = σ + 1/μ`. Convergence is declared
28//! when the eigenpair residual `‖A v − λ v‖` falls below the tolerance.
29
30use crate::error::{SparseError, SparseResult};
31use crate::host_csr::HostCsr;
32
33/// Multiplier of the deterministic LCG used to seed the starting vector,
34/// matching the crate's `LcgRng` recipe.
35const LCG_MULT: u64 = 6_364_136_223_846_793_005;
36/// Increment of the deterministic LCG (see [`LCG_MULT`]).
37const LCG_INCR: u64 = 1_442_695_040_888_963_407;
38/// Floor below which the Rayleigh quotient `μ` is treated as an underflow
39/// (`v` essentially orthogonal to every eigenvector with finite `1/(λ−σ)`).
40const MU_FLOOR: f64 = 1e-300;
41/// Floor below which an un-normalised iterate is treated as collapsed.
42const VEC_FLOOR: f64 = 1e-300;
43
44/// Outcome of a shift-invert solve.
45#[derive(Debug, Clone)]
46pub struct ShiftInvertResult {
47    /// The converged eigenvalue of `A` nearest the requested shift `σ`.
48    pub eigenvalue: f64,
49    /// The associated unit-norm eigenvector (length `n`).
50    pub eigenvector: Vec<f64>,
51    /// Number of iterations performed.
52    pub iters: usize,
53    /// Whether the residual tolerance was met within `max_iter` iterations.
54    pub converged: bool,
55    /// The final eigenpair residual `‖A v − λ v‖`.
56    pub residual: f64,
57}
58
59/// Finds the eigenvalue of `a` closest to the shift `sigma` via shift-invert
60/// power iteration.
61///
62/// The matrix `a` must be square. It need not be symmetric, though the Rayleigh
63/// quotient recovery is most accurate (and convergence cleanest) for symmetric
64/// `a`. The shift `sigma` must not coincide exactly with an eigenvalue, since
65/// `A − σI` would then be singular; a shift merely *close* to an eigenvalue is
66/// ideal and accelerates convergence.
67///
68/// # Arguments
69///
70/// * `a` -- the square host CSR matrix.
71/// * `sigma` -- the target shift; the eigenvalue nearest this value is sought.
72/// * `max_iter` -- maximum number of power iterations (`≥ 1`).
73/// * `tol` -- residual tolerance `‖A v − λ v‖` for convergence.
74///
75/// # Errors
76///
77/// Returns [`SparseError::DimensionMismatch`] if `a` is not square,
78/// [`SparseError::InvalidArgument`] if `a` is empty or `max_iter == 0`,
79/// [`SparseError::SingularMatrix`] if `A − σI` is numerically singular (the
80/// shift hit an eigenvalue exactly), and [`SparseError::ConvergenceFailure`] if
81/// the iterate collapses (e.g. an unreachable spectrum component).
82pub fn shift_invert(
83    a: &HostCsr,
84    sigma: f64,
85    max_iter: usize,
86    tol: f64,
87) -> SparseResult<ShiftInvertResult> {
88    if a.nrows != a.ncols {
89        return Err(SparseError::DimensionMismatch(format!(
90            "shift-invert requires a square matrix, got {}x{}",
91            a.nrows, a.ncols
92        )));
93    }
94    let n = a.nrows;
95    if n == 0 {
96        return Err(SparseError::InvalidArgument(
97            "shift-invert requires a non-empty matrix".to_string(),
98        ));
99    }
100    if max_iter == 0 {
101        return Err(SparseError::InvalidArgument(
102            "shift-invert requires max_iter >= 1".to_string(),
103        ));
104    }
105
106    // Assemble and factor the shifted operator A − σI once.
107    let mut shifted = a.to_dense();
108    for i in 0..n {
109        shifted[i * n + i] -= sigma;
110    }
111    let lu = DenseLu::factor(shifted, n)?;
112
113    // Deterministic unit-norm starting vector.
114    let mut rng = Lcg::new(0x51f7_1234_abcd_9e01);
115    let mut v = vec![0.0f64; n];
116    for slot in v.iter_mut() {
117        *slot = rng.next_signed();
118    }
119    if !normalize(&mut v) {
120        // Degenerate all-zero draw: fall back to the first canonical axis.
121        v.iter_mut().for_each(|x| *x = 0.0);
122        v[0] = 1.0;
123    }
124
125    let mut eigenvalue = sigma;
126    let mut residual = f64::INFINITY;
127    let mut converged = false;
128    let mut iters = 0usize;
129    // `pending` carries the un-normalised next direction `w = B v` from the
130    // previous iteration so that the eigenvalue, vector, and residual reported
131    // on the iteration we stop are all consistent with the same `v`.
132    let mut pending: Option<Vec<f64>> = None;
133
134    for it in 0..max_iter {
135        iters = it + 1;
136
137        if let Some(w) = pending.take() {
138            let mut next = w;
139            if !normalize(&mut next) {
140                return Err(SparseError::ConvergenceFailure(
141                    "shift-invert iterate collapsed to zero".to_string(),
142                ));
143            }
144            v = next;
145        }
146
147        // w = (A − σI)^{-1} v.
148        let w = lu.solve(&v);
149
150        // Rayleigh quotient of the inverted operator B at the unit vector v.
151        let mu = dot(&v, &w);
152        if mu.abs() < MU_FLOOR {
153            return Err(SparseError::ConvergenceFailure(
154                "shift-invert Rayleigh quotient underflowed".to_string(),
155            ));
156        }
157        eigenvalue = sigma + 1.0 / mu;
158
159        // Eigenpair residual on the *same* v used for the Rayleigh quotient.
160        let av = a.matvec(&v);
161        residual = av
162            .iter()
163            .zip(v.iter())
164            .map(|(&av_i, &v_i)| {
165                let d = av_i - eigenvalue * v_i;
166                d * d
167            })
168            .sum::<f64>()
169            .sqrt();
170
171        if residual < tol {
172            converged = true;
173            break;
174        }
175
176        // Guard against a collapsing solve before deferring the normalisation.
177        if norm(&w) < VEC_FLOOR {
178            return Err(SparseError::ConvergenceFailure(
179                "shift-invert solve produced a zero vector".to_string(),
180            ));
181        }
182        pending = Some(w);
183    }
184
185    Ok(ShiftInvertResult {
186        eigenvalue,
187        eigenvector: v,
188        iters,
189        converged,
190        residual,
191    })
192}
193
194// ---------------------------------------------------------------------------
195// Dense LU factorisation (factor once, solve many) used for the shifted solves.
196// ---------------------------------------------------------------------------
197
198/// A dense LU factorisation with partial pivoting (`PA = LU`).
199///
200/// Factored once from the assembled shifted matrix and reused for every
201/// shift-invert solve, which is the entire computational point of the
202/// transformation: the expensive `O(n³)` factorisation happens a single time
203/// while each iteration costs only an `O(n²)` triangular pair of solves.
204struct DenseLu {
205    /// Matrix order.
206    n: usize,
207    /// Combined `L\U` factors, row-major; the unit-diagonal of `L` is implicit.
208    lu: Vec<f64>,
209    /// Row permutation: `piv[i]` is the original row now occupying position `i`.
210    piv: Vec<usize>,
211}
212
213impl DenseLu {
214    /// Factors the row-major `n × n` matrix `a` in place.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`SparseError::SingularMatrix`] if a pivot is numerically zero.
219    fn factor(mut a: Vec<f64>, n: usize) -> SparseResult<Self> {
220        let mut piv: Vec<usize> = (0..n).collect();
221        for col in 0..n {
222            // Partial pivot: largest magnitude entry at or below the diagonal.
223            let mut pivot_row = col;
224            let mut pivot_mag = a[col * n + col].abs();
225            for r in (col + 1)..n {
226                let mag = a[r * n + col].abs();
227                if mag > pivot_mag {
228                    pivot_mag = mag;
229                    pivot_row = r;
230                }
231            }
232            if pivot_mag < 1e-300 {
233                return Err(SparseError::SingularMatrix);
234            }
235            if pivot_row != col {
236                for c in 0..n {
237                    a.swap(col * n + c, pivot_row * n + c);
238                }
239                piv.swap(col, pivot_row);
240            }
241            let pivot = a[col * n + col];
242            for r in (col + 1)..n {
243                let factor = a[r * n + col] / pivot;
244                a[r * n + col] = factor; // store the multiplier in L
245                if factor != 0.0 {
246                    for c in (col + 1)..n {
247                        a[r * n + c] -= factor * a[col * n + c];
248                    }
249                }
250            }
251        }
252        Ok(Self { n, lu: a, piv })
253    }
254
255    /// Solves `A x = b` using the stored factors.
256    fn solve(&self, b: &[f64]) -> Vec<f64> {
257        let n = self.n;
258        // Apply the row permutation: x = P b.
259        let mut x: Vec<f64> = self.piv.iter().map(|&p| b[p]).collect();
260        // Forward substitution with unit-lower L: subtract the dot product of
261        // the sub-diagonal row entries with the already-solved prefix of x.
262        for i in 0..n {
263            let lrow = &self.lu[i * n..i * n + i];
264            let dotp: f64 = lrow.iter().zip(x[..i].iter()).map(|(&l, &xj)| l * xj).sum();
265            x[i] -= dotp;
266        }
267        // Backward substitution with upper U.
268        for i in (0..n).rev() {
269            let urow = &self.lu[i * n + i + 1..i * n + n];
270            let dotp: f64 = urow
271                .iter()
272                .zip(x[i + 1..].iter())
273                .map(|(&u, &xj)| u * xj)
274                .sum();
275            x[i] = (x[i] - dotp) / self.lu[i * n + i];
276        }
277        x
278    }
279}
280
281// ---------------------------------------------------------------------------
282// Small vector helpers and the deterministic LCG.
283// ---------------------------------------------------------------------------
284
285/// Euclidean inner product.
286fn dot(a: &[f64], b: &[f64]) -> f64 {
287    a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
288}
289
290/// Euclidean norm.
291fn norm(a: &[f64]) -> f64 {
292    dot(a, a).sqrt()
293}
294
295/// Normalises `v` to unit length in place, returning `false` if `v` is too
296/// small to normalise (left unchanged in that case).
297fn normalize(v: &mut [f64]) -> bool {
298    let nrm = norm(v);
299    if nrm < VEC_FLOOR {
300        return false;
301    }
302    let inv = 1.0 / nrm;
303    for x in v.iter_mut() {
304        *x *= inv;
305    }
306    true
307}
308
309/// Deterministic linear congruential generator mirroring the crate's `LcgRng`.
310struct Lcg {
311    state: u64,
312}
313
314impl Lcg {
315    fn new(seed: u64) -> Self {
316        Self {
317            state: seed.wrapping_add(1),
318        }
319    }
320
321    fn next_u32(&mut self) -> u32 {
322        self.state = self.state.wrapping_mul(LCG_MULT).wrapping_add(LCG_INCR);
323        (self.state >> 32) as u32
324    }
325
326    /// Uniform sample in `[-1, 1)`.
327    fn next_signed(&mut self) -> f64 {
328        let u = self.next_u32() as f64 / (u32::MAX as f64 + 1.0);
329        2.0 * u - 1.0
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::host_csr::laplacian_1d;
337    use std::f64::consts::PI;
338
339    /// Analytic eigenvalues of `tridiag(-1, 2, -1)` of order `n`:
340    /// `λ_j = 2 − 2 cos(jπ / (n+1))`, `j = 1..=n`.
341    fn laplacian_1d_eigs(n: usize) -> Vec<f64> {
342        (1..=n)
343            .map(|j| 2.0 - 2.0 * ((j as f64) * PI / ((n + 1) as f64)).cos())
344            .collect()
345    }
346
347    #[test]
348    fn finds_interior_eigenvalue_not_dominant() {
349        // n = 5 Laplacian eigenvalues ≈ {0.268, 1.0, 2.0, 3.0, 3.732}.
350        let n = 5;
351        let a = laplacian_1d(n);
352        let eigs = laplacian_1d_eigs(n);
353        let dominant = eigs[n - 1];
354
355        // Shift near the interior eigenvalue 2.0 (index 2).
356        let res = shift_invert(&a, 1.9, 200, 1e-10).expect("shift-invert");
357        assert!(res.converged, "did not converge");
358        assert!(
359            (res.eigenvalue - eigs[2]).abs() < 1e-7,
360            "expected {} got {}",
361            eigs[2],
362            res.eigenvalue
363        );
364        // It must NOT have drifted to the dominant eigenvalue.
365        assert!(
366            (res.eigenvalue - dominant).abs() > 1.0,
367            "shift-invert returned the dominant eigenvalue"
368        );
369        // Residual must be tiny and finite.
370        assert!(res.residual < 1e-9);
371        assert!(res.eigenvalue.is_finite());
372        assert!(res.eigenvector.iter().all(|v| v.is_finite()));
373    }
374
375    #[test]
376    fn eigenvector_residual_small() {
377        let n = 5;
378        let a = laplacian_1d(n);
379        let res = shift_invert(&a, 0.9, 200, 1e-11).expect("shift-invert");
380        // Independently recompute ‖A v − λ v‖.
381        let av = a.matvec(&res.eigenvector);
382        let r: f64 = av
383            .iter()
384            .zip(res.eigenvector.iter())
385            .map(|(&av_i, &v_i)| {
386                let d = av_i - res.eigenvalue * v_i;
387                d * d
388            })
389            .sum::<f64>()
390            .sqrt();
391        assert!(r < 1e-9, "residual too large: {r}");
392        // Unit-norm eigenvector.
393        let nrm: f64 = res.eigenvector.iter().map(|&x| x * x).sum::<f64>().sqrt();
394        assert!((nrm - 1.0).abs() < 1e-10);
395    }
396
397    #[test]
398    fn different_shifts_target_different_eigenvalues() {
399        let n = 5;
400        let a = laplacian_1d(n);
401        let eigs = laplacian_1d_eigs(n);
402
403        // Each shift is placed nearest a distinct eigenvalue.
404        let cases = [
405            (0.3, eigs[0]), // ≈ 0.268
406            (0.9, eigs[1]), // 1.0
407            (1.9, eigs[2]), // 2.0
408            (2.9, eigs[3]), // 3.0
409            (3.8, eigs[4]), // ≈ 3.732
410        ];
411        for (sigma, expected) in cases {
412            let res = shift_invert(&a, sigma, 300, 1e-10).expect("shift-invert");
413            assert!(res.converged, "sigma {sigma} did not converge");
414            assert!(
415                (res.eigenvalue - expected).abs() < 1e-6,
416                "sigma {sigma}: expected {expected} got {}",
417                res.eigenvalue
418            );
419        }
420    }
421
422    #[test]
423    fn finds_smallest_eigenvalue() {
424        // Shift below the spectrum targets the smallest eigenvalue, which
425        // ordinary power iteration would never find.
426        let n = 6;
427        let a = laplacian_1d(n);
428        let eigs = laplacian_1d_eigs(n);
429        let res = shift_invert(&a, -0.5, 300, 1e-10).expect("shift-invert");
430        assert!(res.converged);
431        assert!(
432            (res.eigenvalue - eigs[0]).abs() < 1e-6,
433            "expected smallest {} got {}",
434            eigs[0],
435            res.eigenvalue
436        );
437    }
438
439    #[test]
440    fn converges_within_max_iter() {
441        let n = 5;
442        let a = laplacian_1d(n);
443        let res = shift_invert(&a, 1.9, 50, 1e-9).expect("shift-invert");
444        assert!(res.converged);
445        assert!(res.iters <= 50);
446    }
447
448    #[test]
449    fn rejects_non_square() {
450        let a = HostCsr::new(2, 3, vec![0, 1, 2], vec![0, 1], vec![1.0, 1.0]).expect("rect");
451        assert!(shift_invert(&a, 0.0, 10, 1e-8).is_err());
452    }
453
454    #[test]
455    fn rejects_zero_max_iter() {
456        let a = laplacian_1d(4);
457        assert!(shift_invert(&a, 0.5, 0, 1e-8).is_err());
458    }
459
460    #[test]
461    fn singular_shift_errors() {
462        // Diagonal matrix diag(1, 2, 3); shift exactly on an eigenvalue makes
463        // A − σI singular.
464        let a =
465            HostCsr::new(3, 3, vec![0, 1, 2, 3], vec![0, 1, 2], vec![1.0, 2.0, 3.0]).expect("diag");
466        assert!(shift_invert(&a, 2.0, 50, 1e-8).is_err());
467    }
468
469    #[test]
470    fn diagonal_matrix_exact() {
471        // For a diagonal matrix the eigenvalues are the diagonal entries.
472        let a = HostCsr::new(
473            4,
474            4,
475            vec![0, 1, 2, 3, 4],
476            vec![0, 1, 2, 3],
477            vec![10.0, 20.0, 30.0, 40.0],
478        )
479        .expect("diag");
480        let res = shift_invert(&a, 19.0, 100, 1e-10).expect("shift-invert");
481        assert!(res.converged);
482        assert!((res.eigenvalue - 20.0).abs() < 1e-8);
483    }
484
485    #[test]
486    fn dense_lu_solves_correctly() {
487        // [[2,1],[1,3]] x = [3,5] -> x = [0.8, 1.4].
488        let lu = DenseLu::factor(vec![2.0, 1.0, 1.0, 3.0], 2).expect("factor");
489        let x = lu.solve(&[3.0, 5.0]);
490        assert!((x[0] - 0.8).abs() < 1e-12);
491        assert!((x[1] - 1.4).abs() < 1e-12);
492    }
493}