use std::cell::Cell;
use rayon::prelude::*;
use crate::numeric::gamma::ln_gamma;
use crate::numeric::lbfgsb::{LbfgsbParams, minimise};
use crate::numeric::optimise::{NelderMeadParams, nelder_mead};
use crate::prelude::*;
use crate::sc::pml::{
CONV_SINGULAR, CONV_SUCCESS, PmlData, PmlParams, PmlVariance, check_convergence, opt_pml,
};
use crate::sc::ptmg::{
GeneData, cell_level_columns, centre_design, cumsum_y, design_cv, offset_summary,
positive_indices, ptmg_value_and_gradient,
};
use crate::sc::test::packed_len;
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_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 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,
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> {
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 total: f64 = totals[g * n_subjects..(g + 1) * n_subjects].iter().sum();
let (indices, _) = sparse.outer(g);
total / (n_cells as f64) > params.cpc && indices.len() >= params.mincp
})
.collect();
if kept.is_empty() {
return Err(EdgeErrors::NoGenesAfterFiltering { n_genes });
}
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,
};
let outcomes: Vec<GeneOutcome> = 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::<Result<Vec<_>, EdgeErrors>>()?;
Ok(assemble(
outcomes,
&kept,
&sds,
intercept,
n_coef,
params.min.0,
))
}
struct Shared<'a> {
design: &'a [f64],
log_offset: &'a [f64],
fid: &'a [usize],
n_cells: usize,
n_coef: usize,
n_subjects: usize,
intercept: usize,
log_mean_offset: f64,
cells_per_subject: f64,
cv2: f64,
cell_columns: &'a [usize],
method: NebulaMethod,
params: NebulaParams,
}
struct GeneOutcome {
beta: Vec<f64>,
covariance: Vec<f64>,
se: Vec<f64>,
subject: f64,
cell: f64,
convergence: i32,
}
fn fit_gene(
shared: &Shared<'_>,
counts: &crate::sc::ptmg::GeneCounts,
subject_totals: &[f64],
) -> Result<GeneOutcome, 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, &start, &lower, &upper);
let mut convergence = if stage_one_failed { 0 } else { CONV_SUCCESS };
let mut sigma = stage_one[n_coef];
let mut 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 pml = PmlData {
design: shared.design,
offset: shared.log_offset,
counts: &counts.counts,
cell_index: &counts.cells,
subject_start: shared.fid,
subject_total: subject_totals,
};
let refit_both = |sigma: f64, gamma: f64| -> (f64, f64, bool) {
let x = refine_variance(
shared,
&pml,
&beta_start,
counts,
ord,
&[sigma, gamma],
None,
);
match x {
Some(v) => (v[0], v[1], true),
None => (sigma, gamma, false),
}
};
match shared.method {
NebulaMethod::Hl => {
let (s, g, ok) = refit_both(sigma, gamma);
sigma = s;
gamma = g;
convergence = if ok { CONV_SUCCESS } else { CONV_OUTER_FAILED };
}
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() {
let (s, g, ok) = refit_both(sigma, gamma);
sigma = s;
gamma = g;
convergence = if ok { CONV_SUCCESS } else { CONV_OUTER_FAILED };
} 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 {
let x = refine_variance(
shared,
&pml,
&beta_start,
counts,
ord,
&[sigma],
Some(gamma),
);
match x {
Some(v) => {
sigma = v[0];
convergence = CONV_SUCCESS;
}
None => convergence = CONV_OUTER_FAILED,
}
}
}
}
}
let final_params = PmlParams {
reml: params.reml,
eps: params.eps,
ord: 1,
..PmlParams::default()
};
beta_start[shared.intercept] -= sigma / 2.0;
let fit = opt_pml(
&pml,
&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 minimise_marginal(
gene: &GeneData<'_>,
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 best_f = ptmg_value_and_gradient(gene, &best).0;
if !best_f.is_finite() {
return (best, true);
}
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(gene, x);
g.copy_from_slice(&gradient);
value
},
&best,
lower,
upper,
Some(params),
) && step.f < best_f
{
best = step.x;
}
(best, false)
}
struct VarianceObjective<'a> {
data: &'a PmlData<'a>,
beta_start: &'a [f64],
intercept: usize,
params: PmlParams,
n_cells: f64,
n_subjects: f64,
counts: &'a [f64],
n_positive: f64,
n_one: f64,
n_two: f64,
fixed_cell: Option<f64>,
invalid: Cell<bool>,
}
impl VarianceObjective<'_> {
fn value(&self, x: &[f64]) -> 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) {
self.invalid.set(true);
return f64::INFINITY;
}
let exps = subject.exp();
let alpha = 1.0 / (exps - 1.0);
let lambda = 1.0 / (exps.sqrt() * (exps - 1.0));
let mut beta = self.beta_start.to_vec();
beta[self.intercept] -= subject / 2.0;
let fit = match opt_pml(
self.data,
&beta,
&PmlVariance { subject, cell },
Some(self.params),
) {
Ok(f) => f,
Err(_) => {
self.invalid.set(true);
return f64::INFINITY;
}
};
if fit.second_order < -1.0 {
self.invalid.set(true);
return f64::INFINITY;
}
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);
let mut tail = 0.0;
for &y in self.counts {
if y != 1.0 && y != 2.0 {
tail += ln_gamma(y + cell);
}
}
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 {
self.invalid.set(true);
f64::INFINITY
}
}
}
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>> {
debug_assert_eq!(start.len(), if fixed_cell.is_some() { 1 } else { 2 });
let params = &shared.params;
let (lower, upper) = 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],
)
};
for order in [ord, 1] {
let objective = 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,
invalid: Cell::new(false),
};
let evaluate = |x: &[f64]| objective.value(x);
let found = minimise_box(&evaluate, start, &lower, &upper);
if found.is_some() && !objective.invalid.get() {
return found;
}
if order == 1 {
return found;
}
}
None
}
fn minimise_box(
objective: &dyn Fn(&[f64]) -> f64,
start: &[f64],
lower: &[f64],
upper: &[f64],
) -> Option<Vec<f64>> {
let n = start.len();
let clamped = |x: &[f64]| -> Vec<f64> {
(0..n)
.map(|j| x[j].clamp(lower[j], upper[j]))
.collect::<Vec<f64>>()
};
let evaluate = |x: &[f64]| objective(&clamped(x));
let x0 = clamped(start);
if !evaluate(&x0).is_finite() {
return None;
}
let simplex = nelder_mead(
evaluate,
&x0,
Some(NelderMeadParams {
xatol: VARIANCE_XATOL,
fatol: VARIANCE_FATOL,
max_iter: VARIANCE_MAX_ITER,
}),
)
.ok()?;
let mut best = clamped(&simplex.x);
if !objective(&best).is_finite() {
return None;
}
for width in POLISH_WIDTHS {
let step: Vec<f64> = (0..n)
.map(|j| (width * best[j].abs()).max(POLISH_FLOOR[j]))
.collect();
if let Some(next) = quadratic_polish(objective, &best, lower, upper, &step) {
best = next;
}
}
Some(best)
}
fn quadratic_polish(
objective: &dyn Fn(&[f64]) -> f64,
x: &[f64],
lower: &[f64],
upper: &[f64],
step: &[f64],
) -> Option<Vec<f64>> {
let n = x.len();
let offsets: Vec<[f64; 3]> = (0..n)
.map(|j| {
if x[j] - step[j] < lower[j] {
[0.0, 1.0, 2.0]
} else if x[j] + step[j] > upper[j] {
[-2.0, -1.0, 0.0]
} else {
[-1.0, 0.0, 1.0]
}
})
.collect();
let n_terms = 1 + 2 * n + n * (n - 1) / 2;
let n_points = 3_usize.pow(n as u32);
let mut rows = Vec::with_capacity(n_points * n_terms);
let mut values = 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 = offsets[j][rest % 3];
rest /= 3;
let p = (x[j] + node * step[j]).clamp(lower[j], upper[j]);
u[j] = (p - x[j]) / step[j];
}
let candidate: Vec<f64> = (0..n).map(|j| x[j] + u[j] * step[j]).collect();
let f = objective(&candidate);
if !f.is_finite() {
return None;
}
values.push(f);
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] * step[j]).clamp(lower[j], upper[j]))
.collect(),
)
}
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 }
));
}
}