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 query row's matched reference rows and their pooling weights.
43/// Empty for a zero-latent query row.
44struct QueryNeighbours {
45    ids: Vec<u32>,
46    weights: Vec<f32>,
47}
48
49/// Weighted-kNN imputation of `ref_data`'s features onto the query rows.
50///
51/// `query_latent` is `[N_query, K]` and `ref_latent` is `[N_ref, K]`, in
52/// the same (caller-established) matching space; `ref_data` holds the
53/// reference counts with one column per `ref_latent` row. Returns the
54/// dense imputed `[N_query, G_ref]` matrix.
55pub fn retrieval_impute(
56    query_latent: &Mat,
57    ref_latent: &Mat,
58    ref_data: &SparseIoVec,
59    cfg: &RetrievalImputeConfig,
60) -> anyhow::Result<Mat> {
61    let (n_query, k_query) = (query_latent.nrows(), query_latent.ncols());
62    let (n_ref, k_ref) = (ref_latent.nrows(), ref_latent.ncols());
63    anyhow::ensure!(
64        k_query == k_ref,
65        "latent dimension mismatch: query K={k_query} vs reference K={k_ref}"
66    );
67    anyhow::ensure!(n_ref > 0, "reference latent has no rows");
68    anyhow::ensure!(
69        ref_data.num_columns() == n_ref,
70        "reference data has {} cells but reference latent has {n_ref}; \
71         the data files don't match the latent",
72        ref_data.num_columns(),
73    );
74    let g_ref = ref_data.num_rows();
75    anyhow::ensure!(g_ref > 0, "reference data has no features");
76
77    // Index only the reference rows that carry evidence.
78    let live: Vec<usize> = (0..n_ref)
79        .filter(|&i| ref_latent.row(i).iter().any(|&x| x != 0.0))
80        .collect();
81    anyhow::ensure!(!live.is_empty(), "every reference latent row is zero");
82    if live.len() < n_ref {
83        warn!(
84            "{} of {n_ref} reference rows have a zero latent and are excluded \
85             from the index",
86            n_ref - live.len()
87        );
88    }
89    info!(
90        "Building kNN index over {} reference rows (k={}, τ={})",
91        live.len(),
92        cfg.knn,
93        cfg.temperature
94    );
95    // One allocation for the [K, n_live] point set the dict copies from;
96    // `transpose().select_columns(&live)` would build the same thing through
97    // an intermediate full-matrix copy.
98    let ref_dict = ColumnDict::<u32>::from_dmatrix(
99        Mat::from_fn(k_ref, live.len(), |r, c| ref_latent[(live[c], r)]),
100        live.iter().map(|&i| i as u32).collect(),
101    );
102
103    let knn = cfg.knn.min(live.len());
104    let neighbours: Vec<QueryNeighbours> = (0..n_query)
105        .into_par_iter()
106        // One search scratch per rayon worker: re-growing the backend's
107        // visited set is O(n_ref) per query otherwise.
108        .map_init(
109            legume_numeric::matrix::knn_match::SearchScratch::default,
110            |scratch, i| {
111                let query: Vec<f32> = query_latent.row(i).iter().copied().collect();
112                if query.iter().all(|&x| x == 0.0) {
113                    return Ok(QueryNeighbours {
114                        ids: Vec::new(),
115                        weights: Vec::new(),
116                    });
117                }
118                let (ids, distances) = ref_dict
119                    .search_by_query_data_reuse(&query, knn, scratch)
120                    .map_err(|e| anyhow::anyhow!("kNN search for query row {i}: {e}"))?;
121                let weights = dist_to_softmax_weights(&distances, cfg.temperature);
122                Ok(QueryNeighbours { ids, weights })
123            },
124        )
125        .collect::<anyhow::Result<_>>()?;
126    let n_skipped = neighbours.iter().filter(|n| n.ids.is_empty()).count();
127    if n_skipped > 0 {
128        warn!(
129            "{n_skipped} of {n_query} query rows have a zero latent and were skipped; \
130             their imputed rows stay zero"
131        );
132    }
133
134    // Heads-up before the big allocation: dense [N_query, G_ref] f32 grows
135    // fast. Streaming-sparse output is a future optimization; for now we
136    // materialize and let the caller write whole.
137    let bytes_est = (n_query * g_ref).saturating_mul(4);
138    if bytes_est > (1 << 30) {
139        warn!(
140            "imputed dense matrix will allocate ~{} MB ({n_query} × {g_ref} f32). \
141             Consider reducing the query size or the reference feature set if \
142             memory is tight.",
143            bytes_est >> 20,
144        );
145    }
146
147    // Invert the (query row → neighbours) map so column-streaming the
148    // reference can do `consumers[ref_id]` lookups in O(1) and skip whole
149    // chunks where no consumer touches any of the chunk's cells.
150    let mut cell_to_consumers: Vec<Vec<(u32, f32)>> = vec![Vec::new(); n_ref];
151    for (query_id, nbr) in neighbours.iter().enumerate() {
152        for (&c, &w) in nbr.ids.iter().zip(nbr.weights.iter()) {
153            cell_to_consumers[c as usize].push((query_id as u32, w));
154        }
155    }
156
157    // Accumulate TRANSPOSED, [G_ref, N_query]: each query row is then one
158    // contiguous column, so a reference column's ascending row indices write
159    // ascending contiguous addresses, and disjoint query rows let the
160    // per-chunk accumulation run in parallel with no locking. One transpose
161    // at the end pays a single O(N×G) pass for a cache-friendly hot loop.
162    let mut imputed_t = Mat::zeros(g_ref, n_query);
163    // Per-chunk (reference-local column, weight) work lists, indexed by
164    // query; allocated once, cleared inside the parallel pass below.
165    let mut groups: Vec<Vec<(u32, f32)>> = vec![Vec::new(); n_query];
166    let chunk_size = cfg.chunk.max(64);
167    let mut col_lb = 0;
168    while col_lb < n_ref {
169        let col_ub = (col_lb + chunk_size).min(n_ref);
170        // Skip the read entirely if no consumer touches this chunk —
171        // common when N_query × knn ≪ N_ref.
172        if cell_to_consumers[col_lb..col_ub]
173            .iter()
174            .all(std::vec::Vec::is_empty)
175        {
176            col_lb = col_ub;
177            continue;
178        }
179        let csc = ref_data.read_columns_csc(col_lb..col_ub)?;
180
181        for c_local in 0..(col_ub - col_lb) {
182            for &(query_id, w) in &cell_to_consumers[col_lb + c_local] {
183                groups[query_id as usize].push((c_local as u32, w));
184            }
185        }
186
187        imputed_t
188            .as_mut_slice()
189            .par_chunks_mut(g_ref)
190            .zip(groups.par_iter_mut())
191            .for_each(|(query_col, grp)| {
192                for &(c_local, w) in grp.iter() {
193                    let col = csc.col(c_local as usize);
194                    for (&row_id, &v) in col.row_indices().iter().zip(col.values().iter()) {
195                        query_col[row_id] += w * v;
196                    }
197                }
198                // Cleared here (in parallel, capacity kept) so the next
199                // chunk's regroup starts from empty lists.
200                grp.clear();
201            });
202
203        col_lb = col_ub;
204    }
205
206    Ok(imputed_t.transpose())
207}
208
209/// Convert kNN L2 distances to a positive-weight simplex via
210/// `w_k ∝ exp(-d_k² / (2 τ²))`, normalised to sum to 1.
211fn dist_to_softmax_weights(distances: &[f32], temperature: f32) -> Vec<f32> {
212    if distances.is_empty() {
213        return Vec::new();
214    }
215    let tau = temperature.max(1e-6);
216    let scale = 1.0 / (2.0 * tau * tau);
217    let mut max = f32::NEG_INFINITY;
218    let mut out: Vec<f32> = distances
219        .iter()
220        .map(|d| {
221            let v = -d * d * scale;
222            if v > max {
223                max = v;
224            }
225            v
226        })
227        .collect();
228    let mut sum = 0.0f32;
229    for x in &mut out {
230        *x = (*x - max).exp();
231        sum += *x;
232    }
233    let inv = 1.0 / sum.max(1e-12);
234    for x in &mut out {
235        *x *= inv;
236    }
237    out
238}
239
240#[cfg(test)]
241#[path = "retrieval_impute_tests.rs"]
242mod tests;