use crate::utils::gemm::{gemm, gram};
use faer::linalg::matmul::triangular::BlockStructure;
use faer::{Accum, Mat, MatRef};
use indexmap::IndexSet;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use statrs::distribution::{ContinuousCDF, Normal};
use std::cmp::Ordering;
use std::time::Instant;
use crate::core::math::linear_algebra::{linear_regression, linear_regression_widen};
use crate::core::math::stats::{inv_logit, logit, p_adjust_fdr, z_scores_to_pval};
use crate::prelude::*;
use crate::single_cell::sc_processing::knn::knn_distance_weights;
use crate::single_cell::sc_utils::simd::*;
use crate::utils::faer_parallelism;
use crate::utils::simd::{
sum_squared_dev_widen_simd_f32, sum_squares_simd_f32, sum_widen_simd_f32,
};
const GENE_BATCH_SIZE: usize = 1000;
type PanelData = (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>);
pub struct HotSpotParams {
pub model: String,
pub normalise: bool,
pub knn_params: KnnParams,
pub graph_params: HotSpotGraphParams,
}
#[derive(Clone, Copy, Debug)]
pub struct HotSpotGraphParams {
pub weighted_graph: bool,
pub neighborhood_factor: f32,
}
impl Default for HotSpotGraphParams {
fn default() -> Self {
Self {
weighted_graph: false,
neighborhood_factor: 3.0,
}
}
}
impl HotSpotGraphParams {
pub fn new(weighted_graph: bool, neighborhood_factor: f32) -> Self {
Self {
weighted_graph,
neighborhood_factor,
}
}
}
#[derive(Debug, Clone)]
pub enum GexModel {
DephAdjustNegBinom,
Bernoulli,
Normal,
PreStandardised,
}
pub fn parse_gex_model(s: &str) -> Option<GexModel> {
match s.to_lowercase().as_str() {
"danb" => Some(GexModel::DephAdjustNegBinom),
"bernoulli" => Some(GexModel::Bernoulli),
"normal" => Some(GexModel::Normal),
"none" => Some(GexModel::PreStandardised),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct HotSpotGeneRes {
pub gene_idx: Vec<usize>,
pub c: Vec<f64>,
pub z: Vec<f64>,
pub pval: Vec<f64>,
pub fdr: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct HotSpotPairRes {
pub cor: Mat<f32>,
pub z_scores: Mat<f32>,
}
#[derive(Debug, Clone)]
struct GeneScratch {
vals: Vec<f32>,
mu: Vec<f32>,
var: Vec<f32>,
x2: Vec<f32>,
mu_sq: Vec<f32>,
t1: Vec<f32>,
u_musq: Vec<f32>,
u_x2: Vec<f32>,
}
impl GeneScratch {
fn new(n_cells: usize, need_moments: bool) -> Self {
let opt = |n: usize| {
if need_moments {
vec![0.0_f32; n]
} else {
Vec::new()
}
};
Self {
vals: vec![0.0_f32; n_cells],
mu: vec![0.0_f32; n_cells],
var: vec![0.0_f32; n_cells],
x2: vec![0.0_f32; n_cells],
mu_sq: opt(n_cells),
t1: opt(n_cells),
u_musq: opt(n_cells),
u_x2: opt(n_cells),
}
}
}
#[derive(Clone, Debug)]
struct GraphCsr {
offsets: Vec<usize>,
indices: Vec<u32>,
weights: Vec<f32>,
}
#[inline]
fn row_dot<const SQUARED: bool>(weights: &[f32], indices: &[u32], c: &[f32]) -> f32 {
let term = |w: f32, idx: u32| -> f32 {
let x = c[idx as usize];
if SQUARED { w * w * x } else { w * x }
};
let w_chunks = weights.chunks_exact(4);
let i_chunks = indices.chunks_exact(4);
let (w_rem, i_rem) = (w_chunks.remainder(), i_chunks.remainder());
let mut acc = [0.0_f32; 4];
for (w, idx) in w_chunks.zip(i_chunks) {
for lane in 0..4 {
acc[lane] += term(w[lane], idx[lane]);
}
}
let mut tail = 0.0_f32;
for (&w, &idx) in w_rem.iter().zip(i_rem) {
tail += term(w, idx);
}
(acc[0] + acc[1]) + (acc[2] + acc[3]) + tail
}
impl GraphCsr {
fn from_non_redundant(neighbours: &[Vec<usize>], weights: &[Vec<f32>]) -> Self {
let n = neighbours.len();
let mut rows: Vec<Vec<(u32, f32)>> = vec![Vec::new(); n];
for i in 0..n {
for (k, &j) in neighbours[i].iter().enumerate() {
let w = weights[i][k];
if w == 0.0 {
continue;
}
rows[i].push((j as u32, w));
rows[j].push((i as u32, w));
}
}
let mut offsets = Vec::with_capacity(n + 1);
let mut indices = Vec::new();
let mut ws = Vec::new();
offsets.push(0);
for row in &rows {
for &(j, w) in row {
indices.push(j);
ws.push(w);
}
offsets.push(indices.len());
}
Self {
offsets,
indices,
weights: ws,
}
}
#[inline]
fn n_nodes(&self) -> usize {
self.offsets.len() - 1
}
fn spmv(&self, c: &[f32], out: &mut [f32]) {
for i in 0..self.n_nodes() {
let (start, end) = (self.offsets[i], self.offsets[i + 1]);
out[i] = row_dot::<false>(&self.weights[start..end], &self.indices[start..end], c);
}
}
fn quadratic_form(&self, c: &[f32]) -> f32 {
let mut total = 0.0_f32;
for i in 0..self.n_nodes() {
let (start, end) = (self.offsets[i], self.offsets[i + 1]);
let acc = row_dot::<false>(&self.weights[start..end], &self.indices[start..end], c);
total += c[i] * acc;
}
total
}
fn spmv_sq(&self, c: &[f32], out: &mut [f32]) {
for i in 0..self.n_nodes() {
let (start, end) = (self.offsets[i], self.offsets[i + 1]);
out[i] = row_dot::<true>(&self.weights[start..end], &self.indices[start..end], c);
}
}
}
fn compute_moments_weights(
graph: &GraphCsr,
mu: &[f32],
x2: &[f32],
mu_sq: &mut [f32],
t1: &mut [f32],
u_musq: &mut [f32],
u_x2: &mut [f32],
) -> (f32, f32) {
let n = mu.len();
for i in 0..n {
mu_sq[i] = mu[i] * mu[i];
}
graph.spmv(mu, t1);
graph.spmv_sq(mu_sq, u_musq);
graph.spmv_sq(x2, u_x2);
let mut eg = 0.0_f32;
for i in 0..n {
eg += mu[i] * t1[i];
}
eg *= 0.5;
let mut eg2 = 0.0_f32;
for i in 0..n {
eg2 += (x2[i] - mu_sq[i]) * (t1[i] * t1[i] - u_musq[i]);
}
let mut b1 = 0.0_f32;
let mut b2 = 0.0_f32;
for i in 0..n {
b1 += x2[i] * u_x2[i];
b2 += mu_sq[i] * u_musq[i];
}
eg2 += 0.5 * (b1 - b2);
eg2 += eg * eg;
(eg, eg2)
}
fn graph_weights(distances: &[Vec<f32>], params: &HotSpotGraphParams) -> Vec<Vec<f32>> {
if params.weighted_graph {
knn_distance_weights(distances, params.neighborhood_factor)
} else {
distances
.iter()
.map(|row| vec![1.0_f32; row.len()])
.collect()
}
}
fn make_weights_non_redundant(neighbours: &[Vec<usize>], weights: &[Vec<f32>]) -> Vec<Vec<f32>> {
let mut w_no_redundant = weights.to_vec();
for i in 0..neighbours.len() {
for k in 0..neighbours[i].len() {
let j = neighbours[i][k];
if j == i {
w_no_redundant[i][k] = 0.0;
continue;
}
if j < i {
continue;
}
for k2 in 0..neighbours[j].len() {
if neighbours[j][k2] == i {
let w_ji = w_no_redundant[j][k2];
w_no_redundant[j][k2] = 0.0;
w_no_redundant[i][k] += w_ji;
break;
}
}
}
}
w_no_redundant
}
fn compute_node_degree(neighbours: &[Vec<usize>], weights: &[Vec<f32>]) -> Vec<f32> {
let mut d = vec![0.0_f32; neighbours.len()];
for i in 0..neighbours.len() {
for k in 0..neighbours[i].len() {
let j = neighbours[i][k];
let w_ij = weights[i][k];
d[i] += w_ij;
d[j] += w_ij;
}
}
d
}
fn compute_local_cov_max(node_degrees: &[f32], vals: &[f32]) -> f32 {
fused_mul_square_sum_simd(node_degrees, vals) / 2.0
}
fn center_values(vals: &mut [f32], mu: &[f32], var: &[f32]) {
assert_same_len!(vals, mu, var);
center_values_simd(vals, mu, var);
}
#[inline]
fn binarise(vals: &mut [f32]) {
for v in vals.iter_mut() {
*v = if *v > 0.0 { 1.0 } else { 0.0 };
}
}
fn quantile_cut(data: &[f32], n_bins: usize) -> (Vec<usize>, Vec<f32>) {
let mut data_sorted = data.to_vec();
data_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = data_sorted.len();
let mut edges = vec![data_sorted[0]];
for i in 1..n_bins {
let idx = (i * n) / n_bins;
let value = data_sorted[idx.min(n - 1)];
if value > *edges.last().unwrap() {
edges.push(value);
}
}
let max_val = data_sorted[n - 1];
if max_val > *edges.last().unwrap() {
edges.push(max_val + 1e-6);
} else {
*edges.last_mut().unwrap() += 1e-6;
}
let n_actual_bins = edges.len() - 1;
let bin_assignments: Vec<usize> = data
.iter()
.map(|&x| {
edges
.partition_point(|&edge| edge <= x)
.saturating_sub(1)
.min(n_actual_bins - 1)
})
.collect();
(bin_assignments, edges)
}
#[derive(Clone, Debug)]
pub struct Hotspot<'a, S: SingleCellReading> {
gene_reader: &'a S,
graph: GraphCsr,
cells_to_keep: &'a [usize],
node_degrees: Vec<f32>,
umi_counts: Vec<f32>,
wtot2: f32,
n_cells: usize,
umi_total: f64,
umi_sq_sum: f64,
log10_umi: Vec<f32>,
umi_variance_zero: bool,
umi_bins: Vec<usize>,
bin_centers: Vec<f32>,
bin_totals: Vec<f32>,
n_bins: usize,
}
impl<'a, S: SingleCellReading> Hotspot<'a, S> {
pub fn new(
gene_reader: &'a S,
cell_reader: &S,
cells_to_keep: &'a [usize],
neighbours: &[Vec<usize>],
distances: &[Vec<f32>],
graph_params: Option<HotSpotGraphParams>,
) -> Result<Self, BixverseErrors> {
let n_cells = neighbours.len();
let weights = graph_weights(distances, &graph_params.unwrap_or_default());
let weights = make_weights_non_redundant(neighbours, &weights);
let node_degrees = compute_node_degree(neighbours, &weights);
let graph = GraphCsr::from_non_redundant(neighbours, &weights);
let wtot2: f32 = weights.iter().flatten().map(|&w| w * w).sum();
let reader = cell_reader;
let lib_sizes = reader.read_cell_library_sizes(cells_to_keep)?;
let umi_counts: Vec<f32> = lib_sizes.iter().map(|x| *x as f32).collect();
let umi_total = sum_widen_simd_f32(&umi_counts);
let umi_sq_sum = sum_squared_dev_widen_simd_f32(&umi_counts, 0.0);
let log10_umi: Vec<f32> = umi_counts
.iter()
.map(|&x| if x > 0.0 { x.log10() } else { 0.0 })
.collect();
let umi_variance_zero =
sum_squared_dev_widen_simd_f32(&umi_counts, umi_total / n_cells as f64) == 0.0;
const N_BIN_TARGET: usize = 30;
let (umi_bins, bin_edges) = quantile_cut(&log10_umi, N_BIN_TARGET);
let n_bins = bin_edges.len() - 1;
let bin_centers: Vec<f32> = (0..n_bins)
.map(|i| (bin_edges[i] + bin_edges[i + 1]) / 2.0)
.collect();
let mut bin_totals = vec![0.0_f32; n_bins];
for &b in &umi_bins {
bin_totals[b] += 1.0;
}
Ok(Self {
gene_reader,
graph,
cells_to_keep,
node_degrees,
umi_counts,
umi_total,
umi_sq_sum,
log10_umi,
umi_variance_zero,
umi_bins,
bin_centers,
bin_totals,
n_bins,
wtot2,
n_cells,
})
}
pub fn compute_all_genes(
&mut self,
gene_indices: &[usize],
model: &str,
centered: bool,
verbose: usize,
) -> Result<HotSpotGeneRes, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let gex_model = parse_gex_model(model)
.ok_or_else(|| BixverseErrors::HotSpotWrongModel(model.to_string()))?;
let cell_set: IndexSet<u32> = self.cells_to_keep.iter().map(|&x| x as u32).collect();
let start_reading = Instant::now();
let reader = self.gene_reader;
let mut gene_chunks: Vec<CscGeneChunk> = reader.read_gene_parallel(gene_indices)?;
gene_chunks.par_iter_mut().for_each(|chunk| {
chunk.filter_selected_cells(&cell_set);
});
if verbosity.normal_verbosity() {
println!("Loaded in data: {:.2?}", start_reading.elapsed());
}
let start_calculation = Instant::now();
let res: Vec<(usize, f32, f32)> = gene_chunks
.par_iter()
.map_init(
|| GeneScratch::new(self.n_cells, !centered),
|sc, chunk| self.compute_single_gene(chunk, &gex_model, centered, sc),
)
.collect();
let mut gene_idx: Vec<usize> = Vec::with_capacity(res.len());
let mut gaery_c: Vec<f64> = Vec::with_capacity(res.len());
let mut z_scores: Vec<f64> = Vec::with_capacity(res.len());
for (idx, c, z) in res {
if z.is_finite() {
gene_idx.push(idx);
gaery_c.push(c as f64);
z_scores.push(z as f64);
}
}
if verbosity.normal_verbosity() {
println!(
"Finished the calculations: {:.2?}",
start_calculation.elapsed()
);
}
let p_vals = z_scores_to_pval(&z_scores, "greater");
let fdrs = p_adjust_fdr(&p_vals);
Ok(HotSpotGeneRes {
gene_idx,
c: gaery_c,
z: z_scores,
pval: p_vals,
fdr: fdrs,
})
}
pub fn compute_all_genes_streaming(
&mut self,
gene_indices: &[usize],
model: &str,
centered: bool,
verbose: usize,
) -> Result<HotSpotGeneRes, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let start_all = Instant::now();
let no_genes = gene_indices.len();
let no_batches = no_genes.div_ceil(GENE_BATCH_SIZE);
let cell_set: IndexSet<u32> = self.cells_to_keep.iter().map(|&x| x as u32).collect();
let reader = self.gene_reader;
let gex_model = parse_gex_model(model)
.ok_or_else(|| BixverseErrors::HotSpotWrongModel(model.to_string()))?;
let mut gene_indices_out: Vec<usize> = Vec::new();
let mut gaery_c: Vec<f64> = Vec::new();
let mut z_scores: Vec<f64> = Vec::new();
for batch_idx in 0..no_batches {
let start_gene = batch_idx * GENE_BATCH_SIZE;
let end_gene = ((batch_idx + 1) * GENE_BATCH_SIZE).min(no_genes);
let batch_gene_indices = &gene_indices[start_gene..end_gene];
let start_loading = Instant::now();
let mut gene_chunks = reader.read_gene_parallel(batch_gene_indices)?;
gene_chunks.par_iter_mut().for_each(|chunk| {
chunk.filter_selected_cells(&cell_set);
});
if verbosity.detailed_verbosity() {
println!(" Loaded batch in: {:.2?}.", start_loading.elapsed());
}
let start_calc = Instant::now();
let batch_res: Vec<(usize, f32, f32)> = gene_chunks
.par_iter()
.map_init(
|| GeneScratch::new(self.n_cells, !centered),
|sc, chunk| self.compute_single_gene(chunk, &gex_model, centered, sc),
)
.collect();
if verbosity.detailed_verbosity() {
println!(" Finished calculations in: {:.2?}.", start_calc.elapsed());
}
for (idx, c, z) in batch_res {
if z.is_finite() {
gene_indices_out.push(idx);
gaery_c.push(c as f64);
z_scores.push(z as f64);
}
}
if verbosity.normal_verbosity() {
report_decile_progress(
end_gene,
start_gene,
no_genes,
"genes",
start_all.elapsed(),
);
}
}
let p_vals = z_scores_to_pval(&z_scores, "greater");
let fdrs = p_adjust_fdr(&p_vals);
if verbosity.normal_verbosity() {
println!("Finished the full run in : {:.2?}.", start_all.elapsed());
}
Ok(HotSpotGeneRes {
gene_idx: gene_indices_out,
c: gaery_c,
z: z_scores,
pval: p_vals,
fdr: fdrs,
})
}
fn compute_single_gene(
&self,
gene_chunk: &CscGeneChunk,
gex_model: &GexModel,
centered: bool,
sc: &mut GeneScratch,
) -> (usize, f32, f32) {
self.load_and_fit(gene_chunk, gex_model, sc);
if centered && !matches!(gex_model, GexModel::PreStandardised) {
center_values(&mut sc.vals, &sc.mu, &sc.var);
}
let g = 0.5 * self.graph.quadratic_form(&sc.vals);
let (eg, eg2) = if centered {
(0.0, self.wtot2)
} else {
compute_moments_weights(
&self.graph,
&sc.mu,
&sc.x2,
&mut sc.mu_sq,
&mut sc.t1,
&mut sc.u_musq,
&mut sc.u_x2,
)
};
let std_g = (eg2 - eg * eg).sqrt();
let z = (g - eg) / std_g;
let g_max = compute_local_cov_max(&self.node_degrees, &sc.vals);
let c = (g - eg) / g_max;
(gene_chunk.original_index, c, z)
}
fn load_and_fit(&self, gene_chunk: &CscGeneChunk, gex_model: &GexModel, sc: &mut GeneScratch) {
sc.vals.fill(0.0);
for (&idx, val) in gene_chunk.indices.iter().zip(gene_chunk.data_raw.iter()) {
sc.vals[idx as usize] = val as f32;
}
if matches!(gex_model, GexModel::Bernoulli) {
binarise(&mut sc.vals);
}
match gex_model {
GexModel::DephAdjustNegBinom => self.fit_danb(gene_chunk, sc),
GexModel::Bernoulli => self.fit_bernoulli(gene_chunk, sc),
GexModel::Normal => self.fit_normal(sc),
GexModel::PreStandardised => self.fit_pre_standardised(sc),
}
}
fn centre_gene_into(
&self,
gene_chunk: &CscGeneChunk,
gex_model: &GexModel,
sc: &mut GeneScratch,
out: &mut [f32],
) {
self.load_and_fit(gene_chunk, gex_model, sc);
if !matches!(gex_model, GexModel::PreStandardised) {
center_values(&mut sc.vals, &sc.mu, &sc.var);
}
out.copy_from_slice(&sc.vals);
}
fn fit_danb(&self, gene: &CscGeneChunk, sc: &mut GeneScratch) {
let n = self.n_cells as f64;
let total = self.umi_total;
let tj: f64 = gene.data_raw.iter().map(|x| x as f64).sum();
let scale = (tj / total) as f32;
for i in 0..self.n_cells {
sc.mu[i] = scale * self.umi_counts[i];
}
let mut sum_sq = 0.0_f64;
for i in 0..self.n_cells {
let diff = (sc.vals[i] - sc.mu[i]) as f64;
sum_sq += diff * diff;
}
let vv = sum_sq / (n - 1.0);
let mut size = ((tj * tj) / total) * (self.umi_sq_sum / total) / ((n - 1.0) * vv - tj);
if size < 0.0 {
size = 1e9;
} else if size < 1e-10 {
size = 1e-10;
}
let size = size as f32;
for i in 0..self.n_cells {
let m = sc.mu[i];
let v = m * (1.0 + m / size);
sc.var[i] = v;
sc.x2[i] = v + m * m;
}
}
fn fit_bernoulli(&self, gene: &CscGeneChunk, sc: &mut GeneScratch) {
let mut bin_detects = vec![0.0_f32; self.n_bins];
for &idx in &gene.indices {
bin_detects[self.umi_bins[idx as usize]] += 1.0;
}
let lbin: Vec<f32> = bin_detects
.iter()
.zip(&self.bin_totals)
.map(|(&d, &t)| logit((d + 1.0) / (t + 2.0)))
.collect();
let coef = linear_regression(&self.bin_centers, &lbin);
for i in 0..self.n_cells {
let p = inv_logit(coef.0 + coef.1 * self.log10_umi[i]);
sc.mu[i] = p;
sc.var[i] = p * (1.0 - p);
sc.x2[i] = p;
}
}
fn fit_normal(&self, sc: &mut GeneScratch) {
let n = self.n_cells as f64;
if self.umi_variance_zero {
let mean = sum_widen_simd_f32(&sc.vals) / n;
let mut sq = 0.0_f64;
for i in 0..self.n_cells {
let d = sc.vals[i] as f64 - mean;
sq += d * d;
}
let (mu_val, var_val) = ((mean as f32), ((sq / n) as f32));
for i in 0..self.n_cells {
sc.mu[i] = mu_val;
sc.var[i] = var_val;
sc.x2[i] = var_val + mu_val * mu_val;
}
return;
}
let (intercept, slope) = linear_regression_widen(&self.umi_counts, &sc.vals);
for i in 0..self.n_cells {
sc.mu[i] = intercept + slope * self.umi_counts[i];
}
let mut resid_sq = 0.0_f64;
for i in 0..self.n_cells {
let d = (sc.vals[i] - sc.mu[i]) as f64;
resid_sq += d * d;
}
let var_val = (resid_sq / n) as f32;
for i in 0..self.n_cells {
sc.var[i] = var_val;
sc.x2[i] = var_val + sc.mu[i] * sc.mu[i];
}
}
fn fit_pre_standardised(&self, sc: &mut GeneScratch) {
sc.mu.fill(0.0);
sc.var.fill(1.0);
sc.x2.fill(1.0);
}
pub fn compute_gene_cor(
&mut self,
gene_indices: &[usize],
model: &str,
verbose: usize,
) -> Result<HotSpotPairRes, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let gex_model = parse_gex_model(model)
.ok_or_else(|| BixverseErrors::HotSpotWrongModel(model.to_string()))?;
let cell_set: IndexSet<u32> = self.cells_to_keep.iter().map(|&x| x as u32).collect();
if verbosity.normal_verbosity() {
println!("Loading {} genes...", gene_indices.len());
}
let start_loading = Instant::now();
let reader = self.gene_reader;
let mut gene_chunks: Vec<CscGeneChunk> = reader.read_gene_parallel(gene_indices)?;
gene_chunks.par_iter_mut().for_each(|chunk| {
chunk.filter_selected_cells(&cell_set);
});
let n_genes = gene_chunks.len();
if verbosity.normal_verbosity() {
println!("Loaded data in {:.2?}", start_loading.elapsed());
println!("Centering gene expression...");
}
let start_center = Instant::now();
let mut cc = vec![0_f32; self.n_cells * n_genes];
cc.par_chunks_mut(self.n_cells)
.zip(gene_chunks.par_iter())
.for_each_init(
|| GeneScratch::new(self.n_cells, false),
|sc, (col, gene)| self.centre_gene_into(gene, &gex_model, sc, col),
);
drop(gene_chunks);
if verbosity.normal_verbosity() {
println!("Centered in {:.2?}", start_center.elapsed());
println!("Computing wy, eg2 and per-gene max values...");
}
let start_eg2 = Instant::now();
let mut wy = vec![0_f32; self.n_cells * n_genes];
let mut eg2s = vec![0_f32; n_genes];
let mut gene_maxs = vec![0_f32; n_genes];
wy.par_chunks_mut(self.n_cells)
.zip(cc.par_chunks(self.n_cells))
.zip(eg2s.par_iter_mut().zip(gene_maxs.par_iter_mut()))
.for_each(|((wy_col, c_col), (e, m))| {
self.graph.spmv(c_col, wy_col);
*e = sum_squares_simd_f32(wy_col);
*m = compute_local_cov_max(&self.node_degrees, c_col);
});
let c_mat = MatRef::from_column_major_slice(&cc, self.n_cells, n_genes);
let wy_mat = MatRef::from_column_major_slice(&wy, self.n_cells, n_genes);
if verbosity.normal_verbosity() {
println!("Computed wy/eg2/maxs in {:.2?}", start_eg2.elapsed());
println!("Computing pairwise correlations (GEMM)...");
}
let start_pairs = Instant::now();
let mut lc = Mat::<f32>::zeros(n_genes, n_genes);
gram(
lc.as_mut(),
BlockStructure::TriangularLower,
Accum::Replace,
c_mat.transpose(),
wy_mat,
1.0_f32,
faer_parallelism(),
);
if verbosity.normal_verbosity() {
println!("Computed GEMM in {:.2?}", start_pairs.elapsed());
}
let (lc_mat, z_mat) = symmetric_pair_matrices(lc.as_ref(), &eg2s, &gene_maxs);
if verbosity.normal_verbosity() {
println!("Done!");
}
Ok(HotSpotPairRes {
cor: lc_mat,
z_scores: z_mat,
})
}
pub fn compute_gene_cor_streaming(
&mut self,
gene_indices: &[usize],
model: &str,
batch_size: usize,
panel_size: usize,
verbose: usize,
) -> Result<HotSpotPairRes, BixverseErrors> {
assert!(batch_size >= 1, "batch_size must be >= 1");
assert!(panel_size >= 1, "panel_size must be >= 1");
let verbosity = parse_verbosity_level(verbose);
let gex_model = parse_gex_model(model)
.ok_or_else(|| BixverseErrors::HotSpotWrongModel(model.to_string()))?;
let cell_set: IndexSet<u32> = self.cells_to_keep.iter().map(|&x| x as u32).collect();
let reader = self.gene_reader;
let n_genes = gene_indices.len();
let n_cells = self.n_cells;
let load_panel = |panel_indices: &[usize]| -> Result<PanelData, BixverseErrors> {
let p = panel_indices.len();
let mut cc = vec![0_f32; n_cells * p];
let mut written = 0usize;
for chunk_indices in panel_indices.chunks(batch_size) {
let mut chunks = reader.read_gene_parallel(chunk_indices)?;
chunks.par_iter_mut().for_each(|c| {
c.filter_selected_cells(&cell_set);
});
let base = written;
cc[base * n_cells..(base + chunk_indices.len()) * n_cells]
.par_chunks_mut(n_cells)
.zip(chunks.par_iter())
.for_each_init(
|| GeneScratch::new(n_cells, false),
|sc, (col, gene)| self.centre_gene_into(gene, &gex_model, sc, col),
);
written += chunk_indices.len();
}
let mut wy = vec![0_f32; n_cells * p];
let mut eg2 = vec![0_f32; p];
let mut maxs = vec![0_f32; p];
wy.par_chunks_mut(n_cells)
.zip(cc.par_chunks(n_cells))
.zip(eg2.par_iter_mut().zip(maxs.par_iter_mut()))
.for_each(|((wy_col, c_col), (e, m))| {
self.graph.spmv(c_col, wy_col);
*e = sum_squares_simd_f32(wy_col);
*m = compute_local_cov_max(&self.node_degrees, c_col);
});
Ok((cc, wy, eg2, maxs))
};
let panel_starts: Vec<usize> = (0..n_genes).step_by(panel_size).collect();
let n_panels = panel_starts.len();
if verbosity.normal_verbosity() {
println!(
"Processing {} genes in {} panels of up to {}",
n_genes, n_panels, panel_size
);
}
let mut lc_mat = Mat::<f32>::zeros(n_genes, n_genes);
let mut z_mat = Mat::<f32>::zeros(n_genes, n_genes);
for (pi, &pstart_i) in panel_starts.iter().enumerate() {
let pend_i = (pstart_i + panel_size).min(n_genes);
let li = pend_i - pstart_i;
let start_panel = Instant::now();
let (ci_flat, wyi_flat, eg2i, maxi) = load_panel(&gene_indices[pstart_i..pend_i])?;
let ci = MatRef::from_column_major_slice(&ci_flat, n_cells, li);
let wyi = MatRef::from_column_major_slice(&wyi_flat, n_cells, li);
if verbosity.normal_verbosity() {
println!(
"Loaded panel {} / {} (genes {}-{}) in {:.2?}",
pi + 1,
n_panels,
pstart_i,
pend_i - 1,
start_panel.elapsed()
);
}
for &pstart_j in panel_starts.iter().skip(pi) {
let pend_j = (pstart_j + panel_size).min(n_genes);
let lj = pend_j - pstart_j;
let diagonal = pstart_i == pstart_j;
if verbosity.detailed_verbosity() {
println!(" Panel pair ({}, {})", pstart_i, pstart_j);
}
if diagonal {
let mut block = Mat::<f32>::zeros(li, li);
gram(
block.as_mut(),
BlockStructure::TriangularLower,
Accum::Replace,
ci.transpose(),
wyi,
1.0_f32,
faer_parallelism(),
);
for b in 1..li {
for a in 0..b {
Self::write_pair(
&mut lc_mat,
&mut z_mat,
pstart_i + a,
pstart_i + b,
block[(b, a)],
eg2i[a],
eg2i[b],
maxi[a],
maxi[b],
);
}
}
} else {
let (_, wyj_flat, eg2j, maxj) = load_panel(&gene_indices[pstart_j..pend_j])?;
let wyj = MatRef::from_column_major_slice(&wyj_flat, n_cells, lj);
let mut block = Mat::<f32>::zeros(li, lj);
gemm(
block.as_mut(),
Accum::Replace,
ci.transpose(),
wyj,
1.0_f32,
faer_parallelism(),
);
for b in 0..lj {
for a in 0..li {
let gi = pstart_i + a;
let gj = pstart_j + b; Self::write_pair(
&mut lc_mat,
&mut z_mat,
gi,
gj,
block[(a, b)],
eg2i[a],
eg2j[b],
maxi[a],
maxj[b],
);
}
}
}
}
}
if verbosity.normal_verbosity() {
println!("Done!");
}
Ok(HotSpotPairRes {
cor: lc_mat,
z_scores: z_mat,
})
}
#[allow(clippy::too_many_arguments)]
fn write_pair(
lc_mat: &mut Mat<f32>,
z_mat: &mut Mat<f32>,
gi: usize,
gj: usize,
lc: f32,
eg2_i: f32,
eg2_j: f32,
max_i: f32,
max_j: f32,
) {
let lc_max = (max_i + max_j) * 0.5;
let normalised_lc = if lc_max > 0.0 { lc / lc_max } else { 0.0 };
let z = lc / eg2_i.max(eg2_j).sqrt();
if z.is_finite() {
lc_mat[(gi, gj)] = normalised_lc;
lc_mat[(gj, gi)] = normalised_lc;
z_mat[(gi, gj)] = z;
z_mat[(gj, gi)] = z;
}
}
}
#[inline]
fn pair_stats(lc: f32, eg2_i: f32, eg2_j: f32, max_i: f32, max_j: f32) -> Option<(f32, f32)> {
let lc_max = (max_i + max_j) * 0.5;
let normalised_lc = if lc_max > 0.0 { lc / lc_max } else { 0.0 };
let z = lc / eg2_i.max(eg2_j).sqrt();
z.is_finite().then_some((normalised_lc, z))
}
const MIRROR_TILE: usize = 32;
fn symmetric_pair_matrices(lc: MatRef<f32>, eg2: &[f32], maxs: &[f32]) -> (Mat<f32>, Mat<f32>) {
let n = lc.nrows();
let mut lc_flat = vec![0.0_f32; n * n];
let mut z_flat = vec![0.0_f32; n * n];
lc_flat
.par_chunks_mut(n.max(1))
.zip(z_flat.par_chunks_mut(n.max(1)))
.enumerate()
.for_each(|(j, (lc_col, z_col))| {
for i in (j + 1)..n {
if let Some((l, z)) = pair_stats(lc[(i, j)], eg2[i], eg2[j], maxs[i], maxs[j]) {
lc_col[i] = l;
z_col[i] = z;
}
}
});
for flat in [&mut lc_flat, &mut z_flat] {
for jb in (0..n).step_by(MIRROR_TILE) {
for ib in (jb..n).step_by(MIRROR_TILE) {
for j in jb..(jb + MIRROR_TILE).min(n) {
for i in ib.max(j + 1)..(ib + MIRROR_TILE).min(n) {
flat[i * n + j] = flat[j * n + i];
}
}
}
}
}
(
MatRef::from_column_major_slice(&lc_flat, n, n).to_owned(),
MatRef::from_column_major_slice(&z_flat, n, n).to_owned(),
)
}
const Z_HIST_BINS: usize = 1 << 16;
const Z_HIST_MAX: f64 = 40.0;
const Z_ALPHA_RELAX: f64 = 1e-9;
const SYMMETRY_CHECK_MAX_DIM: usize = 512;
#[inline]
fn upper_tail_pval(z: f64) -> f64 {
z_scores_to_pval(&[z], "greater")[0]
}
#[derive(Debug)]
struct ZHistogram {
counts: Vec<u32>,
lo: f64,
width: f64,
overflow: u32,
overflow_min: f64,
}
impl ZHistogram {
fn build(z_mat: MatRef<f64>, lo: f64) -> Self {
let n = z_mat.nrows();
let width = (Z_HIST_MAX - lo) / Z_HIST_BINS as f64;
let (counts, overflow, overflow_min) = (0..n)
.into_par_iter()
.fold(
|| (vec![0_u32; Z_HIST_BINS], 0_u32, f64::INFINITY),
|(mut counts, mut overflow, mut overflow_min), j| {
for i in 0..j {
let z = z_mat[(i, j)];
if z < lo {
continue;
}
if z >= Z_HIST_MAX {
overflow += 1;
overflow_min = overflow_min.min(z);
continue;
}
let bin = (((z - lo) / width) as usize).min(Z_HIST_BINS - 1);
counts[bin] += 1;
}
(counts, overflow, overflow_min)
},
)
.reduce(
|| (vec![0_u32; Z_HIST_BINS], 0_u32, f64::INFINITY),
|(mut a_counts, a_over, a_min), (b_counts, b_over, b_min)| {
for (a, b) in a_counts.iter_mut().zip(b_counts.iter()) {
*a += b;
}
(a_counts, a_over + b_over, a_min.min(b_min))
},
);
Self {
counts,
lo,
width,
overflow,
overflow_min,
}
}
#[inline]
fn edge(&self, bin: usize) -> f64 {
self.lo + self.width * bin as f64
}
}
fn bh_z_threshold(z_mat: MatRef<f64>, fdr_threshold: f64) -> f64 {
let n = z_mat.nrows();
if n < 2 {
return f64::INFINITY;
}
debug_assert!(
n > SYMMETRY_CHECK_MAX_DIM
|| (0..n).all(|j| (0..j).all(|i| z_mat[(i, j)] == z_mat[(j, i)])),
"bh_z_threshold requires a symmetric Z matrix"
);
let m = n * (n - 1) / 2;
if fdr_threshold > 1.0 {
return (0..n)
.into_par_iter()
.map(|j| {
(0..j)
.map(|i| z_mat[(i, j)])
.fold(f64::INFINITY, |a, b| a.min(b))
})
.reduce(|| f64::INFINITY, |a, b| a.min(b));
}
let normal = Normal::new(0.0, 1.0).unwrap();
let lo = (normal.inverse_cdf(1.0 - fdr_threshold) - Z_ALPHA_RELAX)
.clamp(-Z_HIST_MAX, Z_HIST_MAX - 1.0);
let hist = ZHistogram::build(z_mat, lo);
let mut at_or_above = vec![0_u32; Z_HIST_BINS + 1];
at_or_above[Z_HIST_BINS] = hist.overflow;
for bin in (0..Z_HIST_BINS).rev() {
at_or_above[bin] = at_or_above[bin + 1] + hist.counts[bin];
}
let m_f = m as f64;
for bin in 0..Z_HIST_BINS {
if hist.counts[bin] == 0 {
continue;
}
let p_floor = upper_tail_pval(hist.edge(bin + 1));
if p_floor >= fdr_threshold * at_or_above[bin] as f64 / m_f {
continue;
}
if let Some(v) = resolve_threshold_bin(
z_mat,
hist.edge(bin),
hist.edge(bin + 1),
at_or_above[bin + 1],
hist.counts[bin] as usize,
m_f,
fdr_threshold,
) {
return v;
}
}
if hist.overflow > 0 {
return hist.overflow_min;
}
f64::INFINITY
}
#[allow(clippy::too_many_arguments)]
fn resolve_threshold_bin(
z_mat: MatRef<f64>,
lo: f64,
hi: f64,
above: u32,
capacity: usize,
m_f: f64,
fdr_threshold: f64,
) -> Option<f64> {
let n = z_mat.nrows();
let mut members: Vec<f64> = Vec::with_capacity(capacity);
for j in 0..n {
for i in 0..j {
let z = z_mat[(i, j)];
if z >= lo && z < hi {
members.push(z);
}
}
}
members.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
let pvals = z_scores_to_pval(&members, "greater");
let len = members.len();
let mut t = 0usize;
while t < len {
let mut group_end = t + 1;
while group_end < len && members[group_end] == members[t] {
group_end += 1;
}
let rank = above as f64 + (len - t) as f64;
if (m_f / rank) * pvals[t] < fdr_threshold {
return Some(members[t]);
}
t = group_end;
}
None
}
#[derive(Clone, Copy, Debug)]
struct Merge {
left: usize,
right: usize,
height: f64,
size: usize,
}
fn average_linkage_nn_chain(z_mat: MatRef<f64>) -> Vec<Merge> {
let n = z_mat.nrows();
if n < 2 {
return Vec::new();
}
let mut sim = vec![0.0_f64; n * n];
sim.par_chunks_mut(n).enumerate().for_each(|(j, col)| {
for (i, slot) in col.iter_mut().enumerate() {
*slot = z_mat[(i, j)];
}
});
let mut size = vec![1_usize; n];
let mut active = vec![true; n];
let mut chain: Vec<usize> = Vec::with_capacity(n);
let mut raw: Vec<(usize, usize, f64)> = Vec::with_capacity(n - 1);
for _ in 0..(n - 1) {
if chain.is_empty() {
let seed = (0..n)
.find(|&i| active[i])
.expect("an active cluster remains");
chain.push(seed);
}
let (x, y, best) = loop {
let x = chain[chain.len() - 1];
let (mut y, mut best) = if chain.len() > 1 {
let prev = chain[chain.len() - 2];
(prev, sim[prev + x * n])
} else {
(usize::MAX, f64::NEG_INFINITY)
};
let col = &sim[x * n..(x + 1) * n];
for k in 0..n {
if !active[k] || k == x {
continue;
}
if col[k] > best {
best = col[k];
y = k;
}
}
if chain.len() > 1 && y == chain[chain.len() - 2] {
chain.pop();
chain.pop();
break (x, y, best);
}
chain.push(y);
};
let (sx, sy) = (size[x], size[y]);
let total = (sx + sy) as f64;
let (wx, wy) = (sx as f64, sy as f64);
for k in 0..n {
if !active[k] || k == x || k == y {
continue;
}
let merged = (sim[k + x * n] * wx + sim[k + y * n] * wy) / total;
sim[k + y * n] = merged;
sim[y + k * n] = merged;
}
active[x] = false;
size[y] = sx + sy;
raw.push((x, y, best));
}
canonicalise_linkage(raw, n)
}
fn canonicalise_linkage(mut raw: Vec<(usize, usize, f64)>, n: usize) -> Vec<Merge> {
raw.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(Ordering::Equal));
let mut parent: Vec<usize> = (0..n).collect();
let mut node_id: Vec<usize> = (0..n).collect();
let mut leaves: Vec<usize> = vec![1; n];
fn find(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
let mut out = Vec::with_capacity(raw.len());
for (idx, (x, y, height)) in raw.into_iter().enumerate() {
let rx = find(&mut parent, x);
let ry = find(&mut parent, y);
let (a, b) = if node_id[rx] < node_id[ry] {
(node_id[rx], node_id[ry])
} else {
(node_id[ry], node_id[rx])
};
let size = leaves[rx] + leaves[ry];
out.push(Merge {
left: a,
right: b,
height,
size,
});
parent[rx] = ry;
node_id[ry] = n + idx;
leaves[ry] = size;
}
out
}
fn assign_modules_core(
merges: &[Merge],
n: usize,
z_threshold: f64,
min_cluster_genes: usize,
) -> Vec<Option<usize>> {
fn child_size(merges: &[Merge], n: usize, node: usize) -> usize {
if node < n { 1 } else { merges[node - n].size }
}
fn child_label(labels: &[Option<usize>], n: usize, node: usize) -> Option<usize> {
if node < n { None } else { labels[node - n] }
}
let mut labels: Vec<Option<usize>> = vec![None; merges.len()];
let mut next_label = 0usize;
for (i, merge) in merges.iter().enumerate() {
let n_a = child_size(merges, n, merge.left);
let n_b = child_size(merges, n, merge.right);
let big_a = n_a >= min_cluster_genes;
let big_b = n_b >= min_cluster_genes;
labels[i] = if (big_a && big_b) || (merge.height < z_threshold) {
None
} else if big_a {
child_label(&labels, n, merge.left)
} else if big_b {
child_label(&labels, n, merge.right)
} else if n_a + n_b >= min_cluster_genes {
let label = next_label;
next_label += 1;
Some(label)
} else {
None
};
}
let mut out: Vec<Option<usize>> = vec![None; n];
if merges.is_empty() {
return out;
}
let root = merges.len() - 1;
let mut stack: Vec<(usize, Option<usize>)> = vec![(root, labels[root])];
while let Some((idx, inherited)) = stack.pop() {
let label = inherited.or(labels[idx]);
for child in [merges[idx].left, merges[idx].right] {
if child < n {
out[child] = label;
} else {
stack.push((child - n, label));
}
}
}
renumber_modules(out)
}
fn renumber_modules(labels: Vec<Option<usize>>) -> Vec<Option<usize>> {
let mut seen: Vec<usize> = labels.iter().flatten().copied().collect();
seen.sort_unstable();
seen.dedup();
let remap: FxHashMap<usize, usize> = seen
.into_iter()
.enumerate()
.map(|(new, old)| (old, new))
.collect();
labels
.into_iter()
.map(|l| l.and_then(|old| remap.get(&old).copied()))
.collect()
}
pub fn hotspot_gene_clusters(
z_mat: MatRef<f64>,
fdr_threshold: f64,
min_cluster_genes: usize,
) -> Vec<f64> {
let n = z_mat.nrows();
let z_threshold = bh_z_threshold(z_mat, fdr_threshold);
let merges = average_linkage_nn_chain(z_mat);
assign_modules_core(&merges, n, z_threshold, min_cluster_genes)
.into_iter()
.map(|l| l.map_or(f64::NAN, |v| v as f64))
.collect()
}
#[cfg(test)]
mod tests {
use approx::assert_relative_eq;
use super::*;
fn small_graph() -> (Vec<Vec<usize>>, Vec<Vec<f32>>) {
let neighbours = vec![
vec![1, 2], vec![0, 2], vec![0, 1, 3], vec![2], ];
let weights = vec![
vec![0.5, 0.25],
vec![0.5, 0.75],
vec![0.25, 0.75, 1.0],
vec![1.0],
];
(neighbours, weights)
}
fn ref_lc_x2(x: &[f32], y: &[f32], neigh: &[Vec<usize>], w_nr: &[Vec<f32>]) -> f32 {
let mut out = 0.0_f32;
for (i, (ns, ws)) in neigh.iter().zip(w_nr.iter()).enumerate() {
let xi = x[i];
let yi = y[i];
for (&j, &wij) in ns.iter().zip(ws.iter()) {
out += wij * (xi * y[j] + yi * x[j]);
}
}
out
}
fn ref_eg2(x: &[f32], neigh: &[Vec<usize>], w_nr: &[Vec<f32>]) -> f32 {
let n = neigh.len();
let mut t = vec![0.0_f32; n];
for i in 0..n {
for k in 0..neigh[i].len() {
let j = neigh[i][k];
let wij = w_nr[i][k];
if wij == 0.0 {
continue;
}
t[i] += wij * x[j];
t[j] += wij * x[i];
}
}
t.iter().map(|&v| v * v).sum()
}
fn dot(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
}
#[test]
fn test_unweighted_graph_is_the_default() {
let distances = vec![vec![0.5, 1.0, 1.5], vec![2.0, 4.0, 8.0]];
let weights = graph_weights(&distances, &HotSpotGraphParams::default());
assert_eq!(weights, vec![vec![1.0_f32; 3], vec![1.0_f32; 3]]);
}
#[test]
fn non_redundant_combines_and_zeroes() {
let (neigh, w) = small_graph();
let nr = make_weights_non_redundant(&neigh, &w);
assert_eq!(nr[0][0], 1.0); assert_eq!(nr[1][0], 0.0); assert_eq!(nr[0][1], 0.5);
assert_eq!(nr[2][0], 0.0);
assert_eq!(nr[1][1], 1.5);
assert_eq!(nr[2][1], 0.0);
assert_eq!(nr[2][2], 2.0);
assert_eq!(nr[3][0], 0.0);
}
#[test]
fn node_degree_counts_both_endpoints() {
let (neigh, w) = small_graph();
let nr = make_weights_non_redundant(&neigh, &w);
let d = compute_node_degree(&neigh, &nr);
assert!((d[0] - 1.5).abs() < 1e-6);
assert!((d[3] - 2.0).abs() < 1e-6);
}
#[test]
fn spmv_matches_reference_t1x() {
let (neigh, w) = small_graph();
let nr = make_weights_non_redundant(&neigh, &w);
let graph = GraphCsr::from_non_redundant(&neigh, &nr);
let x = [1.0_f32, -2.0, 3.0, 0.5];
let mut wy = vec![0.0_f32; x.len()];
graph.spmv(&x, &mut wy);
let mut t = vec![0.0_f32; x.len()];
for i in 0..neigh.len() {
for k in 0..neigh[i].len() {
let j = neigh[i][k];
let wij = nr[i][k];
if wij == 0.0 {
continue;
}
t[i] += wij * x[j];
t[j] += wij * x[i];
}
}
for (a, b) in wy.iter().zip(t.iter()) {
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
}
}
#[test]
fn eg2_equals_sum_squares_wy() {
let (neigh, w) = small_graph();
let nr = make_weights_non_redundant(&neigh, &w);
let graph = GraphCsr::from_non_redundant(&neigh, &nr);
let x = [0.3_f32, 1.7, -0.4, 2.2];
let mut wy = vec![0.0_f32; x.len()];
graph.spmv(&x, &mut wy);
let new_eg2: f32 = wy.iter().map(|&v| v * v).sum();
let old = ref_eg2(&x, &neigh, &nr);
assert!((new_eg2 - old).abs() < 1e-5, "{new_eg2} vs {old}");
}
#[test]
fn pair_formula_equivalence() {
let (neigh, w) = small_graph();
let nr = make_weights_non_redundant(&neigh, &w);
let graph = GraphCsr::from_non_redundant(&neigh, &nr);
let x = [1.0_f32, 0.5, -1.5, 2.0];
let y = [-0.5_f32, 2.0, 0.25, -1.0];
let old = ref_lc_x2(&x, &y, &neigh, &nr);
let mut wy = vec![0.0_f32; y.len()];
graph.spmv(&y, &mut wy);
let new = dot(&x, &wy);
assert!((new - old).abs() < 1e-5, "new {new} vs old {old}");
let mut wx = vec![0.0_f32; x.len()];
graph.spmv(&x, &mut wx);
let new_sym = dot(&y, &wx);
assert!((new_sym - old).abs() < 1e-5, "sym {new_sym} vs old {old}");
}
#[test]
fn quantile_cut_assigns_and_orders() {
let data: Vec<f32> = (0..100).map(|i| i as f32).collect();
let (bins, edges) = quantile_cut(&data, 10);
assert_eq!(bins.len(), data.len());
for win in edges.windows(2) {
assert!(win[1] > win[0]);
}
let n_bins = edges.len() - 1;
assert!(bins.iter().all(|&b| b < n_bins));
}
use std::collections::BinaryHeap;
#[derive(Clone, Debug)]
struct MergeCandidate {
z: f64,
i: usize,
j: usize,
gen_i: u32,
gen_j: u32,
}
impl PartialEq for MergeCandidate {
fn eq(&self, other: &Self) -> bool {
self.z == other.z
}
}
impl Eq for MergeCandidate {}
impl PartialOrd for MergeCandidate {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for MergeCandidate {
fn cmp(&self, other: &Self) -> Ordering {
self.z.partial_cmp(&other.z).unwrap_or(Ordering::Equal)
}
}
fn z_threshold_reference(z_mat: MatRef<f64>, fdr_threshold: f64) -> f64 {
let z_upper_triangle = faer_mat_to_upper_triangle(z_mat, 1);
let pvals = z_scores_to_pval(&z_upper_triangle, "greater");
let fdrs = p_adjust_fdr(&pvals);
z_upper_triangle
.iter()
.zip(fdrs.iter())
.filter(|&(_, &fdr)| fdr < fdr_threshold)
.map(|(&z, _)| z)
.min_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap_or(f64::INFINITY)
}
fn greedy_merge_reference(z_mat: MatRef<f64>, z_threshold: f64) -> (Vec<Vec<usize>>, Vec<f64>) {
let n = z_mat.nrows();
let mut z = z_mat.to_owned();
let mut sizes: Vec<usize> = vec![1; n];
let mut clusters: Vec<Vec<usize>> = (0..n).map(|i| vec![i]).collect();
let mut gene_to_cluster: Vec<usize> = (0..n).collect();
let mut generation: Vec<u32> = vec![0; n];
let mut active = vec![true; n];
let mut heights: Vec<f64> = Vec::new();
let mut heap = BinaryHeap::new();
for i in 0..n {
for j in (i + 1)..n {
let zij = z[(i, j)];
if zij >= z_threshold {
heap.push(MergeCandidate {
z: zij,
i,
j,
gen_i: 0,
gen_j: 0,
});
}
}
}
while let Some(candidate) = heap.pop() {
if candidate.z < z_threshold {
break;
}
let ci = candidate.i;
let cj = candidate.j;
if !active[ci]
|| !active[cj]
|| generation[ci] != candidate.gen_i
|| generation[cj] != candidate.gen_j
{
continue;
}
let new_size = sizes[ci] + sizes[cj];
heights.push(candidate.z);
generation[ci] += 1;
active[cj] = false;
for k in 0..n {
if k == ci || k == cj || !active[k] {
continue;
}
let new_z = (z[(ci, k)] * sizes[ci] as f64 + z[(cj, k)] * sizes[cj] as f64)
/ new_size as f64;
z[(ci, k)] = new_z;
z[(k, ci)] = new_z;
if new_z >= z_threshold {
heap.push(MergeCandidate {
z: new_z,
i: ci.min(k),
j: ci.max(k),
gen_i: generation[ci.min(k)],
gen_j: generation[ci.max(k)],
});
}
}
let cj_genes: Vec<usize> = clusters[cj].drain(..).collect();
clusters[ci].extend(cj_genes);
sizes[ci] = new_size;
for &gene in &clusters[ci] {
gene_to_cluster[gene] = ci;
}
}
(canonical_partition(&gene_to_cluster), heights)
}
fn canonical_partition(assignment: &[usize]) -> Vec<Vec<usize>> {
let mut groups: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
for (gene, &cluster) in assignment.iter().enumerate() {
groups.entry(cluster).or_default().push(gene);
}
let mut out: Vec<Vec<usize>> = groups.into_values().collect();
for g in out.iter_mut() {
g.sort_unstable();
}
out.sort_unstable();
out
}
fn linkage_partition(merges: &[Merge], n: usize, z_threshold: f64) -> Vec<Vec<usize>> {
let mut assignment: Vec<usize> = (0..n).collect();
let mut rep: Vec<usize> = (0..n + merges.len()).collect();
for (i, merge) in merges.iter().enumerate() {
if merge.height < z_threshold {
continue;
}
let target = rep[merge.left].min(rep[merge.right]);
let (a, b) = (rep[merge.left], rep[merge.right]);
for slot in assignment.iter_mut() {
if *slot == a || *slot == b {
*slot = target;
}
}
rep[n + i] = target;
}
canonical_partition(&assignment)
}
fn random_symmetric_z(n: usize, scale: f64, seed: u64) -> Mat<f64> {
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
let mut rng = StdRng::seed_from_u64(seed);
let mut out = Mat::<f64>::zeros(n, n);
for i in 0..n {
for j in (i + 1)..n {
let u1: f64 = rng.random_range(1e-12..1.0);
let u2: f64 = rng.random_range(0.0..1.0);
let v = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
let z = v * scale;
out[(i, j)] = z;
out[(j, i)] = z;
}
}
out
}
fn two_block_z(block: usize, within: f64, between: f64) -> Mat<f64> {
let n = 2 * block;
Mat::<f64>::from_fn(n, n, |i, j| {
if i == j {
0.0
} else if (i < block) == (j < block) {
within
} else {
between
}
})
}
#[test]
fn nn_chain_matches_greedy_clustering() {
for (n, scale, seed) in [(20, 1.0, 1), (60, 2.0, 2), (40, 0.5, 3), (80, 1.5, 4)] {
let z = random_symmetric_z(n, scale, seed);
let merges = average_linkage_nn_chain(z.as_ref());
assert_eq!(merges.len(), n - 1);
for w in merges.windows(2) {
assert!(
w[0].height >= w[1].height,
"non-monotone dendrogram at n={n} scale={scale}"
);
}
for threshold in [f64::NEG_INFINITY, -1.0, 0.0, 0.5, 1.0, 2.0] {
let (expected, ref_heights) = greedy_merge_reference(z.as_ref(), threshold);
let actual = linkage_partition(&merges, n, threshold);
assert_eq!(
expected, actual,
"partition mismatch at n={n} scale={scale} threshold={threshold}"
);
let mut a: Vec<f64> = ref_heights;
let mut b: Vec<f64> = merges
.iter()
.map(|m| m.height)
.filter(|h| *h >= threshold)
.collect();
a.sort_by(|x, y| x.partial_cmp(y).unwrap());
b.sort_by(|x, y| x.partial_cmp(y).unwrap());
assert_eq!(a.len(), b.len(), "merge count at threshold={threshold}");
for (x, y) in a.iter().zip(b.iter()) {
assert_relative_eq!(x, y, epsilon = 1e-12);
}
}
}
}
#[test]
fn assign_modules_core_keeps_both_declined_children() {
let z = two_block_z(6, 10.0, 8.0);
let merges = average_linkage_nn_chain(z.as_ref());
let labels = assign_modules_core(&merges, 12, 1.0, 3);
let assigned: Vec<usize> = labels.iter().flatten().copied().collect();
assert_eq!(assigned.len(), 12, "every gene should land in a module");
let mut distinct = assigned.clone();
distinct.sort_unstable();
distinct.dedup();
assert_eq!(distinct, vec![0, 1], "expected exactly two modules");
let first = labels[0].unwrap();
assert!(labels[..6].iter().all(|l| *l == Some(first)));
assert!(labels[6..].iter().all(|l| *l == Some(1 - first)));
}
#[test]
fn assign_modules_core_respects_the_threshold() {
let z = two_block_z(6, 0.5, 0.1);
let merges = average_linkage_nn_chain(z.as_ref());
let labels = assign_modules_core(&merges, 12, 5.0, 3);
assert!(labels.iter().all(|l| l.is_none()));
}
#[test]
fn bh_z_threshold_matches_reference() {
for (n, scale, seed) in [
(20, 0.3, 11),
(20, 3.0, 12),
(60, 1.0, 13),
(60, 2.5, 14),
(120, 0.8, 15),
(120, 4.0, 16),
] {
let z = random_symmetric_z(n, scale, seed);
for alpha in [1e-6, 0.001, 0.05, 0.5, 1.0, 1.5] {
let expected = z_threshold_reference(z.as_ref(), alpha);
let actual = bh_z_threshold(z.as_ref(), alpha);
assert_eq!(
expected, actual,
"n={n} scale={scale} alpha={alpha}: {expected} vs {actual}"
);
}
}
}
#[test]
fn bh_z_threshold_edge_cases() {
assert_eq!(
bh_z_threshold(Mat::<f64>::zeros(0, 0).as_ref(), 0.05),
f64::INFINITY
);
assert_eq!(
bh_z_threshold(Mat::<f64>::zeros(1, 1).as_ref(), 0.05),
f64::INFINITY
);
let flat = Mat::<f64>::zeros(10, 10);
assert_eq!(bh_z_threshold(flat.as_ref(), 0.05), f64::INFINITY);
let huge = two_block_z(5, 60.0, 50.0);
assert_eq!(bh_z_threshold(huge.as_ref(), 0.05), 50.0);
let tied = two_block_z(8, 7.0, 7.0);
assert_eq!(
bh_z_threshold(tied.as_ref(), 0.05),
z_threshold_reference(tied.as_ref(), 0.05)
);
let mixed = two_block_z(6, 4.0, -2.0);
assert_eq!(bh_z_threshold(mixed.as_ref(), 1.5), -2.0);
}
}