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 {
45 ids: Vec<u32>,
46 weights: Vec<f32>,
47}
48
49pub 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 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 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 .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 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 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 let mut imputed_t = Mat::zeros(g_ref, n_query);
163 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 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 grp.clear();
201 });
202
203 col_lb = col_ub;
204 }
205
206 Ok(imputed_t.transpose())
207}
208
209fn 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;