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    // Row totals, then the residuals: both walk `counts` one column at a
302    // time, the order it is stored in, and the residual columns are filled
303    // in parallel.
304    let mut totals = vec![0f32; rows.len()];
305    for col in counts.column_iter() {
306        for (t, &g) in totals.iter_mut().zip(rows) {
307            *t += col[g];
308        }
309    }
310    let mut z = DMatrix::<f32>::zeros(rows.len(), n_pb);
311    if !rows.is_empty() {
312        z.as_mut_slice()
313            .par_chunks_mut(rows.len())
314            .enumerate()
315            .for_each(|(s, z_col)| {
316                let col = counts.column(s);
317                for ((z_is, &t), &g) in z_col.iter_mut().zip(&totals).zip(rows) {
318                    let e = t * sizes[s] / total_size.max(f32::MIN_POSITIVE);
319                    if e > 0.0 {
320                        *z_is = ((col[g] - e) / e.sqrt()).clamp(-clip, clip);
321                    }
322                }
323            });
324    }
325    if n_pb > MAX_PROFILE_DIM {
326        let basis = DMatrix::<f32>::rnorm_seeded(n_pb, SKETCH_DIM, mix_seed(seed, 0x5343_4854));
327        z = (&z * basis) / (SKETCH_DIM as f32).sqrt();
328    }
329    unit_rows(&mut z);
330    z
331}
332
333fn unit_rows(z: &mut DMatrix<f32>) {
334    for mut row in z.row_iter_mut() {
335        let norm = row.norm();
336        if norm > 0.0 {
337            row /= norm;
338        }
339    }
340}
341
342/// Nested feature coarsenings of pseudobulk `counts` `[D × S]` with pseudobulk
343/// sizes `sizes` (cells per column), one per entry of `level_targets`
344/// (coarsest → finest, non-decreasing): at most `target` groups each,
345/// counting the reserved background group when uninformative features exist.
346///
347/// # Method
348///
349/// Most features of a real axis carry no grouping evidence: a feature that is
350/// barely detected, or detected at one flat rate everywhere, has counts a
351/// single rate explains, and grouping such features by distance chains them
352/// into one group that drags informative features in with it. So every feature
353/// is first tested for homogeneity ([`informative_features`]); the ones that
354/// fail form ONE background group, numbered last, at every level.
355///
356/// The informative features are grouped by k-means on their Pearson residuals
357/// `(n − E)/√E`, clipped and scaled to unit length, so a feature is placed by
358/// the SHAPE of its deviation from the flat rate with count noise in
359/// proportion; k-means keeps groups balanced by construction. Coarser levels
360/// cluster the finest centroids, so every level nests in the one below.
361/// Each level's group sizes are logged.
362///
363/// When a background group is reserved, the k-means budget is `target - 1`
364/// informative slots (and may be zero): `target == 1` then yields a single
365/// all-background coarsening rather than forcing an extra informative cluster.
366pub fn coarsen_features(
367    counts: &DMatrix<f32>,
368    sizes: &[f32],
369    level_targets: &[usize],
370    seed: u64,
371) -> anyhow::Result<Vec<FeatureCoarsening>> {
372    let d = counts.nrows();
373    anyhow::ensure!(
374        counts.ncols() == sizes.len(),
375        "{} count columns for {} pseudobulk sizes",
376        counts.ncols(),
377        sizes.len()
378    );
379    anyhow::ensure!(!level_targets.is_empty(), "no coarsening level requested");
380    anyhow::ensure!(
381        level_targets.windows(2).all(|w| w[0] <= w[1]),
382        "coarsening levels must run coarsest → finest: {level_targets:?}"
383    );
384    let informative = informative_features(counts, sizes);
385    let rows: Vec<usize> = (0..d).filter(|&g| informative[g]).collect();
386    let has_background = rows.len() < d;
387    // `target` is a total group budget; the background (when present) spends
388    // one slot, and the rest are informative k-means clusters (possibly zero).
389    let slots = |target: usize| target.max(1).saturating_sub(usize::from(has_background));
390    info!(
391        "feature coarsening: {} of {d} features informative, {} in the background group",
392        rows.len(),
393        d - rows.len()
394    );
395
396    // Finest level: k-means on the informative features' residual profiles.
397    let finest = *level_targets.last().expect("checked non-empty");
398    let k_fine = slots(finest).min(rows.len());
399    let (fine_label, centroids) = if rows.is_empty() || k_fine == 0 {
400        (Vec::new(), DMatrix::<f32>::zeros(0, 0))
401    } else {
402        let z = residual_profiles(counts, sizes, &rows, seed);
403        let (mut c, labels) = kmeans_centroids_seeded(&z, k_fine, KMEANS_ITER, seed);
404        unit_rows(&mut c);
405        (labels, c)
406    };
407    let n_fine = centroids.nrows();
408
409    // Coarser levels: k-means on the finest centroids, so every level nests.
410    let levels = level_targets
411        .iter()
412        .enumerate()
413        .map(|(l, &target)| {
414            let k = slots(target);
415            let to_level: Vec<usize> = if n_fine == 0 || k == 0 {
416                Vec::new()
417            } else if l + 1 == level_targets.len() || k >= n_fine {
418                (0..n_fine).collect()
419            } else {
420                kmeans_centroids_seeded(&centroids, k, KMEANS_ITER, mix_seed(seed, l as u64)).1
421            };
422            // A level with no informative slot is all background.
423            let level = if to_level.is_empty() {
424                level_from_labels(d, &[], &[], &[], has_background)
425            } else {
426                level_from_labels(d, &rows, &fine_label, &to_level, has_background)
427            };
428            info!(
429                "feature coarsening level {l} (target {target}): {}",
430                group_sizes(&level, has_background)
431            );
432            level
433        })
434        .collect();
435    Ok(levels)
436}
437
438/// A [`FeatureCoarsening`] from the informative rows' finest labels mapped to
439/// this level, with every other feature in one background group (numbered
440/// last). Group ids are compacted in order of first use.
441fn level_from_labels(
442    d: usize,
443    rows: &[usize],
444    fine_label: &[usize],
445    to_level: &[usize],
446    has_background: bool,
447) -> FeatureCoarsening {
448    let mut in_rows = vec![false; d];
449    let mut compact = std::collections::HashMap::<usize, usize>::new();
450    let mut fine_to_coarse = vec![0usize; d];
451    for (&g, &f) in rows.iter().zip(fine_label) {
452        in_rows[g] = true;
453        let next = compact.len();
454        fine_to_coarse[g] = *compact.entry(to_level[f]).or_insert(next);
455    }
456    let mut num_coarse = compact.len();
457    if has_background {
458        for (g, &inside) in in_rows.iter().enumerate() {
459            if !inside {
460                fine_to_coarse[g] = num_coarse;
461            }
462        }
463        num_coarse += 1;
464    }
465    let mut coarse_to_fine = vec![Vec::new(); num_coarse];
466    for (g, &c) in fine_to_coarse.iter().enumerate() {
467        coarse_to_fine[c].push(g);
468    }
469    FeatureCoarsening {
470        fine_to_coarse,
471        coarse_to_fine,
472        num_coarse,
473    }
474}
475
476/// The informative groups' sizes, the background (numbered last) reported
477/// apart: `"K informative group(s), sizes min / median / max, N singleton(s);
478/// background B"`.
479fn group_sizes(level: &FeatureCoarsening, has_background: bool) -> String {
480    let n_inf = level.num_coarse - usize::from(has_background);
481    let mut sizes: Vec<usize> = level.coarse_to_fine[..n_inf].iter().map(Vec::len).collect();
482    sizes.sort_unstable();
483    format!(
484        "{} informative group(s), sizes min {} / median {} / max {}, {} singleton(s); \
485         background {}",
486        sizes.len(),
487        sizes.first().copied().unwrap_or(0),
488        sizes.get(sizes.len() / 2).copied().unwrap_or(0),
489        sizes.last().copied().unwrap_or(0),
490        sizes.iter().filter(|&&n| n == 1).count(),
491        if has_background {
492            level.coarse_to_fine[n_inf].len()
493        } else {
494            0
495        }
496    )
497}
498
499/// Options of [`partition_features`]; the default is the plain single-level
500/// coarsening.
501#[derive(Clone, Debug, Default)]
502pub struct PartitionOptions {
503    /// Groups of fewer features hold **scattered** features — ones whose
504    /// profile matches no other, which k-means places alone. They join the
505    /// background, and the slots they held split the largest groups in two
506    /// (a split kept only when both halves reach this size). Re-clustering
507    /// without them instead only promotes the next outliers. `0` or `1`: off.
508    pub min_group_size: usize,
509    /// Per feature, a BLOCK no group may cross (e.g. a genomic window);
510    /// `u32::MAX` is an ordinary block id. Each block is partitioned on its
511    /// own with a share of the slots proportional to its informative
512    /// features (at least one). `None`: one block.
513    pub block: Option<Vec<u32>>,
514}
515
516/// A single-level partition of the features (see [`partition_features`]).
517#[derive(Clone, Debug)]
518pub struct FeaturePartition {
519    /// Group of every feature, `0..num_groups`; the background, when there is
520    /// one, is numbered last.
521    pub labels: Vec<usize>,
522    pub num_groups: usize,
523    /// The background group: flat features and scattered ones.
524    pub background: Option<usize>,
525    /// Per feature, [`informative_features`] (not flat), computed once here
526    /// so callers need not run the test again.
527    pub informative: Vec<bool>,
528}
529
530/// Salt of the k-means seed of a group split.
531const SPLIT_SEED_SALT: u64 = 0x5350_4c54;
532
533/// `k` k-means groups of `rows` by their residual profiles, as lists of row
534/// ids in first-use order (empty groups dropped); `k` is clamped to the row
535/// count.
536fn kmeans_groups(
537    counts: &DMatrix<f32>,
538    sizes: &[f32],
539    rows: &[usize],
540    k: usize,
541    seed: u64,
542) -> Vec<Vec<usize>> {
543    let k = k.min(rows.len());
544    if k == 0 {
545        return Vec::new();
546    }
547    let z = residual_profiles(counts, sizes, rows, seed);
548    let (_, labels) = kmeans_centroids_seeded(&z, k, KMEANS_ITER, seed);
549    let mut slot = vec![usize::MAX; k];
550    let mut groups: Vec<Vec<usize>> = Vec::new();
551    for (&r, &l) in rows.iter().zip(&labels) {
552        if slot[l] == usize::MAX {
553            slot[l] = groups.len();
554            groups.push(Vec::new());
555        }
556        groups[slot[l]].push(r);
557    }
558    groups
559}
560
561/// The informative groups of `rows` within a budget of `n_groups` (one slot
562/// kept for the background when it has members): the plain k-means, then,
563/// with `min_size > 1`, scattered groups set aside and the freed slots spent
564/// splitting the largest groups. Rows in no returned group are background.
565fn informative_groups(
566    counts: &DMatrix<f32>,
567    sizes: &[f32],
568    rows: &[usize],
569    informative: &[bool],
570    n_groups: usize,
571    seed: u64,
572    min_size: usize,
573) -> Vec<Vec<usize>> {
574    let inf: Vec<usize> = rows.iter().copied().filter(|&r| informative[r]).collect();
575    let has_empty = inf.len() < rows.len();
576    let slots = n_groups.max(1).saturating_sub(usize::from(has_empty));
577    let groups = kmeans_groups(counts, sizes, &inf, slots, seed);
578    if min_size <= 1 {
579        return groups;
580    }
581    let (mut groups, scattered): (Vec<Vec<usize>>, Vec<Vec<usize>>) =
582        groups.into_iter().partition(|g| g.len() >= min_size);
583    let has_bg = has_empty || !scattered.is_empty();
584    let mut free = n_groups.saturating_sub(groups.len() + usize::from(has_bg));
585    let mut heap: std::collections::BinaryHeap<(usize, usize)> = groups
586        .iter()
587        .enumerate()
588        .map(|(i, g)| (g.len(), i))
589        .collect();
590    while free > 0 {
591        let Some((len, i)) = heap.pop() else { break };
592        if len < 2 * min_size {
593            break;
594        }
595        let mut halves = kmeans_groups(
596            counts,
597            sizes,
598            &groups[i],
599            2,
600            mix_seed(seed, SPLIT_SEED_SALT + i as u64),
601        );
602        if halves.len() == 2 && halves.iter().all(|h| h.len() >= min_size) {
603            let b = halves.pop().expect("two halves");
604            groups[i] = halves.pop().expect("two halves");
605            heap.push((groups[i].len(), i));
606            heap.push((b.len(), groups.len()));
607            groups.push(b);
608            free -= 1;
609        }
610    }
611    groups
612}
613
614/// Proportional shares of `budget` slots over blocks with `weight` informative
615/// features each: at least one per block, the remainder to the blocks with the
616/// most features per slot. No block, no share.
617fn block_shares(weight: &[usize], budget: usize) -> Vec<usize> {
618    if weight.is_empty() {
619        return Vec::new();
620    }
621    let total: usize = weight.iter().sum();
622    let mut alloc: Vec<usize> = weight
623        .iter()
624        .map(|&c| (budget * c / total.max(1)).max(1))
625        .collect();
626    while alloc.iter().sum::<usize>() > budget {
627        let i = (0..alloc.len())
628            .max_by_key(|&i| alloc[i])
629            .expect("non-empty");
630        alloc[i] -= 1;
631    }
632    while alloc.iter().sum::<usize>() < budget {
633        let i = (0..alloc.len())
634            .max_by(|&a, &b| (weight[a] * alloc[b]).cmp(&(weight[b] * alloc[a])))
635            .expect("non-empty");
636        alloc[i] += 1;
637    }
638    alloc
639}
640
641/// A single-level partition of `counts` `[D × S]` into at most `n_groups`
642/// groups, the background counted: [`coarsen_features`] at one level, with
643/// the [`PartitionOptions`] applied and the background id and informative
644/// flags returned. Without options the labels are exactly the single-level
645/// coarsening's.
646pub fn partition_features(
647    counts: &DMatrix<f32>,
648    sizes: &[f32],
649    n_groups: usize,
650    seed: u64,
651    opts: &PartitionOptions,
652) -> anyhow::Result<FeaturePartition> {
653    let d = counts.nrows();
654    anyhow::ensure!(
655        counts.ncols() == sizes.len(),
656        "{} count columns for {} pseudobulk sizes",
657        counts.ncols(),
658        sizes.len()
659    );
660    let informative = informative_features(counts, sizes);
661    let min_size = opts.min_group_size;
662    let mut rows_of = std::collections::BTreeMap::<u32, Vec<usize>>::new();
663    match &opts.block {
664        Some(block) => {
665            anyhow::ensure!(
666                block.len() == d,
667                "{} block ids for {d} features",
668                block.len()
669            );
670            for (r, &b) in block.iter().enumerate() {
671                rows_of.entry(b).or_default().push(r);
672            }
673        }
674        None => {
675            rows_of.insert(0, (0..d).collect());
676        }
677    }
678
679    let groups: Vec<Vec<usize>> = if rows_of.len() <= 1 {
680        let rows: Vec<usize> = (0..d).collect();
681        informative_groups(counts, sizes, &rows, &informative, n_groups, seed, min_size)
682    } else {
683        let n_inf = |rows: &[usize]| rows.iter().filter(|&&r| informative[r]).count();
684        let live: Vec<(u32, usize)> = rows_of
685            .iter()
686            .map(|(&b, rows)| (b, n_inf(rows)))
687            .filter(|&(_, c)| c > 0)
688            .collect();
689        let budget = n_groups.saturating_sub(1);
690        anyhow::ensure!(
691            live.len() <= budget,
692            "{} blocks hold informative features but only {budget} group slots: widen the blocks",
693            live.len()
694        );
695        let weight: Vec<usize> = live.iter().map(|&(_, c)| c).collect();
696        let alloc = block_shares(&weight, budget);
697        live.iter()
698            .zip(&alloc)
699            .flat_map(|(&(b, _), &k)| {
700                let rows = &rows_of[&b];
701                // One more slot when the block has near-empty rows: it is spent
702                // on their background, which joins the shared one.
703                let has_empty = rows.len() > n_inf(rows);
704                informative_groups(
705                    counts,
706                    sizes,
707                    rows,
708                    &informative,
709                    k + usize::from(has_empty),
710                    mix_seed(seed, u64::from(b)),
711                    min_size,
712                )
713            })
714            .collect()
715    };
716
717    // Labels: the groups in order, then the background.
718    let bg = groups.len();
719    let mut labels = vec![bg; d];
720    for (i, g) in groups.iter().enumerate() {
721        for &r in g {
722            labels[r] = i;
723        }
724    }
725    let n_bg = d - groups.iter().map(Vec::len).sum::<usize>();
726    let n_flat = informative.iter().filter(|&&i| !i).count();
727    let mut group_sizes: Vec<usize> = groups.iter().map(Vec::len).collect();
728    group_sizes.sort_unstable();
729    info!(
730        "feature partition: {bg} group(s) over {} block(s), sizes min {} / median {} / max {}; \
731         background {n_bg} ({n_flat} flat, {} scattered)",
732        rows_of.len(),
733        group_sizes.first().copied().unwrap_or(0),
734        group_sizes.get(group_sizes.len() / 2).copied().unwrap_or(0),
735        group_sizes.last().copied().unwrap_or(0),
736        n_bg - n_flat,
737    );
738    Ok(FeaturePartition {
739        labels,
740        num_groups: bg + usize::from(n_bg > 0),
741        background: (n_bg > 0).then_some(bg),
742        informative,
743    })
744}
745
746/// Shared CLI arg for grouping co-expressed features before training.
747///
748/// One declaration, one wording, one default, flattened by every command that
749/// can train at reduced feature resolution — the same shape as
750/// `crate::alg::hvg::HvgCliArgs`, and read alongside it: selection decides which
751/// features weigh on the sketch, this decides how many distinct outputs the
752/// model answers for.
753#[derive(Args, Debug, Clone, Serialize, Deserialize)]
754#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
755pub struct FeatureCoarseningArgs {
756    #[arg(
757        long,
758        default_value_t = 1000,
759        value_name = "N",
760        help = "Group co-expressed features into at most N coarse features; 0 = every feature",
761        long_help = "Group co-expressed features into at most N coarse features,\n\
762                     so the model answers for a group instead of for each feature.\n\
763                     Groups come from the finest pseudobulk profiles, nested per\n\
764                     level with log-spaced widths. The dictionary is expanded back\n\
765                     to every feature on output, so what you read is unchanged.\n\
766                     \n\
767                     --no-feature-coarsening trains on every feature instead. So\n\
768                     does 0 here, kept because recorded runs and existing scripts\n\
769                     spell it that way.\n\
770                     \n\
771                     WHAT THE GROUPING APPLIES TO DEPENDS ON THE COMMAND:\n\
772                     \n\
773                     `topic` and `vae` group both sides. The encoder reads coarse\n\
774                     features and every decoder answers for them, so 0 makes each\n\
775                     per-step tensor as wide as the feature axis.\n\
776                     \n\
777                     `masked-topic`, `masked-vae` and `masked-sbp` group the decoder\n\
778                     targets only. The encoder keeps its feature-level context and\n\
779                     embedding either way.\n\
780                     \n\
781                     `joint-topic` groups per modality, or on the reference modality\n\
782                     and shares it, following --decoder-type.\n\
783                     \n\
784                     This is a COARSENING, not a module. senna tells the two kinds\n\
785                     of grouping apart by one question: does training move the\n\
786                     membership? A coarsening's does not. It is read off the data\n\
787                     before the first step and fixed for the life of the model,\n\
788                     which is what lets every decoder be keyed to it and what lets a\n\
789                     continued fit inherit it. A module's does: --gene-modules learns\n\
790                     its centroids, so which features group together changes as the\n\
791                     fit proceeds.\n\
792                     \n\
793                     What a coarsening fixes is the assignment, not the meaning. A\n\
794                     group's embedding is the mean of its members' and moves at every\n\
795                     step, so grouping the targets does not freeze what the model can\n\
796                     learn about a feature."
797    )]
798    pub max_coarse_features: usize,
799
800    #[arg(
801        long,
802        conflicts_with = "max_coarse_features",
803        help = "Train on every feature, with no grouping",
804        long_help = "Train on every feature. The named form of\n\
805                     --max-coarse-features 0, which reads as a request for zero\n\
806                     features when it means the opposite.\n\
807                     \n\
808                     Every per-step tensor is then as wide as the feature axis, so\n\
809                     the fit costs considerably more at the same epoch count. It is\n\
810                     also fixed for the life of a model: a continued fit inherits\n\
811                     the groups the source run trained on, so a chain that starts\n\
812                     ungrouped stays ungrouped and one that starts grouped cannot\n\
813                     be switched over partway."
814    )]
815    pub no_feature_coarsening: bool,
816}
817
818impl FeatureCoarseningArgs {
819    /// The cap, or `None` when the model trains on every feature.
820    ///
821    /// The one place either spelling of "off" is read, so no command repeats
822    /// the convention: the named switch and the zero mean the same thing here
823    /// and clap refuses both at once.
824    #[must_use]
825    pub fn cap(&self) -> Option<std::num::NonZeroUsize> {
826        if self.no_feature_coarsening {
827            return None;
828        }
829        std::num::NonZeroUsize::new(self.max_coarse_features)
830    }
831}
832
833#[cfg(test)]
834#[path = "feature_coarsening_tests.rs"]
835mod tests;