use std::cell::{Cell, RefCell};
use edge_rs::dispersion::apl::AplWorkspace;
use edge_rs::glm::levenberg::{LevenbergParams, mglm_levenberg};
use edge_rs::prelude::*;
use crate::errors::ScErrors;
use bixverse_core::core::math::optimise::brent_fmin;
use bixverse_core::errors::CoreErrors;
const LOG_ALPHA_LOWER: f64 = -25.0;
const LOG_ALPHA_UPPER: f64 = 5.0;
const LOG_ALPHA_TOL: f64 = 1e-8;
const ALPHA_POISSON_FLOOR: f64 = 1e-10;
const START_DISPERSION: f64 = 4.539_992_976_248_485e-5;
const COEF_TOL: f64 = 1e-14;
pub const LOG_UMI_COEF: f64 = std::f64::consts::LN_10;
#[derive(Clone, Debug, PartialEq)]
pub struct NbOffsetFit {
pub theta: f64,
pub coefficients: Vec<f64>,
pub converged: bool,
}
impl NbOffsetFit {
pub fn intercept(&self) -> f64 {
self.coefficients[0]
}
}
pub fn fit_nb_offset_gene(
counts: &[f64],
log_offset: &[f64],
design: &[f64],
n_coef: usize,
) -> Result<NbOffsetFit, ScErrors> {
let n = counts.len();
if log_offset.len() != n {
return Err(CoreErrors::LengthMismatch {
name: "log_offset",
expected: n,
found: log_offset.len(),
}
.into());
}
if n == 0 || n_coef == 0 {
return Err(CoreErrors::LengthMismatch {
name: "counts",
expected: 1,
found: n,
}
.into());
}
if design.len() != n * n_coef {
return Err(CoreErrors::LengthMismatch {
name: "design",
expected: n * n_coef,
found: design.len(),
}
.into());
}
if counts.iter().all(|&y| y == 0.0) {
let mut coefficients = vec![0.0; n_coef];
coefficients[0] = f64::NEG_INFINITY;
return Ok(NbOffsetFit {
theta: f64::INFINITY,
coefficients,
converged: true,
});
}
let offset = Recycled::BySample(log_offset.to_vec());
let first_error: RefCell<Option<String>> = RefCell::new(None);
let any_ok = Cell::new(false);
let mut workspace = AplWorkspace::new(design, n, n_coef).map_err(ScErrors::from)?;
workspace
.begin_gene(
counts,
RecycledRow::Values(log_offset),
None,
START_DISPERSION,
)
.map_err(ScErrors::from)?;
let log_alpha = brent_fmin(
LOG_ALPHA_LOWER,
LOG_ALPHA_UPPER,
|log_alpha: f64| -> f64 {
match workspace.eval(log_alpha.exp()) {
Ok(v) => {
any_ok.set(true);
-v
}
Err(e) => {
first_error
.borrow_mut()
.get_or_insert_with(|| e.to_string());
f64::INFINITY
}
}
},
LOG_ALPHA_TOL,
);
if !any_ok.get() {
return Err(ScErrors::SctDispersionSearchFailed {
reason: first_error
.take()
.unwrap_or_else(|| "the profile likelihood failed everywhere".to_string()),
});
}
let alpha = log_alpha.exp();
let (theta, dispersion) = if alpha <= ALPHA_POISSON_FLOOR {
(f64::INFINITY, 0.0)
} else {
(1.0 / alpha, alpha)
};
let coef_params = LevenbergParams {
tol: COEF_TOL,
..LevenbergParams::default()
};
let fit = mglm_levenberg(
counts,
1,
n,
design,
n_coef,
&Recycled::Scalar(dispersion),
&offset,
None,
None,
Some(coef_params),
)
.map_err(ScErrors::from)?;
let converged = !fit.failed[0] && fit.iterations[0] < coef_params.max_iter;
Ok(NbOffsetFit {
theta,
coefficients: fit.coefficients,
converged,
})
}
#[cfg(test)]
mod tests {
use super::*;
const POISSON_THETA_FLOOR: f64 = 1e5;
fn fixture() -> (Vec<Vec<f64>>, Vec<f64>) {
const MODULUS: [u64; 12] = [2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377];
let (n_genes, n_cells) = (12_usize, 400_usize);
let mut state = 20_260_101_u64;
let mut counts = vec![vec![0.0_f64; n_cells]; n_genes];
for (g, row) in counts.iter_mut().enumerate() {
for slot in row.iter_mut() {
state = (1_664_525_u64
.wrapping_mul(state)
.wrapping_add(1_013_904_223))
% 4_294_967_296;
*slot = (state % MODULUS[g]) as f64;
}
}
let total_umi: Vec<f64> = (0..n_cells)
.map(|c| counts.iter().map(|row| row[c]).sum())
.collect();
(counts, total_umi)
}
#[test]
fn test_nb_fixture_matches_r() {
let (counts, total_umi) = fixture();
assert_eq!(&counts[0][..10], &[0., 1., 0., 1., 0., 1., 0., 1., 0., 1.]);
assert_eq!(&total_umi[..5], &[537.0, 364.0, 659.0, 379.0, 256.0]);
let row_sums: Vec<f64> = counts.iter().map(|r| r.iter().sum()).collect();
assert_eq!(
row_sums,
vec![
200.0, 386.0, 749.0, 1400.0, 2435.0, 3847.0, 6590.0, 10701.0, 17882.0, 28504.0,
47179.0, 74795.0
]
);
}
#[test]
fn test_nb_offset_fit_is_the_cox_reid_joint_mle() {
let (counts, total_umi) = fixture();
let log_offset: Vec<f64> = total_umi.iter().map(|u| u.ln()).collect();
let ones = vec![1.0_f64; log_offset.len()];
let r_theta = [
60_488_309.625_924_04,
2_191_807_574.316_569_3,
5.513_574_095_376_203,
3.133_668_357_936_107_5,
2.368_659_443_652_557,
1.957_655_842_984_292_3,
1.854_434_151_652_939_7,
1.817_632_203_480_953_3,
2.126_488_146_916_778,
1.799_514_028_743_560_3,
1.960_104_840_351_798_4,
2.438_988_023_082_542_3,
];
let r_intercept = [
-6.880_733_455_250_665,
-6.223_213_452_934_95,
-5.538_823_940_048_09,
-4.892_388_460_356_183,
-4.325_969_879_985_012,
-3.854_724_993_628_699,
-3.314_049_780_990_182,
-2.843_407_974_978_344,
-2.327_788_350_269_296,
-1.884_251_074_082_975_2,
-1.420_506_386_373_765,
-1.012_693_909_316_931_6,
];
for (g, (&rt, &ri)) in r_theta.iter().zip(r_intercept.iter()).enumerate() {
let fit = fit_nb_offset_gene(&counts[g], &log_offset, &ones, 1).unwrap();
let rel_i = ((fit.intercept() - ri) / ri).abs();
assert!(
rel_i < 1e-8,
"gene {g}: intercept {} vs R {ri} (rel {rel_i:.3e})",
fit.intercept()
);
if rt > POISSON_THETA_FLOOR {
assert!(
fit.theta > POISSON_THETA_FLOOR,
"gene {g}: theta {} should be at the Poisson boundary",
fit.theta
);
continue;
}
let rel_t = ((fit.theta - rt) / rt).abs();
assert!(
rel_t < 1e-6,
"gene {g}: theta {} vs R {rt} (rel {rel_t:.3e})",
fit.theta
);
}
}
#[test]
fn test_nb_offset_fit_band_against_glmgampoi() {
let (counts, total_umi) = fixture();
let log_offset: Vec<f64> = total_umi.iter().map(|u| u.ln()).collect();
let ones = vec![1.0_f64; log_offset.len()];
let gp_theta = [
f64::INFINITY,
2_960_918.101_872_272,
5.358_462_058_249_337,
3.126_993_566_920_339,
2.369_260_436_554_973,
1.958_116_250_503_067_4,
1.854_705_676_226_394,
1.817_738_562_108_434_5,
2.126_576_339_459_727,
1.799_560_754_399_995_4,
1.960_152_479_199_029,
2.439_049_644_472_123,
];
let mut worst = 0.0_f64;
for (g, >) in gp_theta.iter().enumerate() {
if gt > POISSON_THETA_FLOOR {
continue;
}
let fit = fit_nb_offset_gene(&counts[g], &log_offset, &ones, 1).unwrap();
worst = worst.max(((fit.theta - gt) / gt).abs());
}
assert!(
worst < 0.03,
"theta drifted {worst:.4} from glmGamPoi, further than the measured 2.9%"
);
}
#[test]
fn test_nb_offset_fit_rejects_length_mismatch() {
assert!(matches!(
fit_nb_offset_gene(&[1.0, 2.0], &[0.0], &[1.0, 1.0], 1),
Err(ScErrors::Core(CoreErrors::LengthMismatch { .. }))
));
}
#[test]
fn test_nb_offset_fit_short_circuits_an_all_zero_gene() {
let counts = vec![0.0_f64; 50];
let log_offset: Vec<f64> = (0..50).map(|i| (100.0 + i as f64).ln()).collect();
let fit = fit_nb_offset_gene(&counts, &log_offset, &vec![1.0; counts.len()], 1).unwrap();
assert!(fit.theta.is_infinite());
assert_eq!(fit.intercept(), f64::NEG_INFINITY);
}
#[test]
fn test_nb_offset_fit_reports_the_poisson_boundary() {
let counts = vec![5.0_f64; 200];
let total_umi = vec![1000.0_f64; 200];
let log_offset: Vec<f64> = total_umi.iter().map(|u| u.ln()).collect();
let ones = vec![1.0_f64; counts.len()];
let fit = fit_nb_offset_gene(&counts, &log_offset, &ones, 1).unwrap();
assert!(
fit.theta > POISSON_THETA_FLOOR,
"theta {} should be at the Poisson boundary",
fit.theta
);
let sum_y: f64 = counts.iter().sum();
let sum_off: f64 = total_umi.iter().sum();
let poisson_mle = (sum_y / sum_off).ln();
assert!((fit.intercept() - poisson_mle).abs() < 1e-10);
}
}