use crate::errors::ScErrors;
use crate::sc_processing::residuals::{
ResidualSource, intersect_gene_sets, validate_clip_range, validate_groups,
validate_residual_row_inputs,
};
use bixverse_core::errors::CoreErrors;
const DEFAULT_THETA: f64 = 100.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AprParams {
pub theta: f64,
pub min_cells: usize,
pub clip_range: Option<(f64, f64)>,
}
impl Default for AprParams {
fn default() -> Self {
Self {
theta: DEFAULT_THETA,
min_cells: 5,
clip_range: None,
}
}
}
impl AprParams {
pub fn resolve_clip_range(&self, n_cells: usize) -> (f64, f64) {
self.clip_range.unwrap_or_else(|| {
let c = (n_cells as f64).sqrt();
(-c, c)
})
}
pub fn validate(&self) -> Result<(), ScErrors> {
if self.theta <= 0.0 || self.theta.is_nan() {
return Err(ScErrors::AnalyticPearsonInvalidTheta { theta: self.theta });
}
if let Some(clip) = self.clip_range {
validate_clip_range(clip)?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AprModel {
pub genes: Vec<usize>,
pub gene_sums: Vec<f64>,
pub total: f64,
pub theta: f64,
pub clip_range: (f64, f64),
}
impl AprModel {
pub fn len(&self) -> usize {
self.genes.len()
}
pub fn is_empty(&self) -> bool {
self.genes.is_empty()
}
pub fn position(&self, gene: usize) -> Option<usize> {
self.genes.binary_search(&gene).ok()
}
pub fn p_gene(&self, pos: usize) -> f64 {
self.gene_sums[pos] / self.total
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct AprGeneParams {
p_gene: f64,
theta: f64,
clip: (f64, f64),
}
impl AprGeneParams {
pub(crate) fn new(model: &AprModel, pos: usize) -> Self {
Self {
p_gene: model.p_gene(pos),
theta: model.theta,
clip: model.clip_range,
}
}
#[inline(always)]
pub(crate) fn residual(&self, cell_total: f64, y: f64) -> f32 {
let mu = self.p_gene * cell_total;
let var = mu + mu * mu / self.theta;
let (lo, hi) = self.clip;
if var <= 0.0 {
return 0.0;
}
((y - mu) / var.sqrt()).clamp(lo, hi) as f32
}
}
#[derive(Clone, Debug)]
pub struct AprResiduals<'a> {
models: &'a [AprModel],
cell_totals: &'a [f64],
genes: Vec<usize>,
positions: Vec<Vec<u32>>,
group_of_cell: Vec<u32>,
}
impl<'a> AprResiduals<'a> {
pub fn new(
models: &'a [AprModel],
cell_totals: &'a [f64],
group_of_cell: Vec<u32>,
) -> Result<Self, ScErrors> {
let n_groups = validate_groups(&group_of_cell, cell_totals.len())?;
if n_groups != models.len() {
return Err(ScErrors::ResidualGroupModelCountMismatch {
implied: n_groups,
models: models.len(),
});
}
for model in models {
validate_clip_range(model.clip_range)?;
}
let sets: Vec<&[usize]> = models.iter().map(|m| m.genes.as_slice()).collect();
let genes = intersect_gene_sets(&sets)?;
let positions = models
.iter()
.map(|model| {
genes
.iter()
.map(|&g| {
model
.position(g)
.map(|p| p as u32)
.ok_or(ScErrors::SctGeneNotModelled { gene: g })
})
.collect::<Result<Vec<u32>, ScErrors>>()
})
.collect::<Result<Vec<_>, ScErrors>>()?;
Ok(Self {
models,
cell_totals,
genes,
positions,
group_of_cell,
})
}
pub fn single(
model: &'a AprModel,
cell_totals: &'a [f64],
) -> Result<AprResiduals<'a>, ScErrors> {
let group_of_cell = vec![0_u32; cell_totals.len()];
Self::new(std::slice::from_ref(model), cell_totals, group_of_cell)
}
pub fn models(&self) -> &[AprModel] {
self.models
}
}
impl ResidualSource for AprResiduals<'_> {
fn genes(&self) -> &[usize] {
&self.genes
}
fn n_cells(&self) -> usize {
self.cell_totals.len()
}
fn group_of_cell(&self) -> &[u32] {
&self.group_of_cell
}
fn n_groups(&self) -> usize {
self.models.len()
}
fn residual_row(
&self,
counts: &[f64],
indices: &[u32],
gene_pos: usize,
out: &mut [f32],
) -> Result<(), ScErrors> {
if gene_pos >= self.genes.len() {
return Err(ScErrors::SctGeneIndexOutOfRange {
index: gene_pos,
n_genes: self.genes.len(),
});
}
if out.len() != self.cell_totals.len() {
return Err(CoreErrors::LengthMismatch {
name: "out",
expected: self.cell_totals.len(),
found: out.len(),
}
.into());
}
validate_residual_row_inputs(counts, indices, out.len())?;
let per_group: Vec<AprGeneParams> = self
.models
.iter()
.zip(&self.positions)
.map(|(model, pos)| AprGeneParams::new(model, pos[gene_pos] as usize))
.collect();
if let [gene] = per_group.as_slice() {
for (slot, &total) in out.iter_mut().zip(self.cell_totals) {
*slot = gene.residual(total, 0.0);
}
for (&i, &y) in indices.iter().zip(counts.iter()) {
let c = i as usize;
out[c] = gene.residual(self.cell_totals[c], y);
}
return Ok(());
}
for (c, slot) in out.iter_mut().enumerate() {
let gene = &per_group[self.group_of_cell[c] as usize];
*slot = gene.residual(self.cell_totals[c], 0.0);
}
for (&i, &y) in indices.iter().zip(counts.iter()) {
let c = i as usize;
let gene = &per_group[self.group_of_cell[c] as usize];
out[c] = gene.residual(self.cell_totals[c], y);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn toy_model() -> AprModel {
AprModel {
genes: vec![0, 1],
gene_sums: vec![6.0, 9.0],
total: 15.0,
theta: 100.0,
clip_range: (-10.0, 10.0),
}
}
#[test]
fn test_apr_residual_matches_closed_form() {
let model = toy_model();
let gene = AprGeneParams::new(&model, 0);
let expected = (3.0 - 2.0) / 2.04_f64.sqrt();
assert_relative_eq!(gene.residual(5.0, 3.0) as f64, expected, epsilon = 1e-6);
}
#[test]
fn test_apr_residual_poisson_limit() {
let mut model = toy_model();
model.theta = f64::INFINITY;
let gene = AprGeneParams::new(&model, 0);
let expected = (3.0 - 2.0) / 2.0_f64.sqrt();
assert_relative_eq!(gene.residual(5.0, 3.0) as f64, expected, epsilon = 1e-6);
}
#[test]
fn test_apr_residual_clips() {
let mut model = toy_model();
model.clip_range = (-0.5, 0.5);
let gene = AprGeneParams::new(&model, 0);
assert_relative_eq!(gene.residual(5.0, 100.0) as f64, 0.5, epsilon = 1e-9);
assert_relative_eq!(gene.residual(5.0, 0.0) as f64, -0.5, epsilon = 1e-9);
}
#[test]
fn test_apr_residual_empty_cell_is_zero() {
let model = toy_model();
let gene = AprGeneParams::new(&model, 0);
assert_eq!(gene.residual(0.0, 0.0), 0.0);
}
#[test]
fn test_apr_residual_row_zeros_and_nonzeros() {
let model = toy_model();
let totals = [5.0, 5.0, 5.0];
let source = AprResiduals::single(&model, &totals).unwrap();
let mut row = vec![0.0_f32; 3];
source.residual_row(&[3.0], &[1], 0, &mut row).unwrap();
let gene = AprGeneParams::new(&model, 0);
assert_relative_eq!(row[0], gene.residual(5.0, 0.0), epsilon = 1e-9);
assert_relative_eq!(row[1], gene.residual(5.0, 3.0), epsilon = 1e-9);
assert_relative_eq!(row[2], gene.residual(5.0, 0.0), epsilon = 1e-9);
}
#[test]
fn test_apr_residual_row_routes_cells_to_their_group() {
let a = toy_model();
let b = AprModel {
genes: vec![0, 1],
gene_sums: vec![1.0, 9.0],
total: 10.0,
theta: 100.0,
clip_range: (-10.0, 10.0),
};
let models = [a.clone(), b.clone()];
let totals = [5.0, 5.0];
let source = AprResiduals::new(&models, &totals, vec![0, 1]).unwrap();
let mut row = vec![0.0_f32; 2];
source.residual_row(&[], &[], 0, &mut row).unwrap();
assert_relative_eq!(
row[0],
AprGeneParams::new(&a, 0).residual(5.0, 0.0),
epsilon = 1e-9
);
assert_relative_eq!(
row[1],
AprGeneParams::new(&b, 0).residual(5.0, 0.0),
epsilon = 1e-9
);
assert_ne!(row[0], row[1]);
}
#[test]
fn test_apr_residuals_intersects_gene_sets() {
let a = AprModel {
genes: vec![0, 1, 2],
gene_sums: vec![1.0, 2.0, 3.0],
total: 6.0,
theta: 100.0,
clip_range: (-10.0, 10.0),
};
let b = AprModel {
genes: vec![1, 2, 5],
gene_sums: vec![2.0, 3.0, 4.0],
total: 9.0,
theta: 100.0,
clip_range: (-10.0, 10.0),
};
let models = [a, b];
let totals = [5.0, 5.0];
let source = AprResiduals::new(&models, &totals, vec![0, 1]).unwrap();
assert_eq!(source.genes(), &[1, 2]);
}
#[test]
fn test_apr_params_rejects_non_positive_theta() {
let params = AprParams {
theta: 0.0,
..Default::default()
};
assert!(matches!(
params.validate(),
Err(ScErrors::AnalyticPearsonInvalidTheta { .. })
));
}
#[test]
fn test_apr_params_default_clip_is_sqrt_n() {
let params = AprParams::default();
let (lo, hi) = params.resolve_clip_range(100);
assert_relative_eq!(lo, -10.0, epsilon = 1e-12);
assert_relative_eq!(hi, 10.0, epsilon = 1e-12);
}
}