data_beans/alg/
retrieval_impute.rs1use 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 pub knn: usize,
36 pub temperature: f32,
38 pub chunk: usize,
40}
41
42struct QueryNeighbours {
44 ids: Vec<u32>,
45 weights: Vec<f32>,
46}
47
48fn 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
64pub 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 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 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 let neighbours: Vec<Option<QueryNeighbours>> = (0..n_query)
122 .into_par_iter()
123 .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 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 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 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 let mut imputed_t = Mat::zeros(g_ref, n_query);
188 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 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 grp.clear();
226 });
227
228 col_lb = col_ub;
229 }
230
231 Ok(imputed_t.transpose())
232}
233
234fn 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;