bixverse_methods/methods/multi_cca.rs
1//! Sparse multiple canonical correlation analysis by penalised matrix
2//! decomposition.
3//!
4//! Given `K` matrices measured on the same rows, find sparse weight vectors
5//! maximising `sum_{i<j} w_i' X_i' X_j w_j` subject to `||w_i||_2 = 1` and
6//! `||w_i||_1 <= c_i`. Block coordinate ascent: each block's update is a
7//! soft-threshold of its cross products against the other blocks' current
8//! variates, with the threshold bisected to sit exactly on the L1 budget.
9//! Components come out one at a time, each deflated against its predecessors.
10//!
11//! A port of `PMA::MultiCCA`. Three things in the original are easy to
12//! reimplement subtly differently and all three change the answer, so they are
13//! called out where they happen: the deflation uses a *diagonalised* cross
14//! product rather than a Hotelling deflation, the convergence test compares
15//! against a criterion computed from the previous sweep's weights, and the L2
16//! normaliser floors at 0.05 instead of erroring on a zero vector.
17//!
18//! ### References
19//!
20//! Witten & Tibshirani, Statistical Applications in Genetics and Molecular
21//! Biology, 2009. Witten, Tibshirani & Hastie, Biostatistics 10(3), 2009.
22
23use faer::{Accum, ColRef, Mat, MatRef, Par};
24use rand::SeedableRng;
25use rand::rngs::StdRng;
26use rand::seq::SliceRandom;
27use rayon::prelude::*;
28
29use crate::prelude::*;
30use bixverse_core::core::math::matrix_helpers::scale_matrix_col;
31use bixverse_core::core::math::vector_helpers::pearson_correlation;
32use bixverse_core::utils::faer_parallelism;
33use bixverse_core::utils::gemm::gemm;
34
35////////////
36// Consts //
37////////////
38
39/// Bisection budget for the L1 threshold search.
40///
41/// The bracket starts at `[0, max|g|]` and halves every step, so 150 steps take
42/// any realistic gradient far below the interval tolerance. It is a runaway
43/// guard rather than a working limit, and matches `PMA::BinarySearch`.
44const L1_SEARCH_MAX_ITER: usize = 150;
45
46/// Absolute width at which the L1 bisection stops.
47const L1_SEARCH_TOL: f64 = 1e-6;
48
49/// Backoff subtracted from the upper end of the bisection bracket.
50///
51/// At exactly `max|g|` the soft-threshold annihilates the whole vector, and the
52/// L1-to-L2 ratio it is bisecting on is then undefined. `PMA` steps just inside.
53const L1_SEARCH_UPPER_BACKOFF: f64 = 1e-5;
54
55/// Floor for the L2 normaliser, from `PMA::l2n`.
56///
57/// A block whose gradient is entirely annihilated by the threshold would
58/// otherwise divide by zero. Dividing by 0.05 leaves the zero vector a zero
59/// vector, so the block simply contributes nothing that sweep.
60const L2_NORM_FLOOR: f64 = 0.05;
61
62/// Relative change in the criterion below which a component has converged.
63const DEFAULT_TOL: f64 = 1e-3;
64
65/// Sweeps per component. `PMA`'s own default is 25; DIALOGUE asks for 100.
66const DEFAULT_N_ITER: usize = 100;
67
68/// Number of penalty values in an auto-generated grid, from
69/// `PMA::MultiCCA.permute`.
70const PENALTY_GRID_SIZE: usize = 10;
71
72/// Lowest multiplier of `sqrt(p_k)` in an auto-generated penalty grid.
73const PENALTY_GRID_LOWER: f64 = 0.1;
74
75/// Highest multiplier of `sqrt(p_k)` in an auto-generated penalty grid.
76const PENALTY_GRID_UPPER: f64 = 0.8;
77
78/// Floor on any auto-generated penalty. The L1 bound must exceed one or the
79/// constraint set is empty.
80const PENALTY_GRID_FLOOR: f64 = 1.1;
81
82/// Sweeps per component while tuning. Tuning only needs the first component to
83/// roughly the right place, and it refits once per grid point per permutation.
84const PERMUTE_N_ITER: usize = 3;
85
86/// Permutations per grid point, from `PMA::MultiCCA.permute`.
87const PERMUTE_N_PERMS: usize = 10;
88
89/// Stabiliser on the permutation standard deviation in the z-statistic.
90///
91/// Without it a grid point whose permuted correlations happen to agree closely
92/// wins on a near-zero denominator rather than on a large gap.
93const PERMUTE_SD_FLOOR: f64 = 0.05;
94
95////////////////////
96// MultiCcaParams //
97////////////////////
98
99/// Tuning knobs for [multi_cca].
100#[derive(Clone, Debug)]
101pub struct MultiCcaParams<T> {
102 /// Number of components to extract.
103 pub n_components: usize,
104 /// Maximum block coordinate ascent sweeps per component.
105 pub n_iter: usize,
106 /// Relative change in the criterion below which a component has converged.
107 pub tol: T,
108 /// L1 bound per block. `None` picks `sqrt(p_1) / 2` for every block, which
109 /// is what DIALOGUE uses; a bound must lie in `[1, sqrt(p_k)]`.
110 pub penalties: Option<Vec<T>>,
111 /// Centre and scale every column to unit variance first, with an `n - 1`
112 /// denominator. `PMA` does this unconditionally by default.
113 pub standardise: bool,
114}
115
116impl<T: BixverseFloat> MultiCcaParams<T> {
117 /// Builds a parameter set.
118 ///
119 /// ### Params
120 ///
121 /// * `n_components` - Number of components to extract
122 /// * `n_iter` - Maximum sweeps per component
123 /// * `tol` - Relative convergence tolerance on the criterion
124 /// * `penalties` - Per-block L1 bounds, or `None` to derive them
125 /// * `standardise` - Centre and scale the columns first
126 ///
127 /// ### Returns
128 ///
129 /// The parameter set. Nothing is validated here; [multi_cca] checks the
130 /// values against the data it is given.
131 pub fn new(
132 n_components: usize,
133 n_iter: usize,
134 tol: T,
135 penalties: Option<Vec<T>>,
136 standardise: bool,
137 ) -> Self {
138 Self {
139 n_components,
140 n_iter,
141 tol,
142 penalties,
143 standardise,
144 }
145 }
146}
147
148impl<T: BixverseFloat> Default for MultiCcaParams<T> {
149 fn default() -> Self {
150 Self {
151 n_components: 2,
152 n_iter: DEFAULT_N_ITER,
153 tol: T::from_f64(DEFAULT_TOL).unwrap(),
154 penalties: None,
155 standardise: true,
156 }
157 }
158}
159
160/// What [multi_cca] found.
161#[derive(Clone, Debug)]
162pub struct MultiCcaResult<T> {
163 /// One `p_k x n_components` weight matrix per block. Sparse: entries the
164 /// L1 bound annihilated are exactly zero.
165 pub ws: Vec<Mat<T>>,
166 /// `sum_{i<j} cor(X_i w_i, X_j w_j)` per component.
167 pub cors: Vec<T>,
168 /// The L1 bound actually applied to each block.
169 pub penalties: Vec<T>,
170}
171
172////////////////////
173// The main solve //
174////////////////////
175
176/// Sparse multiple canonical correlation analysis.
177///
178/// Every block must have the same number of rows and at least two columns.
179/// Components are extracted sequentially; each sweep cycles the blocks in
180/// order, so the block order is part of the specification rather than an
181/// implementation detail.
182///
183/// Initialised from the leading right singular vectors of each block. Sign
184/// conventions for singular vectors are arbitrary and differ between LAPACK
185/// and `faer`, and the objective is *not* invariant to flipping a single
186/// block's weights, only to flipping all of them at once. A deterministic
187/// convention is applied here (largest-magnitude entry made positive) so
188/// repeated runs agree; a run of `PMA` on the same data may still land on a
189/// different local optimum.
190///
191/// ### Params
192///
193/// * `x` - The `K` blocks, each `n x p_k`, rows aligned across blocks
194/// * `params` - Optional [MultiCcaParams]; [MultiCcaParams::default] otherwise
195///
196/// ### Returns
197///
198/// The [MultiCcaResult], or [CoreErrors::InvalidArgument] for fewer than
199/// two blocks, a block with fewer than two columns, or an L1 bound outside
200/// `[1, sqrt(p_k)]`; [CoreErrors::ShapeMismatch] when the row counts
201/// disagree.
202///
203/// ### References
204///
205/// Witten & Tibshirani, Statistical Applications in Genetics and Molecular
206/// Biology, 2009
207pub fn multi_cca<T: BixverseFloat>(
208 x: &[MatRef<T>],
209 params: Option<MultiCcaParams<T>>,
210) -> Result<MultiCcaResult<T>, MethodsErrors> {
211 let params = params.unwrap_or_default();
212 let n_comp = params.n_components;
213 if n_comp == 0 {
214 return Err(CoreErrors::InvalidArgument(
215 "multi-CCA needs at least one component.".to_string(),
216 )
217 .into());
218 }
219 let blocks = prepare_blocks(x, params.standardise)?;
220
221 // Leading right singular vectors, one column per component.
222 let init = svd_inits(&blocks, n_comp)?;
223
224 multi_cca_fit(&blocks, &init, ¶ms, faer_parallelism())
225}
226
227/// Leading right singular vectors of every block.
228///
229/// ### Params
230///
231/// * `blocks` - The prepared blocks
232/// * `n_comp` - Number of components, one column each
233///
234/// ### Returns
235///
236/// One `p_k x n_comp` matrix per block.
237fn svd_inits<T: BixverseFloat>(
238 blocks: &[Mat<T>],
239 n_comp: usize,
240) -> Result<Vec<Mat<T>>, MethodsErrors> {
241 blocks
242 .iter()
243 .map(|block| svd_init(block.as_ref(), n_comp))
244 .collect()
245}
246
247/// The block coordinate ascent on prepared blocks and precomputed starts.
248///
249/// ### Params
250///
251/// * `blocks` - The prepared blocks
252/// * `init` - Starting weights from [svd_inits], at least `n_components` wide
253/// * `params` - The solver options; `standardise` is not read here
254/// * `par` - Parallelism of the matrix-vector products
255///
256/// ### Returns
257///
258/// The [MultiCcaResult], or [CoreErrors::InvalidArgument] for an L1 bound
259/// outside `[1, sqrt(p_k)]`.
260fn multi_cca_fit<T: BixverseFloat>(
261 blocks: &[Mat<T>],
262 init: &[Mat<T>],
263 params: &MultiCcaParams<T>,
264 par: Par,
265) -> Result<MultiCcaResult<T>, MethodsErrors> {
266 let n_comp = params.n_components;
267 let k = blocks.len();
268
269 let penalties = resolve_penalties(blocks, params.penalties.as_deref())?;
270
271 let mut ws_final: Vec<Mat<T>> = blocks
272 .iter()
273 .map(|b| Mat::<T>::zeros(b.ncols(), n_comp))
274 .collect();
275 let mut cors: Vec<T> = Vec::with_capacity(n_comp);
276
277 for comp in 0..n_comp {
278 let diag = deflation_table(blocks, &ws_final, comp);
279 let mut w: Vec<Vec<T>> = init
280 .iter()
281 .map(|m| (0..m.nrows()).map(|i| m[(i, comp)]).collect())
282 .collect();
283
284 // X_i w_i for every block, refreshed whenever w_i changes
285 let mut variates: Vec<Vec<T>> = blocks
286 .iter()
287 .zip(w.iter())
288 .map(|(b, wi)| mat_vec(b, wi))
289 .collect();
290
291 // The criterion is evaluated at the top of the sweep, so it lags the
292 // weights by one. Seeding the pair at -10 and -20 is what makes the
293 // first test pass unconditionally and the loop run at least twice.
294 // Both come straight from PMA; changing either changes where it stops.
295 let mut crit_old = T::from_f64(-10.0).unwrap();
296 let mut crit = T::from_f64(-20.0).unwrap();
297 let mut sweep = 0;
298 while sweep < params.n_iter
299 && ((crit_old - crit).abs() / crit_old.abs()) > params.tol
300 && crit_old != T::zero()
301 {
302 crit_old = crit;
303 crit = criterion(&variates);
304 sweep += 1;
305 for i in 0..k {
306 w[i] = update_block(
307 blocks,
308 i,
309 &w,
310 &variates,
311 &ws_final,
312 comp,
313 &diag,
314 penalties[i],
315 par,
316 );
317 variates[i] = mat_vec(&blocks[i], &w[i]);
318 }
319 }
320
321 for (i, wi) in w.iter().enumerate() {
322 for (j, &v) in wi.iter().enumerate() {
323 ws_final[i][(j, comp)] = v;
324 }
325 }
326 cors.push(sum_pairwise_correlations(&variates));
327 }
328
329 Ok(MultiCcaResult {
330 ws: ws_final,
331 cors,
332 penalties,
333 })
334}
335
336/// Validates the blocks and optionally standardises their columns.
337///
338/// ### Params
339///
340/// * `x` - The blocks as supplied
341/// * `standardise` - Centre and scale each column
342///
343/// ### Returns
344///
345/// Owned copies ready for the solve, or the relevant [MethodsErrors].
346fn prepare_blocks<T: BixverseFloat>(
347 x: &[MatRef<T>],
348 standardise: bool,
349) -> Result<Vec<Mat<T>>, MethodsErrors> {
350 if x.len() < 2 {
351 return Err(CoreErrors::InvalidArgument(format!(
352 "multi-CCA needs at least two blocks; got {}.",
353 x.len()
354 ))
355 .into());
356 }
357 let n = x[0].nrows();
358 for (i, block) in x.iter().enumerate() {
359 if block.nrows() != n {
360 return Err(CoreErrors::ShapeMismatch {
361 expected: (n, block.ncols()),
362 got: (block.nrows(), block.ncols()),
363 }
364 .into());
365 }
366 if block.ncols() < 2 {
367 return Err(CoreErrors::InvalidArgument(format!(
368 "block {i} has {} columns; multi-CCA needs at least 2.",
369 block.ncols()
370 ))
371 .into());
372 }
373 }
374 Ok(x.iter()
375 .map(|b| {
376 if standardise {
377 scale_matrix_col(b, true)
378 } else {
379 b.to_owned()
380 }
381 })
382 .collect())
383}
384
385/// Resolves the per-block L1 bounds.
386///
387/// `None` gives `sqrt(p_1) / 2` for *every* block, using the first block's
388/// column count throughout. That is what DIALOGUE passes and it is deliberate
389/// there, where every block has the same number of features; it is worth
390/// knowing when the blocks differ in width.
391///
392/// ### Params
393///
394/// * `blocks` - The prepared blocks
395/// * `penalties` - Supplied bounds, one per block, or `None`
396///
397/// ### Returns
398///
399/// One bound per block, or [CoreErrors::InvalidArgument] when a bound is
400/// outside `[1, sqrt(p_k)]` or the count does not match.
401fn resolve_penalties<T: BixverseFloat>(
402 blocks: &[Mat<T>],
403 penalties: Option<&[T]>,
404) -> Result<Vec<T>, MethodsErrors> {
405 let k = blocks.len();
406 let resolved: Vec<T> = match penalties {
407 Some(p) if p.len() == 1 => vec![p[0]; k],
408 Some(p) if p.len() == k => p.to_vec(),
409 Some(p) => {
410 return Err(CoreErrors::InvalidArgument(format!(
411 "expected 1 or {k} penalties; got {}.",
412 p.len()
413 ))
414 .into());
415 }
416 None => {
417 let default =
418 T::from_usize(blocks[0].ncols()).unwrap().sqrt() / T::from_f64(2.0).unwrap();
419 vec![default; k]
420 }
421 };
422
423 for (i, (&p, block)) in resolved.iter().zip(blocks.iter()).enumerate() {
424 let upper = T::from_usize(block.ncols()).unwrap().sqrt();
425 if p < T::one() || p > upper {
426 return Err(CoreErrors::InvalidArgument(format!(
427 "the L1 bound for block {i} must lie in [1, sqrt(p_k)]; got {p} against an \
428 upper limit of {upper}."
429 ))
430 .into());
431 }
432 }
433 Ok(resolved)
434}
435
436/// Leading right singular vectors of a block, sign-normalised.
437///
438/// ### Params
439///
440/// * `block` - One prepared block
441/// * `n_comp` - How many singular vectors to take
442///
443/// ### Returns
444///
445/// A `p_k x n_comp` matrix, each column with its largest-magnitude entry
446/// positive, or [CoreErrors::FaerSvdError].
447fn svd_init<T: BixverseFloat>(block: MatRef<T>, n_comp: usize) -> Result<Mat<T>, MethodsErrors> {
448 let svd = block
449 .thin_svd()
450 .map_err(|e| CoreErrors::FaerSvdError(format!("{e:?}")))?;
451 let v = svd.V();
452 let p = block.ncols();
453 let available = v.ncols();
454 let mut out = Mat::<T>::zeros(p, n_comp);
455 for c in 0..n_comp {
456 // A block of rank below n_comp has no further singular vectors. Fall
457 // back to a unit basis vector so the ascent still has somewhere to
458 // start rather than an all-zero column it can never leave.
459 if c < available {
460 for i in 0..p {
461 out[(i, c)] = v[(i, c)];
462 }
463 } else {
464 out[(c % p, c)] = T::one();
465 }
466 normalise_sign(&mut out, c);
467 }
468 Ok(out)
469}
470
471/// Flips a column so its largest-magnitude entry is positive.
472///
473/// ### Params
474///
475/// * `m` - Matrix to adjust in place
476/// * `col` - Column index
477fn normalise_sign<T: BixverseFloat>(m: &mut Mat<T>, col: usize) {
478 let mut best = T::zero();
479 let mut sign = T::one();
480 for i in 0..m.nrows() {
481 let v = m[(i, col)];
482 if v.abs() > best {
483 best = v.abs();
484 sign = if v < T::zero() { -T::one() } else { T::one() };
485 }
486 }
487 if sign < T::zero() {
488 for i in 0..m.nrows() {
489 m[(i, col)] = -m[(i, col)];
490 }
491 }
492}
493
494/// One block's coordinate ascent update.
495///
496/// Accumulates `sum_{j != i} X_i' X_j w_j`, less a deflation term against the
497/// components already extracted, then soft-thresholds onto the L1 budget and
498/// renormalises to unit L2.
499///
500/// The deflation is `PMA`'s, and it is not a Hotelling deflation: the
501/// cross-product `W_i' X_i' X_j W_j` between the two blocks' *finished*
502/// components is stripped of its off-diagonal before being applied. That makes
503/// it an approximate orthogonality constraint, and reproducing it exactly is
504/// the difference between matching `PMA` and merely resembling it.
505///
506/// ### Params
507///
508/// * `blocks` - The prepared blocks
509/// * `i` - Block being updated
510/// * `w` - Current weights for every block, this component
511/// * `variates` - `X_j w_j` for every block at the current weights
512/// * `ws_final` - Components already extracted, zero-filled beyond `comp`
513/// * `comp` - Component being extracted
514/// * `diag` - Deflation table from [deflation_table], indexed
515/// `(i * K + j) * comp + c`
516/// * `penalty` - L1 bound for this block
517/// * `par` - Parallelism of the cross product
518///
519/// ### Returns
520///
521/// The updated weight vector for block `i`.
522#[allow(clippy::too_many_arguments)]
523fn update_block<T: BixverseFloat>(
524 blocks: &[Mat<T>],
525 i: usize,
526 w: &[Vec<T>],
527 variates: &[Vec<T>],
528 ws_final: &[Mat<T>],
529 comp: usize,
530 diag: &[T],
531 penalty: T,
532 par: Par,
533) -> Vec<T> {
534 let p_i = blocks[i].ncols();
535 let mut totals = vec![T::zero(); p_i];
536
537 for j in 0..blocks.len() {
538 if j == i {
539 continue;
540 }
541 // X_i' (X_j w_j)
542 let mut cross = Mat::<T>::zeros(p_i, 1);
543 gemm(
544 cross.as_mut(),
545 Accum::Replace,
546 blocks[i].transpose(),
547 ColRef::from_slice(&variates[j]).as_mat(),
548 T::one(),
549 par,
550 );
551 for (t, c) in totals.iter_mut().zip(cross.col_as_slice(0)) {
552 *t += *c;
553 }
554
555 if comp == 0 {
556 continue;
557 }
558 // Deflation: W_i (diag(W_i' X_i' X_j W_j) (W_j' w_j)). Only the
559 // projection onto the current weights varies across sweeps; the
560 // diagonal was computed once for the whole component.
561 let k = blocks.len();
562 let mut correction = vec![T::zero(); comp];
563 for (c, corr) in correction.iter_mut().enumerate() {
564 let proj: T = (0..blocks[j].ncols())
565 .fold(T::zero(), |acc, r| acc + ws_final[j][(r, c)] * w[j][r]);
566 *corr = diag[(i * k + j) * comp + c] * proj;
567 }
568 for (r, t) in totals.iter_mut().enumerate() {
569 for (c, &corr) in correction.iter().enumerate() {
570 *t -= ws_final[i][(r, c)] * corr;
571 }
572 }
573 }
574
575 let lambda = l1_threshold(&totals, penalty);
576 let thresholded = soft_threshold(&totals, lambda);
577 let norm = l2_norm_floored(&thresholded);
578 thresholded.into_iter().map(|v| v / norm).collect()
579}
580
581/// Diagonal cross products between every pair of blocks' finished components.
582///
583/// `diag[(i * K + j) * comp + c]` is `(X_i W_i[:,c]) . (X_j W_j[:,c])`. None of
584/// it depends on the weights currently being solved for, so it is constant for
585/// the whole of a component's sweeps -- which is why it is built once here
586/// rather than rebuilt inside every block update, where it cost a factor of
587/// `n_iter` in matrix-vector products.
588///
589/// ### Params
590///
591/// * `blocks` - The prepared blocks
592/// * `ws_final` - Components already extracted
593/// * `comp` - Component being extracted; the table covers `0..comp`
594///
595/// ### Returns
596///
597/// The table, empty when `comp` is zero.
598fn deflation_table<T: BixverseFloat>(
599 blocks: &[Mat<T>],
600 ws_final: &[Mat<T>],
601 comp: usize,
602) -> Vec<T> {
603 let k = blocks.len();
604 if comp == 0 {
605 return Vec::new();
606 }
607 let variates: Vec<Vec<Vec<T>>> = (0..k)
608 .map(|b| {
609 (0..comp)
610 .map(|c| {
611 let column: Vec<T> = (0..blocks[b].ncols())
612 .map(|r| ws_final[b][(r, c)])
613 .collect();
614 mat_vec(&blocks[b], &column)
615 })
616 .collect()
617 })
618 .collect();
619
620 let mut out = vec![T::zero(); k * k * comp];
621 for i in 0..k {
622 for j in 0..k {
623 for c in 0..comp {
624 out[(i * k + j) * comp + c] = variates[i][c]
625 .iter()
626 .zip(variates[j][c].iter())
627 .fold(T::zero(), |acc, (a, b)| acc + *a * *b);
628 }
629 }
630 }
631 out
632}
633
634/// `sum_{i<j} w_i' X_i' X_j w_j`, the objective being maximised.
635///
636/// ### Params
637///
638/// * `variates` - `X_i w_i` for every block
639///
640/// ### Returns
641///
642/// The criterion value.
643fn criterion<T: BixverseFloat>(variates: &[Vec<T>]) -> T {
644 let mut total = T::zero();
645 for i in 1..variates.len() {
646 for j in 0..i {
647 total += variates[i]
648 .iter()
649 .zip(variates[j].iter())
650 .fold(T::zero(), |acc, (a, b)| acc + *a * *b);
651 }
652 }
653 total
654}
655
656/// `sum_{i<j} cor(X_i w_i, X_j w_j)`, with a degenerate pair scoring zero.
657///
658/// ### Params
659///
660/// * `variates` - `X_i w_i` for every block
661///
662/// ### Returns
663///
664/// The summed pairwise correlation.
665fn sum_pairwise_correlations<T: BixverseFloat>(variates: &[Vec<T>]) -> T {
666 let mut total = T::zero();
667 for i in 1..variates.len() {
668 for j in 0..i {
669 let r = pearson_correlation(&variates[i], &variates[j]).unwrap_or(0.0);
670 total += T::from_f64(r).unwrap_or_else(T::zero);
671 }
672 }
673 total
674}
675
676/// Soft-threshold, `sign(x) max(0, |x| - lambda)`.
677///
678/// ### Params
679///
680/// * `x` - Vector to threshold
681/// * `lambda` - Threshold, non-negative
682///
683/// ### Returns
684///
685/// The thresholded vector, same length.
686fn soft_threshold<T: BixverseFloat>(x: &[T], lambda: T) -> Vec<T> {
687 x.iter()
688 .map(|&v| {
689 let shrunk = v.abs() - lambda;
690 if shrunk > T::zero() {
691 if v < T::zero() { -shrunk } else { shrunk }
692 } else {
693 T::zero()
694 }
695 })
696 .collect()
697}
698
699/// L2 norm with `PMA`'s floor: a zero vector reports 0.05 rather than zero.
700///
701/// ### Params
702///
703/// * `x` - Vector
704///
705/// ### Returns
706///
707/// The L2 norm, or 0.05 when it would be exactly zero.
708fn l2_norm_floored<T: BixverseFloat>(x: &[T]) -> T {
709 let norm = x.iter().fold(T::zero(), |acc, &v| acc + v * v).sqrt();
710 if norm == T::zero() {
711 T::from_f64(L2_NORM_FLOOR).unwrap()
712 } else {
713 norm
714 }
715}
716
717/// Bisects the soft-threshold that puts `||w||_1 / ||w||_2` on the budget.
718///
719/// Returns zero when the unthresholded direction already satisfies the bound,
720/// which is the case whenever the L1 constraint is slack.
721///
722/// ### Params
723///
724/// * `x` - Gradient to threshold
725/// * `budget` - L1 bound on the unit-norm result
726///
727/// ### Returns
728///
729/// The threshold.
730fn l1_threshold<T: BixverseFloat>(x: &[T], budget: T) -> T {
731 let norm = l2_norm_floored(x);
732 let raw_l1 = x.iter().fold(T::zero(), |acc, &v| acc + (v / norm).abs());
733 if raw_l1 <= budget {
734 return T::zero();
735 }
736
737 let mut lower = T::zero();
738 let mut upper = x.iter().fold(T::zero(), |acc, &v| acc.max(v.abs()))
739 - T::from_f64(L1_SEARCH_UPPER_BACKOFF).unwrap();
740 let tol = T::from_f64(L1_SEARCH_TOL).unwrap();
741
742 for _ in 0..L1_SEARCH_MAX_ITER {
743 let mid = (lower + upper) / T::from_f64(2.0).unwrap();
744 let candidate = soft_threshold(x, mid);
745 let cand_norm = l2_norm_floored(&candidate);
746 let l1 = candidate
747 .iter()
748 .fold(T::zero(), |acc, &v| acc + (v / cand_norm).abs());
749 if l1 < budget {
750 upper = mid;
751 } else {
752 lower = mid;
753 }
754 if (upper - lower) < tol {
755 return (lower + upper) / T::from_f64(2.0).unwrap();
756 }
757 }
758 (lower + upper) / T::from_f64(2.0).unwrap()
759}
760
761/// `X v`.
762///
763/// ### Params
764///
765/// * `m` - Matrix, `n x p`
766/// * `v` - Vector, length `p`
767///
768/// ### Returns
769///
770/// The product, length `n`.
771fn mat_vec<T: BixverseFloat>(m: &Mat<T>, v: &[T]) -> Vec<T> {
772 let n = m.nrows();
773 let p = m.ncols();
774 let mut out = vec![T::zero(); n];
775 for j in 0..p {
776 let vj = v[j];
777 if vj == T::zero() {
778 continue;
779 }
780 for (o, &x) in out.iter_mut().zip(m.col_as_slice(j)) {
781 *o += x * vj;
782 }
783 }
784 out
785}
786
787//////////////////
788// Penalty tuning //
789//////////////////////
790
791/// Tuning knobs for [multi_cca_permute].
792#[derive(Clone, Debug)]
793pub struct MultiCcaPermuteParams<T> {
794 /// Candidate L1 bounds, one row per grid point and one entry per block.
795 /// `None` builds `PMA`'s grid: ten points spanning
796 /// `[0.1, 0.8] * sqrt(p_k)`, floored at 1.1.
797 pub grid: Option<Vec<Vec<T>>>,
798 /// Permutations per grid point.
799 pub n_perms: usize,
800 /// Sweeps per fit while tuning.
801 pub n_iter: usize,
802 /// Seed for the row permutations.
803 pub seed: u64,
804}
805
806impl<T: BixverseFloat> Default for MultiCcaPermuteParams<T> {
807 fn default() -> Self {
808 Self {
809 grid: None,
810 n_perms: PERMUTE_N_PERMS,
811 n_iter: PERMUTE_N_ITER,
812 seed: 42,
813 }
814 }
815}
816
817/// What [multi_cca_permute] found.
818#[derive(Clone, Debug)]
819pub struct MultiCcaPermuteResult<T> {
820 /// The grid point with the highest z-statistic, one bound per block.
821 pub best_penalties: Vec<T>,
822 /// z-statistic per grid point.
823 pub z_stats: Vec<T>,
824 /// Fraction of permutations matching or beating the real fit, per grid
825 /// point.
826 pub p_values: Vec<T>,
827 /// The grid that was searched.
828 pub grid: Vec<Vec<T>>,
829}
830
831/// Picks an L1 bound by permutation.
832///
833/// For each candidate, fits the first component on the real data and on
834/// `n_perms` row-permuted copies, then scores it by
835/// `(real - mean(permuted)) / (sd(permuted) + 0.05)`. Rows are permuted
836/// independently per block, which is what breaks the cross-block association
837/// while leaving each block's own covariance intact.
838///
839/// Only worth calling when the grid has more than one point. DIALOGUE's default
840/// path passes a single fixed bound, in which case `PMA` still runs every
841/// permutation and then picks the only candidate; skip it and call [multi_cca]
842/// directly instead.
843///
844/// ### Params
845///
846/// * `x` - The `K` blocks, each `n x p_k`
847/// * `params` - Optional [MultiCcaPermuteParams]; the default otherwise
848///
849/// ### Returns
850///
851/// The [MultiCcaPermuteResult], or whatever [multi_cca] rejects.
852pub fn multi_cca_permute<T: BixverseFloat>(
853 x: &[MatRef<T>],
854 params: Option<MultiCcaPermuteParams<T>>,
855) -> Result<MultiCcaPermuteResult<T>, MethodsErrors> {
856 let params = params.unwrap_or_default();
857 let blocks = prepare_blocks(x, true)?;
858 let grid = match params.grid {
859 Some(g) => g,
860 None => default_penalty_grid(&blocks),
861 };
862 if grid.is_empty() {
863 return Err(CoreErrors::InvalidArgument("the penalty grid is empty.".to_string()).into());
864 }
865
866 // Blocks are standardised above, so the fits below must not repeat it.
867 // The svd start depends only on the blocks, so every grid point on the
868 // same data shares it.
869 let fit = |b: &[Mat<T>],
870 init: &[Mat<T>],
871 penalties: &[T],
872 n_iter: usize,
873 par: Par|
874 -> Result<T, MethodsErrors> {
875 let res = multi_cca_fit(
876 b,
877 init,
878 &MultiCcaParams {
879 n_components: 1,
880 n_iter,
881 tol: T::from_f64(DEFAULT_TOL).unwrap(),
882 penalties: Some(penalties.to_vec()),
883 standardise: false,
884 },
885 par,
886 )?;
887 Ok(res.cors[0])
888 };
889
890 let real_init = svd_inits(&blocks, 1)?;
891 let real: Vec<T> = grid
892 .iter()
893 .map(|p| fit(&blocks, &real_init, p, params.n_iter, faer_parallelism()))
894 .collect::<Result<_, _>>()?;
895
896 // Orders are drawn up front, perm-major then block-major, so the RNG
897 // stream matches a sequential run.
898 let mut rng = StdRng::seed_from_u64(params.seed);
899 let n = blocks[0].nrows();
900 let orders: Vec<Vec<Vec<usize>>> = (0..params.n_perms)
901 .map(|_| {
902 blocks
903 .iter()
904 .map(|_| {
905 let mut order: Vec<usize> = (0..n).collect();
906 order.shuffle(&mut rng);
907 order
908 })
909 .collect()
910 })
911 .collect();
912
913 let per_perm: Vec<Vec<T>> = orders
914 .par_iter()
915 .map(|perm_orders| {
916 let shuffled: Vec<Mat<T>> = blocks
917 .iter()
918 .zip(perm_orders)
919 .map(|(b, order)| Mat::<T>::from_fn(n, b.ncols(), |i, j| b[(order[i], j)]))
920 .collect();
921 let init = svd_inits(&shuffled, 1)?;
922 grid.iter()
923 .map(|point| fit(&shuffled, &init, point, params.n_iter, Par::Seq))
924 .collect::<Result<Vec<T>, MethodsErrors>>()
925 })
926 .collect::<Result<_, _>>()?;
927
928 let mut permuted: Vec<Vec<T>> = vec![Vec::with_capacity(params.n_perms); grid.len()];
929 for perm_res in per_perm {
930 for (g, v) in perm_res.into_iter().enumerate() {
931 permuted[g].push(v);
932 }
933 }
934
935 let sd_floor = T::from_f64(PERMUTE_SD_FLOOR).unwrap();
936 let mut z_stats = Vec::with_capacity(grid.len());
937 let mut p_values = Vec::with_capacity(grid.len());
938 for (g, &observed) in real.iter().enumerate() {
939 let null = &permuted[g];
940 let m = T::from_usize(null.len()).unwrap();
941 let mean = null.iter().fold(T::zero(), |a, &v| a + v) / m;
942 let var = null
943 .iter()
944 .fold(T::zero(), |a, &v| a + (v - mean) * (v - mean))
945 / (m - T::one()).max(T::one());
946 z_stats.push((observed - mean) / (var.sqrt() + sd_floor));
947 let hits = null.iter().filter(|&&v| v >= observed).count();
948 p_values.push(T::from_usize(hits).unwrap() / m);
949 }
950
951 let best = z_stats
952 .iter()
953 .enumerate()
954 .fold((0usize, T::neg_infinity()), |(bi, bv), (i, &v)| {
955 if v > bv { (i, v) } else { (bi, bv) }
956 })
957 .0;
958
959 Ok(MultiCcaPermuteResult {
960 best_penalties: grid[best].clone(),
961 z_stats,
962 p_values,
963 grid,
964 })
965}
966
967/// `PMA`'s default penalty grid: ten points over `[0.1, 0.8] * sqrt(p_k)`,
968/// floored at 1.1 so the L1 constraint stays feasible.
969///
970/// ### Params
971///
972/// * `blocks` - The prepared blocks
973///
974/// ### Returns
975///
976/// One row per grid point, one entry per block.
977fn default_penalty_grid<T: BixverseFloat>(blocks: &[Mat<T>]) -> Vec<Vec<T>> {
978 let floor = T::from_f64(PENALTY_GRID_FLOOR).unwrap();
979 let steps = PENALTY_GRID_SIZE;
980 (0..steps)
981 .map(|s| {
982 let frac = PENALTY_GRID_LOWER
983 + (PENALTY_GRID_UPPER - PENALTY_GRID_LOWER) * s as f64 / (steps - 1) as f64;
984 let frac = T::from_f64(frac).unwrap();
985 blocks
986 .iter()
987 .map(|b| {
988 let upper = T::from_usize(b.ncols()).unwrap().sqrt();
989 (frac * upper).max(floor).min(upper)
990 })
991 .collect()
992 })
993 .collect()
994}
995
996///////////
997// Tests //
998///////////
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003 use approx::assert_relative_eq;
1004
1005 /// Three blocks on 12 shared rows, the first two features of each loaded on
1006 /// a common latent factor. Generated in R and run through `PMA::MultiCCA`
1007 /// itself for the reference weights below.
1008 fn pma_fixture() -> (Mat<f64>, Mat<f64>, Mat<f64>) {
1009 let x1 = [
1010 -0.1, -1.13, 0.55, 0.78, 0.38, 1.01, -0.1, -1.89, -0.06, -0.08, -0.5, -0.71, 0.85,
1011 2.24, -0.31, 0.25, -0.33, -0.69, 1.85, 0.22, -0.38, -0.03, -1.02, 1.03, 0.61, -1.02,
1012 -0.69, -0.04, -1.07, -0.28, -0.42, 2.8, -0.21, 1.37, 0.3, -1.22, 1.12, 0.84, -1.27,
1013 -0.23, 0.45, 0.18, -1.61, -1.43, 2.17, 1.52, 0.05, -0.14, -1.69, 0.28, 1.21, -1.55,
1014 0.92, 0.01, -0.62, 0.48, -1.12, 0.58, 2.05, 0.39, 0.08, 1.92, -0.4, 0.12, -0.49, -0.37,
1015 -0.41, 1.01, -0.47, 0.22, -2.31, 0.64,
1016 ];
1017 let x2 = [
1018 -0.89, 1.51, -0.38, 0.12, 0.04, 0.06, 1.26, 0.92, -0.95, -0.42, 2.97, 1.63, -0.58,
1019 -0.49, -2.05, 0.52, -0.94, 0.61, -0.26, 1.13, -0.17, -0.56, -1.62, 1.84, -1.46, 3.21,
1020 2.31, -0.06, -0.65, 0.74, 1.55, 0.31, 0.52, 0.24, 1.91, -0.97, -1.87, 0.3, 0.08, -1.44,
1021 -0.59, -1.78, 0.11, -0.96, 0.7, -1.16, -0.58, -0.64, -0.07, -0.26, 2.83, 0.68, -0.85,
1022 1.44, -1.57, -0.17, -1.24, -1.02, 0.45, -1.51,
1023 ];
1024 let x3 = [
1025 -2.05, 0.11, 0.52, -1.06, -0.72, -0.56, 0.37, 1.26, -0.21, 2.22, -0.22, -0.35, 0.74,
1026 -0.32, 0.07, -0.87, 2.2, 1.16, -0.03, -0.24, 0.09, 0.32, 2.13, -0.2, 1.16, -0.89,
1027 -0.74, 1.11, -0.24, 2.23, -1.1, 0.08, -0.22, -0.97, 0.04, 0.75, -1.36, -0.06, 0.31,
1028 -0.5, 0.86, 1.62, 0.44, 0.21, 0.01, -0.2, -0.46, -0.32,
1029 ];
1030 (
1031 Mat::from_fn(12, 6, |i, j| x1[i * 6 + j]),
1032 Mat::from_fn(12, 5, |i, j| x2[i * 5 + j]),
1033 Mat::from_fn(12, 4, |i, j| x3[i * 4 + j]),
1034 )
1035 }
1036
1037 /// `sqrt(ncol(x1)) / 2` applied to every block, as DIALOGUE does.
1038 const PMA_PENALTY: f64 = 1.224_744_871_391_589;
1039
1040 /// Parameters matching the reference `PMA::MultiCCA` call.
1041 fn pma_params() -> MultiCcaParams<f64> {
1042 MultiCcaParams {
1043 n_components: 2,
1044 n_iter: 100,
1045 tol: 1e-3,
1046 penalties: Some(vec![PMA_PENALTY; 3]),
1047 standardise: true,
1048 }
1049 }
1050
1051 /// The reference weights from `PMA::MultiCCA`, row-major per block.
1052 fn pma_expected() -> [Vec<f64>; 3] {
1053 [
1054 vec![
1055 0.0,
1056 0.0,
1057 -0.965_925_812_666_518_6,
1058 0.0,
1059 0.0,
1060 0.0,
1061 0.0,
1062 0.965_925_840_039_351_2,
1063 0.0,
1064 -0.258_818_993_785_760_6,
1065 0.25881909594256314,
1066 0.0,
1067 ],
1068 vec![
1069 -0.965_925_811_281_016_2,
1070 0.0,
1071 -0.258_819_101_113_327,
1072 0.0,
1073 0.0,
1074 -0.1398497689827376,
1075 0.0,
1076 0.099_760_763_370_438_63,
1077 0.0,
1078 -0.985_134_423_420_084_6,
1079 ],
1080 vec![
1081 -0.12522129863695705,
1082 0.0,
1083 -0.1139615511233123,
1084 0.965_925_827_364_77,
1085 -0.985_561_967_221_362_9,
1086 0.0,
1087 0.0,
1088 -0.258_819_041_087_947,
1089 ],
1090 ]
1091 }
1092
1093 /// Weights against `PMA::MultiCCA` on the same data.
1094 ///
1095 /// Compared on magnitude, not signed value. The objective is invariant only
1096 /// under flipping every block at once, and the SVD that seeds the ascent
1097 /// has no shared sign convention between LAPACK and `faer`, so a signed
1098 /// comparison would be testing the singular value decomposition rather than
1099 /// the ascent.
1100 #[test]
1101 fn test_multi_cca_matches_pma() {
1102 let (x1, x2, x3) = pma_fixture();
1103 let blocks = [x1.as_ref(), x2.as_ref(), x3.as_ref()];
1104 let res = multi_cca(&blocks, Some(pma_params())).unwrap();
1105
1106 for (b, expected) in pma_expected().iter().enumerate() {
1107 let w = &res.ws[b];
1108 for c in 0..2 {
1109 for r in 0..w.nrows() {
1110 let got = w[(r, c)];
1111 let want = expected[r * 2 + c];
1112 assert_relative_eq!(got.abs(), want.abs(), epsilon = 1e-7);
1113 // The active set must match exactly, and the zeros must be
1114 // exact rather than merely small: DIALOGUE counts non-zero
1115 // weights to decide which features enter a programme.
1116 if want == 0.0 {
1117 assert_eq!(got, 0.0, "block {b} entry ({r}, {c}) should be zero");
1118 } else {
1119 assert_ne!(got, 0.0, "block {b} entry ({r}, {c}) should be non-zero");
1120 }
1121 }
1122 }
1123 }
1124 }
1125
1126 /// The summed pairwise correlations `PMA` reports per component.
1127 #[test]
1128 fn test_multi_cca_reports_pma_correlations() {
1129 let (x1, x2, x3) = pma_fixture();
1130 let blocks = [x1.as_ref(), x2.as_ref(), x3.as_ref()];
1131 let res = multi_cca(&blocks, Some(pma_params())).unwrap();
1132 assert_relative_eq!(res.cors[0].abs(), 1.8951346746976176, epsilon = 1e-7);
1133 assert_relative_eq!(res.cors[1].abs(), 1.6787391871183392, epsilon = 1e-7);
1134 }
1135
1136 /// The L1 bound binds: with `sqrt(p) / 2` the solution is 2-sparse, and the
1137 /// surviving pair sits at `(cos 15, sin 15)` because that is the only unit
1138 /// vector with two non-zeros whose L1 norm is `sqrt(6) / 2`.
1139 #[test]
1140 fn test_multi_cca_respects_the_l1_budget() {
1141 let (x1, x2, x3) = pma_fixture();
1142 let blocks = [x1.as_ref(), x2.as_ref(), x3.as_ref()];
1143 let res = multi_cca(&blocks, Some(pma_params())).unwrap();
1144
1145 for w in res.ws.iter() {
1146 for c in 0..2 {
1147 let col: Vec<f64> = (0..w.nrows()).map(|r| w[(r, c)]).collect();
1148 let l2: f64 = col.iter().map(|v| v * v).sum::<f64>().sqrt();
1149 let l1: f64 = col.iter().map(|v| v.abs()).sum();
1150 assert_relative_eq!(l2, 1.0, epsilon = 1e-9);
1151 assert!(l1 <= PMA_PENALTY + 1e-6, "L1 norm {l1} exceeded the budget");
1152 }
1153 }
1154 }
1155
1156 /// A loose bound leaves the constraint slack, so nothing is thresholded
1157 /// away and every weight stays non-zero.
1158 #[test]
1159 fn test_multi_cca_slack_penalty_keeps_every_feature() {
1160 let (x1, x2, x3) = pma_fixture();
1161 let blocks = [x1.as_ref(), x2.as_ref(), x3.as_ref()];
1162 let params = MultiCcaParams {
1163 n_components: 1,
1164 n_iter: 100,
1165 tol: 1e-3,
1166 // sqrt(p_k) is the largest admissible bound and never binds.
1167 penalties: Some(vec![6f64.sqrt(), 5f64.sqrt(), 4f64.sqrt()]),
1168 standardise: true,
1169 };
1170 let res = multi_cca(&blocks, Some(params)).unwrap();
1171 for w in res.ws.iter() {
1172 let zeros = (0..w.nrows()).filter(|&r| w[(r, 0)] == 0.0).count();
1173 assert_eq!(zeros, 0);
1174 }
1175 }
1176
1177 #[test]
1178 fn test_multi_cca_rejects_bad_input() {
1179 let (x1, x2, x3) = pma_fixture();
1180 // Fewer than two blocks.
1181 assert!(multi_cca(&[x1.as_ref()], None).is_err());
1182 // Mismatched row counts.
1183 let short = Mat::<f64>::zeros(11, 4);
1184 assert!(multi_cca(&[x1.as_ref(), short.as_ref()], None).is_err());
1185 // A single-column block.
1186 let thin = Mat::<f64>::zeros(12, 1);
1187 assert!(multi_cca(&[x1.as_ref(), thin.as_ref()], None).is_err());
1188 // An L1 bound below one leaves the constraint set empty, and one above
1189 // sqrt(p_k) can never bind.
1190 let blocks = [x1.as_ref(), x2.as_ref(), x3.as_ref()];
1191 let too_small = MultiCcaParams {
1192 penalties: Some(vec![0.5; 3]),
1193 ..MultiCcaParams::default()
1194 };
1195 assert!(multi_cca(&blocks, Some(too_small)).is_err());
1196 let too_large = MultiCcaParams {
1197 penalties: Some(vec![10.0; 3]),
1198 ..MultiCcaParams::default()
1199 };
1200 assert!(multi_cca(&blocks, Some(too_large)).is_err());
1201 }
1202
1203 /// Tuning prefers a bound that separates the real fit from its permutation
1204 /// null, and always returns a point from the grid it searched.
1205 #[test]
1206 fn test_multi_cca_permute_picks_from_the_grid() {
1207 let (x1, x2, x3) = pma_fixture();
1208 let blocks = [x1.as_ref(), x2.as_ref(), x3.as_ref()];
1209 let res = multi_cca_permute(&blocks, None).unwrap();
1210
1211 // Every grid point must stay inside [1, sqrt(p_k)] or multi_cca would
1212 // have rejected it on the way through.
1213 for point in res.grid.iter() {
1214 for (p, upper) in point.iter().zip([6f64.sqrt(), 5f64.sqrt(), 4f64.sqrt()]) {
1215 assert!(*p >= 1.0 && *p <= upper);
1216 }
1217 }
1218 for p in res.p_values.iter() {
1219 assert!((0.0..=1.0).contains(p));
1220 }
1221 }
1222
1223 /// A planted shared factor is recovered: the leading component's weight
1224 /// lands on the loaded features rather than the noise ones.
1225 #[test]
1226 fn test_multi_cca_recovers_a_planted_factor() {
1227 use rand::Rng;
1228 use rand_distr::StandardNormal;
1229
1230 let n = 40;
1231 let mut rng = StdRng::seed_from_u64(7);
1232 let latent: Vec<f64> = (0..n).map(|_| rng.sample(StandardNormal)).collect();
1233 // Two loaded features, six of pure noise, in each of three blocks. The
1234 // noise has to be genuinely independent across blocks: anything with
1235 // shared structure is a real cross-block signal and multi-CCA is
1236 // entitled to find it instead.
1237 let mut build = |load: f64| {
1238 let draws: Vec<f64> = (0..n * 8).map(|_| rng.sample(StandardNormal)).collect();
1239 Mat::<f64>::from_fn(n, 8, |i, j| {
1240 let base = draws[i * 8 + j];
1241 if j < 2 { base + load * latent[i] } else { base }
1242 })
1243 };
1244 let b1 = build(2.0);
1245 let b2 = build(2.0);
1246 let b3 = build(2.0);
1247 let blocks = [b1.as_ref(), b2.as_ref(), b3.as_ref()];
1248
1249 let params = MultiCcaParams {
1250 n_components: 1,
1251 ..MultiCcaParams::default()
1252 };
1253 let res = multi_cca(&blocks, Some(params)).unwrap();
1254
1255 for (b, w) in res.ws.iter().enumerate() {
1256 let loaded: f64 = (0..2).map(|r| w[(r, 0)].abs()).sum();
1257 let rest: f64 = (2..8).map(|r| w[(r, 0)].abs()).sum();
1258 assert!(
1259 loaded > rest,
1260 "block {b}: loaded weight {loaded} did not beat the noise weight {rest}"
1261 );
1262 }
1263 }
1264}