Skip to main content

optirs_core/parameter_groups/
nuclear_norm.rs

1// Nuclear-norm (trace-norm) operations built on a self-contained truncated SVD.
2//
3// The nuclear norm of a matrix `M` is the sum of its singular values,
4// `‖M‖_* = Σ_i σ_i(M)`. Unlike the entrywise L1 norm it is *not* separable over
5// matrix entries, so neither the proximal operator nor the projection onto a
6// nuclear-norm ball can be expressed as elementwise shrinkage. Both act on the
7// **singular values**:
8//
9// * `prox_{t‖·‖_*}(M) = U · max(S − t, 0) · Vᵀ` — soft-thresholding of `S`.
10// * `Π_{‖·‖_* ≤ τ}(M)  = U · max(S − θ, 0) · Vᵀ` where `θ ≥ 0` is chosen so the
11//   surviving singular values sum to exactly `τ` (projection of `S` onto the
12//   L1 ball of radius `τ`).
13//
14// The SVD used here is computed with power iteration plus deflation, using only
15// `scirs2_core::ndarray` — no external linear-algebra backend and no FFI. The
16// starting vectors come from a deterministic integer hash, so every call on the
17// same input produces bit-identical output.
18
19use scirs2_core::ndarray::{Array1, Array2};
20use scirs2_core::numeric::Float;
21
22/// Maximum number of power iterations spent on a single singular triplet.
23const MAX_POWER_ITERATIONS: usize = 512;
24
25/// Result of a truncated singular value decomposition, `M ≈ U · diag(s) · Vᵀ`.
26#[derive(Debug, Clone)]
27pub struct TruncatedSvd<A: Float> {
28    /// Extracted singular values, in non-increasing order.
29    pub singular_values: Array1<A>,
30    /// Left singular vectors stored as columns; shape `(rows, k)`.
31    pub u: Array2<A>,
32    /// Right singular vectors stored as columns; shape `(cols, k)`.
33    pub v: Array2<A>,
34}
35
36impl<A: Float> TruncatedSvd<A> {
37    /// Number of extracted singular triplets.
38    pub fn rank(&self) -> usize {
39        self.singular_values.len()
40    }
41
42    /// Sum of the extracted singular values (the nuclear norm of the captured part).
43    pub fn nuclear_norm(&self) -> A {
44        self.singular_values
45            .iter()
46            .fold(A::zero(), |acc, &s| acc + s)
47    }
48
49    /// Reconstruct `U · diag(s) · Vᵀ` for the extracted components.
50    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
69/// Deterministic 64-bit mixing function (SplitMix64 finalizer).
70///
71/// Used purely to build reproducible, non-degenerate starting vectors for the
72/// power iteration; no randomness source is involved, so repeated calls with the
73/// same inputs always yield the same decomposition.
74fn 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
82/// Build a deterministic, normalized starting vector of length `len`.
83///
84/// Entries are pseudo-random in `[-1.5, -0.5] ∪ [0.5, 1.5]`, which keeps the
85/// vector well away from being orthogonal to the dominant singular direction
86/// (the failure mode of a plain all-ones start) while remaining fully
87/// reproducible.
88fn 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); // in [0, 1)
98        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
115/// `w = M v`
116fn 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
129/// `a = Mᵀ w`
130fn 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
143/// Euclidean norm of a vector.
144fn 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
152/// Frobenius norm of a matrix.
153fn 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
161/// Default stopping threshold for singular values: singular values at or below
162/// this magnitude are treated as numerically zero and the deflation stops.
163fn 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
175/// Compute a truncated SVD of `matrix` via power iteration with deflation.
176///
177/// Repeatedly extracts the dominant singular triplet `(σ, u, v)` of the current
178/// residual by running power iteration on `RᵀR` (which converges at rate
179/// `(σ_{k+1}/σ_k)²`), then deflates `R ← R − σ · u vᵀ`. Extraction stops after
180/// `max_components` triplets, after `min(rows, cols)` triplets, or as soon as a
181/// singular value drops to or below `tolerance`.
182///
183/// # Arguments
184///
185/// * `matrix` - Input matrix.
186/// * `max_components` - Upper bound on the number of triplets to extract.
187/// * `tolerance` - Singular values `≤ tolerance` terminate the extraction. Pass
188///   a non-positive value to fall back to a scale-aware default.
189///
190/// The starting vectors are derived from a deterministic hash, so the result is
191/// reproducible across runs and platforms.
192pub 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    // Convergence threshold on the right singular vector between iterations.
214    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                // Residual annihilates this direction: nothing left to extract.
230                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        // Deflate: R ← R − σ · u vᵀ
255        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
286/// Full-rank truncated SVD with the scale-aware default tolerance.
287fn 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
292/// Compute the nuclear norm `‖M‖_* = Σ_i σ_i(M)` of a matrix.
293///
294/// # Examples
295///
296/// ```
297/// use scirs2_core::ndarray::arr2;
298/// use optirs_core::parameter_groups::nuclear_norm_of_matrix;
299///
300/// // A diagonal matrix has singular values equal to |diagonal entries|.
301/// let m = arr2(&[[3.0, 0.0], [0.0, -4.0]]);
302/// let nn: f64 = nuclear_norm_of_matrix(&m);
303/// assert!((nn - 7.0).abs() < 1e-8);
304/// ```
305pub fn nuclear_norm_of_matrix<A: Float>(matrix: &Array2<A>) -> A {
306    full_svd(matrix).nuclear_norm()
307}
308
309/// Proximal operator of the nuclear norm: `prox_{t‖·‖_*}(M) = U max(S − t, 0) Vᵀ`.
310///
311/// This is **singular-value** soft-thresholding, the operation at the heart of
312/// low-rank matrix recovery (singular value thresholding / soft-impute). It is
313/// deliberately *not* the same as elementwise L1 soft-thresholding of `M`.
314///
315/// A non-positive `threshold` returns the input unchanged.
316///
317/// # Examples
318///
319/// ```
320/// use scirs2_core::ndarray::arr2;
321/// use optirs_core::parameter_groups::nuclear_norm_prox;
322///
323/// let m = arr2(&[[3.0f64, 0.0], [0.0, 2.0]]);
324/// let shrunk = nuclear_norm_prox(&m, 1.0);
325/// assert!((shrunk[[0, 0]] - 2.0).abs() < 1e-6);
326/// assert!((shrunk[[1, 1]] - 1.0).abs() < 1e-6);
327/// ```
328pub 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
342/// Project a matrix onto the nuclear-norm ball `{X : ‖X‖_* ≤ max_norm}`.
343///
344/// Computes the singular values, projects them onto the L1 ball of radius
345/// `max_norm` (which is soft-thresholding by a data-dependent `θ ≥ 0`), then
346/// reconstructs. When the matrix already satisfies the constraint it is
347/// returned unchanged. A non-positive `max_norm` collapses the matrix to zero.
348///
349/// # Examples
350///
351/// ```
352/// use scirs2_core::ndarray::arr2;
353/// use optirs_core::parameter_groups::{nuclear_norm_of_matrix, project_onto_nuclear_norm_ball};
354///
355/// let m = arr2(&[[3.0, 0.0], [0.0, 2.0]]); // nuclear norm 5
356/// let projected = project_onto_nuclear_norm_ball(&m, 3.0);
357/// let nn: f64 = nuclear_norm_of_matrix(&projected);
358/// assert!((nn - 3.0).abs() < 1e-6);
359/// ```
360pub 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
389/// Rebuild `M − Σ_i shrink(σ_i) · u_i v_iᵀ`.
390///
391/// Expressing the result as a correction to the original matrix (rather than
392/// re-accumulating `U diag(σ') Vᵀ`) keeps any singular components that fell
393/// below the extraction tolerance intact and avoids amplifying the small
394/// orthogonality error of the deflation.
395fn 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
417/// Find the soft-threshold `θ ≥ 0` whose shrinkage puts `values` (non-negative)
418/// exactly on the L1 sphere of radius `radius`.
419///
420/// Standard simplex/L1-ball projection: sort descending, walk the cumulative
421/// sums, and take the largest prefix `ρ` for which `values[ρ-1] − (cum_ρ − radius)/ρ > 0`.
422fn 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    /// Build `U diag(s) Vᵀ` from two orthonormal bases and a spectrum.
454    fn synthetic_matrix(spectrum: &[f64]) -> Array2<f64> {
455        // 3x3 orthonormal U (rotation about z by 30 degrees composed with a swap).
456        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        // 3x3 orthonormal V (rotation about x by 45 degrees).
460        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        // Reconstruction matches the original matrix.
485        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        // Third singular value fully shrunk away.
519        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        // 2x3 matrix with singular values sqrt(eigenvalues of M Mᵀ).
577        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}