Skip to main content

bixverse_core/core/math/
pca_missing.rs

1//! Probabilistic (PPCA) and Bayesian (BPCA) PCA on data with missing values.
2//! Ports of `ppca()` and `bpca()` from the R package pcaMethods: both fit the
3//! principal subspace on the observed entries only and return the imputed
4//! matrix alongside scores and loadings.
5//!
6//! Inputs are observations x variables with `NaN` marking a missing value.
7//! The fit runs in f64 regardless of `T`: the PPCA residual comes from a trace
8//! identity and the BPCA noise precision from a difference of traces, and both
9//! cancel badly in f32 near convergence.
10
11use crate::utils::gemm as dense;
12use faer::linalg::solvers::DenseSolveCore;
13use faer::{Accum, Mat, MatRef, Side};
14use rand::prelude::*;
15use rand_distr::Normal;
16use rayon::prelude::*;
17
18use crate::prelude::*;
19use crate::utils::faer_parallelism;
20
21////////////
22// Consts //
23////////////
24
25/// Standard deviation floor from pcaMethods' `prep()`. Columns below it are
26/// left unscaled rather than blown up.
27const SCALE_EPS: f64 = 1e-12;
28
29/// PPCA runs at least this many EM iterations before the relative change in
30/// the objective may stop it (pcaMethods' `count > 5`).
31const PPCA_MIN_ITER: usize = 5;
32
33/// PPCA progress is printed every this many iterations at
34/// [Verbosity::Normal]; [Verbosity::Detailed] prints every iteration.
35const PPCA_REPORT_EVERY: usize = 25;
36
37/// BPCA: shape of the Gamma prior on the ARD precisions `alpha`.
38const BPCA_GALPHA0: f64 = 1e-10;
39
40/// BPCA: scale of the Gamma prior on the ARD precisions `alpha`.
41const BPCA_BALPHA0: f64 = 1.0;
42
43/// BPCA: prior precision on the column means.
44const BPCA_GMU0: f64 = 0.001;
45
46/// BPCA: shape of the Gamma prior on the noise precision `tau`.
47const BPCA_GTAU0: f64 = 1e-10;
48
49/// BPCA: scale of the Gamma prior on the noise precision `tau`.
50const BPCA_BTAU0: f64 = 1.0;
51
52/// BPCA: lower clamp on the initial noise precision.
53const BPCA_TAU_MIN: f64 = 1e-10;
54
55/// BPCA: upper clamp on the initial noise precision.
56const BPCA_TAU_MAX: f64 = 1e10;
57
58/// BPCA: reference `tau` for the first convergence check.
59const BPCA_TAU_START: f64 = 1000.0;
60
61/// BPCA: convergence (change in `log10(tau)`) is only checked every this many
62/// steps, as in pcaMethods.
63const BPCA_CHECK_EVERY: usize = 10;
64
65////////////
66// Params //
67////////////
68
69/// Parameters for probabilistic PCA.
70#[derive(Clone, Copy, Debug)]
71pub struct PpcaParams {
72    /// Number of principal components
73    pub n_pcs: usize,
74    /// Maximum number of EM iterations
75    pub max_iter: usize,
76    /// Relative change in the objective below which EM stops
77    pub tol: f64,
78    /// Seed for the random initial loadings
79    pub seed: u64,
80    /// Subtract the observed column means first
81    pub centre: bool,
82    /// Divide by the observed column SD first (pcaMethods' `"uv"`)
83    pub scale: bool,
84}
85
86impl PpcaParams {
87    /// Generate new PPCA parameters.
88    ///
89    /// ### Params
90    ///
91    /// * `n_pcs` - Number of principal components
92    /// * `max_iter` - Maximum number of EM iterations
93    /// * `tol` - Relative change in the objective below which EM stops
94    /// * `seed` - Seed for the random initial loadings
95    /// * `centre` - Subtract the observed column means first
96    /// * `scale` - Divide by the observed column SD first
97    ///
98    /// ### Returns
99    ///
100    /// The [PpcaParams].
101    pub fn new(
102        n_pcs: usize,
103        max_iter: usize,
104        tol: f64,
105        seed: u64,
106        centre: bool,
107        scale: bool,
108    ) -> Self {
109        Self {
110            n_pcs,
111            max_iter,
112            tol,
113            seed,
114            centre,
115            scale,
116        }
117    }
118}
119
120impl Default for PpcaParams {
121    /// pcaMethods' defaults.
122    fn default() -> Self {
123        Self {
124            n_pcs: 2,
125            max_iter: 1000,
126            tol: 1e-5,
127            seed: 42,
128            centre: true,
129            scale: false,
130        }
131    }
132}
133
134/// Parameters for Bayesian PCA.
135#[derive(Clone, Copy, Debug)]
136pub struct BpcaParams {
137    /// Number of principal components
138    pub n_pcs: usize,
139    /// Maximum number of variational steps
140    pub max_iter: usize,
141    /// Change in `log10(tau)` over ten steps below which the fit stops
142    pub tol: f64,
143    /// Subtract the observed column means first
144    pub centre: bool,
145    /// Divide by the observed column SD first (pcaMethods' `"uv"`)
146    pub scale: bool,
147}
148
149impl BpcaParams {
150    /// Generate new BPCA parameters.
151    ///
152    /// ### Params
153    ///
154    /// * `n_pcs` - Number of principal components
155    /// * `max_iter` - Maximum number of variational steps
156    /// * `tol` - Change in `log10(tau)` over ten steps below which the fit
157    ///   stops
158    /// * `centre` - Subtract the observed column means first
159    /// * `scale` - Divide by the observed column SD first
160    ///
161    /// ### Returns
162    ///
163    /// The [BpcaParams].
164    pub fn new(n_pcs: usize, max_iter: usize, tol: f64, centre: bool, scale: bool) -> Self {
165        Self {
166            n_pcs,
167            max_iter,
168            tol,
169            centre,
170            scale,
171        }
172    }
173}
174
175impl Default for BpcaParams {
176    /// pcaMethods' defaults.
177    fn default() -> Self {
178        Self {
179            n_pcs: 2,
180            max_iter: 100,
181            tol: 1e-4,
182            centre: true,
183            scale: false,
184        }
185    }
186}
187
188////////////////
189// Structures //
190////////////////
191
192/// Result of [ppca] or [bpca].
193#[derive(Clone, Debug)]
194pub struct MissingPcaResults<T> {
195    /// Scores, N x n_pcs
196    pub scores: Mat<T>,
197    /// Loadings, D x n_pcs. Orthonormal for PPCA, not for BPCA
198    pub loadings: Mat<T>,
199    /// Cumulative R^2 per component. PPCA scores it on the completed matrix,
200    /// BPCA on the observed entries only, as pcaMethods does
201    pub r2_cum: Vec<T>,
202    /// Column centres that were subtracted (zeros when not centring)
203    pub centre: Vec<T>,
204    /// Column scales that were divided out (ones when not scaling)
205    pub scale: Vec<T>,
206    /// Input with missing entries replaced by `scores %*% t(loadings)` on the
207    /// original scale. Observed entries are untouched
208    pub completed: Mat<T>,
209    /// Residual variance outside the subspace: `ss` for PPCA, `1 / tau` for
210    /// BPCA
211    pub noise_var: T,
212    /// Iterations run
213    pub n_iter: usize,
214    /// Whether the stopping criterion was met before `max_iter`
215    pub converged: bool,
216}
217
218/// Centred and scaled input in f64 with its missingness mask.
219struct PreppedData {
220    /// Row-major N x D values; missing entries hold 0
221    y: Vec<f64>,
222    /// Row-major N x D mask, true where the input was missing
223    missing: Vec<bool>,
224    /// Number of missing entries per row
225    n_miss_row: Vec<usize>,
226    /// Number of observations
227    n: usize,
228    /// Number of variables
229    d: usize,
230    /// Total number of missing entries
231    n_missing: usize,
232    /// Column centres
233    centre: Vec<f64>,
234    /// Column scales
235    scale: Vec<f64>,
236}
237
238/// Raw output of one of the fits, before back-transformation.
239struct Fit {
240    /// Scores, N x k
241    scores: Mat<f64>,
242    /// Loadings, D x k
243    loadings: Mat<f64>,
244    /// Cumulative R^2 per component
245    r2_cum: Vec<f64>,
246    /// Residual variance
247    noise_var: f64,
248    /// Iterations run
249    n_iter: usize,
250    /// Whether the stopping criterion was met
251    converged: bool,
252}
253
254/////////////
255// Helpers //
256/////////////
257
258/// Validate the input and apply pcaMethods' `prep()`.
259///
260/// Centres use the observed column means; scales the observed column SD
261/// (`sd(na.rm = TRUE)`), with SDs under [SCALE_EPS] or from fewer than two
262/// observations left at one.
263///
264/// ### Params
265///
266/// * `y` - Input, N x D, `NaN` for missing
267/// * `n_pcs` - Requested number of components
268/// * `centre` - Whether to centre
269/// * `scale` - Whether to scale to unit variance
270///
271/// ### Returns
272///
273/// The [PreppedData], or an error on non-finite input, all-missing rows or
274/// columns, fewer than two rows, or too many components.
275fn prep_data<T>(
276    y: MatRef<T>,
277    n_pcs: usize,
278    centre: bool,
279    scale: bool,
280) -> Result<PreppedData, CoreErrors>
281where
282    T: BixverseFloat,
283{
284    let (n, d) = y.shape();
285    if n_pcs == 0 {
286        return Err(CoreErrors::MustBePositive("n_pcs".to_string()));
287    }
288    if n < 2 {
289        return Err(CoreErrors::InvalidArgument(
290            "PCA with missing values needs at least two rows".to_string(),
291        ));
292    }
293    let max = n.min(d);
294    if n_pcs > max {
295        return Err(CoreErrors::PcaTooManyComponents { n_pcs, max });
296    }
297
298    let col_stats: Vec<(f64, f64, usize, bool)> = y
299        .par_col_iter()
300        .map(|col| {
301            let mut sum = 0.0;
302            let mut n_obs = 0usize;
303            let mut finite = true;
304            for v in col.iter() {
305                let v = v.to_f64().unwrap();
306                if v.is_nan() {
307                    continue;
308                }
309                finite &= v.is_finite();
310                sum += v;
311                n_obs += 1;
312            }
313            let mean = sum / n_obs as f64;
314            let ss: f64 = col
315                .iter()
316                .map(|v| v.to_f64().unwrap())
317                .filter(|v| !v.is_nan())
318                .map(|v| (v - mean) * (v - mean))
319                .sum();
320            let sd = (ss / (n_obs as f64 - 1.0)).sqrt();
321            (mean, sd, n_obs, finite)
322        })
323        .collect();
324
325    if col_stats.iter().any(|s| !s.3) {
326        return Err(CoreErrors::InvalidArgument(
327            "PCA input contains infinite values".to_string(),
328        ));
329    }
330    if let Some(j) = col_stats.iter().position(|s| s.2 == 0) {
331        return Err(CoreErrors::PcaAllMissing {
332            axis: "column",
333            index: j,
334        });
335    }
336
337    let centre: Vec<f64> = col_stats
338        .iter()
339        .map(|s| if centre { s.0 } else { 0.0 })
340        .collect();
341    let scale: Vec<f64> = col_stats
342        .iter()
343        .map(|s| {
344            if scale && s.2 > 1 && s.1 >= SCALE_EPS {
345                s.1
346            } else {
347                1.0
348            }
349        })
350        .collect();
351
352    let mut y_rm = vec![0.0; n * d];
353    let mut missing = vec![false; n * d];
354    let n_miss_row: Vec<usize> = y_rm
355        .par_chunks_mut(d)
356        .zip(missing.par_chunks_mut(d))
357        .enumerate()
358        .map(|(i, (row, m_row))| {
359            let mut nm = 0;
360            for j in 0..d {
361                let v = y[(i, j)].to_f64().unwrap();
362                if v.is_nan() {
363                    m_row[j] = true;
364                    nm += 1;
365                } else {
366                    row[j] = (v - centre[j]) / scale[j];
367                }
368            }
369            nm
370        })
371        .collect();
372
373    if let Some(i) = n_miss_row.iter().position(|&nm| nm == d) {
374        return Err(CoreErrors::PcaAllMissing {
375            axis: "row",
376            index: i,
377        });
378    }
379    let n_missing = n_miss_row.iter().sum();
380
381    Ok(PreppedData {
382        y: y_rm,
383        missing,
384        n_miss_row,
385        n,
386        d,
387        n_missing,
388        centre,
389        scale,
390    })
391}
392
393/// Parallel GEMM `a %*% b` into a fresh matrix.
394///
395/// ### Params
396///
397/// * `a` - Left factor
398/// * `b` - Right factor
399///
400/// ### Returns
401///
402/// The product.
403fn gemm(a: MatRef<f64>, b: MatRef<f64>) -> Mat<f64> {
404    let mut out = Mat::zeros(a.nrows(), b.ncols());
405    dense::gemm(out.as_mut(), Accum::Replace, a, b, 1.0, faer_parallelism());
406    out
407}
408
409/// Frobenius inner product `sum(a * b)`.
410///
411/// ### Params
412///
413/// * `a` - First matrix
414/// * `b` - Second matrix, same shape
415///
416/// ### Returns
417///
418/// The sum of the elementwise product.
419fn frob_dot(a: MatRef<f64>, b: MatRef<f64>) -> f64 {
420    a.col_iter()
421        .zip(b.col_iter())
422        .map(|(ca, cb)| ca.iter().zip(cb.iter()).map(|(x, y)| x * y).sum::<f64>())
423        .sum()
424}
425
426/// Inverse and log-determinant of a small symmetric positive definite matrix
427/// via Cholesky.
428///
429/// ### Params
430///
431/// * `a` - SPD matrix, k x k
432///
433/// ### Returns
434///
435/// `(inverse, log(det(a)))`, or the Cholesky error if `a` is not SPD.
436fn spd_inverse(a: MatRef<f64>) -> Result<(Mat<f64>, f64), CoreErrors> {
437    let llt = a.llt(Side::Lower)?;
438    let logdet = 2.0
439        * llt
440            .L()
441            .diagonal()
442            .column_vector()
443            .iter()
444            .map(|v| v.ln())
445            .sum::<f64>();
446    Ok((llt.inverse(), logdet))
447}
448
449/// Cumulative R^2 of the rank-1..k reconstructions `scores %*% t(loadings)`.
450///
451/// ### Params
452///
453/// * `y` - Row-major N x D data
454/// * `mask` - Optional row-major missingness mask; masked entries are skipped
455///   in both the residual and the total sum of squares
456/// * `d` - Number of columns
457/// * `scores` - N x k
458/// * `loadings` - D x k
459///
460/// ### Returns
461///
462/// `1 - RSS_i / TSS` for each prefix of components.
463fn r2_cum(
464    y: &[f64],
465    mask: Option<&[bool]>,
466    d: usize,
467    scores: MatRef<f64>,
468    loadings: MatRef<f64>,
469) -> Vec<f64> {
470    let k = scores.ncols();
471    let w_rows = loadings.transpose().to_owned();
472
473    // per-row partials summed sequentially: a rayon fold/reduce associates in
474    // whatever order work stealing produces, so the result drifts run to run
475    let rows: Vec<(f64, Vec<f64>)> = y
476        .par_chunks(d)
477        .enumerate()
478        .map(|(i, row)| {
479            let (mut tss, mut rss) = (0.0, vec![0.0; k]);
480            for (j, &v) in row.iter().enumerate() {
481                if mask.is_some_and(|m| m[i * d + j]) {
482                    continue;
483                }
484                tss += v * v;
485                let w = w_rows.col(j);
486                let mut rec = 0.0;
487                for l in 0..k {
488                    rec += scores[(i, l)] * w[l];
489                    let r = v - rec;
490                    rss[l] += r * r;
491                }
492            }
493            (tss, rss)
494        })
495        .collect();
496    let (mut tss, mut rss) = (0.0, vec![0.0; k]);
497    for (t, r) in rows {
498        tss += t;
499        rss.iter_mut().zip(r).for_each(|(a, b)| *a += b);
500    }
501
502    rss.iter().map(|r| 1.0 - r / tss).collect()
503}
504
505/// Assemble the public result: back-transform and fill the missing entries.
506///
507/// ### Params
508///
509/// * `y` - The original input
510/// * `prep` - The prepped data
511/// * `fit` - The raw fit
512///
513/// ### Returns
514///
515/// The [MissingPcaResults] in `T`.
516fn finish<T>(y: MatRef<T>, prep: &PreppedData, fit: Fit) -> MissingPcaResults<T>
517where
518    T: BixverseFloat,
519{
520    let (n, d) = (prep.n, prep.d);
521    let k = fit.scores.ncols();
522    let w_rows = fit.loadings.transpose().to_owned();
523    let to_t = |v: f64| T::from_f64(v).unwrap();
524
525    let mut completed_rm = vec![0.0; n * d];
526    completed_rm
527        .par_chunks_mut(d)
528        .enumerate()
529        .for_each(|(i, row)| {
530            for j in 0..d {
531                row[j] = if prep.missing[i * d + j] {
532                    let w = w_rows.col(j);
533                    let rec: f64 = (0..k).map(|l| fit.scores[(i, l)] * w[l]).sum();
534                    rec * prep.scale[j] + prep.centre[j]
535                } else {
536                    y[(i, j)].to_f64().unwrap()
537                };
538            }
539        });
540
541    MissingPcaResults {
542        scores: Mat::from_fn(n, k, |i, j| to_t(fit.scores[(i, j)])),
543        loadings: Mat::from_fn(d, k, |i, j| to_t(fit.loadings[(i, j)])),
544        r2_cum: fit.r2_cum.into_iter().map(to_t).collect(),
545        centre: prep.centre.iter().map(|&v| to_t(v)).collect(),
546        scale: prep.scale.iter().map(|&v| to_t(v)).collect(),
547        completed: Mat::from_fn(n, d, |i, j| to_t(completed_rm[i * d + j])),
548        noise_var: to_t(fit.noise_var),
549        n_iter: fit.n_iter,
550        converged: fit.converged,
551    }
552}
553
554//////////
555// PPCA //
556//////////
557
558/// Overwrite the missing entries of `y` with `x %*% t(c)`.
559///
560/// ### Params
561///
562/// * `y` - Row-major N x D data, modified in place
563/// * `missing` - Row-major missingness mask
564/// * `d` - Number of columns
565/// * `x` - Scores, N x k
566/// * `c` - Loadings, D x k
567///
568/// ### Returns
569///
570/// The sum of squares of the written values.
571fn fill_missing(y: &mut [f64], missing: &[bool], d: usize, x: MatRef<f64>, c: MatRef<f64>) -> f64 {
572    let k = x.ncols();
573    let c_rows = c.transpose().to_owned();
574    y.par_chunks_mut(d)
575        .zip(missing.par_chunks(d))
576        .enumerate()
577        .map(|(i, (row, m_row))| {
578            let mut sq = 0.0;
579            for j in 0..d {
580                if m_row[j] {
581                    let cj = c_rows.col(j);
582                    let v: f64 = (0..k).map(|l| x[(i, l)] * cj[l]).sum();
583                    row[j] = v;
584                    sq += v * v;
585                }
586            }
587            sq
588        })
589        .collect::<Vec<f64>>()
590        .iter()
591        .sum()
592}
593
594/// PPCA EM loop on prepped data, after Porta's Matlab `ppca.m` as ported by
595/// pcaMethods.
596///
597/// Per iteration: two N x D x k GEMMs (`Y C` and `Y^T X`), a k-dot per missing
598/// entry, and k x k solves. The residual `||Y - X C^T||^2` is taken from
599/// `||Y||^2 - 2 <Y^T X, C> + <X^T X, C^T C>` rather than materialised, with
600/// `||Y||^2` updated over the refilled entries only.
601///
602/// ### Params
603///
604/// * `prep` - Prepped data, consumed; the missing entries are refilled in place
605/// * `c` - Initial loadings, D x k
606/// * `params` - PPCA parameters
607/// * `verbosity` - Progress reporting
608///
609/// ### Returns
610///
611/// The raw [Fit], or a Cholesky error if a k x k system goes singular.
612///
613/// ### References
614///
615/// Roweis, NIPS, 1998; Tipping & Bishop, J R Stat Soc B, 1999
616fn ppca_fit(
617    prep: &mut PreppedData,
618    mut c: Mat<f64>,
619    params: &PpcaParams,
620    verbosity: Verbosity,
621) -> Result<Fit, CoreErrors> {
622    let (n, d, k) = (prep.n, prep.d, params.n_pcs);
623    let (nf, df, n_miss) = (n as f64, d as f64, prep.n_missing as f64);
624    let eye = Mat::<f64>::identity(k, k);
625
626    let y_obs_sq: f64 = prep.y.iter().map(|v| v * v).sum();
627
628    // initial X = Y C (C^T C)^-1 and residual on the observed entries. The
629    // missing entries of Y are still zero, so the residual over all entries
630    // minus the squared reconstruction at the missing ones is the observed
631    // residual. The fill written here is exactly the one the first E-step
632    // would write, since X and C do not change in between.
633    let mut ctc = c.transpose() * &c;
634    let yc = gemm(MatRef::from_row_major_slice(&prep.y, n, d), c.as_ref());
635    let mut x = &yc * spd_inverse(ctc.as_ref())?.0;
636    let xtx = x.transpose() * &x;
637    let resid_all =
638        y_obs_sq - 2.0 * frob_dot(yc.as_ref(), x.as_ref()) + frob_dot(xtx.as_ref(), ctc.as_ref());
639    let miss_sq = if prep.n_missing > 0 {
640        fill_missing(&mut prep.y, &prep.missing, d, x.as_ref(), c.as_ref())
641    } else {
642        0.0
643    };
644    let mut ss = (resid_all - miss_sq) / (nf * df - n_miss);
645    let mut y_sq = y_obs_sq + miss_sq;
646
647    let mut old = f64::INFINITY;
648    let mut n_iter = 0;
649    let mut converged = false;
650
651    loop {
652        let (sx, logdet_a) = spd_inverse((&eye + &ctc * (1.0 / ss)).as_ref())?;
653        let logdet_sx = -logdet_a;
654        let ss_old = ss;
655
656        if prep.n_missing > 0 {
657            y_sq = y_obs_sq + fill_missing(&mut prep.y, &prep.missing, d, x.as_ref(), c.as_ref());
658        }
659        let y_mat = MatRef::from_row_major_slice(&prep.y, n, d);
660
661        // E-step
662        x = gemm(y_mat, c.as_ref()) * (&sx * (1.0 / ss));
663
664        // M-step
665        let xtx = gemm(x.transpose(), x.as_ref());
666        let ytx = gemm(y_mat.transpose(), x.as_ref());
667        c = &ytx * spd_inverse((&xtx + &sx * nf).as_ref())?.0;
668        ctc = c.transpose() * &c;
669
670        let resid =
671            y_sq - 2.0 * frob_dot(ytx.as_ref(), c.as_ref()) + frob_dot(xtx.as_ref(), ctc.as_ref());
672        // pcaMethods sums every entry of CtC %*% Sx where the Matlab original
673        // takes the trace. Kept for parity; imputation RMSE agreed to five
674        // significant digits between the two on three test shapes.
675        let ctc_sx = &ctc * &sx;
676        let ctc_sx_sum: f64 = ctc_sx.col_iter().map(|col| col.iter().sum::<f64>()).sum();
677        ss = (resid + nf * ctc_sx_sum + n_miss * ss_old) / (nf * df);
678
679        let tr_sx: f64 = sx.diagonal().column_vector().iter().sum();
680        let tr_xtx: f64 = xtx.diagonal().column_vector().iter().sum();
681        let objective = nf * (df * ss.ln() + tr_sx - logdet_sx) + tr_xtx - n_miss * ss_old.ln();
682        let rel_ch = (1.0 - objective / old).abs();
683        old = objective;
684
685        n_iter += 1;
686        if verbosity.detailed_verbosity()
687            || (verbosity.normal_verbosity() && n_iter % PPCA_REPORT_EVERY == 0)
688        {
689            println!(
690                "PPCA: iteration {n_iter}, objective {objective:.6e}, relative change {rel_ch:.3e}"
691            );
692        }
693        if rel_ch < params.tol && n_iter + 1 > PPCA_MIN_ITER {
694            converged = true;
695            break;
696        }
697        if n_iter + 1 > params.max_iter {
698            break;
699        }
700    }
701
702    if verbosity.normal_verbosity() {
703        let status = if converged {
704            "converged"
705        } else {
706            "hit max_iter"
707        };
708        println!("PPCA: {status} after {n_iter} iterations, noise variance {ss:.4e}");
709    }
710
711    // orthonormalise, then rotate onto the eigenvectors of the score covariance
712    let y_mat = MatRef::from_row_major_slice(&prep.y, n, d);
713    let c_orth = c
714        .thin_svd()
715        .map_err(|e| CoreErrors::FaerSvdError(format!("{e:?}")))?
716        .U()
717        .to_owned();
718    let yc = gemm(y_mat, c_orth.as_ref());
719    let means: Vec<f64> = yc
720        .col_iter()
721        .map(|col| col.iter().sum::<f64>() / nf)
722        .collect();
723    let yc_c = Mat::from_fn(n, k, |i, j| yc[(i, j)] - means[j]);
724    let cov = (yc_c.transpose() * &yc_c) * (1.0 / (nf - 1.0));
725    let eig = cov
726        .self_adjoint_eigen(Side::Lower)
727        .map_err(|_| CoreErrors::FaerEigenError)?;
728    // faer sorts ascending, R's eigen() descending
729    let u = eig.U();
730    let vecs = Mat::from_fn(k, k, |i, j| u[(i, k - 1 - j)]);
731    let loadings = &c_orth * &vecs;
732    let scores = gemm(y_mat, loadings.as_ref());
733
734    let r2 = r2_cum(&prep.y, None, d, scores.as_ref(), loadings.as_ref());
735
736    Ok(Fit {
737        scores,
738        loadings,
739        r2_cum: r2,
740        noise_var: ss,
741        n_iter,
742        converged,
743    })
744}
745
746/// Probabilistic PCA with missing values, from a random start.
747///
748/// The initial loadings are standard normal draws from `params.seed`. R's
749/// `ppca()` draws from its own RNG, so results match pcaMethods at
750/// convergence, not iterate by iterate; use [ppca_from_init] with R's start
751/// for the latter.
752///
753/// ### Params
754///
755/// * `y` - Observations x variables, `NaN` for missing
756/// * `params` - PPCA parameters; defaults to [PpcaParams::default]
757/// * `verbosity` - Progress reporting
758///
759/// ### Returns
760///
761/// The [MissingPcaResults], or an error on invalid input or a singular
762/// k x k system.
763///
764/// ### References
765///
766/// Roweis, NIPS, 1998; Tipping & Bishop, J R Stat Soc B, 1999; Stacklies et
767/// al., Bioinformatics, 2007
768pub fn ppca<T>(
769    y: MatRef<T>,
770    params: Option<PpcaParams>,
771    verbosity: Verbosity,
772) -> Result<MissingPcaResults<T>, CoreErrors>
773where
774    T: BixverseFloat,
775{
776    let params = params.unwrap_or_default();
777    let mut rng = StdRng::seed_from_u64(params.seed);
778    let normal = Normal::new(0.0, 1.0).expect("unit normal is valid");
779    let c0 = Mat::<T>::from_fn(y.ncols(), params.n_pcs, |_, _| {
780        T::from_f64(normal.sample(&mut rng)).unwrap()
781    });
782    ppca_from_init(y, c0.as_ref(), Some(params), verbosity)
783}
784
785/// Probabilistic PCA with missing values, from given initial loadings.
786///
787/// Doubles as a warm start and as the hook for iterate-level parity with
788/// pcaMethods, whose start is `matrix(rnorm(D * k), D, k)` after
789/// `set.seed(seed); sample(N)`.
790///
791/// ### Params
792///
793/// * `y` - Observations x variables, `NaN` for missing
794/// * `c0` - Initial loadings, D x n_pcs, on the prepped scale
795/// * `params` - PPCA parameters; defaults to [PpcaParams::default]
796/// * `verbosity` - Progress reporting
797///
798/// ### Returns
799///
800/// The [MissingPcaResults], or an error on invalid input, a mis-shaped `c0`
801/// or a singular k x k system.
802pub fn ppca_from_init<T>(
803    y: MatRef<T>,
804    c0: MatRef<T>,
805    params: Option<PpcaParams>,
806    verbosity: Verbosity,
807) -> Result<MissingPcaResults<T>, CoreErrors>
808where
809    T: BixverseFloat,
810{
811    let params = params.unwrap_or_default();
812    let mut prep = prep_data(y, params.n_pcs, params.centre, params.scale)?;
813    if c0.shape() != (prep.d, params.n_pcs) {
814        return Err(CoreErrors::InvalidArgument(format!(
815            "initial loadings are {} x {}, expected {} x {}",
816            c0.nrows(),
817            c0.ncols(),
818            prep.d,
819            params.n_pcs
820        )));
821    }
822    let c = Mat::from_fn(prep.d, params.n_pcs, |i, j| c0[(i, j)].to_f64().unwrap());
823    let fit = ppca_fit(&mut prep, c, &params, verbosity)?;
824    Ok(finish(y, &prep, fit))
825}
826
827//////////
828// BPCA //
829//////////
830
831/// State of the BPCA variational fit.
832struct BpcaState {
833    /// Loadings (principal axes), D x k
834    pa: Mat<f64>,
835    /// Noise precision
836    tau: f64,
837    /// ARD precisions, one per component
838    alpha: Vec<f64>,
839    /// Posterior covariance of the loadings, k x k
840    sig_w: Mat<f64>,
841}
842
843/// BPCA initialisation (pcaMethods' `BPCA_initmodel`).
844///
845/// R eigendecomposes the D x D covariance of the zero-filled matrix. Its top
846/// k eigenpairs are the right singular vectors and `s^2 / (N - 1)` of the
847/// column-centred N x D matrix, so this never forms the D x D matrix. As in R,
848/// the covariance centres by the zero-filled column means while the model
849/// later uses the observed means.
850///
851/// ### Params
852///
853/// * `prep` - Prepped data
854/// * `k` - Number of components
855///
856/// ### Returns
857///
858/// The initial [BpcaState], or an SVD error.
859fn bpca_init(prep: &PreppedData, k: usize) -> Result<BpcaState, CoreErrors> {
860    let (n, d) = (prep.n, prep.d);
861    let nf = n as f64;
862    let y = MatRef::from_row_major_slice(&prep.y, n, d);
863
864    let zero_means: Vec<f64> = y
865        .par_col_iter()
866        .map(|col| col.iter().sum::<f64>() / nf)
867        .collect();
868    let yc = Mat::from_fn(n, d, |i, j| y[(i, j)] - zero_means[j]);
869    let tr_cov = frob_dot(yc.as_ref(), yc.as_ref()) / (nf - 1.0);
870
871    let svd = yc
872        .thin_svd()
873        .map_err(|e| CoreErrors::FaerSvdError(format!("{e:?}")))?;
874    let s = svd.S().column_vector();
875    let v = svd.V();
876    let eig: Vec<f64> = (0..k).map(|l| s[l] * s[l] / (nf - 1.0)).collect();
877
878    let pa = Mat::from_fn(d, k, |j, l| v[(j, l)] * eig[l].sqrt());
879    let tau = (1.0 / (tr_cov - eig.iter().sum::<f64>())).clamp(BPCA_TAU_MIN, BPCA_TAU_MAX);
880    let alpha = pa
881        .col_iter()
882        .map(|col| {
883            let sq: f64 = col.iter().map(|v| v * v).sum();
884            (2.0 * BPCA_GALPHA0 + d as f64) / (tau * sq + 2.0 * BPCA_GALPHA0 / BPCA_BALPHA0)
885        })
886        .collect();
887
888    Ok(BpcaState {
889        pa,
890        tau,
891        alpha,
892        sig_w: Mat::identity(k, k),
893    })
894}
895
896/// One BPCA variational step (pcaMethods' `BPCA_dostep`).
897///
898/// E-step per row in parallel: complete rows share `Rx^-1`, incomplete rows
899/// solve their own `Rx - tau Wm^T Wm`, built from whichever of the missing or
900/// observed loadings is shorter. The missing entries of `dy` are overwritten
901/// with their conditional means, so `T = dy^T X` is one GEMM; the posterior
902/// covariance term R adds into the missing rows of `T` is summed per column
903/// afterwards, which needs no reduction across threads.
904///
905/// ### Params
906///
907/// * `state` - Current state, updated in place
908/// * `dy` - Row-major N x D data minus the observed means; the missing entries
909///   are overwritten
910/// * `prep` - Prepped data (mask and row counts)
911/// * `mean_sq` - Squared norm of the observed column means
912///
913/// ### Returns
914///
915/// The scores from this E-step (N x k), or a Cholesky/LU error.
916///
917/// ### References
918///
919/// Oba et al., Bioinformatics, 2003
920fn bpca_step(
921    state: &mut BpcaState,
922    dy: &mut [f64],
923    prep: &PreppedData,
924    mean_sq: f64,
925) -> Result<Mat<f64>, CoreErrors> {
926    let (n, d) = (prep.n, prep.d);
927    let k = state.pa.ncols();
928    let (nf, df) = (n as f64, d as f64);
929    let tau = state.tau;
930    let eye = Mat::<f64>::identity(k, k);
931
932    let ptp = state.pa.transpose() * &state.pa;
933    let rx = &eye + &ptp * tau + &state.sig_w;
934    let (rx_inv, _) = spd_inverse(rx.as_ref())?;
935    let base = &eye + &state.sig_w;
936    // row j of pa_rows is loading row j, contiguous
937    let pa_rows = state.pa.transpose().to_owned();
938
939    let rows: Vec<(Vec<f64>, Option<Mat<f64>>, f64)> = dy
940        .par_chunks_mut(d)
941        .zip(prep.missing.par_chunks(d))
942        .zip(prep.n_miss_row.par_iter())
943        .map(|((row, m_row), &nm)| {
944            let mut ex = vec![0.0; k];
945            for j in 0..d {
946                if !m_row[j] {
947                    let w = pa_rows.col(j);
948                    for l in 0..k {
949                        ex[l] += w[l] * row[j];
950                    }
951                }
952            }
953
954            if nm == 0 {
955                let x: Vec<f64> = (0..k)
956                    .map(|a| tau * (0..k).map(|b| rx_inv[(a, b)] * ex[b]).sum::<f64>())
957                    .collect();
958                let dd: f64 = row.iter().map(|v| v * v).sum();
959                return Ok((x, None, dd));
960            }
961
962            let use_missing = nm < d - nm;
963            let mut g = if use_missing {
964                rx.clone()
965            } else {
966                base.clone()
967            };
968            let sign = if use_missing { -tau } else { tau };
969            for j in 0..d {
970                if m_row[j] == use_missing {
971                    let w = pa_rows.col(j);
972                    for a in 0..k {
973                        for b in 0..k {
974                            g[(a, b)] += sign * w[a] * w[b];
975                        }
976                    }
977                }
978            }
979            let (g_inv, _) = spd_inverse(g.as_ref())?;
980            let x: Vec<f64> = (0..k)
981                .map(|a| tau * (0..k).map(|b| g_inv[(a, b)] * ex[b]).sum::<f64>())
982                .collect();
983
984            let mut quad = 0.0;
985            for j in 0..d {
986                if m_row[j] {
987                    let w = pa_rows.col(j);
988                    row[j] = (0..k).map(|l| w[l] * x[l]).sum();
989                    for a in 0..k {
990                        for b in 0..k {
991                            quad += w[a] * g_inv[(a, b)] * w[b];
992                        }
993                    }
994                }
995            }
996            let dd: f64 = row.iter().map(|v| v * v).sum();
997            Ok((x, Some(g_inv), dd + nm as f64 / tau + quad))
998        })
999        .collect::<Result<_, CoreErrors>>()?;
1000
1001    let scores = Mat::from_fn(n, k, |i, l| rows[i].0[l]);
1002    let tr_s = rows.iter().map(|r| r.2).sum::<f64>() / nf;
1003
1004    let mut t = gemm(
1005        MatRef::from_row_major_slice(dy, n, d).transpose(),
1006        scores.as_ref(),
1007    );
1008
1009    // T[j, ] += pa_j^T sum_{i: y_ij missing} Rxinv_i
1010    let incomplete: Vec<usize> = (0..n).filter(|&i| prep.n_miss_row[i] > 0).collect();
1011    if !incomplete.is_empty() {
1012        let mut corr = vec![0.0; d * k];
1013        corr.par_chunks_mut(k).enumerate().for_each(|(j, out)| {
1014            let w = pa_rows.col(j);
1015            for &i in &incomplete {
1016                if prep.missing[i * d + j] {
1017                    let g_inv = rows[i].1.as_ref().expect("incomplete rows carry Rxinv");
1018                    for b in 0..k {
1019                        out[b] += (0..k).map(|a| w[a] * g_inv[(a, b)]).sum::<f64>();
1020                    }
1021                }
1022            }
1023        });
1024        for j in 0..d {
1025            for b in 0..k {
1026                t[(j, b)] += corr[j * k + b];
1027            }
1028        }
1029    }
1030    t *= faer::Scale(1.0 / nf);
1031
1032    // M-step
1033    let alpha_diag = Mat::from_fn(k, k, |a, b| if a == b { state.alpha[a] / nf } else { 0.0 });
1034    let dw = &rx_inv + (t.transpose() * &state.pa * &rx_inv) * tau + alpha_diag;
1035    let dw_inv = dw.partial_piv_lu().inverse();
1036    state.pa = &t * &dw_inv;
1037
1038    let tr_tpa = frob_dot(t.as_ref(), state.pa.as_ref());
1039    state.tau = (df + 2.0 * BPCA_GTAU0 / nf)
1040        / (tr_s - tr_tpa + (mean_sq * BPCA_GMU0 + 2.0 * BPCA_GTAU0 / BPCA_BTAU0) / nf);
1041    state.sig_w = dw_inv * (df / nf);
1042    state.alpha = state
1043        .pa
1044        .col_iter()
1045        .enumerate()
1046        .map(|(l, col)| {
1047            let sq: f64 = col.iter().map(|v| v * v).sum();
1048            (2.0 * BPCA_GALPHA0 + df)
1049                / (state.tau * sq + state.sig_w[(l, l)] + 2.0 * BPCA_GALPHA0 / BPCA_BALPHA0)
1050        })
1051        .collect();
1052
1053    Ok(scores)
1054}
1055
1056/// BPCA loop on prepped data.
1057///
1058/// ### Params
1059///
1060/// * `prep` - Prepped data
1061/// * `params` - BPCA parameters
1062/// * `verbosity` - Progress reporting
1063///
1064/// ### Returns
1065///
1066/// The raw [Fit]. Scores come from the last E-step and loadings from the last
1067/// M-step, as pcaMethods returns them.
1068fn bpca_fit(
1069    prep: &PreppedData,
1070    params: &BpcaParams,
1071    verbosity: Verbosity,
1072) -> Result<Fit, CoreErrors> {
1073    let (n, d, k) = (prep.n, prep.d, params.n_pcs);
1074    let mut state = bpca_init(prep, k)?;
1075
1076    let obs_mean: Vec<f64> = (0..d)
1077        .into_par_iter()
1078        .map(|j| {
1079            let (s, c) = (0..n)
1080                .filter(|&i| !prep.missing[i * d + j])
1081                .fold((0.0, 0usize), |(s, c), i| (s + prep.y[i * d + j], c + 1));
1082            s / c as f64
1083        })
1084        .collect();
1085    let mean_sq: f64 = obs_mean.iter().map(|v| v * v).sum();
1086
1087    let mut dy = prep.y.clone();
1088    dy.par_chunks_mut(d).for_each(|row| {
1089        row.iter_mut().zip(&obs_mean).for_each(|(v, m)| *v -= m);
1090    });
1091
1092    let mut tau_old = BPCA_TAU_START;
1093    let mut scores = Mat::zeros(n, k);
1094    let mut n_iter = 0;
1095    let mut converged = false;
1096
1097    for step in 1..=params.max_iter {
1098        scores = bpca_step(&mut state, &mut dy, prep, mean_sq)?;
1099        n_iter = step;
1100        let check = step % BPCA_CHECK_EVERY == 0;
1101        if verbosity.detailed_verbosity() && !check {
1102            println!("BPCA: step {step}, tau {:.6e}", state.tau);
1103        }
1104        if check {
1105            let dtau = (state.tau.log10() - tau_old.log10()).abs();
1106            if verbosity.normal_verbosity() {
1107                println!(
1108                    "BPCA: step {step}, tau {:.6e}, change in log10(tau) {dtau:.3e}",
1109                    state.tau
1110                );
1111            }
1112            if dtau < params.tol {
1113                converged = true;
1114                break;
1115            }
1116            tau_old = state.tau;
1117        }
1118    }
1119
1120    if verbosity.normal_verbosity() {
1121        let status = if converged {
1122            "converged"
1123        } else {
1124            "hit max_iter"
1125        };
1126        println!(
1127            "BPCA: {status} after {n_iter} steps, noise variance {:.4e}",
1128            1.0 / state.tau
1129        );
1130    }
1131
1132    let r2 = r2_cum(
1133        &prep.y,
1134        Some(&prep.missing),
1135        d,
1136        scores.as_ref(),
1137        state.pa.as_ref(),
1138    );
1139
1140    Ok(Fit {
1141        scores,
1142        loadings: state.pa,
1143        r2_cum: r2,
1144        noise_var: 1.0 / state.tau,
1145        n_iter,
1146        converged,
1147    })
1148}
1149
1150/// Bayesian PCA with missing values.
1151///
1152/// Variational Bayes with an ARD prior per component, so superfluous
1153/// components shrink towards zero rather than fitting noise. Deterministic:
1154/// the start comes from an SVD. Loadings are not orthonormal.
1155///
1156/// ### Params
1157///
1158/// * `y` - Observations x variables, `NaN` for missing
1159/// * `params` - BPCA parameters; defaults to [BpcaParams::default]
1160/// * `verbosity` - Progress reporting
1161///
1162/// ### Returns
1163///
1164/// The [MissingPcaResults], or an error on invalid input or a singular
1165/// k x k system.
1166///
1167/// ### References
1168///
1169/// Oba et al., Bioinformatics, 2003; Stacklies et al., Bioinformatics, 2007
1170pub fn bpca<T>(
1171    y: MatRef<T>,
1172    params: Option<BpcaParams>,
1173    verbosity: Verbosity,
1174) -> Result<MissingPcaResults<T>, CoreErrors>
1175where
1176    T: BixverseFloat,
1177{
1178    let params = params.unwrap_or_default();
1179    let prep = prep_data(y, params.n_pcs, params.centre, params.scale)?;
1180    let fit = bpca_fit(&prep, &params, verbosity)?;
1181    Ok(finish(y, &prep, fit))
1182}
1183
1184///////////
1185// Tests //
1186///////////
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191
1192    /// Rank-`k` signal plus `noise`-scaled Gaussian noise, N x D, with a
1193    /// fraction `miss` of entries set to `NaN` completely at random.
1194    ///
1195    /// Returns the masked matrix and the full one.
1196    fn low_rank(n: usize, d: usize, k: usize, noise: f64, miss: f64) -> (Mat<f64>, Mat<f64>) {
1197        let mut rng = StdRng::seed_from_u64(1);
1198        let normal = Normal::new(0.0, 1.0).unwrap();
1199        let z = Mat::<f64>::from_fn(n, k, |_, _| normal.sample(&mut rng));
1200        let w = Mat::<f64>::from_fn(d, k, |_, _| normal.sample(&mut rng));
1201        let signal = &z * w.transpose();
1202        let full = Mat::from_fn(n, d, |i, j| {
1203            signal[(i, j)] + noise * normal.sample(&mut rng)
1204        });
1205        let masked = Mat::from_fn(n, d, |i, j| {
1206            if rng.random::<f64>() < miss {
1207                f64::NAN
1208            } else {
1209                full[(i, j)]
1210            }
1211        });
1212        (masked, full)
1213    }
1214
1215    /// Frobenius distance between the projectors onto the column spaces of
1216    /// two D x k bases.
1217    fn subspace_gap(a: MatRef<f64>, b: MatRef<f64>) -> f64 {
1218        let qa = a.qr().compute_thin_Q();
1219        let qb = b.qr().compute_thin_Q();
1220        let diff = &qa * qa.transpose() - &qb * qb.transpose();
1221        frob_dot(diff.as_ref(), diff.as_ref()).sqrt()
1222    }
1223
1224    /// Top-k right singular vectors of the column-centred matrix.
1225    fn svd_basis(y: MatRef<f64>, k: usize) -> Mat<f64> {
1226        let (n, d) = y.shape();
1227        let means: Vec<f64> = (0..d)
1228            .map(|j| (0..n).map(|i| y[(i, j)]).sum::<f64>() / n as f64)
1229            .collect();
1230        let yc = Mat::from_fn(n, d, |i, j| y[(i, j)] - means[j]);
1231        let svd = yc.thin_svd().unwrap();
1232        svd.V().subcols(0, k).to_owned()
1233    }
1234
1235    /// With nothing missing, both fits recover the PCA subspace.
1236    #[test]
1237    fn test_complete_data_matches_svd_subspace() {
1238        let (_, full) = low_rank(80, 15, 3, 0.1, 0.0);
1239        let basis = svd_basis(full.as_ref(), 3);
1240
1241        let params = PpcaParams {
1242            n_pcs: 3,
1243            ..PpcaParams::default()
1244        };
1245        let pp = ppca(full.as_ref(), Some(params), Verbosity::Quiet).unwrap();
1246        assert!(pp.converged);
1247        assert!(subspace_gap(pp.loadings.as_ref(), basis.as_ref()) < 1e-4);
1248
1249        let params = BpcaParams {
1250            n_pcs: 3,
1251            ..BpcaParams::default()
1252        };
1253        let bp = bpca(full.as_ref(), Some(params), Verbosity::Quiet).unwrap();
1254        assert!(subspace_gap(bp.loadings.as_ref(), basis.as_ref()) < 1e-4);
1255    }
1256
1257    /// Same seed, same fit; the completed matrix leaves observed entries alone.
1258    #[test]
1259    fn test_ppca_seed_reproducible_and_observed_untouched() {
1260        let (masked, _) = low_rank(50, 10, 2, 0.2, 0.2);
1261        let a = ppca(masked.as_ref(), None, Verbosity::Quiet).unwrap();
1262        let b = ppca(masked.as_ref(), None, Verbosity::Quiet).unwrap();
1263        assert_eq!(a.n_iter, b.n_iter);
1264        for i in 0..50 {
1265            for j in 0..10 {
1266                assert_eq!(a.completed[(i, j)], b.completed[(i, j)]);
1267                if !masked[(i, j)].is_nan() {
1268                    assert_eq!(a.completed[(i, j)], masked[(i, j)]);
1269                }
1270            }
1271        }
1272    }
1273
1274    /// Low-rank data with 30 % MCAR: imputation error sits near the noise
1275    /// level, far below the column spread, for both fits and with scaling on.
1276    #[test]
1277    fn test_imputation_recovers_low_rank_signal() {
1278        let (masked, full) = low_rank(120, 30, 3, 0.1, 0.3);
1279        let rmse = |c: &Mat<f64>| {
1280            let (mut s, mut m) = (0.0, 0usize);
1281            for i in 0..120 {
1282                for j in 0..30 {
1283                    if masked[(i, j)].is_nan() {
1284                        s += (c[(i, j)] - full[(i, j)]).powi(2);
1285                        m += 1;
1286                    }
1287                }
1288            }
1289            (s / m as f64).sqrt()
1290        };
1291
1292        for scale in [false, true] {
1293            let pp = ppca(
1294                masked.as_ref(),
1295                Some(PpcaParams {
1296                    n_pcs: 3,
1297                    scale,
1298                    ..PpcaParams::default()
1299                }),
1300                Verbosity::Quiet,
1301            )
1302            .unwrap();
1303            let bp = bpca(
1304                masked.as_ref(),
1305                Some(BpcaParams {
1306                    n_pcs: 3,
1307                    scale,
1308                    ..BpcaParams::default()
1309                }),
1310                Verbosity::Quiet,
1311            )
1312            .unwrap();
1313            // the signal has per-entry SD ~ sqrt(3); noise SD is 0.1
1314            assert!(
1315                rmse(&pp.completed) < 0.3,
1316                "ppca scale={scale}: {}",
1317                rmse(&pp.completed)
1318            );
1319            assert!(
1320                rmse(&bp.completed) < 0.3,
1321                "bpca scale={scale}: {}",
1322                rmse(&bp.completed)
1323            );
1324            assert!(pp.r2_cum.windows(2).all(|w| w[0] <= w[1]));
1325        }
1326    }
1327
1328    /// The f32 entry point agrees with the f64 one: the fit itself runs in f64.
1329    #[test]
1330    fn test_f32_input_matches_f64() {
1331        let (masked, _) = low_rank(40, 8, 2, 0.2, 0.2);
1332        let masked32 = Mat::<f32>::from_fn(40, 8, |i, j| masked[(i, j)] as f32);
1333        let a = bpca(masked.as_ref(), None, Verbosity::Quiet).unwrap();
1334        let b = bpca(masked32.as_ref(), None, Verbosity::Quiet).unwrap();
1335        for i in 0..40 {
1336            for j in 0..8 {
1337                assert!((a.completed[(i, j)] - b.completed[(i, j)] as f64).abs() < 1e-3);
1338            }
1339        }
1340    }
1341
1342    /// Unusable inputs error rather than panic or return NaN.
1343    #[test]
1344    fn test_invalid_inputs_error() {
1345        let (mut masked, _) = low_rank(20, 6, 2, 0.1, 0.0);
1346        let too_many = PpcaParams {
1347            n_pcs: 7,
1348            ..PpcaParams::default()
1349        };
1350        assert!(matches!(
1351            ppca(masked.as_ref(), Some(too_many), Verbosity::Quiet),
1352            Err(CoreErrors::PcaTooManyComponents { n_pcs: 7, max: 6 })
1353        ));
1354
1355        for i in 0..20 {
1356            masked[(i, 4)] = f64::NAN;
1357        }
1358        assert!(matches!(
1359            bpca(masked.as_ref(), None, Verbosity::Quiet),
1360            Err(CoreErrors::PcaAllMissing {
1361                axis: "column",
1362                index: 4
1363            })
1364        ));
1365
1366        let (mut masked, _) = low_rank(20, 6, 2, 0.1, 0.0);
1367        for j in 0..6 {
1368            masked[(3, j)] = f64::NAN;
1369        }
1370        assert!(matches!(
1371            ppca(masked.as_ref(), None, Verbosity::Quiet),
1372            Err(CoreErrors::PcaAllMissing {
1373                axis: "row",
1374                index: 3
1375            })
1376        ));
1377
1378        let c0 = Mat::<f64>::zeros(5, 2);
1379        let (full, _) = low_rank(20, 6, 2, 0.1, 0.0);
1380        assert!(matches!(
1381            ppca_from_init(full.as_ref(), c0.as_ref(), None, Verbosity::Quiet),
1382            Err(CoreErrors::InvalidArgument(_))
1383        ));
1384    }
1385}