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