use cubecl::prelude::*;
use cubecl_utils_rs::prelude::*;
use faer::{Mat, concat};
use indexmap::IndexSet;
use rayon::prelude::*;
use std::time::Instant;
use crate::core::math::pca_svd::compute_pc_scores;
use crate::core::math::{DEFAULT_N_POWER_ITERS_RAND_SVD, MAX_OVERSAMPLING_SINGLE_CELL};
use crate::gpu::linalg::sparse_gpu::GpuCompressedSparseData;
use crate::gpu::linalg::sparse_rand_svd_gpu::{RandSvdGpuParams, randomised_sparse_svd_gpu};
use crate::gpu::linalg::spmm::{launch_dense_column_weighted_sum, launch_spmm_csr_forward};
use crate::gpu::sc_gpu::knn_gpu::{KnnParamsGpu, dispatch_knn_gpu};
use crate::prelude::*;
use crate::single_cell::sc_processing::knn::KnnParams;
use crate::single_cell::sc_processing::pca::{sparse_csc_column_means, sparse_csc_column_stds};
use crate::single_cell::sc_processing::scrublet::{
FinalScrubletRes, calculate_doublet_scores, call_doublets,
};
use crate::single_cell::sc_processing::utils_doublets::*;
const MIN_GENE_STD: f32 = 1e-8;
#[derive(Clone, Debug)]
pub struct ScrubletParamsGpu {
pub log_transform: bool,
pub mean_center: bool,
pub normalise_variance: bool,
pub target_size: Option<f32>,
pub min_gene_var_pctl: f32,
pub hvg_method: String,
pub loess_span: f64,
pub clip_max: Option<f32>,
pub binning_strategy: String,
pub n_bins: usize,
pub no_pcs: usize,
pub sim_doublet_ratio: f32,
pub expected_doublet_rate: f32,
pub stdev_doublet_rate: f32,
pub n_bins_hist: usize,
pub manual_threshold: Option<f32>,
pub knn_params: ScrubletKnnBackend,
}
#[derive(Clone, Debug)]
pub enum ScrubletKnnBackend {
Cpu(KnnParams),
Gpu(KnnParamsGpu),
}
impl ScrubletKnnBackend {
pub fn k(&self) -> usize {
match self {
Self::Cpu(p) => p.k,
Self::Gpu(p) => p.k,
}
}
}
impl Default for ScrubletKnnBackend {
fn default() -> Self {
Self::Gpu(KnnParamsGpu::new())
}
}
impl ScrubletParamsGpu {
pub fn new() -> Self {
Self {
log_transform: true,
mean_center: true,
normalise_variance: true,
target_size: None,
min_gene_var_pctl: 0.85,
hvg_method: "vst".to_string(),
loess_span: 0.3,
clip_max: None,
binning_strategy: "equal_width".to_string(),
n_bins: 20,
no_pcs: 30,
sim_doublet_ratio: 2.0,
expected_doublet_rate: 0.1,
stdev_doublet_rate: 0.02,
n_bins_hist: 100,
manual_threshold: None,
knn_params: ScrubletKnnBackend::default(),
}
}
}
impl Default for ScrubletParamsGpu {
fn default() -> Self {
Self::new()
}
}
fn resolve_oversampling(n_cells: usize, n_genes: usize, no_pcs: usize) -> usize {
let max_s = std::cmp::min(n_cells, n_genes).saturating_sub(1);
std::cmp::min(MAX_OVERSAMPLING_SINGLE_CELL, max_s.saturating_sub(no_pcs))
}
#[allow(clippy::too_many_arguments)]
pub fn pca_observed_gpu<R, S>(
reader: &S,
cell_indices: &[usize],
gene_indices: &[usize],
hvg_library_sizes: &[usize],
target_size: f32,
log_transform: bool,
mean_center: bool,
normalise_variance: bool,
no_pcs: usize,
seed: usize,
device: R::Device,
verbose: usize,
) -> Result<DoubletPcaRes, BixverseErrors>
where
R: Runtime,
S: SingleCellReading,
{
let verbosity = parse_verbosity_level(verbose);
let start_total = Instant::now();
let cell_set: IndexSet<u32> = cell_indices.iter().map(|&x| x as u32).collect();
let n_cells = cell_indices.len();
let n_genes = gene_indices.len();
let start_reading = Instant::now();
let mut gene_chunks: Vec<CscGeneChunk> =
reader.read_gene_parallel_filtered(gene_indices, &cell_set)?;
if verbosity.normal_verbosity() {
println!("Loaded in data: {:.2?}", start_reading.elapsed());
}
let start_prep = Instant::now();
gene_chunks.par_iter_mut().for_each(|chunk| {
for (i, &pos) in chunk.indices.iter().enumerate() {
let raw_count = chunk.data_raw.get(i) as f32;
let lib_size = hvg_library_sizes[pos as usize] as f32;
let val = if log_transform {
((raw_count / lib_size) * target_size).ln_1p()
} else {
(raw_count / lib_size) * target_size
};
chunk.data_norm[i] = F16::from(half::f16::from_f32(val.clamp(-65504.0, 65504.0)));
}
});
let csc = from_gene_chunks::<f32>(gene_chunks, &DataLayerReturn::Norm, n_cells)?;
let real_col_means: Vec<f64> = sparse_csc_column_means(&csc, true, None)?;
let col_stds: Vec<f32> = if normalise_variance {
let stds = sparse_csc_column_stds(&csc, &real_col_means, true, None)?;
stds.iter().map(|&x| (x as f32).max(MIN_GENE_STD)).collect()
} else {
vec![1.0; n_genes]
};
let col_means: Vec<f32> = if mean_center {
real_col_means.iter().map(|&x| x as f32).collect()
} else {
vec![0.0; n_genes]
};
if verbosity.normal_verbosity() {
println!("Finished data preparation: {:.2?}", start_prep.elapsed());
}
let start_svd = Instant::now();
let oversampling = resolve_oversampling(n_cells, n_genes, no_pcs);
let svd_params = RandSvdGpuParams::new(DEFAULT_N_POWER_ITERS_RAND_SVD, oversampling);
let svd_res = randomised_sparse_svd_gpu::<R, f32, f32>(
csc,
&col_means,
&col_stds,
None,
no_pcs,
Some(svd_params),
seed as u64,
device,
verbose,
)?;
let scores = compute_pc_scores(&svd_res);
let loadings = svd_res.v.clone();
if verbosity.normal_verbosity() {
println!("Finished PCA calculations: {:.2?}", start_svd.elapsed());
println!(
"Total run time PCA detection: {:.2?}",
start_total.elapsed()
);
}
Ok((scores, loadings, col_means, col_stds))
}
fn project_sim_gpu<R: Runtime>(
sim_chunks: &[CsrCellChunk],
loadings: &Mat<f32>,
gene_means: &[f32],
gene_stds: &[f32],
mean_center: bool,
client: &ComputeClient<R>,
) -> Result<Mat<f32>, BixverseErrors> {
let n_sim = sim_chunks.len();
let n_genes = gene_means.len();
let no_pcs = loadings.ncols();
let csr = from_cell_chunks::<f32>(sim_chunks, &DataLayerReturn::Norm, n_genes)?;
let csr_gpu =
GpuCompressedSparseData::<R, f32>::from_compressed_sparse_data_2(&csr, true, client)?;
drop(csr);
let omega: Vec<f32> = (0..n_genes)
.flat_map(|g| {
let inv_sd = 1.0 / gene_stds[g];
(0..no_pcs).map(move |j| loadings[(g, j)] * inv_sd)
})
.collect();
let omega_gpu = GpuTensor::<R, f32>::from_slice(&omega, vec![n_genes, no_pcs], client)?;
let c_buf = if mean_center {
let c = GpuTensor::<R, f32>::empty(vec![no_pcs], client)?;
let mu_gpu = GpuTensor::<R, f32>::from_slice(gene_means, vec![n_genes], client)?;
launch_dense_column_weighted_sum::<R, f32>(
&mu_gpu, &omega_gpu, &c, n_genes, no_pcs, client,
)?;
c
} else {
GpuTensor::<R, f32>::from_slice(&vec![0.0f32; no_pcs], vec![no_pcs], client)?
};
project_with_correction::<R>(&csr_gpu, &omega_gpu, &c_buf, n_sim, no_pcs, client)
}
fn project_with_correction<R: Runtime>(
csr_gpu: &GpuCompressedSparseData<R, f32>,
omega_gpu: &GpuTensor<R, f32>,
c_buf: &GpuTensor<R, f32>,
n_sim: usize,
no_pcs: usize,
client: &ComputeClient<R>,
) -> Result<Mat<f32>, BixverseErrors> {
let zero_n = vec![0.0f32; n_sim];
let zero_s = vec![0.0f32; no_pcs];
let row_offsets = GpuTensor::<R, f32>::from_slice(&zero_n, vec![n_sim], client)?;
let x_sum = GpuTensor::<R, f32>::from_slice(&zero_s, vec![no_pcs], client)?;
let y_buf = GpuTensor::<R, f32>::empty(vec![n_sim, no_pcs], client)?;
launch_spmm_csr_forward::<R, f32, f32>(
csr_gpu,
omega_gpu,
c_buf,
&row_offsets,
&x_sum,
&y_buf,
no_pcs,
client,
)?;
let host = y_buf.read(client)?;
Ok(Mat::<f32>::from_fn(n_sim, no_pcs, |i, j| {
host[i * no_pcs + j]
}))
}
#[allow(clippy::too_many_arguments)]
pub fn run_scrublet_gpu<R, S>(
gene_reader: &S,
cell_reader: &S,
params: &ScrubletParamsGpu,
cell_indices: &[usize],
streaming: bool,
seed: usize,
device: R::Device,
verbose: usize,
return_combined_pca: bool,
return_pairs: bool,
) -> Result<FinalScrubletRes, BixverseErrors>
where
R: Runtime,
S: SingleCellReading,
{
let verbosity = parse_verbosity_level(verbose);
let n_cells = cell_indices.len();
let hvg_opts = HvgOpts {
method: params.hvg_method.clone(),
loess_span: params.loess_span as f32,
clip_max: params.clip_max,
min_gene_var_pctl: params.min_gene_var_pctl,
binning_strategy: params.binning_strategy.clone(),
n_bins: params.n_bins,
};
if verbosity.normal_verbosity() {
println!("Identifying highly variable genes...");
}
let start_all = Instant::now();
let start_hvg = Instant::now();
let hvg_genes = select_hvg(gene_reader, cell_indices, &hvg_opts, streaming, verbose)?;
if verbosity.normal_verbosity() {
println!(
"Using {} highly variable genes. Done in {:.2?}",
hvg_genes.len(),
start_hvg.elapsed()
);
}
let hvg_library_sizes = compute_hvg_library_sizes(cell_reader, cell_indices, &hvg_genes)?;
let target_size = resolve_target_size(params.target_size, &hvg_library_sizes);
if verbosity.normal_verbosity() {
println!("Simulating doublets...");
}
let start_sim = Instant::now();
let n_cells_sim = (n_cells as f32 * params.sim_doublet_ratio) as usize;
let pairs = random_pairs(cell_indices.len(), n_cells_sim, seed);
let (pair_1, pair_2): (Vec<usize>, Vec<usize>) = pairs.iter().copied().unzip();
let sim_chunks = simulate_from_pairs(
&pairs,
cell_indices,
&hvg_library_sizes,
&hvg_genes,
cell_reader,
target_size,
params.log_transform,
)?;
if verbosity.normal_verbosity() {
println!(
"Simulated {} doublets. Done in {:.2?}",
sim_chunks.len(),
start_sim.elapsed()
);
}
if verbosity.normal_verbosity() {
println!("Running PCA (GPU-accelerated)...");
}
let pca_res = pca_observed_gpu::<R, S>(
gene_reader,
cell_indices,
&hvg_genes,
&hvg_library_sizes,
target_size,
params.log_transform,
params.mean_center,
params.normalise_variance,
params.no_pcs,
seed,
device.clone(),
verbose,
)?;
if verbosity.normal_verbosity() {
println!("Projecting simulated doublets (GPU-accelerated)...");
}
let start_proj = Instant::now();
let client = R::client(&device);
let sim_pca = project_sim_gpu::<R>(
&sim_chunks,
&pca_res.1,
&pca_res.2,
&pca_res.3,
params.mean_center,
&client,
)?;
drop(client);
let combined_pca = concat![[&pca_res.0], [sim_pca]];
if verbosity.normal_verbosity() {
println!("Done with projection in {:.2?}", start_proj.elapsed());
}
if verbosity.normal_verbosity() {
println!("Building kNN graph (GPU-accelerated)...");
}
let start_knn = Instant::now();
let k_adj = adjusted_k(params.knn_params.k(), n_cells, n_cells_sim);
if verbosity.normal_verbosity() {
println!("Using {} neighbours in the kNN generation.", k_adj);
}
let knn_indices = match ¶ms.knn_params {
ScrubletKnnBackend::Cpu(knn_params) => dispatch_knn(
combined_pca.as_ref(),
k_adj,
knn_params,
seed,
verbosity.detailed_verbosity(),
)?,
ScrubletKnnBackend::Gpu(knn_params) => dispatch_knn_gpu::<R>(
combined_pca.as_ref(),
k_adj,
knn_params,
device,
seed,
verbose,
)?,
};
if verbosity.normal_verbosity() {
println!("Done with kNN generation in {:.2?}", start_knn.elapsed());
}
if verbosity.normal_verbosity() {
println!("Calculating doublet scores...");
}
let start_score = Instant::now();
let doublet_scores = calculate_doublet_scores(
&knn_indices,
n_cells,
n_cells_sim,
k_adj,
params.expected_doublet_rate,
params.stdev_doublet_rate,
);
let res = call_doublets(
doublet_scores,
params.manual_threshold,
params.n_bins_hist,
params.expected_doublet_rate,
verbose,
);
if verbosity.normal_verbosity() {
println!(
"Done with doublet scoring and calling {:.2?}",
start_score.elapsed()
);
println!("Finished Scrublet (GPU) {:.2?}", start_all.elapsed());
}
let pca_out = if return_combined_pca {
Some(combined_pca)
} else {
None
};
let pair_1_out = if return_pairs { Some(pair_1) } else { None };
let pair_2_out = if return_pairs { Some(pair_2) } else { None };
Ok((res, pca_out, pair_1_out, pair_2_out))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gpu::sc_gpu::knn_gpu::{KnnSearchGpu, parse_knn_method_gpu};
use crate::single_cell::sc_data::data_io::{CellGeneSparseWriter, ParallelSparseReader};
use crate::single_cell::sc_processing::knn::KnnParams;
use crate::single_cell::sc_processing::scrublet::{Scrublet, ScrubletParams};
use cubecl::wgpu::{WgpuDevice, WgpuRuntime};
const TEST_CELLS: usize = 600;
const TEST_GENES: usize = 150;
const TEST_PCS: usize = 5;
fn try_device() -> Option<WgpuDevice> {
let device = WgpuDevice::DefaultDevice;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
WgpuRuntime::client(&device);
}))
.ok()
.map(|_| device)
}
fn hash01(a: usize, b: usize) -> f32 {
let mut h = (a as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add((b as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F));
h ^= h >> 29;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
h ^= h >> 32;
((h >> 40) as f32) / 16_777_216.0
}
fn synthetic_counts() -> Vec<Vec<u32>> {
(0..TEST_CELLS)
.map(|cell| {
let cell_type = cell % 3;
(0..TEST_GENES)
.map(|gene| {
let base = if gene % 3 == cell_type { 30.0 } else { 3.0 };
let v = base * (0.5 + hash01(cell, gene));
if hash01(gene, cell + 7) < 0.3 {
0
} else {
v as u32
}
})
.collect()
})
.collect()
}
fn zero_gene_counts() -> Vec<Vec<u32>> {
let mut counts = synthetic_counts();
for row in counts.iter_mut() {
row[0] = 0;
}
counts
}
fn write_stores(counts: &[Vec<u32>], gene_path: &str, cell_path: &str) {
let mut gene_writer =
CellGeneSparseWriter::new(gene_path, false, TEST_CELLS, TEST_GENES, 1e4)
.expect("gene writer");
for gene in 0..TEST_GENES {
let mut data_raw: Vec<u32> = Vec::new();
let mut indices: Vec<usize> = Vec::new();
for (cell, row) in counts.iter().enumerate() {
if row[gene] > 0 {
data_raw.push(row[gene]);
indices.push(cell);
}
}
let sum: f32 = data_raw.iter().map(|&x| x as f32).sum();
let data_norm: Vec<F16> = data_raw
.iter()
.map(|&x| F16::from(half::f16::from_f32((x as f32 / sum * 1e4).ln_1p())))
.collect();
let chunk = CscGeneChunk::from_conversion(
RawCounts::from_u32_auto(&data_raw),
&data_norm,
&indices,
gene,
true,
);
gene_writer.write_gene_chunk(chunk).expect("write gene");
}
gene_writer.finalise().expect("finalise gene store");
let mut cell_writer =
CellGeneSparseWriter::new(cell_path, true, TEST_CELLS, TEST_GENES, 1e4)
.expect("cell writer");
for (cell, row) in counts.iter().enumerate() {
let mut data_raw: Vec<u32> = Vec::new();
let mut indices: Vec<usize> = Vec::new();
for (gene, &v) in row.iter().enumerate() {
if v > 0 {
data_raw.push(v);
indices.push(gene);
}
}
let chunk = CsrCellChunk::from_data(&data_raw, &indices, cell, 1e4, true);
cell_writer.write_cell_chunk(chunk).expect("write cell");
}
cell_writer.finalise().expect("finalise cell store");
}
fn pearson(a: &[f32], b: &[f32]) -> f32 {
let n = a.len() as f32;
let ma = a.iter().sum::<f32>() / n;
let mb = b.iter().sum::<f32>() / n;
let mut num = 0.0;
let mut da = 0.0;
let mut db = 0.0;
for (x, y) in a.iter().zip(b.iter()) {
let dx = x - ma;
let dy = y - mb;
num += dx * dy;
da += dx * dx;
db += dy * dy;
}
if da <= 0.0 || db <= 0.0 {
return 0.0;
}
num / (da.sqrt() * db.sqrt())
}
fn cpu_params() -> ScrubletParams {
ScrubletParams {
log_transform: true,
mean_center: true,
normalise_variance: true,
target_size: None,
min_gene_var_pctl: 0.7,
hvg_method: "vst".to_string(),
loess_span: 0.5,
clip_max: None,
binning_strategy: "equal_width".to_string(),
n_bins: 10,
no_pcs: TEST_PCS,
random_svd: true,
sim_doublet_ratio: 2.0,
expected_doublet_rate: 0.1,
stdev_doublet_rate: 0.02,
n_bins_hist: 50,
manual_threshold: None,
knn_params: KnnParams {
knn_method: "exhaustive".to_string(),
..KnnParams::new()
},
}
}
fn gpu_params(knn: ScrubletKnnBackend) -> ScrubletParamsGpu {
ScrubletParamsGpu {
min_gene_var_pctl: 0.7,
loess_span: 0.5,
n_bins: 10,
no_pcs: TEST_PCS,
n_bins_hist: 50,
knn_params: knn,
..ScrubletParamsGpu::new()
}
}
fn gpu_knn_exhaustive() -> ScrubletKnnBackend {
ScrubletKnnBackend::Gpu(KnnParamsGpu {
knn_method: "exhaustive".to_string(),
..KnnParamsGpu::new()
})
}
fn cpu_knn_exhaustive() -> ScrubletKnnBackend {
ScrubletKnnBackend::Cpu(KnnParams {
knn_method: "exhaustive".to_string(),
..KnnParams::new()
})
}
#[test]
fn test_resolve_oversampling_clamps_on_small_inputs() {
assert_eq!(resolve_oversampling(10_000, 3_000, 30), 100);
assert_eq!(resolve_oversampling(10_000, 20, 30), 0);
assert_eq!(resolve_oversampling(10_000, 41, 30), 10);
}
#[test]
fn test_default_knn_backend_is_exact_gpu() {
match ScrubletParamsGpu::new().knn_params {
ScrubletKnnBackend::Gpu(p) => {
assert_eq!(
parse_knn_method_gpu(&p.knn_method),
Some(KnnSearchGpu::ExhaustiveGpu)
);
}
other => panic!("default kNN backend should be exact GPU, got {:?}", other),
}
}
#[test]
fn test_run_scrublet_gpu_matches_cpu() {
let Some(device) = try_device() else { return };
let gene_path = std::env::temp_dir().join("bixverse_scrublet_gpu_genes.bin");
let cell_path = std::env::temp_dir().join("bixverse_scrublet_gpu_cells.bin");
let gene_str = gene_path.to_str().expect("utf8 path");
let cell_str = cell_path.to_str().expect("utf8 path");
write_stores(&synthetic_counts(), gene_str, cell_str);
let gene_reader = ParallelSparseReader::new(gene_str).expect("gene reader");
let cell_reader = ParallelSparseReader::new(cell_str).expect("cell reader");
let cell_indices: Vec<usize> = (0..TEST_CELLS).collect();
let mut cpu = Scrublet::new(&gene_reader, &cell_reader, cpu_params(), &cell_indices);
let (cpu_res, _, _, _) = cpu
.run_scrublet(false, 42, 0, false, false)
.expect("cpu run");
for (label, backend) in [
("cpu-knn", cpu_knn_exhaustive()),
("gpu-knn", gpu_knn_exhaustive()),
] {
let (gpu_res, gpu_pca, _, _) = run_scrublet_gpu::<WgpuRuntime, _>(
&gene_reader,
&cell_reader,
&gpu_params(backend),
&cell_indices,
false,
42,
device.clone(),
0,
true,
false,
)
.expect("gpu run");
let pca = gpu_pca.expect("combined pca requested");
assert_eq!(pca.nrows(), TEST_CELLS * 3);
assert_eq!(pca.ncols(), TEST_PCS);
let block_energy = |rows: std::ops::Range<usize>| -> f64 {
rows.map(|i| {
(0..pca.ncols())
.map(|j| (pca[(i, j)] as f64).abs())
.sum::<f64>()
})
.sum()
};
let obs_energy = block_energy(0..TEST_CELLS);
let sim_energy = block_energy(TEST_CELLS..pca.nrows());
assert!(
obs_energy.is_finite() && obs_energy > 1.0,
"{}: observed block is degenerate (energy {}), the GPU SVD did no work",
label,
obs_energy
);
assert!(
sim_energy.is_finite() && sim_energy > 1.0,
"{}: simulated block is degenerate (energy {}), the SpMM projection did no work",
label,
sim_energy
);
assert_eq!(gpu_res.doublet_scores_obs.len(), TEST_CELLS);
assert!(
gpu_res.doublet_scores_obs.iter().all(|x| x.is_finite()),
"{}: GPU doublet scores contain non-finite values",
label
);
let r = pearson(&cpu_res.doublet_scores_obs, &gpu_res.doublet_scores_obs);
println!(
"Scrublet CPU vs GPU ({}): obs score Pearson {:.4}, thresholds {:.4} / {:.4}",
label, r, cpu_res.threshold, gpu_res.threshold
);
assert!(
r > 0.97,
"{}: GPU doublet scores only correlate {:.4} with the CPU reference",
label,
r
);
}
let _ = std::fs::remove_file(&gene_path);
let _ = std::fs::remove_file(&cell_path);
}
#[test]
fn test_pca_observed_gpu_survives_zero_variance_gene() {
let Some(device) = try_device() else { return };
let gene_path = std::env::temp_dir().join("bixverse_scrublet_gpu_constant.bin");
let cell_path = std::env::temp_dir().join("bixverse_scrublet_gpu_constant_cells.bin");
let gene_str = gene_path.to_str().expect("utf8 path");
let cell_str = cell_path.to_str().expect("utf8 path");
write_stores(&zero_gene_counts(), gene_str, cell_str);
let gene_reader = ParallelSparseReader::new(gene_str).expect("gene reader");
let cell_reader = ParallelSparseReader::new(cell_str).expect("cell reader");
let cell_indices: Vec<usize> = (0..TEST_CELLS).collect();
let gene_indices: Vec<usize> = (0..TEST_GENES).collect();
let lib_sizes =
compute_hvg_library_sizes(&cell_reader, &cell_indices, &gene_indices).expect("libs");
let target_size = resolve_target_size(None, &lib_sizes);
let (scores, _, _, stds) = pca_observed_gpu::<WgpuRuntime, _>(
&gene_reader,
&cell_indices,
&gene_indices,
&lib_sizes,
target_size,
true,
true,
true,
5,
42,
device,
0,
)
.expect("gpu pca on a zero-variance column");
assert_eq!(
stds[0], MIN_GENE_STD,
"gene 0 should have been floored, got a standard deviation of {}",
stds[0]
);
assert!(
stds[1..].iter().all(|&s| s > MIN_GENE_STD),
"the expressed genes should not have been floored"
);
assert!(
(0..scores.nrows()).all(|i| (0..scores.ncols()).all(|j| scores[(i, j)].is_finite())),
"zero-variance gene produced non-finite PC scores"
);
let _ = std::fs::remove_file(&gene_path);
let _ = std::fs::remove_file(&cell_path);
}
}