use faer::{Mat, MatRef};
use rayon::prelude::*;
use crate::core::math::matrix_helpers::subset_rows;
use crate::core::math::vector_helpers::quantile_sorted;
use crate::ml::gp::{LandmarkGpParams, fit_landmark_gp};
use crate::prelude::*;
use crate::single_cell::sc_trajectory::palantir::PalantirResult;
pub const PSEUDOTIME_RES: usize = 500;
const GENE_TRENDS_MIN_CELLS: usize = 3;
const GENE_TRENDS_MIN_RESOLUTION: usize = 2;
#[derive(Clone, Copy, Debug)]
pub struct BranchSelectionParams {
pub q: f64,
pub eps: f64,
pub resolution: usize,
}
impl BranchSelectionParams {
pub fn new(q: f64, eps: f64, resolution: usize) -> Self {
Self { q, eps, resolution }
}
}
impl Default for BranchSelectionParams {
fn default() -> Self {
Self {
q: 1e-2,
eps: 1e-2,
resolution: PSEUDOTIME_RES,
}
}
}
pub fn select_branch_cells(
pseudotime: &[f32],
branch_probs: MatRef<'_, f32>,
params: &BranchSelectionParams,
) -> Result<Vec<Vec<usize>>, BixverseErrors> {
let n = pseudotime.len();
let n_fates = branch_probs.ncols();
if n == 0 || n_fates == 0 {
return Err(BixverseErrors::GpEmptyInput {
name: "branch selection input",
});
}
if branch_probs.nrows() != n {
return Err(BixverseErrors::GeneTrendsShapeMismatch {
n_cells: branch_probs.nrows(),
n_pseudotime: n,
});
}
if pseudotime.iter().any(|v| !v.is_finite()) {
return Err(BixverseErrors::GpNonFiniteInput { name: "pseudotime" });
}
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| pseudotime[a].total_cmp(&pseudotime[b]));
let resolution = params.resolution.clamp(1, n);
let step = (n / resolution).max(1);
let n_steps = n / step;
let uniform = 1.0 / n_fates as f64;
let upper = 1.0 - params.q;
let masks: Vec<Vec<usize>> = (0..n_fates)
.into_par_iter()
.map(|fate| {
let sorted_probs: Vec<f64> = order
.iter()
.map(|&cell| {
let v = branch_probs[(cell, fate)] as f64;
if v.is_nan() { uniform } else { v }
})
.collect();
let mut prefix: Vec<f64> = Vec::with_capacity(n);
let mut merged: Vec<f64> = Vec::with_capacity(n);
let mut bucket: Vec<f64> = Vec::with_capacity(step);
let mut thresholds = vec![0.0_f64; n];
let mut r = 0usize;
for i in 0..n_steps {
let (l, hi) = (i * step, (i + 1) * step);
r = hi;
bucket.clear();
bucket.extend_from_slice(&sorted_probs[l..hi]);
bucket.sort_by(|a, b| a.total_cmp(b));
merge_sorted(&prefix, &bucket, &mut merged);
std::mem::swap(&mut prefix, &mut merged);
let threshold = quantile_sorted(&prefix, upper);
thresholds[l..hi].fill(threshold);
}
if r < n {
bucket.clear();
bucket.extend_from_slice(&sorted_probs[r..]);
bucket.sort_by(|a, b| a.total_cmp(b));
merge_sorted(&prefix, &bucket, &mut merged);
thresholds[r..].fill(quantile_sorted(&merged, upper));
}
let mut running = f64::NEG_INFINITY;
for t in thresholds.iter_mut() {
running = running.max(*t);
*t = running;
}
let mut cells: Vec<usize> = (0..n)
.filter(|&pos| thresholds[pos] - params.eps < sorted_probs[pos])
.map(|pos| order[pos])
.collect();
cells.sort_unstable();
cells
})
.collect();
Ok(masks)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BranchWeighting {
#[default]
HardMask,
FateProbability,
}
#[derive(Clone, Copy, Debug)]
pub struct GeneTrendsParams {
pub resolution: usize,
pub weighting: BranchWeighting,
pub gp: LandmarkGpParams,
}
impl GeneTrendsParams {
pub fn new(resolution: usize, weighting: BranchWeighting, gp: LandmarkGpParams) -> Self {
Self {
resolution,
weighting,
gp,
}
}
}
impl Default for GeneTrendsParams {
fn default() -> Self {
Self {
resolution: PSEUDOTIME_RES,
weighting: BranchWeighting::default(),
gp: LandmarkGpParams::default(),
}
}
}
#[derive(Clone, Debug)]
pub struct GeneTrendsResult<T> {
pub trends: Vec<Mat<T>>,
pub grids: Vec<Vec<f64>>,
pub n_cells: Vec<usize>,
pub jitter_used: Vec<f64>,
}
pub fn compute_gene_trends<T>(
expression: MatRef<'_, T>,
pseudotime: &[f32],
branch_cells: &[Vec<usize>],
branch_probs: Option<MatRef<'_, f32>>,
params: &GeneTrendsParams,
) -> Result<GeneTrendsResult<T>, BixverseErrors>
where
T: BixverseFloat,
{
let n = pseudotime.len();
if expression.nrows() != n {
return Err(BixverseErrors::GeneTrendsShapeMismatch {
n_cells: expression.nrows(),
n_pseudotime: n,
});
}
if pseudotime.iter().any(|v| !v.is_finite()) {
return Err(BixverseErrors::GpNonFiniteInput { name: "pseudotime" });
}
if params.resolution < GENE_TRENDS_MIN_RESOLUTION {
return Err(BixverseErrors::GeneTrendsResolutionTooLow {
resolution: params.resolution,
minimum: GENE_TRENDS_MIN_RESOLUTION,
});
}
if params.weighting == BranchWeighting::FateProbability {
let Some(probs) = branch_probs else {
return Err(BixverseErrors::GeneTrendsMissingFateProbabilities);
};
if probs.nrows() != n {
return Err(BixverseErrors::GeneTrendsFateShapeMismatch {
n_rows: probs.nrows(),
n_cells: n,
});
}
if probs.ncols() != branch_cells.len() {
return Err(BixverseErrors::GeneTrendsFateColumnMismatch {
n_columns: probs.ncols(),
n_branches: branch_cells.len(),
});
}
}
let mut trends = Vec::with_capacity(branch_cells.len());
let mut grids = Vec::with_capacity(branch_cells.len());
let mut n_cells = Vec::with_capacity(branch_cells.len());
let mut jitter_used = Vec::with_capacity(branch_cells.len());
for (branch, cells) in branch_cells.iter().enumerate() {
if cells.is_empty() {
return Err(BixverseErrors::GeneTrendsBranchEmpty { branch });
}
if cells.len() < GENE_TRENDS_MIN_CELLS {
return Err(BixverseErrors::GeneTrendsBranchTooFewCells {
branch,
n_cells: cells.len(),
minimum: GENE_TRENDS_MIN_CELLS,
});
}
if let Some(&bad) = cells.iter().find(|&&c| c >= n) {
return Err(BixverseErrors::GeneTrendsCellOutOfRange {
branch,
cell: bad,
n_cells: n,
});
}
let pt: Vec<f64> = cells.iter().map(|&c| pseudotime[c] as f64).collect();
let (lo, hi) = pt
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
(lo.min(v), hi.max(v))
});
if hi <= lo {
return Err(BixverseErrors::GeneTrendsDegeneratePseudotime { branch });
}
let grid = linspace(lo, hi, params.resolution);
let sub = subset_rows(expression, cells);
let weights: Option<Vec<f64>> = match params.weighting {
BranchWeighting::HardMask => None,
BranchWeighting::FateProbability => {
let probs = branch_probs.expect("checked above");
let w: Vec<f64> = cells
.iter()
.map(|&c| {
let v = probs[(c, branch)] as f64;
if v.is_finite() { v.max(0.0) } else { 0.0 }
})
.collect();
if w.iter().all(|v| *v == 0.0) {
return Err(BixverseErrors::GeneTrendsZeroWeightBranch { branch });
}
Some(w)
}
};
let fit = fit_landmark_gp(&pt, sub.as_ref(), &grid, weights.as_deref(), ¶ms.gp)?;
trends.push(fit.predict_on_landmarks::<T>());
jitter_used.push(fit.jitter_used);
grids.push(grid);
n_cells.push(cells.len());
}
Ok(GeneTrendsResult {
trends,
grids,
n_cells,
jitter_used,
})
}
pub fn compute_gene_trends_palantir<T>(
expression: MatRef<'_, T>,
palantir: &PalantirResult,
selection: &BranchSelectionParams,
params: &GeneTrendsParams,
) -> Result<GeneTrendsResult<T>, BixverseErrors>
where
T: BixverseFloat,
{
let branch_cells = select_branch_cells(
&palantir.pseudotime,
palantir.branch_probs.as_ref(),
selection,
)?;
compute_gene_trends(
expression,
&palantir.pseudotime,
&branch_cells,
Some(palantir.branch_probs.as_ref()),
params,
)
}
fn merge_sorted(a: &[f64], b: &[f64], out: &mut Vec<f64>) {
out.clear();
out.reserve(a.len() + b.len());
let (mut i, mut j) = (0usize, 0usize);
while i < a.len() && j < b.len() {
if a[i] <= b[j] {
out.push(a[i]);
i += 1;
} else {
out.push(b[j]);
j += 1;
}
}
out.extend_from_slice(&a[i..]);
out.extend_from_slice(&b[j..]);
}
fn linspace(lo: f64, hi: f64, n: usize) -> Vec<f64> {
if n <= 1 {
return vec![lo];
}
let step = (hi - lo) / (n - 1) as f64;
let mut out: Vec<f64> = (0..n).map(|i| lo + step * i as f64).collect();
out[n - 1] = hi;
out
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_select_branch_cells_matches_reference() {
let n = 12usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32 / (n - 1) as f32).collect();
let probs = Mat::<f32>::from_fn(n, 2, |i, j| {
let t = i as f32 / (n - 1) as f32;
if j == 0 { t } else { 1.0 - t }
});
let masks = select_branch_cells(
&pseudotime,
probs.as_ref(),
&BranchSelectionParams {
q: 0.25,
eps: 0.0,
resolution: PSEUDOTIME_RES,
},
)
.unwrap();
assert_eq!(masks[0], (1..12).collect::<Vec<usize>>());
assert_eq!(masks[1], Vec::<usize>::new());
let masks = select_branch_cells(
&pseudotime,
probs.as_ref(),
&BranchSelectionParams::default(),
)
.unwrap();
assert_eq!(masks[0], (0..12).collect::<Vec<usize>>());
assert_eq!(masks[1], vec![0]);
}
#[test]
fn test_select_branch_cells_cumulative_maximum_is_load_bearing() {
let n = 20usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32).collect();
let probs = Mat::<f32>::from_fn(n, 1, |i, _| match i {
0 => 0.95,
19 => 0.939,
_ => 0.10,
});
let masks = select_branch_cells(
&pseudotime,
probs.as_ref(),
&BranchSelectionParams::default(),
)
.unwrap();
assert_eq!(
masks[0],
vec![0],
"cell 19 was kept, which is what a missing cumulative maximum looks like"
);
}
#[test]
fn test_select_branch_cells_step_above_one() {
let n = 11usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32).collect();
let probs = Mat::<f32>::from_fn(n, 1, |i, _| i as f32 / (n - 1) as f32);
let masks = select_branch_cells(
&pseudotime,
probs.as_ref(),
&BranchSelectionParams {
resolution: 5,
..Default::default()
},
)
.unwrap();
assert_eq!(masks[0], vec![1, 3, 5, 7, 9, 10]);
}
#[test]
fn test_select_branch_cells_production_regime() {
let n = 1001usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32).collect();
let probs = Mat::<f32>::from_fn(n, 1, |i, _| 0.5 + 0.4 * (i as f64 * 0.02).sin() as f32);
let masks = select_branch_cells(
&pseudotime,
probs.as_ref(),
&BranchSelectionParams::default(),
)
.unwrap();
let want: Vec<usize> = (0..=89).chain(382..=403).chain(696..=718).collect();
assert_eq!(masks[0].len(), 135);
assert_eq!(masks[0], want);
}
#[test]
fn test_select_branch_cells_threshold_is_monotone_at_unit_step() {
let n = 60usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32 / (n - 1) as f32).collect();
let probs = Mat::<f32>::from_fn(n, 2, |i, j| {
let t = i as f32 / (n - 1) as f32;
if j == 0 { t } else { 1.0 - t }
});
let masks = select_branch_cells(
&pseudotime,
probs.as_ref(),
&BranchSelectionParams::default(),
)
.unwrap();
assert_eq!(masks[0].len(), n);
assert!(masks[1].contains(&0));
assert!(!masks[1].contains(&(n - 1)));
let last_kept = *masks[1].last().unwrap();
assert_eq!(masks[1], (0..=last_kept).collect::<Vec<usize>>());
}
#[test]
fn test_select_branch_cells_respects_pseudotime_order() {
let n = 30usize;
let pseudotime: Vec<f32> = (0..n).map(|i| (n - 1 - i) as f32).collect();
let probs = Mat::<f32>::from_fn(n, 1, |i, _| i as f32 / (n - 1) as f32);
let masks = select_branch_cells(
&pseudotime,
probs.as_ref(),
&BranchSelectionParams::default(),
)
.unwrap();
assert!(masks[0].contains(&(n - 1)));
assert!(!masks[0].contains(&0));
assert!(
masks[0].len() < n,
"storage order would have kept everything"
);
assert!(masks[0].windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn test_select_branch_cells_fills_nan_probabilities() {
let n = 12usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32).collect();
let probs = Mat::<f32>::from_fn(n, 2, |i, j| match (i, j) {
(5, 0) => f32::NAN,
(_, 0) => 0.1,
_ => 0.9,
});
let masks = select_branch_cells(
&pseudotime,
probs.as_ref(),
&BranchSelectionParams::default(),
)
.unwrap();
assert_eq!(masks.len(), 2);
assert_eq!(masks[0], vec![0, 1, 2, 3, 4, 5]);
assert_eq!(masks[1].len(), n);
}
#[test]
fn test_select_branch_cells_rejects_bad_inputs() {
let probs = Mat::<f32>::from_fn(4, 2, |_, _| 0.5);
let params = BranchSelectionParams::default();
assert!(matches!(
select_branch_cells(&[], probs.as_ref(), ¶ms),
Err(BixverseErrors::GpEmptyInput { .. })
));
assert!(matches!(
select_branch_cells(&[0.0, 1.0], probs.as_ref(), ¶ms),
Err(BixverseErrors::GeneTrendsShapeMismatch { .. })
));
assert!(matches!(
select_branch_cells(&[0.0, 1.0, f32::NAN, 3.0], probs.as_ref(), ¶ms),
Err(BixverseErrors::GpNonFiniteInput { .. })
));
}
#[test]
fn test_merge_sorted() {
let mut out = Vec::new();
merge_sorted(&[1.0, 3.0, 5.0], &[2.0, 4.0], &mut out);
assert_eq!(out, vec![1.0, 2.0, 3.0, 4.0, 5.0]);
merge_sorted(&[], &[2.0, 4.0], &mut out);
assert_eq!(out, vec![2.0, 4.0]);
merge_sorted(&[1.0], &[], &mut out);
assert_eq!(out, vec![1.0]);
}
#[test]
fn test_linspace_pins_both_endpoints() {
let g = linspace(0.3, 2.7, 5);
assert_eq!(g.len(), 5);
assert_relative_eq!(g[0], 0.3, epsilon = 1e-15);
assert_relative_eq!(g[4], 2.7, epsilon = 1e-15);
assert!(g.windows(2).all(|w| w[0] < w[1]));
}
#[test]
fn test_gene_trends_recovers_a_peak() {
let n = 300usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32 / (n - 1) as f32).collect();
let expression = Mat::<f32>::from_fn(n, 2, |i, j| {
let t = pseudotime[i] as f64;
if j == 0 {
(-((t - 0.3) / 0.1).powi(2)).exp() as f32
} else {
t as f32
}
});
let cells: Vec<Vec<usize>> = vec![(0..n).collect()];
let params = GeneTrendsParams {
resolution: 100,
gp: LandmarkGpParams {
length_scale: 0.08,
sigma: 0.05,
..Default::default()
},
..Default::default()
};
let res =
compute_gene_trends(expression.as_ref(), &pseudotime, &cells, None, ¶ms).unwrap();
assert_eq!(res.trends.len(), 1);
assert_eq!(res.trends[0].nrows(), 100);
assert_eq!(res.trends[0].ncols(), 2);
assert_eq!(res.n_cells, vec![n]);
let (mut best, mut best_v) = (0usize, f32::NEG_INFINITY);
for i in 0..100 {
if res.trends[0][(i, 0)] > best_v {
best_v = res.trends[0][(i, 0)];
best = i;
}
}
let peak_t = res.grids[0][best];
assert!(
(peak_t - 0.3).abs() < 0.06,
"peak landed at t={}, expected near 0.3",
peak_t
);
for i in 1..100 {
assert!(
res.trends[0][(i, 1)] >= res.trends[0][(i - 1, 1)] - 1e-3,
"monotone gene dipped at grid point {}",
i
);
}
}
#[test]
fn test_gene_trends_separates_branches() {
let n = 200usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32 / (n - 1) as f32).collect();
let branch_a: Vec<usize> = (0..130).collect();
let branch_b: Vec<usize> = (70..n).collect();
let expression = Mat::<f32>::from_fn(n, 1, |i, _| if i >= 130 { 1.0 } else { 0.0 });
let params = GeneTrendsParams {
resolution: 50,
gp: LandmarkGpParams {
length_scale: 0.1,
sigma: 0.05,
..Default::default()
},
..Default::default()
};
let res = compute_gene_trends(
expression.as_ref(),
&pseudotime,
&[branch_a, branch_b],
None,
¶ms,
)
.unwrap();
let max_a = (0..50).fold(f32::NEG_INFINITY, |m, i| m.max(res.trends[0][(i, 0)]));
let end_b = res.trends[1][(49, 0)];
assert!(
max_a < 0.2,
"branch 0 picked up signal it never saw: {max_a}"
);
assert!(end_b > 0.5, "branch 1 lost its signal: {end_b}");
}
#[test]
fn test_gene_trends_fate_probability_weighting() {
let n = 120usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32 / (n - 1) as f32).collect();
let expression = Mat::<f32>::from_fn(n, 1, |i, _| if i >= 60 { 1.0 } else { 0.0 });
let probs = Mat::<f32>::from_fn(n, 1, |i, _| if i >= 60 { 0.01 } else { 1.0 });
let cells: Vec<Vec<usize>> = vec![(0..n).collect()];
let base = GeneTrendsParams {
resolution: 40,
gp: LandmarkGpParams {
length_scale: 0.1,
sigma: 0.05,
..Default::default()
},
..Default::default()
};
let hard =
compute_gene_trends(expression.as_ref(), &pseudotime, &cells, None, &base).unwrap();
let weighted = compute_gene_trends(
expression.as_ref(),
&pseudotime,
&cells,
Some(probs.as_ref()),
&GeneTrendsParams {
weighting: BranchWeighting::FateProbability,
..base
},
)
.unwrap();
assert!(
weighted.trends[0][(39, 0)] < hard.trends[0][(39, 0)],
"weighting did not change the fit"
);
assert!(matches!(
compute_gene_trends(
expression.as_ref(),
&pseudotime,
&cells,
None,
&GeneTrendsParams {
weighting: BranchWeighting::FateProbability,
..base
}
),
Err(BixverseErrors::GeneTrendsMissingFateProbabilities)
));
}
#[test]
fn test_gene_trends_rejects_bad_branches() {
let n = 20usize;
let pseudotime: Vec<f32> = (0..n).map(|i| i as f32).collect();
let expression = Mat::<f32>::from_fn(n, 1, |i, _| i as f32);
let params = GeneTrendsParams {
resolution: 10,
..Default::default()
};
assert!(matches!(
compute_gene_trends(
expression.as_ref(),
&pseudotime,
&[Vec::new()],
None,
¶ms
),
Err(BixverseErrors::GeneTrendsBranchEmpty { branch: 0 })
));
assert!(matches!(
compute_gene_trends(
expression.as_ref(),
&pseudotime,
&[vec![0, 1]],
None,
¶ms
),
Err(BixverseErrors::GeneTrendsBranchTooFewCells { branch: 0, .. })
));
let flat = vec![1.0_f32; n];
assert!(matches!(
compute_gene_trends(
expression.as_ref(),
&flat,
&[(0..n).collect()],
None,
¶ms
),
Err(BixverseErrors::GeneTrendsDegeneratePseudotime { branch: 0 })
));
assert!(matches!(
compute_gene_trends(
expression.as_ref(),
&pseudotime[..5],
&[(0..5).collect()],
None,
¶ms
),
Err(BixverseErrors::GeneTrendsShapeMismatch { .. })
));
}
}