Skip to main content

data_beans/alg/
pseudobulk.rs

1use rustc_hash::FxHashMap as HashMap;
2use std::ops::AddAssign;
3use std::sync::{Arc, Mutex};
4
5use anyhow::{bail, Result};
6use log::info;
7use nalgebra::{DMatrix, DVector};
8
9use crate::aux::cell_annotations::{CellAnnotations, CellTypeMembership};
10use crate::sparse_data_visitors::VisitColumnsOps;
11use crate::sparse_io_vector::SparseIoVec;
12use legume_numeric::param::dmatrix_gamma::GammaMatrix;
13use legume_numeric::param::traits::TwoStatParam;
14
15/// Accumulates gene-level count sums per (individual × cell_type).
16///
17/// Layout: genes × (n_groups * n_cell_types)
18/// Column index for (individual group i, cell_type k) = k * n_groups + i
19struct CollapseStat {
20    /// Weighted sum of counts: genes × (n_groups * n_ct)
21    count_sum: DMatrix<f32>,
22    /// Effective cell count per (individual group, cell_type): n_groups * n_ct
23    cell_weight: DVector<f32>,
24    /// Number of individual groups (the column stride of one cell type)
25    n_groups: usize,
26}
27
28/// Input to the visitor: cell-level membership matrix.
29struct VisitorInput {
30    /// Cell membership: cells × cell_types.
31    membership: DMatrix<f32>,
32}
33
34/// Poisson-Gamma pseudobulk output.
35#[derive(Debug)]
36pub struct CollapsedPseudobulk {
37    /// Per cell type: GammaMatrix (genes × individuals)
38    pub gamma_params: Vec<GammaMatrix>,
39    /// Ordered cell type names
40    pub cell_type_names: Vec<Box<str>>,
41    /// Ordered individual IDs
42    pub individual_ids: Vec<Box<str>>,
43    /// Gene names
44    pub gene_names: Vec<Box<str>>,
45    /// Effective cell count per (cell_type_idx, individual_idx) — fractional for soft membership
46    pub cell_weights: Vec<Vec<f32>>,
47}
48
49/// Collapse single-cell counts into Poisson-Gamma pseudobulk parameters.
50///
51/// Groups cells by individual, then weights each cell's expression by its
52/// cell type membership (hard one-hot or soft probabilities).
53///
54/// # Arguments
55/// * `data_vec` - Sparse single-cell count matrix (SparseIoVec over one or more backends)
56/// * `annotations` - Cell-to-individual mapping
57/// * `membership` - Membership matrix (cells × cell_types) and cell type names
58/// * `a0` - Gamma prior shape parameter (default: 1.0)
59/// * `b0` - Gamma prior rate parameter (default: 1.0)
60pub fn collapse_pseudobulk(
61    data_vec: SparseIoVec,
62    annotations: &CellAnnotations,
63    membership: &CellTypeMembership,
64    a0: f32,
65    b0: f32,
66) -> Result<CollapsedPseudobulk> {
67    collapse_pseudobulk_weighted(data_vec, annotations, membership, a0, b0, None)
68}
69
70/// Same as [`collapse_pseudobulk`], but row-scales the per-(individual,
71/// cell_type) count sums by `gene_weights[g]` before the Gamma update. This
72/// is the NB-Fisher housekeeping adjustment used by pinto (see
73/// `gene_weighting::compute_nb_fisher_weights`); housekeeping genes get
74/// attenuated in the posterior mean while informative genes stay at w≈1.
75pub fn collapse_pseudobulk_weighted(
76    mut data_vec: SparseIoVec,
77    annotations: &CellAnnotations,
78    membership: &CellTypeMembership,
79    a0: f32,
80    b0: f32,
81    gene_weights: Option<&[f32]>,
82) -> Result<CollapsedPseudobulk> {
83    let num_genes = data_vec.num_rows();
84    let num_cells = data_vec.num_columns();
85    let n_cell_types = membership.cell_type_names.len();
86
87    if num_genes == 0 || num_cells == 0 {
88        bail!(
89            "SC backend has no data (rows={}, cols={})",
90            num_genes,
91            num_cells
92        );
93    }
94
95    if n_cell_types == 0 {
96        bail!("Membership has no cell types; nothing to collapse into");
97    }
98
99    if membership.matrix.nrows() != num_cells || membership.matrix.ncols() != n_cell_types {
100        bail!(
101            "Membership matrix shape ({}, {}) doesn't match (cells={}, cell_types={})",
102            membership.matrix.nrows(),
103            membership.matrix.ncols(),
104            num_cells,
105            n_cell_types
106        );
107    }
108
109    let column_names = data_vec.column_names()?;
110    let gene_names = data_vec.row_names()?;
111
112    info!("SC backend: {} genes × {} cells", num_genes, num_cells);
113
114    let individual_labels = annotations.to_column_aligned_vec(&column_names, "");
115    let matched = individual_labels.iter().filter(|s| !s.is_empty()).count();
116
117    info!("Matched {}/{} cells to annotations", matched, num_cells);
118
119    if matched == 0 {
120        bail!("No cells matched between SC backend and annotations");
121    }
122
123    // Assign groups by individual
124    data_vec.assign_groups(&individual_labels, None);
125
126    let group_keys = data_vec
127        .group_keys()
128        .ok_or_else(|| anyhow::anyhow!("groups not assigned"))?
129        .clone();
130
131    // Map group_key -> individual_idx, skipping unmatched cells (empty key)
132    let ind_lookup: HashMap<&str, usize> = annotations
133        .individual_ids
134        .iter()
135        .enumerate()
136        .map(|(i, id)| (id.as_ref(), i))
137        .collect();
138
139    let n_groups = group_keys.len();
140    let n_individuals = annotations.individual_ids.len();
141
142    info!(
143        "Created {} individual groups, {} cell types",
144        n_groups, n_cell_types
145    );
146
147    // Initialize accumulator: genes × (n_groups * n_cell_types)
148    let stat_cols = n_groups * n_cell_types;
149    let mut stat = CollapseStat {
150        count_sum: DMatrix::zeros(num_genes, stat_cols),
151        cell_weight: DVector::zeros(stat_cols),
152        n_groups,
153    };
154
155    let visitor_input = VisitorInput {
156        membership: membership.matrix.clone(),
157    };
158
159    // Visit groups in parallel
160    data_vec.visit_columns_by_group(&collect_stat_visitor, &visitor_input, &mut stat)?;
161
162    // Build GammaMatrix per cell type
163    let mut gamma_params: Vec<GammaMatrix> = Vec::with_capacity(n_cell_types);
164    let mut cell_weights: Vec<Vec<f32>> = Vec::with_capacity(n_cell_types);
165
166    // Map group_keys back to individual indices, skipping unmatched group
167    let group_to_ind: Vec<Option<usize>> = group_keys
168        .iter()
169        .map(|key| ind_lookup.get(key.as_ref()).copied())
170        .collect();
171
172    if let Some(w) = gene_weights {
173        if w.len() != num_genes {
174            bail!("gene_weights length {} != num_genes {}", w.len(), num_genes);
175        }
176    }
177
178    for ct_idx in 0..n_cell_types {
179        let mut count_sum_ct = DMatrix::<f32>::zeros(num_genes, n_individuals);
180        let mut weight_ct = DVector::<f32>::zeros(n_individuals);
181
182        for (group_idx, ind_opt) in group_to_ind.iter().enumerate() {
183            let Some(&ind_idx) = ind_opt.as_ref() else {
184                continue;
185            };
186            let src_col = ct_idx * n_groups + group_idx;
187            count_sum_ct
188                .column_mut(ind_idx)
189                .add_assign(&stat.count_sum.column(src_col));
190            weight_ct[ind_idx] += stat.cell_weight[src_col];
191        }
192
193        if let Some(w) = gene_weights {
194            for (g, &wg) in w.iter().enumerate() {
195                count_sum_ct.row_mut(g).scale_mut(wg);
196            }
197        }
198
199        let denom = DMatrix::from_fn(num_genes, n_individuals, |_g, i| weight_ct[i]);
200
201        let mut gamma = GammaMatrix::new((num_genes, n_individuals), a0, b0);
202        gamma.update_stat(&count_sum_ct, &denom);
203        gamma.calibrate();
204
205        let cw: Vec<f32> = weight_ct.iter().copied().collect();
206        let n_with_cells = cw.iter().filter(|&&c| c > 0.0).count();
207        let total_weight: f32 = cw.iter().sum();
208
209        info!(
210            "Cell type {}: {} individuals with cells, total weight {:.1}",
211            membership.cell_type_names[ct_idx], n_with_cells, total_weight
212        );
213
214        gamma_params.push(gamma);
215        cell_weights.push(cw);
216    }
217
218    Ok(CollapsedPseudobulk {
219        gamma_params,
220        cell_type_names: membership.cell_type_names.clone(),
221        individual_ids: annotations.individual_ids.clone(),
222        gene_names,
223        cell_weights,
224    })
225}
226
227/// Visitor: for each individual group, multiply sparse counts by membership
228/// weights and accumulate.
229///
230/// Computes: `y_gk = Y_gn * Z_nk` (genes × cell_types) per individual
231fn collect_stat_visitor(
232    group_id: usize,
233    cells: &[usize],
234    data: &SparseIoVec,
235    input: &VisitorInput,
236    arc_stat: Arc<Mutex<&mut CollapseStat>>,
237) -> Result<()> {
238    if cells.is_empty() {
239        return Ok(());
240    }
241
242    let y_gn = data.read_columns_csc(cells.iter().cloned())?;
243    let n_cells = cells.len();
244    let n_ct = input.membership.ncols();
245
246    let mut z_nk = DMatrix::<f32>::zeros(n_cells, n_ct);
247    for (local_idx, &global_idx) in cells.iter().enumerate() {
248        z_nk.row_mut(local_idx)
249            .copy_from(&input.membership.row(global_idx));
250    }
251
252    let y_gk: DMatrix<f32> = &y_gn * &z_nk;
253    let z_k = DVector::from_fn(n_ct, |k, _| z_nk.column(k).sum());
254
255    let mut stat = arc_stat.lock().expect("lock");
256    let n_groups = stat.n_groups;
257
258    for k in 0..n_ct {
259        let col = k * n_groups + group_id;
260        stat.count_sum.column_mut(col).add_assign(&y_gk.column(k));
261        stat.cell_weight[col] += z_k[k];
262    }
263
264    Ok(())
265}
266
267#[cfg(test)]
268#[path = "pseudobulk_tests.rs"]
269mod tests;