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