Skip to main content

data_beans/alg/
hvg.rs

1//! Highly-variable-gene (HVG) selection.
2//!
3//! Two surfaces:
4//! - [`select_hvg`] / [`select_hvg_with_indices`] / [`select_hvg_by_stats`] —
5//!   dense-matrix scoring via NB dispersion trend (`σ²(μ) = μ + φ(μ)·μ²`),
6//!   ranking genes by excess dispersion above the trend.
7//! - [`select_hvg_streaming`] — sparse streaming wrapper that computes
8//!   per-gene `(mean, variance)` of raw counts directly from CSC chunks
9//!   (lock-free, per-thread accumulators merged at the end), then
10//!   delegates to [`select_hvg_by_stats`].
11//!
12//! Used by senna and chickpea to reweight the random-projection basis so
13//! the sketch geometry reflects variable biology rather than housekeeping
14//! signal.
15
16use 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
31/// Select top N highly variable genes by NB excess dispersion.
32///
33/// # Arguments
34/// * `mat` - Expression matrix (rows = samples, columns = genes)
35/// * `n_genes` - Number of HVGs to select
36///
37/// # Returns
38/// Matrix with only the selected HVG columns.
39pub fn select_hvg(mat: &DMatrix<f32>, n_genes: usize) -> DMatrix<f32> {
40    select_hvg_with_indices(mat, n_genes).0
41}
42
43/// Select HVGs and return both the subset matrix and the selected indices.
44pub 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
74/// Select top-N HVG indices from pre-computed per-gene means and variances.
75/// Returns indices sorted ascending.
76pub 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/// Shared CLI args for HVG gating of the random projection.
98#[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        // The one-line help has to say what selection actually DOES, because it
105        // differs by command and "keep top N genes" reads as a hard subset
106        // everywhere — which is wrong for senna, where every gene is still trained.
107        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/// A force-include list of feature names loaded from `--must-train-features`:
167/// features that must enter the fit whether or not they make the HVG cut.
168#[derive(Clone)]
169pub struct MustTrainFeatures {
170    names: Vec<Box<str>>,
171    source: Box<str>,
172}
173
174impl MustTrainFeatures {
175    /// Load the list from any of the supported formats. See the
176    /// `--must-train-features` `long_help` for the accepted layouts.
177    pub fn load(file_path: &str) -> anyhow::Result<Self> {
178        Self::load_union(std::slice::from_ref(&file_path))
179    }
180
181    /// Load the **union** of several name lists as one force-train set.
182    ///
183    /// The motivating pair is an explicit `--must-train-features` list plus the `--markers`
184    /// panel a downstream `annotate` will score on: those genes have to be on the trained
185    /// axis for their calls to mean anything, and requiring the user to name the same file
186    /// twice is exactly the kind of step that gets forgotten. Empty input is not an error —
187    /// it just yields an empty set.
188    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    /// Union of **already-loaded** lists, so a caller holding both an explicit force-train
204    /// list and a marker panel can merge them without re-reading either file.
205    #[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    /// Resolve the list against a vocabulary (feature names, or the interned gene
222    /// keys behind them) and return the matching indices, ascending and deduped.
223    ///
224    /// Matching is lenient — see [`GeneIndex`] — so a `CD8A` panel resolves against
225    /// an `ENSG00000153563_CD8A` vocabulary (and vice versa). A name that matches
226    /// nothing is reported rather than fatal: marker panels routinely carry genes
227    /// that a given assay never captured.
228    #[must_use]
229    pub fn resolve(&self, vocab: &[Box<str>]) -> Vec<usize> {
230        self.resolve_with(&GeneIndex::build(vocab))
231    }
232
233    /// [`Self::resolve`] against an **already-built** index. Building a [`GeneIndex`] lowercases
234    /// and hash-indexes the whole gene vocabulary — tens of thousands of allocations — so a
235    /// caller resolving two lists against the same vocabulary should build it once and call
236    /// this twice, rather than paying for the index on each list.
237    #[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    /// [`Self::resolve`] without the match/miss summary, for a second resolution
273    /// against the same vocabulary (the caller already reported it once, and
274    /// re-reporting reads as a second, different list).
275    #[must_use]
276    pub fn resolve_quiet(&self, vocab: &[Box<str>]) -> Vec<usize> {
277        self.resolve_quiet_with(&GeneIndex::build(vocab))
278    }
279
280    /// [`Self::resolve_quiet`] against an already-built index — see [`Self::resolve_with`].
281    #[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
294/// Load the `--must-train-features` list, but only when a feature selection is
295/// actually running.
296///
297/// With selection off every feature is trained already, so the list has nothing to
298/// promote — silently doing nothing would look like the flag worked, so say so.
299pub 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
306/// [`load_must_train`] over the **union** of several lists — an explicit force-train list
307/// plus, say, the marker panel the run will later be annotated with. Same no-op guard: with
308/// selection off, nothing can be promoted, so say so rather than appear to work.
309pub 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
327/// Merge `extra` into `selected` in place (both become ascending + deduped).
328/// Returns how many indices were genuinely added.
329pub 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/// HVG selection result used by SVD / topic / indexed-topic / joint-*
338/// pipelines to subset or weight the feature axis.
339#[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    /// Per-feature weight vector suitable for `project_columns_weighted`:
349    /// 1.0 at selected indices, 0.0 elsewhere.
350    #[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
362/// Stream cells through the sparse backend to compute per-gene mean and
363/// variance of raw expression, then select the top `n_features` HVGs via
364/// the shared NB-trend scoring routine.
365///
366/// If `feature_list_file` is supplied it takes precedence and the HVG
367/// computation is skipped entirely.
368///
369/// `must_train` is then UNIONed into whichever selection ran, so a curated panel
370/// survives even when it does not make the variance cut.
371pub 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
416/// Resolve an explicit `--feature-list-file` against the data's feature names.
417/// Same lenient matching as the force-include list, so the two flags agree on
418/// what a name means.
419fn 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}