Skip to main content

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}