data_beans/alg/cell_pairs.rs
1//! Cell-cell interaction counts: a graph over cells paired with the counts
2//! those cells came from.
3//!
4//! [`CellPairs`] borrows a [`SparseIoVec`] and owns the [`KnnGraph`] whose
5//! edges are the interacting pairs. Nothing here knows about spatial
6//! coordinates — a graph built from tissue positions and one built from an
7//! expression embedding are the same object at this level. Front-ends that
8//! do carry geometry wrap this and add it themselves (e.g. pinto's
9//! `SrtCellPairs`).
10//!
11//! Originally lived in `pinto/src/util/cell_pairs.rs`; moved here so senna /
12//! faba / chickpea can share one cell-pair representation instead of each
13//! growing an inline edge list.
14
15use crate::sparse_data_visitors::styled_progress_bar;
16use crate::sparse_io_vector::SparseIoVec;
17use indicatif::ParallelProgressIterator;
18use legume_numeric::matrix::knn_graph::KnnGraph;
19use legume_numeric::matrix::parquet::{write_named_table, Column};
20use legume_numeric::matrix::utils::generate_minibatch_intervals;
21use rayon::prelude::*;
22use rustc_hash::FxHashMap as HashMap;
23use std::sync::{Arc, Mutex};
24
25/// A set of interacting pairs over some node indexing, plus optional per-pair
26/// weights.
27///
28/// Deliberately says nothing about *what* the endpoints are. Cells indexed into
29/// a [`SparseIoVec`], pseudobulk samples indexed into a dense profile matrix,
30/// and the coarser levels produced by [`collapse_pairs`] are all the same shape
31/// at this level — which is what lets one collapsing routine serve all of them.
32#[derive(Clone, Debug, Default)]
33pub struct PairSet {
34 pub pairs: Vec<(usize, usize)>,
35 /// Per-pair weight, parallel to `pairs` — kNN distance at the cell level,
36 /// `None` once collapsed (see [`collapse_pairs`]).
37 pub weights: Option<Vec<f32>>,
38}
39
40impl PairSet {
41 pub fn new(pairs: Vec<(usize, usize)>) -> Self {
42 Self {
43 pairs,
44 weights: None,
45 }
46 }
47
48 pub fn len(&self) -> usize {
49 self.pairs.len()
50 }
51
52 pub fn is_empty(&self) -> bool {
53 self.pairs.is_empty()
54 }
55
56 /// See [`collapse_pairs`].
57 pub fn collapse_by(&self, node_labels: &[usize]) -> (PairSet, Vec<usize>) {
58 collapse_pairs(&self.pairs, node_labels)
59 }
60}
61
62/// Collapse pairs by node label: every pair whose endpoints carry the same
63/// unordered label pair becomes one coarse pair.
64///
65/// Returns the coarse set and `fine_to_coarse[e]`, the coarse index of fine
66/// pair `e`. Coarse ids are handed out in first-seen order, so the result is
67/// deterministic in the input order and independent of hasher iteration.
68///
69/// Pairs whose endpoints share a label collapse to a self-loop `(l, l)`; that
70/// is deliberate — a within-group interaction is still an interaction.
71///
72/// Weights are dropped rather than aggregated: summing distances, averaging
73/// them, and counting multiplicity are all defensible and the caller knows
74/// which one it wants.
75///
76/// This is the pair-level analogue of the column-level collapsing in
77/// [`crate::alg::collapse_data`], and it composes: the coarse [`PairSet`] can be
78/// collapsed again to build a multi-level hierarchy.
79pub fn collapse_pairs(pairs: &[(usize, usize)], node_labels: &[usize]) -> (PairSet, Vec<usize>) {
80 let mut key_to_coarse: HashMap<(usize, usize), usize> = HashMap::default();
81 let mut coarse: Vec<(usize, usize)> = Vec::new();
82 let mut fine_to_coarse = Vec::with_capacity(pairs.len());
83
84 for &(i, j) in pairs {
85 let (li, lj) = (node_labels[i], node_labels[j]);
86 let key = (li.min(lj), li.max(lj));
87 let c = *key_to_coarse.entry(key).or_insert_with(|| {
88 let next = coarse.len();
89 coarse.push(key);
90 next
91 });
92 fine_to_coarse.push(c);
93 }
94
95 (PairSet::new(coarse), fine_to_coarse)
96}
97
98/// Interacting cell pairs paired with the counts their endpoints index into.
99///
100/// Borrows the pair list rather than owning it, so it costs nothing to build
101/// over a [`KnnGraph`]'s edges (see [`CellPairs::from_graph`]) or over a
102/// [`PairSet`] returned by [`collapse_pairs`]. It holds no graph, because
103/// nothing here needs adjacency — callers that do keep the graph themselves.
104pub struct CellPairs<'a> {
105 /// Counts the endpoints index into: features × cells.
106 pub data: &'a SparseIoVec,
107 pairs: &'a [(usize, usize)],
108 weights: Option<&'a [f32]>,
109 /// Name of the exported per-pair weight column. `distance` by default; a
110 /// caller whose weights are NOT lengths (within-source ranks after a graph
111 /// union, say) renames it so a reader can never mistake the unit.
112 pub weight_column: &'static str,
113}
114
115impl<'a> CellPairs<'a> {
116 pub fn new(data: &'a SparseIoVec, pairs: &'a [(usize, usize)]) -> Self {
117 Self {
118 data,
119 pairs,
120 weights: None,
121 weight_column: "distance",
122 }
123 }
124
125 /// As [`Self::new`], plus a per-pair weight written as the `distance`
126 /// column by [`Self::to_parquet`].
127 pub fn with_weights(
128 data: &'a SparseIoVec,
129 pairs: &'a [(usize, usize)],
130 weights: &'a [f32],
131 ) -> anyhow::Result<Self> {
132 anyhow::ensure!(
133 weights.len() == pairs.len(),
134 "{} weights for {} pairs",
135 weights.len(),
136 pairs.len()
137 );
138 Ok(Self {
139 data,
140 pairs,
141 weights: Some(weights),
142 weight_column: "distance",
143 })
144 }
145
146 /// Take a kNN graph's edges and distances. The graph stays with the caller.
147 ///
148 /// Note what `distances` means depends on the graph. Straight from a kNN
149 /// build they are distances in whatever space it searched. After a union of
150 /// two graphs they may be within-source ranks instead, because the two
151 /// sides measured different things and raw values would not be comparable.
152 /// Anything reading the exported column as a length has to know which.
153 pub fn from_graph(data: &'a SparseIoVec, graph: &'a KnnGraph) -> Self {
154 Self {
155 data,
156 pairs: &graph.edges,
157 weights: Some(&graph.distances),
158 weight_column: "distance",
159 }
160 }
161
162 /// Interacting cell pairs `(left, right)`, canonically `left < right`.
163 pub fn pairs(&self) -> &[(usize, usize)] {
164 self.pairs
165 }
166
167 /// Per-pair weights, parallel to [`Self::pairs`], when the caller supplied any.
168 pub fn weights(&self) -> Option<&[f32]> {
169 self.weights
170 }
171
172 pub fn num_pairs(&self) -> usize {
173 self.pairs.len()
174 }
175
176 pub fn num_features(&self) -> usize {
177 self.data.num_rows()
178 }
179
180 /// visit cell pairs by regular-sized block
181 ///
182 /// A visitor function takes
183 /// - `(lb,ub)` `(usize,usize)`
184 /// - data itself
185 /// - `shared_input`
186 /// - `shared_out` (`Arc(Mutex())`)
187 pub fn visit_pairs_by_block<Visitor, SharedIn, SharedOut>(
188 &self,
189 visitor: &Visitor,
190 shared_in: &SharedIn,
191 shared_out: &mut SharedOut,
192 block_size: Option<usize>,
193 ) -> anyhow::Result<()>
194 where
195 Visitor: Fn(
196 (usize, usize),
197 &CellPairs,
198 &SharedIn,
199 Arc<Mutex<&mut SharedOut>>,
200 ) -> anyhow::Result<()>
201 + Sync
202 + Send,
203 SharedIn: Sync + Send + ?Sized,
204 SharedOut: Sync + Send,
205 {
206 let ntot = self.num_pairs();
207 let jobs = generate_minibatch_intervals(ntot, self.num_features(), block_size);
208 let arc_shared_out = Arc::new(Mutex::new(shared_out));
209
210 let prog_bar = styled_progress_bar(jobs.len() as u64, "blocks");
211 let result = jobs
212 .par_iter()
213 .progress_with(prog_bar.clone())
214 .map(|&(lb, ub)| -> anyhow::Result<()> {
215 visitor((lb, ub), self, shared_in, arc_shared_out.clone())
216 })
217 .collect::<anyhow::Result<()>>();
218 prog_bar.finish_and_clear();
219 result
220 }
221
222 /// Write all the cell pairs into a `.parquet` file.
223 ///
224 /// Columns are `left_cell`, `right_cell`, then `extra` in the order
225 /// given, then the weight column (named [`Self::weight_column`], `distance`
226 /// unless the caller renamed it) when weights were supplied. `extra` lets a
227 /// caller splice in per-pair values — endpoint coordinates, say — without
228 /// this module having to know what they mean; each must carry one value
229 /// per pair.
230 ///
231 /// * `file_path`: destination file name (try to include a recognizable
232 /// extension in the end, e.g., `.parquet`)
233 pub fn to_parquet(
234 &self,
235 file_path: &str,
236 extra: &[(Box<str>, Column<'_>)],
237 ) -> anyhow::Result<()> {
238 let num_pairs = self.num_pairs();
239 let cell_names = self.data.column_names()?;
240
241 let left: Vec<Box<str>> = self
242 .pairs()
243 .iter()
244 .map(|&(left, _)| cell_names[left].clone())
245 .collect();
246 let right: Vec<Box<str>> = self
247 .pairs()
248 .iter()
249 .map(|&(_, right)| cell_names[right].clone())
250 .collect();
251
252 let mut columns: Vec<(Box<str>, Column<'_>)> = vec![
253 ("left_cell".into(), Column::Str(&left)),
254 ("right_cell".into(), Column::Str(&right)),
255 ];
256
257 for (name, col) in extra {
258 // Name the offending column; parquet's own row-count check fires
259 // later and cannot say which one is short.
260 let len = col.len();
261 if len != num_pairs {
262 return Err(anyhow::anyhow!(
263 "column `{}` carries {} values for {} pairs",
264 name,
265 len,
266 num_pairs
267 ));
268 }
269 columns.push((
270 name.clone(),
271 match col {
272 Column::Str(d) => Column::Str(d),
273 Column::F32(d) => Column::F32(d),
274 Column::I32(d) => Column::I32(d),
275 Column::I64(d) => Column::I64(d),
276 },
277 ));
278 }
279
280 if let Some(w) = self.weights {
281 columns.push((self.weight_column.into(), Column::F32(w)));
282 }
283
284 let row_names: Vec<Box<str>> = (0..num_pairs)
285 .map(|i| i.to_string().into_boxed_str())
286 .collect();
287
288 write_named_table(file_path, "cell_pair", &row_names, &columns)
289 }
290}