use faer::{Mat, MatRef};
use rand::distr::weighted::WeightedIndex;
use rand::prelude::*;
use rand::rngs::StdRng;
use rayon::prelude::*;
use rustc_hash::FxHashSet;
use std::time::Instant;
use thousands::Separable;
use crate::core::math::sparse::*;
use crate::prelude::*;
use crate::utils::simd::argmin_diff_simd_f32;
#[derive(Clone, Copy, Default)]
pub enum KnnSymmetrisation {
#[default]
Intersection,
Union,
}
pub fn parse_knn_symmetrisation(s: &str) -> Option<KnnSymmetrisation> {
match s.to_lowercase().as_str() {
"intersection" => Some(KnnSymmetrisation::Intersection),
"union" => Some(KnnSymmetrisation::Union),
_ => None,
}
}
const BANDWIDTH_RANK_DIVISOR: usize = 3;
#[derive(Clone, Debug)]
pub struct SEACellsParams {
pub n_sea_cells: usize,
pub max_fw_iters: usize,
pub convergence_epsilon: f32,
pub max_iter: usize,
pub min_iter: usize,
pub greedy_threshold: usize,
pub graph_building: String,
pub pruning: bool,
pub pruning_threshold: f32,
pub n_landmarks: Option<usize>,
pub knn_params: KnnParams,
pub lanczos_params: LanczosParams,
}
impl SEACellsParams {
pub fn new() -> Self {
Self {
n_sea_cells: 0,
max_fw_iters: 50,
convergence_epsilon: 1e-3,
max_iter: 100,
min_iter: 10,
greedy_threshold: 20000,
graph_building: "union".to_string(),
pruning: true,
pruning_threshold: 1e-7,
n_landmarks: None,
knn_params: KnnParams::default(),
lanczos_params: LanczosParams::default(),
}
}
}
impl Default for SEACellsParams {
fn default() -> Self {
Self::new()
}
}
pub fn assignments_to_metacells(assignments: &[usize], k: usize) -> Vec<Vec<usize>> {
let mut metacells = vec![Vec::new(); k];
for (cell_id, &seacell_id) in assignments.iter().enumerate() {
metacells[seacell_id].push(cell_id);
}
metacells
}
#[cfg(test)]
fn prune_and_renormalise(mat: &mut CompressedSparseData2<f32>, threshold: f32) {
let mut new_data = Vec::new();
let mut new_indices = Vec::new();
let mut new_indptr = vec![0];
for row in 0..mat.shape.0 {
let start = mat.indptr[row] as usize;
let end = mat.indptr[row + 1] as usize;
for idx in start..end {
if mat.data[idx].abs() > threshold {
new_data.push(mat.data[idx]);
new_indices.push(mat.indices[idx]);
}
}
new_indptr.push(new_data.len() as u32);
}
mat.data = new_data;
mat.indices = new_indices;
mat.indptr = new_indptr;
normalise_csr_columns_l1(mat);
}
fn matrix_trace(mat: &CompressedSparseData2<f32>) -> f64 {
let n = mat.shape.0.min(mat.shape.1);
let mut trace = 0.0f64;
for i in 0..n {
let row_start = mat.indptr[i] as usize;
let row_end = mat.indptr[i + 1] as usize;
for idx in row_start..row_end {
if mat.indices[idx] == i as u32 {
trace += mat.data[idx] as f64;
break;
}
}
}
trace
}
fn frobenius_norm_sq_f64(mat: &CompressedSparseData2<f32>) -> f64 {
mat.data
.par_iter()
.with_min_len(10000)
.map(|&v| (v as f64) * (v as f64))
.sum()
}
pub fn compute_diffusion_kernel(
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
squared_dist: bool,
) -> Result<CompressedSparseData2<f32>, BixverseErrors> {
let n = knn_indices.len();
let mut adaptive_std: Vec<f32> = knn_distances
.iter()
.enumerate()
.map(|(i, dists)| {
let mut sorted = dists.clone();
sorted.sort_by(|a, b| a.total_cmp(b));
sorted
.get(((dists.len() + 1) / BANDWIDTH_RANK_DIVISOR).max(1) - 1)
.copied()
.ok_or_else(|| {
BixverseErrors::InvalidArgument(format!(
"kNN row {i} has no neighbours; cannot derive an adaptive bandwidth"
))
})
})
.collect::<Result<Vec<f32>, BixverseErrors>>()?;
let smallest_positive = adaptive_std
.iter()
.copied()
.filter(|s| *s > 0.0)
.fold(f32::INFINITY, f32::min);
let fallback = if smallest_positive.is_finite() {
smallest_positive
} else {
1.0
};
for s in adaptive_std.iter_mut() {
if !s.is_finite() || *s <= 0.0 {
*s = fallback;
}
}
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut vals = Vec::new();
for (i, neighbours) in knn_indices.iter().enumerate() {
for (idx, &j) in neighbours.iter().enumerate() {
let dist = if squared_dist {
knn_distances[i][idx].sqrt()
} else {
knn_distances[i][idx]
};
let weight = (-dist / adaptive_std[i]).exp();
rows.push(i);
cols.push(j);
vals.push(weight);
}
}
let w = coo_to_csr(&rows.index_cast(), &cols.index_cast(), &vals, (n, n));
let w_t = w.transpose_and_convert();
let res = sparse_add_csr(&w, &w_t)?;
if res.data.par_iter().any(|v| !v.is_finite()) {
return Err(BixverseErrors::DiffusionKernelNotFinite { n_cells: n });
}
Ok(res)
}
pub fn diffusion_map_from_kernel(
kernel: &mut CompressedSparseData2<f32>,
n_components: usize,
seed: u64,
lanczos_params: Option<LanczosParams>,
) -> Result<(Vec<f64>, Vec<Vec<f32>>), BixverseErrors> {
let res = diffusion_map_from_kernel_diag(kernel, n_components, seed, lanczos_params)?;
Ok((res.eigenvalues, res.eigenvectors))
}
pub fn diffusion_map_from_kernel_diag(
kernel: &mut CompressedSparseData2<f32>,
n_components: usize,
seed: u64,
lanczos_params: Option<LanczosParams>,
) -> Result<LanczosResult, BixverseErrors> {
let row_sums: Vec<f32> = (0..kernel.shape.0)
.map(|i| {
(kernel.indptr[i]..kernel.indptr[i + 1])
.map(|idx| kernel.data[idx as usize])
.sum()
})
.collect();
for i in 0..kernel.shape.0 {
let d_i_sqrt = row_sums[i].sqrt();
for idx in kernel.indptr[i]..kernel.indptr[i + 1] {
let j = kernel.indices[idx as usize] as usize;
let d_j_sqrt = row_sums[j].sqrt();
kernel.data[idx as usize] /= d_i_sqrt * d_j_sqrt;
}
}
compute_largest_eigenpairs_lanczos_diag(kernel, n_components, seed, lanczos_params)
}
const MAX_MULTISCALE_LAMBDA: f64 = 1.0 - 1e-10;
pub fn determine_multiscale_space(
eigenvalues: &[f64],
eigenvectors: &[Vec<f32>],
n_eigs: Option<usize>,
) -> Vec<Vec<f32>> {
let n = eigenvectors.len();
let available = eigenvalues
.len()
.min(eigenvectors.first().map_or(0, Vec::len));
let requested = if let Some(n) = n_eigs {
n
} else {
let gaps: Vec<f64> = eigenvalues.windows(2).map(|w| w[0] - w[1]).collect();
let max_gap_idx = gaps
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.map(|(idx, _)| idx + 1)
.unwrap_or(3);
max_gap_idx.max(3)
};
let use_n_eigs = requested.min(available);
let use_indices: Vec<usize> = (1..use_n_eigs).collect();
let mut scaled = vec![vec![0.0f32; use_indices.len()]; n];
for (out_idx, &eig_idx) in use_indices.iter().enumerate() {
let lambda = eigenvalues[eig_idx].min(MAX_MULTISCALE_LAMBDA);
let scale = (lambda / (1.0 - lambda)) as f32;
for i in 0..n {
scaled[i][out_idx] = eigenvectors[i][eig_idx] * scale;
}
}
scaled
}
pub fn max_min_sampling(data: &[Vec<f32>], num_waypoints: usize, seed: u64) -> Vec<usize> {
let n = data.len();
let n_dims = data.first().map_or(0, Vec::len);
if n == 0 || n_dims == 0 {
return Vec::new();
}
let no_iterations = (num_waypoints / n_dims).max(1);
let mut rng = StdRng::seed_from_u64(seed);
let mut waypoint_set = FxHashSet::default();
for dim in 0..n_dims {
let vec: Vec<f32> = data.iter().map(|row| row[dim]).collect();
let mut iter_set = vec![rng.random_range(0..n)];
let mut min_dists = vec![f32::MAX; n];
for i in 0..n {
min_dists[i] = (vec[i] - vec[iter_set[0]]).abs();
}
for _ in 1..no_iterations {
let new_wp = min_dists
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(idx, _)| idx)
.unwrap();
iter_set.push(new_wp);
for i in 0..n {
let dist = (vec[i] - vec[new_wp]).abs();
min_dists[i] = min_dists[i].min(dist);
}
}
waypoint_set.extend(iter_set);
}
waypoint_set.into_iter().collect()
}
pub fn select_density_landmarks(
kernel: &CompressedSparseData2<f32>,
n_landmarks: usize,
seed: u64,
) -> Vec<usize> {
let n = kernel.shape.0;
let weights: Vec<f64> = (0..n)
.map(|i| {
let s: f32 = (kernel.indptr[i]..kernel.indptr[i + 1])
.map(|idx| kernel.data[idx as usize])
.sum();
(s as f64).max(0.0).sqrt()
})
.collect();
let mut rng = StdRng::seed_from_u64(seed);
let dist = WeightedIndex::new(&weights).expect("invalid weights for density landmarks");
let target = n_landmarks.min(n);
let mut set = FxHashSet::default();
while set.len() < target {
set.insert(dist.sample(&mut rng));
}
set.into_iter().collect()
}
pub fn landmark_knn(
pca: MatRef<f32>,
landmark_indices: &[usize],
k: usize,
knn_params: &KnnParams,
seed: usize,
verbose: usize,
) -> ScKnnResults {
let verbosity = parse_verbosity_level(verbose);
let l = landmark_indices.len();
let dim = pca.ncols();
let landmark_mat = Mat::<f32>::from_fn(l, dim, |i, j| *pca.get(landmark_indices[i], j));
let mut params = knn_params.clone();
params.k = k.min(l.saturating_sub(1)).max(1);
let (indices, distances) = generate_knn_with_dist(
landmark_mat.as_ref(),
¶ms,
true,
false,
seed,
verbosity.detailed_verbosity(),
)?;
Ok((indices, distances.expect("distances must be present")))
}
pub fn build_data_to_landmark_transitions(
pca: MatRef<f32>,
landmark_indices: &[usize],
k: usize,
bandwidth_scale: f32,
thresh: f32,
) -> CompressedSparseData2<f32> {
let n = pca.nrows();
let l = landmark_indices.len();
let dim = pca.ncols();
let k_used = k.min(l).max(1);
let bw_factor = bandwidth_scale * bandwidth_scale;
let landmark_coords: Vec<f32> = landmark_indices
.iter()
.flat_map(|&li| (0..dim).map(move |c| *pca.get(li, c)))
.collect();
let per_row: Vec<(Vec<usize>, Vec<f32>)> = (0..n)
.into_par_iter()
.map(|i| {
let mut dists: Vec<(usize, f32)> = (0..l)
.map(|li_idx| {
let mut d = 0.0f32;
for c in 0..dim {
let diff = pca.get(i, c) - landmark_coords[li_idx * dim + c];
d += diff * diff;
}
(li_idx, d)
})
.collect();
dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
dists.truncate(k_used);
let bw_raw = dists.last().map(|&(_, d)| d).unwrap_or(0.0);
let bw = (bw_raw * bw_factor).max(f32::EPSILON);
let mut kept: Vec<(usize, f32)> = dists
.into_iter()
.filter_map(|(li_idx, d)| {
let w = (-d / bw).exp();
(w >= thresh).then_some((li_idx, w))
})
.collect();
let s: f32 = kept.iter().map(|&(_, w)| w).sum();
if s > 0.0 {
for (_, w) in &mut kept {
*w /= s;
}
}
kept.sort_by_key(|&(li_idx, _)| li_idx);
let idx: Vec<usize> = kept.iter().map(|&(li_idx, _)| li_idx).collect();
let val: Vec<f32> = kept.iter().map(|&(_, w)| w).collect();
(idx, val)
})
.collect();
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut vals = Vec::new();
for (cell_id, (idx, val)) in per_row.into_iter().enumerate() {
for (j, w) in idx.into_iter().zip(val) {
rows.push(cell_id);
cols.push(j);
vals.push(w);
}
}
coo_to_csr(&rows.index_cast(), &cols.index_cast(), &vals, (n, l))
}
fn nystrom_extend(
p_nl: &CompressedSparseData2<f32>,
landmark_embedding: &[Vec<f32>],
lambdas: &[f32],
) -> Vec<Vec<f32>> {
let n = p_nl.shape.0;
let n_dim = landmark_embedding[0].len();
(0..n)
.into_par_iter()
.map(|i| {
let mut row = vec![0.0f32; n_dim];
let start = p_nl.indptr[i] as usize;
let end = p_nl.indptr[i + 1] as usize;
for idx in start..end {
let l = p_nl.indices[idx] as usize;
let w = p_nl.data[idx];
for d in 0..n_dim {
row[d] += w * landmark_embedding[l][d];
}
}
for d in 0..n_dim {
if lambdas[d].abs() > f32::EPSILON {
row[d] /= lambdas[d];
}
}
row
})
.collect()
}
const FW_RENORM_FLOOR: f64 = 1e-15;
#[derive(Clone, Debug, Default)]
pub struct FwAtoms {
indices: Vec<u32>,
weights: Vec<f32>,
}
#[derive(Clone, Debug, Default)]
pub struct FwPruneOutcome {
pub dropped: Vec<(u32, f32)>,
pub renorm: f32,
}
impl FwAtoms {
pub fn with_capacity(max_atoms: usize) -> Self {
Self {
indices: Vec::with_capacity(max_atoms),
weights: Vec::with_capacity(max_atoms),
}
}
pub fn reset_from(&mut self, indices: &[u32], weights: &[f32]) {
self.indices.clear();
self.weights.clear();
self.indices.extend_from_slice(indices);
self.weights.extend_from_slice(weights);
}
pub fn clear(&mut self) {
self.indices.clear();
self.weights.clear();
}
pub fn atoms(&self) -> (&[u32], &[f32]) {
(&self.indices, &self.weights)
}
pub fn len(&self) -> usize {
self.indices.len()
}
pub fn is_empty(&self) -> bool {
self.indices.is_empty()
}
pub fn step(&mut self, gamma: f32, amin: u32) {
let retain = 1.0 - gamma;
for weight in &mut self.weights {
*weight *= retain;
}
match self.indices.iter().position(|&i| i == amin) {
Some(pos) => self.weights[pos] += gamma,
None => {
self.indices.push(amin);
self.weights.push(gamma);
}
}
}
pub fn prune(&mut self, threshold: f32) -> FwPruneOutcome {
let mut dropped = Vec::new();
let mut write = 0;
for read in 0..self.indices.len() {
if self.weights[read].abs() > threshold {
self.indices[write] = self.indices[read];
self.weights[write] = self.weights[read];
write += 1;
} else {
dropped.push((self.indices[read], self.weights[read]));
}
}
self.indices.truncate(write);
self.weights.truncate(write);
let sum: f32 = self.weights.iter().sum();
let renorm = if (sum as f64) > FW_RENORM_FLOOR {
1.0 / sum
} else {
1.0
};
if renorm != 1.0 {
for weight in &mut self.weights {
*weight *= renorm;
}
}
FwPruneOutcome { dropped, renorm }
}
}
pub trait FwArgminB {
fn begin(
&mut self,
t1: &CompressedSparseData2<f32>,
t2: &CompressedSparseData2<f32>,
) -> Result<(), BixverseErrors>;
fn argmins(
&mut self,
k2_b: &CompressedSparseData2<f32>,
b: &CompressedSparseData2<f32>,
) -> Result<(Vec<usize>, f32), BixverseErrors>;
#[allow(clippy::too_many_arguments)]
fn columns_a(
&mut self,
t1: &CompressedSparseData2<f32>,
a_prev_t: &CompressedSparseData2<f32>,
k2_b: &CompressedSparseData2<f32>,
k: usize,
n: usize,
n_iters: usize,
pruning: Option<f32>,
) -> Result<Vec<FwAtoms>, BixverseErrors> {
Ok(fw_columns_a(t1, a_prev_t, k2_b, k, n, n_iters, pruning))
}
}
#[derive(Default)]
pub struct CpuFwArgminB {
t1: Option<CompressedSparseData2<f32>>,
t2_t: Option<CompressedSparseData2<f32>>,
}
impl FwArgminB for CpuFwArgminB {
fn begin(
&mut self,
t1: &CompressedSparseData2<f32>,
t2: &CompressedSparseData2<f32>,
) -> Result<(), BixverseErrors> {
self.t1 = Some(t1.clone());
self.t2_t = Some(t2.transpose_and_convert());
Ok(())
}
fn argmins(
&mut self,
k2_b: &CompressedSparseData2<f32>,
b: &CompressedSparseData2<f32>,
) -> Result<(Vec<usize>, f32), BixverseErrors> {
let t1 = self
.t1
.as_ref()
.ok_or(BixverseErrors::SEACellsModelNotFitted)?;
let t2_t = self
.t2_t
.as_ref()
.ok_or(BixverseErrors::SEACellsModelNotFitted)?;
let (n, k) = (k2_b.shape.0, k2_b.shape.1);
let k2_b_t = k2_b.transpose_and_convert();
let b_t = b.transpose_and_convert();
Ok(fw_argmins_b(&k2_b_t, t1, t2_t, &b_t, n, k))
}
}
#[cfg(test)]
fn fw_argmins_a(
t1: &CompressedSparseData2<f32>, a: &CompressedSparseData2<f32>, k2_b: &CompressedSparseData2<f32>, k: usize,
n: usize,
) -> Vec<usize> {
let a_t = a.transpose_and_convert();
const CHUNK: usize = 256;
let n_chunks = n.div_ceil(CHUNK);
let chunks: Vec<Vec<usize>> = (0..n_chunks)
.into_par_iter()
.map_init(
|| vec![0.0f32; k],
|buf, chunk_idx| {
let start = chunk_idx * CHUNK;
let end = ((chunk_idx + 1) * CHUNK).min(n);
let mut out = Vec::with_capacity(end - start);
for j in start..end {
buf.fill(0.0);
for ai in a_t.indptr[j] as usize..a_t.indptr[j + 1] as usize {
let r = a_t.indices[ai] as usize;
let w = a_t.data[ai];
for ti in t1.indptr[r] as usize..t1.indptr[r + 1] as usize {
buf[t1.indices[ti] as usize] += w * t1.data[ti];
}
}
for ki in k2_b.indptr[j] as usize..k2_b.indptr[j + 1] as usize {
buf[k2_b.indices[ki] as usize] -= k2_b.data[ki];
}
let mut min_val = buf[0];
let mut min_idx = 0;
for i in 1..k {
if buf[i] < min_val {
min_val = buf[i];
min_idx = i;
}
}
out.push(min_idx);
}
out
},
)
.collect();
chunks.into_iter().flatten().collect()
}
pub(crate) fn fw_columns_a(
t1: &CompressedSparseData2<f32>,
a_prev_t: &CompressedSparseData2<f32>,
k2_b: &CompressedSparseData2<f32>,
k: usize,
n: usize,
n_iters: usize,
pruning: Option<f32>,
) -> Vec<FwAtoms> {
const CHUNK: usize = 256;
let n_chunks = n.div_ceil(CHUNK);
let chunks: Vec<Vec<FwAtoms>> = (0..n_chunks)
.into_par_iter()
.map_init(
|| (vec![0.0f32; k], vec![0.0f32; k]),
|(w, k2b_row), chunk_idx| {
let start = chunk_idx * CHUNK;
let end = ((chunk_idx + 1) * CHUNK).min(n);
let mut out = Vec::with_capacity(end - start);
for j in start..end {
k2b_row.fill(0.0);
for ki in k2_b.indptr[j] as usize..k2_b.indptr[j + 1] as usize {
k2b_row[k2_b.indices[ki] as usize] = k2_b.data[ki];
}
let seed = a_prev_t.indptr[j] as usize..a_prev_t.indptr[j + 1] as usize;
let mut atoms = FwAtoms::with_capacity(n_iters + seed.len());
atoms.reset_from(
&a_prev_t.indices[seed.clone()],
&a_prev_t.data[seed.clone()],
);
w.fill(0.0);
for ai in seed {
let r = a_prev_t.indices[ai] as usize;
let weight = a_prev_t.data[ai];
for ti in t1.indptr[r] as usize..t1.indptr[r + 1] as usize {
w[t1.indices[ti] as usize] += weight * t1.data[ti];
}
}
for t in 0..n_iters {
let (amin, _) = argmin_diff_simd_f32(&w[..k], &k2b_row[..k]);
let gamma = 2.0 / (t as f32 + 2.0);
atoms.step(gamma, amin as u32);
let retain = 1.0 - gamma;
for value in w.iter_mut() {
*value *= retain;
}
for ti in t1.indptr[amin] as usize..t1.indptr[amin + 1] as usize {
w[t1.indices[ti] as usize] += gamma * t1.data[ti];
}
if let Some(threshold) = pruning {
let outcome = atoms.prune(threshold);
for (r, weight) in outcome.dropped {
let r = r as usize;
for ti in t1.indptr[r] as usize..t1.indptr[r + 1] as usize {
w[t1.indices[ti] as usize] -= weight * t1.data[ti];
}
}
if outcome.renorm != 1.0 {
for value in w.iter_mut() {
*value *= outcome.renorm;
}
}
}
}
out.push(atoms);
}
out
},
)
.collect();
chunks.into_iter().flatten().collect()
}
fn fw_atoms_to_csr(columns: &[FwAtoms], shape: (usize, usize)) -> CompressedSparseData2<f32> {
let nnz: usize = columns.iter().map(|c| c.len()).sum();
let mut rows = Vec::with_capacity(nnz);
let mut cols = Vec::with_capacity(nnz);
let mut vals = Vec::with_capacity(nnz);
for (col, atoms) in columns.iter().enumerate() {
let (indices, weights) = atoms.atoms();
for (&row, &weight) in indices.iter().zip(weights.iter()) {
rows.push(row as usize);
cols.push(col);
vals.push(weight);
}
}
coo_to_csr(&rows.index_cast(), &cols.index_cast(), &vals, shape)
}
fn fw_argmins_b(
k2_b_t: &CompressedSparseData2<f32>, t1: &CompressedSparseData2<f32>, t2_t: &CompressedSparseData2<f32>, b_t: &CompressedSparseData2<f32>, n: usize,
k: usize,
) -> (Vec<usize>, f32) {
const CHUNK: usize = 64;
let n_chunks = k.div_ceil(CHUNK);
let chunks: Vec<(Vec<usize>, f32, f32)> = (0..n_chunks)
.into_par_iter()
.map_init(
|| vec![0.0f32; n],
|buf, chunk_idx| {
let start = chunk_idx * CHUNK;
let end = ((chunk_idx + 1) * CHUNK).min(k);
let mut amins = Vec::with_capacity(end - start);
let mut g_dot_b = 0.0f32;
let mut g_dot_e = 0.0f32;
for c in start..end {
buf.fill(0.0);
for ti in t1.indptr[c] as usize..t1.indptr[c + 1] as usize {
let m = t1.indices[ti] as usize;
let tmc = t1.data[ti];
for ki in k2_b_t.indptr[m] as usize..k2_b_t.indptr[m + 1] as usize {
buf[k2_b_t.indices[ki] as usize] += tmc * k2_b_t.data[ki];
}
}
for si in t2_t.indptr[c] as usize..t2_t.indptr[c + 1] as usize {
buf[t2_t.indices[si] as usize] -= t2_t.data[si];
}
let mut min_val = buf[0];
let mut min_idx = 0;
for r in 1..n {
if buf[r] < min_val {
min_val = buf[r];
min_idx = r;
}
}
amins.push(min_idx);
g_dot_e += min_val;
for bi in b_t.indptr[c] as usize..b_t.indptr[c + 1] as usize {
g_dot_b += b_t.data[bi] * buf[b_t.indices[bi] as usize];
}
}
(amins, g_dot_b, g_dot_e)
},
)
.collect();
let mut argmins = Vec::with_capacity(k);
let mut g_dot_b = 0.0f32;
let mut g_dot_e = 0.0f32;
for (amins, gb, ge) in chunks {
argmins.extend(amins);
g_dot_b += gb;
g_dot_e += ge;
}
(argmins, (g_dot_b - g_dot_e).abs())
}
const K2B_REFRESH_EVERY: usize = 8;
const MIN_FW_ITERS: usize = 10;
const _: () = assert!(
K2B_REFRESH_EVERY < MIN_FW_ITERS,
"K2B_REFRESH_EVERY must sit below MIN_FW_ITERS or the refresh never fires"
);
fn k_squared_column_combination(
kernel: &CompressedSparseData2<f32>,
per_column: &[Vec<(u32, f32)>],
n: usize,
) -> CompressedSparseData2<f32> {
const CHUNK: usize = 64;
let k = per_column.len();
let columns: Vec<(Vec<u32>, Vec<f32>)> = per_column
.par_chunks(CHUNK)
.map_init(
|| (vec![0.0f32; n], vec![0u32; n], Vec::<u32>::new(), 0u32),
|(acc, stamp, touched, epoch), chunk| {
let mut out = Vec::with_capacity(chunk.len());
for sources in chunk.iter() {
*epoch += 1;
let tag = *epoch;
touched.clear();
for &(j, weight) in sources.iter() {
if weight == 0.0 {
continue;
}
let j = j as usize;
for mid in kernel.indptr[j] as usize..kernel.indptr[j + 1] as usize {
let m = kernel.indices[mid] as usize;
let scaled = kernel.data[mid] * weight;
for oid in kernel.indptr[m] as usize..kernel.indptr[m + 1] as usize {
let o = kernel.indices[oid] as usize;
if stamp[o] != tag {
stamp[o] = tag;
acc[o] = 0.0;
touched.push(o as u32);
}
acc[o] += scaled * kernel.data[oid];
}
}
}
let mut indices = Vec::with_capacity(touched.len());
let mut data = Vec::with_capacity(touched.len());
for &o in touched.iter() {
let value = acc[o as usize];
if value != 0.0 {
indices.push(o);
data.push(value);
}
}
out.push((indices, data));
}
out
},
)
.flatten()
.collect();
let nnz: usize = columns.iter().map(|(idx, _)| idx.len()).sum();
let mut indptr = Vec::with_capacity(k + 1);
let mut indices = Vec::with_capacity(nnz);
let mut data = Vec::with_capacity(nnz);
indptr.push(0u32);
for (col_indices, col_data) in columns {
indices.extend_from_slice(&col_indices);
data.extend_from_slice(&col_data);
indptr.push(indices.len() as u32);
}
CompressedSparseData2 {
data,
indices,
indptr,
cs_type: CompressedSparseFormat::Csc,
data_2: None,
shape: (n, k),
}
}
fn prune_and_renormalise_tracked(
mat: &mut CompressedSparseData2<f32>,
threshold: f32,
) -> (Vec<(u32, u32, f32)>, Vec<f32>) {
assert!(
mat.cs_type.is_csr(),
"prune_and_renormalise_tracked expects CSR; a CSC input would read rows as columns"
);
let ncols = mat.shape.1;
let mut dropped = Vec::new();
let mut new_data = Vec::with_capacity(mat.data.len());
let mut new_indices = Vec::with_capacity(mat.indices.len());
let mut new_indptr = Vec::with_capacity(mat.indptr.len());
new_indptr.push(0u32);
for row in 0..mat.shape.0 {
for idx in mat.indptr[row] as usize..mat.indptr[row + 1] as usize {
let value = mat.data[idx];
let col = mat.indices[idx];
if value.abs() > threshold {
new_data.push(value);
new_indices.push(col);
} else {
dropped.push((col, row as u32, value));
}
}
new_indptr.push(new_data.len() as u32);
}
mat.data = new_data;
mat.indices = new_indices;
mat.indptr = new_indptr;
let mut col_sums = vec![0.0f32; ncols];
for (idx, &col) in mat.indices.iter().enumerate() {
col_sums[col as usize] += mat.data[idx];
}
let factors: Vec<f32> = col_sums
.iter()
.map(|&sum| {
if (sum as f64) > FW_RENORM_FLOOR {
1.0 / sum
} else {
1.0
}
})
.collect();
for (idx, &col) in mat.indices.iter().enumerate() {
mat.data[idx] *= factors[col as usize];
}
(dropped, factors)
}
pub struct SEACells<'a> {
n_cells: usize,
kernel_mat: Option<CompressedSparseData2<f32>>,
a: Option<CompressedSparseData2<f32>>,
b: Option<CompressedSparseData2<f32>>,
k2_b: Option<CompressedSparseData2<f32>>,
archetypes: Option<Vec<usize>>,
rss_history: Vec<f32>,
convergence_threshold: Option<f32>,
k_frobenius_norm_sq: Option<f64>,
params: &'a SEACellsParams,
}
impl<'a> SEACells<'a> {
pub fn new(n_cells: usize, params: &'a SEACellsParams) -> Self {
Self {
n_cells,
kernel_mat: None,
a: None,
b: None,
k2_b: None,
archetypes: None,
convergence_threshold: None,
k_frobenius_norm_sq: None,
rss_history: Vec::new(),
params,
}
}
pub fn construct_kernel_mat(
&mut self,
pca: MatRef<f32>,
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
verbose: usize,
) {
let verbosity = parse_verbosity_level(verbose);
let n = pca.nrows();
let k = knn_indices[0].len();
if verbosity.normal_verbosity() {
println!("Computing adaptive bandwidth RBF kernel...");
}
let graph_construction =
parse_knn_symmetrisation(&self.params.graph_building).unwrap_or_default();
let median_idx = k / 2;
let median_dist = knn_distances
.iter()
.map(|d| d[median_idx].sqrt())
.collect::<Vec<f32>>();
let mut edges = FxHashSet::default();
for (i, neighbours) in knn_indices.iter().enumerate() {
for &j in neighbours {
edges.insert((i, j));
}
}
match graph_construction {
KnnSymmetrisation::Union => {
let to_add: Vec<_> = edges
.iter()
.filter_map(|&(i, j)| (!edges.contains(&(j, i))).then_some((j, i)))
.collect();
edges.extend(to_add);
}
KnnSymmetrisation::Intersection => {
let to_keep: FxHashSet<_> = edges
.iter()
.copied()
.filter(|&(i, j)| edges.contains(&(j, i)))
.collect();
edges = to_keep;
}
}
for i in 0..n {
edges.insert((i, i));
}
let mut rows: Vec<usize> = Vec::new();
let mut cols: Vec<usize> = Vec::new();
let mut vals: Vec<f32> = Vec::new();
for &(i, j) in &edges {
let mut dist_square = 0_f32;
for dim in 0..pca.ncols() {
let diff = pca.get(i, dim) - pca.get(j, dim);
dist_square += diff * diff;
}
let sigma_prod = median_dist[i] * median_dist[j];
let val = (-dist_square / sigma_prod).exp();
rows.push(i);
cols.push(j);
vals.push(val);
}
if verbosity.normal_verbosity() {
println!(
"Built kernel with {} non-zeros",
vals.len().separate_with_underscores()
);
}
let kernel = coo_to_csr(&rows.index_cast(), &cols.index_cast(), &vals, (n, n));
if verbosity.detailed_verbosity() {
println!(" Pre-computing kernel Frobenius norm...");
}
self.k_frobenius_norm_sq = Some(frobenius_norm_sq_f64(&kernel));
self.kernel_mat = Some(kernel);
}
fn k_squared_matmul(
&self,
x: &CompressedSparseData2<f32>,
) -> Result<CompressedSparseData2<f32>, BixverseErrors> {
if self.kernel_mat.is_none() {
return Err(BixverseErrors::SEACellsKernelMatrixMissing);
}
let k = self.kernel_mat.as_ref().unwrap();
let kx = csr_matmul_csr(k, x)?;
let res = csr_matmul_csr(k, &kx)?;
Ok(res)
}
fn k_squared_matvec(&self, v: &[f32]) -> Result<Vec<f32>, BixverseErrors> {
let k = self.kernel_mat.as_ref().unwrap();
let kv = csr_matvec(k, v)?;
csr_matvec(k, &kv)
}
pub fn fit(&mut self, seed: usize, verbose: usize) -> Result<(), BixverseErrors> {
self.fit_with(seed, verbose, &mut CpuFwArgminB::default())
}
pub fn fit_with(
&mut self,
seed: usize,
verbose: usize,
backend: &mut impl FwArgminB,
) -> Result<(), BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
if self.kernel_mat.is_none() {
return Err(BixverseErrors::SEACellsKernelMatrixMissing);
}
if self.archetypes.is_none() {
return Err(BixverseErrors::SEACellsArchetypesMissing);
}
self.initialise_matrices(verbose, seed as u64, backend)?;
let a = self.a.as_ref().unwrap();
let b = self.b.as_ref().unwrap();
let k2_b = self.k2_b.as_ref().unwrap();
let initial_rss = self.compute_rss(a, b, k2_b)?;
self.rss_history.push(initial_rss);
self.convergence_threshold = Some(self.params.convergence_epsilon * initial_rss);
if verbosity.normal_verbosity() {
println!("Initial RSS: {:.6}", initial_rss);
println!(
"Convergence threshold: {:.6}",
self.convergence_threshold.unwrap()
);
}
let mut converged = false;
let mut n_iter = 0;
while (!converged && n_iter < self.params.max_iter) || n_iter < self.params.min_iter {
let iter_start = Instant::now();
n_iter += 1;
let b_current = self.b.take().unwrap();
let a_current = self.a.take().unwrap();
let k2_b_current = self.k2_b.take().unwrap();
debug_assert!(
self.k2_b_matches(&b_current, &k2_b_current),
"the K^2 B cache drifted from B before iteration {}",
n_iter
);
let a_new =
self.update_a_mat(&b_current, &k2_b_current, &a_current, verbose, backend)?;
let (b_new, k2_b_new) =
self.update_b_mat(&a_new, &b_current, &k2_b_current, verbose, backend)?;
debug_assert!(
self.k2_b_matches(&b_new, &k2_b_new),
"the K^2 B cache drifted from B after iteration {}",
n_iter
);
let rss = self.compute_rss(&a_new, &b_new, &k2_b_new)?;
self.rss_history.push(rss);
self.a = Some(a_new);
self.b = Some(b_new);
self.k2_b = Some(k2_b_new);
let iter_duration = iter_start.elapsed();
if verbosity.normal_verbosity() {
println!(
"Iteration {}: RSS = {:.6}, Time = {:.2}s",
n_iter,
rss,
iter_duration.as_secs_f32()
);
}
if n_iter > 1 {
let rss_diff = (self.rss_history[n_iter - 1] - self.rss_history[n_iter]).abs();
if rss_diff < self.convergence_threshold.unwrap() && n_iter >= self.params.min_iter
{
if verbosity.normal_verbosity() {
println!("Converged after {} iterations!", n_iter);
}
converged = true;
}
}
}
if !converged && verbosity.normal_verbosity() {
println!(
"Warning: Algorithm did not converge after {} iterations",
self.params.max_iter
);
}
Ok(())
}
pub fn initialise_archetypes(
&mut self,
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
verbose: usize,
squared_dist: bool,
seed: u64,
) -> Result<(), BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
if self.n_cells > self.params.greedy_threshold {
if verbosity.normal_verbosity() {
println!(
"Dataset large (n={}), using fast random init (threshold: {})",
self.n_cells.separate_with_underscores(),
self.params.greedy_threshold
);
}
self.initialise_archetypes_random(verbose, seed);
} else {
self.initialise_archetypes_combined(
knn_indices,
knn_distances,
squared_dist,
verbose,
seed,
)?;
}
Ok(())
}
fn initialise_archetypes_random(&mut self, verbose: usize, seed: u64) {
let verbosity = parse_verbosity_level(verbose);
let mut rng = StdRng::seed_from_u64(seed);
let mut indices: Vec<usize> = (0..self.n_cells).collect();
indices.shuffle(&mut rng);
let archetypes: Vec<usize> = indices.into_iter().take(self.params.n_sea_cells).collect();
if verbosity.normal_verbosity() {
println!("Selected {} random archetypes", archetypes.len());
}
self.archetypes = Some(archetypes);
}
fn initialise_archetypes_combined(
&mut self,
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
squared_dist: bool,
verbose: usize,
seed: u64,
) -> Result<(), BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let k = self.params.n_sea_cells;
if verbosity.normal_verbosity() {
println!("Computing diffusion maps for waypoint initialisation...");
}
let mut kernel = compute_diffusion_kernel(knn_indices, knn_distances, squared_dist)?;
let (eigenvalues, eigenvectors) = diffusion_map_from_kernel(
&mut kernel,
self.params.knn_params.k,
seed,
Some(self.params.lanczos_params),
)?;
let multiscale = determine_multiscale_space(&eigenvalues, &eigenvectors, Some(10));
let waypoint_ix = max_min_sampling(&multiscale, k, seed);
if verbosity.normal_verbosity() {
println!(
"Selecting {} cells from waypoint initialisation.",
waypoint_ix.len()
);
}
let from_greedy = k.saturating_sub(waypoint_ix.len());
if verbosity.normal_verbosity() {
println!("Initialising residual matrix using greedy column selection");
}
let greedy_ix = self.get_greedy_centres(from_greedy + 10)?;
if verbosity.normal_verbosity() {
println!(
"Selecting {} cells from greedy initialisation.",
from_greedy
);
}
let mut all_ix = waypoint_ix;
all_ix.extend(greedy_ix);
let mut seen = FxHashSet::default();
let unique_ix: Vec<usize> = all_ix
.into_iter()
.filter(|&x| seen.insert(x))
.take(k)
.collect();
self.archetypes = Some(unique_ix);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn initialise_archetypes_landmark(
&mut self,
pca: MatRef<f32>,
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
squared_dist: bool,
n_landmarks: usize,
verbose: usize,
seed: u64,
) -> Result<(), BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let k = self.params.n_sea_cells;
let n = self.n_cells;
let knn_k = self.params.knn_params.k;
if verbosity.normal_verbosity() {
println!("Building diffusion kernel for landmark selection...");
}
let kernel = compute_diffusion_kernel(knn_indices, knn_distances, squared_dist)?;
if verbosity.normal_verbosity() {
println!(
"Selecting {} density-weighted landmarks...",
n_landmarks.separate_with_underscores()
);
}
let landmark_indices = select_density_landmarks(&kernel, n_landmarks, seed);
let l = landmark_indices.len();
let k_ll = knn_k.min(l.saturating_sub(1)).max(3);
if verbosity.normal_verbosity() {
println!("Building landmark-landmark diffusion operator (L={})...", l);
}
let (ll_idx, ll_dist) = landmark_knn(
pca,
&landmark_indices,
k_ll,
&self.params.knn_params,
seed as usize,
verbose,
)?;
let squared_dist = self.params.knn_params.ann_dist == "euclidean";
let mut ll_kernel = compute_diffusion_kernel(&ll_idx, &ll_dist, squared_dist)?;
let n_eigs = k_ll.min(l - 1).max(11);
let (evals, evecs) = diffusion_map_from_kernel(
&mut ll_kernel,
n_eigs,
seed,
Some(self.params.lanczos_params),
)?;
let landmark_multiscale = determine_multiscale_space(&evals, &evecs, Some(10));
let n_components = landmark_multiscale.first().map_or(0, Vec::len);
let used_lambdas: Vec<f32> = (1..=n_components).map(|i| evals[i] as f32).collect();
if verbosity.normal_verbosity() {
println!(
"Building data-to-landmark transitions ({} × {})...",
n.separate_with_underscores(),
l
);
}
let p_nl = build_data_to_landmark_transitions(pca, &landmark_indices, knn_k, 1.0, 1e-4);
if verbosity.detailed_verbosity() {
println!("Nystroem-extending multiscale embedding to full data...");
}
let multiscale = nystrom_extend(&p_nl, &landmark_multiscale, &used_lambdas);
let waypoint_ix = max_min_sampling(&multiscale, k, seed);
if verbosity.detailed_verbosity() {
println!("Selected {} cells from waypoint init", waypoint_ix.len());
}
let mut seen = FxHashSet::default();
let mut unique_ix: Vec<usize> = waypoint_ix
.into_iter()
.filter(|&x| seen.insert(x))
.take(k)
.collect();
if unique_ix.len() < k {
if verbosity.normal_verbosity() {
println!(
"Padding {} cells with random selection",
k - unique_ix.len()
);
}
let mut rng = StdRng::seed_from_u64(seed.wrapping_add(1));
let mut remaining: Vec<usize> = (0..n).filter(|i| !seen.contains(i)).collect();
remaining.shuffle(&mut rng);
while unique_ix.len() < k {
match remaining.pop() {
Some(idx) => unique_ix.push(idx),
None => break,
}
}
}
self.archetypes = Some(unique_ix);
Ok(())
}
fn get_greedy_centres(&self, n_centres: usize) -> Result<Vec<usize>, BixverseErrors> {
let kernel = self.kernel_mat.as_ref().unwrap();
let n = kernel.shape.0;
const INIT_CHUNK_SIZE: usize = 256;
const TILE: usize = 4096;
let mut f = vec![0_f32; n];
let mut g = vec![0_f32; n];
for chunk_start in (0..n).step_by(INIT_CHUNK_SIZE) {
let chunk_end = (chunk_start + INIT_CHUNK_SIZE).min(n);
let chunk_results: Vec<(usize, Vec<f32>)> = (chunk_start..chunk_end)
.into_par_iter()
.map(|i| {
let mut row_i = vec![0_f32; n];
for idx in kernel.indptr[i]..kernel.indptr[i + 1] {
let idx_usize = idx as usize;
row_i[kernel.indices[idx_usize] as usize] = kernel.data[idx_usize];
}
let k2_col_i = csr_matvec(kernel, &row_i)?;
Ok((i, k2_col_i))
})
.collect::<Result<Vec<(usize, Vec<f32>)>, BixverseErrors>>()?;
for (i, k2_col_i) in chunk_results {
g[i] = k2_col_i[i];
for j in 0..n {
f[j] += k2_col_i[j] * k2_col_i[j];
}
}
}
let mut omega: Vec<Vec<f32>> = vec![vec![0_f32; n]; n_centres];
let mut centres: Vec<usize> = Vec::with_capacity(n_centres);
let mut e_p = vec![0.0f32; n];
let mut omega_new = vec![0.0f32; n];
let mut pl = vec![0.0f32; n];
for iter in 0..n_centres {
let best_idx = (0..n)
.into_par_iter()
.filter_map(|i| (g[i] > 1e-15).then(|| (i, f[i] / g[i])))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.map(|(i, _)| i)
.unwrap_or(0);
centres.push(best_idx);
e_p.fill(0.0);
e_p[best_idx] = 1.0;
let mut delta = self.k_squared_matvec(&e_p)?;
let delta_coefs: Vec<f32> = (0..iter).map(|r| omega[r][best_idx]).collect();
delta
.par_chunks_mut(TILE)
.enumerate()
.for_each(|(tile_idx, tile)| {
let start = tile_idx * TILE;
let end = start + tile.len();
for r in 0..iter {
let coef = delta_coefs[r];
if coef == 0.0 {
continue;
}
let omega_r = &omega[r][start..end];
for (d, o) in tile.iter_mut().zip(omega_r.iter()) {
*d -= coef * o;
}
}
});
delta[best_idx] = delta[best_idx].max(0.0);
let delta_p_sqrt = delta[best_idx].sqrt().max(1e-6);
omega_new
.par_iter_mut()
.zip(delta.par_iter())
.for_each(|(o, &d)| *o = d / delta_p_sqrt);
let omega_sq_norm: f32 = omega_new.par_iter().map(|&x| x * x).sum();
let k_omega_new = self.k_squared_matvec(&omega_new)?;
let omega_dot_new: Vec<f32> = (0..iter)
.into_par_iter()
.map(|r| {
omega[r]
.iter()
.zip(omega_new.iter())
.map(|(a, b)| a * b)
.sum::<f32>()
})
.collect();
pl.fill(0.0);
pl.par_chunks_mut(TILE)
.enumerate()
.for_each(|(tile_idx, tile)| {
let start = tile_idx * TILE;
let end = start + tile.len();
for r in 0..iter {
let coef = omega_dot_new[r];
if coef == 0.0 {
continue;
}
let omega_r = &omega[r][start..end];
for (p, o) in tile.iter_mut().zip(omega_r.iter()) {
*p += coef * o;
}
}
});
f.par_iter_mut()
.zip(g.par_iter_mut())
.zip(omega_new.par_iter())
.zip(k_omega_new.par_iter())
.zip(pl.par_iter())
.for_each(|((((f_i, g_i), &o_i), &k_i), &p_i)| {
let omega_hadamard = o_i * o_i;
let term1 = omega_sq_norm * omega_hadamard;
let term2 = o_i * (k_i - p_i);
*f_i += -2.0 * term2 + term1;
*g_i += omega_hadamard;
});
omega[iter].copy_from_slice(&omega_new);
}
Ok(centres)
}
fn initialise_matrices(
&mut self,
verbose: usize,
seed: u64,
backend: &mut impl FwArgminB,
) -> Result<(), BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let archetypes = self.archetypes.as_ref().unwrap();
let k = archetypes.len();
let n = self.n_cells;
if verbosity.normal_verbosity() {
println!("Initialising A and B matrices...");
}
let mut b_rows = Vec::new();
let mut b_cols = Vec::new();
let mut b_vals = Vec::new();
for (col, &row) in archetypes.iter().enumerate() {
b_rows.push(row);
b_cols.push(col);
b_vals.push(1_f32);
}
let b = coo_to_csr(&b_rows.index_cast(), &b_cols.index_cast(), &b_vals, (n, k));
let archetypes_per_cell = 10.min(k);
let mut rng = StdRng::seed_from_u64(seed);
let mut a_rows = Vec::new();
let mut a_cols = Vec::new();
let mut a_vals = Vec::new();
for cell in 0..n {
for _ in 0..archetypes_per_cell {
let archetype = rng.random_range(0..k);
a_rows.push(archetype);
a_cols.push(cell);
a_vals.push(rng.random::<f32>());
}
}
let mut a = coo_to_csr(&a_rows.index_cast(), &a_cols.index_cast(), &a_vals, (k, n));
normalise_csr_columns_l1(&mut a);
let k2_b = self.k_squared_matmul(&b)?;
a = self.update_a_mat(&b, &k2_b, &a, verbose, backend)?;
self.a = Some(a);
self.b = Some(b);
self.k2_b = Some(k2_b);
Ok(())
}
fn k2_b_matches(
&self,
b: &CompressedSparseData2<f32>,
k2_b: &CompressedSparseData2<f32>,
) -> bool {
let Ok(reference) = self.k_squared_matmul(b) else {
return false;
};
if reference.shape != k2_b.shape {
return false;
}
let (nrow, ncol) = reference.shape;
let mut dense = vec![0.0f32; nrow * ncol];
for row in 0..nrow {
for idx in reference.indptr[row] as usize..reference.indptr[row + 1] as usize {
dense[row * ncol + reference.indices[idx] as usize] += reference.data[idx];
}
}
let scale = dense.iter().fold(0.0f32, |acc, v| acc.max(v.abs()));
for row in 0..nrow {
for idx in k2_b.indptr[row] as usize..k2_b.indptr[row + 1] as usize {
dense[row * ncol + k2_b.indices[idx] as usize] -= k2_b.data[idx];
}
}
let worst = dense.iter().fold(0.0f32, |acc, v| acc.max(v.abs()));
scale == 0.0 || worst / scale < 1e-3
}
fn update_a_mat(
&self,
b: &CompressedSparseData2<f32>,
k2_b: &CompressedSparseData2<f32>,
a_prev: &CompressedSparseData2<f32>,
verbose: usize,
backend: &mut impl FwArgminB,
) -> Result<CompressedSparseData2<f32>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let t2 = k2_b.transpose_and_convert();
let t1 = csr_matmul_csr(&t2, b)?;
drop(t2);
let a_prev_t = a_prev.transpose_and_convert();
let n = a_prev.shape.1;
let k = a_prev.shape.0;
let columns = backend.columns_a(
&t1,
&a_prev_t,
k2_b,
k,
n,
self.params.max_fw_iters,
self.params.pruning.then_some(self.params.pruning_threshold),
)?;
let a = fw_atoms_to_csr(&columns, (k, n));
if verbosity.detailed_verbosity() {
println!(
" A matrix Frank-Wolfe: {} iterations over {} cells",
self.params.max_fw_iters,
n.separate_with_underscores()
);
}
Ok(a)
}
#[cfg(test)]
fn update_a_mat_iteration_major(
&self,
b: &CompressedSparseData2<f32>,
a_prev: &CompressedSparseData2<f32>,
) -> Result<CompressedSparseData2<f32>, BixverseErrors> {
let k2_b = self.k_squared_matmul(b)?;
let t2 = k2_b.transpose_and_convert();
let t1 = csr_matmul_csr(&t2, b)?;
drop(t2);
let mut a = a_prev.clone();
let n = a.shape.1;
let k = a.shape.0;
for t in 0..self.params.max_fw_iters {
let argmins = fw_argmins_a(&t1, &a, &k2_b, k, n);
let mut e_data: Vec<(usize, usize, f32)> = argmins
.iter()
.enumerate()
.map(|(col, &row)| (row, col, 1.0f32))
.collect();
e_data.sort_unstable_by_key(|&(r, c, _)| (r, c));
let e_rows: Vec<usize> = e_data.iter().map(|&(r, _, _)| r).collect();
let e_cols: Vec<usize> = e_data.iter().map(|&(_, c, _)| c).collect();
let e_vals: Vec<f32> = e_data.iter().map(|&(_, _, v)| v).collect();
let e =
coo_to_csr_presorted(&e_rows.index_cast(), &e_cols.index_cast(), &e_vals, (k, n));
let step_size = 2.0 / (t as f32 + 2.0);
let retain = 1.0 - step_size;
for val in &mut a.data {
*val *= retain;
}
let e_scaled = sparse_scalar_multiply_csr(&e, step_size);
a = sparse_add_csr(&a, &e_scaled)?;
if self.params.pruning {
prune_and_renormalise(&mut a, self.params.pruning_threshold);
}
}
Ok(a)
}
fn update_b_mat(
&self,
a: &CompressedSparseData2<f32>,
b_prev: &CompressedSparseData2<f32>,
k2_b_prev: &CompressedSparseData2<f32>,
verbose: usize,
backend: &mut impl FwArgminB,
) -> Result<(CompressedSparseData2<f32>, CompressedSparseData2<f32>), BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
const FW_REL_TOL: f32 = 1e-3;
let kernel = self
.kernel_mat
.as_ref()
.ok_or(BixverseErrors::SEACellsKernelMatrixMissing)?;
let a_t = a.transpose_and_convert();
let t1 = csr_matmul_csr(a, &a_t)?;
let t2 = self.k_squared_matmul(&a_t)?;
backend.begin(&t1, &t2)?;
drop(t2);
let mut b = b_prev.clone();
let mut k2_b = k2_b_prev.clone();
let n = b.shape.0;
let k = b.shape.1;
let mut initial_gap: f32 = 0.0;
for t in 0..self.params.max_fw_iters {
let (argmins, fw_gap) = backend.argmins(&k2_b, &b)?;
if t == 0 {
initial_gap = fw_gap.max(1e-12);
}
let mut e_data: Vec<(usize, usize, f32)> = argmins
.iter()
.enumerate()
.map(|(col, &row)| (row, col, 1.0f32))
.collect();
e_data.sort_unstable_by_key(|&(r, c, _)| (r, c));
let e_rows: Vec<usize> = e_data.iter().map(|&(r, _, _)| r).collect();
let e_cols: Vec<usize> = e_data.iter().map(|&(_, c, _)| c).collect();
let e_vals: Vec<f32> = e_data.iter().map(|&(_, _, v)| v).collect();
let e =
coo_to_csr_presorted(&e_rows.index_cast(), &e_cols.index_cast(), &e_vals, (n, k));
let step_size = 2.0 / (t as f32 + 2.0);
let retain = 1.0 - step_size;
for val in &mut b.data {
*val *= retain;
}
let e_scaled = sparse_scalar_multiply_csr(&e, step_size);
b = sparse_add_csr(&b, &e_scaled)?;
let mut contributions: Vec<Vec<(u32, f32)>> = vec![Vec::new(); k];
for (col, &row) in argmins.iter().enumerate() {
contributions[col].push((row as u32, step_size));
}
let renorm = if self.params.pruning {
let (dropped, factors) =
prune_and_renormalise_tracked(&mut b, self.params.pruning_threshold);
for (col, row, value) in dropped {
contributions[col as usize].push((row, -value));
}
Some(factors)
} else {
None
};
for val in &mut k2_b.data {
*val *= retain;
}
let delta = k_squared_column_combination(kernel, &contributions, n);
k2_b = sparse_add_csr(&k2_b, &transpose_sparse(&delta))?;
if let Some(factors) = renorm {
for (idx, &col) in k2_b.indices.iter().enumerate() {
k2_b.data[idx] *= factors[col as usize];
}
}
if (t + 1) % K2B_REFRESH_EVERY == 0 {
k2_b = self.k_squared_matmul(&b)?;
}
if verbosity.detailed_verbosity() && (t + 1) % 10 == 0 {
println!(
" B matrix Frank-Wolfe iteration: {} / {}, nnz(B) = {}, nnz(K^2 B) = {}",
t + 1,
self.params.max_fw_iters,
b.get_nnz().separate_with_underscores(),
k2_b.get_nnz().separate_with_underscores()
);
}
if fw_gap / initial_gap < FW_REL_TOL && t >= MIN_FW_ITERS {
if verbosity.detailed_verbosity() {
println!(
" B matrix FW converged at iter {} (gap: {:.4e})",
t + 1,
fw_gap
);
}
break;
}
}
Ok((b, k2_b))
}
fn compute_rss(
&self,
a: &CompressedSparseData2<f32>,
b: &CompressedSparseData2<f32>,
k2_b: &CompressedSparseData2<f32>,
) -> Result<f32, BixverseErrors> {
self.compute_rss_trace(a, b, k2_b)
}
#[cfg(test)]
fn compute_rss_simple(
&self,
a: &CompressedSparseData2<f32>,
b: &CompressedSparseData2<f32>,
) -> Result<f32, BixverseErrors> {
let k_mat = self.kernel_mat.as_ref().unwrap();
let k_b = csr_matmul_csr(k_mat, b)?;
let reconstruction = csr_matmul_csr(&k_b, a)?;
let diff = sparse_subtract_csr(k_mat, &reconstruction)?;
Ok(frobenius_norm(&diff))
}
fn compute_rss_trace(
&self,
a: &CompressedSparseData2<f32>,
b: &CompressedSparseData2<f32>,
k2_b: &CompressedSparseData2<f32>,
) -> Result<f32, BixverseErrors> {
let k_frob_sq = self
.k_frobenius_norm_sq
.ok_or(BixverseErrors::SEACellsKernelMatrixMissing)?;
let a_k2b = csr_matmul_csr(a, k2_b)?;
let trace_term = matrix_trace(&a_k2b);
let a_t = a.transpose_and_convert();
let a_at = csr_matmul_csr(a, &a_t)?;
let b_t = b.transpose_and_convert();
let bt_k2b = csr_matmul_csr(&b_t, k2_b)?;
let result = csr_matmul_csr(&a_at, &bt_k2b)?; let reconstruction_frob_sq = matrix_trace(&result);
let residual_sq = k_frob_sq - 2.0 * trace_term + reconstruction_frob_sq;
Ok(residual_sq.max(0.0).sqrt() as f32)
}
pub fn get_hard_assignments(&self) -> Result<Vec<usize>, BixverseErrors> {
if self.a.is_none() {
return Err(BixverseErrors::SEACellsModelNotFitted);
}
let a = self.a.as_ref().unwrap();
let n = a.shape.1;
let a_csc = a.transpose_and_convert();
let mut assignments = vec![0usize; n];
for cell in 0..n {
let start = a_csc.indptr[cell];
let end = a_csc.indptr[cell + 1];
let mut max_val = f32::NEG_INFINITY;
let mut max_arch = 0;
for idx in start..end {
let idx_usize = idx as usize;
if a_csc.data[idx_usize] > max_val {
max_val = a_csc.data[idx_usize];
max_arch = a_csc.indices[idx_usize] as usize;
}
}
assignments[cell] = max_arch;
}
Ok(assignments)
}
pub fn get_rss_history(&self) -> &[f32] {
&self.rss_history
}
pub fn get_archetypes(&self) -> Result<Vec<usize>, BixverseErrors> {
if self.archetypes.is_none() {
return Err(BixverseErrors::SEACellsArchetypesMissing);
}
Ok(self.archetypes.as_ref().unwrap().clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn dense_t1(k: usize) -> Vec<f32> {
let mut t1 = vec![0.0f32; k * k];
for i in 0..k {
for j in i..k {
let v = ((i * 7 + j * 13) % 11) as f32 * 0.1 + if i == j { 2.0 } else { 0.0 };
t1[i * k + j] = v;
t1[j * k + i] = v;
}
}
t1
}
fn reference_product(t1: &[f32], k: usize, atoms: &FwAtoms) -> Vec<f32> {
let (indices, weights) = atoms.atoms();
let mut out = vec![0.0f32; k];
for (&r, &v) in indices.iter().zip(weights.iter()) {
for i in 0..k {
out[i] += v * t1[r as usize * k + i];
}
}
out
}
fn step_both(atoms: &mut FwAtoms, w: &mut [f32], t1: &[f32], k: usize, gamma: f32, amin: u32) {
atoms.step(gamma, amin);
let retain = 1.0 - gamma;
let row = &t1[amin as usize * k..(amin as usize + 1) * k];
for i in 0..k {
w[i] = w[i] * retain + gamma * row[i];
}
}
fn prune_both(atoms: &mut FwAtoms, w: &mut [f32], t1: &[f32], k: usize, threshold: f32) {
let outcome = atoms.prune(threshold);
for (r, v) in outcome.dropped {
let row = &t1[r as usize * k..(r as usize + 1) * k];
for i in 0..k {
w[i] -= v * row[i];
}
}
if outcome.renorm != 1.0 {
for value in w.iter_mut() {
*value *= outcome.renorm;
}
}
}
#[test]
fn test_fw_atoms_gradient_tracks_reference_without_pruning() {
let k = 12;
let t1 = dense_t1(k);
let mut atoms = FwAtoms::with_capacity(50);
let mut w = vec![0.0f32; k];
for t in 0..50u32 {
let gamma = 2.0 / (t as f32 + 2.0);
let amin = (t * 5 + 3) % k as u32;
step_both(&mut atoms, &mut w, &t1, k, gamma, amin);
let reference = reference_product(&t1, k, &atoms);
for i in 0..k {
assert_relative_eq!(w[i], reference[i], max_relative = 1e-4, epsilon = 1e-6);
}
}
let (_, weights) = atoms.atoms();
let mass: f32 = weights.iter().sum();
assert_relative_eq!(mass, 1.0, max_relative = 1e-5);
}
#[test]
fn test_fw_atoms_gradient_tracks_reference_with_pruning() {
let k = 12;
let t1 = dense_t1(k);
let mut atoms = FwAtoms::with_capacity(50);
let mut w = vec![0.0f32; k];
let threshold = 5e-2f32;
for t in 0..50u32 {
let gamma = 2.0 / (t as f32 + 2.0);
let amin = (t * 5 + 3) % k as u32;
step_both(&mut atoms, &mut w, &t1, k, gamma, amin);
prune_both(&mut atoms, &mut w, &t1, k, threshold);
let reference = reference_product(&t1, k, &atoms);
for i in 0..k {
assert_relative_eq!(w[i], reference[i], max_relative = 1e-3, epsilon = 1e-5);
}
}
assert!(
!atoms.is_empty(),
"pruning should not empty the column at this threshold"
);
let (_, weights) = atoms.atoms();
let mass: f32 = weights.iter().sum();
assert_relative_eq!(mass, 1.0, max_relative = 1e-4);
}
#[test]
fn test_fw_atoms_dropped_atom_can_return() {
let k = 8;
let t1 = dense_t1(k);
let mut atoms = FwAtoms::with_capacity(16);
let mut w = vec![0.0f32; k];
step_both(&mut atoms, &mut w, &t1, k, 1.0, 3);
step_both(&mut atoms, &mut w, &t1, k, 0.5, 5);
prune_both(&mut atoms, &mut w, &t1, k, 0.1);
assert_eq!(atoms.len(), 2);
step_both(&mut atoms, &mut w, &t1, k, 0.9, 5);
prune_both(&mut atoms, &mut w, &t1, k, 0.2);
assert_eq!(atoms.len(), 1, "atom 3 should have been pruned");
assert_eq!(atoms.atoms().0[0], 5);
step_both(&mut atoms, &mut w, &t1, k, 0.5, 3);
assert_eq!(atoms.len(), 2);
let reference = reference_product(&t1, k, &atoms);
for i in 0..k {
assert_relative_eq!(w[i], reference[i], max_relative = 1e-4, epsilon = 1e-6);
}
}
#[test]
fn test_fw_atoms_prune_everything_leaves_zero_state() {
let k = 6;
let t1 = dense_t1(k);
let mut atoms = FwAtoms::with_capacity(8);
let mut w = vec![0.0f32; k];
step_both(&mut atoms, &mut w, &t1, k, 1.0, 2);
prune_both(&mut atoms, &mut w, &t1, k, 2.0);
assert!(atoms.is_empty());
for value in &w {
assert_relative_eq!(*value, 0.0, epsilon = 1e-6);
}
}
#[test]
fn test_fw_atoms_repeated_argmin_merges() {
let mut atoms = FwAtoms::with_capacity(8);
atoms.step(1.0, 4);
atoms.step(0.5, 4);
atoms.step(0.25, 4);
assert_eq!(atoms.len(), 1);
let (indices, weights) = atoms.atoms();
assert_eq!(indices[0], 4);
assert_relative_eq!(weights[0], 1.0, max_relative = 1e-6);
}
fn banded_kernel(n: usize, bandwidth: usize) -> CompressedSparseData2<f32> {
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut vals = Vec::new();
for i in 0..n {
let lo = i.saturating_sub(bandwidth);
let hi = (i + bandwidth + 1).min(n);
for j in lo..hi {
let d = (i as f32 - j as f32).abs();
rows.push(i);
cols.push(j);
vals.push((-d * d / 4.0).exp());
}
}
coo_to_csr(&rows.index_cast(), &cols.index_cast(), &vals, (n, n))
}
fn densify(mat: &CompressedSparseData2<f32>) -> Vec<f32> {
let (nrow, ncol) = mat.shape;
let mut out = vec![0.0f32; nrow * ncol];
for row in 0..nrow {
for idx in mat.indptr[row] as usize..mat.indptr[row + 1] as usize {
out[row * ncol + mat.indices[idx] as usize] += mat.data[idx];
}
}
out
}
fn initial_matrices(
n: usize,
k: usize,
seed: u64,
) -> (CompressedSparseData2<f32>, CompressedSparseData2<f32>) {
let stride = n / k;
let b_rows: Vec<usize> = (0..k).map(|c| c * stride).collect();
let b_cols: Vec<usize> = (0..k).collect();
let b_vals = vec![1.0f32; k];
let b = coo_to_csr(&b_rows.index_cast(), &b_cols.index_cast(), &b_vals, (n, k));
let mut rng = StdRng::seed_from_u64(seed);
let per_cell = 10.min(k);
let mut a_rows = Vec::new();
let mut a_cols = Vec::new();
let mut a_vals = Vec::new();
for cell in 0..n {
for _ in 0..per_cell {
a_rows.push(rng.random_range(0..k));
a_cols.push(cell);
a_vals.push(rng.random::<f32>());
}
}
let mut a = coo_to_csr(&a_rows.index_cast(), &a_cols.index_cast(), &a_vals, (k, n));
normalise_csr_columns_l1(&mut a);
(b, a)
}
#[test]
fn test_k2b_maintenance_matches_from_scratch() {
let n = 600usize;
let k = 12usize;
for (max_fw_iters, pruning, threshold) in [
(K2B_REFRESH_EVERY - 1, false, 0.0f32),
(K2B_REFRESH_EVERY - 1, true, 1e-7),
(K2B_REFRESH_EVERY + 5, false, 0.0),
(K2B_REFRESH_EVERY + 5, true, 1e-7),
(K2B_REFRESH_EVERY + 4, true, 5e-2),
] {
let params = SEACellsParams {
lanczos_params: LanczosParams::default(),
n_sea_cells: k,
max_fw_iters,
convergence_epsilon: 1e-3,
max_iter: 2,
min_iter: 2,
greedy_threshold: 0,
graph_building: "union".to_string(),
pruning,
pruning_threshold: threshold,
n_landmarks: None,
knn_params: KnnParams::new(),
};
let mut model = SEACells::new(n, ¶ms);
let kernel = banded_kernel(n, 3);
model.k_frobenius_norm_sq = Some(frobenius_norm_sq_f64(&kernel));
model.kernel_mat = Some(kernel);
let (b, a_prev) = initial_matrices(n, k, 11);
let k2_b = model.k_squared_matmul(&b).expect("K^2 B failed");
let a = model
.update_a_mat(&b, &k2_b, &a_prev, 0, &mut CpuFwArgminB::default())
.expect("A update failed");
let (b_new, k2_b_new) = model
.update_b_mat(&a, &b, &k2_b, 0, &mut CpuFwArgminB::default())
.expect("B update failed");
let reference = model.k_squared_matmul(&b_new).expect("K^2 B failed");
assert_eq!(k2_b_new.shape, reference.shape);
let maintained = densify(&k2_b_new);
let expected = densify(&reference);
let scale = expected.iter().fold(0.0f32, |acc, v| acc.max(v.abs()));
assert!(scale > 0.0, "reference K^2 B is all zeros, test is vacuous");
let floor = 1e-6 * scale;
let mut worst_rel = 0.0f32;
let mut worst_at = (0usize, 0usize);
for row in 0..expected.len() / k2_b_new.shape.1 {
for col in 0..k2_b_new.shape.1 {
let i = row * k2_b_new.shape.1 + col;
let diff = (maintained[i] - expected[i]).abs();
if diff <= floor {
continue;
}
let rel = diff / expected[i].abs().max(floor);
if rel > worst_rel {
worst_rel = rel;
worst_at = (row, col);
}
}
}
assert!(
worst_rel < 1e-3,
"K^2 B drifted from the from-scratch product: worst relative {:.3e} at \
({}, {}) (max_fw_iters {}, pruning {:?}, threshold {})",
worst_rel,
worst_at.0,
worst_at.1,
max_fw_iters,
pruning,
threshold
);
}
}
#[test]
fn test_update_a_mat_matches_iteration_major() {
let n = 300usize;
let k = 12usize;
for (pruning, threshold) in [(false, 0.0f32), (true, 1e-7), (true, 5e-2)] {
let params = SEACellsParams {
lanczos_params: LanczosParams::default(),
n_sea_cells: k,
max_fw_iters: 50,
convergence_epsilon: 1e-3,
max_iter: 3,
min_iter: 1,
greedy_threshold: 0,
graph_building: "union".to_string(),
pruning,
pruning_threshold: threshold,
n_landmarks: None,
knn_params: KnnParams::new(),
};
let mut model = SEACells::new(n, ¶ms);
let kernel = banded_kernel(n, 4);
model.k_frobenius_norm_sq = Some(frobenius_norm_sq_f64(&kernel));
model.kernel_mat = Some(kernel);
let (b, a_prev) = initial_matrices(n, k, 11);
let k2_b = model.k_squared_matmul(&b).expect("K^2 B failed");
let a_new = model
.update_a_mat(&b, &k2_b, &a_prev, 0, &mut CpuFwArgminB::default())
.expect("cell-major update failed");
let a_ref = model
.update_a_mat_iteration_major(&b, &a_prev)
.expect("iteration-major update failed");
assert_eq!(a_new.shape, a_ref.shape);
let dense_new = densify(&a_new);
let dense_ref = densify(&a_ref);
let differing = (0..n)
.filter(|&cell| {
(0..k).any(|r| (dense_new[r * n + cell] - dense_ref[r * n + cell]).abs() > 1e-4)
})
.count();
let t_iters = params.max_fw_iters as f32;
let min_fw_weight = 2.0 / (t_iters * (t_iters + 1.0));
if !pruning || threshold < min_fw_weight {
assert!(
differing * 50 <= n,
"{} / {} cells differ (pruning {:?}, threshold {}) below the \
cascade threshold {:.2e}, well past a tie-break rate",
differing,
n,
pruning,
threshold,
min_fw_weight
);
}
for cell in 0..n {
let mass: f32 = (0..k).map(|r| dense_new[r * n + cell]).sum();
assert_relative_eq!(mass, 1.0, max_relative = 1e-4);
}
let rss_new = model.compute_rss(&a_new, &b, &k2_b).expect("RSS failed");
let rss_ref = model.compute_rss(&a_ref, &b, &k2_b).expect("RSS failed");
assert_relative_eq!(rss_new, rss_ref, max_relative = 1e-4);
assert!(
a_new.get_nnz() <= a_ref.get_nnz() + differing,
"nnz grew beyond the tie-break slack: {} vs {} + {} (pruning {:?}, threshold {})",
a_new.get_nnz(),
a_ref.get_nnz(),
differing,
pruning,
threshold
);
}
}
#[test]
fn test_fw_atoms_closed_form_weights() {
let n_iters = 20usize;
let mut atoms = FwAtoms::with_capacity(n_iters);
for t in 0..n_iters {
let gamma = 2.0 / (t as f32 + 2.0);
atoms.step(gamma, t as u32);
}
let denom = (n_iters * (n_iters + 1)) as f32;
let (indices, weights) = atoms.atoms();
assert_eq!(indices.len(), n_iters);
for (t, &weight) in weights.iter().enumerate() {
let expected = 2.0 * (t as f32 + 1.0) / denom;
assert_relative_eq!(weight, expected, max_relative = 1e-5);
}
}
#[test]
fn test_rss_trace_survives_near_total_cancellation() {
let n = 200usize;
let k = 200usize;
let params = SEACellsParams {
lanczos_params: LanczosParams::default(),
n_sea_cells: k,
max_fw_iters: 50,
convergence_epsilon: 1e-3,
max_iter: 3,
min_iter: 1,
greedy_threshold: 0,
graph_building: "union".to_string(),
pruning: false,
pruning_threshold: 0.0,
n_landmarks: None,
knn_params: KnnParams::new(),
};
for bandwidth in [0usize, 2] {
let mut model = SEACells::new(n, ¶ms);
let kernel = banded_kernel(n, bandwidth);
model.k_frobenius_norm_sq = Some(frobenius_norm_sq_f64(&kernel));
model.kernel_mat = Some(kernel);
let (b, a_prev) = initial_matrices(n, k, 11);
let k2_b = model.k_squared_matmul(&b).expect("K^2 B failed");
let a = model
.update_a_mat(&b, &k2_b, &a_prev, 0, &mut CpuFwArgminB::default())
.expect("A update failed");
let simple = model.compute_rss_simple(&a, &b).expect("simple failed");
let trace = model
.compute_rss_trace(&a, &b, &k2_b)
.expect("trace failed");
let residual_fraction = simple / model.k_frobenius_norm_sq.unwrap().sqrt() as f32;
println!(
"bandwidth {} | simple {:.6} trace {:.6} | residual {:.3}% of |K|_F | rel {:.2e}",
bandwidth,
simple,
trace,
100.0 * residual_fraction,
((simple - trace) / simple).abs()
);
assert!(
trace.is_finite() && trace >= 0.0,
"trace RSS is not a usable number at bandwidth {}: {}",
bandwidth,
trace
);
assert!(
((simple - trace) / simple).abs() < 5e-2,
"trace RSS lost its accuracy at bandwidth {}: {} vs {}",
bandwidth,
trace,
simple
);
}
}
#[test]
fn test_rss_paths_agree() {
#[cfg(not(feature = "large_scale_diagnostics"))]
let sizes: &[usize] = &[2000];
#[cfg(feature = "large_scale_diagnostics")]
let sizes: &[usize] = &[2000, 8000, 20000];
for &n in sizes {
let k = (n / 75).max(8);
let params = SEACellsParams {
lanczos_params: LanczosParams::default(),
n_sea_cells: k,
max_fw_iters: 50,
convergence_epsilon: 1e-3,
max_iter: 3,
min_iter: 1,
greedy_threshold: 0,
graph_building: "union".to_string(),
pruning: false,
pruning_threshold: 0.0,
n_landmarks: None,
knn_params: KnnParams::new(),
};
let mut model = SEACells::new(n, ¶ms);
let kernel = banded_kernel(n, 6);
model.k_frobenius_norm_sq = Some(frobenius_norm_sq_f64(&kernel));
model.kernel_mat = Some(kernel);
let (b, a_prev) = initial_matrices(n, k, 11);
let k2_b = model.k_squared_matmul(&b).expect("K^2 B failed");
let a = model
.update_a_mat(&b, &k2_b, &a_prev, 0, &mut CpuFwArgminB::default())
.expect("A update failed");
let simple_start = Instant::now();
let simple = model.compute_rss_simple(&a, &b).expect("simple failed");
let simple_time = simple_start.elapsed();
let trace_start = Instant::now();
let trace = model
.compute_rss_trace(&a, &b, &k2_b)
.expect("trace failed");
let trace_time = trace_start.elapsed();
let rel = ((simple - trace) / simple).abs();
println!(
"n = {:>6} k = {:>4} | simple {:>8.4} in {:>8.3}s | trace {:>8.4} in {:>8.3}s \
| speedup {:>7.1}x | rel diff {:.2e}",
n,
k,
simple,
simple_time.as_secs_f64(),
trace,
trace_time.as_secs_f64(),
simple_time.as_secs_f64() / trace_time.as_secs_f64(),
rel
);
assert!(
rel < 1e-4,
"RSS paths disagree at n = {}: {} vs {} (rel {:.2e})",
n,
simple,
trace,
rel
);
}
}
fn ring_knn(n: usize, k: usize) -> (Vec<Vec<usize>>, Vec<Vec<f32>>) {
let indices: Vec<Vec<usize>> = (0..n)
.map(|i| (1..=k).map(|step| (i + step) % n).collect())
.collect();
let distances: Vec<Vec<f32>> = (0..n)
.map(|_| (1..=k).map(|step| step as f32).collect())
.collect();
(indices, distances)
}
#[test]
fn test_diffusion_kernel_narrow_graph() {
let (indices, distances) = ring_knn(20, 5);
let kernel = compute_diffusion_kernel(&indices, &distances, false)
.expect("narrow kNN graph must not panic");
assert_eq!(kernel.shape, (20, 20));
}
#[test]
fn test_diffusion_kernel_bandwidth_scales_with_row_width() {
let (idx_narrow, dist_narrow) = ring_knn(12, 3);
let (idx_wide, dist_wide) = ring_knn(12, 9);
let narrow = compute_diffusion_kernel(&idx_narrow, &dist_narrow, false).unwrap();
let wide = compute_diffusion_kernel(&idx_wide, &dist_wide, false).unwrap();
let narrow_first = narrow.data[0];
let wide_first = wide.data[0];
assert!(
wide_first > narrow_first,
"wider graph should give a larger bandwidth and heavier weights: {narrow_first} vs {wide_first}"
);
}
#[test]
fn test_diffusion_kernel_empty_row_errors() {
let indices = vec![vec![1usize], vec![]];
let distances = vec![vec![1.0f32], vec![]];
assert!(compute_diffusion_kernel(&indices, &distances, false).is_err());
}
#[test]
fn test_diffusion_kernel_nan_distance_errors_rather_than_panics() {
let (indices, mut distances) = ring_knn(10, 6);
distances[3][2] = f32::NAN;
assert!(matches!(
compute_diffusion_kernel(&indices, &distances, false),
Err(BixverseErrors::DiffusionKernelNotFinite { n_cells: 10 })
));
}
fn duplicate_cell_knn(mutual: bool) -> (Vec<Vec<usize>>, Vec<Vec<f32>>) {
let n = 8usize;
let coords: Vec<f32> = (0..n)
.map(|i| if i == 1 { 0.0 } else { i as f32 })
.collect();
let mut indices = Vec::with_capacity(n);
let mut distances = Vec::with_capacity(n);
for i in 0..n {
let mut order: Vec<usize> = (0..n)
.filter(|&j| j != i && (mutual || i != 1 || j != 0))
.collect();
order.sort_by(|&a, &b| {
(coords[a] - coords[i])
.abs()
.total_cmp(&(coords[b] - coords[i]).abs())
});
order.truncate(4);
distances.push(
order
.iter()
.map(|&j| (coords[j] - coords[i]).abs())
.collect::<Vec<f32>>(),
);
indices.push(order);
}
(indices, distances)
}
#[test]
fn test_diffusion_kernel_keeps_the_row_of_a_duplicated_cell() {
let (indices, distances) = duplicate_cell_knn(true);
assert_eq!(distances[0][0], 0.0);
let kernel = compute_diffusion_kernel(&indices, &distances, false).unwrap();
assert!(kernel.data.iter().all(|v| v.is_finite()));
let lo = kernel.indptr[0] as usize;
let hi = kernel.indptr[1] as usize;
assert!(
kernel.indices[lo..hi].contains(&1),
"the edge to the duplicate was dropped"
);
assert_eq!(hi - lo, 4, "cell 0 lost neighbours to a zero bandwidth");
assert!(kernel.data[lo..hi].iter().all(|&v| v > 0.0));
}
#[test]
fn test_diffusion_kernel_survives_a_one_sided_duplicate() {
let (indices, distances) = duplicate_cell_knn(false);
assert_eq!(distances[0][0], 0.0);
assert!(distances[1][0] > 0.0);
let kernel = compute_diffusion_kernel(&indices, &distances, false).unwrap();
assert!(kernel.data.iter().all(|v| v.is_finite()));
let lo = kernel.indptr[0] as usize;
let hi = kernel.indptr[1] as usize;
assert!(kernel.indices[lo..hi].contains(&1));
}
#[test]
fn test_diffusion_kernel_bandwidth_rank_is_self_inclusive() {
let n = 40usize;
let mut indices = Vec::with_capacity(n);
let mut distances = Vec::with_capacity(n);
for i in 0..n {
let mut order: Vec<usize> = (0..n).filter(|&j| j != i).collect();
order.sort_by_key(|&j| j.abs_diff(i));
order.truncate(29);
distances.push(
order
.iter()
.map(|&j| j.abs_diff(i) as f32)
.collect::<Vec<f32>>(),
);
indices.push(order);
}
let kernel = compute_diffusion_kernel(&indices, &distances, false).unwrap();
let lo = kernel.indptr[20] as usize;
let hi = kernel.indptr[20 + 1] as usize;
let nearest = kernel.data[lo..hi]
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max);
assert_relative_eq!(nearest, 2.0 * (-1.0f32 / 5.0).exp(), epsilon = 1e-6);
}
}