Skip to main content

edge_rs/sc/
pml.rs

1//! NEBULA's penalised maximum likelihood inner solver.
2//!
3//! Given fixed variance components, profiles out the fixed and subject random
4//! effects jointly. The parameters are `(beta, log_w)`: `n_beta` fixed effects
5//! and one random effect per subject. The `(n_beta + k)` Hessian is never
6//! formed. The random-effect block is diagonal (independent subjects,
7//! contiguous cells), so a Schur complement reduces each Newton step to an
8//! `n_beta` system plus `k` divisions.
9//!
10//! * [`opt_pml`]: gamma prior on `w` (NBGMM). The penalty is
11//!   `alpha log w - lambda w` with `alpha`, `lambda` derived from `sigma[0]`;
12//!   `ord` extends the Laplace approximation past its leading term.
13//! * [`opt_pml_nbm`]: normal prior on `log w` (NBLMM). The penalty is
14//!   `-log_w'log_w / (2 alpha) - (k/2) log alpha`; no higher-order correction.
15//!
16//! ### Layout
17//!
18//! The design is row-major `n_cells * n_beta`. Counts are the positive entries
19//! with their cell indices, and subjects are half-open `[start, end)` blocks.
20//!
21//! ### Parallelism
22//!
23//! Sequential by design: one call is one gene, and the caller parallelises over
24//! genes. The outer loop also stops on an absolute improvement in the
25//! log-likelihood, so a reassociating reduction can flip the last iteration and
26//! move `beta` by more than the reassociation itself.
27//!
28//! ### Deviations from edgePython
29//!
30//! Ported from `nebula`'s `src/optimization.cpp`, not edgePython's
31//! `_opt_pml_nb`, which adds a ridge, a `vw` floor, a linear-predictor clamp
32//! and drops REML. See `UPSTREAM_DEVIATIONS.md` A21.
33//!
34//! ### References
35//!
36//! He et al., Communications Biology 4, 629, 2021
37
38#![allow(clippy::needless_range_loop)]
39
40use crate::errors::EdgeErrors;
41use crate::sc::zeros::ZeroCells;
42
43//////////////////////
44// Tuning and codes //
45//////////////////////
46
47/// Newton iteration budget, matching nebula's hard-coded `maxstep`.
48///
49/// nebula flags a fit that reaches this as `-20`, so the value is fixed.
50const DEFAULT_MAX_ITER: usize = 50;
51
52/// Backtracking budget inside one Newton step, nebula's `maxstd`.
53///
54/// Exceeding it is what produces the `-10` and `-40` convergence codes.
55const DEFAULT_MAX_BACKTRACK: usize = 10;
56
57/// Default stopping tolerance on the improvement in the penalised
58/// log-likelihood, nebula's `eps`.
59const DEFAULT_EPS: f64 = 1e-6;
60
61/// Largest gradient component nebula will still call a critical point.
62///
63/// Reached only when backtracking has been exhausted: a stalled step with every
64/// gradient below this is `-10`, with any gradient above it `-40`.
65const GRADIENT_TOLERANCE: f64 = 0.01;
66
67/// Magnitude past which a Newton component is treated as unbounded rather than
68/// damped.
69///
70/// nebula replaces such a step with a fixed displacement, seeded by the same
71/// constant and halved once per backtrack, instead of halving it forty times.
72const STEP_CUTOFF: f64 = 40.0;
73
74/// Smallest eigenvalue of the information matrix nebula will invert.
75///
76/// Below it the fixed effects are not identifiable for this gene and the fit is
77/// reported as `-25` with no standard errors.
78const EIGENVALUE_CUTOFF: f64 = 1e-8;
79
80/// Jacobi sweep budget for the smallest-eigenvalue check.
81///
82/// Cyclic Jacobi needs three or four sweeps on these small matrices; thirty is
83/// slack.
84const JACOBI_SWEEPS: usize = 30;
85
86/// Converged, by a sufficiently small improvement in the objective.
87pub const CONV_SUCCESS: i32 = 1;
88
89/// Converged at a critical point: no step improved the objective, but every
90/// gradient component is below nebula's `convd`, 0.01.
91pub const CONV_CRITICAL_POINT: i32 = -10;
92
93/// The Newton iteration budget was exhausted.
94pub const CONV_MAX_ITER: i32 = -20;
95
96/// The information matrix is numerically singular, so no standard errors.
97pub const CONV_SINGULAR: i32 = -25;
98
99/// The penalised log-likelihood was not a number at the reported optimum.
100pub const CONV_NOT_FINITE: i32 = -30;
101
102/// Backtracking stalled with a gradient still above nebula's `convd`, 0.01.
103pub const CONV_STALLED: i32 = -40;
104
105/// A variance component sat on its box constraint.
106///
107/// Not detectable from inside this module; pass `variance_at_bound` to
108/// [`check_convergence`] to raise it.
109pub const CONV_AT_BOUND: i32 = -60;
110
111/////////////
112// PmlData //
113/////////////
114
115/// One gene's data, in the layout NEBULA's C++ expects.
116///
117/// Cells must already be grouped by subject, which is what makes
118/// `subject_start` a valid block partition. Only the positive counts are
119/// stored: `counts[i]` is the count in cell `cell_index[i]`.
120#[derive(Clone, Copy, Debug)]
121pub struct PmlData<'a> {
122    /// Design matrix, row-major `n_cells * n_beta`.
123    pub design: &'a [f64],
124    /// Log offset per cell, length `n_cells`.
125    pub offset: &'a [f64],
126    /// The positive counts, in increasing cell order.
127    pub counts: &'a [f64],
128    /// Cell index of each entry of `counts`, zero-based and increasing.
129    pub cell_index: &'a [usize],
130    /// Start of each subject's block plus a trailing `n_cells`, length `k + 1`.
131    pub subject_start: &'a [usize],
132    /// Total count per subject, length `k`. nebula's `cumsumy`.
133    pub subject_total: &'a [f64],
134}
135
136impl PmlData<'_> {
137    /// Number of cells.
138    ///
139    /// ### Returns
140    ///
141    /// The length of `offset`, which defines the cell axis.
142    pub fn n_cells(&self) -> usize {
143        self.offset.len()
144    }
145
146    /// Number of subjects.
147    ///
148    /// ### Returns
149    ///
150    /// One fewer than the length of `subject_start`, or zero if that is empty.
151    pub fn n_subjects(&self) -> usize {
152        self.subject_start.len().saturating_sub(1)
153    }
154
155    /// Number of fixed effects.
156    ///
157    /// ### Returns
158    ///
159    /// The design width implied by `design.len()` and `n_cells`, or zero when
160    /// there are no cells.
161    pub fn n_beta(&self) -> usize {
162        let n = self.n_cells();
163        self.design.len().checked_div(n).unwrap_or(0)
164    }
165
166    /// Checks every invariant the inner loops assume.
167    ///
168    /// ### Returns
169    ///
170    /// `Ok(())`, or the first violation found.
171    fn validate(&self) -> Result<(), EdgeErrors> {
172        let n_cells = self.n_cells();
173        if n_cells == 0 {
174            return Err(EdgeErrors::MustBePositive("n_cells".to_string()));
175        }
176        let k = self.n_subjects();
177        if k == 0 {
178            return Err(EdgeErrors::MustBePositive("n_subjects".to_string()));
179        }
180        let n_beta = self.n_beta();
181        if n_beta == 0 || n_beta * n_cells != self.design.len() {
182            return Err(EdgeErrors::LengthMismatch {
183                name: "design",
184                expected: n_cells * n_beta.max(1),
185                got: self.design.len(),
186            });
187        }
188        if self.counts.len() != self.cell_index.len() {
189            return Err(EdgeErrors::LengthMismatch {
190                name: "cell_index",
191                expected: self.counts.len(),
192                got: self.cell_index.len(),
193            });
194        }
195        if self.subject_total.len() != k {
196            return Err(EdgeErrors::LengthMismatch {
197                name: "subject_total",
198                expected: k,
199                got: self.subject_total.len(),
200            });
201        }
202        if self.subject_start[0] != 0 || self.subject_start[k] != n_cells {
203            return Err(EdgeErrors::InvalidArgument(format!(
204                "subject_start must run from 0 to {n_cells}; got {} to {}.",
205                self.subject_start[0], self.subject_start[k]
206            )));
207        }
208        for s in 0..k {
209            if self.subject_start[s] >= self.subject_start[s + 1] {
210                return Err(EdgeErrors::SubjectsNotContiguous { subject: s });
211            }
212        }
213        let mut previous: Option<usize> = None;
214        for &c in self.cell_index {
215            if c >= n_cells {
216                return Err(EdgeErrors::InvalidArgument(format!(
217                    "cell_index {c} is out of range for {n_cells} cells."
218                )));
219            }
220            if previous.is_some_and(|p| c <= p) {
221                return Err(EdgeErrors::InvalidArgument(
222                    "cell_index must be strictly increasing.".to_string(),
223                ));
224            }
225            previous = Some(c);
226        }
227        Ok(())
228    }
229}
230
231/////////////////
232// PmlVariance //
233/////////////////
234
235/// The two variance components, held on nebula's own scale.
236///
237/// `subject` means different things in the two solvers: the C++ reads the same
238/// `sigma[0]` slot differently in `opt_pml` and `opt_pml_nbm`.
239#[derive(Clone, Copy, Debug)]
240pub struct PmlVariance {
241    /// nebula's `sigma[0]`.
242    ///
243    /// For [`opt_pml`] this is `log(1 + sigma^2)`, the log of one plus the
244    /// squared coefficient of variation of the subject-level gamma random
245    /// effect; the solver derives `alpha = 1 / (e^s - 1)` and
246    /// `lambda = 1 / (sqrt(e^s) (e^s - 1))` from it. For [`opt_pml_nbm`] it is
247    /// the variance of the normal random effect on the log scale, used as is.
248    pub subject: f64,
249    /// nebula's `sigma[1]`, its `gamma`: the cell-level negative binomial size,
250    /// the reciprocal of the cell-level overdispersion.
251    pub cell: f64,
252}
253
254///////////////
255// PmlParams //
256///////////////
257
258/// Tuning knobs shared by both solvers.
259#[derive(Clone, Copy, Debug)]
260pub struct PmlParams {
261    /// Add `log|det(information)|` to the log-determinant.
262    ///
263    /// nebula's `reml`: restricted instead of plain maximum likelihood for the
264    /// outer overdispersion estimate. Off by default, as `nebula(..., reml = 0)`.
265    pub reml: bool,
266    /// Stop once a Newton step improves the penalised log-likelihood by less
267    /// than this (absolute, as in nebula).
268    pub eps: f64,
269    /// Order of the Laplace expansion of the marginal likelihood.
270    ///
271    /// `1`: leading term only ([`PmlResult::second_order`] stays zero). `2` adds
272    /// the third-derivative term, `3` the fourth. nebula uses `3` for genes with
273    /// an expected count per subject below three. Ignored by [`opt_pml_nbm`].
274    pub ord: u32,
275    /// Newton iteration budget.
276    pub max_iter: usize,
277    /// Backtracking halvings allowed inside one Newton step.
278    pub max_backtrack: usize,
279}
280
281impl Default for PmlParams {
282    /// nebula's defaults as called from `nebula()`.
283    fn default() -> Self {
284        Self {
285            reml: false,
286            eps: DEFAULT_EPS,
287            ord: 1,
288            max_iter: DEFAULT_MAX_ITER,
289            max_backtrack: DEFAULT_MAX_BACKTRACK,
290        }
291    }
292}
293
294///////////////
295// PmlResult //
296///////////////
297
298/// The fitted penalised maximum likelihood solution for one gene.
299#[derive(Clone, Debug)]
300pub struct PmlResult {
301    /// Fixed effect coefficients.
302    pub beta: Vec<f64>,
303    /// Subject random effects on the log scale.
304    pub log_w: Vec<f64>,
305    /// Observed information for the fixed effects, row-major `nb * nb`.
306    ///
307    /// nebula's `var`: the Schur complement of the joint observed information
308    /// after eliminating the random-effect block. Its inverse is the covariance
309    /// of `beta`.
310    pub information: Vec<f64>,
311    /// Value of the penalised log-likelihood at the optimum.
312    pub log_likelihood: f64,
313    /// Value at the previous Newton iterate, nebula's `loglikp`.
314    ///
315    /// nebula falls back to this when `log_likelihood` is not a number.
316    pub log_likelihood_prev: f64,
317    /// Log-determinant of the observed information.
318    ///
319    /// `sum log|vw|` over subjects, plus `log|det(information)|` when
320    /// [`PmlParams::reml`] is set. The outer objective subtracts half of it.
321    pub log_det: f64,
322    /// Higher-order Laplace correction, nebula's `second`.
323    ///
324    /// Zero unless [`PmlParams::ord`] exceeds one. The outer objective adds
325    /// `log(1 + second)`.
326    pub second_order: f64,
327    /// nebula's convergence code, one of [`CONV_SUCCESS`],
328    /// [`CONV_CRITICAL_POINT`], [`CONV_MAX_ITER`], [`CONV_SINGULAR`],
329    /// [`CONV_NOT_FINITE`] or [`CONV_STALLED`].
330    ///
331    /// [`CONV_AT_BOUND`] is not reachable from here; see [`check_convergence`].
332    pub convergence: i32,
333    /// Newton steps taken.
334    pub iterations: usize,
335    /// Backtracking halvings in the final Newton step, nebula's `damp`. Eleven:
336    /// stalled at a critical point; twelve: stalled with a live gradient.
337    pub backtracks: usize,
338}
339
340/// The penalised log-likelihood and its gradient at a given point.
341///
342/// Both are negated, as in nebula's `pml_ll_der_eigen`, for direct use by a
343/// minimiser.
344#[derive(Clone, Debug)]
345pub struct PmlObjective {
346    /// Negative penalised log-likelihood.
347    pub objective: f64,
348    /// Negative gradient: `n_beta` fixed-effect entries followed by
349    /// `n_subjects` random-effect entries.
350    pub gradient: Vec<f64>,
351}
352
353////////////////////
354// Random effects //
355////////////////////
356
357/// The prior on the subject random effects, with its scalars already resolved.
358#[derive(Clone, Copy, Debug)]
359enum Penalty {
360    /// Gamma prior on `w`, NEBULA's NBGMM.
361    Gamma {
362        /// Shape, `1 / (e^s - 1)`.
363        alpha: f64,
364        /// Rate, `1 / (sqrt(e^s) (e^s - 1))`.
365        lambda: f64,
366    },
367    /// Normal prior on `log w`, NEBULA's NBLMM.
368    LogNormal {
369        /// Variance of the normal, used directly.
370        alpha: f64,
371    },
372}
373
374impl Penalty {
375    /// Adds the prior's contribution to the log-likelihood.
376    ///
377    /// ### Params
378    ///
379    /// * `log_likelihood` - Data contribution accumulated so far
380    /// * `log_w` - Random effects on the log scale
381    /// * `w` - `exp(log_w)`, unused by the log-normal variant
382    ///
383    /// ### Returns
384    ///
385    /// The penalised log-likelihood.
386    fn accumulate(&self, log_likelihood: f64, log_w: &[f64], w: &[f64]) -> f64 {
387        match *self {
388            Penalty::Gamma { alpha, lambda } => {
389                let mut sum_log_w = 0.0;
390                for &l in log_w {
391                    sum_log_w += l;
392                }
393                let mut sum_w = 0.0;
394                for &x in w {
395                    sum_w += x;
396                }
397                log_likelihood + (alpha * sum_log_w - lambda * sum_w)
398            }
399            Penalty::LogNormal { alpha } => {
400                let mut quad = 0.0;
401                for &l in log_w {
402                    quad += l * l;
403                }
404                log_likelihood - quad / alpha / 2.0 - (log_w.len() as f64) / 2.0 * alpha.ln()
405            }
406        }
407    }
408
409    /// The prior's contribution to the random-effect gradient.
410    ///
411    /// ### Params
412    ///
413    /// * `log_w_s` - Random effect for this subject
414    /// * `w_s` - `exp(log_w_s)`
415    ///
416    /// ### Returns
417    ///
418    /// The term added to `cumsumy - sum(gstar_extb_phi)` for this subject.
419    fn gradient(&self, log_w_s: f64, w_s: f64) -> f64 {
420        match *self {
421            Penalty::Gamma { alpha, lambda } => alpha - lambda * w_s,
422            Penalty::LogNormal { alpha } => -log_w_s / alpha,
423        }
424    }
425
426    /// The prior's contribution to the random-effect curvature.
427    ///
428    /// ### Params
429    ///
430    /// * `w_s` - `exp(log_w_s)`
431    ///
432    /// ### Returns
433    ///
434    /// The term added to the data curvature for this subject.
435    fn curvature(&self, w_s: f64) -> f64 {
436        match *self {
437            Penalty::Gamma { lambda, .. } => lambda * w_s,
438            Penalty::LogNormal { alpha } => 1.0 / alpha,
439        }
440    }
441}
442
443///////////////
444// Front end //
445///////////////
446
447/// Penalised maximum likelihood fit with a gamma random effect, NEBULA's NBGMM.
448///
449/// Joint Newton iteration over `beta` and `log_w` (Schur complement,
450/// per-coordinate backtracking, Laplace corrections per [`PmlParams::ord`]).
451///
452/// `w` has a `Gamma(alpha, lambda)` prior with `alpha = 1 / (e^s - 1)` and
453/// `lambda = 1 / (sqrt(e^s) (e^s - 1))`, `s` being [`PmlVariance::subject`]: mean
454/// `sqrt(e^s)`, squared coefficient of variation `e^s - 1`.
455///
456/// ### Params
457///
458/// * `data` - One gene's design, offsets, positive counts and subject blocks
459/// * `beta_init` - Starting fixed effects, length `n_beta`. `log_w` always
460///   starts at zero, as in the C++
461/// * `variance` - The two variance components, held fixed throughout
462/// * `params` - Tuning knobs, or `None` for nebula's defaults
463///
464/// ### Returns
465///
466/// The fitted coefficients, random effects, observed information and
467/// convergence diagnostics.
468///
469/// ### References
470///
471/// He et al., Communications Biology 4, 629, 2021
472pub fn opt_pml(
473    data: &PmlData<'_>,
474    beta_init: &[f64],
475    variance: &PmlVariance,
476    params: Option<PmlParams>,
477) -> Result<PmlResult, EdgeErrors> {
478    opt_pml_tabled(data, None, beta_init, variance, params)
479}
480
481/// [`opt_pml`], with the zero counts summed from the run's tables.
482///
483/// ### Params
484///
485/// * `data` - One gene's design, offsets, positive counts and subject blocks
486/// * `zeros` - The tables built from this `data`'s design, offsets and
487///   subjects, or `None` to sweep every cell
488/// * `beta_init` - Starting fixed effects, length `n_beta`
489/// * `variance` - The two variance components, held fixed throughout
490/// * `params` - Tuning knobs, or `None` for nebula's defaults
491///
492/// ### Returns
493///
494/// As [`opt_pml`].
495pub(crate) fn opt_pml_tabled(
496    data: &PmlData<'_>,
497    zeros: Option<&ZeroCells>,
498    beta_init: &[f64],
499    variance: &PmlVariance,
500    params: Option<PmlParams>,
501) -> Result<PmlResult, EdgeErrors> {
502    let exps = variance.subject.exp();
503    if !(exps.is_finite() && exps > 1.0) {
504        return Err(EdgeErrors::InvalidArgument(format!(
505            "PmlVariance::subject must be finite and strictly positive for opt_pml; got {}.",
506            variance.subject
507        )));
508    }
509    let penalty = Penalty::Gamma {
510        alpha: 1.0 / (exps - 1.0),
511        lambda: 1.0 / (exps.sqrt() * (exps - 1.0)),
512    };
513    optimise(
514        data,
515        zeros,
516        beta_init,
517        None,
518        penalty,
519        variance.cell,
520        params.unwrap_or_default(),
521    )
522}
523
524/// Penalised maximum likelihood fit with a log-normal random effect, NEBULA's
525/// NBLMM.
526///
527/// The [`opt_pml`] loop with penalty `-log_w'log_w / (2 alpha) - (k/2) log alpha`,
528/// `alpha` being [`PmlVariance::subject`] read as a variance.
529/// [`PmlResult::second_order`] is always zero and [`PmlParams::ord`] is ignored.
530///
531/// ### Params
532///
533/// * `data` - One gene's design, offsets, positive counts and subject blocks
534/// * `beta_init` - Starting fixed effects, length `n_beta`
535/// * `variance` - The two variance components, held fixed throughout
536/// * `params` - Tuning knobs, or `None` for nebula's defaults
537///
538/// ### Returns
539///
540/// The fitted coefficients, random effects, observed information and
541/// convergence diagnostics.
542pub fn opt_pml_nbm(
543    data: &PmlData<'_>,
544    beta_init: &[f64],
545    variance: &PmlVariance,
546    params: Option<PmlParams>,
547) -> Result<PmlResult, EdgeErrors> {
548    if !(variance.subject.is_finite() && variance.subject > 0.0) {
549        return Err(EdgeErrors::InvalidArgument(format!(
550            "PmlVariance::subject must be finite and strictly positive for opt_pml_nbm; got {}.",
551            variance.subject
552        )));
553    }
554    let penalty = Penalty::LogNormal {
555        alpha: variance.subject,
556    };
557    optimise(
558        data,
559        None,
560        beta_init,
561        None,
562        penalty,
563        variance.cell,
564        params.unwrap_or_default(),
565    )
566}
567
568/// Penalised log-likelihood and gradient at a point, nebula's
569/// `pml_ll_der_eigen`.
570///
571/// The entry the R side hands to `lbfgs` and `trust`. It reads
572/// [`PmlVariance::subject`] as the shape of a mean-one `Gamma(alpha, alpha)`
573/// prior (shape and rate both `alpha`), which is nebula's own inconsistency with
574/// [`opt_pml`], reproduced.
575///
576/// ### Params
577///
578/// * `data` - One gene's design, offsets, positive counts and subject blocks
579/// * `beta` - Fixed effects, length `n_beta`
580/// * `log_w` - Random effects on the log scale, length `n_subjects`
581/// * `variance` - The two variance components
582///
583/// ### Returns
584///
585/// The negated objective and the negated gradient, laid out as `n_beta` fixed
586/// effect entries followed by `n_subjects` random effect entries.
587pub fn pml_log_likelihood_gradient(
588    data: &PmlData<'_>,
589    beta: &[f64],
590    log_w: &[f64],
591    variance: &PmlVariance,
592) -> Result<PmlObjective, EdgeErrors> {
593    data.validate()?;
594    let n_beta = data.n_beta();
595    let k = data.n_subjects();
596    if beta.len() != n_beta {
597        return Err(EdgeErrors::LengthMismatch {
598            name: "beta",
599            expected: n_beta,
600            got: beta.len(),
601        });
602    }
603    if log_w.len() != k {
604        return Err(EdgeErrors::LengthMismatch {
605            name: "log_w",
606            expected: k,
607            got: log_w.len(),
608        });
609    }
610
611    let alpha = variance.subject;
612    let gamma = variance.cell;
613    // Both the shape and the rate: `alpha*sum(log w) - alpha*sum(w)`.
614    let penalty = Penalty::Gamma {
615        alpha,
616        lambda: alpha,
617    };
618
619    let mut work = Workspace::new(data, None);
620    work.sweep_current(data, beta, log_w, gamma, penalty);
621
622    let mut gradient = Vec::with_capacity(n_beta + k);
623    gradient.extend(work.current.db.iter().map(|v| -v));
624    gradient.extend(work.current.dw.iter().map(|v| -v));
625    Ok(PmlObjective {
626        objective: -work.current.log_likelihood,
627        gradient,
628    })
629}
630
631/// [`opt_pml`], started from a given point rather than from zero random effects.
632///
633/// The GPU path finds the optimum in `f32` and finishes it here in `f64`. From a
634/// point at the optimum this takes one Newton step (about four passes over the
635/// cells against fourteen for a cold fit) and returns what [`opt_pml`] returns,
636/// including the log-determinant at the penultimate iterate.
637///
638/// ### Params
639///
640/// * `data` - One gene's design, offsets, positive counts and subject blocks
641/// * `beta_init` - Starting fixed effects, length `n_beta`
642/// * `log_w_init` - Starting random effects on the log scale, length
643///   `n_subjects`
644/// * `variance` - The two variance components, read as [`opt_pml`] reads them
645/// * `params` - Tuning knobs, or `None` for nebula's defaults
646///
647/// ### Returns
648///
649/// As [`opt_pml`].
650#[cfg(feature = "gpu")]
651pub(crate) fn opt_pml_from(
652    data: &PmlData<'_>,
653    beta_init: &[f64],
654    log_w_init: &[f64],
655    variance: &PmlVariance,
656    params: Option<PmlParams>,
657) -> Result<PmlResult, EdgeErrors> {
658    let exps = variance.subject.exp();
659    if !(exps.is_finite() && exps > 1.0) {
660        return Err(EdgeErrors::InvalidArgument(format!(
661            "PmlVariance::subject must be finite and strictly positive for opt_pml; got {}.",
662            variance.subject
663        )));
664    }
665    let penalty = Penalty::Gamma {
666        alpha: 1.0 / (exps - 1.0),
667        lambda: 1.0 / (exps.sqrt() * (exps - 1.0)),
668    };
669    optimise(
670        data,
671        None,
672        beta_init,
673        Some(log_w_init),
674        penalty,
675        variance.cell,
676        params.unwrap_or_default(),
677    )
678}
679
680/// Largest move in a cell's linear predictor for which [`newton_finish`]
681/// updates the stored `exp` by series instead of taking it again.
682///
683/// The second sweep is one converged Newton step from the first, so
684/// `exp(eta + z) = exp(eta) exp(z)` with `z` of order `1e-4`. The fifth-order
685/// truncation `z^6 / 720` is `1.4e-21` relative at this bound, under an ulp.
686#[cfg(feature = "gpu")]
687const EXP_SERIES_MAX: f64 = 1e-3;
688
689/// Relative fall in the objective that [`newton_finish`] still reads as
690/// rounding at a converged point rather than a step that went wrong.
691///
692/// From the device's `f32` optimum the `f64` step gains less than the rounding
693/// of a sum over all cells, so `likdif` is a few ulp negative on about 15 per
694/// cent of fits (S1: 10804 of 73650, between `1e-16` and `1e-15` relative).
695/// [`opt_pml_from`] would backtrack to the same point. A real overshoot loses
696/// orders of magnitude more.
697#[cfg(feature = "gpu")]
698const LIKDIF_ROUNDING: f64 = 1e-12;
699
700/// `exp(z)` to fifth order, for `|z|` within [`EXP_SERIES_MAX`].
701///
702/// ### Params
703///
704/// * `z` - The exponent
705///
706/// ### Returns
707///
708/// `exp(z)`, to well under an ulp.
709#[cfg(feature = "gpu")]
710#[inline(always)]
711fn exp_series(z: f64) -> f64 {
712    1.0 + z * (1.0 + z * (0.5 + z * (1.0 / 6.0 + z * (1.0 / 24.0 + z * (1.0 / 120.0)))))
713}
714
715/// Terms multiplied together before [`LogSum`] takes one logarithm.
716///
717/// Each term is `extb + gamma`, far inside `1e-38..1e38`, so eight cannot leave
718/// the `f64` range. An overflowing product gives an infinite sum, as the
719/// per-term logarithm would.
720#[cfg(feature = "gpu")]
721const LOG_PRODUCT_GROUP: u32 = 8;
722
723/// `sum ln(t)` taken as `ln` of running products, one logarithm per
724/// [`LOG_PRODUCT_GROUP`] terms.
725///
726/// The logarithm is the dearest operation in the dense sweep of
727/// [`newton_finish`]. Group rounding is `1e-15` absolute on the logarithm, the
728/// same as the per-term form.
729#[cfg(feature = "gpu")]
730struct LogSum {
731    /// Logarithms of the finished groups.
732    sum: f64,
733    /// Product of the group in flight.
734    product: f64,
735    /// Terms in the group in flight.
736    count: u32,
737}
738
739#[cfg(feature = "gpu")]
740impl LogSum {
741    /// An empty sum.
742    fn new() -> Self {
743        Self {
744            sum: 0.0,
745            product: 1.0,
746            count: 0,
747        }
748    }
749
750    /// Adds `ln(t)`.
751    ///
752    /// ### Params
753    ///
754    /// * `t` - The term, strictly positive
755    #[inline(always)]
756    fn push(&mut self, t: f64) {
757        self.product *= t;
758        self.count += 1;
759        if self.count == LOG_PRODUCT_GROUP {
760            self.sum += self.product.ln();
761            self.product = 1.0;
762            self.count = 0;
763        }
764    }
765
766    /// The sum, with the group in flight folded in.
767    ///
768    /// ### Returns
769    ///
770    /// `sum ln(t)` over everything pushed.
771    fn finish(self) -> f64 {
772        self.sum + self.product.ln()
773    }
774}
775
776/// The device's curvature at, or one converged step behind, the point
777/// [`newton_finish`] starts from.
778#[cfg(feature = "gpu")]
779#[derive(Clone, Copy, Debug)]
780pub(crate) struct DeviceCurvature<'a> {
781    /// Curvature of each random effect, `vw`, length `n_subjects`.
782    pub(crate) subject: &'a [f64],
783    /// Cross block, `vwb`, row-major `n_subjects * n_beta`.
784    pub(crate) cross: &'a [f64],
785    /// Schur complement, row-major `n_beta * n_beta`.
786    pub(crate) schur: &'a [f64],
787}
788
789/// What [`newton_finish`] brings back: the three scalars the profile objective
790/// reads off an order-one inner fit.
791#[cfg(feature = "gpu")]
792#[derive(Clone, Copy, Debug)]
793pub(crate) struct NewtonFinish {
794    /// Penalised log-likelihood after the step.
795    pub(crate) log_likelihood: f64,
796    /// Penalised log-likelihood at the starting point.
797    pub(crate) log_likelihood_prev: f64,
798    /// Log-determinant of the random-effect block at the final iterate.
799    pub(crate) log_det: f64,
800    /// Newton steps taken.
801    pub(crate) iterations: usize,
802}
803
804/// Full `f64` Newton steps from a point already near the optimum, two fused
805/// passes over the cells per step.
806///
807/// The common case of [`opt_pml_from`] on the GPU path: the device point is
808/// converged to `f32`, one step settles it (two or three for a few per cent of
809/// fits) and no step needs damping. [`opt_pml`] allocates
810/// `(5 + n_beta) * n_cells` per call and walks it in a dozen passes; here each
811/// step is two fused passes per subject with nothing stored per cell, split as
812/// the device kernel splits it: a dense sweep over every cell at a count of
813/// zero, then a sparse sweep adding what the positive counts change. Sums
814/// associate differently from [`opt_pml`], so results agree to rounding, not
815/// bitwise.
816///
817/// The log-determinant is taken at the final iterate, not the penultimate one as
818/// in [`opt_pml`]: here the penultimate iterate is the device's `f32` point,
819/// whose location error enters the log-determinant at first order, so the
820/// second pass also accumulates the curvature weight.
821///
822/// ### Params
823///
824/// * `data` - One gene's design, offsets, positive counts and subject blocks
825/// * `beta` - Starting fixed effects, length `n_beta`
826/// * `log_w` - Starting random effects on the log scale, length `n_subjects`
827/// * `variance` - The two variance components, read as [`opt_pml`] reads them
828/// * `eps` - nebula's absolute stopping tolerance
829/// * `max_iter` - Newton budget
830/// * `curvature` - The device's curvature near the starting point. When given,
831///   the first step takes its gradient in `f64` and its Hessian from here, so
832///   its sweep skips the `n_beta^2` cross-products per cell. The Hessian only
833///   steers the step: `f32` curvature and an `f64` gradient land as close as an
834///   exact step from a point already converged to `f32`. Later steps, which a
835///   few per cent of fits need, assemble their own
836///
837/// * `stored` - Scratch the first sweep leaves each cell's `exp` in for the
838///   second; resized here, so one per thread serves every call
839///
840/// ### Returns
841///
842/// The finished scalars, or `None` when a full step worsens the objective by
843/// more than [`LIKDIF_ROUNDING`] or leaves it not finite, or the budget runs
844/// out. Those need the backtracking search and the convergence codes, so the
845/// caller wants [`opt_pml_from`].
846#[cfg(feature = "gpu")]
847#[allow(clippy::too_many_arguments)]
848pub(crate) fn newton_finish(
849    data: &PmlData<'_>,
850    beta: &[f64],
851    log_w: &[f64],
852    variance: &PmlVariance,
853    eps: f64,
854    max_iter: usize,
855    curvature: Option<DeviceCurvature<'_>>,
856    stored: &mut Vec<f64>,
857) -> Option<NewtonFinish> {
858    match data.n_beta() {
859        1 => newton_finish_width::<1>(
860            data, beta, log_w, variance, eps, max_iter, curvature, stored,
861        ),
862        2 => newton_finish_width::<2>(
863            data, beta, log_w, variance, eps, max_iter, curvature, stored,
864        ),
865        3 => newton_finish_width::<3>(
866            data, beta, log_w, variance, eps, max_iter, curvature, stored,
867        ),
868        4 => newton_finish_width::<4>(
869            data, beta, log_w, variance, eps, max_iter, curvature, stored,
870        ),
871        5 => newton_finish_width::<5>(
872            data, beta, log_w, variance, eps, max_iter, curvature, stored,
873        ),
874        6 => newton_finish_width::<6>(
875            data, beta, log_w, variance, eps, max_iter, curvature, stored,
876        ),
877        7 => newton_finish_width::<7>(
878            data, beta, log_w, variance, eps, max_iter, curvature, stored,
879        ),
880        8 => newton_finish_width::<8>(
881            data, beta, log_w, variance, eps, max_iter, curvature, stored,
882        ),
883        // The device never fits a wider design, so nothing reaches this.
884        _ => None,
885    }
886}
887
888/// [`newton_finish`] for one design width.
889///
890/// With the width a constant, the loops over design columns unroll and a
891/// subject's sums sit in `NB`-sized arrays held in registers, instead of heap
892/// accumulators with a store-to-load round trip per cell.
893///
894/// The first sweep takes a subject's `exp` in its own loop, so the accumulating
895/// loop has no call in it and the positive cells read the stored value. Together
896/// with the registers: 15.6 to 12.1 ns per cell per step on a 20000-cell gene;
897/// the split alone is slower.
898#[cfg(feature = "gpu")]
899#[allow(clippy::too_many_arguments)]
900fn newton_finish_width<const NB: usize>(
901    data: &PmlData<'_>,
902    beta: &[f64],
903    log_w: &[f64],
904    variance: &PmlVariance,
905    eps: f64,
906    max_iter: usize,
907    curvature: Option<DeviceCurvature<'_>>,
908    stored: &mut Vec<f64>,
909) -> Option<NewtonFinish> {
910    let nb = NB;
911    let k = data.n_subjects();
912    let gamma = variance.cell;
913    let exps = variance.subject.exp();
914    if beta.len() != nb || log_w.len() != k || !(exps.is_finite() && exps > 1.0) {
915        return None;
916    }
917    if curvature.is_some_and(|c| {
918        c.subject.len() != k || c.cross.len() != k * nb || c.schur.len() != nb * nb
919    }) {
920        return None;
921    }
922    let alpha = 1.0 / (exps - 1.0);
923    let lambda = 1.0 / (exps.sqrt() * (exps - 1.0));
924
925    let run = positive_runs(data.cell_index, data.subject_start);
926
927    let mut beta = beta.to_vec();
928    let mut log_w = log_w.to_vec();
929    let mut new_beta = vec![0.0; nb];
930    let mut new_log_w = vec![0.0; k];
931    let mut db = vec![0.0; nb];
932    let mut dw = vec![0.0; k];
933    let mut vw = vec![0.0; k];
934    let mut vwb = vec![0.0; k * nb];
935    let mut vb = vec![0.0; nb * nb];
936    let mut factor = vec![0.0; nb * nb];
937    let mut step_beta = vec![0.0; nb];
938    let mut perm = vec![0usize; nb];
939    let mut tmp = vec![0.0; nb];
940    // Every entry is written by the first sweep before the second reads it.
941    stored.resize(data.n_cells(), 0.0);
942
943    for iterations in 1..=max_iter {
944        let borrowed = curvature.filter(|_| iterations == 1);
945        let assemble = borrowed.is_none();
946        db.fill(0.0);
947        vb.fill(0.0);
948        let mut log_likelihood_prev = 0.0;
949        for s in 0..k {
950            let w_s = log_w[s].exp();
951            let mut resid = 0.0;
952            let mut weight = 0.0;
953            let mut sum_log = LogSum::new();
954            let mut linear = 0.0;
955            let mut weighted_log = 0.0;
956            let mut score = [0.0; NB];
957            let mut first = [0.0; NB];
958            let mut second = [[0.0; NB]; NB];
959
960            let mut fold = |row: &[f64], d: f64, p: f64| {
961                resid += d;
962                for a in 0..nb {
963                    score[a] += row[a] * d;
964                }
965                if assemble {
966                    weight += p;
967                    for a in 0..nb {
968                        let pa = p * row[a];
969                        first[a] += pa;
970                        for b in a..nb {
971                            second[a][b] += pa * row[b];
972                        }
973                    }
974                }
975            };
976
977            for r in data.subject_start[s]..data.subject_start[s + 1] {
978                let row = &data.design[r * nb..(r + 1) * nb];
979                let mut eta = data.offset[r];
980                for j in 0..nb {
981                    eta += row[j] * beta[j];
982                }
983                stored[r] = (eta + log_w[s]).exp();
984            }
985            for r in data.subject_start[s]..data.subject_start[s + 1] {
986                let row = &data.design[r * nb..(r + 1) * nb];
987                let extb = stored[r];
988                let t = extb + gamma;
989                sum_log.push(t);
990                let inv = 1.0 / t;
991                let u = extb * inv;
992                fold(row, -gamma * u, gamma * u * inv);
993            }
994            for i in run[s]..run[s + 1] {
995                let c = data.cell_index[i];
996                let y = data.counts[i];
997                let row = &data.design[c * nb..(c + 1) * nb];
998                let mut eta = data.offset[c];
999                for j in 0..nb {
1000                    eta += row[j] * beta[j];
1001                }
1002                let extb = stored[c];
1003                let t = extb + gamma;
1004                linear += eta * y;
1005                weighted_log += y * t.ln();
1006                let inv = 1.0 / t;
1007                let u = extb * inv;
1008                fold(row, y * (1.0 - u), y * u * inv);
1009            }
1010
1011            log_likelihood_prev +=
1012                linear + log_w[s] * data.subject_total[s] - gamma * sum_log.finish() - weighted_log
1013                    + (alpha * log_w[s] - lambda * w_s);
1014            dw[s] = resid + (alpha - lambda * w_s);
1015            for a in 0..nb {
1016                db[a] += score[a];
1017            }
1018            if assemble {
1019                vw[s] = gamma * weight + lambda * w_s;
1020                for a in 0..nb {
1021                    vwb[s * nb + a] = gamma * first[a];
1022                    for b in a..nb {
1023                        vb[a * nb + b] += gamma * second[a][b];
1024                    }
1025                }
1026            }
1027        }
1028
1029        // Schur complement and the step, as in `optimise`.
1030        if let Some(device) = borrowed {
1031            vw.copy_from_slice(device.subject);
1032            vwb.copy_from_slice(device.cross);
1033            factor.copy_from_slice(device.schur);
1034        } else {
1035            for a in 0..nb {
1036                for b in a..nb {
1037                    let mut acc = 0.0;
1038                    for s in 0..k {
1039                        acc += vwb[s * nb + a] * vwb[s * nb + b] / vw[s];
1040                    }
1041                    let v = vb[a * nb + b] - acc;
1042                    factor[a * nb + b] = v;
1043                    factor[b * nb + a] = v;
1044                }
1045            }
1046        }
1047        for j in 0..nb {
1048            let mut acc = 0.0;
1049            for s in 0..k {
1050                acc += vwb[s * nb + j] * dw[s] / vw[s];
1051            }
1052            step_beta[j] = db[j] - acc;
1053        }
1054        ldlt_solve(&mut factor, &mut step_beta, nb, &mut perm, &mut tmp);
1055
1056        for j in 0..nb {
1057            new_beta[j] = beta[j] + step_beta[j];
1058        }
1059        let mut log_likelihood = 0.0;
1060        let mut log_det = 0.0;
1061        for s in 0..k {
1062            let mut acc = 0.0;
1063            for j in 0..nb {
1064                acc += vwb[s * nb + j] * step_beta[j];
1065            }
1066            let new_log_w_s = log_w[s] + (dw[s] - acc) / vw[s];
1067            new_log_w[s] = new_log_w_s;
1068            let new_w_s = new_log_w_s.exp();
1069            let step_log_w_s = new_log_w_s - log_w[s];
1070
1071            let mut sum_log = LogSum::new();
1072            let mut linear = 0.0;
1073            let mut weighted_log = 0.0;
1074            let mut weight = 0.0;
1075            for r in data.subject_start[s]..data.subject_start[s + 1] {
1076                let row = &data.design[r * nb..(r + 1) * nb];
1077                let mut z = step_log_w_s;
1078                for j in 0..nb {
1079                    z += row[j] * step_beta[j];
1080                }
1081                let extb = if z.abs() < EXP_SERIES_MAX {
1082                    stored[r] * exp_series(z)
1083                } else {
1084                    let mut eta = data.offset[r];
1085                    for j in 0..nb {
1086                        eta += row[j] * new_beta[j];
1087                    }
1088                    (eta + new_log_w_s).exp()
1089                };
1090                let t = extb + gamma;
1091                sum_log.push(t);
1092                let inv = 1.0 / t;
1093                weight += gamma * extb * inv * inv;
1094            }
1095            for i in run[s]..run[s + 1] {
1096                let c = data.cell_index[i];
1097                let y = data.counts[i];
1098                let row = &data.design[c * nb..(c + 1) * nb];
1099                let mut eta = data.offset[c];
1100                for j in 0..nb {
1101                    eta += row[j] * new_beta[j];
1102                }
1103                let extb = (eta + new_log_w_s).exp();
1104                let t = extb + gamma;
1105                linear += eta * y;
1106                weighted_log += y * t.ln();
1107                let inv = 1.0 / t;
1108                weight += y * extb * inv * inv;
1109            }
1110            log_likelihood += linear + new_log_w_s * data.subject_total[s]
1111                - gamma * sum_log.finish()
1112                - weighted_log
1113                + (alpha * new_log_w_s - lambda * new_w_s);
1114            log_det += (gamma * weight + lambda * new_w_s).abs().ln();
1115        }
1116
1117        let likdif = log_likelihood - log_likelihood_prev;
1118        if likdif.is_nan() || likdif < -LIKDIF_ROUNDING * log_likelihood.abs() {
1119            return None;
1120        }
1121        if likdif <= eps {
1122            return Some(NewtonFinish {
1123                log_likelihood,
1124                log_likelihood_prev,
1125                log_det,
1126                iterations,
1127            });
1128        }
1129        beta.copy_from_slice(&new_beta);
1130        log_w.copy_from_slice(&new_log_w);
1131    }
1132    None
1133}
1134
1135/// nebula's `check_conv`, applied to a finished fit.
1136///
1137/// [`opt_pml`] already runs this with `variance_at_bound` false and an initial
1138/// code of [`CONV_SUCCESS`]. The outer overdispersion loop re-runs it, as only
1139/// it knows whether a variance component hit its bound or an earlier optimiser
1140/// failed. The singular-information test overrides everything, including
1141/// [`CONV_AT_BOUND`] (nebula's ordering).
1142///
1143/// ### Params
1144///
1145/// * `result` - The fit to grade
1146/// * `params` - The knobs it was run with; only `max_iter` is consulted
1147/// * `variance_at_bound` - Whether a variance component sat on its bound
1148/// * `initial` - Code carried in from the outer optimiser, [`CONV_SUCCESS`]
1149///   when that succeeded and something negative when it did not
1150///
1151/// ### Returns
1152///
1153/// The convergence code.
1154pub fn check_convergence(
1155    result: &PmlResult,
1156    params: &PmlParams,
1157    variance_at_bound: bool,
1158    initial: i32,
1159) -> i32 {
1160    let mut code = initial;
1161    if code == CONV_SUCCESS {
1162        code = if variance_at_bound {
1163            CONV_AT_BOUND
1164        } else if result.log_likelihood.is_nan() {
1165            CONV_NOT_FINITE
1166        } else if result.iterations == params.max_iter {
1167            CONV_MAX_ITER
1168        } else if result.backtracks == params.max_backtrack + 1 {
1169            CONV_CRITICAL_POINT
1170        } else if result.backtracks == params.max_backtrack + 2 {
1171            CONV_STALLED
1172        } else {
1173            CONV_SUCCESS
1174        };
1175    }
1176
1177    let n_beta = result.beta.len();
1178    if n_beta > 1 && min_eigenvalue(&result.information, n_beta) < EIGENVALUE_CUTOFF {
1179        code = CONV_SINGULAR;
1180    }
1181    code
1182}
1183
1184///////////////
1185// Workspace //
1186///////////////
1187
1188/// Where each subject's run of positive counts starts.
1189///
1190/// ### Params
1191///
1192/// * `cells` - Cell index of each positive count, increasing
1193/// * `starts` - Subject boundaries, length `k + 1`
1194///
1195/// ### Returns
1196///
1197/// For each boundary, the first positive count at or past it, length `k + 1`.
1198pub(crate) fn positive_runs(cells: &[usize], starts: &[usize]) -> Vec<usize> {
1199    starts
1200        .iter()
1201        .map(|&b| cells.partition_point(|&c| c < b))
1202        .collect()
1203}
1204
1205/// Widest design whose per-subject sums sit in fixed-size arrays, so the loops
1206/// over design columns unroll and the sums stay in registers. Wider designs run
1207/// the same sweep with the width read at run time and the sums on the heap.
1208const MAX_UNROLLED_WIDTH: usize = 8;
1209
1210/// Penalised log-likelihood, gradient and curvature at one point.
1211///
1212/// One [`sweep`] fills all of it, so a trial point that is accepted already
1213/// carries what the next Newton step needs.
1214struct Sweep {
1215    /// Penalised log-likelihood.
1216    log_likelihood: f64,
1217    /// `exp(log_w)`, one per subject.
1218    w: Vec<f64>,
1219    /// Fixed-effect gradient, length `n_beta`.
1220    db: Vec<f64>,
1221    /// Random-effect gradient, length `n_subjects`.
1222    dw: Vec<f64>,
1223    /// Random-effect curvature, length `n_subjects`.
1224    vw: Vec<f64>,
1225    /// Cross block of the information, row-major `n_subjects * n_beta`.
1226    vwb: Vec<f64>,
1227    /// Fixed-effect block of the information, row-major `n_beta * n_beta`.
1228    vb: Vec<f64>,
1229}
1230
1231impl Sweep {
1232    /// Zeroed buffers for one gene's shape.
1233    ///
1234    /// ### Params
1235    ///
1236    /// * `nb` - Number of fixed effects
1237    /// * `k` - Number of subjects
1238    ///
1239    /// ### Returns
1240    ///
1241    /// The empty sweep.
1242    fn new(nb: usize, k: usize) -> Self {
1243        Self {
1244            log_likelihood: 0.0,
1245            w: vec![0.0; k],
1246            db: vec![0.0; nb],
1247            dw: vec![0.0; k],
1248            vw: vec![0.0; k],
1249            vwb: vec![0.0; k * nb],
1250            vb: vec![0.0; nb * nb],
1251        }
1252    }
1253}
1254
1255/// Every buffer the Newton loop needs, allocated once per call.
1256struct Workspace<'z> {
1257    /// The run's zero-count tables, or `None` to sweep every cell.
1258    zeros: Option<&'z ZeroCells>,
1259    /// `exp(offset + design*beta + log_w)` per cell at the last swept point.
1260    /// With tables, only the positive and the loose cells are written.
1261    stored: Vec<f64>,
1262    /// Where each subject's run of positive counts starts, length `k + 1`.
1263    run: Vec<usize>,
1264    /// Per-subject sums for designs wider than [`MAX_UNROLLED_WIDTH`],
1265    /// length `2 * n_beta + n_beta^2`.
1266    wide: Vec<f64>,
1267    /// The sweep at the current iterate.
1268    current: Sweep,
1269    /// The sweep at the trial point.
1270    trial: Sweep,
1271    /// `vw` the last Newton step used, for the log-determinant.
1272    vw_step: Vec<f64>,
1273    /// Schur complement of the information, row-major `n_beta * n_beta`.
1274    vb2: Vec<f64>,
1275    /// Copy of `vb2`, destroyed by each factorisation.
1276    factor: Vec<f64>,
1277    /// Transposition record from the pivoted factorisation, length `n_beta`.
1278    perm: Vec<usize>,
1279    /// Scratch row for the factorisation, length `n_beta`.
1280    tmp: Vec<f64>,
1281    /// Newton step in `beta`, length `n_beta`.
1282    step_beta: Vec<f64>,
1283    /// Newton step in `log_w`, length `n_subjects`.
1284    step_log_w: Vec<f64>,
1285    /// `dw / vw`, length `n_subjects`.
1286    dwvw: Vec<f64>,
1287    /// Per-coordinate damping on `beta`, length `n_beta`.
1288    damp_beta: Vec<f64>,
1289    /// Per-coordinate damping on `log_w`, length `n_subjects`.
1290    damp_log_w: Vec<f64>,
1291    /// Trial fixed effects, length `n_beta`.
1292    new_beta: Vec<f64>,
1293    /// Trial random effects, length `n_subjects`.
1294    new_log_w: Vec<f64>,
1295}
1296
1297impl<'z> Workspace<'z> {
1298    /// Allocates every buffer to the shape implied by `data`.
1299    ///
1300    /// ### Params
1301    ///
1302    /// * `data` - The gene whose shape sets the buffer sizes
1303    /// * `zeros` - The run's zero-count tables, or `None`
1304    ///
1305    /// ### Returns
1306    ///
1307    /// A zeroed workspace with the positive-count runs located.
1308    fn new(data: &PmlData<'_>, zeros: Option<&'z ZeroCells>) -> Self {
1309        let n_cells = data.n_cells();
1310        let k = data.n_subjects();
1311        let nb = data.n_beta();
1312        let run = positive_runs(data.cell_index, data.subject_start);
1313
1314        Self {
1315            zeros,
1316            stored: vec![0.0; n_cells],
1317            run,
1318            wide: if nb > MAX_UNROLLED_WIDTH {
1319                vec![0.0; 2 * nb + nb * nb]
1320            } else {
1321                Vec::new()
1322            },
1323            current: Sweep::new(nb, k),
1324            trial: Sweep::new(nb, k),
1325            vw_step: vec![0.0; k],
1326            vb2: vec![0.0; nb * nb],
1327            factor: vec![0.0; nb * nb],
1328            perm: vec![0; nb],
1329            tmp: vec![0.0; nb],
1330            step_beta: vec![0.0; nb],
1331            step_log_w: vec![0.0; k],
1332            dwvw: vec![0.0; k],
1333            damp_beta: vec![0.0; nb],
1334            damp_log_w: vec![0.0; k],
1335            new_beta: vec![0.0; nb],
1336            new_log_w: vec![0.0; k],
1337        }
1338    }
1339
1340    /// Sweeps the gene at `(beta, log_w)` into [`Self::current`].
1341    ///
1342    /// ### Params
1343    ///
1344    /// * `data` - The gene
1345    /// * `beta` - Fixed effects
1346    /// * `log_w` - Random effects on the log scale
1347    /// * `gamma` - Cell-level negative binomial size
1348    /// * `penalty` - Prior on the random effects
1349    fn sweep_current(
1350        &mut self,
1351        data: &PmlData<'_>,
1352        beta: &[f64],
1353        log_w: &[f64],
1354        gamma: f64,
1355        penalty: Penalty,
1356    ) {
1357        sweep(
1358            data,
1359            beta,
1360            log_w,
1361            gamma,
1362            penalty,
1363            self.zeros,
1364            &mut self.stored,
1365            &self.run,
1366            &mut self.wide,
1367            &mut self.current,
1368        );
1369    }
1370
1371    /// Sweeps the gene at `(new_beta, new_log_w)` into [`Self::trial`].
1372    ///
1373    /// ### Params
1374    ///
1375    /// * `data` - The gene
1376    /// * `gamma` - Cell-level negative binomial size
1377    /// * `penalty` - Prior on the random effects
1378    ///
1379    /// ### Returns
1380    ///
1381    /// The penalised log-likelihood at the trial point.
1382    fn sweep_trial(&mut self, data: &PmlData<'_>, gamma: f64, penalty: Penalty) -> f64 {
1383        sweep(
1384            data,
1385            &self.new_beta,
1386            &self.new_log_w,
1387            gamma,
1388            penalty,
1389            self.zeros,
1390            &mut self.stored,
1391            &self.run,
1392            &mut self.wide,
1393            &mut self.trial,
1394        );
1395        self.trial.log_likelihood
1396    }
1397}
1398
1399/// Penalised log-likelihood, gradient and curvature at one point, in one pass
1400/// per subject.
1401///
1402/// Each subject is a dense part over every cell as if its count were zero, then
1403/// a sparse sweep adding what the positive counts change. Every per-cell term is
1404/// linear in the count through `gstar = gamma + y`, so the split is exact.
1405/// Without tables the dense part is two loops, the `exp` of every cell into
1406/// `stored` and the accumulation; with them it is one lookup per group of
1407/// [`ZeroCells`] and the accumulation over the loose cells. Sums associate
1408/// differently from nebula's C++, so results agree to rounding, not bitwise.
1409///
1410/// ### Params
1411///
1412/// * `data` - The gene
1413/// * `beta` - Fixed effects
1414/// * `log_w` - Random effects on the log scale
1415/// * `gamma` - Cell-level negative binomial size
1416/// * `penalty` - Prior on the random effects
1417/// * `zeros` - The run's zero-count tables, or `None` to sweep every cell
1418/// * `stored` - Per-cell `exp`, overwritten
1419/// * `run` - Start of each subject's positive counts, from [`Workspace::new`]
1420/// * `wide` - Heap sums for designs wider than [`MAX_UNROLLED_WIDTH`]
1421/// * `out` - Filled with the sweep
1422#[allow(clippy::too_many_arguments)]
1423fn sweep(
1424    data: &PmlData<'_>,
1425    beta: &[f64],
1426    log_w: &[f64],
1427    gamma: f64,
1428    penalty: Penalty,
1429    zeros: Option<&ZeroCells>,
1430    stored: &mut [f64],
1431    run: &[usize],
1432    wide: &mut [f64],
1433    out: &mut Sweep,
1434) {
1435    macro_rules! width {
1436        ($nb:literal) => {
1437            sweep_width::<$nb>(
1438                data, beta, log_w, gamma, penalty, zeros, stored, run, wide, out,
1439            )
1440        };
1441    }
1442    match data.n_beta() {
1443        1 => width!(1),
1444        2 => width!(2),
1445        3 => width!(3),
1446        4 => width!(4),
1447        5 => width!(5),
1448        6 => width!(6),
1449        7 => width!(7),
1450        8 => width!(8),
1451        _ => width!(0),
1452    }
1453}
1454
1455/// [`sweep`] for one design width, `NB == 0` meaning the width is read from
1456/// `data` at run time.
1457///
1458/// ### Params
1459///
1460/// As [`sweep`].
1461#[allow(clippy::too_many_arguments)]
1462fn sweep_width<const NB: usize>(
1463    data: &PmlData<'_>,
1464    beta: &[f64],
1465    log_w: &[f64],
1466    gamma: f64,
1467    penalty: Penalty,
1468    zeros: Option<&ZeroCells>,
1469    stored: &mut [f64],
1470    run: &[usize],
1471    wide: &mut [f64],
1472    out: &mut Sweep,
1473) {
1474    let nb = if NB == 0 { data.n_beta() } else { NB };
1475    let k = data.n_subjects();
1476    let mut local = [0.0; 2 * MAX_UNROLLED_WIDTH + MAX_UNROLLED_WIDTH * MAX_UNROLLED_WIDTH];
1477    let sums: &mut [f64] = if NB == 0 {
1478        wide
1479    } else {
1480        &mut local[..2 * nb + nb * nb]
1481    };
1482
1483    out.db.fill(0.0);
1484    out.vb.fill(0.0);
1485    let ln_gamma = gamma.ln();
1486    let mut log_likelihood = 0.0;
1487    for s in 0..k {
1488        let cells = data.subject_start[s]..data.subject_start[s + 1];
1489        let log_w_s = log_w[s];
1490        let w_s = log_w_s.exp();
1491        sums.fill(0.0);
1492        let (score, rest) = sums.split_at_mut(nb);
1493        let (first, second) = rest.split_at_mut(nb);
1494        let mut resid = 0.0;
1495        let mut weight = 0.0;
1496        let mut sum_log = 0.0;
1497        let mut linear = 0.0;
1498        let mut weighted_log = 0.0;
1499
1500        // `d` adds to the gradient, `p` to the curvature, the latter before the
1501        // factor of `gamma` the curvature blocks take at the end.
1502        let mut fold = |row: &[f64], d: f64, p: f64| {
1503            resid += d;
1504            weight += p;
1505            for a in 0..nb {
1506                score[a] += row[a] * d;
1507                let pa = p * row[a];
1508                first[a] += pa;
1509                for b in a..nb {
1510                    second[a * nb + b] += pa * row[b];
1511                }
1512            }
1513        };
1514
1515        match zeros {
1516            None => {
1517                // The `exp` in its own loop keeps the accumulating loop free of
1518                // calls.
1519                for r in cells.clone() {
1520                    let row = &data.design[r * nb..(r + 1) * nb];
1521                    let mut eta = data.offset[r];
1522                    for j in 0..nb {
1523                        eta += row[j] * beta[j];
1524                    }
1525                    stored[r] = (eta + log_w_s).exp();
1526                }
1527                for r in cells {
1528                    let row = &data.design[r * nb..(r + 1) * nb];
1529                    let extb = stored[r];
1530                    let t = extb + gamma;
1531                    sum_log += t.ln();
1532                    let inv = 1.0 / t;
1533                    let u = extb * inv;
1534                    fold(row, -gamma * u, gamma * u * inv);
1535                }
1536            }
1537            Some(z) => {
1538                // Per group, `sum ln t = n ln gamma + L`, `sum u = A` and
1539                // `sum gamma u / t = B`.
1540                for g in &z.groups[z.subject_groups[s]..z.subject_groups[s + 1]] {
1541                    let row = &data.design[g.row * nb..(g.row + 1) * nb];
1542                    let mut log_tau = log_w_s - ln_gamma;
1543                    for j in 0..nb {
1544                        log_tau += row[j] * beta[j];
1545                    }
1546                    let [l, a, b] = g.eval::<0, 3>(log_tau);
1547                    sum_log += g.n * ln_gamma + l;
1548                    fold(row, -gamma * a, b);
1549                }
1550                for &r in &z.loose[z.subject_loose[s]..z.subject_loose[s + 1]] {
1551                    let row = &data.design[r * nb..(r + 1) * nb];
1552                    let mut eta = data.offset[r];
1553                    for j in 0..nb {
1554                        eta += row[j] * beta[j];
1555                    }
1556                    let extb = (eta + log_w_s).exp();
1557                    stored[r] = extb;
1558                    let t = extb + gamma;
1559                    sum_log += t.ln();
1560                    let inv = 1.0 / t;
1561                    let u = extb * inv;
1562                    fold(row, -gamma * u, gamma * u * inv);
1563                }
1564            }
1565        }
1566        for i in run[s]..run[s + 1] {
1567            let c = data.cell_index[i];
1568            let y = data.counts[i];
1569            let row = &data.design[c * nb..(c + 1) * nb];
1570            let mut eta = data.offset[c];
1571            for j in 0..nb {
1572                eta += row[j] * beta[j];
1573            }
1574            let extb = if zeros.is_some() {
1575                let e = (eta + log_w_s).exp();
1576                stored[c] = e;
1577                e
1578            } else {
1579                stored[c]
1580            };
1581            let t = extb + gamma;
1582            linear += eta * y;
1583            weighted_log += y * t.ln();
1584            let inv = 1.0 / t;
1585            let u = extb * inv;
1586            fold(row, y * (1.0 - u), y * u * inv);
1587        }
1588
1589        log_likelihood += linear + log_w_s * data.subject_total[s] - gamma * sum_log - weighted_log;
1590        out.w[s] = w_s;
1591        out.dw[s] = resid + penalty.gradient(log_w_s, w_s);
1592        out.vw[s] = gamma * weight + penalty.curvature(w_s);
1593        for a in 0..nb {
1594            out.db[a] += score[a];
1595            out.vwb[s * nb + a] = gamma * first[a];
1596            for b in a..nb {
1597                out.vb[a * nb + b] += gamma * second[a * nb + b];
1598            }
1599        }
1600    }
1601    for a in 0..nb {
1602        for b in (a + 1)..nb {
1603            out.vb[b * nb + a] = out.vb[a * nb + b];
1604        }
1605    }
1606    out.log_likelihood = penalty.accumulate(log_likelihood, log_w, &out.w);
1607}
1608
1609////////////
1610// Solver //
1611////////////
1612
1613/// The shared Newton loop behind [`opt_pml`] and [`opt_pml_nbm`].
1614///
1615/// Each point is one [`sweep`]; an accepted trial point's sweep carries the
1616/// gradient and curvature of the next Newton step.
1617///
1618/// ### Params
1619///
1620/// * `data` - The gene
1621/// * `zeros` - The run's zero-count tables, or `None` to sweep every cell
1622/// * `beta_init` - Starting fixed effects
1623/// * `log_w_init` - Starting random effects, or `None` for zeros
1624/// * `penalty` - Prior on the random effects
1625/// * `gamma` - Cell-level negative binomial size
1626/// * `params` - Tuning knobs
1627///
1628/// ### Returns
1629///
1630/// The fitted solution and its diagnostics.
1631fn optimise(
1632    data: &PmlData<'_>,
1633    zeros: Option<&ZeroCells>,
1634    beta_init: &[f64],
1635    log_w_init: Option<&[f64]>,
1636    penalty: Penalty,
1637    gamma: f64,
1638    params: PmlParams,
1639) -> Result<PmlResult, EdgeErrors> {
1640    data.validate()?;
1641    let nb = data.n_beta();
1642    let k = data.n_subjects();
1643    if beta_init.len() != nb {
1644        return Err(EdgeErrors::LengthMismatch {
1645            name: "beta_init",
1646            expected: nb,
1647            got: beta_init.len(),
1648        });
1649    }
1650    if !(gamma.is_finite() && gamma > 0.0) {
1651        return Err(EdgeErrors::InvalidDispersion(gamma));
1652    }
1653
1654    let mut beta = beta_init.to_vec();
1655    let mut log_w = match log_w_init {
1656        Some(init) if init.len() == k => init.to_vec(),
1657        Some(init) => {
1658            return Err(EdgeErrors::LengthMismatch {
1659                name: "log_w_init",
1660                expected: k,
1661                got: init.len(),
1662            });
1663        }
1664        None => vec![0.0; k],
1665    };
1666    let mut work = Workspace::new(data, zeros);
1667    work.sweep_current(data, &beta, &log_w, gamma, penalty);
1668
1669    let mut log_likelihood = work.current.log_likelihood;
1670    let mut log_likelihood_prev = 0.0;
1671    let mut likdif = 0.0;
1672    let mut step = 0usize;
1673    let mut backtracks = 0usize;
1674
1675    while step == 0 || (likdif > params.eps && step < params.max_iter) {
1676        step += 1;
1677        work.damp_beta.fill(1.0);
1678        work.damp_log_w.fill(1.0);
1679
1680        let cur = &work.current;
1681        work.vw_step.copy_from_slice(&cur.vw);
1682        for s in 0..k {
1683            work.dwvw[s] = cur.dw[s] / cur.vw[s];
1684        }
1685        // Schur complement of the random-effect block, symmetric by
1686        // construction.
1687        for a in 0..nb {
1688            for b in a..nb {
1689                let mut acc = 0.0;
1690                for s in 0..k {
1691                    acc += cur.vwb[s * nb + a] * cur.vwb[s * nb + b] / cur.vw[s];
1692                }
1693                let v = cur.vb[a * nb + b] - acc;
1694                work.vb2[a * nb + b] = v;
1695                work.vb2[b * nb + a] = v;
1696            }
1697        }
1698        // `rhs = db - vwb' (dw/vw)`, then one `nb` solve gives the whole step.
1699        for j in 0..nb {
1700            let mut acc = 0.0;
1701            for s in 0..k {
1702                acc += cur.vwb[s * nb + j] * work.dwvw[s];
1703            }
1704            work.step_beta[j] = cur.db[j] - acc;
1705        }
1706        work.factor.copy_from_slice(&work.vb2);
1707        ldlt_solve(
1708            &mut work.factor,
1709            &mut work.step_beta,
1710            nb,
1711            &mut work.perm,
1712            &mut work.tmp,
1713        );
1714        for s in 0..k {
1715            let mut acc = 0.0;
1716            for j in 0..nb {
1717                acc += cur.vwb[s * nb + j] * work.step_beta[j];
1718            }
1719            work.step_log_w[s] = work.dwvw[s] - acc / cur.vw[s];
1720        }
1721
1722        for j in 0..nb {
1723            work.new_beta[j] = beta[j] + work.step_beta[j];
1724        }
1725        for s in 0..k {
1726            work.new_log_w[s] = log_w[s] + work.step_log_w[s];
1727        }
1728
1729        log_likelihood_prev = log_likelihood;
1730        log_likelihood = work.sweep_trial(data, gamma, penalty);
1731        likdif = log_likelihood - log_likelihood_prev;
1732
1733        backtracks = 0;
1734        let mut min_step = STEP_CUTOFF;
1735        while likdif < 0.0 || log_likelihood.is_infinite() {
1736            backtracks += 1;
1737            min_step /= 2.0;
1738
1739            if backtracks > params.max_backtrack {
1740                likdif = 0.0;
1741                log_likelihood = log_likelihood_prev;
1742                let grad_beta = work.current.db.iter().fold(0.0f64, |m, v| m.max(v.abs()));
1743                let grad_log_w = work.current.dw.iter().fold(0.0f64, |m, v| m.max(v.abs()));
1744                if grad_beta > GRADIENT_TOLERANCE || grad_log_w > GRADIENT_TOLERANCE {
1745                    backtracks += 1;
1746                }
1747                break;
1748            }
1749
1750            // A step past the cutoff is replaced outright: halving would need
1751            // forty rounds and the budget is ten.
1752            for j in 0..nb {
1753                let d = work.step_beta[j];
1754                if d < STEP_CUTOFF && d > -STEP_CUTOFF {
1755                    work.damp_beta[j] /= 2.0;
1756                    work.new_beta[j] = beta[j] + d * work.damp_beta[j];
1757                } else if d > 0.0 {
1758                    work.new_beta[j] = beta[j] + min_step;
1759                } else {
1760                    work.new_beta[j] = beta[j] - min_step;
1761                }
1762            }
1763            for s in 0..k {
1764                let d = work.step_log_w[s];
1765                if d < STEP_CUTOFF && d > -STEP_CUTOFF {
1766                    work.damp_log_w[s] /= 2.0;
1767                    work.new_log_w[s] = log_w[s] + d * work.damp_log_w[s];
1768                } else if d > 0.0 {
1769                    work.new_log_w[s] = log_w[s] + min_step;
1770                } else {
1771                    work.new_log_w[s] = log_w[s] - min_step;
1772                }
1773            }
1774
1775            log_likelihood = work.sweep_trial(data, gamma, penalty);
1776            likdif = log_likelihood - log_likelihood_prev;
1777        }
1778
1779        beta.copy_from_slice(&work.new_beta);
1780        log_w.copy_from_slice(&work.new_log_w);
1781        std::mem::swap(&mut work.current, &mut work.trial);
1782    }
1783
1784    // `vw` and `vb2` come from the previous iterate, as nebula reports; the
1785    // outer objective is calibrated against that.
1786    let mut log_det = 0.0;
1787    for s in 0..k {
1788        log_det += work.vw_step[s].abs().ln();
1789    }
1790    if params.reml {
1791        work.factor.copy_from_slice(&work.vb2);
1792        let det = determinant(&mut work.factor, nb, &mut work.perm);
1793        log_det += det.abs().ln();
1794    }
1795
1796    let second_order = match penalty {
1797        Penalty::Gamma { lambda, .. } if params.ord > 1 => {
1798            laplace_correction(&work, data, &beta, &log_w, gamma, lambda, params.ord)
1799        }
1800        _ => 0.0,
1801    };
1802
1803    let mut result = PmlResult {
1804        beta,
1805        log_w,
1806        information: work.vb2.clone(),
1807        log_likelihood,
1808        log_likelihood_prev,
1809        log_det,
1810        second_order,
1811        convergence: CONV_SUCCESS,
1812        iterations: step,
1813        backtracks,
1814    };
1815    result.convergence = check_convergence(&result, &params, false, CONV_SUCCESS);
1816    Ok(result)
1817}
1818
1819/// Higher-order terms of the Laplace expansion of the marginal likelihood.
1820///
1821/// The leading Laplace term is `-0.5 log|V|`; this returns the next correction,
1822/// which the outer objective adds as `log(1 + second)`. `ord == 2` keeps only
1823/// the third-derivative term `5 m3^2 / (24 V^3)`; `ord == 3` adds the
1824/// fourth-derivative term `-m4 / (8 V^2)` and the mixed term
1825/// `7 m5 m3 / (48 V^4)`. Every derivative is diagonal in the subjects, and each
1826/// per-cell summand is `gstar` times a function of the cell's `exp`, so this is
1827/// one pass split dense and sparse as in [`sweep`].
1828///
1829/// Recomputes the curvature at the final iterate, not the one the
1830/// log-determinant used (as nebula does).
1831///
1832/// ### Params
1833///
1834/// * `work` - Workspace whose `stored` and `current.w` sit at the final iterate
1835/// * `data` - The gene
1836/// * `beta` - Fixed effects at the final iterate
1837/// * `log_w` - Random effects at the final iterate
1838/// * `gamma` - Cell-level negative binomial size
1839/// * `lambda` - Rate of the gamma prior
1840/// * `ord` - Expansion order, greater than one
1841///
1842/// ### Returns
1843///
1844/// The correction, nebula's `second`.
1845fn laplace_correction(
1846    work: &Workspace<'_>,
1847    data: &PmlData<'_>,
1848    beta: &[f64],
1849    log_w: &[f64],
1850    gamma: f64,
1851    lambda: f64,
1852    ord: u32,
1853) -> f64 {
1854    let k = data.n_subjects();
1855    let nb = data.n_beta();
1856    let ln_gamma = gamma.ln();
1857    let high = ord > 2;
1858    // The curvature and the three higher derivatives, per unit of `gstar`.
1859    let terms = |e: f64| {
1860        let t = e + gamma;
1861        let p2 = e / (t * t);
1862        let p3 = p2 / t;
1863        let p4 = p3 / t;
1864        let p5 = p4 / t;
1865        let e2 = e * e;
1866        [
1867            p2,
1868            p3 * (gamma - e),
1869            p4 * (gamma * gamma + e2 - (4.0 * gamma) * e),
1870            p5 * (gamma * gamma * gamma - (11.0 * gamma * gamma) * e + (11.0 * gamma) * e2
1871                - e2 * e),
1872        ]
1873    };
1874
1875    let mut third_acc = 0.0;
1876    let mut fourth_acc = 0.0;
1877    let mut fifth_acc = 0.0;
1878    for s in 0..k {
1879        let mut sums = [0.0; 4];
1880        let mut dense = |e: f64| {
1881            let f = terms(e);
1882            for j in 0..4 {
1883                sums[j] += gamma * f[j];
1884            }
1885        };
1886        match work.zeros {
1887            None => {
1888                for &e in &work.stored[data.subject_start[s]..data.subject_start[s + 1]] {
1889                    dense(e);
1890                }
1891            }
1892            Some(z) => {
1893                for &r in &z.loose[z.subject_loose[s]..z.subject_loose[s + 1]] {
1894                    dense(work.stored[r]);
1895                }
1896                // `gamma` times each factor sums to the group's `B`..`E`.
1897                for g in &z.groups[z.subject_groups[s]..z.subject_groups[s + 1]] {
1898                    let row = &data.design[g.row * nb..(g.row + 1) * nb];
1899                    let mut log_tau = log_w[s] - ln_gamma;
1900                    for j in 0..nb {
1901                        log_tau += row[j] * beta[j];
1902                    }
1903                    let f = g.eval::<2, 4>(log_tau);
1904                    for j in 0..4 {
1905                        sums[j] += f[j];
1906                    }
1907                }
1908            }
1909        }
1910        for i in work.run[s]..work.run[s + 1] {
1911            let y = data.counts[i];
1912            let f = terms(work.stored[data.cell_index[i]]);
1913            for j in 0..4 {
1914                sums[j] += y * f[j];
1915            }
1916        }
1917        let prior = lambda * work.current.w[s];
1918        let vw = gamma * sums[0] + prior;
1919        let third = gamma * sums[1] + prior;
1920        third_acc += third * third / (vw * vw * vw);
1921        if high {
1922            let fourth = gamma * sums[2] + prior;
1923            let fifth = gamma * sums[3] + prior;
1924            let vs = vw * vw;
1925            fourth_acc += fourth / vs;
1926            fifth_acc += fifth * third / (vs * vs);
1927        }
1928    }
1929
1930    let mut second = 5.0 * third_acc / 24.0;
1931    if high {
1932        second -= fourth_acc / 8.0;
1933        second += 7.0 * fifth_acc / 48.0;
1934    }
1935    second
1936}
1937
1938////////////////////
1939// Linear algebra //
1940////////////////////
1941
1942/// Solves a small symmetric system in place by pivoted `LDL'`.
1943///
1944/// Mirrors Eigen's `LDLT` as the C++ calls it: symmetric pivoting on the largest
1945/// remaining diagonal, and a solve that zeroes a vanishing pivot instead of
1946/// dividing. Backtracking visits indefinite points where plain Cholesky fails,
1947/// and a rank-deficient design must still give a finite step. See
1948/// [`crate::glm::levenberg`] for the unpivoted Cholesky.
1949///
1950/// ### Params
1951///
1952/// * `a` - Row-major `n * n` symmetric matrix, overwritten by the factor
1953/// * `b` - Right-hand side, overwritten by the solution
1954/// * `n` - System size
1955/// * `perm` - Scratch for the transposition record, length `n`
1956/// * `tmp` - Scratch row, length `n`
1957fn ldlt_solve(a: &mut [f64], b: &mut [f64], n: usize, perm: &mut [usize], tmp: &mut [f64]) {
1958    for step in 0..n {
1959        let mut pivot = step;
1960        let mut best = a[step * n + step].abs();
1961        for i in (step + 1)..n {
1962            let v = a[i * n + i].abs();
1963            if v > best {
1964                best = v;
1965                pivot = i;
1966            }
1967        }
1968        perm[step] = pivot;
1969        if pivot != step {
1970            for j in 0..step {
1971                a.swap(step * n + j, pivot * n + j);
1972            }
1973            for i in (pivot + 1)..n {
1974                a.swap(i * n + step, i * n + pivot);
1975            }
1976            a.swap(step * n + step, pivot * n + pivot);
1977            for i in (step + 1)..pivot {
1978                a.swap(i * n + step, pivot * n + i);
1979            }
1980        }
1981
1982        if step > 0 {
1983            for j in 0..step {
1984                tmp[j] = a[j * n + j] * a[step * n + j];
1985            }
1986            let mut acc = 0.0;
1987            for j in 0..step {
1988                acc += a[step * n + j] * tmp[j];
1989            }
1990            a[step * n + step] -= acc;
1991            for i in (step + 1)..n {
1992                let mut acc = 0.0;
1993                for j in 0..step {
1994                    acc += a[i * n + j] * tmp[j];
1995                }
1996                a[i * n + step] -= acc;
1997            }
1998        }
1999
2000        let d = a[step * n + step];
2001        if d != 0.0 {
2002            for i in (step + 1)..n {
2003                a[i * n + step] /= d;
2004            }
2005        }
2006    }
2007
2008    for step in 0..n {
2009        b.swap(step, perm[step]);
2010    }
2011    for i in 0..n {
2012        let mut acc = b[i];
2013        for j in 0..i {
2014            acc -= a[i * n + j] * b[j];
2015        }
2016        b[i] = acc;
2017    }
2018    for i in 0..n {
2019        let d = a[i * n + i];
2020        // Eigen's tolerance: a pivot below the smallest normal double is an
2021        // exact zero and the component is dropped.
2022        b[i] = if d.abs() > f64::MIN_POSITIVE {
2023            b[i] / d
2024        } else {
2025            0.0
2026        };
2027    }
2028    for i in (0..n).rev() {
2029        let mut acc = b[i];
2030        for j in (i + 1)..n {
2031            acc -= a[j * n + i] * b[j];
2032        }
2033        b[i] = acc;
2034    }
2035    for step in (0..n).rev() {
2036        b.swap(step, perm[step]);
2037    }
2038}
2039
2040/// Determinant of a small square matrix by partial-pivot `LU`.
2041///
2042/// A product, not a sum of logs, as in the C++, so under- and overflow match.
2043///
2044/// ### Params
2045///
2046/// * `a` - Row-major `n * n` matrix, overwritten by the factor
2047/// * `n` - Matrix order
2048/// * `perm` - Scratch, length `n`
2049///
2050/// ### Returns
2051///
2052/// The signed determinant.
2053fn determinant(a: &mut [f64], n: usize, perm: &mut [usize]) -> f64 {
2054    let mut det = 1.0;
2055    perm.fill(0);
2056    for step in 0..n {
2057        let mut pivot = step;
2058        let mut best = a[step * n + step].abs();
2059        for i in (step + 1)..n {
2060            let v = a[i * n + step].abs();
2061            if v > best {
2062                best = v;
2063                pivot = i;
2064            }
2065        }
2066        if pivot != step {
2067            for j in 0..n {
2068                a.swap(step * n + j, pivot * n + j);
2069            }
2070            det = -det;
2071        }
2072        let d = a[step * n + step];
2073        det *= d;
2074        if d == 0.0 {
2075            return 0.0;
2076        }
2077        for i in (step + 1)..n {
2078            let factor = a[i * n + step] / d;
2079            a[i * n + step] = factor;
2080            for j in (step + 1)..n {
2081                a[i * n + j] -= factor * a[step * n + j];
2082            }
2083        }
2084    }
2085    det
2086}
2087
2088/// Smallest eigenvalue of a small symmetric matrix, by cyclic Jacobi.
2089///
2090/// Used only for the identifiability test against [`EIGENVALUE_CUTOFF`]. Jacobi
2091/// beats a faer decomposition on matrices a few columns wide.
2092///
2093/// ### Params
2094///
2095/// * `matrix` - Row-major `n * n` symmetric matrix, not modified
2096/// * `n` - Matrix order
2097///
2098/// ### Returns
2099///
2100/// The smallest eigenvalue, or the single entry when `n` is one.
2101fn min_eigenvalue(matrix: &[f64], n: usize) -> f64 {
2102    if n == 0 {
2103        return f64::NAN;
2104    }
2105    if n == 1 {
2106        return matrix[0];
2107    }
2108    let mut a = matrix.to_vec();
2109    let mut frobenius = 0.0;
2110    for &v in &a {
2111        frobenius += v * v;
2112    }
2113    let tolerance = (frobenius * f64::EPSILON * f64::EPSILON).max(f64::MIN_POSITIVE);
2114
2115    for _ in 0..JACOBI_SWEEPS {
2116        let mut off = 0.0;
2117        for p in 0..n {
2118            for q in (p + 1)..n {
2119                off += a[p * n + q] * a[p * n + q];
2120            }
2121        }
2122        if off <= tolerance {
2123            break;
2124        }
2125        for p in 0..n {
2126            for q in (p + 1)..n {
2127                let apq = a[p * n + q];
2128                if apq == 0.0 {
2129                    continue;
2130                }
2131                let theta = (a[q * n + q] - a[p * n + p]) / (2.0 * apq);
2132                let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
2133                let c = 1.0 / (t * t + 1.0).sqrt();
2134                let s = t * c;
2135                for r in 0..n {
2136                    let arp = a[r * n + p];
2137                    let arq = a[r * n + q];
2138                    a[r * n + p] = c * arp - s * arq;
2139                    a[r * n + q] = s * arp + c * arq;
2140                }
2141                for r in 0..n {
2142                    let apr = a[p * n + r];
2143                    let aqr = a[q * n + r];
2144                    a[p * n + r] = c * apr - s * aqr;
2145                    a[q * n + r] = s * apr + c * aqr;
2146                }
2147            }
2148        }
2149    }
2150    (0..n).map(|i| a[i * n + i]).fold(f64::INFINITY, f64::min)
2151}
2152
2153///////////
2154// Tests //
2155///////////
2156
2157#[cfg(test)]
2158// Reference values are pasted verbatim from R's 17-digit print.
2159#[allow(clippy::excessive_precision)]
2160// Fixture builders return the five parallel arrays `PmlData` borrows.
2161#[allow(clippy::type_complexity)]
2162mod tests {
2163    use super::*;
2164    use approx::assert_relative_eq;
2165
2166    /// Design of fixture A, row-major `12 * 2`: intercept plus a cell-level
2167    /// binary covariate.
2168    const DESIGN: [f64; 24] = [
2169        1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0,
2170        1.0, 0.0, 1.0, 1.0, 1.0, 1.0,
2171    ];
2172
2173    /// Offsets of fixture A, all dyadic so they round-trip exactly through R.
2174    const OFFSET: [f64; 12] = [
2175        0.25, -0.5, 0.75, 0.0, 0.5, -0.25, 0.125, 0.375, -0.125, 0.25, 0.0, -0.375,
2176    ];
2177
2178    /// Subject block boundaries: three subjects of four, five and three cells.
2179    const SUBJECT_START: [usize; 4] = [0, 4, 9, 12];
2180
2181    /// Positive counts of fixture A.
2182    const COUNTS: [f64; 8] = [3.0, 5.0, 2.0, 4.0, 1.0, 7.0, 2.0, 6.0];
2183
2184    /// Cells holding those counts.
2185    const CELL_INDEX: [usize; 8] = [0, 2, 3, 5, 6, 7, 9, 10];
2186
2187    /// Per-subject totals of fixture A.
2188    const SUBJECT_TOTAL: [f64; 3] = [10.0, 12.0, 8.0];
2189
2190    /// Positive counts of fixture B, where subject two is entirely zero.
2191    const COUNTS_B: [f64; 5] = [3.0, 5.0, 2.0, 2.0, 6.0];
2192
2193    /// Cells holding those counts.
2194    const CELL_INDEX_B: [usize; 5] = [0, 2, 3, 9, 10];
2195
2196    /// Per-subject totals of fixture B.
2197    const SUBJECT_TOTAL_B: [f64; 3] = [10.0, 0.0, 8.0];
2198
2199    /// Relative tolerance against the R package.
2200    ///
2201    /// Three orders tighter than 1e-8, reached on every fixture below. The
2202    /// binding constraint is nebula's own absolute `eps`: at a near-tie Newton
2203    /// step, a one-ulp shift in summation adds or drops a whole damped step and
2204    /// moves the answer by 1e-7. Fixtures avoid such ties.
2205    const TOL: f64 = 1e-11;
2206
2207    /// Fixture A: a well-behaved gene.
2208    fn fixture_a() -> PmlData<'static> {
2209        PmlData {
2210            design: &DESIGN,
2211            offset: &OFFSET,
2212            counts: &COUNTS,
2213            cell_index: &CELL_INDEX,
2214            subject_start: &SUBJECT_START,
2215            subject_total: &SUBJECT_TOTAL,
2216        }
2217    }
2218
2219    /// Fixture B: the same gene with every count in subject two set to zero.
2220    fn fixture_b() -> PmlData<'static> {
2221        PmlData {
2222            design: &DESIGN,
2223            offset: &OFFSET,
2224            counts: &COUNTS_B,
2225            cell_index: &CELL_INDEX_B,
2226            subject_start: &SUBJECT_START,
2227            subject_total: &SUBJECT_TOTAL_B,
2228        }
2229    }
2230
2231    /// Compares two slices elementwise at [`TOL`].
2232    ///
2233    /// ### Params
2234    ///
2235    /// * `got` - Values produced here
2236    /// * `want` - Values produced by nebula 1.5.8
2237    fn assert_slice(got: &[f64], want: &[f64]) {
2238        assert_eq!(got.len(), want.len());
2239        for (g, w) in got.iter().zip(want) {
2240            assert_relative_eq!(*g, *w, max_relative = TOL, epsilon = 1e-12);
2241        }
2242    }
2243
2244    // Reference values from nebula 1.5.8:
2245    //   Rscript -e 'library(nebula); nebula:::opt_pml(X, offset, Y, fid, cumsumy,
2246    //     posind, posindy, nb, nind, k, beta, sigma, reml, eps, ord)'
2247    // with X, offset, Y, fid, cumsumy, posindy as the constants above.
2248
2249    #[test]
2250    fn test_opt_pml_well_behaved_matches_nebula() {
2251        let data = fixture_a();
2252        let variance = PmlVariance {
2253            subject: 0.25,
2254            cell: 2.0,
2255        };
2256        let fit = opt_pml(&data, &[0.5, -0.25], &variance, None).expect("fit");
2257
2258        assert_slice(&fit.beta, &[1.1055943907637618, -0.98348970166026206]);
2259        assert_slice(
2260            &fit.log_w,
2261            &[
2262                -0.025429869309964156,
2263                0.022419629678247836,
2264                0.33781545877640862,
2265            ],
2266        );
2267        assert_slice(
2268            &fit.information,
2269            &[
2270                5.4708370512237092,
2271                2.6193993778249318,
2272                2.6193993778249318,
2273                4.0223986651682804,
2274            ],
2275        );
2276        assert_relative_eq!(fit.log_likelihood, -60.378642466146786, max_relative = TOL);
2277        assert_relative_eq!(fit.log_det, 6.0421923783970968, max_relative = TOL);
2278        assert_eq!(fit.second_order, 0.0);
2279        assert_eq!(fit.iterations, 4);
2280        assert_eq!(fit.backtracks, 0);
2281        assert_eq!(fit.convergence, CONV_SUCCESS);
2282    }
2283
2284    #[test]
2285    fn test_opt_pml_third_order_laplace_correction() {
2286        let data = fixture_a();
2287        let variance = PmlVariance {
2288            subject: 0.25,
2289            cell: 2.0,
2290        };
2291        let params = PmlParams {
2292            ord: 3,
2293            ..Default::default()
2294        };
2295        let fit = opt_pml(&data, &[0.5, -0.25], &variance, Some(params)).expect("fit");
2296
2297        // Same optimum, only the correction differs from ord = 1.
2298        assert_slice(&fit.beta, &[1.1055943907637618, -0.98348970166026206]);
2299        assert_relative_eq!(fit.second_order, 0.0028301625845031939, max_relative = TOL);
2300    }
2301
2302    #[test]
2303    fn test_opt_pml_reml_log_determinant() {
2304        let data = fixture_a();
2305        let variance = PmlVariance {
2306            subject: 0.25,
2307            cell: 2.0,
2308        };
2309        let params = PmlParams {
2310            reml: true,
2311            ..Default::default()
2312        };
2313        let fit = opt_pml(&data, &[0.5, -0.25], &variance, Some(params)).expect("fit");
2314
2315        assert_relative_eq!(fit.log_det, 8.759838692617901, max_relative = TOL);
2316        assert_slice(&fit.beta, &[1.1055943907637618, -0.98348970166026206]);
2317    }
2318
2319    #[test]
2320    fn test_opt_pml_subject_with_all_zero_counts() {
2321        let data = fixture_b();
2322        let variance = PmlVariance {
2323            subject: 0.25,
2324            cell: 2.0,
2325        };
2326        let fit = opt_pml(&data, &[0.5, -0.25], &variance, None).expect("fit");
2327
2328        assert_slice(&fit.beta, &[0.15781792039265752, -0.098178037869394941]);
2329        assert_slice(
2330            &fit.log_w,
2331            &[
2332                0.31781101972092152,
2333                -0.83477809302857631,
2334                0.46458776408134972,
2335            ],
2336        );
2337        assert_slice(
2338            &fit.information,
2339            &[
2340                4.9151627959404793,
2341                2.59325292186585,
2342                2.59325292186585,
2343                3.5781777249175555,
2344            ],
2345        );
2346        assert_relative_eq!(fit.log_likelihood, -50.769290856943599, max_relative = TOL);
2347        assert_relative_eq!(fit.log_det, 5.3802869340559738, max_relative = TOL);
2348        assert_eq!(fit.convergence, CONV_SUCCESS);
2349    }
2350
2351    #[test]
2352    fn test_opt_pml_zero_subject_third_order() {
2353        let data = fixture_b();
2354        let variance = PmlVariance {
2355            subject: 0.25,
2356            cell: 2.0,
2357        };
2358        let params = PmlParams {
2359            ord: 3,
2360            ..Default::default()
2361        };
2362        let fit = opt_pml(&data, &[0.5, -0.25], &variance, Some(params)).expect("fit");
2363        assert_relative_eq!(fit.second_order, 0.029026901671004347, max_relative = TOL);
2364    }
2365
2366    #[test]
2367    fn test_opt_pml_start_near_bound_backtracks() {
2368        // beta[0] = 90 is just inside nebula's +-100 box: the first Newton step
2369        // overshoots and per-coordinate damping runs.
2370        let data = fixture_a();
2371        let variance = PmlVariance {
2372            subject: 0.25,
2373            cell: 2.0,
2374        };
2375        let fit = opt_pml(&data, &[90.0, 0.0], &variance, None).expect("fit");
2376
2377        assert_slice(&fit.beta, &[1.1055943759446161, -0.98348968952057669]);
2378        assert_slice(
2379            &fit.log_w,
2380            &[
2381                -0.025429869876909816,
2382                0.022419639320108459,
2383                0.3378154594265026,
2384            ],
2385        );
2386        assert_slice(
2387            &fit.information,
2388            &[
2389                5.4711020927179739,
2390                2.6193969725187061,
2391                2.6193969725187061,
2392                4.0224446400576319,
2393            ],
2394        );
2395        assert_eq!(fit.iterations, 9);
2396        assert_eq!(fit.convergence, CONV_SUCCESS);
2397    }
2398
2399    #[test]
2400    fn test_opt_pml_far_outside_bounds_uses_fixed_displacement() {
2401        // Both coordinates start well outside the box: the Newton step exceeds
2402        // the cutoff and the fixed-displacement branch runs.
2403        let data = fixture_a();
2404        let variance = PmlVariance {
2405            subject: 0.25,
2406            cell: 2.0,
2407        };
2408        let fit = opt_pml(&data, &[-90.0, 60.0], &variance, None).expect("fit");
2409
2410        assert_slice(&fit.beta, &[1.1055943725487707, -0.98348968650982715]);
2411        assert_relative_eq!(fit.log_det, 6.0423215051590002, max_relative = TOL);
2412        assert_eq!(fit.iterations, 12);
2413    }
2414
2415    #[test]
2416    fn test_opt_pml_critical_point_reports_minus_ten() {
2417        // Backtracking is exhausted with every gradient under 0.01: a critical
2418        // point, not a failure. The stall is decisive (orders of magnitude from
2419        // improving); near-tie stalls are not testable to this tolerance.
2420        let data = fixture_a();
2421        let variance = PmlVariance {
2422            subject: 8.0,
2423            cell: 0.001,
2424        };
2425        let fit = opt_pml(&data, &[15.0, 5.0], &variance, None).expect("fit");
2426
2427        assert_slice(&fit.beta, &[4.9609375, -4.9609375]);
2428        assert_slice(&fit.log_w, &[0.0390625, 0.0390625, 4.5234136840078936]);
2429        assert_slice(
2430            &fit.information,
2431            &[
2432                7.0733722800021132e-05,
2433                7.0105348391562988e-05,
2434                7.0105348391562988e-05,
2435                0.00016310178559274507,
2436            ],
2437        );
2438        assert_relative_eq!(fit.log_det, -20.515549220945573, max_relative = TOL);
2439        assert_eq!(fit.iterations, 3);
2440        assert_eq!(fit.backtracks, 11);
2441        assert_eq!(fit.convergence, CONV_CRITICAL_POINT);
2442    }
2443
2444    #[test]
2445    fn test_opt_pml_stalled_reports_minus_forty() {
2446        let data = fixture_a();
2447        let variance = PmlVariance {
2448            subject: 8.0,
2449            cell: 0.001,
2450        };
2451        let fit = opt_pml(&data, &[20.0, 0.0], &variance, None).expect("fit");
2452
2453        assert_slice(&fit.beta, &[14.9609375, -4.9609375]);
2454        assert_slice(&fit.log_w, &[-4.9609375, -4.9609375, 4.9962571608576489]);
2455        assert_eq!(fit.iterations, 2);
2456        assert_eq!(fit.backtracks, 12);
2457        assert_eq!(fit.convergence, CONV_STALLED);
2458    }
2459
2460    #[test]
2461    fn test_opt_pml_iteration_budget_reports_minus_twenty() {
2462        // A negative eps is unsatisfiable: the loop exhausts its budget.
2463        let data = fixture_a();
2464        let variance = PmlVariance {
2465            subject: 0.25,
2466            cell: 2.0,
2467        };
2468        let params = PmlParams {
2469            eps: -1e-300,
2470            ..Default::default()
2471        };
2472        let fit = opt_pml(&data, &[0.5, -0.25], &variance, Some(params)).expect("fit");
2473
2474        assert_eq!(fit.iterations, 50);
2475        assert_eq!(fit.convergence, CONV_MAX_ITER);
2476        assert_slice(&fit.beta, &[1.1055943907637629, -0.98348970166026151]);
2477        assert_slice(
2478            &fit.information,
2479            &[
2480                5.4708368635191373,
2481                2.619399280826618,
2482                2.619399280826618,
2483                4.0223986123865263,
2484            ],
2485        );
2486    }
2487
2488    #[test]
2489    fn test_opt_pml_collinear_design_reports_minus_twentyfive() {
2490        // The covariate is duplicated, so the information matrix is singular.
2491        let design: Vec<f64> = DESIGN
2492            .chunks_exact(2)
2493            .flat_map(|r| [r[0], r[1], r[1]])
2494            .collect();
2495        let data = PmlData {
2496            design: &design,
2497            offset: &OFFSET,
2498            counts: &COUNTS,
2499            cell_index: &CELL_INDEX,
2500            subject_start: &SUBJECT_START,
2501            subject_total: &SUBJECT_TOTAL,
2502        };
2503        let variance = PmlVariance {
2504            subject: 0.25,
2505            cell: 2.0,
2506        };
2507        let fit = opt_pml(&data, &[0.5, -0.25, 0.1], &variance, None).expect("fit");
2508
2509        assert_eq!(fit.convergence, CONV_SINGULAR);
2510        assert_slice(
2511            &fit.beta,
2512            &[1.1055943330380458, -1.0834897078722583, 0.10000000000000001],
2513        );
2514        assert_relative_eq!(fit.log_det, 6.042689573294485, max_relative = TOL);
2515    }
2516
2517    #[test]
2518    fn test_opt_pml_single_coefficient() {
2519        let design = [1.0; 12];
2520        let data = PmlData {
2521            design: &design,
2522            offset: &OFFSET,
2523            counts: &COUNTS,
2524            cell_index: &CELL_INDEX,
2525            subject_start: &SUBJECT_START,
2526            subject_total: &SUBJECT_TOTAL,
2527        };
2528        let variance = PmlVariance {
2529            subject: 0.25,
2530            cell: 2.0,
2531        };
2532        let params = PmlParams {
2533            reml: true,
2534            ord: 3,
2535            ..Default::default()
2536        };
2537        let fit = opt_pml(&data, &[0.0], &variance, Some(params)).expect("fit");
2538
2539        assert_slice(&fit.beta, &[0.64526354365336647]);
2540        assert_slice(
2541            &fit.log_w,
2542            &[
2543                0.040002950937536336,
2544                0.10805651074009694,
2545                0.21875064209166895,
2546            ],
2547        );
2548        assert_slice(&fit.information, &[5.7533150000267472]);
2549        assert_relative_eq!(fit.log_det, 7.9235375511695514, max_relative = TOL);
2550        assert_relative_eq!(fit.second_order, 0.0044134970888637676, max_relative = TOL);
2551    }
2552
2553    #[test]
2554    fn test_opt_pml_nbm_matches_nebula() {
2555        let data = fixture_a();
2556        let variance = PmlVariance {
2557            subject: 0.3,
2558            cell: 2.0,
2559        };
2560        let params = PmlParams {
2561            reml: true,
2562            ..Default::default()
2563        };
2564        let fit = opt_pml_nbm(&data, &[0.5, -0.25], &variance, Some(params)).expect("fit");
2565
2566        assert_slice(&fit.beta, &[1.2213403122060453, -0.98985988766009869]);
2567        assert_slice(
2568            &fit.log_w,
2569            &[
2570                -0.14301073703498665,
2571                -0.095894342874540239,
2572                0.23890507990952689,
2573            ],
2574        );
2575        assert_slice(
2576            &fit.information,
2577            &[
2578                5.4025646031315233,
2579                2.5076353884976541,
2580                2.5076353884976541,
2581                3.9141513741254204,
2582            ],
2583        );
2584        assert_relative_eq!(fit.log_likelihood, -49.318101708775217, max_relative = TOL);
2585        assert_relative_eq!(fit.log_det, 8.6579114426780528, max_relative = TOL);
2586        assert_eq!(fit.second_order, 0.0);
2587        assert_eq!(fit.iterations, 3);
2588        assert_eq!(fit.convergence, CONV_SUCCESS);
2589    }
2590
2591    #[test]
2592    fn test_pml_log_likelihood_gradient_matches_nebula() {
2593        // Rscript -e 'library(nebula); nebula:::pml_ll_der_eigen(X, offset, Y,
2594        //   fid, cumsumy, posind, posindy, 2, 12, 3, c(0.5,-0.25),
2595        //   c(0.1,-0.2,0.05), c(0.25,2))'
2596        let data = fixture_a();
2597        let variance = PmlVariance {
2598            subject: 0.25,
2599            cell: 2.0,
2600        };
2601        let out = pml_log_likelihood_gradient(&data, &[0.5, -0.25], &[0.1, -0.2, 0.05], &variance)
2602            .expect("gradient");
2603
2604        assert_relative_eq!(out.objective, 53.926203132942852, max_relative = TOL);
2605        assert_slice(
2606            &out.gradient,
2607            &[
2608                -5.2597994101747254,
2609                -0.19846989257393055,
2610                -0.40624529144311583,
2611                -2.8367693486273806,
2612                -2.0229915782218155,
2613            ],
2614        );
2615    }
2616
2617    /// Builds the wider fixture: 64 cells, 4 subjects, 3 coefficients.
2618    ///
2619    /// Generated by an integer recurrence so R reproduces it with the same three
2620    /// expressions. Every value is dyadic.
2621    ///
2622    /// ### Returns
2623    ///
2624    /// Design, offsets, counts, cell indices and subject totals.
2625    fn fixture_large() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<usize>, Vec<f64>) {
2626        let n_cells = 64usize;
2627        let mut design = Vec::with_capacity(n_cells * 3);
2628        let mut offset = Vec::with_capacity(n_cells);
2629        let mut counts = Vec::new();
2630        let mut cell_index = Vec::new();
2631        let mut subject_total = vec![0.0; 4];
2632        let starts = [0usize, 12, 32, 48, 64];
2633        for i in 0..n_cells {
2634            design.push(1.0);
2635            design.push(if i % 3 == 0 { 1.0 } else { 0.0 });
2636            design.push((i % 7) as f64 / 8.0);
2637            offset.push(((i % 9) as f64 - 4.0) / 8.0);
2638            let y = ((i * 7 + 3) % 11) as f64;
2639            if y > 0.0 {
2640                counts.push(y);
2641                cell_index.push(i);
2642            }
2643            let s = starts.iter().rposition(|&b| b <= i).unwrap_or(0).min(3);
2644            subject_total[s] += y;
2645        }
2646        (design, offset, counts, cell_index, subject_total)
2647    }
2648
2649    #[test]
2650    fn test_opt_pml_wider_design_and_more_cells() {
2651        let (design, offset, counts, cell_index, subject_total) = fixture_large();
2652        assert_eq!(subject_total, vec![58.0, 100.0, 83.0, 82.0]);
2653        let starts = [0usize, 12, 32, 48, 64];
2654        let data = PmlData {
2655            design: &design,
2656            offset: &offset,
2657            counts: &counts,
2658            cell_index: &cell_index,
2659            subject_start: &starts,
2660            subject_total: &subject_total,
2661        };
2662        let variance = PmlVariance {
2663            subject: 0.35,
2664            cell: 1.5,
2665        };
2666        let params = PmlParams {
2667            reml: true,
2668            ord: 3,
2669            ..Default::default()
2670        };
2671        let fit = opt_pml(&data, &[0.5, -0.25, 0.75], &variance, Some(params)).expect("fit");
2672
2673        assert_slice(
2674            &fit.beta,
2675            &[
2676                1.4272053191863423,
2677                0.20743588590645803,
2678                -0.017982095723719935,
2679            ],
2680        );
2681        assert_slice(
2682            &fit.log_w,
2683            &[
2684                0.21944093016930771,
2685                0.12415265795703513,
2686                0.20913336580790309,
2687                0.14393280971073011,
2688            ],
2689        );
2690        assert_slice(
2691            &fit.information,
2692            &[
2693                8.398261243665857,
2694                2.7062153540842324,
2695                3.0637064599168475,
2696                2.7062153540842324,
2697                16.226178650135836,
2698                1.2821611476475319,
2699                3.0637064599168475,
2700                1.2821611476475319,
2701                5.5864643706193053,
2702            ],
2703        );
2704        assert_relative_eq!(fit.log_likelihood, -276.5244116459354, max_relative = TOL);
2705        assert_relative_eq!(fit.log_det, 18.407453390744379, max_relative = TOL);
2706        assert_relative_eq!(fit.second_order, 0.0029993806316514779, max_relative = TOL);
2707        assert_eq!(fit.iterations, 4);
2708        assert_eq!(fit.convergence, CONV_SUCCESS);
2709
2710        // Only the REML term of the log-determinant should move.
2711        let plain = opt_pml(&data, &[0.5, -0.25, 0.75], &variance, None).expect("fit");
2712        assert_relative_eq!(plain.log_det, 12.052188343414912, max_relative = TOL);
2713        assert_eq!(plain.second_order, 0.0);
2714        assert_slice(&plain.beta, &fit.beta);
2715    }
2716
2717    #[test]
2718    fn test_check_convergence_bound_and_nan_branches() {
2719        let base = PmlResult {
2720            beta: vec![0.0, 0.0],
2721            log_w: vec![0.0],
2722            information: vec![4.0, 0.0, 0.0, 4.0],
2723            log_likelihood: -1.0,
2724            log_likelihood_prev: -1.0,
2725            log_det: 0.0,
2726            second_order: 0.0,
2727            convergence: CONV_SUCCESS,
2728            iterations: 3,
2729            backtracks: 0,
2730        };
2731        let params = PmlParams::default();
2732
2733        assert_eq!(
2734            check_convergence(&base, &params, false, CONV_SUCCESS),
2735            CONV_SUCCESS
2736        );
2737        assert_eq!(
2738            check_convergence(&base, &params, true, CONV_SUCCESS),
2739            CONV_AT_BOUND
2740        );
2741
2742        let nan = PmlResult {
2743            log_likelihood: f64::NAN,
2744            ..base.clone()
2745        };
2746        assert_eq!(
2747            check_convergence(&nan, &params, false, CONV_SUCCESS),
2748            CONV_NOT_FINITE
2749        );
2750
2751        // An incoming failure from the outer optimiser is carried through.
2752        assert_eq!(check_convergence(&base, &params, false, -50), -50);
2753
2754        // The singular test overrides even that.
2755        let singular = PmlResult {
2756            information: vec![1.0, 1.0, 1.0, 1.0],
2757            ..base.clone()
2758        };
2759        assert_eq!(
2760            check_convergence(&singular, &params, true, CONV_SUCCESS),
2761            CONV_SINGULAR
2762        );
2763    }
2764
2765    #[test]
2766    fn test_min_eigenvalue_known_matrix() {
2767        // Eigenvalues 1 and 3.
2768        let a = [2.0, 1.0, 1.0, 2.0];
2769        assert_relative_eq!(min_eigenvalue(&a, 2), 1.0, max_relative = 1e-13);
2770        assert_relative_eq!(min_eigenvalue(&[7.0], 1), 7.0, max_relative = 1e-15);
2771    }
2772
2773    #[test]
2774    fn test_ldlt_solve_indefinite_system() {
2775        // Eigenvalues 3 and -1, so a plain Cholesky would fail here.
2776        let mut a = [1.0, 2.0, 2.0, 1.0];
2777        let mut b = [3.0, 3.0];
2778        let mut perm = [0usize; 2];
2779        let mut tmp = [0.0; 2];
2780        ldlt_solve(&mut a, &mut b, 2, &mut perm, &mut tmp);
2781        assert_relative_eq!(b[0], 1.0, max_relative = 1e-13);
2782        assert_relative_eq!(b[1], 1.0, max_relative = 1e-13);
2783    }
2784
2785    #[test]
2786    fn test_determinant_matches_closed_form() {
2787        let mut a = [4.0, 1.0, 2.0, 1.0, 5.0, 3.0, 2.0, 3.0, 6.0];
2788        let mut perm = [0usize; 3];
2789        // 4(30-9) - 1(6-6) + 2(3-10) = 84 - 0 - 14 = 70.
2790        assert_relative_eq!(
2791            determinant(&mut a, 3, &mut perm),
2792            70.0,
2793            max_relative = 1e-13
2794        );
2795    }
2796
2797    #[test]
2798    fn test_rejects_malformed_input() {
2799        let data = fixture_a();
2800        let variance = PmlVariance {
2801            subject: 0.25,
2802            cell: 2.0,
2803        };
2804        assert!(opt_pml(&data, &[0.5], &variance, None).is_err());
2805
2806        let bad_gamma = PmlVariance {
2807            subject: 0.25,
2808            cell: 0.0,
2809        };
2810        assert!(opt_pml(&data, &[0.5, -0.25], &bad_gamma, None).is_err());
2811
2812        let bad_subject = PmlVariance {
2813            subject: 0.0,
2814            cell: 2.0,
2815        };
2816        assert!(opt_pml(&data, &[0.5, -0.25], &bad_subject, None).is_err());
2817
2818        let short = PmlData {
2819            subject_total: &SUBJECT_TOTAL[..2],
2820            ..data
2821        };
2822        assert!(opt_pml(&short, &[0.5, -0.25], &variance, None).is_err());
2823    }
2824
2825    /// Three subjects of 400 cells, a subject-level group and a cell-level
2826    /// indicator, offsets over two orders of magnitude, about a tenth of the
2827    /// cells expressed.
2828    fn categorical_gene() -> (
2829        Vec<f64>,
2830        Vec<f64>,
2831        Vec<f64>,
2832        Vec<usize>,
2833        Vec<f64>,
2834        Vec<usize>,
2835    ) {
2836        use rand::prelude::*;
2837        use rand::rngs::SmallRng;
2838        let mut rng = SmallRng::seed_from_u64(11);
2839        let starts = vec![0usize, 400, 800, 1200];
2840        let mut design = Vec::new();
2841        let mut offset = Vec::new();
2842        let mut counts = Vec::new();
2843        let mut cell_index = Vec::new();
2844        let mut subject_total = vec![0.0; 3];
2845        for c in 0..1200 {
2846            let s = c / 400;
2847            let kind = f64::from(rng.random_bool(0.4));
2848            design.extend([1.0, (s % 2) as f64, kind]);
2849            let o: f64 = rng.random_range(-2.3..2.3);
2850            offset.push(o);
2851            if rng.random_bool(0.1 + 0.05 * kind) {
2852                let y = rng.random_range(1..6) as f64;
2853                counts.push(y);
2854                cell_index.push(c);
2855                subject_total[s] += y;
2856            }
2857        }
2858        (design, offset, counts, cell_index, subject_total, starts)
2859    }
2860
2861    #[test]
2862    fn test_opt_pml_tables_match_the_dense_sweep() {
2863        let (design, offset, counts, cell_index, subject_total, starts) = categorical_gene();
2864        let data = PmlData {
2865            design: &design,
2866            offset: &offset,
2867            counts: &counts,
2868            cell_index: &cell_index,
2869            subject_start: &starts,
2870            subject_total: &subject_total,
2871        };
2872        let zeros = ZeroCells::build(&design, &offset, &starts, 3).expect("categorical");
2873        for (subject, cell) in [(0.3, 0.05), (0.8, 2.0), (0.05, 40.0)] {
2874            let variance = PmlVariance { subject, cell };
2875            let params = PmlParams {
2876                reml: true,
2877                ord: 3,
2878                ..Default::default()
2879            };
2880            let start = [-1.5, 0.2, 0.1];
2881            let dense = opt_pml(&data, &start, &variance, Some(params)).expect("dense");
2882            let tabled = opt_pml_tabled(&data, Some(&zeros), &start, &variance, Some(params))
2883                .expect("tabled");
2884            assert_eq!(dense.iterations, tabled.iterations);
2885            for (a, b) in dense.beta.iter().zip(&tabled.beta) {
2886                assert_relative_eq!(*a, *b, max_relative = 1e-11);
2887            }
2888            for (a, b) in dense.information.iter().zip(&tabled.information) {
2889                assert_relative_eq!(*a, *b, max_relative = 1e-11);
2890            }
2891            assert_relative_eq!(
2892                dense.log_likelihood,
2893                tabled.log_likelihood,
2894                max_relative = 1e-13
2895            );
2896            assert_relative_eq!(dense.log_det, tabled.log_det, max_relative = 1e-12);
2897            assert_relative_eq!(
2898                dense.second_order,
2899                tabled.second_order,
2900                max_relative = 1e-10
2901            );
2902        }
2903    }
2904}