Skip to main content

data_beans/alg/
feature_coarsening.rs

1//! Grouping features so a model answers for a group instead of each feature.
2//!
3//! **A coarsening is fixed; a module is learned.** That is the line senna
4//! draws between its two kinds of feature grouping, and the reason both words
5//! exist. A coarsening's membership is read off the data before training and
6//! never moves, which is what lets a decoder be keyed to it and what lets a
7//! continued fit inherit it verbatim. A module's membership is a parameter:
8//! the masked encoder's `--gene-modules` learns centroids and re-derives
9//! membership from them every step. Neither word should be used for the other,
10//! and a grouping that learns its membership is a module however it is built.
11//!
12//! What a coarsening fixes is the assignment, not the meaning: a group's
13//! embedding is the mean of its members' and moves at every step.
14//!
15//! A coarsening is built from the finest pseudobulks' counts by
16//! [`coarsen_features`]: features with no grouping evidence form one
17//! background group, and the rest are grouped by k-means on their residual
18//! profiles (see that function for the method).
19
20use clap::Args;
21use legume_numeric::matrix::dmatrix_util::build_columns_par;
22use legume_numeric::matrix::kmeans::kmeans_centroids_seeded;
23use legume_numeric::matrix::rand_util::mix_seed;
24use legume_numeric::matrix::traits::SampleOps;
25use log::{debug, info};
26use nalgebra::DMatrix;
27use rayon::prelude::*;
28use serde::{Deserialize, Serialize};
29
30type CscMat = nalgebra_sparse::CscMatrix<f32>;
31
32/// Maps D fine features to d coarse coarse features and back.
33#[derive(Clone, Serialize, Deserialize)]
34pub struct FeatureCoarsening {
35    /// For each original feature, the coarse group index it belongs to.
36    pub fine_to_coarse: Vec<usize>,
37    /// For each coarse group, the list of original feature indices.
38    pub coarse_to_fine: Vec<Vec<usize>>,
39    /// Number of coarse coarse features (d).
40    pub num_coarse: usize,
41}
42
43impl FeatureCoarsening {
44    /// Build the two-way map from the fine → coarse assignment alone.
45    ///
46    /// The one place the inverse is derived, and the one place a stray group
47    /// index is caught: every consumer indexes `coarse_to_fine` by the
48    /// assignment, so an out-of-range entry would otherwise panic at first use.
49    pub fn from_fine_to_coarse(
50        fine_to_coarse: Vec<usize>,
51        num_coarse: usize,
52    ) -> anyhow::Result<Self> {
53        let mut coarse_to_fine = vec![Vec::new(); num_coarse];
54        for (f, &c) in fine_to_coarse.iter().enumerate() {
55            anyhow::ensure!(
56                c < num_coarse,
57                "feature coarsening: feature {f} is assigned to group {c} of {num_coarse}"
58            );
59            coarse_to_fine[c].push(f);
60        }
61        Ok(Self {
62            fine_to_coarse,
63            coarse_to_fine,
64            num_coarse,
65        })
66    }
67
68    /// This coarsening carried onto a different fine axis, by name.
69    ///
70    /// `new_to_old[g]` is the position on this coarsening's axis of gene `g`
71    /// of the new axis, or `None` for a gene it never covered. A known gene
72    /// keeps its group. An unknown one joins the group whose known members it
73    /// most resembles, by cosine between `unit_profiles` — one unit vector per
74    /// gene of the NEW axis, in whatever reading of a profile the caller uses —
75    /// and each group's centroid of its known members' vectors. A gene with no
76    /// profile (a zero vector) carries nothing to place it by and goes to the
77    /// group with the most known members, which perturbs the fit least. A
78    /// group none of whose members survived cannot attract anything; it keeps
79    /// its index and stays empty of new genes, because whatever is keyed to
80    /// the groups (a decoder, say) still has a slot for it.
81    ///
82    /// The group count is unchanged by construction.
83    pub fn grow_by_profile(
84        &self,
85        new_to_old: &[Option<usize>],
86        unit_profiles: &[Vec<f32>],
87    ) -> anyhow::Result<FeatureCoarsening> {
88        let d_new = new_to_old.len();
89        anyhow::ensure!(
90            unit_profiles.len() == d_new,
91            "feature coarsening growth: {} profiles for {d_new} features",
92            unit_profiles.len(),
93        );
94        let k = self.num_coarse;
95        let n_pb = unit_profiles.first().map_or(0, Vec::len);
96
97        // Known genes keep their group; their unit profiles sum into the
98        // group's centroid, one contiguous column per group.
99        let mut fine_to_coarse = vec![usize::MAX; d_new];
100        let mut centroid = DMatrix::<f32>::zeros(n_pb, k);
101        let mut members = vec![0usize; k];
102        for (g, old) in new_to_old.iter().enumerate() {
103            let Some(p) = old else { continue };
104            anyhow::ensure!(
105                *p < self.fine_to_coarse.len(),
106                "feature coarsening growth: feature {g} maps to {p}, beyond the {} covered",
107                self.fine_to_coarse.len(),
108            );
109            let m = self.fine_to_coarse[*p];
110            fine_to_coarse[g] = m;
111            members[m] += 1;
112            for (c, v) in centroid.column_mut(m).iter_mut().zip(&unit_profiles[g]) {
113                *c += v;
114            }
115        }
116        let live: Vec<usize> = (0..k).filter(|&m| members[m] > 0).collect();
117        anyhow::ensure!(
118            !live.is_empty(),
119            "feature coarsening growth: no feature of the new axis is covered, so the groups \
120             cannot be placed on it"
121        );
122        let fallback = live
123            .iter()
124            .copied()
125            .max_by_key(|&m| members[m])
126            .expect("a live group exists");
127        let mut centroid = centroid.select_columns(&live);
128        for mut c in centroid.column_iter_mut() {
129            let nrm = c.norm();
130            if nrm > 0.0 {
131                c /= nrm;
132            }
133        }
134
135        // Unknown genes: one product of their unit profiles against the live
136        // centroids, then a row-wise argmax.
137        let new: Vec<usize> = (0..d_new).filter(|&g| new_to_old[g].is_none()).collect();
138        let u = DMatrix::from_fn(new.len(), n_pb, |i, j| unit_profiles[new[i]][j]);
139        let scores = u * centroid;
140        for (i, &g) in new.iter().enumerate() {
141            let row = scores.row(i);
142            let best = row
143                .iter()
144                .enumerate()
145                .max_by(|a, b| a.1.total_cmp(b.1))
146                .expect("a live group exists");
147            fine_to_coarse[g] = if row.iter().all(|&v| v == 0.0) {
148                fallback
149            } else {
150                live[best.0]
151            };
152        }
153        debug!(
154            "feature coarsening growth: {} of {d_new} features known, {} placed by profile into \
155             {k} groups ({} groups had no surviving member)",
156            d_new - new.len(),
157            new.len(),
158            k - live.len(),
159        );
160        Self::from_fine_to_coarse(fine_to_coarse, k)
161    }
162
163    /// Aggregate columns of an [N, D] matrix → [N, d] by summing
164    /// features within each coarse group.
165    pub fn aggregate_columns_nd(&self, data_nd: &DMatrix<f32>) -> DMatrix<f32> {
166        let n = data_nd.nrows();
167        build_columns_par(n, self.num_coarse, |c, col| {
168            for &fine in &self.coarse_to_fine[c] {
169                let src = data_nd.column(fine);
170                for (dst, src_v) in col.iter_mut().zip(src.iter()) {
171                    *dst += *src_v;
172                }
173            }
174        })
175    }
176
177    /// Aggregate rows of a [D, S] matrix → [d, S] by summing
178    /// features within each coarse group.
179    pub fn aggregate_rows_ds(&self, data_ds: &DMatrix<f32>) -> DMatrix<f32> {
180        let s = data_ds.ncols();
181        build_columns_par(self.num_coarse, s, |j, col| {
182            let src_col = data_ds.column(j);
183            for (fine, &coarse) in self.fine_to_coarse.iter().enumerate() {
184                col[coarse] += src_col[fine];
185            }
186        })
187    }
188
189    /// Expand log-probability dictionary [d, K] → [D, K].
190    ///
191    /// For fine feature `f` in group `c` (size `g`):
192    ///   `expanded[f, k] = coarse[c, k] - ln(g)`
193    ///
194    /// After exponentiation, probabilities split evenly within each group:
195    ///   `β[f, k] = β_coarse[c, k] / g`
196    pub fn expand_log_dict_dk(&self, log_dict_dk: &DMatrix<f32>, d_fine: usize) -> DMatrix<f32> {
197        let k = log_dict_dk.ncols();
198        build_columns_par(d_fine, k, |kk, col| {
199            let src_col = log_dict_dk.column(kk);
200            for (c, fine_indices) in self.coarse_to_fine.iter().enumerate() {
201                let val = src_col[c] - (fine_indices.len() as f32).ln();
202                for &f in fine_indices {
203                    col[f] = val;
204                }
205            }
206        })
207    }
208
209    /// Expand a per-group `[d, K]` table to `[D, K]` by giving every fine
210    /// feature its group's row unchanged.
211    ///
212    /// The counterpart to [`Self::expand_log_dict_dk`], for a table that is
213    /// NOT a log-probability and must not be split across the group's
214    /// members: factor loadings multiply a latent rather than carrying mass,
215    /// so a group's loading IS each of its features' loading. Where such a
216    /// model also has a per-feature offset, that offset is where the split
217    /// belongs.
218    pub fn expand_rows_dk(&self, table_dk: &DMatrix<f32>, d_fine: usize) -> DMatrix<f32> {
219        let k = table_dk.ncols();
220        build_columns_par(d_fine, k, |kk, col| {
221            let src_col = table_dk.column(kk);
222            for (c, fine_indices) in self.coarse_to_fine.iter().enumerate() {
223                for &f in fine_indices {
224                    col[f] = src_col[c];
225                }
226            }
227        })
228    }
229
230    /// Aggregate a sparse [D, n] CSC matrix → dense [d, n] by summing
231    /// rows within each coarse group. Efficient: O(nnz) work.
232    pub fn aggregate_sparse_csc(&self, data_dn: &CscMat) -> DMatrix<f32> {
233        let n = data_dn.ncols();
234        build_columns_par(self.num_coarse, n, |j, col| {
235            let src = data_dn.col(j);
236            for (&row, &val) in src.row_indices().iter().zip(src.values().iter()) {
237                col[self.fine_to_coarse[row]] += val;
238            }
239        })
240    }
241}
242
243////////////////////////////////////
244// Building a coarsening from counts //
245////////////////////////////////////
246
247/// A feature is informative when its homogeneity deviance exceeds its degrees
248/// of freedom by this many standard deviations (`(D − df)/√(2·df)`).
249const INFORMATIVE_Z: f64 = 5.0;
250/// Lloyd iterations of every k-means here.
251const KMEANS_ITER: usize = 30;
252/// Widest residual profile k-means runs on directly; a wider one is sketched
253/// down to [`SKETCH_DIM`] with a seeded Gaussian first.
254const MAX_PROFILE_DIM: usize = 1024;
255const SKETCH_DIM: usize = 64;
256
257/// Per feature, whether its counts carry grouping evidence: the Poisson
258/// deviance of row `g` of `counts` against the flat rate `E_gs = T_g·n_s/N`
259/// exceeds its `S − 1` degrees of freedom by [`INFORMATIVE_Z`] standard
260/// deviations. A feature never counted is not informative.
261pub fn informative_features(counts: &DMatrix<f32>, sizes: &[f32]) -> Vec<bool> {
262    let total_size: f64 = sizes.iter().map(|&n| f64::from(n)).sum();
263    let live: Vec<usize> = (0..sizes.len()).filter(|&s| sizes[s] > 0.0).collect();
264    if live.len() < 2 || total_size <= 0.0 {
265        return vec![false; counts.nrows()];
266    }
267    let df = (live.len() - 1) as f64;
268    (0..counts.nrows())
269        .into_par_iter()
270        .map(|g| {
271            let t: f64 = live.iter().map(|&s| f64::from(counts[(g, s)])).sum();
272            if t <= 0.0 {
273                return false;
274            }
275            let dev: f64 = live
276                .iter()
277                .map(|&s| {
278                    let n = f64::from(counts[(g, s)]);
279                    let e = t * f64::from(sizes[s]) / total_size;
280                    let log_term = if n > 0.0 { n * (n / e).ln() } else { 0.0 };
281                    2.0 * (log_term - (n - e))
282                })
283                .sum();
284            (dev - df) / (2.0 * df).sqrt() > INFORMATIVE_Z
285        })
286        .collect()
287}
288
289/// Unit-length Pearson residual profiles `(n − E)/√E` of the `rows` of
290/// `counts`, clipped to `±√S`, sketched to [`SKETCH_DIM`] when wider than
291/// [`MAX_PROFILE_DIM`].
292fn residual_profiles(
293    counts: &DMatrix<f32>,
294    sizes: &[f32],
295    rows: &[usize],
296    seed: u64,
297) -> DMatrix<f32> {
298    let n_pb = sizes.len();
299    let total_size: f32 = sizes.iter().sum();
300    let clip = (n_pb as f32).sqrt();
301    let mut z = DMatrix::<f32>::zeros(rows.len(), n_pb);
302    for (i, &g) in rows.iter().enumerate() {
303        let t: f32 = counts.row(g).sum();
304        for s in 0..n_pb {
305            let e = t * sizes[s] / total_size.max(f32::MIN_POSITIVE);
306            if e > 0.0 {
307                z[(i, s)] = ((counts[(g, s)] - e) / e.sqrt()).clamp(-clip, clip);
308            }
309        }
310    }
311    if n_pb > MAX_PROFILE_DIM {
312        let basis = DMatrix::<f32>::rnorm_seeded(n_pb, SKETCH_DIM, mix_seed(seed, 0x5343_4854));
313        z = (&z * basis) / (SKETCH_DIM as f32).sqrt();
314    }
315    unit_rows(&mut z);
316    z
317}
318
319fn unit_rows(z: &mut DMatrix<f32>) {
320    for mut row in z.row_iter_mut() {
321        let norm = row.norm();
322        if norm > 0.0 {
323            row /= norm;
324        }
325    }
326}
327
328/// Nested feature coarsenings of pseudobulk `counts` `[D × S]` with pseudobulk
329/// sizes `sizes` (cells per column), one per entry of `level_targets`
330/// (coarsest → finest, non-decreasing): at most `target` groups each,
331/// counting the reserved background group when uninformative features exist.
332///
333/// # Method
334///
335/// Most features of a real axis carry no grouping evidence: a feature that is
336/// barely detected, or detected at one flat rate everywhere, has counts a
337/// single rate explains, and grouping such features by distance chains them
338/// into one group that drags informative features in with it. So every feature
339/// is first tested for homogeneity ([`informative_features`]); the ones that
340/// fail form ONE background group, numbered last, at every level.
341///
342/// The informative features are grouped by k-means on their Pearson residuals
343/// `(n − E)/√E`, clipped and scaled to unit length, so a feature is placed by
344/// the SHAPE of its deviation from the flat rate with count noise in
345/// proportion; k-means keeps groups balanced by construction. Coarser levels
346/// cluster the finest centroids, so every level nests in the one below.
347/// Each level's group sizes are logged.
348///
349/// When a background group is reserved, the k-means budget is `target - 1`
350/// informative slots (and may be zero): `target == 1` then yields a single
351/// all-background coarsening rather than forcing an extra informative cluster.
352pub fn coarsen_features(
353    counts: &DMatrix<f32>,
354    sizes: &[f32],
355    level_targets: &[usize],
356    seed: u64,
357) -> anyhow::Result<Vec<FeatureCoarsening>> {
358    let d = counts.nrows();
359    anyhow::ensure!(
360        counts.ncols() == sizes.len(),
361        "{} count columns for {} pseudobulk sizes",
362        counts.ncols(),
363        sizes.len()
364    );
365    anyhow::ensure!(!level_targets.is_empty(), "no coarsening level requested");
366    anyhow::ensure!(
367        level_targets.windows(2).all(|w| w[0] <= w[1]),
368        "coarsening levels must run coarsest → finest: {level_targets:?}"
369    );
370    let informative = informative_features(counts, sizes);
371    let rows: Vec<usize> = (0..d).filter(|&g| informative[g]).collect();
372    let has_background = rows.len() < d;
373    // `target` is a total group budget; the background (when present) spends
374    // one slot, and the rest are informative k-means clusters (possibly zero).
375    let slots = |target: usize| target.max(1).saturating_sub(usize::from(has_background));
376    info!(
377        "feature coarsening: {} of {d} features informative, {} in the background group",
378        rows.len(),
379        d - rows.len()
380    );
381
382    // Finest level: k-means on the informative features' residual profiles.
383    let finest = *level_targets.last().expect("checked non-empty");
384    let k_fine = slots(finest).min(rows.len());
385    let (fine_label, centroids) = if rows.is_empty() || k_fine == 0 {
386        (Vec::new(), DMatrix::<f32>::zeros(0, 0))
387    } else {
388        let z = residual_profiles(counts, sizes, &rows, seed);
389        let (mut c, labels) = kmeans_centroids_seeded(&z, k_fine, KMEANS_ITER, seed);
390        unit_rows(&mut c);
391        (labels, c)
392    };
393    let n_fine = centroids.nrows();
394
395    // Coarser levels: k-means on the finest centroids, so every level nests.
396    let levels = level_targets
397        .iter()
398        .enumerate()
399        .map(|(l, &target)| {
400            let k = slots(target);
401            let to_level: Vec<usize> = if n_fine == 0 || k == 0 {
402                Vec::new()
403            } else if l + 1 == level_targets.len() || k >= n_fine {
404                (0..n_fine).collect()
405            } else {
406                kmeans_centroids_seeded(&centroids, k, KMEANS_ITER, mix_seed(seed, l as u64)).1
407            };
408            let level = level_from_labels(d, &rows, &fine_label, &to_level, has_background);
409            info!(
410                "feature coarsening level {l} (target {target}): {}",
411                group_sizes(&level, has_background)
412            );
413            level
414        })
415        .collect();
416    Ok(levels)
417}
418
419/// A [`FeatureCoarsening`] from the informative rows' finest labels mapped to
420/// this level, with every other feature in one background group (numbered
421/// last). Group ids are compacted in order of first use.
422fn level_from_labels(
423    d: usize,
424    rows: &[usize],
425    fine_label: &[usize],
426    to_level: &[usize],
427    has_background: bool,
428) -> FeatureCoarsening {
429    let mut in_rows = vec![false; d];
430    let mut compact = std::collections::HashMap::<usize, usize>::new();
431    let mut fine_to_coarse = vec![0usize; d];
432    for (&g, &f) in rows.iter().zip(fine_label) {
433        in_rows[g] = true;
434        let next = compact.len();
435        fine_to_coarse[g] = *compact.entry(to_level[f]).or_insert(next);
436    }
437    let mut num_coarse = compact.len();
438    if has_background {
439        for (g, &inside) in in_rows.iter().enumerate() {
440            if !inside {
441                fine_to_coarse[g] = num_coarse;
442            }
443        }
444        num_coarse += 1;
445    }
446    let mut coarse_to_fine = vec![Vec::new(); num_coarse];
447    for (g, &c) in fine_to_coarse.iter().enumerate() {
448        coarse_to_fine[c].push(g);
449    }
450    FeatureCoarsening {
451        fine_to_coarse,
452        coarse_to_fine,
453        num_coarse,
454    }
455}
456
457/// The informative groups' sizes, the background (numbered last) reported
458/// apart: `"K informative group(s), sizes min / median / max, N singleton(s);
459/// background B"`.
460fn group_sizes(level: &FeatureCoarsening, has_background: bool) -> String {
461    let n_inf = level.num_coarse - usize::from(has_background);
462    let mut sizes: Vec<usize> = level.coarse_to_fine[..n_inf].iter().map(Vec::len).collect();
463    sizes.sort_unstable();
464    format!(
465        "{} informative group(s), sizes min {} / median {} / max {}, {} singleton(s); \
466         background {}",
467        sizes.len(),
468        sizes.first().copied().unwrap_or(0),
469        sizes.get(sizes.len() / 2).copied().unwrap_or(0),
470        sizes.last().copied().unwrap_or(0),
471        sizes.iter().filter(|&&n| n == 1).count(),
472        if has_background {
473            level.coarse_to_fine[n_inf].len()
474        } else {
475            0
476        }
477    )
478}
479
480/// Options of [`partition_features`]; the default is the plain single-level
481/// coarsening.
482#[derive(Clone, Debug, Default)]
483pub struct PartitionOptions {
484    /// Groups of fewer features hold **scattered** features — ones whose
485    /// profile matches no other, which k-means places alone. They join the
486    /// background, and the slots they held split the largest groups in two
487    /// (a split kept only when both halves reach this size). Re-clustering
488    /// without them instead only promotes the next outliers. `0` or `1`: off.
489    pub min_group_size: usize,
490    /// Per feature, a BLOCK no group may cross (e.g. a genomic window);
491    /// `u32::MAX` is an ordinary block id. Each block is partitioned on its
492    /// own with a share of the slots proportional to its informative
493    /// features (at least one). `None`: one block.
494    pub block: Option<Vec<u32>>,
495}
496
497/// A single-level partition of the features (see [`partition_features`]).
498#[derive(Clone, Debug)]
499pub struct FeaturePartition {
500    /// Group of every feature, `0..num_groups`; the background, when there is
501    /// one, is numbered last.
502    pub labels: Vec<usize>,
503    pub num_groups: usize,
504    /// The background group: flat features and scattered ones.
505    pub background: Option<usize>,
506    /// Per feature, [`informative_features`] (not flat), computed once here
507    /// so callers need not run the test again.
508    pub informative: Vec<bool>,
509}
510
511/// Salt of the k-means seed of a group split.
512const SPLIT_SEED_SALT: u64 = 0x5350_4c54;
513
514/// `k` k-means groups of `rows` by their residual profiles, as lists of row
515/// ids in first-use order (empty groups dropped); `k` is clamped to the row
516/// count.
517fn kmeans_groups(
518    counts: &DMatrix<f32>,
519    sizes: &[f32],
520    rows: &[usize],
521    k: usize,
522    seed: u64,
523) -> Vec<Vec<usize>> {
524    let k = k.min(rows.len());
525    if k == 0 {
526        return Vec::new();
527    }
528    let z = residual_profiles(counts, sizes, rows, seed);
529    let (_, labels) = kmeans_centroids_seeded(&z, k, KMEANS_ITER, seed);
530    let mut slot = vec![usize::MAX; k];
531    let mut groups: Vec<Vec<usize>> = Vec::new();
532    for (&r, &l) in rows.iter().zip(&labels) {
533        if slot[l] == usize::MAX {
534            slot[l] = groups.len();
535            groups.push(Vec::new());
536        }
537        groups[slot[l]].push(r);
538    }
539    groups
540}
541
542/// The informative groups of `rows` within a budget of `n_groups` (one slot
543/// kept for the background when it has members): the plain k-means, then,
544/// with `min_size > 1`, scattered groups set aside and the freed slots spent
545/// splitting the largest groups. Rows in no returned group are background.
546fn informative_groups(
547    counts: &DMatrix<f32>,
548    sizes: &[f32],
549    rows: &[usize],
550    informative: &[bool],
551    n_groups: usize,
552    seed: u64,
553    min_size: usize,
554) -> Vec<Vec<usize>> {
555    let inf: Vec<usize> = rows.iter().copied().filter(|&r| informative[r]).collect();
556    let has_empty = inf.len() < rows.len();
557    let slots = n_groups.max(1).saturating_sub(usize::from(has_empty));
558    let groups = kmeans_groups(counts, sizes, &inf, slots, seed);
559    if min_size <= 1 {
560        return groups;
561    }
562    let (mut groups, scattered): (Vec<Vec<usize>>, Vec<Vec<usize>>) =
563        groups.into_iter().partition(|g| g.len() >= min_size);
564    let has_bg = has_empty || !scattered.is_empty();
565    let mut free = n_groups.saturating_sub(groups.len() + usize::from(has_bg));
566    let mut heap: std::collections::BinaryHeap<(usize, usize)> = groups
567        .iter()
568        .enumerate()
569        .map(|(i, g)| (g.len(), i))
570        .collect();
571    while free > 0 {
572        let Some((len, i)) = heap.pop() else { break };
573        if len < 2 * min_size {
574            break;
575        }
576        let mut halves = kmeans_groups(
577            counts,
578            sizes,
579            &groups[i],
580            2,
581            mix_seed(seed, SPLIT_SEED_SALT + i as u64),
582        );
583        if halves.len() == 2 && halves.iter().all(|h| h.len() >= min_size) {
584            let b = halves.pop().expect("two halves");
585            groups[i] = halves.pop().expect("two halves");
586            heap.push((groups[i].len(), i));
587            heap.push((b.len(), groups.len()));
588            groups.push(b);
589            free -= 1;
590        }
591    }
592    groups
593}
594
595/// Proportional shares of `budget` slots over blocks with `weight` informative
596/// features each: at least one per block, the remainder to the blocks with the
597/// most features per slot.
598fn block_shares(weight: &[usize], budget: usize) -> Vec<usize> {
599    let total: usize = weight.iter().sum();
600    let mut alloc: Vec<usize> = weight
601        .iter()
602        .map(|&c| (budget * c / total.max(1)).max(1))
603        .collect();
604    while alloc.iter().sum::<usize>() > budget {
605        let i = (0..alloc.len())
606            .max_by_key(|&i| alloc[i])
607            .expect("non-empty");
608        alloc[i] -= 1;
609    }
610    while alloc.iter().sum::<usize>() < budget {
611        let i = (0..alloc.len())
612            .max_by(|&a, &b| (weight[a] * alloc[b]).cmp(&(weight[b] * alloc[a])))
613            .expect("non-empty");
614        alloc[i] += 1;
615    }
616    alloc
617}
618
619/// A single-level partition of `counts` `[D × S]` into at most `n_groups`
620/// groups, the background counted: [`coarsen_features`] at one level, with
621/// the [`PartitionOptions`] applied and the background id and informative
622/// flags returned. Without options the labels are exactly the single-level
623/// coarsening's.
624pub fn partition_features(
625    counts: &DMatrix<f32>,
626    sizes: &[f32],
627    n_groups: usize,
628    seed: u64,
629    opts: &PartitionOptions,
630) -> anyhow::Result<FeaturePartition> {
631    let d = counts.nrows();
632    anyhow::ensure!(
633        counts.ncols() == sizes.len(),
634        "{} count columns for {} pseudobulk sizes",
635        counts.ncols(),
636        sizes.len()
637    );
638    let informative = informative_features(counts, sizes);
639    let min_size = opts.min_group_size;
640    let mut rows_of = std::collections::BTreeMap::<u32, Vec<usize>>::new();
641    match &opts.block {
642        Some(block) => {
643            anyhow::ensure!(
644                block.len() == d,
645                "{} block ids for {d} features",
646                block.len()
647            );
648            for (r, &b) in block.iter().enumerate() {
649                rows_of.entry(b).or_default().push(r);
650            }
651        }
652        None => {
653            rows_of.insert(0, (0..d).collect());
654        }
655    }
656
657    let groups: Vec<Vec<usize>> = if rows_of.len() <= 1 {
658        let rows: Vec<usize> = (0..d).collect();
659        informative_groups(counts, sizes, &rows, &informative, n_groups, seed, min_size)
660    } else {
661        let n_inf = |rows: &[usize]| rows.iter().filter(|&&r| informative[r]).count();
662        let live: Vec<(u32, usize)> = rows_of
663            .iter()
664            .map(|(&b, rows)| (b, n_inf(rows)))
665            .filter(|&(_, c)| c > 0)
666            .collect();
667        let budget = n_groups.saturating_sub(1);
668        anyhow::ensure!(
669            live.len() <= budget,
670            "{} blocks hold informative features but only {budget} group slots: widen the blocks",
671            live.len()
672        );
673        let weight: Vec<usize> = live.iter().map(|&(_, c)| c).collect();
674        let alloc = block_shares(&weight, budget);
675        live.iter()
676            .zip(&alloc)
677            .flat_map(|(&(b, _), &k)| {
678                let rows = &rows_of[&b];
679                // One more slot when the block has near-empty rows: it is spent
680                // on their background, which joins the shared one.
681                let has_empty = rows.len() > n_inf(rows);
682                informative_groups(
683                    counts,
684                    sizes,
685                    rows,
686                    &informative,
687                    k + usize::from(has_empty),
688                    mix_seed(seed, u64::from(b)),
689                    min_size,
690                )
691            })
692            .collect()
693    };
694
695    // Labels: the groups in order, then the background.
696    let bg = groups.len();
697    let mut labels = vec![bg; d];
698    for (i, g) in groups.iter().enumerate() {
699        for &r in g {
700            labels[r] = i;
701        }
702    }
703    let n_bg = d - groups.iter().map(Vec::len).sum::<usize>();
704    let n_flat = informative.iter().filter(|&&i| !i).count();
705    let mut group_sizes: Vec<usize> = groups.iter().map(Vec::len).collect();
706    group_sizes.sort_unstable();
707    info!(
708        "feature partition: {bg} group(s) over {} block(s), sizes min {} / median {} / max {}; \
709         background {n_bg} ({n_flat} flat, {} scattered)",
710        rows_of.len(),
711        group_sizes.first().copied().unwrap_or(0),
712        group_sizes.get(group_sizes.len() / 2).copied().unwrap_or(0),
713        group_sizes.last().copied().unwrap_or(0),
714        n_bg - n_flat,
715    );
716    Ok(FeaturePartition {
717        labels,
718        num_groups: bg + usize::from(n_bg > 0),
719        background: (n_bg > 0).then_some(bg),
720        informative,
721    })
722}
723
724/// Shared CLI arg for grouping co-expressed features before training.
725///
726/// One declaration, one wording, one default, flattened by every command that
727/// can train at reduced feature resolution — the same shape as
728/// `crate::alg::hvg::HvgCliArgs`, and read alongside it: selection decides which
729/// features weigh on the sketch, this decides how many distinct outputs the
730/// model answers for.
731#[derive(Args, Debug, Clone, Serialize, Deserialize)]
732#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
733pub struct FeatureCoarseningArgs {
734    #[arg(
735        long,
736        default_value_t = 1000,
737        value_name = "N",
738        help = "Group co-expressed features into at most N coarse features; 0 = every feature",
739        long_help = "Group co-expressed features into at most N coarse features,\n\
740                     so the model answers for a group instead of for each feature.\n\
741                     Groups come from the finest pseudobulk profiles, nested per\n\
742                     level with log-spaced widths. The dictionary is expanded back\n\
743                     to every feature on output, so what you read is unchanged.\n\
744                     \n\
745                     --no-feature-coarsening trains on every feature instead. So\n\
746                     does 0 here, kept because recorded runs and existing scripts\n\
747                     spell it that way.\n\
748                     \n\
749                     WHAT THE GROUPING APPLIES TO DEPENDS ON THE COMMAND:\n\
750                     \n\
751                     `topic` and `vae` group both sides. The encoder reads coarse\n\
752                     features and every decoder answers for them, so 0 makes each\n\
753                     per-step tensor as wide as the feature axis.\n\
754                     \n\
755                     `masked-topic`, `masked-vae` and `masked-sbp` group the decoder\n\
756                     targets only. The encoder keeps its feature-level context and\n\
757                     embedding either way.\n\
758                     \n\
759                     `joint-topic` groups per modality, or on the reference modality\n\
760                     and shares it, following --decoder-type.\n\
761                     \n\
762                     This is a COARSENING, not a module. senna tells the two kinds\n\
763                     of grouping apart by one question: does training move the\n\
764                     membership? A coarsening's does not. It is read off the data\n\
765                     before the first step and fixed for the life of the model,\n\
766                     which is what lets every decoder be keyed to it and what lets a\n\
767                     continued fit inherit it. A module's does: --gene-modules learns\n\
768                     its centroids, so which features group together changes as the\n\
769                     fit proceeds.\n\
770                     \n\
771                     What a coarsening fixes is the assignment, not the meaning. A\n\
772                     group's embedding is the mean of its members' and moves at every\n\
773                     step, so grouping the targets does not freeze what the model can\n\
774                     learn about a feature."
775    )]
776    pub max_coarse_features: usize,
777
778    #[arg(
779        long,
780        conflicts_with = "max_coarse_features",
781        help = "Train on every feature, with no grouping",
782        long_help = "Train on every feature. The named form of\n\
783                     --max-coarse-features 0, which reads as a request for zero\n\
784                     features when it means the opposite.\n\
785                     \n\
786                     Every per-step tensor is then as wide as the feature axis, so\n\
787                     the fit costs considerably more at the same epoch count. It is\n\
788                     also fixed for the life of a model: a continued fit inherits\n\
789                     the groups the source run trained on, so a chain that starts\n\
790                     ungrouped stays ungrouped and one that starts grouped cannot\n\
791                     be switched over partway."
792    )]
793    pub no_feature_coarsening: bool,
794}
795
796impl FeatureCoarseningArgs {
797    /// The cap, or `None` when the model trains on every feature.
798    ///
799    /// The one place either spelling of "off" is read, so no command repeats
800    /// the convention: the named switch and the zero mean the same thing here
801    /// and clap refuses both at once.
802    #[must_use]
803    pub fn cap(&self) -> Option<std::num::NonZeroUsize> {
804        if self.no_feature_coarsening {
805            return None;
806        }
807        std::num::NonZeroUsize::new(self.max_coarse_features)
808    }
809}
810
811#[cfg(test)]
812#[path = "feature_coarsening_tests.rs"]
813mod tests;