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