Skip to main content

data_beans/sparse_io_vector/
batch.rs

1#![allow(dead_code)]
2
3use super::*;
4
5impl SparseIoVec {
6    /////////////////////////////////////
7    // data dictionary related methods //
8    /////////////////////////////////////
9
10    /// Register batch membership information along with the feature
11    /// matrix for quick look up operations.
12    ///
13    /// # Arguments
14    /// * `feature_matrix` - A feature matrix where each column corresponds to a cell.
15    /// * `batch_membership` - A vector of batch membership information for each cell.
16    #[cfg(feature = "ndarray")]
17    pub fn register_batches_ndarray<T>(
18        &mut self,
19        feature_matrix: &ndarray::Array2<f32>,
20        batch_membership: &[T],
21    ) -> anyhow::Result<()>
22    where
23        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
24    {
25        anyhow::ensure!(
26            feature_matrix.ncols() == batch_membership.len(),
27            "the feature matrix has {} columns but there are {} batch labels",
28            feature_matrix.ncols(),
29            batch_membership.len(),
30        );
31        self._register_batches(
32            feature_matrix,
33            batch_membership,
34            |feature_matrix, batch_cells| {
35                let columns = batch_cells
36                    .iter()
37                    .map(|&c| feature_matrix.column(c))
38                    .collect::<Vec<_>>();
39                ColumnDict::<usize>::from_ndarray_views(columns, batch_cells.clone())
40            },
41        )
42    }
43
44    /// Register batch membership information along with the feature
45    /// matrix for quick look up operations.
46    ///
47    /// # Arguments
48    /// * `feature_matrix` - A feature matrix where each column corresponds to a cell.
49    /// * `batch_membership` - A vector of batch membership information for each cell.
50    pub fn register_batches_dmatrix<T>(
51        &mut self,
52        feature_matrix: &nalgebra::DMatrix<f32>,
53        batch_membership: &[T],
54    ) -> anyhow::Result<()>
55    where
56        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
57    {
58        anyhow::ensure!(
59            feature_matrix.ncols() == batch_membership.len(),
60            "the feature matrix has {} columns but there are {} batch labels",
61            feature_matrix.ncols(),
62            batch_membership.len(),
63        );
64
65        self._register_batches(
66            feature_matrix,
67            batch_membership,
68            |feature_matrix, batch_cells| {
69                let columns = batch_cells
70                    .iter()
71                    .map(|&c| feature_matrix.column(c))
72                    .collect::<Vec<_>>();
73                ColumnDict::<usize>::from_dvector_views(columns, batch_cells.clone())
74            },
75        )
76    }
77
78    fn _register_batches<M, F, T>(
79        &mut self,
80        feature_matrix: &M,
81        batch_membership: &[T],
82        create_column_dict: F,
83    ) -> anyhow::Result<()>
84    where
85        M: Sync,
86        F: Fn(&M, &Vec<usize>) -> ColumnDict<usize> + Sync,
87        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
88    {
89        let ntot = self.num_columns();
90        anyhow::ensure!(
91            batch_membership.len() == ntot,
92            "batch membership has {} entries but there are {ntot} columns",
93            batch_membership.len(),
94        );
95        let batches = partition_by_membership(batch_membership, None);
96        let mut col_to_batch = vec![0; ntot];
97
98        // A ColumnDict build is internally parallel (instant-distance's rayon
99        // insert) only ABOVE EXACT_THRESHOLD points; smaller batches use the
100        // exact backend and build single-threaded. So when batches are many
101        // (>= n_threads) run the outer loop in parallel — that is the only
102        // parallelism the small batches get, and large ones just nest into the
103        // same work-stealing pool (safe, no true oversubscription). When batches
104        // are few, build sequentially so each large build owns the pool and only
105        // one HNSW index is under construction at a time (bounds peak memory).
106        let n_threads = rayon::current_num_threads();
107        let outer_parallel = batches.len() >= n_threads;
108
109        info!(
110            "building per-batch kNN indices ({} batches, {} cells, {}) ...",
111            batches.len(),
112            ntot,
113            if outer_parallel {
114                "parallel over batches"
115            } else {
116                "sequential over batches"
117            }
118        );
119
120        let prog_bar =
121            crate::sparse_data_visitors::styled_progress_bar(batches.len() as u64, "batches kNN");
122
123        // Canonical batch id = SORTED LABEL order (consistent with
124        // `register_batch_membership`), so per-batch stats / δ columns carry the
125        // same batch ids as the rest of the pipeline. Previously this sorted by
126        // descending size and used the processing position as the id, which
127        // silently permuted batch ids vs the label order — a latent bug for δ /
128        // `AdjMethod::Batch` consumers.
129        let mut batches_vec: Vec<_> = batches.into_iter().collect();
130        batches_vec.sort_by_key(|a| a.0.to_string());
131        // Schedule largest batches first (LPT) so the heavy tail overlaps with
132        // the small batches. The canonical id rides along; `sort_by_key(idx)`
133        // below restores canonical order.
134        let mut enumerated: Vec<_> = batches_vec.iter().enumerate().collect();
135        enumerated.sort_by_key(|(_, (_, cells))| std::cmp::Reverse(cells.len()));
136        let mut idx_name_glob_dict: Vec<_> = if outer_parallel {
137            enumerated
138                .into_par_iter()
139                .progress_with(prog_bar.clone())
140                .map(|(batch_index, (batch_name, batch_glob_indices))| {
141                    (
142                        batch_index,
143                        batch_name.to_string().into_boxed_str(),
144                        batch_glob_indices.clone(),
145                        create_column_dict(feature_matrix, batch_glob_indices),
146                    )
147                })
148                .collect()
149        } else {
150            enumerated
151                .into_iter()
152                .progress_with(prog_bar.clone())
153                .map(|(batch_index, (batch_name, batch_glob_indices))| {
154                    (
155                        batch_index,
156                        batch_name.to_string().into_boxed_str(),
157                        batch_glob_indices.clone(),
158                        create_column_dict(feature_matrix, batch_glob_indices),
159                    )
160                })
161                .collect()
162        };
163        prog_bar.finish_and_clear();
164
165        idx_name_glob_dict.sort_by_key(|&(idx, _, _, _)| idx);
166
167        let mut batch_names = vec![];
168        let mut batch_to_cols = vec![];
169        let mut dictionaries = vec![];
170
171        for (batch_idx, batch_name, glob_indices, dict) in idx_name_glob_dict.into_iter() {
172            dict.names()
173                .iter()
174                .for_each(|&cell| col_to_batch[cell] = batch_idx);
175
176            batch_names.push(batch_name);
177            batch_to_cols.push(glob_indices);
178            dictionaries.push(dict);
179        }
180
181        self.derived.batch_knn_lookup = Some(dictionaries);
182        self.derived.col_to_batch = Some(col_to_batch);
183        self.derived.batch_to_cols = Some(batch_to_cols);
184        self.derived.batch_idx_to_name = Some(batch_names);
185
186        if self.num_batches() > 2 {
187            self.sort_batch_proximity()?;
188        }
189
190        Ok(())
191    }
192
193    fn sort_batch_proximity(&mut self) -> anyhow::Result<()> {
194        let lookups = self
195            .derived
196            .batch_knn_lookup
197            .as_ref()
198            .ok_or(anyhow::anyhow!("no knn lookup"))?;
199
200        use nalgebra::DMatrix;
201
202        info!("retrieving batch-specific lookups");
203        let batch_data = lookups
204            .iter()
205            .flat_map(|dict| {
206                let data: Vec<f32> = dict.points().flatten().copied().collect();
207                let ncols = dict.num_points();
208                let nrows = data.len() / ncols;
209                DMatrix::from_vec(nrows, ncols, data)
210                    .column_mean()
211                    .data
212                    .as_vec()
213                    .clone()
214            })
215            .collect::<Vec<_>>();
216
217        let ncols = self.num_batches();
218        let nrows = batch_data.len() / ncols;
219        let batch_features = DMatrix::<f32>::from_vec(nrows, ncols, batch_data);
220
221        info!(
222            "built feature matrix across batches: {} x {}",
223            batch_features.nrows(),
224            batch_features.ncols()
225        );
226
227        let nbatches = self.num_batches();
228        let batches = (0..nbatches).collect();
229
230        let dict = ColumnDict::<usize>::from_dvector_views(
231            batch_features.column_iter().collect(),
232            batches,
233        );
234
235        let ret: Vec<Vec<usize>> = (0..nbatches)
236            .into_par_iter()
237            .map(|b| {
238                dict.search_by_query_name(&b, nbatches, false)
239                    .map(|(others, _)| others)
240            })
241            .collect::<anyhow::Result<Vec<Vec<usize>>>>()?;
242        self.derived.between_batch_proximity = Some(ret);
243
244        Ok(())
245    }
246
247    pub fn batch_name_map(&self) -> Option<HashMap<Box<str>, usize>> {
248        self.derived.batch_idx_to_name.as_ref().map(|names| {
249            names
250                .iter()
251                .enumerate()
252                .map(|(idx, name)| (name.clone(), idx))
253                .collect::<HashMap<Box<str>, usize>>()
254        })
255    }
256
257    pub fn num_batches(&self) -> usize {
258        if let Some(v) = &self.derived.batch_to_cols {
259            v.len()
260        } else if let Some(v) = &self.derived.batch_knn_lookup {
261            v.len()
262        } else {
263            0
264        }
265    }
266
267    /// Borrow the per-batch HNSW lookups populated by
268    /// `build_hnsw_per_batch` / `register_batches_dmatrix`. Returns `None`
269    /// before the indices have been built.
270    pub fn batch_knn_lookup(&self) -> Option<&Vec<ColumnDict<usize>>> {
271        self.derived.batch_knn_lookup.as_ref()
272    }
273
274    /// Register batch membership information without building HNSW
275    /// indices. This is a lightweight alternative to `register_batches_dmatrix`
276    /// for use with pb-sample based batch correction.
277    ///
278    /// # Panics
279    /// If `batch_membership` is not one entry per column;
280    /// [`try_register_batch_membership`](Self::try_register_batch_membership)
281    /// returns that as an error instead.
282    pub fn register_batch_membership<T>(&mut self, batch_membership: &[T])
283    where
284        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
285    {
286        self.try_register_batch_membership(batch_membership)
287            .unwrap_or_else(|e| panic!("{e}"));
288    }
289
290    /// [`register_batch_membership`](Self::register_batch_membership) that
291    /// returns a membership of the wrong length as an error.
292    ///
293    /// # Errors
294    /// If `batch_membership` is not one entry per column: a shorter slice
295    /// would leave the unlisted columns in the first batch, absent from that
296    /// batch's column list. Nothing is registered then.
297    pub fn try_register_batch_membership<T>(&mut self, batch_membership: &[T]) -> anyhow::Result<()>
298    where
299        T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
300    {
301        let ntot = self.num_columns();
302        anyhow::ensure!(
303            batch_membership.len() == ntot,
304            "batch membership has {} entries but there are {ntot} columns",
305            batch_membership.len(),
306        );
307        let batches = partition_by_membership(batch_membership, None);
308        let mut col_to_batch = vec![0; ntot];
309
310        let mut sorted_batches: Vec<_> = batches.into_iter().collect();
311        sorted_batches.sort_by_key(|a| a.0.to_string());
312
313        let mut batch_names = Vec::with_capacity(sorted_batches.len());
314        let mut batch_to_cols = Vec::with_capacity(sorted_batches.len());
315
316        for (batch_idx, (batch_name, glob_indices)) in sorted_batches.into_iter().enumerate() {
317            for &cell in &glob_indices {
318                col_to_batch[cell] = batch_idx;
319            }
320            batch_names.push(batch_name.to_string().into_boxed_str());
321            batch_to_cols.push(glob_indices);
322        }
323
324        self.derived.col_to_batch = Some(col_to_batch);
325        self.derived.batch_to_cols = Some(batch_to_cols);
326        self.derived.batch_idx_to_name = Some(batch_names);
327        Ok(())
328    }
329
330    /// Declare that each column stands for more than one observation.
331    ///
332    /// A column is normally one cell, so every statistic that divides by a
333    /// count adds `1` per column. That breaks when a column is a *summary* of
334    /// many cells — a carried pseudobulk, or a bulk sample — because the
335    /// per-cell rate `μ = Σy / n` would divide a whole group's counts by one.
336    ///
337    /// With multiplicities registered, a column holding the **mean** profile of
338    /// `m` cells and a weight of `m` contributes exactly what those `m` cells
339    /// would have: `m·mean` to the sums and `m` to the count.
340    ///
341    /// Absent (the default) every column weighs `1`, and every accumulation is
342    /// bit-for-bit what it was before this existed.
343    ///
344    /// # Errors
345    /// If `multiplicity` is not one entry per column, or holds a non-finite or
346    /// non-positive weight — a zero would silently delete a column from the
347    /// denominator while leaving its counts in the numerator.
348    pub fn register_column_multiplicity(&mut self, multiplicity: &[f32]) -> anyhow::Result<()> {
349        let ntot = self.num_columns();
350        anyhow::ensure!(
351            multiplicity.len() == ntot,
352            "column multiplicity has {} entries but there are {ntot} columns",
353            multiplicity.len(),
354        );
355        if let Some((i, w)) = multiplicity
356            .iter()
357            .enumerate()
358            .find(|(_, w)| !w.is_finite() || **w <= 0.0)
359        {
360            anyhow::bail!("column {i} has multiplicity {w}; weights must be finite and positive");
361        }
362        self.col_multiplicity = Some(multiplicity.to_vec());
363        Ok(())
364    }
365
366    /// Weight of a single column — `1.0` when no multiplicities are registered.
367    #[must_use]
368    pub fn column_multiplicity(&self, col: usize) -> f32 {
369        self.col_multiplicity.as_ref().map_or(1.0, |m| m[col])
370    }
371
372    /// True when any column stands for more than one observation.
373    #[must_use]
374    pub fn has_column_multiplicity(&self) -> bool {
375        self.col_multiplicity.is_some()
376    }
377
378    /// The whole multiplicity vector, one weight per column — `None` when no
379    /// multiplicities are registered (every column is one observation).
380    ///
381    /// Prefer this over gathering [`Self::column_multiplicity`] in a loop:
382    /// callers were rebuilding the vector element-by-element, re-encoding the
383    /// "absent means 1.0" default at every site.
384    #[must_use]
385    pub fn column_multiplicities(&self) -> Option<&[f32]> {
386        self.col_multiplicity.as_deref()
387    }
388
389    pub fn batch_names(&self) -> Option<Vec<Box<str>>> {
390        self.derived.batch_idx_to_name.clone()
391    }
392
393    pub fn batch_to_columns(&self, batch: usize) -> Option<&Vec<usize>> {
394        if let Some(batch_to_cols) = &self.derived.batch_to_cols {
395            Some(&batch_to_cols[batch])
396        } else {
397            None
398        }
399    }
400
401    pub fn get_batch_membership<I>(&self, cells: I) -> Vec<usize>
402    where
403        I: Iterator<Item = usize>,
404    {
405        let cell_to_batch = self
406            .derived
407            .col_to_batch
408            .as_ref()
409            .expect("cell_to_batch not initialized");
410        cells.into_iter().map(|c| cell_to_batch[c]).collect()
411    }
412
413    pub fn column_names(&self) -> anyhow::Result<Vec<Box<str>>> {
414        debug_assert_eq!(self.num_columns(), self.column_names_with_data_tag.len());
415        Ok(self.column_names_with_data_tag.clone())
416    }
417}