Skip to main content

data_beans/alg/collapse_data/
pb_tree.rs

1//! The pseudobulk tree: grow the finest partition from the top nodes by
2//! residual bisection to a leaf target per level.
3//!
4//! The marginal hash (`binary_sort_columns`) signs the leading components of
5//! a random sketch of the counts. Those components follow the mass shared
6//! within a lineage, so the low `coarse_bits` bits name a lineage node while the
7//! remaining high bits split that node along directions that are still
8//! mass-driven. A within-node program of modest fold change over a small
9//! share of the counts rarely flips such a bit, and the leaves then mix the
10//! states the program separates.
11//!
12//! Here the high bits are rewritten per node. Every cell is standardised
13//! against its node's profile (Pearson residuals, per batch), the leading
14//! residual component is signed into a left and a right side, residuals are
15//! recomputed inside each side against the side's own profile, and the
16//! bisection recurses to the requested depth. Every split keeps what
17//! differentiates its branches: the loadings of the component that defined
18//! it, the weighted left-versus-right log fold change per gene, a strength
19//! verdict against the Marchenko-Pastur noise edge, and a two-group Poisson
20//! likelihood ratio. The low bits are preserved exactly and the bit layout
21//! is unchanged, so every downstream level mask still yields a nested
22//! partition.
23
24use super::*;
25use crate::alg::nb_dispersion::DispersionTrend;
26use nalgebra_sparse::CscMatrix;
27use rand::rngs::SmallRng;
28use rand::{RngExt, SeedableRng};
29use serde::{Deserialize, Serialize};
30
31////////////////
32// Parameters //
33////////////////
34
35/// Seed for the landmark draw on large nodes.
36pub const DEFAULT_RESIDUAL_BITS_SEED: u64 = 0x5245_5349_4442_4954;
37
38/// Genes (by total residual variance) that enter the variance-explained
39/// summary of a partition.
40const VE_TOP_GENES: usize = 200;
41
42/// What to do with a split whose leading residual component sits at the
43/// noise edge.
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
45pub enum BelowEdge {
46    /// Apply the sign split anyway and flag it. Keeps the leaf count of the
47    /// marginal code, where every split is applied.
48    Keep,
49    /// Leave the sub-node as a leaf.
50    Stop,
51}
52
53#[derive(Clone, Debug)]
54pub struct PbTreeParams {
55    /// A sub-node with fewer cells is a leaf.
56    pub min_cells_to_split: usize,
57    /// A gene enters a node's residual only if its count over the node's
58    /// cells reaches this floor.
59    pub min_node_gene_count: f32,
60    /// Genes kept per node, by residual variance.
61    pub max_genes: usize,
62    /// A split carries signal when its leading singular value exceeds the
63    /// noise edge by this relative margin.
64    pub edge_margin: f32,
65    pub below_edge: BelowEdge,
66    /// Above this many cells the component is fitted on landmarks.
67    pub max_cells_exact: usize,
68    pub num_landmarks: usize,
69    /// Contrast genes stored per side of a split.
70    pub top_contrast_genes: usize,
71    pub seed: u64,
72    /// Reassign cells between the top nodes by likelihood before the
73    /// growth (see `reassign_cells`). `None` keeps the marginal nodes.
74    pub reassign_cells: Option<super::ReassignCellsParams>,
75}
76
77impl Default for PbTreeParams {
78    fn default() -> Self {
79        Self {
80            min_cells_to_split: 8,
81            min_node_gene_count: 5.0,
82            max_genes: 2000,
83            edge_margin: 0.10,
84            below_edge: BelowEdge::Keep,
85            max_cells_exact: 512,
86            num_landmarks: 512,
87            top_contrast_genes: 20,
88            seed: DEFAULT_RESIDUAL_BITS_SEED,
89            reassign_cells: None,
90        }
91    }
92}
93
94/////////////
95// Records //
96/////////////
97
98/// One gene's role in a split contrast. `lfc` is right over left.
99#[derive(Clone, Debug, Serialize, Deserialize)]
100pub struct ContrastGene {
101    pub gene: usize,
102    pub loading: f32,
103    pub lfc: f32,
104    pub weight: f32,
105}
106
107#[derive(Clone, Debug, Serialize, Deserialize)]
108pub struct SplitRecord {
109    /// The root (top node) this split lives under.
110    pub root: usize,
111    /// 1-based depth below the node.
112    pub depth: usize,
113    /// High bits fixed above this split, i.e. the branch prefix.
114    pub path: usize,
115    pub n_cells: usize,
116    pub n_left: usize,
117    pub n_right: usize,
118    pub n_genes: usize,
119    pub n_left_per_batch: Vec<usize>,
120    pub n_right_per_batch: Vec<usize>,
121    pub s1: f32,
122    pub s2: f32,
123    pub sigma: f32,
124    pub mp_edge: f32,
125    pub passes_edge: bool,
126    /// Two-group Poisson log-likelihood ratio of the split against the
127    /// merged node, over the node's genes.
128    pub llr_split: f64,
129    /// Residual variance explained by the split over the random expectation.
130    pub ve_ratio: f32,
131    /// Whether the split was written into the codes.
132    pub applied: bool,
133    /// Genes the loadings refer to (not serialised).
134    #[serde(skip)]
135    pub genes: Vec<usize>,
136    /// Loadings of the defining component over `genes` (not serialised).
137    #[serde(skip)]
138    pub loadings: Vec<f32>,
139    /// Genes up on the right side.
140    pub pos: Vec<ContrastGene>,
141    /// Genes up on the left side.
142    pub neg: Vec<ContrastGene>,
143}
144
145#[derive(Clone, Debug, Serialize, Deserialize)]
146pub struct RootRecord {
147    pub root: usize,
148    pub n_cells: usize,
149    pub n_genes: usize,
150    /// Residual variance explained by the residual leaves over the random
151    /// expectation, on the node-level residual.
152    pub ve_ratio_residual: f32,
153    /// The same statistic for the marginal high bits of the same cells.
154    pub ve_ratio_marginal: f32,
155    pub splits: Vec<SplitRecord>,
156}
157
158#[derive(Clone, Debug, Default, Serialize, Deserialize)]
159pub struct PbTree {
160    /// Width of the coarsest level's field in the packed codes.
161    pub coarse_bits: usize,
162    pub depth: usize,
163    pub num_cells: usize,
164    pub num_batches: usize,
165    pub edge_margin: f32,
166    /// Cells moved between top nodes by the reassignment pass before the
167    /// growth (0 when the pass is off).
168    pub reassigned_cells: usize,
169    pub roots: Vec<RootRecord>,
170    /// Finest leaf code to the finest pb ids holding its cells, filled after
171    /// refinement.
172    pub leaf_to_finest_pb: Vec<(usize, Vec<usize>)>,
173}
174
175//////////////////////
176// Node-level input //
177//////////////////////
178
179/// One coarse node's cells: counts, batch and depth in the node's local
180/// column order.
181pub(crate) struct NodeBlock {
182    /// genes x cells
183    pub csc: CscMatrix<f32>,
184    pub batch: Vec<usize>,
185    pub depth: Vec<f32>,
186    pub num_batches: usize,
187}
188
189impl NodeBlock {
190    pub(crate) fn new(csc: CscMatrix<f32>, batch: Vec<usize>, num_batches: usize) -> Self {
191        let depth: Vec<f32> = (0..csc.ncols())
192            .map(|j| csc.col(j).values().iter().sum())
193            .collect();
194        Self {
195            csc,
196            batch,
197            depth,
198            num_batches: num_batches.max(1),
199        }
200    }
201
202    fn ngenes(&self) -> usize {
203        self.csc.nrows()
204    }
205}
206
207/// Fewest cells a parallel fold over one node takes per task. Each task
208/// carries its own dense per-gene accumulators (all genes, per batch), so
209/// splitting a small leaf down to a handful of cells per task spends far
210/// more time zeroing and merging those vectors than summing counts.
211const FOLD_MIN_CELLS: usize = 256;
212
213/// Per-batch gene rates of a (sub)node, with a pooled fallback for batches
214/// that hold too few cells to carry their own profile.
215pub(crate) struct NodeProfiles {
216    pub pooled: Vec<f64>,
217    per_batch: Vec<Vec<f64>>,
218    pub batch_depth: Vec<f64>,
219    pub node_sum: Vec<f64>,
220    node_sum_sq: Vec<f64>,
221    pub total_depth: f64,
222    pub num_cells: usize,
223}
224
225impl NodeProfiles {
226    pub(crate) fn new(block: &NodeBlock, local: &[usize], min_cells: usize) -> Self {
227        let d = block.ngenes();
228        let nb = block.num_batches;
229        struct Acc {
230            sum_b: Vec<Vec<f64>>,
231            depth_b: Vec<f64>,
232            cells_b: Vec<usize>,
233            sum_sq: Vec<f64>,
234        }
235        let fresh = || Acc {
236            sum_b: vec![vec![0f64; d]; nb],
237            depth_b: vec![0f64; nb],
238            cells_b: vec![0usize; nb],
239            sum_sq: vec![0f64; d],
240        };
241        let acc = local
242            .par_iter()
243            .with_min_len(FOLD_MIN_CELLS)
244            .fold(fresh, |mut a, &c| {
245                let b = block.batch[c];
246                let col = block.csc.col(c);
247                for (&g, &v) in col.row_indices().iter().zip(col.values()) {
248                    a.sum_b[b][g] += f64::from(v);
249                    a.sum_sq[g] += f64::from(v) * f64::from(v);
250                }
251                a.depth_b[b] += f64::from(block.depth[c]);
252                a.cells_b[b] += 1;
253                a
254            })
255            .reduce(fresh, |mut a, o| {
256                for (x, y) in a.sum_b.iter_mut().zip(&o.sum_b) {
257                    for (p, q) in x.iter_mut().zip(y) {
258                        *p += q;
259                    }
260                }
261                for (x, y) in a.depth_b.iter_mut().zip(&o.depth_b) {
262                    *x += y;
263                }
264                for (x, y) in a.cells_b.iter_mut().zip(&o.cells_b) {
265                    *x += y;
266                }
267                for (x, y) in a.sum_sq.iter_mut().zip(&o.sum_sq) {
268                    *x += y;
269                }
270                a
271            });
272        let Acc {
273            sum_b,
274            depth_b,
275            cells_b,
276            sum_sq: node_sum_sq,
277        } = acc;
278        let node_sum: Vec<f64> = (0..d).map(|g| sum_b.iter().map(|s| s[g]).sum()).collect();
279        let total_depth: f64 = depth_b.iter().sum();
280        let pooled: Vec<f64> = node_sum
281            .iter()
282            .map(|&s| {
283                if total_depth > 0.0 {
284                    s / total_depth
285                } else {
286                    0.0
287                }
288            })
289            .collect();
290        let per_batch: Vec<Vec<f64>> = (0..nb)
291            .map(|b| {
292                if cells_b[b] >= min_cells && depth_b[b] > 0.0 {
293                    sum_b[b].iter().map(|&s| s / depth_b[b]).collect()
294                } else {
295                    pooled.clone()
296                }
297            })
298            .collect();
299        Self {
300            pooled,
301            per_batch,
302            batch_depth: depth_b,
303            node_sum,
304            node_sum_sq,
305            total_depth,
306            num_cells: local.len(),
307        }
308    }
309
310    #[inline]
311    fn rate(&self, b: usize, g: usize) -> f64 {
312        self.per_batch[b][g]
313    }
314}
315
316#[inline]
317fn clipped(x: f64, clip: f32) -> f32 {
318    let c = f64::from(clip);
319    x.clamp(-c, c) as f32
320}
321
322/// Mean squared Pearson residual per gene over `local`, against `prof`.
323/// Nonzero counts are clipped; zero counts contribute their exact
324/// expectation.
325pub(crate) fn residual_variance(
326    block: &NodeBlock,
327    local: &[usize],
328    prof: &NodeProfiles,
329    clip: f32,
330) -> Vec<f64> {
331    let d = block.ngenes();
332    let nb = block.num_batches;
333    let fresh = || (vec![0f64; d], vec![vec![0f64; d]; nb]);
334    let (sq, nz_depth) = local
335        .par_iter()
336        .with_min_len(FOLD_MIN_CELLS)
337        .fold(fresh, |(mut sq, mut nz), &c| {
338            let b = block.batch[c];
339            let n_c = f64::from(block.depth[c]);
340            let col = block.csc.col(c);
341            for (&g, &v) in col.row_indices().iter().zip(col.values()) {
342                let mu = n_c * prof.rate(b, g);
343                if mu > 0.0 {
344                    let r = f64::from(clipped((f64::from(v) - mu) / mu.sqrt(), clip));
345                    sq[g] += r * r;
346                    nz[b][g] += n_c;
347                }
348            }
349            (sq, nz)
350        })
351        .reduce(fresh, |(mut sq, mut nz), (sq2, nz2)| {
352            for (x, y) in sq.iter_mut().zip(&sq2) {
353                *x += y;
354            }
355            for (a, b) in nz.iter_mut().zip(&nz2) {
356                for (x, y) in a.iter_mut().zip(b) {
357                    *x += y;
358                }
359            }
360            (sq, nz)
361        });
362    let n = local.len().max(1) as f64;
363    (0..d)
364        .map(|g| {
365            let zeros: f64 = (0..nb)
366                .map(|b| prof.rate(b, g) * (prof.batch_depth[b] - nz_depth[b][g]).max(0.0))
367                .sum();
368            (sq[g] + zeros) / n
369        })
370        .collect()
371}
372
373/// Dense Pearson residuals of `rows` over `genes` against `prof`.
374pub(crate) fn residual_dense(
375    block: &NodeBlock,
376    rows: &[usize],
377    genes: &[usize],
378    prof: &NodeProfiles,
379    clip: f32,
380) -> DMatrix<f32> {
381    let d = block.ngenes();
382    let mut gidx = vec![usize::MAX; d];
383    for (j, &g) in genes.iter().enumerate() {
384        gidx[g] = j;
385    }
386    let p = genes.len();
387    let flat: Vec<Vec<f32>> = rows
388        .par_iter()
389        .map(|&c| {
390            let b = block.batch[c];
391            let n_c = f64::from(block.depth[c]);
392            let mut row = vec![0f32; p];
393            for (j, &g) in genes.iter().enumerate() {
394                let mu = n_c * prof.rate(b, g);
395                row[j] = if mu > 0.0 {
396                    clipped(-mu.sqrt(), clip)
397                } else {
398                    0.0
399                };
400            }
401            let col = block.csc.col(c);
402            for (&g, &v) in col.row_indices().iter().zip(col.values()) {
403                let j = gidx[g];
404                if j == usize::MAX {
405                    continue;
406                }
407                let mu = n_c * prof.rate(b, g);
408                row[j] = if mu > 0.0 {
409                    clipped((f64::from(v) - mu) / mu.sqrt(), clip)
410                } else {
411                    0.0
412                };
413            }
414            row
415        })
416        .collect();
417    DMatrix::<f32>::from_fn(rows.len(), p, |i, j| flat[i][j])
418}
419
420/// Projection of every cell's residual row onto `v1`, without forming the
421/// dense residual: the zero entries of a cell are handled in closed form.
422fn residual_scores(
423    block: &NodeBlock,
424    local: &[usize],
425    genes: &[usize],
426    prof: &NodeProfiles,
427    v1: &[f32],
428    clip: f32,
429) -> Vec<f32> {
430    let d = block.ngenes();
431    let nb = block.num_batches;
432    let mut gidx = vec![usize::MAX; d];
433    for (j, &g) in genes.iter().enumerate() {
434        gidx[g] = j;
435    }
436    let s_b: Vec<f64> = (0..nb)
437        .map(|b| {
438            genes
439                .iter()
440                .enumerate()
441                .map(|(j, &g)| prof.rate(b, g).sqrt() * f64::from(v1[j]))
442                .sum()
443        })
444        .collect();
445    local
446        .par_iter()
447        .map(|&c| {
448            let b = block.batch[c];
449            let n_c = f64::from(block.depth[c]);
450            let col = block.csc.col(c);
451            let mut acc = 0f64;
452            for (&g, &v) in col.row_indices().iter().zip(col.values()) {
453                let j = gidx[g];
454                if j == usize::MAX {
455                    continue;
456                }
457                let mu = n_c * prof.rate(b, g);
458                if mu > 0.0 {
459                    let r = f64::from(clipped((f64::from(v) - mu) / mu.sqrt(), clip));
460                    acc += (r + mu.sqrt()) * f64::from(v1[j]);
461                }
462            }
463            (acc - n_c.sqrt() * s_b[b]) as f32
464        })
465        .collect()
466}
467
468//////////////////////
469// Split statistics //
470//////////////////////
471
472/// Largest singular value of an iid noise matrix of the same shape.
473pub(crate) fn mp_edge(n: usize, p: usize, sigma2: f64) -> f32 {
474    (sigma2.sqrt() * ((n as f64).sqrt() + (p as f64).sqrt())) as f32
475}
476
477/// Two-group Poisson profile log-likelihood ratio of splitting into `sum_l`
478/// and `sum_r` against the merged composition.
479pub(crate) fn two_group_llr(sum_l: &[f64], sum_r: &[f64]) -> f64 {
480    let nl: f64 = sum_l.iter().sum();
481    let nr: f64 = sum_r.iter().sum();
482    if nl <= 0.0 || nr <= 0.0 {
483        return 0.0;
484    }
485    let f = |y: f64, n: f64| if y > 0.0 { y * (y / n).ln() } else { 0.0 };
486    sum_l
487        .iter()
488        .zip(sum_r)
489        .map(|(&l, &r)| f(l, nl) + f(r, nr) - f(l + r, nl + nr))
490        .sum()
491}
492
493/// Variance explained by a partition of the rows of `r`, over the random
494/// expectation `(groups - 1) / (rows - 1)`, averaged across the `top_q`
495/// columns with the largest total variance. 1.0 for a trivial partition.
496pub(crate) fn ve_ratio(r: &DMatrix<f32>, labels: &[usize], top_q: usize) -> f32 {
497    let n = r.nrows();
498    if n < 2 {
499        return 1.0;
500    }
501    let (compact, k) = crate::alg::dc_poisson::compact_labels(labels);
502    if k < 2 {
503        return 1.0;
504    }
505    let expected = (k - 1) as f64 / (n - 1) as f64;
506    let mut counts = vec![0f64; k];
507    for &z in &compact {
508        counts[z] += 1.0;
509    }
510    let mut per_gene: Vec<(f64, f64)> = (0..r.ncols())
511        .into_par_iter()
512        .map(|j| {
513            let col = r.column(j);
514            let mean = col.iter().map(|&x| f64::from(x)).sum::<f64>() / n as f64;
515            let ss_total: f64 = col.iter().map(|&x| (f64::from(x) - mean).powi(2)).sum();
516            let mut gsum = vec![0f64; k];
517            for (i, &x) in col.iter().enumerate() {
518                gsum[compact[i]] += f64::from(x);
519            }
520            let ss_between: f64 = (0..k)
521                .filter(|&z| counts[z] > 0.0)
522                .map(|z| counts[z] * (gsum[z] / counts[z] - mean).powi(2))
523                .sum();
524            (ss_total, ss_between)
525        })
526        .collect();
527    per_gene.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
528    let top: Vec<f64> = per_gene
529        .iter()
530        .take(top_q)
531        .filter(|(t, _)| *t > 0.0)
532        .map(|(t, b)| b / t / expected)
533        .collect();
534    if top.is_empty() {
535        1.0
536    } else {
537        (top.iter().sum::<f64>() / top.len() as f64) as f32
538    }
539}
540
541fn median(v: &mut [f64]) -> f64 {
542    if v.is_empty() {
543        return 0.0;
544    }
545    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
546    let m = v.len() / 2;
547    if v.len() % 2 == 1 {
548        v[m]
549    } else {
550        0.5 * (v[m - 1] + v[m])
551    }
552}
553
554/// Leading component of `r` (rows x columns): loadings over columns
555/// oriented so the largest magnitude loading is positive, plus the top two
556/// singular values, from the crate-wide randomised SVD.
557fn top_component(r: &DMatrix<f32>) -> Option<(Vec<f32>, f32, f32)> {
558    if r.nrows() < 2 || r.ncols() < 2 {
559        return None;
560    }
561    let (_, s, v) = r.rsvd(2).ok()?;
562    if s.is_empty() || v.ncols() == 0 {
563        return None;
564    }
565    let mut order: Vec<usize> = (0..s.len()).collect();
566    order.sort_by(|&a, &b| s[b].partial_cmp(&s[a]).unwrap_or(std::cmp::Ordering::Equal));
567    let s1 = s[order[0]];
568    let s2 = order.get(1).map_or(0.0, |&i| s[i]);
569    if !s1.is_finite() || s1 <= 0.0 {
570        return None;
571    }
572    let mut v1: Vec<f32> = v.column(order[0]).iter().copied().collect();
573    let argmax = v1
574        .iter()
575        .enumerate()
576        .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap())
577        .map_or(0, |(i, _)| i);
578    if v1[argmax] < 0.0 {
579        v1.iter_mut().for_each(|x| *x = -*x);
580    }
581    Some((v1, s1, s2))
582}
583
584///////////////////
585// Node handling //
586///////////////////
587
588struct SubNode {
589    prof: NodeProfiles,
590    genes: Vec<usize>,
591    sigma2: f64,
592}
593
594/// Profile of `local`, gene selection by residual variance, and the robust
595/// noise level of the selected residuals.
596fn prepare(block: &NodeBlock, local: &[usize], params: &PbTreeParams) -> SubNode {
597    let prof = NodeProfiles::new(block, local, params.min_cells_to_split);
598    let clip = (local.len() as f32).sqrt();
599    let var = residual_variance(block, local, &prof, clip);
600    let mut cand: Vec<(usize, f64)> = (0..block.ngenes())
601        .filter(|&g| prof.node_sum[g] >= f64::from(params.min_node_gene_count))
602        .map(|g| (g, var[g]))
603        .collect();
604    cand.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
605    cand.truncate(params.max_genes);
606    cand.sort_by_key(|x| x.0);
607    let mut vars: Vec<f64> = cand.iter().map(|x| x.1).collect();
608    let sigma2 = median(&mut vars).max(f64::EPSILON);
609    SubNode {
610        prof,
611        genes: cand.into_iter().map(|x| x.0).collect(),
612        sigma2,
613    }
614}
615
616/// Rows the component is fitted on: every cell up to the exact cap, else a
617/// seeded landmark subset.
618fn svd_rows(local: &[usize], params: &PbTreeParams, seed: u64) -> Vec<usize> {
619    if local.len() <= params.max_cells_exact || params.num_landmarks >= local.len() {
620        return local.to_vec();
621    }
622    let mut rng = SmallRng::seed_from_u64(seed);
623    let mut pool = local.to_vec();
624    let k = params.num_landmarks.max(2);
625    for i in 0..k {
626        let j = rng.random_range(i..pool.len());
627        pool.swap(i, j);
628    }
629    pool.truncate(k);
630    pool
631}
632
633fn side_sums(block: &NodeBlock, cells: &[usize], genes: &[usize]) -> Vec<f64> {
634    let d = block.ngenes();
635    let mut gidx = vec![usize::MAX; d];
636    for (j, &g) in genes.iter().enumerate() {
637        gidx[g] = j;
638    }
639    cells
640        .par_iter()
641        .with_min_len(FOLD_MIN_CELLS)
642        .fold(
643            || vec![0f64; genes.len()],
644            |mut acc, &c| {
645                let col = block.csc.col(c);
646                for (&g, &v) in col.row_indices().iter().zip(col.values()) {
647                    let j = gidx[g];
648                    if j != usize::MAX {
649                        acc[j] += f64::from(v);
650                    }
651                }
652                acc
653            },
654        )
655        .reduce(
656            || vec![0f64; genes.len()],
657            |mut a, b| {
658                for (x, y) in a.iter_mut().zip(b) {
659                    *x += y;
660                }
661                a
662            },
663        )
664}
665
666/// Contrast genes of a split: the strongest loadings on either side with
667/// their post-split log fold change (right over left) and a dispersion-trend
668/// weight fitted on the node's own counts.
669fn contrast(
670    sub: &SubNode,
671    loadings: &[f32],
672    sum_l: &[f64],
673    sum_r: &[f64],
674    k: usize,
675) -> (Vec<ContrastGene>, Vec<ContrastGene>) {
676    let n = sub.prof.num_cells.max(1) as f64;
677    let means: Vec<f32> = sub
678        .genes
679        .iter()
680        .map(|&g| (sub.prof.node_sum[g] / n) as f32)
681        .collect();
682    let vars: Vec<f32> = sub
683        .genes
684        .iter()
685        .zip(&means)
686        .map(|(&g, &m)| (sub.prof.node_sum_sq[g] / n - f64::from(m) * f64::from(m)).max(0.0) as f32)
687        .collect();
688    let trend = DispersionTrend::fit(&means, &vars);
689    let total: f64 = sub
690        .genes
691        .iter()
692        .map(|&g| sub.prof.node_sum[g])
693        .sum::<f64>()
694        .max(1.0);
695    let avg_s = (sub.prof.total_depth / n) as f32;
696    let nl: f64 = sum_l.iter().sum::<f64>().max(1.0);
697    let nr: f64 = sum_r.iter().sum::<f64>().max(1.0);
698    let make = |j: usize| ContrastGene {
699        gene: sub.genes[j],
700        loading: loadings[j],
701        lfc: (((sum_r[j] + 0.5) / nr).ln() - ((sum_l[j] + 0.5) / nl).ln()) as f32,
702        weight: trend.fisher_weight(
703            (sub.prof.node_sum[sub.genes[j]] / total) as f32,
704            avg_s,
705            means[j],
706        ),
707    };
708    let mut order: Vec<usize> = (0..loadings.len()).collect();
709    order.sort_by(|&a, &b| {
710        loadings[b]
711            .partial_cmp(&loadings[a])
712            .unwrap_or(std::cmp::Ordering::Equal)
713    });
714    let pos: Vec<ContrastGene> = order
715        .iter()
716        .take(k)
717        .filter(|&&j| loadings[j] > 0.0)
718        .map(|&j| make(j))
719        .collect();
720    let neg: Vec<ContrastGene> = order
721        .iter()
722        .rev()
723        .take(k)
724        .filter(|&&j| loadings[j] < 0.0)
725        .map(|&j| make(j))
726        .collect();
727    (pos, neg)
728}
729
730//////////////////////////////
731// Frontier: target counts  //
732//////////////////////////////
733
734/// One root of the tree: a top-level node with its cells (global ids) and
735/// their counts in the same order.
736pub(crate) struct RootBlock {
737    pub root: usize,
738    pub cells: Vec<usize>,
739    pub block: NodeBlock,
740}
741
742/// A proposed split of one leaf, computed independently of every other leaf.
743struct Proposal {
744    left: Vec<usize>,
745    right: Vec<usize>,
746    record: SplitRecord,
747    /// Whether the split may be written: both sides populated and the
748    /// below-edge policy satisfied.
749    applicable: bool,
750    /// Ordering key for the budget: the two-group likelihood ratio.
751    gain: f64,
752}
753
754struct Leaf {
755    root_idx: usize,
756    /// Block-local cell indices.
757    cells: Vec<usize>,
758    depth: usize,
759    path: usize,
760    proposal: Option<Proposal>,
761}
762
763/// Split proposal for one leaf; `None` when the leaf is too small or has no
764/// usable genes or component.
765fn propose(root: &RootBlock, leaf: &Leaf, params: &PbTreeParams) -> Option<Proposal> {
766    let block = &root.block;
767    let local = &leaf.cells;
768    if local.len() < params.min_cells_to_split {
769        return None;
770    }
771    let sub = prepare(block, local, params);
772    if sub.genes.len() < 2 {
773        return None;
774    }
775    let clip = (local.len() as f32).sqrt();
776    let seed = params.seed
777        ^ (root.root as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
778        ^ ((leaf.depth as u64) << 48)
779        ^ (leaf.path as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F);
780    let rows = svd_rows(local, params, seed);
781    let r = residual_dense(block, &rows, &sub.genes, &sub.prof, clip);
782    let (v1, s1, s2) = top_component(&r)?;
783    let scores = residual_scores(block, local, &sub.genes, &sub.prof, &v1, clip);
784    let mut right: Vec<usize> = Vec::new();
785    let mut left: Vec<usize> = Vec::new();
786    for (&c, &s) in local.iter().zip(&scores) {
787        if s > 0.0 {
788            right.push(c);
789        } else {
790            left.push(c);
791        }
792    }
793    let edge = mp_edge(rows.len(), sub.genes.len(), sub.sigma2);
794    let min_side = (params.min_cells_to_split / 2).max(1);
795    let balanced = left.len().min(right.len()) >= min_side;
796    let passes = balanced && s1 > (1.0 + params.edge_margin) * edge;
797    let applicable = balanced && (passes || params.below_edge == BelowEdge::Keep);
798    let sum_l = side_sums(block, &left, &sub.genes);
799    let sum_r = side_sums(block, &right, &sub.genes);
800    let nb = block.num_batches;
801    let per_batch = |cells: &[usize]| {
802        let mut v = vec![0usize; nb];
803        for &c in cells {
804            v[block.batch[c]] += 1;
805        }
806        v
807    };
808    let mut side_of_local = vec![0usize; block.csc.ncols()];
809    for &c in &right {
810        side_of_local[c] = 1;
811    }
812    let row_side: Vec<usize> = rows.iter().map(|&c| side_of_local[c]).collect();
813    let llr = two_group_llr(&sum_l, &sum_r);
814    let (pos, neg) = contrast(&sub, &v1, &sum_l, &sum_r, params.top_contrast_genes);
815    let record = SplitRecord {
816        root: root.root,
817        depth: leaf.depth + 1,
818        path: leaf.path,
819        n_cells: local.len(),
820        n_left: left.len(),
821        n_right: right.len(),
822        n_genes: sub.genes.len(),
823        n_left_per_batch: per_batch(&left),
824        n_right_per_batch: per_batch(&right),
825        s1,
826        s2,
827        sigma: sub.sigma2.sqrt() as f32,
828        mp_edge: edge,
829        passes_edge: passes,
830        llr_split: llr,
831        ve_ratio: ve_ratio(&r, &row_side, VE_TOP_GENES),
832        applied: false,
833        genes: sub.genes,
834        loadings: v1,
835        pos,
836        neg,
837    };
838    Some(Proposal {
839        left,
840        right,
841        record,
842        applicable,
843        gain: llr,
844    })
845}
846
847/// The nested partitions produced by [`bisect_to_target`], coarse to fine,
848/// plus the recorded tree.
849pub(crate) struct TargetTree {
850    /// Per level (coarse first), a leaf label per global cell;
851    /// `usize::MAX` for cells that belong to no root.
852    pub labels_per_level: Vec<Vec<usize>>,
853    pub tree: PbTree,
854}
855
856/// Grow the tree from `roots` until each of `level_targets` (coarse to
857/// fine, leaf counts) is met, snapshotting the partition at every target.
858///
859/// Each round proposes a split for every leaf in parallel, then applies
860/// proposals in decreasing evidence until the next target is reached. A
861/// leaf below the cell floor, or one whose split fails the below-edge
862/// policy, stays a leaf; when no leaf can split, the remaining levels
863/// repeat the last partition. Deterministic given the seed, independent of
864/// the thread count.
865pub(crate) fn bisect_to_target(
866    roots: &[RootBlock],
867    num_cells: usize,
868    level_targets: &[usize],
869    params: &PbTreeParams,
870) -> TargetTree {
871    let mut leaves: Vec<Leaf> = roots
872        .iter()
873        .enumerate()
874        .map(|(i, r)| Leaf {
875            root_idx: i,
876            cells: (0..r.cells.len()).collect(),
877            depth: 0,
878            path: 0,
879            proposal: None,
880        })
881        .collect();
882    let mut label: Vec<usize> = vec![usize::MAX; num_cells];
883    for (i, r) in roots.iter().enumerate() {
884        for &c in &r.cells {
885            label[c] = i;
886        }
887    }
888    leaves.par_iter_mut().for_each(|leaf| {
889        leaf.proposal = propose(&roots[leaf.root_idx], leaf, params);
890    });
891    let mut splits: Vec<Vec<SplitRecord>> = vec![Vec::new(); roots.len()];
892    let mut levels: Vec<Vec<usize>> = Vec::with_capacity(level_targets.len());
893    let mut next = 0usize;
894    let mut alive: Vec<bool> = vec![true; leaves.len()];
895    // One bar over the whole growth: leaves reached against the finest target.
896    let final_target = level_targets.last().copied().unwrap_or(0) as u64;
897    let prog_bar = legume_numeric::matrix::progress::new_progress_bar(final_target)
898        .with_message("pb tree leaves");
899    prog_bar.set_position(leaves.len() as u64);
900
901    loop {
902        let n_leaves = alive.iter().filter(|&&a| a).count();
903        while next < level_targets.len() && n_leaves >= level_targets[next] {
904            levels.push(label.clone());
905            next += 1;
906        }
907        if next >= level_targets.len() {
908            break;
909        }
910        let mut cand: Vec<(f64, usize)> = leaves
911            .iter()
912            .enumerate()
913            .filter(|(i, l)| alive[*i] && l.proposal.as_ref().is_some_and(|p| p.applicable))
914            .map(|(i, l)| (l.proposal.as_ref().map_or(0.0, |p| p.gain), i))
915            .collect();
916        if cand.is_empty() {
917            while next < level_targets.len() {
918                levels.push(label.clone());
919                next += 1;
920            }
921            break;
922        }
923        cand.sort_by(|a, b| {
924            b.0.partial_cmp(&a.0)
925                .unwrap_or(std::cmp::Ordering::Equal)
926                .then(a.1.cmp(&b.1))
927        });
928        let budget = level_targets[next] - n_leaves;
929        prog_bar.set_message(format!(
930            "pb tree leaves, level {}/{} toward {}",
931            next + 1,
932            level_targets.len(),
933            level_targets[next]
934        ));
935        let mut fresh: Vec<usize> = Vec::new();
936        for &(_, i) in cand.iter().take(budget) {
937            let Leaf {
938                root_idx,
939                depth,
940                path,
941                proposal,
942                ..
943            } = std::mem::replace(
944                &mut leaves[i],
945                Leaf {
946                    root_idx: 0,
947                    cells: Vec::new(),
948                    depth: 0,
949                    path: 0,
950                    proposal: None,
951                },
952            );
953            let mut p = proposal.expect("candidate has a proposal");
954            p.record.applied = true;
955            splits[root_idx].push(p.record);
956            alive[i] = false;
957            let root = &roots[root_idx];
958            for (side, cells) in [(0usize, p.left), (1usize, p.right)] {
959                let id = leaves.len();
960                for &c in &cells {
961                    label[root.cells[c]] = id;
962                }
963                leaves.push(Leaf {
964                    root_idx,
965                    cells,
966                    depth: depth + 1,
967                    path: (path << 1) | side,
968                    proposal: None,
969                });
970                alive.push(true);
971                fresh.push(id);
972            }
973            prog_bar.inc(1);
974        }
975        let mut new_leaves: Vec<(usize, Leaf)> = fresh
976            .iter()
977            .map(|&id| {
978                (
979                    id,
980                    std::mem::replace(
981                        &mut leaves[id],
982                        Leaf {
983                            root_idx: 0,
984                            cells: Vec::new(),
985                            depth: 0,
986                            path: 0,
987                            proposal: None,
988                        },
989                    ),
990                )
991            })
992            .collect();
993        new_leaves.par_iter_mut().for_each(|(_, leaf)| {
994            leaf.proposal = propose(&roots[leaf.root_idx], leaf, params);
995        });
996        for (id, leaf) in new_leaves {
997            leaves[id] = leaf;
998        }
999    }
1000
1001    prog_bar.finish_and_clear();
1002
1003    // Proposals that were never applied are still worth reading.
1004    for (i, leaf) in leaves.iter_mut().enumerate() {
1005        if alive[i] {
1006            if let Some(p) = leaf.proposal.take() {
1007                splits[leaf.root_idx].push(p.record);
1008            }
1009        }
1010    }
1011    let max_depth = splits
1012        .iter()
1013        .flatten()
1014        .filter(|s| s.applied)
1015        .map(|s| s.depth)
1016        .max()
1017        .unwrap_or(0);
1018    let num_batches = roots.first().map_or(1, |r| r.block.num_batches);
1019    let nodes: Vec<RootRecord> = roots
1020        .iter()
1021        .zip(splits)
1022        .map(|(r, mut sp)| {
1023            sp.sort_by_key(|s| (s.depth, s.path));
1024            RootRecord {
1025                root: r.root,
1026                n_cells: r.cells.len(),
1027                n_genes: sp.first().map_or(0, |s| s.n_genes),
1028                ve_ratio_residual: 1.0,
1029                ve_ratio_marginal: 1.0,
1030                splits: sp,
1031            }
1032        })
1033        .collect();
1034    let tree = PbTree {
1035        coarse_bits: 0,
1036        depth: max_depth,
1037        num_cells,
1038        num_batches,
1039        edge_margin: params.edge_margin,
1040        reassigned_cells: 0,
1041        roots: nodes,
1042        leaf_to_finest_pb: Vec::new(),
1043    };
1044    TargetTree {
1045        labels_per_level: levels,
1046        tree,
1047    }
1048}
1049
1050/// Variance explained by two partitions of a root's cells on the root-level
1051/// residual: the tree's finest labels and a reference labelling (the
1052/// marginal code). Written into the node records.
1053pub(crate) fn node_ve_ratios(
1054    roots: &[RootBlock],
1055    finest: &[usize],
1056    reference: &[usize],
1057    params: &PbTreeParams,
1058    tree: &mut PbTree,
1059) {
1060    let ratios: Vec<(f32, f32)> = roots
1061        .par_iter()
1062        .map(|r| {
1063            if r.cells.len() < params.min_cells_to_split {
1064                return (1.0, 1.0);
1065            }
1066            let local: Vec<usize> = (0..r.cells.len()).collect();
1067            let sub = prepare(&r.block, &local, params);
1068            if sub.genes.len() < 2 {
1069                return (1.0, 1.0);
1070            }
1071            let rows = svd_rows(&local, params, params.seed ^ r.root as u64);
1072            let m = residual_dense(
1073                &r.block,
1074                &rows,
1075                &sub.genes,
1076                &sub.prof,
1077                (local.len() as f32).sqrt(),
1078            );
1079            let a: Vec<usize> = rows.iter().map(|&c| finest[r.cells[c]]).collect();
1080            let b: Vec<usize> = rows.iter().map(|&c| reference[r.cells[c]]).collect();
1081            (
1082                ve_ratio(&m, &a, VE_TOP_GENES),
1083                ve_ratio(&m, &b, VE_TOP_GENES),
1084            )
1085        })
1086        .collect();
1087    for (node, (res, marg)) in tree.roots.iter_mut().zip(ratios) {
1088        node.ve_ratio_residual = res;
1089        node.ve_ratio_marginal = marg;
1090    }
1091}
1092
1093/////////////////////
1094// Prefix packing  //
1095/////////////////////
1096
1097fn bits_for(k: usize) -> usize {
1098    (usize::BITS as usize - k.max(2).saturating_sub(1).leading_zeros() as usize).max(1)
1099}
1100
1101/// Pack nested partitions (coarse to fine) into one code per cell whose low
1102/// bits name the coarsest group and each further field the child index
1103/// within the parent, so masking the code to a level's width reproduces
1104/// that level's partition. Returns the codes and the level widths,
1105/// finest-first as the collapse expects. Cells labelled `usize::MAX` get 0.
1106pub(crate) fn pack_levels(levels: &[Vec<usize>]) -> (Vec<usize>, Vec<usize>) {
1107    let n = levels.first().map_or(0, Vec::len);
1108    let mut codes = vec![0usize; n];
1109    let mut widths: Vec<usize> = Vec::with_capacity(levels.len());
1110    let mut shift = 0usize;
1111    let mut parent_codes: Vec<usize> = vec![0; n];
1112    for (li, level) in levels.iter().enumerate() {
1113        let active: Vec<usize> = level
1114            .iter()
1115            .map(|&l| if l == usize::MAX { 0 } else { l })
1116            .collect();
1117        let field: Vec<usize>;
1118        let width: usize;
1119        if li == 0 {
1120            let (compact, k) = crate::alg::dc_poisson::compact_labels(&active);
1121            width = bits_for(k);
1122            field = compact;
1123        } else {
1124            // child index of each fine group within its parent code
1125            let mut child_index: HashMap<(usize, usize), usize> = HashMap::default();
1126            let mut children_of: HashMap<usize, usize> = HashMap::default();
1127            let mut idx = vec![0usize; n];
1128            for c in 0..n {
1129                let key = (parent_codes[c], active[c]);
1130                let next = children_of.entry(parent_codes[c]).or_insert(0);
1131                let e = child_index.entry(key).or_insert_with(|| {
1132                    let v = *next;
1133                    *next += 1;
1134                    v
1135                });
1136                idx[c] = *e;
1137            }
1138            let max_children = children_of.values().copied().max().unwrap_or(1);
1139            width = bits_for(max_children);
1140            field = idx;
1141        }
1142        for c in 0..n {
1143            codes[c] |= field[c] << shift;
1144        }
1145        shift += width;
1146        widths.push(shift);
1147        parent_codes.copy_from_slice(&codes);
1148    }
1149    widths.reverse();
1150    (codes, widths)
1151}
1152
1153/////////////////////////
1154// Driver over a store //
1155/////////////////////////
1156
1157/// Build the tree over `data_vec` from a top-level partition `node_of_cell`
1158/// (`usize::MAX` = not a root member) to the leaf targets, and return the
1159/// packed codes, their level widths (finest-first) and the tree.
1160///
1161/// `csc` holds every cell's counts (genes x cells, global column order), read
1162/// once by the caller. Each node's block is cut from it in memory: a node's
1163/// cells are scattered over the whole store, so reading per node decodes
1164/// every chunk once per node, and in parallel those reads thrash a slow disk.
1165pub(crate) fn build_tree(
1166    data_vec: &SparseIoVec,
1167    csc: &CscMatrix<f32>,
1168    node_of_cell: &[usize],
1169    reference: &[usize],
1170    level_targets: &[usize],
1171    params: &PbTreeParams,
1172) -> anyhow::Result<(Vec<usize>, Vec<usize>, PbTree)> {
1173    let n = data_vec.num_columns();
1174    anyhow::ensure!(
1175        node_of_cell.len() == n,
1176        "node labels for {} of {n} cells",
1177        node_of_cell.len()
1178    );
1179    anyhow::ensure!(csc.ncols() == n, "counts for {} of {n} cells", csc.ncols());
1180    let col_to_batch = data_vec.get_batch_membership(0..n);
1181    let num_batches = data_vec.num_batches().max(1);
1182    let mut by_node: HashMap<usize, Vec<usize>> = HashMap::default();
1183    for (c, &nd) in node_of_cell.iter().enumerate() {
1184        if nd != usize::MAX {
1185            by_node.entry(nd).or_default().push(c);
1186        }
1187    }
1188    let mut nodes: Vec<(usize, Vec<usize>)> = by_node.into_iter().collect();
1189    nodes.sort_by_key(|x| x.0);
1190    let roots: Vec<RootBlock> = nodes
1191        .into_par_iter()
1192        .map(|(root, cells)| {
1193            let block = select_columns(csc, &cells);
1194            let batch: Vec<usize> = cells.iter().map(|&c| col_to_batch[c]).collect();
1195            RootBlock {
1196                root,
1197                cells,
1198                block: NodeBlock::new(block, batch, num_batches),
1199            }
1200        })
1201        .collect();
1202    let mut out = bisect_to_target(&roots, n, level_targets, params);
1203    let finest = out
1204        .labels_per_level
1205        .last()
1206        .cloned()
1207        .unwrap_or_else(|| vec![usize::MAX; n]);
1208    node_ve_ratios(&roots, &finest, reference, params, &mut out.tree);
1209    let (codes, widths) = pack_levels(&out.labels_per_level);
1210    out.tree.coarse_bits = *widths.last().unwrap_or(&0);
1211    log_tree(&out.tree, level_targets, &out.labels_per_level);
1212    Ok((codes, widths, out.tree))
1213}
1214
1215/// Columns `cols` of `csc`, in that order.
1216pub(crate) fn select_columns(csc: &CscMatrix<f32>, cols: &[usize]) -> CscMatrix<f32> {
1217    let nnz: usize = cols.iter().map(|&c| csc.col(c).nnz()).sum();
1218    let mut offsets = Vec::with_capacity(cols.len() + 1);
1219    let mut rows = Vec::with_capacity(nnz);
1220    let mut vals = Vec::with_capacity(nnz);
1221    offsets.push(0);
1222    for &c in cols {
1223        let col = csc.col(c);
1224        rows.extend_from_slice(col.row_indices());
1225        vals.extend_from_slice(col.values());
1226        offsets.push(rows.len());
1227    }
1228    CscMatrix::try_from_csc_data(csc.nrows(), cols.len(), offsets, rows, vals)
1229        .expect("columns of a valid CSC form a valid CSC")
1230}
1231
1232fn log_tree(tree: &PbTree, targets: &[usize], levels: &[Vec<usize>]) {
1233    let mut per_depth: Vec<(usize, usize)> = vec![(0, 0); tree.depth];
1234    let mut ratios: Vec<f64> = Vec::new();
1235    let mut ve_res: Vec<f64> = Vec::new();
1236    let mut ve_marg: Vec<f64> = Vec::new();
1237    let mut applied = 0usize;
1238    let mut total = 0usize;
1239    for node in &tree.roots {
1240        ve_res.push(f64::from(node.ve_ratio_residual));
1241        ve_marg.push(f64::from(node.ve_ratio_marginal));
1242        for s in &node.splits {
1243            total += 1;
1244            applied += usize::from(s.applied);
1245            if s.applied && s.depth >= 1 && s.depth <= tree.depth {
1246                per_depth[s.depth - 1].1 += 1;
1247                per_depth[s.depth - 1].0 += usize::from(s.passes_edge);
1248            }
1249            if s.mp_edge > 0.0 {
1250                ratios.push(f64::from(s.s1 / s.mp_edge));
1251            }
1252        }
1253    }
1254    let by_depth: Vec<String> = per_depth
1255        .iter()
1256        .enumerate()
1257        .map(|(d, (a, n))| format!("d{} {}/{}", d + 1, a, n))
1258        .collect();
1259    let reached: Vec<usize> = levels
1260        .iter()
1261        .map(|l| {
1262            l.iter()
1263                .filter(|&&x| x != usize::MAX)
1264                .collect::<std::collections::HashSet<_>>()
1265                .len()
1266        })
1267        .collect();
1268    info!(
1269        "pb tree: {} roots, max depth {}, {} splits proposed, {} applied; leaves per level {:?} for targets {:?}; \
1270         applied splits above the noise edge by depth: {}; median s1/edge {:.2}; \
1271         variance-explained ratio residual {:.2} vs marginal {:.2} (median over roots, 1.0 = random)",
1272        tree.roots.len(),
1273        tree.depth,
1274        total,
1275        applied,
1276        reached,
1277        targets,
1278        by_depth.join(", "),
1279        median(&mut ratios),
1280        median(&mut ve_res),
1281        median(&mut ve_marg),
1282    );
1283}
1284
1285#[cfg(test)]
1286#[path = "pb_tree_tests.rs"]
1287mod tests;