optirs_core/parameter_groups/
nuclear_norm.rs1use scirs2_core::ndarray::{Array1, Array2};
20use scirs2_core::numeric::Float;
21
22const MAX_POWER_ITERATIONS: usize = 512;
24
25#[derive(Debug, Clone)]
27pub struct TruncatedSvd<A: Float> {
28 pub singular_values: Array1<A>,
30 pub u: Array2<A>,
32 pub v: Array2<A>,
34}
35
36impl<A: Float> TruncatedSvd<A> {
37 pub fn rank(&self) -> usize {
39 self.singular_values.len()
40 }
41
42 pub fn nuclear_norm(&self) -> A {
44 self.singular_values
45 .iter()
46 .fold(A::zero(), |acc, &s| acc + s)
47 }
48
49 pub fn reconstruct(&self) -> Array2<A> {
51 let rows = self.u.nrows();
52 let cols = self.v.nrows();
53 let mut out: Array2<A> = Array2::zeros((rows, cols));
54 for (k, &sigma) in self.singular_values.iter().enumerate() {
55 if sigma == A::zero() {
56 continue;
57 }
58 for r in 0..rows {
59 let scaled = sigma * self.u[[r, k]];
60 for c in 0..cols {
61 out[[r, c]] = out[[r, c]] + scaled * self.v[[c, k]];
62 }
63 }
64 }
65 out
66 }
67}
68
69fn splitmix64(mut state: u64) -> u64 {
75 state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
76 let mut z = state;
77 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
78 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
79 z ^ (z >> 31)
80}
81
82fn deterministic_start_vector<A: Float>(len: usize, salt: u64) -> Array1<A> {
89 let mut v: Array1<A> = Array1::zeros(len);
90 if len == 0 {
91 return v;
92 }
93
94 let scale = 1.0f64 / (u64::MAX as f64);
95 for (i, slot) in v.iter_mut().enumerate() {
96 let bits = splitmix64(salt.wrapping_mul(0x0100_0000_01B3).wrapping_add(i as u64));
97 let unit = (bits >> 11) as f64 * (scale * 2048.0); let magnitude = 0.5 + unit;
99 let signed = if bits & 1 == 0 { magnitude } else { -magnitude };
100 *slot = A::from(signed).unwrap_or_else(A::one);
101 }
102
103 let mut norm_sq = A::zero();
104 for &x in v.iter() {
105 norm_sq = norm_sq + x * x;
106 }
107 let norm = norm_sq.sqrt();
108 if norm > A::zero() {
109 let inv = A::one() / norm;
110 v.mapv_inplace(|x| x * inv);
111 }
112 v
113}
114
115fn matvec<A: Float>(matrix: &Array2<A>, v: &Array1<A>) -> Array1<A> {
117 let (rows, cols) = matrix.dim();
118 let mut w: Array1<A> = Array1::zeros(rows);
119 for r in 0..rows {
120 let mut acc = A::zero();
121 for c in 0..cols {
122 acc = acc + matrix[[r, c]] * v[c];
123 }
124 w[r] = acc;
125 }
126 w
127}
128
129fn transpose_matvec<A: Float>(matrix: &Array2<A>, w: &Array1<A>) -> Array1<A> {
131 let (rows, cols) = matrix.dim();
132 let mut a: Array1<A> = Array1::zeros(cols);
133 for c in 0..cols {
134 let mut acc = A::zero();
135 for r in 0..rows {
136 acc = acc + matrix[[r, c]] * w[r];
137 }
138 a[c] = acc;
139 }
140 a
141}
142
143fn vector_norm<A: Float>(v: &Array1<A>) -> A {
145 let mut acc = A::zero();
146 for &x in v.iter() {
147 acc = acc + x * x;
148 }
149 acc.sqrt()
150}
151
152fn frobenius_norm<A: Float>(matrix: &Array2<A>) -> A {
154 let mut acc = A::zero();
155 for &x in matrix.iter() {
156 acc = acc + x * x;
157 }
158 acc.sqrt()
159}
160
161fn default_singular_tolerance<A: Float>(matrix: &Array2<A>) -> A {
164 let scale = frobenius_norm(matrix);
165 let eps = A::epsilon();
166 let relative = eps.sqrt() * scale;
167 let floor = eps * eps.sqrt();
168 if relative > floor {
169 relative
170 } else {
171 floor
172 }
173}
174
175pub fn truncated_svd_power_iteration<A: Float>(
193 matrix: &Array2<A>,
194 max_components: usize,
195 tolerance: A,
196) -> TruncatedSvd<A> {
197 let (rows, cols) = matrix.dim();
198 let max_rank = rows.min(cols).min(max_components);
199
200 if rows == 0 || cols == 0 || max_rank == 0 {
201 return TruncatedSvd {
202 singular_values: Array1::zeros(0),
203 u: Array2::zeros((rows, 0)),
204 v: Array2::zeros((cols, 0)),
205 };
206 }
207
208 let tol = if tolerance > A::zero() {
209 tolerance
210 } else {
211 default_singular_tolerance(matrix)
212 };
213 let vector_tol = A::epsilon().sqrt();
215
216 let mut residual = matrix.clone();
217 let mut sigmas: Vec<A> = Vec::with_capacity(max_rank);
218 let mut left: Vec<Array1<A>> = Vec::with_capacity(max_rank);
219 let mut right: Vec<Array1<A>> = Vec::with_capacity(max_rank);
220
221 for component in 0..max_rank {
222 let mut v = deterministic_start_vector::<A>(cols, component as u64 + 1);
223
224 for _ in 0..MAX_POWER_ITERATIONS {
225 let w = matvec(&residual, &v);
226 let a = transpose_matvec(&residual, &w);
227 let norm = vector_norm(&a);
228 if norm <= A::zero() {
229 break;
231 }
232 let inv = A::one() / norm;
233 let mut delta_sq = A::zero();
234 for c in 0..cols {
235 let next = a[c] * inv;
236 let diff = next - v[c];
237 delta_sq = delta_sq + diff * diff;
238 v[c] = next;
239 }
240 if delta_sq.sqrt() <= vector_tol {
241 break;
242 }
243 }
244
245 let w = matvec(&residual, &v);
246 let sigma = vector_norm(&w);
247 if sigma <= tol {
248 break;
249 }
250
251 let inv_sigma = A::one() / sigma;
252 let u = w.mapv(|x| x * inv_sigma);
253
254 for r in 0..rows {
256 let scaled = sigma * u[r];
257 for c in 0..cols {
258 residual[[r, c]] = residual[[r, c]] - scaled * v[c];
259 }
260 }
261
262 sigmas.push(sigma);
263 left.push(u);
264 right.push(v);
265 }
266
267 let k = sigmas.len();
268 let mut u_mat: Array2<A> = Array2::zeros((rows, k));
269 let mut v_mat: Array2<A> = Array2::zeros((cols, k));
270 for (idx, (u_vec, v_vec)) in left.iter().zip(right.iter()).enumerate() {
271 for r in 0..rows {
272 u_mat[[r, idx]] = u_vec[r];
273 }
274 for c in 0..cols {
275 v_mat[[c, idx]] = v_vec[c];
276 }
277 }
278
279 TruncatedSvd {
280 singular_values: Array1::from_vec(sigmas),
281 u: u_mat,
282 v: v_mat,
283 }
284}
285
286fn full_svd<A: Float>(matrix: &Array2<A>) -> TruncatedSvd<A> {
288 let (rows, cols) = matrix.dim();
289 truncated_svd_power_iteration(matrix, rows.min(cols), A::zero())
290}
291
292pub fn nuclear_norm_of_matrix<A: Float>(matrix: &Array2<A>) -> A {
306 full_svd(matrix).nuclear_norm()
307}
308
309pub fn nuclear_norm_prox<A: Float>(matrix: &Array2<A>, threshold: A) -> Array2<A> {
329 if threshold <= A::zero() {
330 return matrix.clone();
331 }
332 let svd = full_svd(matrix);
333 subtract_shrinkage(matrix, &svd, |sigma| {
334 if sigma < threshold {
335 sigma
336 } else {
337 threshold
338 }
339 })
340}
341
342pub fn project_onto_nuclear_norm_ball<A: Float>(matrix: &Array2<A>, max_norm: A) -> Array2<A> {
361 if max_norm <= A::zero() {
362 return Array2::zeros(matrix.dim());
363 }
364
365 let svd = full_svd(matrix);
366 let total = svd.nuclear_norm();
367 if total <= max_norm {
368 return matrix.clone();
369 }
370
371 let theta = l1_ball_threshold(&svd.singular_values, max_norm);
372 if theta <= A::zero() {
373 return matrix.clone();
374 }
375
376 subtract_shrinkage(
377 matrix,
378 &svd,
379 |sigma| {
380 if sigma < theta {
381 sigma
382 } else {
383 theta
384 }
385 },
386 )
387}
388
389fn subtract_shrinkage<A, F>(matrix: &Array2<A>, svd: &TruncatedSvd<A>, shrink: F) -> Array2<A>
396where
397 A: Float,
398 F: Fn(A) -> A,
399{
400 let (rows, cols) = matrix.dim();
401 let mut out = matrix.clone();
402 for (k, &sigma) in svd.singular_values.iter().enumerate() {
403 let amount = shrink(sigma);
404 if amount <= A::zero() {
405 continue;
406 }
407 for r in 0..rows {
408 let scaled = amount * svd.u[[r, k]];
409 for c in 0..cols {
410 out[[r, c]] = out[[r, c]] - scaled * svd.v[[c, k]];
411 }
412 }
413 }
414 out
415}
416
417fn l1_ball_threshold<A: Float>(values: &Array1<A>, radius: A) -> A {
423 let mut sorted: Vec<A> = values.iter().copied().collect();
424 sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
425
426 let mut cumulative = A::zero();
427 let mut theta = A::zero();
428 let mut found = false;
429 for (idx, &value) in sorted.iter().enumerate() {
430 cumulative = cumulative + value;
431 let count = A::from(idx + 1).unwrap_or_else(A::one);
432 let candidate = (cumulative - radius) / count;
433 if value - candidate > A::zero() {
434 theta = candidate;
435 found = true;
436 } else {
437 break;
438 }
439 }
440
441 if !found || theta < A::zero() {
442 A::zero()
443 } else {
444 theta
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use scirs2_core::ndarray::{arr2, Array2};
452
453 fn synthetic_matrix(spectrum: &[f64]) -> Array2<f64> {
455 let c = (std::f64::consts::PI / 6.0).cos();
457 let s = (std::f64::consts::PI / 6.0).sin();
458 let u = arr2(&[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]);
459 let c2 = (std::f64::consts::PI / 4.0).cos();
461 let s2 = (std::f64::consts::PI / 4.0).sin();
462 let v = arr2(&[[1.0, 0.0, 0.0], [0.0, c2, -s2], [0.0, s2, c2]]);
463
464 let mut m = Array2::<f64>::zeros((3, 3));
465 for (k, &sigma) in spectrum.iter().enumerate() {
466 for i in 0..3 {
467 for j in 0..3 {
468 m[[i, j]] += sigma * u[[i, k]] * v[[j, k]];
469 }
470 }
471 }
472 m
473 }
474
475 #[test]
476 fn truncated_svd_recovers_known_spectrum() {
477 let m = synthetic_matrix(&[3.0, 2.0, 0.5]);
478 let svd = truncated_svd_power_iteration(&m, 3, 0.0);
479 assert_eq!(svd.rank(), 3);
480 assert!((svd.singular_values[0] - 3.0).abs() < 1e-6);
481 assert!((svd.singular_values[1] - 2.0).abs() < 1e-6);
482 assert!((svd.singular_values[2] - 0.5).abs() < 1e-6);
483
484 let recon = svd.reconstruct();
486 let mut err = 0.0;
487 for (a, b) in recon.iter().zip(m.iter()) {
488 err += (a - b) * (a - b);
489 }
490 assert!(err.sqrt() < 1e-6, "reconstruction error {}", err.sqrt());
491 }
492
493 #[test]
494 fn truncated_svd_handles_rank_deficiency() {
495 let m = synthetic_matrix(&[2.0, 0.0, 0.0]);
496 let svd = truncated_svd_power_iteration(&m, 3, 0.0);
497 assert_eq!(svd.rank(), 1);
498 assert!((svd.singular_values[0] - 2.0).abs() < 1e-6);
499 }
500
501 #[test]
502 fn truncated_svd_is_deterministic() {
503 let m = synthetic_matrix(&[3.0, 2.0, 0.5]);
504 let a = truncated_svd_power_iteration(&m, 3, 0.0);
505 let b = truncated_svd_power_iteration(&m, 3, 0.0);
506 assert_eq!(a.singular_values, b.singular_values);
507 assert_eq!(a.u, b.u);
508 assert_eq!(a.v, b.v);
509 }
510
511 #[test]
512 fn prox_soft_thresholds_singular_values() {
513 let m = synthetic_matrix(&[3.0, 2.0, 0.5]);
514 let shrunk = nuclear_norm_prox(&m, 1.0);
515 let svd = truncated_svd_power_iteration(&shrunk, 3, 1e-9);
516 assert!((svd.singular_values[0] - 2.0).abs() < 1e-6);
517 assert!((svd.singular_values[1] - 1.0).abs() < 1e-6);
518 assert!(svd.singular_values.iter().skip(2).all(|&s| s < 1e-5));
520 }
521
522 #[test]
523 fn prox_differs_from_elementwise_l1() {
524 let m = synthetic_matrix(&[3.0, 2.0, 0.5]);
525 let shrunk = nuclear_norm_prox(&m, 1.0);
526 let elementwise = m.mapv(|x: f64| {
527 if x > 1.0 {
528 x - 1.0
529 } else if x < -1.0 {
530 x + 1.0
531 } else {
532 0.0
533 }
534 });
535 let mut diff = 0.0;
536 for (a, b) in shrunk.iter().zip(elementwise.iter()) {
537 diff += (a - b).abs();
538 }
539 assert!(
540 diff > 1e-3,
541 "nuclear prox collapsed to elementwise L1 shrinkage"
542 );
543 }
544
545 #[test]
546 fn projection_hits_the_ball_boundary() {
547 let m = synthetic_matrix(&[3.0, 2.0, 0.5]);
548 let projected = project_onto_nuclear_norm_ball(&m, 3.0);
549 let nn = nuclear_norm_of_matrix(&projected);
550 assert!(
551 (nn - 3.0).abs() < 1e-6,
552 "nuclear norm after projection {nn}"
553 );
554 }
555
556 #[test]
557 fn projection_is_a_no_op_inside_the_ball() {
558 let m = synthetic_matrix(&[1.0, 0.5, 0.25]);
559 let projected = project_onto_nuclear_norm_ball(&m, 10.0);
560 assert_eq!(projected, m);
561 }
562
563 #[test]
564 fn empty_and_degenerate_shapes_are_safe() {
565 let empty: Array2<f64> = Array2::zeros((0, 3));
566 let svd = truncated_svd_power_iteration(&empty, 3, 0.0);
567 assert_eq!(svd.rank(), 0);
568
569 let zeros: Array2<f64> = Array2::zeros((3, 3));
570 assert_eq!(nuclear_norm_of_matrix(&zeros), 0.0);
571 assert_eq!(nuclear_norm_prox(&zeros, 1.0), zeros);
572 }
573
574 #[test]
575 fn non_square_matrices_are_supported() {
576 let m = arr2(&[[3.0, 0.0, 0.0], [0.0, 4.0, 0.0]]);
578 let nn = nuclear_norm_of_matrix(&m);
579 assert!((nn - 7.0).abs() < 1e-6);
580
581 let shrunk = nuclear_norm_prox(&m, 1.0);
582 assert!((shrunk[[0, 0]] - 2.0).abs() < 1e-6);
583 assert!((shrunk[[1, 1]] - 3.0).abs() < 1e-6);
584 }
585}