use either::Either;
use faer::{Mat, MatRef};
use rand::distr::{Distribution, weighted::WeightedIndex};
use rand::prelude::*;
use rayon::prelude::*;
use rustc_hash::{FxHashMap, FxHashSet};
use std::time::Instant;
use crate::core::math::pca_svd::{compute_pc_scores, randomised_svd};
use crate::graph::community_detections::*;
use crate::graph::graph_structures::*;
use crate::prelude::*;
use crate::single_cell::sc_analysis::fast_clusters::*;
use crate::single_cell::sc_processing::knn::generate_knn_with_dist;
use crate::single_cell::sc_processing::utils_doublets::*;
use crate::single_cell::sc_utils::{cxds::*, logistic_gbm::*, utils_tree::*};
use crate::utils::vec_utils::min_max_scale;
#[derive(Clone, Debug)]
pub struct ScDblFinderParams {
pub log_transform: bool,
pub mean_center: bool,
pub normalise_variance: bool,
pub target_size: Option<f32>,
pub n_genes: usize,
pub doublet_ratio: f32,
pub heterotypic_bias: f32,
pub sim_params: ScDblSimParams,
pub no_pcs: usize,
pub random_svd: bool,
pub cluster_resolution: f32,
pub cluster_iters: usize,
pub fast_cluster: bool,
pub km_type: String,
pub fast_cluster_params: FastLouvainParams<f32>,
pub knn_params: KnnParams,
pub n_iterations: usize,
pub n_trees: usize,
pub max_depth: usize,
pub learning_rate: f32,
pub min_samples_leaf: usize,
pub subsample_rate: f32,
pub cv_folds: usize,
pub cv_early_stop: usize,
pub se_fraction: f32,
pub include_pcs: usize,
pub expected_doublet_rate: Option<f32>,
pub return_features: bool,
pub cxds_genes: Option<usize>,
pub manual_threshold: Option<f32>,
}
impl Default for ScDblFinderParams {
fn default() -> Self {
Self {
log_transform: true,
mean_center: true,
normalise_variance: true,
target_size: None,
n_genes: 1352,
no_pcs: 30,
random_svd: true,
doublet_ratio: 1.0,
heterotypic_bias: 0.8,
sim_params: ScDblSimParams::default(),
cluster_resolution: 1.0,
cluster_iters: 10,
fast_cluster: false,
km_type: "minibatch".to_string(),
fast_cluster_params: FastLouvainParams::default(),
knn_params: KnnParams::default(),
n_iterations: 3,
n_trees: 200,
max_depth: 4,
learning_rate: 0.3,
min_samples_leaf: 20,
subsample_rate: 0.75,
cv_folds: 5,
cv_early_stop: 2,
se_fraction: 0.25,
include_pcs: 19,
expected_doublet_rate: None,
return_features: false,
cxds_genes: None,
manual_threshold: None,
}
}
}
#[derive(Clone, Debug)]
pub struct ScDblFinderResult {
pub predicted_doublets: Vec<bool>,
pub doublet_scores: Vec<f32>,
pub weighted: Vec<f32>,
pub cxds: Vec<f32>,
pub threshold: f32,
pub cluster_labels: Vec<usize>,
pub detected_doublet_rate: f32,
pub selected_genes: Vec<usize>,
pub features: Option<FeatureTable>,
}
struct CellKnnFeatures {
k_ratios: Vec<f32>,
weighted: f32,
dist_real: f32,
origin: Option<(usize, usize)>,
}
#[derive(Clone, Debug)]
pub struct ScDblFinderDiagnostics {
pub weighted: Vec<f32>,
pub ratio: Vec<f32>,
pub cxds_score: Vec<f32>,
pub dist_to_real: Vec<f32>,
pub difficulty: Vec<f32>,
pub most_likely_origin: Vec<Option<(usize, usize)>>,
}
#[derive(Clone, Debug)]
pub struct FeatureTable {
pub feature_names: Vec<String>,
pub values: Mat<f32>,
pub include_in_training: Vec<bool>,
}
type ClusterAwarePairs = (Vec<(usize, usize)>, Vec<(usize, usize)>);
fn resolve_doublet_rate(explicit: Option<f32>, n_cells: usize) -> f32 {
match explicit {
Some(r) => r.clamp(0.001, 0.5),
None => (0.008 * n_cells as f32 / 1000.0).clamp(0.001, 0.5),
}
}
fn compute_class_weighted(
sim_scores: &[f32],
parent_clusters: &[(usize, usize)],
exclusion_mask: &[bool],
) -> (FxHashMap<(usize, usize), f32>, f32) {
let mut sums: FxHashMap<(usize, usize), (f64, u32)> = FxHashMap::default();
for (si, &(ca, cb)) in parent_clusters.iter().enumerate() {
if exclusion_mask[si] {
continue;
}
if let Some(canon) = canonical_pair(ca, cb) {
let e = sums.entry(canon).or_insert((0.0, 0));
e.0 += sim_scores[si] as f64;
e.1 += 1;
}
}
let class_weighted: FxHashMap<(usize, usize), f32> = sums
.into_iter()
.map(|(k, (s, n))| (k, (s / n as f64) as f32))
.collect();
let mean = if class_weighted.is_empty() {
0.5
} else {
class_weighted.values().copied().sum::<f32>() / class_weighted.len() as f32
};
(class_weighted, mean)
}
fn compute_difficulties(
n_obs: usize,
n_total: usize,
obs_origins: &[Option<(usize, usize)>],
parent_clusters: &[(usize, usize)],
class_weighted: &FxHashMap<(usize, usize), f32>,
fallback_mean: f32,
) -> Vec<f32> {
let fallback = 1.0 - fallback_mean;
(0..n_total)
.map(|i| {
let origin = if i < n_obs {
obs_origins[i]
} else {
let (ca, cb) = parent_clusters[i - n_obs];
canonical_pair(ca, cb)
};
match origin {
Some(o) => class_weighted.get(&o).map_or(fallback, |&cd| 1.0 - cd),
None => fallback,
}
})
.collect()
}
fn update_difficulty_column(features: &mut Mat<f32>, difficulty_col: usize, difficulties: &[f32]) {
for (i, &d) in difficulties.iter().enumerate() {
*features.get_mut(i, difficulty_col) = d;
}
}
fn feature_names(k_values: &[usize], n_pcs: usize) -> Vec<String> {
let mut names = Vec::with_capacity(k_values.len() + 7 + n_pcs);
for &k in k_values {
names.push(format!("ratio.k{}", k));
}
names.extend(
[
"weighted",
"distanceToNearestReal",
"difficulty",
"lsizes",
"nfeatures",
"nAbove2",
"cxds_score",
]
.iter()
.map(|s| s.to_string()),
);
for pc in 0..n_pcs {
names.push(format!("PC{}", pc + 1));
}
names
}
fn slice_obs_rows(features: MatRef<f32>, n_obs: usize) -> Mat<f32> {
let n_feat = features.ncols();
Mat::<f32>::from_fn(n_obs, n_feat, |i, j| *features.get(i, j))
}
fn compute_cell_complexity(
f_path_cell: &str,
cells_to_keep: &[usize],
selected_genes: &[usize],
) -> Result<(Vec<u32>, Vec<u32>), BixverseErrors> {
let selected_gene_set: FxHashSet<usize> = selected_genes.iter().copied().collect();
let reader = ParallelSparseReader::new(f_path_cell)?;
let results: Vec<(u32, u32)> = cells_to_keep
.par_iter()
.map(|&cell_idx| -> Result<(u32, u32), BixverseErrors> {
let chunk = reader.read_cell(cell_idx)?;
let mut n_feat = 0u32;
let mut n_above2 = 0u32;
for (i, &gene_idx) in chunk.indices.iter().enumerate() {
if selected_gene_set.contains(&(gene_idx as usize)) {
let count = chunk.data_raw.get(i);
if count > 0 {
n_feat += 1;
}
if count > 2 {
n_above2 += 1;
}
}
}
Ok((n_feat, n_above2))
})
.collect::<Result<Vec<_>, _>>()?;
let n_features: Vec<u32> = results.iter().map(|&(nf, _)| nf).collect();
let n_above2: Vec<u32> = results.iter().map(|&(_, na)| na).collect();
Ok((n_features, n_above2))
}
fn compute_sim_complexity(sim_chunks: &[CsrCellChunk]) -> (Vec<u32>, Vec<u32>) {
let results: Vec<(u32, u32)> = sim_chunks
.par_iter()
.map(|chunk| {
let mut n_feat = 0u32;
let mut n_above2 = 0u32;
for i in 0..chunk.indices.len() {
let count = chunk.data_raw.get(i);
if count > 0 {
n_feat += 1;
}
if count > 2 {
n_above2 += 1;
}
}
(n_feat, n_above2)
})
.collect();
let n_features: Vec<u32> = results.iter().map(|&(nf, _)| nf).collect();
let n_above2: Vec<u32> = results.iter().map(|&(_, na)| na).collect();
(n_features, n_above2)
}
#[inline]
fn canonical_origin(c_a: usize, c_b: usize) -> Option<(usize, usize)> {
if c_a == c_b {
None
} else {
Some((c_a.min(c_b), c_a.max(c_b)))
}
}
fn cell_most_likely_origin(
neighbours: &[usize],
distances: &[f32],
n_obs: usize,
parent_clusters: &[(usize, usize)],
) -> Option<(usize, usize)> {
let mut counts: FxHashMap<(usize, usize), u32> = FxHashMap::default();
let mut min_dists: FxHashMap<(usize, usize), f32> = FxHashMap::default();
for (&neigh, &dist) in neighbours.iter().zip(distances.iter()) {
if neigh < n_obs {
continue;
}
let (c_a, c_b) = parent_clusters[neigh - n_obs];
if let Some(origin) = canonical_origin(c_a, c_b) {
*counts.entry(origin).or_insert(0) += 1;
let entry = min_dists.entry(origin).or_insert(f32::INFINITY);
if dist < *entry {
*entry = dist;
}
}
}
if counts.is_empty() {
return None;
}
let max_count = *counts.values().max().unwrap();
let mut candidates: Vec<(usize, usize)> = counts
.iter()
.filter(|&(_, &c)| c == max_count)
.map(|(k, _)| *k)
.collect();
if candidates.len() == 1 {
return Some(candidates[0]);
}
candidates.sort_unstable_by(|a, b| {
let da = min_dists[a];
let db = min_dists[b];
da.partial_cmp(&db)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.cmp(b))
});
Some(candidates[0])
}
fn cell_distance_to_nearest_real(
neighbours: &[usize],
distances: &[f32],
n_obs: usize,
fallback_max_dist: f32,
) -> f32 {
for (&neigh, &dist) in neighbours.iter().zip(distances.iter()) {
if neigh < n_obs {
return dist;
}
}
2.0 * fallback_max_dist
}
#[allow(dead_code)]
fn aggregate_origin_counts(origins: &[Option<(usize, usize)>]) -> FxHashMap<(usize, usize), u32> {
let mut counts: FxHashMap<(usize, usize), u32> = FxHashMap::default();
for o in origins.iter().flatten() {
*counts.entry(*o).or_insert(0) += 1;
}
counts
}
#[allow(clippy::too_many_arguments)]
pub fn pca_combined(
f_path_cell: &str,
cells_to_keep: &[usize],
hvg_genes: &[usize],
hvg_library_sizes: &[usize],
target_size: f32,
sim_chunks: &[CsrCellChunk],
log_transform: bool,
mean_center: bool,
normalise_variance: bool,
no_pcs: usize,
seed: usize,
) -> Result<Mat<f32>, BixverseErrors> {
let n_obs = cells_to_keep.len();
let n_sim = sim_chunks.len();
let n_total = n_obs + n_sim;
let n_genes = hvg_genes.len();
let gene_to_hvg: FxHashMap<usize, usize> = hvg_genes
.iter()
.enumerate()
.map(|(pos, &g)| (g, pos))
.collect();
let mut combined = Mat::<f64>::zeros(n_total, n_genes);
let reader = ParallelSparseReader::new(f_path_cell)?;
for (row, &cell_idx) in cells_to_keep.iter().enumerate() {
let chunk = reader.read_cell(cell_idx)?;
let lib = hvg_library_sizes[row] as f64;
for (i, &gene_idx) in chunk.indices.iter().enumerate() {
let gi = gene_idx as usize;
if let Some(&hvg_pos) = gene_to_hvg.get(&gi) {
let raw = chunk.data_raw.get(i) as f64;
let val = if log_transform {
((raw / lib) * target_size as f64).ln_1p()
} else {
(raw / lib) * target_size as f64
};
*combined.get_mut(row, hvg_pos) = val;
}
}
}
for (si, chunk) in sim_chunks.iter().enumerate() {
let row = n_obs + si;
for i in 0..chunk.indices.len() {
let gene = chunk.indices[i] as usize;
let val = chunk.data_norm[i].to_f32() as f64;
*combined.get_mut(row, gene) = val;
}
}
let mut means = vec![0.0f64; n_genes];
let mut vars = vec![0.0f64; n_genes];
let nf = n_total as f64;
for j in 0..n_genes {
let mut sum = 0.0f64;
for i in 0..n_total {
sum += *combined.get(i, j);
}
means[j] = sum / nf;
}
for j in 0..n_genes {
let mu = means[j];
let mut ss = 0.0f64;
for i in 0..n_total {
let d = *combined.get(i, j) - mu;
ss += d * d;
}
vars[j] = ss / (nf - 1.0);
}
for j in 0..n_genes {
let mu = if mean_center { means[j] } else { 0.0 };
let sd = if normalise_variance {
let s = vars[j].sqrt();
if s > 1e-10 { s } else { 1.0 }
} else {
1.0
};
for i in 0..n_total {
let v = combined.get_mut(i, j);
*v = (*v - mu) / sd;
}
}
let svd_res = randomised_svd(combined.as_ref(), no_pcs, seed, None, None)?;
let scores = compute_pc_scores(&svd_res);
Ok(Mat::from_fn(scores.nrows(), scores.ncols(), |i, j| {
scores[(i, j)] as f32
}))
}
fn default_knn_ks(n_obs: usize) -> Vec<usize> {
let kmax = ((n_obs as f32 / 2.0).sqrt().ceil() as usize).max(25);
let candidates = [3, 10, 15, 20, 25, 50, kmax];
let mut ks: Vec<usize> = candidates
.iter()
.copied()
.filter(|&k| k <= kmax)
.map(|k| k.min(MAX_DOUBLET_K_NEIGHBOURS))
.collect();
ks.sort_unstable();
ks.dedup();
ks
}
#[allow(clippy::too_many_arguments)]
fn build_feature_matrix(
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
n_obs: usize,
parent_clusters: &[(usize, usize)],
k_values: &[usize],
obs_n_features: &[u32],
obs_n_above2: &[u32],
sim_n_features: &[u32],
sim_n_above2: &[u32],
library_sizes: &[usize],
sim_combined_lib_sizes: &[usize],
obs_cxds: &[f32],
sim_cxds: &[f32],
combined_pca: MatRef<f32>,
n_pcs: usize,
) -> (Mat<f32>, Vec<CellKnnFeatures>, usize) {
let n_total = knn_indices.len();
let n_k = k_values.len();
let n_feat = n_k + 7 + n_pcs;
let difficulty_col = n_k + 2;
let fallback_max_dist = knn_distances
.iter()
.map(|d| d.first().copied().unwrap_or(0.0))
.fold(0.0f32, f32::max);
let stage1: Vec<CellKnnFeatures> = (0..n_total)
.into_par_iter()
.map(|i| {
let neighbours = &knn_indices[i];
let distances = &knn_distances[i];
let k_max = neighbours.len();
let mut k_ratios = Vec::with_capacity(n_k);
for &k in k_values {
let k_use = k.min(k_max);
let n_sim_nb = neighbours[..k_use]
.iter()
.filter(|&&idx| idx >= n_obs)
.count();
k_ratios.push(n_sim_nb as f32 / k_use as f32);
}
let k_f = k_max as f32;
let mut w_sum = 0.0f32;
let mut w_dbl = 0.0f32;
for (rank, (&neigh, &dist)) in neighbours.iter().zip(distances.iter()).enumerate() {
let d = dist.max(1e-10);
let w = (k_f - rank as f32 - 1.0).max(0.0).sqrt() / d;
w_sum += w;
if neigh >= n_obs {
w_dbl += w;
}
}
let weighted = if w_sum > 0.0 { w_dbl / w_sum } else { 0.0 };
let dist_real =
cell_distance_to_nearest_real(neighbours, distances, n_obs, fallback_max_dist);
let origin = cell_most_likely_origin(neighbours, distances, n_obs, parent_clusters);
CellKnnFeatures {
k_ratios,
weighted,
dist_real,
origin,
}
})
.collect();
let weighted_arr: Vec<f32> = stage1.iter().map(|c| c.weighted).collect();
let obs_origins: Vec<Option<(usize, usize)>> =
stage1.iter().take(n_obs).map(|c| c.origin).collect();
let no_exclusions = vec![false; parent_clusters.len()];
let (class_weighted, mean_cw) =
compute_class_weighted(&weighted_arr[n_obs..], parent_clusters, &no_exclusions);
let difficulties = compute_difficulties(
n_obs,
n_total,
&obs_origins,
parent_clusters,
&class_weighted,
mean_cw,
);
let rows: Vec<Vec<f32>> = (0..n_total)
.into_par_iter()
.map(|i| {
let int = &stage1[i];
let mut feats = Vec::with_capacity(n_feat);
feats.extend_from_slice(&int.k_ratios);
feats.push(int.weighted);
feats.push(int.dist_real);
feats.push(difficulties[i]);
let lib = if i < n_obs {
library_sizes[i] as f32
} else {
sim_combined_lib_sizes[i - n_obs] as f32
};
feats.push(lib);
if i < n_obs {
feats.push(obs_n_features[i] as f32);
feats.push(obs_n_above2[i] as f32);
} else {
let si = i - n_obs;
feats.push(sim_n_features[si] as f32);
feats.push(sim_n_above2[si] as f32);
}
if i < n_obs {
feats.push(obs_cxds[i]);
} else {
feats.push(sim_cxds[i - n_obs]);
}
for pc in 0..n_pcs {
feats.push(*combined_pca.get(i, pc));
}
feats
})
.collect();
let mut features = Mat::<f32>::zeros(n_total, n_feat);
for (i, row) in rows.iter().enumerate() {
for (j, &val) in row.iter().enumerate() {
*features.get_mut(i, j) = val;
}
}
(features, stage1, difficulty_col)
}
fn cluster_aware_pairs(
cluster_labels: &[usize],
n_sim: usize,
heterotypic_bias: f32,
seed: usize,
) -> ClusterAwarePairs {
let mut rng = StdRng::seed_from_u64(seed as u64);
let n_cells = cluster_labels.len();
let mut cluster_to_cells: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
for (pos, &cl) in cluster_labels.iter().enumerate() {
cluster_to_cells.entry(cl).or_default().push(pos);
}
let cluster_ids: Vec<usize> = cluster_to_cells.keys().copied().collect();
let have_multiple_clusters = cluster_ids.len() >= 2;
let n_heterotypic = (n_sim as f32 * heterotypic_bias) as usize;
let n_random = n_sim - n_heterotypic;
let mut pairs = Vec::with_capacity(n_sim);
let mut parent_clusters = Vec::with_capacity(n_sim);
if have_multiple_clusters && n_heterotypic > 0 {
let mut cluster_pairs: Vec<(usize, usize)> = Vec::new();
let mut weights: Vec<f64> = Vec::new();
for i in 0..cluster_ids.len() {
for j in (i + 1)..cluster_ids.len() {
let ca = cluster_ids[i];
let cb = cluster_ids[j];
let sa = cluster_to_cells[&ca].len() as f64;
let sb = cluster_to_cells[&cb].len() as f64;
cluster_pairs.push((ca, cb));
weights.push(sa * sb);
}
}
let dist = WeightedIndex::new(&weights).expect("invalid cluster-pair weights");
for _ in 0..n_heterotypic {
let (ca, cb) = cluster_pairs[dist.sample(&mut rng)];
let cells_a = &cluster_to_cells[&ca];
let cells_b = &cluster_to_cells[&cb];
let i = cells_a[rng.random_range(0..cells_a.len())];
let j = cells_b[rng.random_range(0..cells_b.len())];
pairs.push((i, j));
parent_clusters.push((ca, cb));
}
} else {
for _ in 0..n_heterotypic {
let i = rng.random_range(0..n_cells);
let j = rng.random_range(0..n_cells);
pairs.push((i, j));
parent_clusters.push((cluster_labels[i], cluster_labels[j]));
}
}
for _ in 0..n_random {
let i = rng.random_range(0..n_cells);
let j = rng.random_range(0..n_cells);
pairs.push((i, j));
parent_clusters.push((cluster_labels[i], cluster_labels[j]));
}
(pairs, parent_clusters)
}
fn classify_doublets(
features: MatRef<f32>,
labels: &[bool],
exclude: &[bool],
config: &LogisticGbmConfig,
seed: u64,
verbose: bool,
) -> Vec<f32> {
let store = QuantisedStore::from_mat(features);
fit_logistic_gbm(&store, labels, exclude, config, seed, verbose)
}
fn find_threshold_optimised(
obs_scores: &[f32],
sim_scores: &[f32],
expected_dbr: f32,
stringency: f32,
verbose: usize,
) -> f32 {
let verbosity = parse_verbosity_level(verbose);
let n_obs = obs_scores.len();
let n_sim = sim_scores.len();
let dbr_sd = 0.3 * expected_dbr + 0.025;
let expected_lo = ((expected_dbr - dbr_sd).max(0.0) * n_obs as f32) + 1.0;
let expected_hi = ((expected_dbr + dbr_sd).min(1.0) * n_obs as f32) + 1.0;
let mut candidates: Vec<f32> = Vec::with_capacity(n_obs + n_sim);
candidates.extend_from_slice(obs_scores);
candidates.extend_from_slice(sim_scores);
candidates.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
candidates.dedup();
let step = (candidates.len() / 500).max(1);
let candidates: Vec<f32> = candidates.into_iter().step_by(step).collect();
let mut best_threshold = 0.5f32;
let mut best_cost = f32::INFINITY;
for &th in &candidates {
let n_obs_above = obs_scores.iter().filter(|&&s| s >= th).count() as f32;
let dev = if n_obs_above >= expected_lo && n_obs_above <= expected_hi {
0.0
} else {
let mid = (expected_lo + expected_hi) / 2.0;
((n_obs_above - mid).abs() / mid.max(1.0)).powi(2)
};
let n_sim_below = sim_scores.iter().filter(|&&s| s < th).count();
let fnr = n_sim_below as f32 / n_sim.max(1) as f32;
let fpr = n_obs_above / n_obs.max(1) as f32;
let cost = dev + 2.0 * (1.0 - stringency) * fnr + 2.0 * stringency * fpr;
if cost < best_cost {
best_cost = cost;
best_threshold = th;
}
}
if verbosity.normal_verbosity() {
println!("Threshold optimiser:");
println!(
" expected band: [{:.0}, {:.0}] doublets",
expected_lo - 1.0,
expected_hi - 1.0
);
println!(" selected threshold: {:.4}", best_threshold);
println!(" best cost: {:.4}", best_cost);
}
best_threshold
}
#[derive(Clone, Debug)]
pub struct ScDblFinder {
f_path_gene: String,
f_path_cell: String,
params: ScDblFinderParams,
n_cells: usize,
n_cells_sim: usize,
cells_to_keep: Vec<usize>,
red_library_sizes: Vec<usize>,
}
impl ScDblFinder {
pub fn new(
f_path_gene: &str,
f_path_cell: &str,
params: ScDblFinderParams,
cell_indices: &[usize],
) -> Self {
Self {
f_path_gene: f_path_gene.to_string(),
f_path_cell: f_path_cell.to_string(),
params,
n_cells: cell_indices.len(),
n_cells_sim: 0,
cells_to_keep: cell_indices.to_vec(),
red_library_sizes: Vec::new(),
}
}
pub fn run(
&mut self,
seed: usize,
streaming: bool,
verbose: usize,
) -> Result<ScDblFinderResult, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let start_all = Instant::now();
if verbosity.normal_verbosity() {
println!("Running scDblFinder in Rust");
println!(" Selecting top-expressed genes...");
}
let start_sel = Instant::now();
let selected_genes = if streaming {
select_top_genes_streaming(
&self.f_path_gene,
&self.cells_to_keep,
None,
self.params.n_genes,
verbosity.detailed_verbosity(),
)?
} else {
select_top_genes(
&self.f_path_gene,
&self.cells_to_keep,
None,
self.params.n_genes,
)?
};
if verbosity.normal_verbosity() {
println!(
" Using {} top-expressed genes. Done in {:.2?}",
selected_genes.len(),
start_sel.elapsed()
);
}
self.red_library_sizes =
compute_hvg_library_sizes(&self.f_path_cell, &self.cells_to_keep, &selected_genes)?;
let target_size = resolve_target_size(self.params.target_size, &self.red_library_sizes);
self.n_cells_sim = (self.n_cells as f32 * self.params.doublet_ratio) as usize;
if verbosity.normal_verbosity() {
println!(" Computing cell complexity features...");
}
let (obs_n_features, obs_n_above2) =
compute_cell_complexity(&self.f_path_cell, &self.cells_to_keep, &selected_genes)?;
let n_cxds = self.params.cxds_genes.unwrap_or(CXDS_NTOP);
if verbosity.normal_verbosity() {
println!(
" Building cxds co-expression model with {} genes...",
n_cxds
);
}
let start_cxds = Instant::now();
let (cxds_model, obs_cxds_gene_sets) = CxdsModel::fit(
&self.f_path_cell,
&self.cells_to_keep,
&selected_genes,
n_cxds,
)?;
let obs_cxds_scores = cxds_model.score(&obs_cxds_gene_sets);
if verbosity.normal_verbosity() {
let mean_cxds: f32 = obs_cxds_scores.iter().sum::<f32>() / obs_cxds_scores.len() as f32;
println!(
" cxds model built in {:.2?} (mean score: {:.2})",
start_cxds.elapsed(),
mean_cxds,
);
}
if verbosity.normal_verbosity() {
println!(" Running PCA on observed cells...");
}
let (obs_pca, _, _, _) = pca_observed(
&self.f_path_gene,
&self.cells_to_keep,
&selected_genes,
&self.red_library_sizes,
target_size,
self.params.log_transform,
self.params.mean_center,
self.params.normalise_variance,
self.params.no_pcs,
self.params.random_svd,
seed,
verbose,
)?;
if verbosity.normal_verbosity() {
println!(" Initial clustering of observed cells...");
}
let start_clustering = Instant::now();
let use_fast = self.params.fast_cluster && self.n_cells >= FAST_CLUSTER_MIN_CELLS;
let cluster_labels = if use_fast {
let n_centroids = self.params.fast_cluster_params.n_centroids;
let centroid_k = ((n_centroids as f32).sqrt() * 0.5).round() as usize;
let mut centroid_knn_params = self.params.knn_params.clone();
centroid_knn_params.k = centroid_k;
let fast_params = FastLouvainParams {
n_centroids,
knn_params: centroid_knn_params,
louvain_iters: self.params.cluster_iters,
batch_size: self.params.fast_cluster_params.batch_size,
kmeans_params: self.params.fast_cluster_params.kmeans_params.clone(),
..Default::default()
};
if verbosity.normal_verbosity() {
println!(
" Using fast clustering with {} centroids, k={} on {} cells.",
n_centroids, centroid_k, self.n_cells
);
}
let res = vec![self.params.cluster_resolution];
let res = fast_louvain_clusters(
obs_pca.as_ref(),
&self.params.km_type,
&res,
&fast_params,
false,
false,
seed,
verbose,
)?;
let get_assignments = |x: FastLouvainResults| -> Vec<Vec<usize>> {
match x.get_assignments() {
Either::Left(v) => v,
Either::Right(_) => panic!("expected Single variant"),
}
};
get_assignments(res)[0].clone()
} else {
let obs_k = if self.params.knn_params.k == 0 {
(((self.n_cells as f32).sqrt() * 0.5).round() as usize)
.max(MAX_DOUBLET_K_NEIGHBOURS)
} else {
self.params.knn_params.k
};
let obs_knn = dispatch_knn(
obs_pca.as_ref(),
obs_k,
&self.params.knn_params,
seed,
verbosity.detailed_verbosity(),
)?;
let obs_graph = knn_to_sparse_graph(&obs_knn, true);
louvain_sparse_graph(
&obs_graph,
self.params.cluster_resolution,
self.params.cluster_iters,
true,
seed,
)?
};
let end_clustering = start_clustering.elapsed();
if verbosity.normal_verbosity() {
println!(
" Finished initial kNN generation and clustering in {:.2?}",
end_clustering
);
}
let k_values = default_knn_ks(self.n_cells);
let k_max = *k_values.last().unwrap();
let n_k = k_values.len();
let n_pcs_include = self.params.include_pcs.min(self.params.no_pcs);
if verbosity.normal_verbosity() {
println!(
" Using k values {:?} (max {}), including {} PCs as features",
k_values, k_max, n_pcs_include
);
}
let expected_dbr = resolve_doublet_rate(self.params.expected_doublet_rate, self.n_cells);
if verbosity.normal_verbosity() {
println!(
" Simulating cluster-aware doublets with a heterotypic bias of {:.2}...",
self.params.heterotypic_bias
);
}
let start_sim = Instant::now();
let (pairs, parent_clusters) = cluster_aware_pairs(
&cluster_labels,
self.n_cells_sim,
self.params.heterotypic_bias,
seed,
);
let (sim_chunks, sim_combined_lib_sizes) = simulate_doublets_scdbl(
&pairs,
&self.cells_to_keep,
&self.red_library_sizes,
&cluster_labels,
&selected_genes,
&self.f_path_cell,
target_size,
self.params.log_transform,
&self.params.sim_params,
seed,
)?;
let (sim_n_features, sim_n_above2) = compute_sim_complexity(&sim_chunks);
let selected_genes_to_cxds = cxds_model.hvg_position_map(&selected_genes);
let sim_cxds_scores = cxds_model.score_simulated_from_chunks(
&sim_chunks,
&selected_genes_to_cxds,
1, );
if verbosity.normal_verbosity() {
println!(" Running PCA across all observed and synthetic cells...");
}
let combined_pca = pca_combined(
&self.f_path_cell,
&self.cells_to_keep,
&selected_genes,
&self.red_library_sizes,
target_size,
&sim_chunks,
self.params.log_transform,
self.params.mean_center,
self.params.normalise_variance,
self.params.no_pcs,
seed,
)?;
if verbosity.normal_verbosity() {
println!(" Building combined kNN graph (k_max={})...", k_max);
}
let mut knn_params = self.params.knn_params.clone();
knn_params.k = k_max;
let (combined_knn, combined_dists) = generate_knn_with_dist(
combined_pca.as_ref(),
&knn_params,
true,
false,
seed,
verbosity.detailed_verbosity(),
)?;
let mut combined_dists = combined_dists.unwrap();
if knn_params.ann_dist == "euclidean" {
combined_dists.par_iter_mut().for_each(|row| {
for d in row.iter_mut() {
*d = d.max(0.0).sqrt();
}
});
}
let end_sim = start_sim.elapsed();
let n_feat = n_k + 7 + n_pcs_include;
if verbosity.normal_verbosity() {
println!(
" Finished generation of simulated doublets, projection on PCA space and kNN generation in {:.2?}",
end_sim
);
println!(" Building feature matrix ({} features)...", n_feat);
}
let (mut features, knn_intermediates, difficulty_col) = build_feature_matrix(
&combined_knn,
&combined_dists,
self.n_cells,
&parent_clusters,
&k_values,
&obs_n_features,
&obs_n_above2,
&sim_n_features,
&sim_n_above2,
&self.red_library_sizes,
&sim_combined_lib_sizes,
&obs_cxds_scores,
&sim_cxds_scores,
combined_pca.as_ref(),
n_pcs_include,
);
let obs_intermediates = &knn_intermediates[..self.n_cells];
let n_total = self.n_cells + self.n_cells_sim;
let labels: Vec<bool> = (0..n_total).map(|i| i >= self.n_cells).collect();
let max_k_col = n_k - 1;
let max_ratio = (0..n_total)
.map(|i| *features.get(i, max_k_col))
.fold(0.0f32, f32::max)
.max(1e-10);
let max_cxds = obs_cxds_scores
.iter()
.chain(sim_cxds_scores.iter())
.copied()
.fold(0.0f32, f32::max)
.max(1e-10);
let mut final_scores: Vec<f32> = (0..self.n_cells)
.map(|i| {
let ratio_norm = *features.get(i, max_k_col) / max_ratio;
let cxds_norm = obs_cxds_scores[i] / max_cxds;
(ratio_norm + cxds_norm) / 2.0
})
.collect();
let mut sim_scores: Vec<f32> = (0..self.n_cells_sim)
.map(|si| {
let ratio_norm = *features.get(self.n_cells + si, max_k_col) / max_ratio;
let cxds_norm = sim_cxds_scores[si] / max_cxds;
(ratio_norm + cxds_norm) / 2.0
})
.collect();
let gbm_config = LogisticGbmConfig {
max_rounds: self.params.n_trees,
learning_rate: self.params.learning_rate,
max_depth: self.params.max_depth,
min_samples_leaf: self.params.min_samples_leaf,
subsample_rate: self.params.subsample_rate,
n_folds: self.params.cv_folds,
cv_early_stop: self.params.cv_early_stop,
se_fraction: self.params.se_fraction,
..Default::default()
};
let mut permanent_sim_exclusion = vec![false; self.n_cells_sim];
for iter in 0..self.params.n_iterations {
if verbosity.normal_verbosity() {
println!(
" == Iteration {} of {} ==",
iter + 1,
self.params.n_iterations
);
}
let start_iter = Instant::now();
let iter_seed = seed + iter * 1000;
let mut exclude_mask = vec![false; n_total];
for (si, &perm) in permanent_sim_exclusion.iter().enumerate() {
if perm {
exclude_mask[self.n_cells + si] = true;
}
}
let mut n_excluded_obs = 0usize;
if iter > 0 {
let mut obs_sorted = final_scores.clone();
obs_sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
let quantile_idx =
((1.0 - expected_dbr) * (self.n_cells - 1) as f32).round() as usize;
let exclusion_threshold = obs_sorted[quantile_idx.min(self.n_cells - 1)];
let max_exclude_obs = self.n_cells / 5;
for i in 0..self.n_cells {
if n_excluded_obs >= max_exclude_obs {
break;
}
if final_scores[i] > exclusion_threshold {
exclude_mask[i] = true;
n_excluded_obs += 1;
}
}
if verbosity.detailed_verbosity() {
let n_perm = permanent_sim_exclusion.iter().filter(|&&x| x).count();
println!(
"Excluding {} suspected real doublets (threshold={:.4}) \
and {} permanently-flagged sims",
n_excluded_obs, exclusion_threshold, n_perm
);
}
}
if verbosity.normal_verbosity() {
println!("Training logistic GBM classifier...");
}
let probabilities = classify_doublets(
features.as_ref(),
&labels,
&exclude_mask,
&gbm_config,
(iter_seed + 100) as u64,
verbosity.detailed_verbosity(),
);
let (class_weighted, mean_cw) =
compute_class_weighted(&sim_scores, &parent_clusters, &permanent_sim_exclusion);
let obs_origins: Vec<Option<(usize, usize)>> = knn_intermediates
.iter()
.take(self.n_cells)
.map(|c| c.origin)
.collect();
let difficulties = compute_difficulties(
self.n_cells,
n_total,
&obs_origins,
&parent_clusters,
&class_weighted,
mean_cw,
);
update_difficulty_column(&mut features, difficulty_col, &difficulties);
final_scores.copy_from_slice(&probabilities[..self.n_cells]);
sim_scores.copy_from_slice(&probabilities[self.n_cells..]);
if iter == 0 {
let flagged = identify_unrecognisable_origins(
&final_scores,
&cluster_labels,
&sim_scores,
&parent_clusters,
&UnrecognisableFilterParams::default(),
);
let sim_mask = mark_sims_from_flagged_origins(&parent_clusters, &flagged);
for (si, &flag) in sim_mask.iter().enumerate() {
if flag {
permanent_sim_exclusion[si] = true;
}
}
if verbosity.detailed_verbosity() {
let n_excl = permanent_sim_exclusion.iter().filter(|&&x| x).count();
println!(
"Unrecognisable filter: flagged {} origin(s), \
{} sims permanently excluded",
flagged.len(),
n_excl,
);
}
}
if verbosity.normal_verbosity() {
let mean = final_scores.iter().sum::<f32>() / final_scores.len() as f32;
let (mn, mx) = final_scores
.iter()
.fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), &v| {
(a.min(v), b.max(v))
});
println!(
"Iteration {} done in {:.2?} (obs scores: mean={:.4}, min={:.4}, max={:.4})",
iter + 1,
start_iter.elapsed(),
mean,
mn,
mx,
);
}
}
for s in final_scores.iter_mut() {
*s = s.clamp(0.0, 1.0);
}
let feature_table = if self.params.return_features {
let mut include_in_training = vec![true; self.n_cells];
let mut obs_sorted = final_scores.clone();
obs_sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
let quantile_idx = ((1.0 - expected_dbr) * (self.n_cells - 1) as f32).round() as usize;
let exclusion_threshold = obs_sorted[quantile_idx.min(self.n_cells - 1)];
let max_exclude_obs = self.n_cells / 5;
let mut n_excluded = 0usize;
for i in 0..self.n_cells {
if n_excluded >= max_exclude_obs {
break;
}
if final_scores[i] > exclusion_threshold {
include_in_training[i] = false;
n_excluded += 1;
}
}
Some(FeatureTable {
feature_names: feature_names(&k_values, n_pcs_include),
values: slice_obs_rows(features.as_ref(), self.n_cells),
include_in_training,
})
} else {
None
};
let threshold = self.params.manual_threshold.unwrap_or_else(|| {
let t =
find_threshold_optimised(&final_scores, &sim_scores, expected_dbr, 0.5, verbose);
if verbosity.normal_verbosity() {
println!("Threshold set at score = {:.4}", t);
}
t
});
let predicted_doublets: Vec<bool> = final_scores.iter().map(|&s| s > threshold).collect();
let n_doublets = predicted_doublets.iter().filter(|&&d| d).count();
let detected_doublet_rate = n_doublets as f32 / self.n_cells as f32;
if verbosity.normal_verbosity() {
println!(
"Detected {} doublets ({:.1}%)",
n_doublets,
100.0 * detected_doublet_rate
);
println!("Total runtime: {:.2?}", start_all.elapsed());
}
Ok(ScDblFinderResult {
predicted_doublets,
doublet_scores: final_scores,
cxds: min_max_scale(&obs_cxds_scores),
weighted: obs_intermediates
.iter()
.map(|c: &CellKnnFeatures| c.weighted)
.collect(),
selected_genes,
threshold,
cluster_labels,
detected_doublet_rate,
features: feature_table,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_canonical_origin_homotypic_is_none() {
assert_eq!(canonical_origin(3, 3), None);
}
#[test]
fn test_canonical_origin_sorts_tuple() {
assert_eq!(canonical_origin(5, 2), Some((2, 5)));
assert_eq!(canonical_origin(2, 5), Some((2, 5)));
}
#[test]
fn test_origin_no_sim_neighbours() {
let neighbours = vec![0, 1, 2, 3];
let distances = vec![0.1, 0.2, 0.3, 0.4];
let parent_clusters: Vec<(usize, usize)> = vec![];
let n_obs = 10;
assert_eq!(
cell_most_likely_origin(&neighbours, &distances, n_obs, &parent_clusters),
None
);
}
#[test]
fn test_origin_unique_majority() {
let neighbours = vec![5, 6, 7, 0];
let distances = vec![0.1, 0.2, 0.3, 0.4];
let parent_clusters = vec![(0, 1), (0, 1), (1, 2)];
let n_obs = 5;
assert_eq!(
cell_most_likely_origin(&neighbours, &distances, n_obs, &parent_clusters),
Some((0, 1))
);
}
#[test]
fn test_origin_tiebreak_by_min_distance() {
let neighbours = vec![5, 6, 7, 8];
let distances = vec![0.05, 0.50, 0.10, 0.60]; let parent_clusters = vec![(0, 1), (1, 2), (0, 1), (1, 2)];
let n_obs = 5;
assert_eq!(
cell_most_likely_origin(&neighbours, &distances, n_obs, &parent_clusters),
Some((0, 1))
);
}
#[test]
fn test_origin_skips_homotypic_neighbours() {
let neighbours = vec![5, 6];
let distances = vec![0.1, 0.9];
let parent_clusters = vec![(2, 2), (0, 1)];
let n_obs = 5;
assert_eq!(
cell_most_likely_origin(&neighbours, &distances, n_obs, &parent_clusters),
Some((0, 1))
);
}
#[test]
fn test_dist_to_real_finds_first_real() {
let neighbours = vec![10, 11, 2, 3]; let distances = vec![0.1, 0.2, 0.3, 0.4];
assert_eq!(
cell_distance_to_nearest_real(&neighbours, &distances, 5, 1.0),
0.3
);
}
#[test]
fn test_dist_to_real_fallback() {
let neighbours = vec![10, 11, 12];
let distances = vec![0.1, 0.2, 0.3];
assert_eq!(
cell_distance_to_nearest_real(&neighbours, &distances, 5, 0.75),
1.5 );
}
#[test]
fn test_aggregate_origin_counts() {
let origins = vec![Some((0, 1)), Some((0, 1)), None, Some((1, 2)), Some((0, 1))];
let counts = aggregate_origin_counts(&origins);
assert_eq!(counts[&(0, 1)], 3);
assert_eq!(counts[&(1, 2)], 1);
assert_eq!(counts.len(), 2);
}
}