Skip to main content

data_beans/alg/
retrieval_impute.rs

1//! Retrieval-based imputation: pool a sparse reference's full-feature
2//! counts onto query cells matched in a shared latent space.
3//!
4//! The caller owns the latent semantics — both matrices must already be
5//! mapped onto whatever space makes their rows comparable under L2
6//! (softmaxed topic proportions, L2-normalized embeddings, …). This
7//! module only does the model-agnostic part:
8//!
9//! 1. Build an approximate kNN index over the reference rows.
10//! 2. For each query row, find the K nearest reference rows and convert
11//!    their L2 distances to softmax weights with temperature τ.
12//! 3. Stream the reference backend's columns chunk by chunk and
13//!    accumulate each query row's weighted average of its neighbours'
14//!    counts into a dense `[N_query, G_ref]` matrix.
15//!
16//! An all-zero row means "no evidence" on BOTH sides: a zero query row
17//! is skipped (its imputed row stays zero), and a zero reference row is
18//! excluded from the index — left in, it would sit at one fixed
19//! coordinate capturing every otherwise-distant query.
20//!
21//! The information-theoretic point of retrieval over a decoder readout:
22//! the imputed expression inherits the reference's full-rank gene–gene
23//! covariance through the retrieved cells, rather than being confined to
24//! the rank of the model's dictionary.
25
26use crate::sparse_io_vector::SparseIoVec;
27use legume_numeric::matrix::knn_match::ColumnDict;
28use log::{info, warn};
29use rayon::prelude::*;
30
31type Mat = nalgebra::DMatrix<f32>;
32
33pub struct RetrievalImputeConfig {
34    /// Reference nearest neighbours pooled per query row.
35    pub knn: usize,
36    /// Softmax temperature on kNN distances (lower = sharper weights).
37    pub temperature: f32,
38    /// Reference columns per streamed read.
39    pub chunk: usize,
40}
41
42/// A searched query row's matched reference rows and their pooling weights.
43struct QueryNeighbours {
44    ids: Vec<u32>,
45    weights: Vec<f32>,
46}
47
48/// How many query rows were not searched for having a zero latent (`None`),
49/// and how many searched ones got fewer than `knn` neighbours, none at all
50/// included.
51fn count_zero_latent_and_short(
52    neighbours: &[Option<QueryNeighbours>],
53    knn: usize,
54) -> (usize, usize) {
55    let n_zero_latent = neighbours.iter().filter(|n| n.is_none()).count();
56    let n_short = neighbours
57        .iter()
58        .flatten()
59        .filter(|n| n.ids.len() < knn)
60        .count();
61    (n_zero_latent, n_short)
62}
63
64/// Weighted-kNN imputation of `ref_data`'s features onto the query rows.
65///
66/// `query_latent` is `[N_query, K]` and `ref_latent` is `[N_ref, K]`, in
67/// the same (caller-established) matching space; `ref_data` holds the
68/// reference counts with one column per `ref_latent` row. Returns the
69/// dense imputed `[N_query, G_ref]` matrix.
70pub fn retrieval_impute(
71    query_latent: &Mat,
72    ref_latent: &Mat,
73    ref_data: &SparseIoVec,
74    cfg: &RetrievalImputeConfig,
75) -> anyhow::Result<Mat> {
76    let (n_query, k_query) = (query_latent.nrows(), query_latent.ncols());
77    let (n_ref, k_ref) = (ref_latent.nrows(), ref_latent.ncols());
78    anyhow::ensure!(
79        k_query == k_ref,
80        "latent dimension mismatch: query K={k_query} vs reference K={k_ref}"
81    );
82    anyhow::ensure!(cfg.knn >= 1, "knn must be at least 1");
83    anyhow::ensure!(n_ref > 0, "reference latent has no rows");
84    anyhow::ensure!(
85        ref_data.num_columns() == n_ref,
86        "reference data has {} cells but reference latent has {n_ref}; \
87         the data files don't match the latent",
88        ref_data.num_columns(),
89    );
90    let g_ref = ref_data.num_rows();
91    anyhow::ensure!(g_ref > 0, "reference data has no features");
92
93    // Index only the reference rows that carry evidence.
94    let live: Vec<usize> = (0..n_ref)
95        .filter(|&i| ref_latent.row(i).iter().any(|&x| x != 0.0))
96        .collect();
97    anyhow::ensure!(!live.is_empty(), "every reference latent row is zero");
98    if live.len() < n_ref {
99        warn!(
100            "{} of {n_ref} reference rows have a zero latent and are excluded \
101             from the index",
102            n_ref - live.len()
103        );
104    }
105    info!(
106        "Building kNN index over {} reference rows (k={}, τ={})",
107        live.len(),
108        cfg.knn,
109        cfg.temperature
110    );
111    // One allocation for the [K, n_live] point set the dict copies from;
112    // `transpose().select_columns(&live)` would build the same thing through
113    // an intermediate full-matrix copy.
114    let ref_dict = ColumnDict::<u32>::from_dmatrix(
115        Mat::from_fn(k_ref, live.len(), |r, c| ref_latent[(live[c], r)]),
116        live.iter().map(|&i| i as u32).collect(),
117    );
118
119    let knn = cfg.knn.min(live.len());
120    // `None` marks a zero-latent query row, which is not searched.
121    let neighbours: Vec<Option<QueryNeighbours>> = (0..n_query)
122        .into_par_iter()
123        // One search scratch per rayon worker: re-growing the backend's
124        // visited set is O(n_ref) per query otherwise.
125        .map_init(
126            legume_numeric::matrix::knn_match::SearchScratch::default,
127            |scratch, i| {
128                let query: Vec<f32> = query_latent.row(i).iter().copied().collect();
129                if query.iter().all(|&x| x == 0.0) {
130                    return Ok(None);
131                }
132                let (ids, distances) = ref_dict
133                    .search_by_query_data_reuse(&query, knn, scratch)
134                    .map_err(|e| anyhow::anyhow!("kNN search for query row {i}: {e}"))?;
135                let weights = dist_to_softmax_weights(&distances, cfg.temperature);
136                Ok(Some(QueryNeighbours { ids, weights }))
137            },
138        )
139        .collect::<anyhow::Result<_>>()?;
140    let (n_zero_latent, n_short) = count_zero_latent_and_short(&neighbours, knn);
141    if n_zero_latent > 0 {
142        warn!(
143            "{n_zero_latent} of {n_query} query rows have a zero latent and were skipped; \
144             their imputed rows stay zero"
145        );
146    }
147    // The index may return fewer neighbours than asked for (an approximate
148    // search has a fixed candidate budget); the weights are then spread over
149    // the ones it did return, and a row it returned none for stays zero.
150    if n_short > 0 {
151        warn!(
152            "{n_short} of {n_query} query rows got fewer than the {knn} neighbours \
153             asked for; they pool the neighbours the index returned, and stay zero \
154             where it returned none"
155        );
156    }
157
158    // Heads-up before the big allocation: dense [N_query, G_ref] f32 grows
159    // fast. Streaming-sparse output is a future optimization; for now we
160    // materialize and let the caller write whole.
161    let bytes_est = (n_query * g_ref).saturating_mul(4);
162    if bytes_est > (1 << 30) {
163        warn!(
164            "imputed dense matrix will allocate ~{} MB ({n_query} × {g_ref} f32). \
165             Consider reducing the query size or the reference feature set if \
166             memory is tight.",
167            bytes_est >> 20,
168        );
169    }
170
171    // Invert the (query row → neighbours) map so column-streaming the
172    // reference can do `consumers[ref_id]` lookups in O(1) and skip whole
173    // chunks where no consumer touches any of the chunk's cells.
174    let mut cell_to_consumers: Vec<Vec<(u32, f32)>> = vec![Vec::new(); n_ref];
175    for (query_id, nbr) in neighbours.iter().enumerate() {
176        let Some(nbr) = nbr else { continue };
177        for (&c, &w) in nbr.ids.iter().zip(nbr.weights.iter()) {
178            cell_to_consumers[c as usize].push((query_id as u32, w));
179        }
180    }
181
182    // Accumulate TRANSPOSED, [G_ref, N_query]: each query row is then one
183    // contiguous column, so a reference column's ascending row indices write
184    // ascending contiguous addresses, and disjoint query rows let the
185    // per-chunk accumulation run in parallel with no locking. One transpose
186    // at the end pays a single O(N×G) pass for a cache-friendly hot loop.
187    let mut imputed_t = Mat::zeros(g_ref, n_query);
188    // Per-chunk (reference-local column, weight) work lists, indexed by
189    // query; allocated once, cleared inside the parallel pass below.
190    let mut groups: Vec<Vec<(u32, f32)>> = vec![Vec::new(); n_query];
191    let chunk_size = cfg.chunk.max(64);
192    let mut col_lb = 0;
193    while col_lb < n_ref {
194        let col_ub = (col_lb + chunk_size).min(n_ref);
195        // Skip the read entirely if no consumer touches this chunk —
196        // common when N_query × knn ≪ N_ref.
197        if cell_to_consumers[col_lb..col_ub]
198            .iter()
199            .all(std::vec::Vec::is_empty)
200        {
201            col_lb = col_ub;
202            continue;
203        }
204        let csc = ref_data.read_columns_csc(col_lb..col_ub)?;
205
206        for c_local in 0..(col_ub - col_lb) {
207            for &(query_id, w) in &cell_to_consumers[col_lb + c_local] {
208                groups[query_id as usize].push((c_local as u32, w));
209            }
210        }
211
212        imputed_t
213            .as_mut_slice()
214            .par_chunks_mut(g_ref)
215            .zip(groups.par_iter_mut())
216            .for_each(|(query_col, grp)| {
217                for &(c_local, w) in grp.iter() {
218                    let col = csc.col(c_local as usize);
219                    for (&row_id, &v) in col.row_indices().iter().zip(col.values().iter()) {
220                        query_col[row_id] += w * v;
221                    }
222                }
223                // Cleared here (in parallel, capacity kept) so the next
224                // chunk's regroup starts from empty lists.
225                grp.clear();
226            });
227
228        col_lb = col_ub;
229    }
230
231    Ok(imputed_t.transpose())
232}
233
234/// Convert kNN L2 distances to a positive-weight simplex via
235/// `w_k ∝ exp(-d_k² / (2 τ²))`, normalised to sum to 1.
236fn dist_to_softmax_weights(distances: &[f32], temperature: f32) -> Vec<f32> {
237    if distances.is_empty() {
238        return Vec::new();
239    }
240    let tau = temperature.max(1e-6);
241    let scale = 1.0 / (2.0 * tau * tau);
242    let mut max = f32::NEG_INFINITY;
243    let mut out: Vec<f32> = distances
244        .iter()
245        .map(|d| {
246            let v = -d * d * scale;
247            if v > max {
248                max = v;
249            }
250            v
251        })
252        .collect();
253    let mut sum = 0.0f32;
254    for x in &mut out {
255        *x = (*x - max).exp();
256        sum += *x;
257    }
258    let inv = 1.0 / sum.max(1e-12);
259    for x in &mut out {
260        *x *= inv;
261    }
262    out
263}
264
265#[cfg(test)]
266#[path = "retrieval_impute_tests.rs"]
267mod tests;