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}