1use crate::sparse_data_visitors::*;
2use crate::sparse_io::*;
3use crate::sparse_io_vector::*;
4
5use indicatif::ParallelProgressIterator;
6use legume_numeric::matrix::sparse_stat::{SparseColumnRunningStatistics, SparseRunningStatistics};
7use legume_numeric::matrix::traits::RunningStatOps;
8use legume_numeric::matrix::utils::partition_by_membership;
9use log::warn;
10use rayon::prelude::*;
11use std::sync::{Arc, Mutex};
12
13use rustc_hash::FxHashMap as HashMap;
14
15#[derive(Clone)]
16pub struct SqueezeCutoffs {
17 pub row: usize,
18 pub column: usize,
19}
20
21pub fn squeeze_by_nnz(
23 data: &dyn SparseIo<IndexIter = Vec<usize>>,
24 cutoffs: SqueezeCutoffs,
25 block_size: Option<usize>,
26 preload: bool,
27) -> anyhow::Result<()> {
28 let col_stat = collect_column_stat(data, block_size)?;
29 let row_stat = collect_row_stat(data, block_size)?;
30
31 let file = data.get_backend_file_name();
32 let backend = data.backend_type();
33
34 let mut data = open_sparse_matrix(file, &backend)?;
35 if preload {
36 data.preload_columns()?;
37 }
38
39 fn nnz_index(nnz: &[f32], cutoff: usize) -> Option<Vec<usize>> {
40 let ret: Vec<usize> = nnz
41 .iter()
42 .enumerate()
43 .filter(|&(_, &x)| (x as usize) >= cutoff)
44 .map(|(i, _)| i)
45 .collect();
46
47 (!ret.is_empty()).then_some(ret)
48 }
49
50 let row_nnz_vec = row_stat.count_positives();
51 let col_nnz_vec = col_stat.count_positives();
52 let row_idx = nnz_index(&row_nnz_vec, cutoffs.row);
53 let col_idx = nnz_index(&col_nnz_vec, cutoffs.column);
54
55 if row_idx.is_none() {
56 warn!(
57 "No rows can be kept with this cutoff {}!\n\
58 \n\
59 We will stop squeezing on the rows.\n\
60 \n",
61 cutoffs.row
62 );
63 }
64
65 if col_idx.is_none() {
66 warn!(
67 "No columns can be kept with this cutoff {}!\n\
68 \n\
69 We will stop squeezing on the columns.\n\
70 \n",
71 cutoffs.column
72 );
73 }
74
75 data.subset_columns_rows(col_idx.as_ref(), row_idx.as_ref())
76}
77
78pub fn collect_row_stat_across_vec(
82 data: &SparseIoVec,
83 block_size: Option<usize>,
84) -> anyhow::Result<SparseRunningStatistics<f32>> {
85 let mut row_stat = SparseRunningStatistics::new(data.num_rows());
86 data.visit_columns_by_block(
87 &row_stat_vec_visitor,
88 &EmptyArgs {},
89 &mut row_stat,
90 block_size,
91 )?;
92 Ok(row_stat)
93}
94
95#[allow(clippy::type_complexity)]
100pub fn collect_stratified_row_stat_across_vec(
101 data: &SparseIoVec,
102 column_membership: &HashMap<Box<str>, Box<str>>,
103 block_size: Option<usize>,
104) -> anyhow::Result<(Vec<Box<str>>, Vec<SparseRunningStatistics<f32>>)> {
105 let column_names = data.column_names()?;
106 let default = "".to_string().into_boxed_str();
107 let membership = column_names
108 .into_iter()
109 .map(|k| column_membership.get(&k).unwrap_or(&default).clone())
110 .collect::<Vec<_>>();
111
112 let partitions = partition_by_membership(&membership, None);
113 let mut group_names = Vec::with_capacity(partitions.len());
114 let mut group_stats = Vec::with_capacity(partitions.len());
115 let num_features = data.num_rows();
116
117 for (k, cols) in partitions {
118 let jobs = create_jobs(cols.len(), num_features, block_size);
119 let mut row_stat = SparseRunningStatistics::new(data.num_rows());
120 let arc_stat = Arc::new(Mutex::new(&mut row_stat));
121
122 jobs.par_iter()
123 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
124 .for_each(|&(lb, ub)| {
125 let cols_sub = cols[lb..ub].iter().cloned();
126 let csc = data
127 .read_columns_csc(cols_sub)
128 .expect("failed to read data");
129 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
130 stat.add_csc(&csc);
131 });
132
133 group_names.push(k);
134 group_stats.push(row_stat);
135 }
136
137 Ok((group_names, group_stats))
138}
139
140pub fn collect_row_stat(
144 data: &dyn SparseIo<IndexIter = Vec<usize>>,
145 block_size: Option<usize>,
146) -> anyhow::Result<SparseRunningStatistics<f32>> {
147 let nrows = data.num_rows().unwrap_or(0);
148 let mut row_stat = SparseRunningStatistics::new(nrows);
149 let arc_stat = Arc::new(Mutex::new(&mut row_stat));
150
151 let jobs = create_jobs(data.num_columns().unwrap_or(0), nrows, block_size);
152
153 jobs.par_iter()
154 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
155 .for_each(|&(lb, ub)| {
156 let csc = data
157 .read_columns_csc((lb..ub).collect())
158 .expect("failed to read data");
159 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
160 stat.add_csc(&csc);
161 });
162
163 Ok(row_stat)
164}
165
166pub fn collect_column_stat_across_vec(
171 data: &SparseIoVec,
172 select_rows: Option<&[usize]>,
173 block_size: Option<usize>,
174) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
175 let ncols = data.num_columns();
176 let nrows_total = data.num_rows();
177
178 let row_mask: Option<Vec<bool>> = select_rows.map(|sel| {
179 let mut m = vec![false; nrows_total];
180 for &r in sel {
181 if r < nrows_total {
182 m[r] = true;
183 }
184 }
185 m
186 });
187 let nrows_denom = row_mask
188 .as_ref()
189 .map(|m| m.iter().filter(|x| **x).count())
190 .unwrap_or(nrows_total);
191
192 let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows_denom);
193 data.visit_columns_by_block(&col_stat_visitor, &row_mask, &mut col_stat, block_size)?;
194 Ok(col_stat)
195}
196
197pub fn collect_column_stat(
201 data: &dyn SparseIo<IndexIter = Vec<usize>>,
202 block_size: Option<usize>,
203) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
204 let ncols = data.num_columns().unwrap_or(0);
205 let nrows = data.num_rows().unwrap_or(0);
206 let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows);
207 let arc_stat = Arc::new(Mutex::new(&mut col_stat));
208
209 let jobs = create_jobs(ncols, nrows, block_size);
210
211 jobs.par_iter()
212 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
213 .for_each(|&(lb, ub)| {
214 let csc = data
215 .read_columns_csc((lb..ub).collect())
216 .expect("failed to read data");
217 let mut stat = arc_stat.lock().expect("failed to lock col_stat");
218 stat.add_csc(&csc, lb);
219 });
220
221 Ok(col_stat)
222}
223
224struct EmptyArgs {}
225
226fn row_stat_vec_visitor(
227 job: (usize, usize),
228 data: &SparseIoVec,
229 _: &EmptyArgs,
230 arc_stat: Arc<Mutex<&mut SparseRunningStatistics<f32>>>,
231) -> anyhow::Result<()> {
232 let (lb, ub) = job;
233 let csc = data.read_columns_csc(lb..ub)?;
234
235 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
236 stat.add_csc(&csc);
237 Ok(())
238}
239
240fn col_stat_visitor(
241 job: (usize, usize),
242 data: &SparseIoVec,
243 row_mask: &Option<Vec<bool>>,
244 arc_stat: Arc<Mutex<&mut SparseColumnRunningStatistics<f32>>>,
245) -> anyhow::Result<()> {
246 let (lb, ub) = job;
247 let csc = data.read_columns_csc(lb..ub)?;
248 let mut stat = arc_stat.lock().expect("failed to lock col_stat");
249 match row_mask {
250 Some(mask) => stat.add_csc_masked(&csc, lb, mask),
251 None => stat.add_csc(&csc, lb),
252 }
253 Ok(())
254}
255
256const TROUGH_BIN: f64 = 0.05;
263const TROUGH_SMOOTH_BINS: f64 = 3.0;
265const TROUGH_MAX_DEPTH: f64 = 0.25;
267const TROUGH_MIN_PEAK_RATIO: f64 = 10.0;
270const TROUGH_MIN_SIDE: usize = 20;
272
273pub fn suggest_nnz_cutoff(nnz: &[f32]) -> Option<usize> {
290 let n = nnz.len();
291 let min_side = TROUGH_MIN_SIDE.max(n / 1000);
292 if n < 2 * min_side {
293 return None;
294 }
295
296 let mut vals: Vec<u64> = nnz.iter().map(|&x| x.max(0.0).round() as u64).collect();
297 vals.sort_unstable();
298 let (vmin, vmax) = (vals[0], vals[n - 1]);
299 if vmin == vmax {
300 return None;
301 }
302
303 let edge = |v: u64, half: f64| (v as f64 + 1.0 + half).ln();
305 let lo = edge(vmin, -0.5);
306 let nbins = ((edge(vmax, 0.5) - lo) / TROUGH_BIN).ceil() as usize;
307 let mut hist = vec![0.0_f64; nbins];
308 let mut i = 0;
309 while i < n {
310 let v = vals[i];
311 let mut j = i;
312 while j < n && vals[j] == v {
313 j += 1;
314 }
315 let a = (edge(v, -0.5) - lo) / TROUGH_BIN;
316 let b = (edge(v, 0.5) - lo) / TROUGH_BIN;
317 let mass = (j - i) as f64 / (b - a);
318 for (k, h) in hist
319 .iter_mut()
320 .enumerate()
321 .take((b.ceil() as usize).min(nbins))
322 .skip(a.floor() as usize)
323 {
324 let overlap = b.min(k as f64 + 1.0) - a.max(k as f64);
325 if overlap > 0.0 {
326 *h += mass * overlap;
327 }
328 }
329 i = j;
330 }
331
332 let radius = (3.0 * TROUGH_SMOOTH_BINS).ceil() as isize;
334 let kernel: Vec<f64> = (-radius..=radius)
335 .map(|d| (-0.5 * (d as f64 / TROUGH_SMOOTH_BINS).powi(2)).exp())
336 .collect();
337 let smooth: Vec<f64> = (0..nbins as isize)
338 .map(|k| {
339 (-radius..=radius)
340 .filter_map(|d| {
341 let t = k + d;
342 (0..nbins as isize)
343 .contains(&t)
344 .then(|| hist[t as usize] * kernel[(d + radius) as usize])
345 })
346 .sum()
347 })
348 .collect();
349
350 let mut below = vec![0.0_f64; nbins + 1];
352 for k in 0..nbins {
353 below[k + 1] = below[k] + hist[k];
354 }
355 let mut left_peak = vec![(0.0_f64, 0usize); nbins];
356 for k in 0..nbins {
357 let prev = if k > 0 { left_peak[k - 1] } else { (-1.0, 0) };
358 left_peak[k] = if smooth[k] > prev.0 {
359 (smooth[k], k)
360 } else {
361 prev
362 };
363 }
364 let mut right_peak = vec![(0.0_f64, 0usize); nbins];
365 for k in (0..nbins).rev() {
366 let next = if k + 1 < nbins {
367 right_peak[k + 1]
368 } else {
369 (-1.0, k)
370 };
371 right_peak[k] = if smooth[k] >= next.0 {
372 (smooth[k], k)
373 } else {
374 next
375 };
376 }
377
378 let min_bins_apart = TROUGH_MIN_PEAK_RATIO.ln() / TROUGH_BIN;
379 let total = below[nbins];
380 let mut best: Option<(f64, usize, usize)> = None; for t in 1..nbins.saturating_sub(1) {
382 let (l_mass, r_mass) = (below[t], total - below[t + 1]);
383 if l_mass < min_side as f64 || r_mass < min_side as f64 {
384 continue;
385 }
386 let ((l_h, l_k), (r_h, r_k)) = (left_peak[t - 1], right_peak[t + 1]);
387 if ((r_k - l_k) as f64) < min_bins_apart || l_h <= 0.0 || r_h <= 0.0 {
388 continue;
389 }
390 let depth = smooth[t] / l_h.min(r_h);
391 match best {
392 Some((d, first, last)) if depth == d && last + 1 == t => best = Some((d, first, t)),
393 Some((d, _, _)) if depth >= d => {}
394 _ => best = Some((depth, t, t)),
395 }
396 }
397
398 let Some((depth, first, last)) = best else {
399 log::info!("nnz cell-calling: no two peaks a decade apart → unimodal, no cutoff");
400 return None;
401 };
402 let x = lo + ((first + last) as f64 / 2.0 + 0.5) * TROUGH_BIN;
404 let cutoff = (x.exp() - 1.0).ceil().max(1.0) as usize;
405 let favors_cut = depth <= TROUGH_MAX_DEPTH;
406 log::info!(
407 "nnz cell-calling: deepest trough at nnz {} (depth {:.3}) → {}",
408 cutoff,
409 depth,
410 if favors_cut {
411 format!("bimodal, cutoff at nnz {cutoff}")
412 } else {
413 "unimodal, no cutoff".to_string()
414 }
415 );
416 favors_cut.then_some(cutoff)
417}
418
419struct HistBin {
421 val_min: f32,
422 val_max: f32,
423 log_val: f64,
424 count: usize,
425 is_cutoff: bool,
426}
427
428fn create_log_histogram(values: &[f32], cutoff: usize) -> Vec<HistBin> {
432 let cutoff_log = ((cutoff as f64 + 1.0).log10() * 10.0).round() as i32;
433
434 let mut bins: std::collections::BTreeMap<i32, (usize, f32, f32)> =
436 std::collections::BTreeMap::new();
437
438 for &val in values {
439 let log_val = ((val as f64 + 1.0).log10() * 10.0).round() as i32;
440 let entry = bins
441 .entry(log_val)
442 .or_insert((0, f32::INFINITY, f32::NEG_INFINITY));
443 entry.0 += 1;
444 entry.1 = entry.1.min(val);
445 entry.2 = entry.2.max(val);
446 }
447
448 let cutoff_bin = (cutoff > 0)
452 .then(|| bins.keys().copied().find(|&b| b >= cutoff_log))
453 .flatten();
454
455 bins.into_iter()
456 .map(|(bin, (count, val_min, val_max))| HistBin {
457 val_min,
458 val_max,
459 log_val: bin as f64 / 10.0,
460 count,
461 is_cutoff: Some(bin) == cutoff_bin,
462 })
463 .collect()
464}
465
466fn fmt_stat(v: f32) -> String {
469 if v.fract() == 0.0 {
470 (v as i64).to_string()
471 } else {
472 format!("{:.2}", v)
473 }
474}
475
476pub fn print_nnz_summary(
484 label: &str,
485 metric: &str,
486 values: &[f32],
487 cutoff: usize,
488 suggested: Option<usize>,
489) {
490 const MAX_BAR_WIDTH: usize = 50; let total = values.len();
493 let below_cutoff = values.iter().filter(|&&x| (x as usize) < cutoff).count();
494 let pct_removed = if total > 0 {
495 100.0 * below_cutoff as f64 / total as f64
496 } else {
497 0.0
498 };
499
500 let min = values.iter().copied().fold(f32::INFINITY, f32::min);
502 let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
503 let sum: f32 = values.iter().sum();
504 let mean = if total > 0 { sum / total as f32 } else { 0.0 };
505
506 let mut sorted = values.to_vec();
508 sorted.sort_by(|a, b| a.total_cmp(b));
509 let median = if total > 0 {
510 if total.is_multiple_of(2) {
511 (sorted[total / 2 - 1] + sorted[total / 2]) / 2.0
512 } else {
513 sorted[total / 2]
514 }
515 } else {
516 0.0
517 };
518
519 println!("{} {} distribution:", label, metric);
520 println!(" Total: {}", total);
521 println!(
522 " Min: {}, Max: {}, Mean: {:.2}, Median: {:.2}",
523 fmt_stat(min),
524 fmt_stat(max),
525 mean,
526 median
527 );
528 if cutoff > 0 {
529 println!(
530 " Cutoff: {} (removes {} / {} = {:.2}%)",
531 cutoff, below_cutoff, total, pct_removed
532 );
533 }
534 if let Some(s) = suggested {
535 let below_s = values.iter().filter(|&&x| (x as usize) < s).count();
536 let pct_s = if total > 0 {
537 100.0 * below_s as f64 / total as f64
538 } else {
539 0.0
540 };
541 println!(
542 " Suggested cutoff (histogram trough of {}): {} (would remove {} / {} = {:.2}%)",
543 metric, s, below_s, total, pct_s
544 );
545 }
546
547 let hist = create_log_histogram(values, cutoff);
549
550 let max_log_count = hist
552 .iter()
553 .map(|b| ((b.count as f64) + 1.0).log10())
554 .fold(0.0_f64, f64::max)
555 .max(1e-9);
556
557 println!(
558 " Histogram (x: actual {m} range [log10({m}+1)], bar: log10(count+1)):",
559 m = metric
560 );
561 for b in hist {
562 let marker = if b.is_cutoff { " <-- CUTOFF" } else { "" };
563 let log_count = ((b.count as f64) + 1.0).log10();
564 let bar_width = ((log_count / max_log_count) * MAX_BAR_WIDTH as f64).round() as usize;
565 let bar_width = if b.count > 0 { bar_width.max(1) } else { 0 };
566 let bar = "█".repeat(bar_width);
567 let range = if b.val_min == b.val_max {
568 fmt_stat(b.val_min)
569 } else {
570 format!("{}-{}", fmt_stat(b.val_min), fmt_stat(b.val_max))
571 };
572 println!(
573 " {:>9} [{:>4.2}]: {:>6} {}{}",
574 range, b.log_val, b.count, bar, marker
575 );
576 }
577}
578
579#[cfg(test)]
580mod tests;