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
15struct CollapseStat {
20 count_sum: DMatrix<f32>,
22 cell_weight: DVector<f32>,
24 n_groups: usize,
26}
27
28struct VisitorInput {
30 membership: DMatrix<f32>,
32}
33
34#[derive(Debug)]
36pub struct CollapsedPseudobulk {
37 pub gamma_params: Vec<GammaMatrix>,
39 pub cell_type_names: Vec<Box<str>>,
41 pub individual_ids: Vec<Box<str>>,
43 pub gene_names: Vec<Box<str>>,
45 pub cell_weights: Vec<Vec<f32>>,
47}
48
49pub 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
70pub 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 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 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 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 data_vec.visit_columns_by_group(&collect_stat_visitor, &visitor_input, &mut stat)?;
161
162 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 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
227fn 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;