1use crate::alg::nb_dispersion::DispersionTrend;
18use crate::alg::sparse_streaming::streaming_sparse_running_stats;
19use crate::sparse_io_vector::SparseIoVec;
20use crate::utilities::name_matching::GeneIndex;
21use clap::Args;
22use legume_numeric::matrix::common_io::read_name_list;
23use legume_numeric::matrix::traits::RunningStatOps;
24use log::info;
25use nalgebra::DMatrix;
26use rayon::prelude::*;
27use rustc_hash::FxHashMap;
28
29#[cfg(test)]
30mod tests;
31
32pub fn select_hvg(mat: &DMatrix<f32>, n_genes: usize) -> DMatrix<f32> {
41 select_hvg_with_indices(mat, n_genes).0
42}
43
44pub fn select_hvg_with_indices(mat: &DMatrix<f32>, n_genes: usize) -> (DMatrix<f32>, Vec<usize>) {
46 let (n_samples, n_genes_total) = (mat.nrows(), mat.ncols());
47
48 if n_genes >= n_genes_total {
49 let indices: Vec<usize> = (0..n_genes_total).collect();
50 return (mat.clone(), indices);
51 }
52
53 let (means, vars): (Vec<f32>, Vec<f32>) = (0..n_genes_total)
54 .into_par_iter()
55 .map(|j| {
56 let col = mat.column(j);
57 let mean: f32 = col.iter().sum::<f32>() / n_samples as f32;
58 let var: f32 = col.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / n_samples as f32;
59 (mean, var)
60 })
61 .unzip();
62
63 let hvg_indices = select_hvg_by_stats(&means, &vars, n_genes);
64
65 let mut hvg_mat = DMatrix::zeros(n_samples, hvg_indices.len());
66 for (new_j, &old_j) in hvg_indices.iter().enumerate() {
67 for i in 0..n_samples {
68 hvg_mat[(i, new_j)] = mat[(i, old_j)];
69 }
70 }
71
72 (hvg_mat, hvg_indices)
73}
74
75pub fn select_hvg_by_stats(means: &[f32], vars: &[f32], n_genes: usize) -> Vec<usize> {
78 assert_eq!(means.len(), vars.len());
79 let n_genes_total = means.len();
80 if n_genes >= n_genes_total {
81 return (0..n_genes_total).collect();
82 }
83
84 let trend = DispersionTrend::fit(means, vars);
85 let mut ranked: Vec<(usize, f32)> = means
86 .iter()
87 .zip(vars.iter())
88 .enumerate()
89 .map(|(j, (&mu, &v))| (j, trend.excess(mu, v)))
90 .collect();
91 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
92
93 let mut hvg_indices: Vec<usize> = ranked.iter().take(n_genes).map(|(idx, _)| *idx).collect();
94 hvg_indices.sort_unstable();
95 hvg_indices
96}
97
98#[derive(Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
100#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
101pub struct HvgCliArgs {
102 #[arg(
103 long = "n-hvg",
104 default_value_t = 5000,
105 help = "Top N variable genes; 0 = all",
109 long_help = "Select the top N genes by excess dispersion.\n\
110 A negative-binomial dispersion trend is fit against the mean,\n\
111 and genes are ranked by their dispersion above that trend.\n\
112 Collapsing and batch-effect estimation still see all genes.\n\
113 0 disables HVG selection.\n\
114 \n\
115 What the selection DOES depends on the command.\n\
116 In `senna` it only weights the random projection, the pb sketch;\n\
117 every gene is still trained.\n\
118 In `pinto` and `senna simba` it hard-subsets the trained axis."
119 )]
120 pub n_hvg: usize,
121
122 #[arg(
123 long,
124 help = "Pre-computed HVG list (replaces --n-hvg selection)",
125 long_help = "Use exactly these features instead of selecting HVGs.\n\
126 It takes precedence over --n-hvg. Accepted formats: .txt, .tsv,\n\
127 .csv and .parquet, optionally gzipped.\n\
128 See --must-train-features for the file format."
129 )]
130 pub feature_list_file: Option<Box<str>>,
131
132 #[arg(
133 long = "must-train-features",
134 value_name = "FILE",
135 help = "Keep these features in the HVG selection regardless of the cut",
136 long_help = "Force-include list: UNIONed into the --n-hvg selection (unlike\n\
137 --feature-list-file, which REPLACES it).\n\
138 \n\
139 WHAT THIS BUYS YOU DEPENDS ON THE COMMAND,\n\
140 because the HVG selection means different things:\n\
141 \n\
142 • `pinto`, `senna simba` — the selection HARD-SUBSETS the\n\
143 trained gene axis. A feature that misses the cut is not fit at all;\n\
144 it only gets a post-hoc PROJECTED embedding.\n\
145 Naming it here is what puts it in the model. This is the intended use.\n\
146 \n\
147 • `senna` (topic / svd / vae / bge / rbm) — HVG only WEIGHTS the\n\
148 random projection used for pseudobulk sketching.\n\
149 Every feature is trained either way.\n\
150 Naming it here raises its projection weight, nothing more.\n\
151 It will NOT change whether a gene is fit.\n\
152 So it is not a fix for weak marker embeddings here.\n\
153 \n\
154 Format is inferred from the extension. Accepted: .txt, .tsv,\n\
155 .csv and .parquet, optionally gzipped. There is one name per row.\n\
156 A gene-like header picks the column: `gene`, `feature`,\n\
157 `symbol` and so on; otherwise the first column is used.\n\
158 EVERY OTHER COLUMN IS IGNORED.\n\
159 So a curated `gene<TAB>celltype` marker table works as-is.\n\
160 \n\
161 Names are matched leniently (case-insensitive, symbol ↔ `ENSG…_SYMBOL` either way);\n\
162 unmatched names are logged, not fatal.\n\
163 A no-op when nothing would drop a feature anyway (--n-hvg 0)."
164 )]
165 pub must_train_features: Option<Box<str>>,
166}
167
168#[derive(Clone)]
171pub struct MustTrainFeatures {
172 names: Vec<Box<str>>,
173 source: Box<str>,
174}
175
176impl MustTrainFeatures {
177 pub fn load(file_path: &str) -> anyhow::Result<Self> {
180 Self::load_union(std::slice::from_ref(&file_path))
181 }
182
183 pub fn load_union(file_paths: &[&str]) -> anyhow::Result<Self> {
191 let mut names: Vec<Box<str>> = Vec::new();
192 for path in file_paths {
193 let read = read_name_list(path)?;
194 info!("force-train: {} name(s) read from {path}", read.len());
195 names.extend(read);
196 }
197 names.sort_unstable();
198 names.dedup();
199 Ok(Self {
200 names,
201 source: file_paths.join(" + ").into(),
202 })
203 }
204
205 #[must_use]
208 pub fn union(parts: &[&Self]) -> Self {
209 let mut names: Vec<Box<str>> = parts.iter().flat_map(|p| p.names.iter().cloned()).collect();
210 names.sort_unstable();
211 names.dedup();
212 let source = parts
213 .iter()
214 .map(|p| p.source.as_ref())
215 .collect::<Vec<_>>()
216 .join(" + ");
217 Self {
218 names,
219 source: source.into_boxed_str(),
220 }
221 }
222
223 #[must_use]
231 pub fn resolve(&self, vocab: &[Box<str>]) -> Vec<usize> {
232 self.resolve_with(&GeneIndex::build(vocab))
233 }
234
235 #[must_use]
240 pub fn resolve_with(&self, index: &GeneIndex) -> Vec<usize> {
241 let mut hits: Vec<usize> = Vec::with_capacity(self.names.len());
242 let mut misses: Vec<&str> = Vec::new();
243 for name in &self.names {
244 match index.match_gene(name) {
245 Some(i) => hits.push(i),
246 None => misses.push(name.as_ref()),
247 }
248 }
249 hits.sort_unstable();
250 hits.dedup();
251
252 info!(
253 "force-train: {} / {} name(s) from {} matched the data",
254 hits.len(),
255 self.names.len(),
256 self.source
257 );
258 if !misses.is_empty() {
259 let preview: Vec<&str> = misses.iter().take(10).copied().collect();
260 log::warn!(
261 "force-train: {} name(s) not found in the data and ignored: {:?}{}",
262 misses.len(),
263 preview,
264 if misses.len() > preview.len() {
265 " …"
266 } else {
267 ""
268 }
269 );
270 }
271 hits
272 }
273
274 #[must_use]
278 pub fn resolve_quiet(&self, vocab: &[Box<str>]) -> Vec<usize> {
279 self.resolve_quiet_with(&GeneIndex::build(vocab))
280 }
281
282 #[must_use]
284 pub fn resolve_quiet_with(&self, index: &GeneIndex) -> Vec<usize> {
285 let mut hits: Vec<usize> = self
286 .names
287 .iter()
288 .filter_map(|name| index.match_gene(name))
289 .collect();
290 hits.sort_unstable();
291 hits.dedup();
292 hits
293 }
294}
295
296pub fn load_must_train(
302 must_train_file: Option<&str>,
303 selection_on: bool,
304) -> anyhow::Result<Option<MustTrainFeatures>> {
305 load_must_train_union(must_train_file.as_slice(), selection_on)
306}
307
308pub fn load_must_train_union(
312 paths: &[&str],
313 selection_on: bool,
314) -> anyhow::Result<Option<MustTrainFeatures>> {
315 if paths.is_empty() {
316 return Ok(None);
317 }
318 if !selection_on {
319 log::warn!(
320 "force-train list ({}) is a no-op: feature selection is off \
321 (--n-hvg 0 / no feature list), so every feature is trained anyway.",
322 paths.join(" + ")
323 );
324 return Ok(None);
325 }
326 MustTrainFeatures::load_union(paths).map(Some)
327}
328
329pub fn union_indices(selected: &mut Vec<usize>, extra: &[usize]) -> usize {
332 let before = selected.len();
333 selected.extend_from_slice(extra);
334 selected.sort_unstable();
335 selected.dedup();
336 selected.len() - before
337}
338
339#[derive(Clone)]
342pub struct HvgSelection {
343 pub selected_indices: Vec<usize>,
344 pub selected_names: Vec<Box<str>>,
345 #[allow(dead_code)]
346 pub index_map: FxHashMap<usize, usize>,
347}
348
349impl HvgSelection {
350 #[must_use]
353 pub fn row_weights(&self, n_total: usize) -> Vec<f32> {
354 let mut w = vec![0.0_f32; n_total];
355 for &i in &self.selected_indices {
356 if i < n_total {
357 w[i] = 1.0;
358 }
359 }
360 w
361 }
362}
363
364pub fn select_hvg_streaming(
374 data_vec: &SparseIoVec,
375 max_features: Option<usize>,
376 feature_list_file: Option<&str>,
377 must_train: Option<&MustTrainFeatures>,
378 block_size: Option<usize>,
379) -> anyhow::Result<HvgSelection> {
380 let feature_names = data_vec.row_names()?;
381
382 let mut selected_indices = if let Some(path) = feature_list_file {
383 load_feature_list_from_file(path, &feature_names)?
384 } else {
385 let n_features = max_features
386 .ok_or_else(|| anyhow::anyhow!("max_features or feature_list_file must be provided"))?;
387 if n_features == 0 {
388 return Err(anyhow::anyhow!("max_features must be >= 1"));
389 }
390
391 let stat = streaming_sparse_running_stats(data_vec, block_size, "HVG")?;
392 let selected = select_hvg_by_stats(&stat.mean(), &stat.variance(), n_features);
393
394 info!(
395 "Selected {} / {} highly variable features (NB dispersion-trend excess)",
396 selected.len(),
397 feature_names.len()
398 );
399 selected
400 };
401
402 if let Some(must_train) = must_train {
403 let forced = must_train.resolve(&feature_names);
404 let added = union_indices(&mut selected_indices, &forced);
405 info!(
406 "--must-train-features: {added} feature(s) force-added on top of the selection \
407 ({} of the {} matched were already selected); {} features kept in total",
408 forced.len() - added,
409 forced.len(),
410 selected_indices.len()
411 );
412 }
413
414 selected_indices.sort_unstable();
415 Ok(build_selection(selected_indices, &feature_names))
416}
417
418fn load_feature_list_from_file(
422 file_path: &str,
423 all_feature_names: &[Box<str>],
424) -> anyhow::Result<Vec<usize>> {
425 let names_from_file = read_name_list(file_path)?;
426
427 let index = GeneIndex::build(all_feature_names);
428 let mut selected_indices: Vec<usize> = Vec::new();
429 let mut not_found = 0usize;
430 for name in &names_from_file {
431 match index.match_gene(name) {
432 Some(idx) => selected_indices.push(idx),
433 None => not_found += 1,
434 }
435 }
436 if selected_indices.is_empty() {
437 return Err(anyhow::anyhow!(
438 "No features from file matched data. File: {file_path}"
439 ));
440 }
441 if not_found > 0 {
442 log::warn!("{not_found} features from {file_path} not found in the data");
443 }
444 selected_indices.sort_unstable();
445 selected_indices.dedup();
446
447 info!(
448 "Loaded {} features from {}",
449 selected_indices.len(),
450 file_path
451 );
452
453 Ok(selected_indices)
454}
455
456fn build_selection(selected_indices: Vec<usize>, feature_names: &[Box<str>]) -> HvgSelection {
457 let selected_names: Vec<Box<str>> = selected_indices
458 .iter()
459 .map(|&i| feature_names[i].clone())
460 .collect();
461 let index_map: FxHashMap<usize, usize> = selected_indices
462 .iter()
463 .enumerate()
464 .map(|(new_i, &old_i)| (old_i, new_i))
465 .collect();
466 HvgSelection {
467 selected_indices,
468 selected_names,
469 index_map,
470 }
471}