Skip to main content

data_beans/alg/
refine_mixed.rs

1//! Cross-individual likelihood refinement of mixed pseudobulk groups.
2//!
3//! [`RandProjOps::partition_columns_to_mixed_groups`] pools the cells of
4//! many individuals by the sign code of a projection. A group can still
5//! hold more than one cell state. This pass moves cells between groups
6//! under the Poisson model
7//!
8//! ```text
9//!   y_cg ~ Poisson(s_c mu_gp omega_gi)
10//! ```
11//!
12//! for cell `c` of individual `i` in group `p`: `s_c` is the cell's depth,
13//! `mu` the group's profile and `omega` the individual's gene offset. A
14//! cell keeps its `omega_gi` in every group, so a move gains only from a
15//! better match of `mu`, the cell state, and not from joining a group rich
16//! in its own individual.
17//!
18//! Each sweep fits `mu` and `omega` for the current labels by alternating
19//! closed-form updates (concave in their logs), then moves every cell to
20//! its best candidate group with the parameters held fixed. Both steps
21//! raise the same likelihood. Candidates are the groups of the codes one
22//! bit away from the cell's own code. A move that would leave its group
23//! with fewer than `min_batches` individuals is vetoed.
24//!
25//! Unlike [`crate::alg::dc_poisson`], the score carries the individual
26//! offset `omega`, and a cell's current group is scored with the cell
27//! inside it (no leave-one-out), which only favours staying.
28
29use crate::alg::dc_poisson::compact_labels;
30use crate::alg::hvg::select_hvg_streaming;
31use crate::alg::random_projection::{mixed_partition, RandProjOps};
32use crate::sparse_data_visitors::VisitColumnsOps;
33use crate::sparse_io_vector::SparseIoVec;
34use log::info;
35use nalgebra::DMatrix;
36use nalgebra_sparse::CscMatrix;
37use rayon::prelude::*;
38use std::collections::HashMap;
39use std::sync::Mutex;
40
41#[derive(Clone, Debug)]
42pub struct MixedRefineParams {
43    /// most sweeps; stops early once no cell moves
44    pub max_sweeps: usize,
45    /// most alternating rounds of the `mu`/`omega` fit per sweep
46    pub max_fit_rounds: usize,
47    /// the fit stops once no `omega` changes by more than this ratio
48    pub fit_tol: f64,
49    /// highly variable genes scored
50    pub num_genes: usize,
51    /// prior weight: pseudo-depth for `mu`, pseudo-count for `omega`
52    pub pseudocount: f64,
53    /// least log-likelihood gain for a move
54    pub min_gain: f64,
55    pub block_size: Option<usize>,
56}
57
58impl Default for MixedRefineParams {
59    fn default() -> Self {
60        Self {
61            max_sweeps: 5,
62            max_fit_rounds: 50,
63            fit_tol: 1e-4,
64            num_genes: 2000,
65            pseudocount: 1.0,
66            min_gain: 1.0,
67            block_size: None,
68        }
69    }
70}
71
72/////////////////////////
73// Candidates by codes //
74/////////////////////////
75
76/// Candidate groups of each cell: the groups of its own code and of the
77/// codes one bit away, as labelled by the initial partition.
78pub(crate) struct CodeNeighbours {
79    codes: Vec<usize>,
80    candidates_of_code: HashMap<usize, Vec<usize>>,
81}
82
83impl CodeNeighbours {
84    /// `codes` - binary codes of `bits` bits; `labels` - group of each
85    /// column (every column of one code shares its group)
86    pub(crate) fn new(codes: Vec<usize>, labels: &[usize], bits: usize) -> Self {
87        let label_of_code: HashMap<usize, usize> =
88            codes.iter().copied().zip(labels.iter().copied()).collect();
89        let candidates_of_code = label_of_code
90            .keys()
91            .map(|&code| {
92                let mut groups: Vec<usize> = std::iter::once(code)
93                    .chain((0..bits).map(|k| code ^ (1 << k)))
94                    .filter_map(|c| label_of_code.get(&c).copied())
95                    .collect();
96                groups.sort_unstable();
97                groups.dedup();
98                (code, groups)
99            })
100            .collect();
101        Self {
102            codes,
103            candidates_of_code,
104        }
105    }
106
107    /// Sorted candidate groups of `cell`.
108    pub(crate) fn candidates(&self, cell: usize) -> &[usize] {
109        &self.candidates_of_code[&self.codes[cell]]
110    }
111}
112
113//////////////////
114// Count source //
115//////////////////
116
117/// Columns of counts visited in blocks.
118pub(crate) trait CountBlocks: Sync {
119    fn num_genes(&self) -> usize;
120    /// Call `visit(first_column, genes x cells)` on every block, possibly
121    /// in parallel.
122    fn visit_blocks<F>(&self, visit: &F) -> anyhow::Result<()>
123    where
124        F: Fn(usize, &CscMatrix<f32>) -> anyhow::Result<()> + Sync + Send;
125}
126
127impl CountBlocks for CscMatrix<f32> {
128    fn num_genes(&self) -> usize {
129        self.nrows()
130    }
131    fn visit_blocks<F>(&self, visit: &F) -> anyhow::Result<()>
132    where
133        F: Fn(usize, &CscMatrix<f32>) -> anyhow::Result<()> + Sync + Send,
134    {
135        visit(0, self)
136    }
137}
138
139/// A data vector read block by block; the block size is the second field.
140struct DataBlocks<'a>(&'a SparseIoVec, Option<usize>);
141
142impl CountBlocks for DataBlocks<'_> {
143    fn num_genes(&self) -> usize {
144        self.0.num_rows()
145    }
146    fn visit_blocks<F>(&self, visit: &F) -> anyhow::Result<()>
147    where
148        F: Fn(usize, &CscMatrix<f32>) -> anyhow::Result<()> + Sync + Send,
149    {
150        let visitor = |(lb, ub): (usize, usize),
151                       data: &SparseIoVec,
152                       _: &(),
153                       _: std::sync::Arc<Mutex<&mut ()>>|
154         -> anyhow::Result<()> { visit(lb, &data.read_columns_csc(lb..ub)?) };
155        self.0
156            .visit_columns_by_block(&visitor, &(), &mut (), self.1)
157    }
158}
159
160////////////////////////////
161// Sufficient statistics //
162////////////////////////////
163
164/// Label-free totals: counts by individual, cell depths, gene rates.
165struct Totals {
166    /// I x G
167    y_ig: DMatrix<f64>,
168    depth: Vec<f64>,
169    /// counts per unit depth, a prior centre for empty groups
170    rate: Vec<f64>,
171}
172
173/// Counts summed by group (K x G) and, when `batch` is given, the
174/// label-free totals in the same pass.
175fn accumulate<S: CountBlocks>(
176    source: &S,
177    labels: &[usize],
178    k: usize,
179    batch: Option<(&[usize], usize)>,
180) -> anyhow::Result<(DMatrix<f64>, Option<Totals>)> {
181    let ng = source.num_genes();
182    let n = labels.len();
183    let out = Mutex::new((
184        DMatrix::<f64>::zeros(k, ng),
185        batch.map(|(_, n_indv)| (DMatrix::<f64>::zeros(n_indv, ng), vec![0.0; n])),
186    ));
187    source.visit_blocks(&|lb, csc| {
188        let mut guard = out.lock().expect("sums lock");
189        let (y_pg, totals) = &mut *guard;
190        for (j, col) in csc.col_iter().enumerate() {
191            let c = lb + j;
192            for (&g, &v) in col.row_indices().iter().zip(col.values()) {
193                y_pg[(labels[c], g)] += f64::from(v);
194            }
195            if let (Some((y_ig, depth)), Some((batch, _))) = (totals.as_mut(), batch) {
196                for (&g, &v) in col.row_indices().iter().zip(col.values()) {
197                    y_ig[(batch[c], g)] += f64::from(v);
198                }
199                depth[c] = col.values().iter().map(|&v| f64::from(v)).sum();
200            }
201        }
202        Ok(())
203    })?;
204    let (y_pg, totals) = out.into_inner().expect("sums lock");
205    let totals = totals.map(|(y_ig, depth)| {
206        let total_depth: f64 = depth.iter().sum();
207        let rate = y_ig
208            .row_sum()
209            .iter()
210            .map(|&y| y / total_depth.max(1.0))
211            .collect();
212        Totals { y_ig, depth, rate }
213    });
214    Ok((y_pg, totals))
215}
216
217/////////
218// Fit //
219/////////
220
221/// Fitted parameters for scoring: `ln mu` (K x G) and
222/// `a[(p, i)] = sum_g mu_gp omega_gi` (K x I).
223struct Scoring {
224    ln_mu: DMatrix<f32>,
225    a: DMatrix<f64>,
226}
227
228/// Fit `mu` and `omega` by alternating their closed-form updates, each a
229/// matrix product with the group x individual depths `n_pi`. `omega`
230/// (I x G) is updated in place so the next sweep starts from it.
231fn fit(
232    y_pg: &DMatrix<f64>,
233    totals: &Totals,
234    n_pi: &DMatrix<f64>,
235    omega: &mut DMatrix<f64>,
236    params: &MixedRefineParams,
237) -> Scoring {
238    let prior = params.pseudocount;
239    let (k, ng) = y_pg.shape();
240    let mut mu = DMatrix::<f64>::zeros(k, ng);
241    for _ in 0..params.max_fit_rounds.max(1) {
242        let den = n_pi * &*omega;
243        mu = DMatrix::from_fn(k, ng, |p, g| {
244            (y_pg[(p, g)] + prior * totals.rate[g]) / (den[(p, g)] + prior)
245        });
246        let den = n_pi.tr_mul(&mu);
247        let next = DMatrix::from_fn(omega.nrows(), ng, |i, g| {
248            (totals.y_ig[(i, g)] + prior) / (den[(i, g)] + prior)
249        });
250        let change = next
251            .iter()
252            .zip(omega.iter())
253            .fold(0.0f64, |c, (a, b)| c.max((a / b).ln().abs()));
254        *omega = next;
255        if change < params.fit_tol {
256            break;
257        }
258    }
259    Scoring {
260        a: &mu * omega.transpose(),
261        ln_mu: mu.map(|m| m.ln() as f32),
262    }
263}
264
265////////////
266// Sweeps //
267////////////
268
269/// Move cells between groups in place; returns the number of moves.
270///
271/// * `source` - counts, genes x cells
272/// * `batch` - individual of each cell, in `0..n_indv`
273/// * `labels` - compact group of each cell, rewritten
274/// * `neighbours` - candidate groups of each cell
275/// * `min_batches` - individuals a group keeps
276pub(crate) fn refine_mixed_labels<S: CountBlocks>(
277    source: &S,
278    batch: &[usize],
279    labels: &mut [usize],
280    neighbours: &CodeNeighbours,
281    min_batches: usize,
282    params: &MixedRefineParams,
283) -> anyhow::Result<usize> {
284    let n = batch.len();
285    anyhow::ensure!(labels.len() == n, "one batch and label per cell");
286    let n_indv = batch.iter().max().map_or(0, |&m| m + 1);
287    let k = labels.iter().max().map_or(0, |&m| m + 1);
288
289    // cells per (group, individual), and individuals per group
290    let mut count = vec![0usize; k * n_indv];
291    let mut distinct = vec![0usize; k];
292    for (&p, &i) in labels.iter().zip(batch) {
293        count[p * n_indv + i] += 1;
294        if count[p * n_indv + i] == 1 {
295            distinct[p] += 1;
296        }
297    }
298
299    let mut totals: Option<Totals> = None;
300    let mut omega = DMatrix::<f64>::from_element(n_indv, source.num_genes(), 1.0);
301    let mut total_moves = 0;
302    for sweep in 0..params.max_sweeps {
303        let first = totals.is_none().then_some((batch, n_indv));
304        let (y_pg, new_totals) = accumulate(source, labels, k, first)?;
305        let totals = &*totals.get_or_insert_with(|| new_totals.expect("totals on first pass"));
306        let mut n_pi = DMatrix::<f64>::zeros(k, n_indv);
307        for (c, &s) in totals.depth.iter().enumerate() {
308            n_pi[(labels[c], batch[c])] += s;
309        }
310        let scoring = fit(&y_pg, totals, &n_pi, &mut omega, params);
311
312        // propose each cell's best candidate, parameters held fixed
313        let proposals = Mutex::new(Vec::<(usize, usize)>::new());
314        let current: &[usize] = labels;
315        source.visit_blocks(&|lb, csc| {
316            let block: Vec<(usize, usize)> = (0..csc.ncols())
317                .into_par_iter()
318                .filter_map(|j| {
319                    let c = lb + j;
320                    let (p, i) = (current[c], batch[c]);
321                    let col = csc.col(j);
322                    let score = |q: usize| -> f64 {
323                        let fit: f64 = col
324                            .row_indices()
325                            .iter()
326                            .zip(col.values())
327                            .map(|(&g, &v)| f64::from(v) * f64::from(scoring.ln_mu[(q, g)]))
328                            .sum();
329                        fit - totals.depth[c] * scoring.a[(q, i)]
330                    };
331                    let stay = score(p);
332                    neighbours
333                        .candidates(c)
334                        .iter()
335                        .filter(|&&q| q != p && distinct[q] > 0)
336                        .map(|&q| (q, score(q) - stay))
337                        .filter(|&(_, gain)| gain > params.min_gain)
338                        .max_by(|x, y| x.1.total_cmp(&y.1))
339                        .map(|(q, _)| (c, q))
340                })
341                .collect();
342            proposals.lock().expect("proposals lock").extend(block);
343            Ok(())
344        })?;
345        let mut proposals = proposals.into_inner().expect("proposals lock");
346        proposals.sort_unstable();
347
348        // apply in cell order, keeping every group's individuals
349        let mut moves = 0;
350        for (c, q) in proposals {
351            let (p, i) = (labels[c], batch[c]);
352            if count[p * n_indv + i] == 1 {
353                if distinct[p] <= min_batches {
354                    continue;
355                }
356                distinct[p] -= 1;
357            }
358            count[p * n_indv + i] -= 1;
359            if count[q * n_indv + i] == 0 {
360                distinct[q] += 1;
361            }
362            count[q * n_indv + i] += 1;
363            labels[c] = q;
364            moves += 1;
365        }
366        info!(
367            "mixed refine sweep {}: {} of {} cells moved",
368            sweep + 1,
369            moves,
370            n
371        );
372        total_moves += moves;
373        if moves == 0 {
374            break;
375        }
376    }
377    Ok(total_moves)
378}
379
380/////////////////
381// Data entry  //
382/////////////////
383
384pub trait MixedRefineOps {
385    /// Like [`RandProjOps::partition_columns_to_mixed_groups`], then move
386    /// cells between the groups under the likelihood of this data's counts
387    /// (see the module docs). Without sweeps the groups are exactly those
388    /// of the unrefined partition. Returns the number of nonempty groups.
389    fn partition_columns_to_refined_mixed_groups<T>(
390        &mut self,
391        proj_kn: &nalgebra::DMatrix<f32>,
392        num_features: Option<usize>,
393        batch_membership: &[T],
394        min_batches: usize,
395        merge_levels: usize,
396        params: &MixedRefineParams,
397    ) -> anyhow::Result<usize>
398    where
399        T: std::hash::Hash + Eq + Clone;
400}
401
402impl MixedRefineOps for SparseIoVec {
403    fn partition_columns_to_refined_mixed_groups<T>(
404        &mut self,
405        proj_kn: &nalgebra::DMatrix<f32>,
406        num_features: Option<usize>,
407        batch_membership: &[T],
408        min_batches: usize,
409        merge_levels: usize,
410        params: &MixedRefineParams,
411    ) -> anyhow::Result<usize>
412    where
413        T: std::hash::Hash + Eq + Clone,
414    {
415        let part = mixed_partition(
416            proj_kn,
417            num_features,
418            batch_membership,
419            min_batches,
420            merge_levels,
421        )?;
422        let mut labels = part.labels;
423
424        if params.max_sweeps > 0 && params.num_genes > 0 {
425            let num_genes = params.num_genes.min(self.num_rows());
426            let hvg = select_hvg_streaming(self, Some(num_genes), None, None, params.block_size)?;
427            let mut keep = vec![false; self.num_rows()];
428            for &g in &hvg.selected_indices {
429                keep[g] = true;
430            }
431            let mut scored = self.clone_for_collapse();
432            scored.mask_rows(&keep)?;
433
434            let (mut groups, k) = compact_labels(&labels);
435            let mut label_of_group = vec![0; k];
436            for (&g, &l) in groups.iter().zip(&labels) {
437                label_of_group[g] = l;
438            }
439            let neighbours = CodeNeighbours::new(part.codes, &groups, part.bits);
440            let moves = refine_mixed_labels(
441                &DataBlocks(&scored, params.block_size),
442                &part.batch,
443                &mut groups,
444                &neighbours,
445                part.min_batches,
446                params,
447            )?;
448            labels = groups.iter().map(|&g| label_of_group[g]).collect();
449            info!(
450                "mixed refine: {} moves over {} genes, groups keep at least {} individuals",
451                moves,
452                scored.num_rows(),
453                part.min_batches
454            );
455        }
456
457        self.assign_group_labels(&labels);
458        Ok(self.num_groups())
459    }
460}
461
462#[cfg(test)]
463#[path = "refine_mixed_tests.rs"]
464mod tests;