use rayon::prelude::*;
use crate::numeric::bobyqa::{BobyqaStatus, BobyqaStepper};
use crate::numeric::gamma::ln_gamma;
use crate::numeric::lbfgsb::{LbfgsbParams, minimise};
use crate::numeric::optimise::{NelderMeadParams, NelderMeadStepper};
use crate::prelude::*;
use crate::sc::pml::{
CONV_SINGULAR, CONV_SUCCESS, PmlData, PmlParams, PmlVariance, check_convergence, opt_pml_tabled,
};
use crate::sc::ptmg::{
GeneData, PtmgScratch, cell_level_columns, centre_design, cumsum_y, design_cv, offset_summary,
positive_indices, ptmg_value_and_gradient_with, ptmg_value_gradient_hessian_with,
};
use crate::sc::test::packed_len;
use crate::sc::zeros::ZeroCells;
const BETA_BOUND: f64 = 100.0;
const MIN_CELLS_PER_SUBJECT_LN: f64 = 30.0;
const HIGH_ORDER_COUNT_CUTOFF: f64 = 3.0;
const HIGH_ORDER: u32 = 3;
const BOUND_SLACK: f64 = 1.0 + 1e-4;
const KAPPA_FLOOR: f64 = 20.0;
const KAPPA_SIGMA_NUMERATOR: f64 = 8.0;
const POLISH_WIDTHS: [f64; 3] = [1e-2, 3e-3, 1e-3];
const POLISH_FLOOR: [f64; 2] = [1e-5, 1e-4];
const POLISH_MAX_STEP: f64 = 2.0;
const STAGE_ONE_FTOL: f64 = 1e-13;
const STAGE_ONE_PGTOL: f64 = 1e-7;
const STAGE_ONE_NEWTON_MAX_ITER: usize = 50;
const STAGE_ONE_ARMIJO: f64 = 1e-4;
const STAGE_ONE_MAX_HALVINGS: usize = 40;
const STAGE_ONE_SHIFT: f64 = 1e-8;
const STAGE_ONE_MAX_ITER: usize = 300;
const STAGE_ONE_MAX_LINE_SEARCH: usize = 40;
const VARIANCE_XATOL: f64 = 1e-7;
const VARIANCE_FATOL: f64 = 1e-7;
const VARIANCE_MAX_ITER: usize = 500;
pub const CONV_OUTER_FAILED: i32 = -50;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NebulaMethod {
Ln,
Hl,
}
#[derive(Clone, Copy, Debug)]
pub struct NebulaParams {
pub min: (f64, f64),
pub max: (f64, f64),
pub method: NebulaMethod,
pub cutoff_cell: f64,
pub kappa: f64,
pub cpc: f64,
pub mincp: usize,
pub min_subjects: usize,
pub reml: bool,
pub eps: f64,
}
impl Default for NebulaParams {
fn default() -> Self {
Self {
min: (1e-4, 1e-4),
max: (10.0, 1000.0),
method: NebulaMethod::Ln,
cutoff_cell: 20.0,
kappa: 800.0,
cpc: 0.005,
mincp: 5,
min_subjects: 0,
reml: false,
eps: 1e-6,
}
}
}
impl NebulaParams {
fn validate(&self) -> Result<(), EdgeErrors> {
for (index, (lo, hi)) in [(self.min.0, self.max.0), (self.min.1, self.max.1)]
.into_iter()
.enumerate()
{
if !(lo.is_finite() && lo > 0.0) {
return Err(EdgeErrors::MustBePositive(format!("min.{index}")));
}
if !(hi.is_finite() && hi > lo) {
return Err(EdgeErrors::InvalidBounds {
index,
lower: lo,
upper: hi,
});
}
}
if !(self.eps.is_finite() && self.eps > 0.0) {
return Err(EdgeErrors::MustBePositive("eps".to_string()));
}
if self.mincp == 0 {
return Err(EdgeErrors::MustBePositive("mincp".to_string()));
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct NebulaFit {
pub coefficients: Vec<f64>,
pub covariance: Vec<f64>,
pub se: Vec<f64>,
pub subject_overdispersion: Vec<f64>,
pub cell_overdispersion: Vec<f64>,
pub convergence: Vec<i32>,
pub sigma_at_bound: Vec<bool>,
pub gene_index: Vec<usize>,
pub n_coef: usize,
}
#[allow(clippy::too_many_arguments)]
pub fn nebula<T: EdgeFloat>(
counts: &[T],
n_genes: usize,
n_cells: usize,
subject_id: &[usize],
design: &[T],
n_coef: usize,
offset: Option<&[T]>,
params: Option<NebulaParams>,
) -> Result<NebulaFit, EdgeErrors> {
if n_genes == 0 || n_cells == 0 {
return Err(EdgeErrors::EmptyCounts {
n_genes,
n_samples: n_cells,
});
}
if counts.len() != n_genes * n_cells {
return Err(EdgeErrors::LengthMismatch {
name: "counts",
expected: n_genes * n_cells,
got: counts.len(),
});
}
let sparse = build_csr(counts, n_genes, n_cells)?;
nebula_sparse(&sparse, subject_id, design, n_coef, offset, params)
}
pub fn nebula_sparse<T: EdgeFloat>(
counts: &CompressedSparse<f64>,
subject_id: &[usize],
design: &[T],
n_coef: usize,
offset: Option<&[T]>,
params: Option<NebulaParams>,
) -> Result<NebulaFit, EdgeErrors> {
nebula_sparse_with(
counts, subject_id, design, n_coef, offset, params, fit_genes,
)
}
pub(crate) fn fit_genes(
shared: &Shared<'_>,
sparse: &CompressedSparse<f64>,
totals: &[f64],
kept: &[usize],
) -> Result<Vec<GeneOutcome>, EdgeErrors> {
let n_subjects = shared.n_subjects;
kept.par_iter()
.map(|&g| {
let counts = positive_indices(sparse, g)?;
let subject_totals = &totals[g * n_subjects..(g + 1) * n_subjects];
fit_gene(shared, &counts, subject_totals)
})
.collect()
}
pub(crate) fn nebula_sparse_with<T, Fit>(
counts: &CompressedSparse<f64>,
subject_id: &[usize],
design: &[T],
n_coef: usize,
offset: Option<&[T]>,
params: Option<NebulaParams>,
fit: Fit,
) -> Result<NebulaFit, EdgeErrors>
where
T: EdgeFloat,
Fit: FnOnce(
&Shared<'_>,
&CompressedSparse<f64>,
&[f64],
&[usize],
) -> Result<Vec<GeneOutcome>, EdgeErrors>,
{
let params = params.unwrap_or_default();
params.validate()?;
if counts.format != SparseFormat::Csr {
return Err(EdgeErrors::MalformedSparse(
"nebula_sparse needs gene-major counts in CSR form.".to_string(),
));
}
let n_genes = counts.nrows();
let n_cells = counts.ncols();
if n_genes == 0 || n_cells == 0 {
return Err(EdgeErrors::EmptyCounts {
n_genes,
n_samples: n_cells,
});
}
if n_cells < 2 {
return Err(EdgeErrors::InvalidArgument(
"NEBULA needs more than one cell in the count matrix.".to_string(),
));
}
if n_coef == 0 {
return Err(EdgeErrors::MustBePositive("n_coef".to_string()));
}
if let Some(bad) = counts.data.iter().find(|v| !(v.is_finite() && **v >= 0.0)) {
return Err(EdgeErrors::InvalidArgument(format!(
"The counts hold {bad}, which is not a non-negative finite number."
)));
}
if design.len() != n_cells * n_coef {
return Err(EdgeErrors::ShapeMismatch {
expected: (n_cells, n_coef),
got: (design.len(), 1),
});
}
if subject_id.len() != n_cells {
return Err(EdgeErrors::LengthMismatch {
name: "subject_id",
expected: n_cells,
got: subject_id.len(),
});
}
let fid = subject_boundaries(subject_id)?;
let n_subjects = fid.len() - 1;
if n_subjects < 2 {
return Err(EdgeErrors::TooFewSubjects {
required: 2,
got: n_subjects,
});
}
let offset_f64: Option<Vec<f64>> = match offset {
Some(o) => Some(to_f64("offset", o)?),
None => None,
};
if let Some(o) = offset_f64.as_deref()
&& o.iter().any(|v| !(v.is_finite() && *v > 0.0))
{
return Err(EdgeErrors::MustBePositive("offset".to_string()));
}
let offsets = offset_summary(offset_f64.as_deref(), n_cells)?;
let log_mean_offset = offsets.mean_offset.ln();
let cv2 = offsets.cv * offsets.cv;
let design_f64 = to_f64("design", design)?;
let (centred, sds) = centre_design(&design_f64, n_cells, n_coef)?;
if sds.iter().any(|s| *s < 0.0) {
return Err(EdgeErrors::InvalidArgument(
"Some predictors are a zero vector.".to_string(),
));
}
let constant: Vec<usize> = (0..n_coef).filter(|&j| sds[j] == 0.0).collect();
let intercept = match constant.as_slice() {
[] => return Err(EdgeErrors::MissingIntercept),
[j] => *j,
_ => {
return Err(EdgeErrors::InvalidArgument(
"More than one predictor has zero variation.".to_string(),
));
}
};
let cell_columns: Vec<usize> = cell_level_columns(¢red, n_cells, n_coef, &fid)?
.iter()
.enumerate()
.filter(|(_, flag)| **flag)
.map(|(j, _)| j)
.collect();
let sparse = counts;
let totals = cumsum_y(sparse, &fid)?;
let cells_per_subject = n_cells as f64 / n_subjects as f64;
let method = if cells_per_subject < MIN_CELLS_PER_SUBJECT_LN {
NebulaMethod::Hl
} else {
params.method
};
let kept: Vec<usize> = (0..n_genes)
.filter(|&g| {
let subject_totals = &totals[g * n_subjects..(g + 1) * n_subjects];
let total: f64 = subject_totals.iter().sum();
let (indices, _) = sparse.outer(g);
let pooled = total / (n_cells as f64) > params.cpc && indices.len() >= params.mincp;
pooled
&& (params.min_subjects == 0
|| subject_totals
.iter()
.zip(fid.windows(2))
.filter(|(t, w)| **t / (w[1] - w[0]) as f64 > params.cpc)
.count()
>= params.min_subjects)
})
.collect();
if kept.is_empty() {
return Err(EdgeErrors::NoGenesAfterFiltering { n_genes });
}
let zeros = ZeroCells::build(¢red, &offsets.log_offset, &fid, n_coef);
let shared = Shared {
design: ¢red,
log_offset: &offsets.log_offset,
fid: &fid,
n_cells,
n_coef,
n_subjects,
intercept,
log_mean_offset,
cells_per_subject,
cv2,
cell_columns: &cell_columns,
method,
params,
zeros: zeros.as_ref(),
};
let outcomes = fit(&shared, sparse, &totals, &kept)?;
Ok(assemble(
outcomes,
&kept,
&sds,
intercept,
n_coef,
params.min.0,
))
}
pub(crate) struct Shared<'a> {
pub(crate) design: &'a [f64],
pub(crate) log_offset: &'a [f64],
pub(crate) fid: &'a [usize],
n_cells: usize,
n_coef: usize,
pub(crate) n_subjects: usize,
intercept: usize,
log_mean_offset: f64,
cells_per_subject: f64,
cv2: f64,
cell_columns: &'a [usize],
method: NebulaMethod,
pub(crate) params: NebulaParams,
pub(crate) zeros: Option<&'a ZeroCells>,
}
pub(crate) struct GeneOutcome {
beta: Vec<f64>,
covariance: Vec<f64>,
se: Vec<f64>,
subject: f64,
cell: f64,
convergence: i32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Refit {
None,
Both,
SubjectOnly,
}
pub(crate) struct GenePlan {
pub(crate) ord: u32,
pub(crate) sigma: f64,
pub(crate) gamma: f64,
pub(crate) convergence: i32,
pub(crate) beta_start: Vec<f64>,
pub(crate) refit: Refit,
}
impl GenePlan {
pub(crate) fn search(&self) -> Option<(Vec<f64>, Option<f64>)> {
match self.refit {
Refit::None => None,
Refit::Both => Some((vec![self.sigma, self.gamma], None)),
Refit::SubjectOnly => Some((vec![self.sigma], Some(self.gamma))),
}
}
}
pub(crate) fn gene_pml<'a>(
shared: &Shared<'a>,
counts: &'a crate::sc::ptmg::GeneCounts,
subject_totals: &'a [f64],
) -> PmlData<'a> {
PmlData {
design: shared.design,
offset: shared.log_offset,
counts: &counts.counts,
cell_index: &counts.cells,
subject_start: shared.fid,
subject_total: subject_totals,
}
}
pub(crate) fn plan_gene(
shared: &Shared<'_>,
counts: &crate::sc::ptmg::GeneCounts,
subject_totals: &[f64],
) -> Result<GenePlan, EdgeErrors> {
let n_coef = shared.n_coef;
let params = &shared.params;
let ord = if counts.mean_count * shared.cells_per_subject < HIGH_ORDER_COUNT_CUTOFF {
HIGH_ORDER
} else {
1
};
let gene = GeneData::new(
shared.design,
shared.log_offset,
&counts.counts,
&counts.cells,
subject_totals,
shared.fid,
n_coef,
)?;
let log_mean_count = counts.mean_count.ln();
let mut start = vec![0.0; n_coef + 2];
start[shared.intercept] = log_mean_count - shared.log_mean_offset;
start[n_coef] = 1.0;
start[n_coef + 1] = 1.0;
let mut lower = vec![-BETA_BOUND; n_coef + 2];
let mut upper = vec![BETA_BOUND; n_coef + 2];
lower[n_coef] = params.min.0;
lower[n_coef + 1] = params.min.1;
upper[n_coef] = params.max.0;
upper[n_coef + 1] = params.max.1;
let (stage_one, stage_one_failed) =
minimise_marginal(&gene, shared.zeros, &start, &lower, &upper);
let convergence = if stage_one_failed { 0 } else { CONV_SUCCESS };
let sigma = stage_one[n_coef];
let gamma = stage_one[n_coef + 1];
let mut beta_start = vec![0.0; n_coef];
beta_start[shared.intercept] = log_mean_count - shared.log_mean_offset;
let refit = match shared.method {
NebulaMethod::Hl => Refit::Both,
NebulaMethod::Ln => {
let cv2p = if shared.cell_columns.is_empty() {
shared.cv2
} else {
design_cv(
shared.log_offset,
shared.design,
shared.n_cells,
n_coef,
&stage_one[..n_coef],
shared.cell_columns,
)?
};
let gni = shared.cells_per_subject * gamma;
if gni < params.cutoff_cell || convergence == 0 || cv2p.is_nan() {
Refit::Both
} else {
let kappa_obs = gni / (1.0 + cv2p);
let weak = kappa_obs < KAPPA_FLOOR
|| (kappa_obs < params.kappa && sigma < KAPPA_SIGMA_NUMERATOR / kappa_obs);
if weak {
Refit::SubjectOnly
} else {
Refit::None
}
}
}
};
Ok(GenePlan {
ord,
sigma,
gamma,
convergence,
beta_start,
refit,
})
}
pub(crate) fn finish_gene(
shared: &Shared<'_>,
counts: &crate::sc::ptmg::GeneCounts,
subject_totals: &[f64],
plan: GenePlan,
refit: Option<Option<(Vec<f64>, bool)>>,
) -> Result<GeneOutcome, EdgeErrors> {
let n_coef = shared.n_coef;
let params = &shared.params;
let mut sigma = plan.sigma;
let mut gamma = plan.gamma;
let mut convergence = plan.convergence;
match (plan.refit, refit) {
(Refit::Both, Some(found)) => match found {
Some((v, failed)) => {
sigma = v[0];
gamma = v[1];
convergence = if failed {
CONV_OUTER_FAILED
} else {
CONV_SUCCESS
};
}
None => convergence = CONV_OUTER_FAILED,
},
(Refit::SubjectOnly, Some(found)) => match found {
Some((v, failed)) => {
sigma = v[0];
convergence = if failed {
CONV_OUTER_FAILED
} else {
CONV_SUCCESS
};
}
None => convergence = CONV_OUTER_FAILED,
},
_ => {}
}
let pml = gene_pml(shared, counts, subject_totals);
let final_params = PmlParams {
reml: params.reml,
eps: params.eps,
ord: 1,
..PmlParams::default()
};
let mut beta_start = plan.beta_start;
beta_start[shared.intercept] -= sigma / 2.0;
let fit = opt_pml_tabled(
&pml,
shared.zeros,
&beta_start,
&PmlVariance {
subject: sigma,
cell: gamma,
},
Some(final_params),
)?;
let at_bound = sigma == params.max.0 || gamma == params.min.1;
let mut code = check_convergence(&fit, &final_params, at_bound, convergence);
let packed = packed_len(n_coef);
let (covariance, se) = match cholesky_inverse(&fit.information, n_coef) {
Some(inverse) if code != CONV_SINGULAR => {
let mut packed_cov = vec![0.0; packed];
for j in 0..n_coef {
for i in 0..=j {
packed_cov[j * (j + 1) / 2 + i] = inverse[i * n_coef + j];
}
}
let se = (0..n_coef)
.map(|j| inverse[j * n_coef + j].sqrt())
.collect();
(packed_cov, se)
}
_ => {
code = CONV_SINGULAR;
(vec![f64::NAN; packed], vec![f64::NAN; n_coef])
}
};
Ok(GeneOutcome {
beta: fit.beta,
covariance,
se,
subject: sigma,
cell: 1.0 / gamma,
convergence: code,
})
}
fn fit_gene(
shared: &Shared<'_>,
counts: &crate::sc::ptmg::GeneCounts,
subject_totals: &[f64],
) -> Result<GeneOutcome, EdgeErrors> {
let plan = plan_gene(shared, counts, subject_totals)?;
let refit = plan.search().map(|(start, fixed_cell)| {
let pml = gene_pml(shared, counts, subject_totals);
refine_variance(
shared,
&pml,
&plan.beta_start,
counts,
plan.ord,
&start,
fixed_cell,
)
});
finish_gene(shared, counts, subject_totals, plan, refit)
}
fn minimise_marginal(
gene: &GeneData<'_>,
zeros: Option<&ZeroCells>,
start: &[f64],
lower: &[f64],
upper: &[f64],
) -> (Vec<f64>, bool) {
let n = start.len();
let mut best: Vec<f64> = (0..n).map(|j| start[j].clamp(lower[j], upper[j])).collect();
let mut scratch = match zeros {
Some(z) => PtmgScratch::tabled(gene, z),
None => PtmgScratch::new(gene),
};
let best_f = ptmg_value_and_gradient_with(gene, &best, &mut scratch).0;
if !best_f.is_finite() {
return (best, true);
}
if zeros.is_none()
&& let Some((x, f)) = newton_marginal(gene, &mut scratch, &best, lower, upper)
&& f < best_f
{
return (x, false);
}
let params = LbfgsbParams {
ftol: STAGE_ONE_FTOL,
pgtol: STAGE_ONE_PGTOL,
max_iter: STAGE_ONE_MAX_ITER,
max_line_search: STAGE_ONE_MAX_LINE_SEARCH,
..LbfgsbParams::default()
};
if let Ok(step) = minimise(
|x, g| {
let (value, gradient) = ptmg_value_and_gradient_with(gene, x, &mut scratch);
g.copy_from_slice(&gradient);
value
},
&best,
lower,
upper,
Some(params),
) && step.f < best_f
{
best = step.x;
}
(best, false)
}
fn newton_marginal(
gene: &GeneData<'_>,
scratch: &mut PtmgScratch<'_>,
x0: &[f64],
lower: &[f64],
upper: &[f64],
) -> Option<(Vec<f64>, f64)> {
let n = x0.len();
let mut x = x0.to_vec();
let (mut f, mut g, mut h) = ptmg_value_gradient_hessian_with(gene, &x, scratch);
let finite =
|v: f64, g: &[f64], h: &[f64]| v.is_finite() && g.iter().chain(h).all(|x| x.is_finite());
if !finite(f, &g, &h) {
return None;
}
for _ in 0..STAGE_ONE_NEWTON_MAX_ITER {
let projected = (0..n)
.map(|i| ((x[i] - g[i]).clamp(lower[i], upper[i]) - x[i]).abs())
.fold(0.0f64, f64::max);
if projected < STAGE_ONE_PGTOL {
return Some((x, f));
}
let free: Vec<usize> = (0..n)
.filter(|&i| !((x[i] <= lower[i] && g[i] > 0.0) || (x[i] >= upper[i] && g[i] < 0.0)))
.collect();
let m = free.len();
let rhs: Vec<f64> = free.iter().map(|&i| -g[i]).collect();
let largest = free
.iter()
.fold(0.0f64, |acc, &i| acc.max(h[i * n + i].abs()));
let mut shift = 0.0;
let step_free = loop {
let mut block = vec![0.0; m * m];
for (p, &i) in free.iter().enumerate() {
for (q, &j) in free.iter().enumerate() {
block[p * m + q] = h[i * n + j];
}
block[p * m + p] += shift;
}
if let Some(d) = cholesky_solve(&block, m, &rhs) {
break d;
}
shift = if shift == 0.0 {
STAGE_ONE_SHIFT * largest.max(1.0)
} else {
shift * 10.0
};
if !shift.is_finite() {
return None;
}
};
let mut d = vec![0.0; n];
for (p, &i) in free.iter().enumerate() {
d[i] = step_free[p];
}
let mut alpha = 1.0;
let mut accepted = None;
for _ in 0..STAGE_ONE_MAX_HALVINGS {
let xt: Vec<f64> = (0..n)
.map(|i| (x[i] + alpha * d[i]).clamp(lower[i], upper[i]))
.collect();
let decrease: f64 = (0..n).map(|i| g[i] * (xt[i] - x[i])).sum();
let (ft, gt, ht) = ptmg_value_gradient_hessian_with(gene, &xt, scratch);
if finite(ft, >, &ht) && ft <= f + STAGE_ONE_ARMIJO * decrease {
accepted = Some((xt, ft, gt, ht));
break;
}
alpha *= 0.5;
}
let (xt, ft, gt, ht) = accepted?;
let done = (f - ft) <= STAGE_ONE_FTOL * f.abs();
(x, f, g, h) = (xt, ft, gt, ht);
if done {
return Some((x, f));
}
}
None
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct InnerFit {
pub(crate) log_likelihood: f64,
pub(crate) log_likelihood_prev: f64,
pub(crate) log_det: f64,
pub(crate) second_order: f64,
}
pub(crate) struct VarianceObjective<'a> {
pub(crate) data: &'a PmlData<'a>,
pub(crate) beta_start: &'a [f64],
pub(crate) intercept: usize,
pub(crate) params: PmlParams,
pub(crate) n_cells: f64,
pub(crate) n_subjects: f64,
pub(crate) counts: &'a [f64],
pub(crate) n_positive: f64,
pub(crate) n_one: f64,
pub(crate) n_two: f64,
pub(crate) fixed_cell: Option<f64>,
pub(crate) zeros: Option<&'a ZeroCells>,
}
impl VarianceObjective<'_> {
pub(crate) fn request(&self, x: &[f64]) -> Option<(f64, f64, Vec<f64>)> {
let subject = x[0];
let cell = match self.fixed_cell {
Some(c) => c,
None => x[1],
};
if !(subject.is_finite() && subject > 0.0 && cell.is_finite() && cell > 0.0) {
return None;
}
let mut beta = self.beta_start.to_vec();
beta[self.intercept] -= subject / 2.0;
Some((subject, cell, beta))
}
pub(crate) fn tail(&self, cell: f64) -> f64 {
let mut tail = 0.0;
for &y in self.counts {
if y != 1.0 && y != 2.0 {
tail += ln_gamma(y + cell);
}
}
tail
}
pub(crate) fn assemble(&self, subject: f64, cell: f64, fit: &InnerFit, tail: f64) -> f64 {
if fit.second_order < -1.0 {
return f64::INFINITY;
}
let exps = subject.exp();
let alpha = 1.0 / (exps - 1.0);
let lambda = 1.0 / (exps.sqrt() * (exps - 1.0));
let base = if fit.log_likelihood.is_nan() {
fit.log_likelihood_prev
} else {
fit.log_likelihood
};
let mut log_likelihood =
base + self.n_cells * cell * cell.ln() + self.n_subjects * alpha * lambda.ln()
- self.n_subjects * ln_gamma(alpha);
log_likelihood += tail - (self.n_positive - self.n_one - self.n_two) * ln_gamma(cell)
+ (self.n_one + self.n_two) * cell.ln()
+ self.n_two * (cell + 1.0).ln();
log_likelihood += -0.5 * fit.log_det + (1.0 + fit.second_order).ln();
if log_likelihood.is_finite() {
-log_likelihood
} else {
f64::INFINITY
}
}
fn value(&self, x: &[f64]) -> f64 {
let Some((subject, cell, beta)) = self.request(x) else {
return f64::INFINITY;
};
let fit = match opt_pml_tabled(
self.data,
self.zeros,
&beta,
&PmlVariance { subject, cell },
Some(self.params),
) {
Ok(f) => f,
Err(_) => return f64::INFINITY,
};
let inner = InnerFit {
log_likelihood: fit.log_likelihood,
log_likelihood_prev: fit.log_likelihood_prev,
log_det: fit.log_det,
second_order: fit.second_order,
};
self.assemble(subject, cell, &inner, self.tail(cell))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SearchStage {
Start,
NelderMead,
Check,
Polish,
Bobyqa,
Done,
}
fn variance_simplex(x0: &[f64]) -> NelderMeadStepper {
NelderMeadStepper::new(
x0,
Some(NelderMeadParams {
xatol: VARIANCE_XATOL,
fatol: VARIANCE_FATOL,
max_iter: VARIANCE_MAX_ITER,
}),
)
.expect("the variance components are never empty")
}
pub(crate) struct StageTwoSearch {
lower: Vec<f64>,
upper: Vec<f64>,
start: Vec<f64>,
order: u32,
invalid: bool,
stage: SearchStage,
asked: Vec<Vec<f64>>,
simplex: Option<NelderMeadStepper>,
best: Vec<f64>,
width: usize,
step: Vec<f64>,
offsets: Vec<Vec<f64>>,
found: Option<Vec<f64>>,
restarted: bool,
pinned: Option<(Vec<f64>, f64)>,
bobyqa: Option<BobyqaStepper>,
failed: bool,
}
impl StageTwoSearch {
pub(crate) fn new(start: &[f64], lower: &[f64], upper: &[f64], ord: u32) -> Self {
let mut search = Self {
lower: lower.to_vec(),
upper: upper.to_vec(),
start: start.to_vec(),
order: ord,
invalid: false,
stage: SearchStage::Start,
asked: Vec::new(),
simplex: None,
best: Vec::new(),
width: 0,
step: Vec::new(),
offsets: Vec::new(),
found: None,
restarted: false,
pinned: None,
bobyqa: None,
failed: false,
};
search.begin_pass();
search
}
pub(crate) fn order(&self) -> u32 {
self.order
}
pub(crate) fn ask(&self) -> Option<&[Vec<f64>]> {
if self.stage == SearchStage::Done {
None
} else {
Some(&self.asked)
}
}
pub(crate) fn tell(&mut self, values: &[f64]) {
debug_assert_eq!(values.len(), self.asked.len());
if values.iter().any(|v| !v.is_finite()) {
self.invalid = true;
}
match self.stage {
SearchStage::Start => {
if !values[0].is_finite() {
self.end_pass(None);
return;
}
let x0 = self.clamped(&self.start);
self.simplex = Some(variance_simplex(&x0));
self.stage = SearchStage::NelderMead;
self.ask_simplex();
}
SearchStage::NelderMead => {
let simplex = self.simplex.as_mut().expect("in the simplex stage");
simplex.tell(values[0]);
self.ask_simplex();
}
SearchStage::Check => {
if !values[0].is_finite() {
self.end_pass(None);
return;
}
self.width = 0;
self.begin_polish();
}
SearchStage::Polish => {
if values.iter().all(|v| v.is_finite())
&& let Some(next) = self.fit_polish(values)
{
self.best = next;
}
self.width += 1;
self.begin_polish();
}
SearchStage::Bobyqa => {
if !values[0].is_finite() {
self.end_pass(None);
return;
}
let stepper = self.bobyqa.as_mut().expect("in the BOBYQA stage");
stepper.tell(values[0]);
self.ask_bobyqa();
}
SearchStage::Done => {}
}
}
pub(crate) fn result(self) -> Option<(Vec<f64>, bool)> {
let failed = self.failed;
self.found.map(|x| (x, failed))
}
fn clamped(&self, x: &[f64]) -> Vec<f64> {
(0..x.len())
.map(|j| x[j].clamp(self.lower[j], self.upper[j]))
.collect()
}
fn begin_pass(&mut self) {
self.invalid = false;
self.restarted = false;
self.pinned = None;
self.simplex = None;
self.failed = false;
if self.start.len() == 2 {
let x0 = self.clamped(&self.start);
match BobyqaStepper::new(&x0, &self.lower, &self.upper, None) {
Ok(stepper) => {
self.bobyqa = Some(stepper);
self.stage = SearchStage::Bobyqa;
self.ask_bobyqa();
}
Err(_) => self.end_pass(None),
}
return;
}
self.stage = SearchStage::Start;
self.asked = vec![self.clamped(&self.start)];
}
fn ask_bobyqa(&mut self) {
let stepper = self.bobyqa.as_ref().expect("in the BOBYQA stage");
if let Some(x) = stepper.ask() {
self.asked = vec![x.to_vec()];
return;
}
let result = stepper.result();
self.bobyqa = None;
self.failed = result.status == BobyqaStatus::RoundoffLimited;
self.end_pass(Some(result.x));
}
fn end_pass(&mut self, found: Option<Vec<f64>>) {
if (found.is_some() && !self.invalid) || self.order == 1 {
self.found = found;
self.stage = SearchStage::Done;
self.asked.clear();
} else {
self.order = 1;
self.begin_pass();
}
}
fn ask_simplex(&mut self) {
let simplex = self.simplex.as_ref().expect("in the simplex stage");
if let Some(x) = simplex.ask() {
self.asked = vec![self.clamped(x)];
return;
}
let result = simplex.result();
let mut best = self.clamped(&result.x);
if !self.restarted
&& let Some(x0) = self.restart_point(&best)
{
self.restarted = true;
self.pinned = Some((best, result.f));
self.simplex = Some(variance_simplex(&x0));
self.ask_simplex();
return;
}
if let Some((first, f)) = self.pinned.take()
&& f <= result.f
{
best = first;
}
self.best = best.clone();
self.simplex = None;
self.stage = SearchStage::Check;
self.asked = vec![best];
}
fn restart_point(&self, best: &[f64]) -> Option<Vec<f64>> {
let start = self.clamped(&self.start);
let mut x0 = best.to_vec();
let mut moved = false;
for j in 0..best.len() {
for bound in [self.lower[j], self.upper[j]] {
if best[j] == bound && start[j] != bound {
x0[j] = (bound * start[j]).sqrt();
moved = true;
}
}
}
moved.then_some(x0)
}
fn begin_polish(&mut self) {
if self.width == POLISH_WIDTHS.len() {
let best = std::mem::take(&mut self.best);
self.end_pass(Some(best));
return;
}
let width = POLISH_WIDTHS[self.width];
let n = self.best.len();
let x = &self.best;
let step: Vec<f64> = (0..n)
.map(|j| (width * x[j].abs()).max(POLISH_FLOOR[j]))
.collect();
let nodes: Vec<[f64; 3]> = (0..n)
.map(|j| {
if x[j] - step[j] < self.lower[j] {
[0.0, 1.0, 2.0]
} else if x[j] + step[j] > self.upper[j] {
[-2.0, -1.0, 0.0]
} else {
[-1.0, 0.0, 1.0]
}
})
.collect();
let n_points = 3_usize.pow(n as u32);
let mut offsets = Vec::with_capacity(n_points);
let mut asked = Vec::with_capacity(n_points);
for point in 0..n_points {
let mut u = vec![0.0; n];
let mut rest = point;
for j in 0..n {
let node = nodes[j][rest % 3];
rest /= 3;
let p = (x[j] + node * step[j]).clamp(self.lower[j], self.upper[j]);
u[j] = (p - x[j]) / step[j];
}
asked.push((0..n).map(|j| x[j] + u[j] * step[j]).collect());
offsets.push(u);
}
self.step = step;
self.offsets = offsets;
self.asked = asked;
self.stage = SearchStage::Polish;
}
fn fit_polish(&self, values: &[f64]) -> Option<Vec<f64>> {
let x = &self.best;
let n = x.len();
let n_terms = 1 + 2 * n + n * (n - 1) / 2;
let mut rows = Vec::with_capacity(values.len() * n_terms);
for u in &self.offsets {
rows.push(1.0);
rows.extend_from_slice(u);
rows.extend(u.iter().map(|v| 0.5 * v * v));
for i in 0..n {
for j in (i + 1)..n {
rows.push(u[i] * u[j]);
}
}
}
let mut normal = vec![0.0; n_terms * n_terms];
let mut rhs = vec![0.0; n_terms];
for (point, &f) in values.iter().enumerate() {
let row = &rows[point * n_terms..(point + 1) * n_terms];
for a in 0..n_terms {
rhs[a] += row[a] * f;
for b in 0..n_terms {
normal[a * n_terms + b] += row[a] * row[b];
}
}
}
let coefficients = cholesky_solve(&normal, n_terms, &rhs)?;
let gradient = &coefficients[1..=n];
let mut hessian = vec![0.0; n * n];
for j in 0..n {
hessian[j * n + j] = coefficients[1 + n + j];
}
let mut cross = 1 + 2 * n;
for i in 0..n {
for j in (i + 1)..n {
hessian[i * n + j] = coefficients[cross];
hessian[j * n + i] = coefficients[cross];
cross += 1;
}
}
let negated: Vec<f64> = gradient.iter().map(|g| -g).collect();
let delta = cholesky_solve(&hessian, n, &negated)?;
if delta
.iter()
.any(|d| !d.is_finite() || d.abs() > POLISH_MAX_STEP)
{
return None;
}
Some(
(0..n)
.map(|j| (x[j] + delta[j] * self.step[j]).clamp(self.lower[j], self.upper[j]))
.collect(),
)
}
}
fn refine_variance(
shared: &Shared<'_>,
pml: &PmlData<'_>,
beta_start: &[f64],
counts: &crate::sc::ptmg::GeneCounts,
ord: u32,
start: &[f64],
fixed_cell: Option<f64>,
) -> Option<(Vec<f64>, bool)> {
debug_assert_eq!(start.len(), if fixed_cell.is_some() { 1 } else { 2 });
let (lower, upper) = variance_bounds(&shared.params, fixed_cell);
let mut search = StageTwoSearch::new(start, &lower, &upper, ord);
while let Some(points) = search.ask() {
let objective =
variance_objective(shared, pml, beta_start, counts, search.order(), fixed_cell);
let values: Vec<f64> = points.iter().map(|x| objective.value(x)).collect();
search.tell(&values);
}
search.result()
}
pub(crate) fn variance_bounds(
params: &NebulaParams,
fixed_cell: Option<f64>,
) -> (Vec<f64>, Vec<f64>) {
if fixed_cell.is_some() {
(vec![params.min.0], vec![params.max.0])
} else {
(
vec![params.min.0, params.min.1],
vec![params.max.0, params.max.1],
)
}
}
pub(crate) fn variance_objective<'a>(
shared: &Shared<'a>,
pml: &'a PmlData<'a>,
beta_start: &'a [f64],
counts: &'a crate::sc::ptmg::GeneCounts,
order: u32,
fixed_cell: Option<f64>,
) -> VarianceObjective<'a> {
let params = &shared.params;
VarianceObjective {
data: pml,
beta_start,
intercept: shared.intercept,
params: PmlParams {
reml: params.reml,
eps: params.eps,
ord: order,
..PmlParams::default()
},
n_cells: shared.n_cells as f64,
n_subjects: shared.n_subjects as f64,
counts: &counts.counts,
n_positive: counts.counts.len() as f64,
n_one: counts.n_one as f64,
n_two: counts.n_two as f64,
fixed_cell,
zeros: shared.zeros,
}
}
fn cholesky_factor(a: &[f64], n: usize) -> Option<Vec<f64>> {
let mut l = vec![0.0; n * n];
for i in 0..n {
for j in 0..=i {
let mut sum = a[i * n + j];
for k in 0..j {
sum -= l[i * n + k] * l[j * n + k];
}
if i == j {
if !(sum.is_finite() && sum > 0.0) {
return None;
}
l[i * n + j] = sum.sqrt();
} else {
l[i * n + j] = sum / l[j * n + j];
}
}
}
Some(l)
}
fn cholesky_solve(a: &[f64], n: usize, b: &[f64]) -> Option<Vec<f64>> {
let l = cholesky_factor(a, n)?;
let mut x = b.to_vec();
for i in 0..n {
let mut sum = x[i];
for k in 0..i {
sum -= l[i * n + k] * x[k];
}
x[i] = sum / l[i * n + i];
}
for i in (0..n).rev() {
let mut sum = x[i];
for k in (i + 1)..n {
sum -= l[k * n + i] * x[k];
}
x[i] = sum / l[i * n + i];
}
if x.iter().all(|v| v.is_finite()) {
Some(x)
} else {
None
}
}
fn cholesky_inverse(a: &[f64], n: usize) -> Option<Vec<f64>> {
let mut inverse = vec![0.0; n * n];
let mut unit = vec![0.0; n];
for j in 0..n {
unit.iter_mut().for_each(|v| *v = 0.0);
unit[j] = 1.0;
let column = cholesky_solve(a, n, &unit)?;
for i in 0..n {
inverse[i * n + j] = column[i];
}
}
Some(inverse)
}
fn subject_boundaries(subject_id: &[usize]) -> Result<Vec<usize>, EdgeErrors> {
let mut fid = vec![0usize];
let mut seen: Vec<usize> = vec![subject_id[0]];
for i in 1..subject_id.len() {
if subject_id[i] != subject_id[i - 1] {
if seen.contains(&subject_id[i]) {
return Err(EdgeErrors::SubjectsNotContiguous {
subject: subject_id[i],
});
}
seen.push(subject_id[i]);
fid.push(i);
}
}
fid.push(subject_id.len());
Ok(fid)
}
fn to_f64<T: EdgeFloat>(name: &str, values: &[T]) -> Result<Vec<f64>, EdgeErrors> {
values
.iter()
.map(|v| {
v.to_f64().ok_or_else(|| {
EdgeErrors::InvalidArgument(format!("{name} holds a non-finite value."))
})
})
.collect()
}
fn build_csr<T: EdgeFloat>(
counts: &[T],
n_genes: usize,
n_cells: usize,
) -> Result<CompressedSparse<f64>, EdgeErrors> {
let mut data = Vec::new();
let mut indices = Vec::new();
let mut indptr = Vec::with_capacity(n_genes + 1);
indptr.push(0u32);
for gene in 0..n_genes {
for (cell, value) in counts[gene * n_cells..(gene + 1) * n_cells]
.iter()
.enumerate()
{
let v = value.to_f64().unwrap_or(f64::NAN);
if !v.is_finite() || v < 0.0 {
return Err(EdgeErrors::InvalidArgument(format!(
"Count for gene {gene}, cell {cell} is not a non-negative finite number."
)));
}
if v > 0.0 {
data.push(v);
indices.push(cell as u32);
}
}
indptr.push(data.len() as u32);
}
CompressedSparse::from_parts(data, indices, indptr, SparseFormat::Csr, (n_genes, n_cells))
}
fn assemble(
outcomes: Vec<GeneOutcome>,
kept: &[usize],
sds: &[f64],
intercept: usize,
n_coef: usize,
min_subject: f64,
) -> NebulaFit {
let mut scale = sds.to_vec();
scale[intercept] = 1.0;
let packed = packed_len(n_coef);
let n = outcomes.len();
let mut fit = NebulaFit {
coefficients: Vec::with_capacity(n * n_coef),
covariance: Vec::with_capacity(n * packed),
se: Vec::with_capacity(n * n_coef),
subject_overdispersion: Vec::with_capacity(n),
cell_overdispersion: Vec::with_capacity(n),
convergence: Vec::with_capacity(n),
sigma_at_bound: Vec::with_capacity(n),
gene_index: kept.to_vec(),
n_coef,
};
for outcome in outcomes {
for (j, s) in scale.iter().enumerate() {
fit.coefficients.push(outcome.beta[j] / s);
fit.se.push(outcome.se[j] / s);
}
for j in 0..n_coef {
for i in 0..=j {
fit.covariance
.push(outcome.covariance[j * (j + 1) / 2 + i] / (scale[i] * scale[j]));
}
}
fit.subject_overdispersion.push(outcome.subject);
fit.cell_overdispersion.push(outcome.cell);
fit.convergence.push(outcome.convergence);
fit.sigma_at_bound
.push(outcome.subject <= min_subject * BOUND_SLACK);
}
fit
}
#[cfg(test)]
#[allow(clippy::excessive_precision)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use std::path::PathBuf;
const TOL_COEF: f64 = 5e-6;
const TOL_SE: f64 = 1e-5;
const TOL_COV: f64 = 1e-5;
const TOL_SUBJECT: f64 = 1e-5;
const TOL_CELL: f64 = 1e-5;
fn read_csv(name: &str, header: bool) -> (Vec<f64>, usize, usize) {
let path: PathBuf = [env!("CARGO_MANIFEST_DIR"), "tests", "data", name]
.iter()
.collect();
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
let mut n_cols = 0;
if header {
n_cols = lines
.next()
.expect("fixture has no header")
.split(',')
.count();
}
let mut values = Vec::new();
let mut n_rows = 0_usize;
for line in lines {
let fields: Vec<&str> = line.split(',').collect();
if n_cols == 0 {
n_cols = fields.len();
}
for field in fields {
values.push(
field
.trim()
.trim_matches('"')
.parse::<f64>()
.unwrap_or_else(|e| panic!("bad field {field:?} in {name}: {e}")),
);
}
n_rows += 1;
}
assert_eq!(values.len(), n_rows * n_cols, "ragged fixture {name}");
(values, n_rows, n_cols)
}
fn fixture() -> (Vec<f64>, Vec<usize>, Vec<f64>, Vec<f64>) {
let (counts, n_genes, n_cells) = read_csv("nebula_counts.csv", false);
assert_eq!((n_genes, n_cells), (8, 150));
let (design_raw, rows, cols) = read_csv("nebula_design.csv", true);
assert_eq!((rows, cols), (150, 4));
let subject: Vec<usize> = (0..rows).map(|i| design_raw[i * cols] as usize).collect();
let mut design = Vec::with_capacity(rows * 3);
let mut offset = Vec::with_capacity(rows);
for i in 0..rows {
design.push(1.0);
design.push(design_raw[i * cols + 1]);
design.push(design_raw[i * cols + 2]);
offset.push(design_raw[i * cols + 3]);
}
(counts, subject, design, offset)
}
fn golden_params() -> NebulaParams {
NebulaParams {
cutoff_cell: 0.0,
..NebulaParams::default()
}
}
fn repack(row: &[f64]) -> Vec<f64> {
vec![row[0], row[1], row[3], row[2], row[4], row[5]]
}
fn assert_convergence(got: i32, want: i32) {
if want == CONV_SUCCESS {
assert!(
got == CONV_SUCCESS || got == crate::sc::pml::CONV_CRITICAL_POINT,
"convergence {got}, expected 1 or -10"
);
} else {
assert_eq!(got, want);
}
}
#[test]
fn test_nebula_matches_r_package() {
let (counts, subject, design, offset) = fixture();
let fit = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
)
.expect("nebula failed");
let (expected, n_rows, n_cols) = read_csv("nebula_expected.csv", true);
let (cov, cov_rows, cov_cols) = read_csv("nebula_covariance.csv", true);
assert_eq!((n_rows, n_cols), (8, 13));
assert_eq!((cov_rows, cov_cols), (8, 6));
assert_eq!(fit.gene_index, (0..8).collect::<Vec<usize>>());
assert_eq!(fit.n_coef, 3);
for g in 0..8 {
let row = &expected[g * n_cols..(g + 1) * n_cols];
for j in 0..3 {
assert_relative_eq!(fit.coefficients[g * 3 + j], row[j], max_relative = TOL_COEF);
assert_relative_eq!(fit.se[g * 3 + j], row[3 + j], max_relative = TOL_SE);
}
assert_relative_eq!(
fit.subject_overdispersion[g],
row[10],
max_relative = TOL_SUBJECT
);
assert_relative_eq!(fit.cell_overdispersion[g], row[11], max_relative = TOL_CELL);
assert_convergence(fit.convergence[g], row[12] as i32);
for (k, w) in repack(&cov[g * 6..(g + 1) * 6]).iter().enumerate() {
assert_relative_eq!(fit.covariance[g * 6 + k], w, max_relative = TOL_COV);
}
}
}
#[test]
fn test_wald_test_reproduces_the_r_p_values() {
use crate::sc::test::{ScTested, glm_sc_test};
let (counts, subject, design, offset) = fixture();
let fit = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
)
.expect("nebula failed");
let (expected, _, n_cols) = read_csv("nebula_expected.csv", true);
for coef in 1..3 {
let tested = glm_sc_test(
&fit.coefficients,
&fit.covariance,
8,
3,
&ScTested::Coef(coef),
)
.expect("test failed");
for g in 0..8 {
assert_relative_eq!(
tested.p_value[g],
expected[g * n_cols + 6 + coef],
max_relative = 1e-4
);
}
}
}
#[test]
fn test_nebula_two_column_design() {
let (counts, subject, design_three, offset) = fixture();
let design: Vec<f64> = (0..150)
.flat_map(|i| [design_three[i * 3], design_three[i * 3 + 1]])
.collect();
const EXPECTED: [[f64; 6]; 8] = [
[
-3.83069111381507010e+00,
4.67477914516942039e-01,
1.52851759868728232e-01,
3.05703519737456464e-01,
1.19930138981593701e-01,
2.50509063784787156e-01,
],
[
-5.22622507034590900e+00,
2.86854056278882219e-01,
1.44924959049115953e-01,
2.89849918098231907e-01,
1.02875112505358357e-01,
2.46112537450009855e-01,
],
[
-2.79850766191427391e+00,
3.82576141900636657e-01,
1.46513262535639927e-01,
2.93026525071279853e-01,
1.09839244633263694e-01,
2.79001107949189975e-01,
],
[
-5.78859782201794282e+00,
1.39871216066227488e-01,
1.13748802726646378e-01,
2.27497605453292728e-01,
5.63655105828342259e-02,
1.68916319758526501e-01,
],
[
-3.18904397195006339e+00,
4.63470376690180819e-01,
1.71383246709902126e-01,
3.42766493419804308e-01,
1.51708497662862124e-01,
2.53029183723021411e-01,
],
[
-4.30946341634605812e+00,
2.84795644398078140e-01,
1.12103980708383288e-01,
2.24207961416766549e-01,
6.00916997253425900e-02,
2.56315004910657396e-01,
],
[
-2.27100390853884093e+00,
3.57947777093715835e-01,
1.21567297349690767e-01,
2.43134594699381507e-01,
7.29528753821947207e-02,
3.03612425865116442e-01,
],
[
-4.71507442768750007e+00,
5.85766182363788412e-01,
1.48580330507162378e-01,
2.97160661014324701e-01,
1.10500262941949104e-01,
2.60169207789380963e-01,
],
];
const EXPECTED_COV: [[f64; 3]; 8] = [
[
2.3363660494967357e-02,
-6.4743666988604335e-05,
9.3454641979869441e-02,
],
[
2.1003243755387938e-02,
-3.4610402755941365e-04,
8.4012975021551750e-02,
],
[
2.1466136098837348e-02,
-1.6108385963441172e-05,
8.5864544395349407e-02,
],
[
1.2938790121745513e-02,
-3.2251258251132168e-04,
5.1755160486982046e-02,
],
[
2.9372217252827182e-02,
9.4745052152154328e-05,
1.1748886901130876e-01,
],
[
1.2567302490665573e-02,
-1.2031208910192646e-04,
5.0269209962662278e-02,
],
[
1.4778607784908132e-02,
-6.2578541012701255e-05,
5.9114431139632516e-02,
],
[
2.2076114613617605e-02,
-3.2334840390101392e-04,
8.8304458454470394e-02,
],
];
let fit = nebula(
&counts,
8,
150,
&subject,
&design,
2,
Some(&offset),
Some(golden_params()),
)
.expect("nebula failed");
assert_eq!(fit.n_coef, 2);
assert_eq!(fit.covariance.len(), 8 * packed_len(2));
for (g, want) in EXPECTED.iter().enumerate() {
for j in 0..2 {
assert_relative_eq!(
fit.coefficients[g * 2 + j],
want[j],
max_relative = TOL_COEF
);
assert_relative_eq!(fit.se[g * 2 + j], want[2 + j], max_relative = TOL_SE);
}
assert_relative_eq!(
fit.subject_overdispersion[g],
want[4],
max_relative = TOL_SUBJECT
);
assert_relative_eq!(fit.cell_overdispersion[g], want[5], max_relative = TOL_CELL);
for (k, w) in EXPECTED_COV[g].iter().enumerate() {
assert_relative_eq!(fit.covariance[g * 3 + k], w, max_relative = TOL_COV);
}
}
}
#[test]
fn test_sigma_at_bound_flags_a_pinned_subject_variance() {
let (counts, subject, design, offset) = fixture();
let fit = nebula(&counts, 8, 150, &subject, &design, 3, Some(&offset), None)
.expect("nebula failed");
assert_eq!(fit.sigma_at_bound.len(), fit.subject_overdispersion.len());
let (expected, _, _) = read_csv("nebula_expected.csv", true);
let floor = NebulaParams::default().min.0;
let mut flagged = Vec::new();
for g in 0..8 {
let r_subject = expected[g * 13 + 10];
let r_pinned = r_subject <= floor;
assert_eq!(
fit.sigma_at_bound[g], r_pinned,
"gene {g}: flag says {}, R reports sigma^2 = {r_subject:e}",
fit.sigma_at_bound[g]
);
if fit.sigma_at_bound[g] {
flagged.push(g);
}
}
assert_eq!(
flagged,
vec![1, 3, 6],
"genes 2, 4 and 7 of the golden are pinned, one-based"
);
let highest = fit
.subject_overdispersion
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
let params = NebulaParams {
min: (highest * 2.0, NebulaParams::default().min.1),
..Default::default()
};
let pinned = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(params),
)
.expect("nebula failed");
assert!(
pinned.sigma_at_bound.iter().all(|b| *b),
"every gene should be pinned once the floor is raised past the data"
);
}
#[test]
fn test_nebula_ln_path() {
let (counts, _, design, offset) = fixture();
let subject: Vec<usize> = (0..150).map(|i| i / 50).collect();
let mut padded = counts.clone();
let mut sparse = vec![0.0; 150];
for cell in [2, 5, 8, 11, 14, 17] {
sparse[cell] = 1.0;
}
padded.extend_from_slice(&sparse);
const EXPECTED: [[f64; 8]; 8] = [
[
-3.81659573486463533e+00,
5.38222100442079743e-01,
3.83483759002136715e-01,
4.69063795540145337e-02,
9.36246418891787113e-02,
5.35717183358771221e-02,
1.00000000000000005e-04,
2.75050598322235385e-01,
],
[
-5.22829697013958850e+00,
3.89022934226685158e-01,
4.16534653064868698e-01,
5.44653730954031065e-02,
1.08630486140098639e-01,
6.46373400145602711e-02,
1.00000000000000005e-04,
2.37867608416352794e-01,
],
[
-2.79147738513702715e+00,
4.60678103206666578e-01,
3.98587913222329615e-01,
4.51226141664423236e-02,
9.01236604810476266e-02,
5.17711920842120824e-02,
1.00000000000000005e-04,
2.82598066974932505e-01,
],
[
-5.78931113441487000e+00,
2.22185044166334139e-01,
3.07728570357372222e-01,
5.80432215750652861e-02,
1.16296244291096931e-01,
6.90662800246763103e-02,
1.00000000000000005e-04,
1.60696729601599086e-01,
],
[
-3.16815060082354449e+00,
5.19641589456372732e-01,
4.12199424890375421e-01,
4.65591163589440318e-02,
9.28337943204101818e-02,
5.24815231974455482e-02,
1.00000000000000005e-04,
2.93727040738100298e-01,
],
[
-4.29958492315647156e+00,
3.36809984235162951e-01,
2.86375115431901306e-01,
4.79413972185084511e-02,
9.58745337839272821e-02,
5.72547237128498490e-02,
1.00000000000000005e-04,
2.62887984227208105e-01,
],
[
-2.26664039047022214e+00,
4.41108571602001120e-01,
3.46229061253199466e-01,
4.55915524962416524e-02,
9.13938987320008084e-02,
5.34981551013472972e-02,
1.00000000000000005e-04,
2.96486842301256492e-01,
],
[
-4.70367062080705978e+00,
6.60875227770174489e-01,
3.84900243156389155e-01,
5.22429472723280330e-02,
1.04269433298777997e-01,
6.19743862314844629e-02,
1.00000000000000005e-04,
2.81557437161172819e-01,
],
];
let fit = nebula(&padded, 9, 150, &subject, &design, 3, Some(&offset), None)
.expect("nebula failed");
assert_eq!(fit.gene_index.len(), 9);
for (g, want) in EXPECTED.iter().enumerate() {
for j in 0..3 {
assert_relative_eq!(
fit.coefficients[g * 3 + j],
want[j],
max_relative = TOL_COEF
);
assert_relative_eq!(fit.se[g * 3 + j], want[3 + j], max_relative = TOL_SE);
}
assert_relative_eq!(
fit.subject_overdispersion[g],
want[6],
max_relative = TOL_SUBJECT
);
assert_relative_eq!(fit.cell_overdispersion[g], want[7], max_relative = TOL_CELL);
}
assert_relative_eq!(fit.subject_overdispersion[8], 1e-4, max_relative = 1e-12);
assert!(fit.cell_overdispersion[8] < 2e-3);
assert!(fit.coefficients[24..27].iter().all(|v| v.is_finite()));
assert!(fit.se[24..27].iter().all(|v| v.is_finite()));
}
#[test]
fn test_nebula_subject_with_all_zero_counts() {
let (mut counts, subject, design, offset) = fixture();
for slot in counts.iter_mut().take(25) {
*slot = 0.0;
}
assert_eq!(subject[24], subject[0]);
assert_ne!(subject[25], subject[0]);
const LOG_FC: [f64; 3] = [
-4.64175020355696155e+00,
2.11998305680204924e+00,
1.63852281831067770e+00,
];
const SE: [f64; 3] = [
4.24684864629024383e-01,
1.17140823143936190e+00,
8.69775089819186276e-01,
];
const SUBJECT: f64 = 7.23281548565090415e-01;
const CELL: f64 = 2.57684232626537890e-01;
let fit = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
)
.expect("nebula failed");
assert_eq!(fit.gene_index.len(), 8);
for j in 0..3 {
assert_relative_eq!(fit.coefficients[j], LOG_FC[j], max_relative = TOL_COEF);
assert_relative_eq!(fit.se[j], SE[j], max_relative = TOL_SE);
}
assert_relative_eq!(
fit.subject_overdispersion[0],
SUBJECT,
max_relative = TOL_SUBJECT
);
assert_relative_eq!(fit.cell_overdispersion[0], CELL, max_relative = TOL_CELL);
}
#[test]
fn test_gene_filter_drops_low_count_gene() {
let (counts, subject, design, offset) = fixture();
let mut padded = counts.clone();
let mut three = vec![0.0; 150];
for cell in [3, 40, 90] {
three[cell] = 1.0;
}
let mut six_a = vec![0.0; 150];
for cell in [2, 5, 8, 11, 14, 17] {
six_a[cell] = 1.0;
}
let mut six_b = vec![0.0; 150];
for slot in six_b.iter_mut().take(6) {
*slot = 1.0;
}
padded.extend_from_slice(&three);
padded.extend_from_slice(&six_a);
padded.extend_from_slice(&six_b);
let fit = nebula(
&padded,
11,
150,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
)
.expect("nebula failed");
assert_eq!(fit.gene_index, vec![0, 1, 2, 3, 4, 5, 6, 7, 9, 10]);
assert_eq!(fit.coefficients.len(), 10 * 3);
let fit = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(NebulaParams {
cpc: 10.0,
..golden_params()
}),
)
.expect("nebula failed");
assert_eq!(fit.gene_index, vec![0, 2, 4, 5, 6]);
}
#[test]
fn test_nebula_is_generic_over_the_float() {
let (counts, subject, design, offset) = fixture();
let wide = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
)
.expect("nebula failed");
let counts32: Vec<f32> = counts.iter().map(|v| *v as f32).collect();
let design32: Vec<f32> = design.iter().map(|v| *v as f32).collect();
let offset32: Vec<f32> = offset.iter().map(|v| *v as f32).collect();
let narrow = nebula(
&counts32,
8,
150,
&subject,
&design32,
3,
Some(&offset32),
Some(golden_params()),
)
.expect("nebula failed");
assert_eq!(wide.coefficients, narrow.coefficients);
assert_eq!(wide.se, narrow.se);
}
#[test]
fn test_nebula_sparse_matches_the_dense_entry_point() {
let (counts, subject, design, offset) = fixture();
let dense = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
)
.expect("nebula failed");
let mut data = Vec::new();
let mut indices = Vec::new();
let mut indptr = vec![0u32];
for gene in 0..8 {
for (cell, value) in counts[gene * 150..(gene + 1) * 150].iter().enumerate() {
if *value > 0.0 {
data.push(*value);
indices.push(cell as u32);
}
}
indptr.push(data.len() as u32);
}
let sparse =
CompressedSparse::from_parts(data, indices, indptr, SparseFormat::Csr, (8, 150))
.expect("malformed fixture");
let got = nebula_sparse(
&sparse,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
)
.expect("nebula_sparse failed");
assert_eq!(got.gene_index, dense.gene_index);
assert_eq!(got.coefficients, dense.coefficients);
assert_eq!(got.covariance, dense.covariance);
assert_eq!(got.se, dense.se);
assert_eq!(got.subject_overdispersion, dense.subject_overdispersion);
assert_eq!(got.cell_overdispersion, dense.cell_overdispersion);
assert_eq!(got.convergence, dense.convergence);
}
#[test]
fn test_nebula_sparse_rejects_a_cell_major_matrix() {
let (counts, subject, design, offset) = fixture();
let sparse =
CompressedSparse::from_dense(&counts, 8, 150, SparseFormat::Csr, |v: &f64| *v == 0.0)
.expect("malformed fixture")
.transpose();
assert!(matches!(
nebula_sparse(
&sparse,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
),
Err(EdgeErrors::MalformedSparse(_))
));
}
#[test]
fn test_nebula_without_an_offset() {
let (counts, subject, design, _) = fixture();
let ones = vec![1.0_f64; 150];
let with = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&ones),
Some(golden_params()),
)
.expect("nebula failed");
let without = nebula(
&counts,
8,
150,
&subject,
&design,
3,
None,
Some(golden_params()),
)
.expect("nebula failed");
assert_eq!(with.coefficients, without.coefficients);
assert_eq!(with.subject_overdispersion, without.subject_overdispersion);
}
#[test]
fn test_ln_falls_back_to_hl_below_thirty_cells_per_subject() {
let (counts, subject, design, offset) = fixture();
let ln = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(golden_params()),
)
.expect("nebula failed");
let hl = nebula(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
Some(NebulaParams {
method: NebulaMethod::Hl,
..golden_params()
}),
)
.expect("nebula failed");
assert_eq!(ln.coefficients, hl.coefficients);
assert_eq!(ln.se, hl.se);
}
#[test]
fn test_cholesky_solves_and_inverts() {
let a = [4.0, 1.0, 0.5, 1.0, 3.0, 0.25, 0.5, 0.25, 2.0];
let inverse = cholesky_inverse(&a, 3).expect("not positive definite");
for i in 0..3 {
for j in 0..3 {
let mut entry = 0.0;
for k in 0..3 {
entry += a[i * 3 + k] * inverse[k * 3 + j];
}
let want = if i == j { 1.0 } else { 0.0 };
assert_relative_eq!(entry, want, epsilon = 1e-12);
}
}
assert!(cholesky_inverse(&[1.0, 2.0, 2.0, 1.0], 2).is_none());
assert!(cholesky_solve(&[0.0], 1, &[1.0]).is_none());
}
#[test]
fn test_subject_boundaries() {
assert_eq!(
subject_boundaries(&[7, 7, 3, 3, 3, 9]).expect("valid"),
vec![0, 2, 5, 6]
);
let err = subject_boundaries(&[1, 2, 1]).expect_err("split subject");
assert!(matches!(
err,
EdgeErrors::SubjectsNotContiguous { subject: 1 }
));
}
#[allow(clippy::too_many_arguments)]
fn expect_error(
counts: &[f64],
n_genes: usize,
n_cells: usize,
subject: &[usize],
design: &[f64],
n_coef: usize,
offset: Option<&[f64]>,
params: NebulaParams,
) -> EdgeErrors {
nebula(
counts,
n_genes,
n_cells,
subject,
design,
n_coef,
offset,
Some(params),
)
.expect_err("expected a rejection")
}
#[test]
fn test_rejects_malformed_inputs() {
let (counts, subject, design, offset) = fixture();
let p = golden_params();
assert!(matches!(
expect_error(&counts, 0, 150, &subject, &design, 3, Some(&offset), p),
EdgeErrors::EmptyCounts { n_genes: 0, .. }
));
assert!(matches!(
expect_error(&counts[..8], 8, 1, &subject[..1], &design[..3], 3, None, p),
EdgeErrors::InvalidArgument(_)
));
assert!(matches!(
expect_error(&counts, 8, 150, &subject, &design, 0, Some(&offset), p),
EdgeErrors::MustBePositive(_)
));
assert!(matches!(
expect_error(
&counts[..100],
8,
150,
&subject,
&design,
3,
Some(&offset),
p
),
EdgeErrors::LengthMismatch { name: "counts", .. }
));
assert!(matches!(
expect_error(
&counts,
8,
150,
&subject,
&design[..30],
3,
Some(&offset),
p
),
EdgeErrors::ShapeMismatch { .. }
));
assert!(matches!(
expect_error(
&counts,
8,
150,
&subject[..10],
&design,
3,
Some(&offset),
p
),
EdgeErrors::LengthMismatch {
name: "subject_id",
..
}
));
assert!(matches!(
expect_error(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset[..10]),
p
),
EdgeErrors::LengthMismatch { name: "offset", .. }
));
let mut negative = counts.clone();
negative[17] = -1.0;
assert!(matches!(
expect_error(&negative, 8, 150, &subject, &design, 3, Some(&offset), p),
EdgeErrors::InvalidArgument(_)
));
let mut bad_offset = offset.clone();
bad_offset[3] = 0.0;
assert!(matches!(
expect_error(&counts, 8, 150, &subject, &design, 3, Some(&bad_offset), p),
EdgeErrors::MustBePositive(_)
));
}
#[test]
fn test_rejects_bad_subject_structure() {
let (counts, subject, design, offset) = fixture();
let p = golden_params();
let mut split = subject.clone();
split[149] = subject[0];
assert!(matches!(
expect_error(&counts, 8, 150, &split, &design, 3, Some(&offset), p),
EdgeErrors::SubjectsNotContiguous { .. }
));
let one = vec![1_usize; 150];
assert!(matches!(
expect_error(&counts, 8, 150, &one, &design, 3, Some(&offset), p),
EdgeErrors::TooFewSubjects {
required: 2,
got: 1
}
));
}
#[test]
fn test_rejects_bad_design() {
let (counts, subject, design, offset) = fixture();
let p = golden_params();
let no_intercept: Vec<f64> = (0..150)
.flat_map(|i| [design[i * 3 + 1], design[i * 3 + 2]])
.collect();
assert!(matches!(
expect_error(
&counts,
8,
150,
&subject,
&no_intercept,
2,
Some(&offset),
p
),
EdgeErrors::MissingIntercept
));
let twice: Vec<f64> = (0..150)
.flat_map(|i| [1.0, 2.0, design[i * 3 + 1]])
.collect();
assert!(matches!(
expect_error(&counts, 8, 150, &subject, &twice, 3, Some(&offset), p),
EdgeErrors::InvalidArgument(_)
));
let zeroed: Vec<f64> = (0..150)
.flat_map(|i| [1.0, 0.0, design[i * 3 + 1]])
.collect();
assert!(matches!(
expect_error(&counts, 8, 150, &subject, &zeroed, 3, Some(&offset), p),
EdgeErrors::InvalidArgument(_)
));
}
#[test]
fn test_rejects_bad_parameters() {
let (counts, subject, design, offset) = fixture();
let base = golden_params();
let cases: [(NebulaParams, &str); 4] = [
(
NebulaParams {
min: (0.0, 1e-4),
..base
},
"min",
),
(
NebulaParams {
max: (1e-5, 1000.0),
..base
},
"max",
),
(NebulaParams { eps: 0.0, ..base }, "eps"),
(NebulaParams { mincp: 0, ..base }, "mincp"),
];
for (params, name) in cases {
let err = expect_error(&counts, 8, 150, &subject, &design, 3, Some(&offset), params);
assert!(
matches!(
err,
EdgeErrors::MustBePositive(_) | EdgeErrors::InvalidBounds { .. }
),
"{name} gave {err}"
);
}
}
#[test]
fn test_rejects_when_no_gene_passes_the_filter() {
let (counts, subject, design, offset) = fixture();
let err = expect_error(
&counts,
8,
150,
&subject,
&design,
3,
Some(&offset),
NebulaParams {
cpc: 1e6,
..golden_params()
},
);
assert!(matches!(
err,
EdgeErrors::NoGenesAfterFiltering { n_genes: 8 }
));
}
fn run_search(
start: &[f64],
ord: u32,
mut f: impl FnMut(&[f64], u32, usize) -> f64,
) -> (Option<(Vec<f64>, bool)>, u32) {
let lower = vec![1e-4; start.len()];
let upper = if start.len() == 2 {
vec![10.0, 1000.0]
} else {
vec![10.0]
};
let mut search = StageTwoSearch::new(start, &lower, &upper, ord);
let mut calls = 0;
while let Some(points) = search.ask() {
let order = search.order();
let values: Vec<f64> = points
.iter()
.map(|x| {
calls += 1;
f(x, order, calls)
})
.collect();
search.tell(&values);
}
let order = search.order();
(search.result(), order)
}
#[test]
fn test_stage_two_finds_an_interior_minimum() {
let (found, order) = run_search(&[0.5, 2.0], 1, |x, _, _| {
(x[0] - 0.3).powi(2) + 0.01 * (x[1] - 4.0).powi(2)
});
let (x, failed) = found.expect("a minimiser");
assert!(!failed);
assert_eq!(order, 1);
assert_relative_eq!(x[0], 0.3, max_relative = 1e-4);
assert_relative_eq!(x[1], 4.0, max_relative = 1e-4);
}
#[test]
fn test_stage_two_retries_at_order_one_after_a_rejected_fit() {
let (found, order) = run_search(&[0.5, 2.0], 3, |x, order, calls| {
if order > 1 && calls >= 5 {
f64::INFINITY
} else {
(x[0] - 0.3).powi(2) + 0.01 * (x[1] - 4.0).powi(2)
}
});
assert_eq!(order, 1);
let (x, failed) = found.expect("the order-one pass finds it");
assert!(!failed);
assert_relative_eq!(x[0], 0.3, max_relative = 1e-4);
}
#[test]
fn test_stage_two_abandons_a_non_finite_order_one_pass() {
let (found, _) = run_search(&[0.5, 2.0], 1, |x, _, calls| {
if calls == 7 {
f64::NAN
} else {
(x[0] - 0.3).powi(2) + 0.01 * (x[1] - 4.0).powi(2)
}
});
assert!(found.is_none());
}
#[test]
fn test_stage_two_reports_a_roundoff_limited_search_as_failed() {
let mut search = StageTwoSearch::new(&[0.0, 0.0], &[-2.0, -2.0], &[2.0, 2.0], 1);
while let Some(points) = search.ask() {
let values: Vec<f64> = points.iter().map(|x| (x[0] + x[1] - 1.0).powi(2)).collect();
search.tell(&values);
}
let (x, failed) = search.result().expect("the point is kept");
assert!(failed);
assert_relative_eq!(x[0] + x[1], 1.0, epsilon = 1e-6);
}
#[test]
fn test_one_component_search_keeps_a_genuine_bound_after_the_restart() {
let (found, _) = run_search(&[0.5], 1, |x, _, _| x[0]);
let (x, failed) = found.expect("a minimiser");
assert!(!failed);
assert_eq!(x[0], 1e-4);
}
#[test]
fn test_restart_point_is_the_geometric_mean_of_bound_and_start() {
let search = StageTwoSearch::new(&[0.25], &[1e-4], &[10.0], 1);
let x0 = search.restart_point(&[1e-4]).expect("on its bound");
assert_relative_eq!(x0[0], (1e-4f64 * 0.25).sqrt(), max_relative = 1e-15);
assert!(search.restart_point(&[0.1]).is_none());
}
type NewtonGene = (
Vec<f64>,
Vec<f64>,
Vec<f64>,
Vec<usize>,
Vec<f64>,
Vec<usize>,
);
fn newton_gene() -> NewtonGene {
use rand::prelude::*;
use rand::rngs::SmallRng;
let mut rng = SmallRng::seed_from_u64(17);
let fid: Vec<usize> = (0..=6).map(|s| s * 80).collect();
let mut design = Vec::new();
let mut log_offset = Vec::new();
let mut counts = Vec::new();
let mut cells = Vec::new();
let mut totals = vec![0.0; 6];
for c in 0..480 {
let s = c / 80;
design.extend([1.0, (s % 2) as f64 - 0.5, rng.random_range(-1.0..1.0)]);
log_offset.push(rng.random_range(-0.5..0.5));
if rng.random_bool(0.35) {
let y = rng.random_range(1..6) as f64;
counts.push(y);
cells.push(c);
totals[s] += y;
}
}
(design, log_offset, counts, cells, totals, fid)
}
#[test]
fn test_stage_one_newton_lands_on_the_quasi_newton_optimum() {
let (design, log_offset, counts, cells, totals, fid) = newton_gene();
let gene = GeneData::new(&design, &log_offset, &counts, &cells, &totals, &fid, 3)
.expect("valid gene");
let start = [0.0, 0.0, 0.0, 1.0, 1.0];
let lower = [-BETA_BOUND, -BETA_BOUND, -BETA_BOUND, 1e-4, 1e-4];
let upper = [BETA_BOUND, BETA_BOUND, BETA_BOUND, 10.0, 1000.0];
let mut scratch = PtmgScratch::new(&gene);
let (x, f) =
newton_marginal(&gene, &mut scratch, &start, &lower, &upper).expect("Newton converges");
let lbfgs = minimise(
|x, g| {
let (value, gradient) = ptmg_value_and_gradient_with(&gene, x, &mut scratch);
g.copy_from_slice(&gradient);
value
},
&start,
&lower,
&upper,
Some(LbfgsbParams {
ftol: STAGE_ONE_FTOL,
pgtol: STAGE_ONE_PGTOL,
max_iter: STAGE_ONE_MAX_ITER,
max_line_search: STAGE_ONE_MAX_LINE_SEARCH,
..LbfgsbParams::default()
}),
)
.expect("quasi-Newton runs");
assert!(f <= lbfgs.f + 1e-9 * lbfgs.f.abs());
for (a, b) in x.iter().zip(&lbfgs.x) {
assert_relative_eq!(*a, *b, max_relative = 1e-4, epsilon = 1e-6);
}
let (x2, f2) =
newton_marginal(&gene, &mut scratch, &x, &lower, &upper).expect("already converged");
assert!(f2 <= f && f - f2 <= STAGE_ONE_FTOL * f.abs());
for (a, b) in x2.iter().zip(&x) {
assert_relative_eq!(*a, *b, max_relative = 1e-6, epsilon = 1e-9);
}
}
#[test]
fn test_stage_one_newton_holds_a_pinned_coordinate() {
let (design, log_offset, counts, cells, totals, fid) = newton_gene();
let gene = GeneData::new(&design, &log_offset, &counts, &cells, &totals, &fid, 3)
.expect("valid gene");
let start = [0.0, 0.0, 0.0, 1e-4, 1.0];
let lower = [-BETA_BOUND, -BETA_BOUND, -BETA_BOUND, 1e-4, 1e-4];
let upper = [BETA_BOUND, BETA_BOUND, BETA_BOUND, 2e-4, 1000.0];
let mut scratch = PtmgScratch::new(&gene);
if let Some((x, _)) = newton_marginal(&gene, &mut scratch, &start, &lower, &upper) {
assert!(x[3] >= 1e-4 && x[3] <= 2e-4);
}
}
}