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
13#[allow(dead_code)]
16pub mod axis;
17pub use axis::Axis;
18
19use rustc_hash::FxHashMap as HashMap;
20
21#[derive(Clone)]
22pub struct SqueezeCutoffs {
23 pub row: usize,
24 pub column: usize,
25}
26
27pub fn below_nnz_cutoff(nnz: f32, cutoff: usize) -> bool {
31 (nnz as usize) < cutoff
32}
33
34pub fn squeeze_by_nnz(
36 data: &dyn SparseIo<IndexIter = Vec<usize>>,
37 cutoffs: SqueezeCutoffs,
38 block_size: Option<usize>,
39 preload: bool,
40) -> anyhow::Result<()> {
41 let col_stat = collect_column_stat(data, block_size)?;
42 let row_stat = collect_row_stat(data, block_size)?;
43
44 let file = data.get_backend_file_name();
45 let backend = data.backend_type();
46
47 let mut data = open_sparse_matrix(file, &backend)?;
48 if preload {
49 data.preload_columns()?;
50 }
51
52 fn nnz_index(nnz: &[f32], cutoff: usize) -> Option<Vec<usize>> {
53 let ret: Vec<usize> = nnz
54 .iter()
55 .enumerate()
56 .filter(|&(_, &x)| !below_nnz_cutoff(x, cutoff))
57 .map(|(i, _)| i)
58 .collect();
59
60 (!ret.is_empty()).then_some(ret)
61 }
62
63 let row_nnz_vec = row_stat.count_positives();
64 let col_nnz_vec = col_stat.count_positives();
65 let row_idx = nnz_index(&row_nnz_vec, cutoffs.row);
66 let col_idx = nnz_index(&col_nnz_vec, cutoffs.column);
67
68 if row_idx.is_none() {
69 warn!(
70 "No rows can be kept with this cutoff {}!\n\
71 \n\
72 We will stop squeezing on the rows.\n\
73 \n",
74 cutoffs.row
75 );
76 }
77
78 if col_idx.is_none() {
79 warn!(
80 "No columns can be kept with this cutoff {}!\n\
81 \n\
82 We will stop squeezing on the columns.\n\
83 \n",
84 cutoffs.column
85 );
86 }
87
88 data.subset_columns_rows(col_idx.as_ref(), row_idx.as_ref())
89}
90
91pub fn collect_row_stat_across_vec(
95 data: &SparseIoVec,
96 block_size: Option<usize>,
97) -> anyhow::Result<SparseRunningStatistics<f32>> {
98 let mut row_stat = SparseRunningStatistics::new(data.num_rows());
99 data.visit_columns_by_block(
100 &row_stat_vec_visitor,
101 &EmptyArgs {},
102 &mut row_stat,
103 block_size,
104 )?;
105 Ok(row_stat)
106}
107
108#[allow(clippy::type_complexity)]
113pub fn collect_stratified_row_stat_across_vec(
114 data: &SparseIoVec,
115 column_membership: &HashMap<Box<str>, Box<str>>,
116 block_size: Option<usize>,
117) -> anyhow::Result<(Vec<Box<str>>, Vec<SparseRunningStatistics<f32>>)> {
118 let column_names = data.column_names()?;
119 let default = "".to_string().into_boxed_str();
120 let membership = column_names
121 .into_iter()
122 .map(|k| column_membership.get(&k).unwrap_or(&default).clone())
123 .collect::<Vec<_>>();
124
125 let partitions = partition_by_membership(&membership, None);
126 let mut group_names = Vec::with_capacity(partitions.len());
127 let mut group_stats = Vec::with_capacity(partitions.len());
128 let num_features = data.num_rows();
129
130 for (k, cols) in partitions {
131 let jobs = create_jobs(cols.len(), num_features, block_size);
132 let mut row_stat = SparseRunningStatistics::new(data.num_rows());
133 let arc_stat = Arc::new(Mutex::new(&mut row_stat));
134
135 jobs.par_iter()
136 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
137 .for_each(|&(lb, ub)| {
138 let cols_sub = cols[lb..ub].iter().cloned();
139 let csc = data
140 .read_columns_csc(cols_sub)
141 .expect("failed to read data");
142 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
143 stat.add_csc(&csc);
144 });
145
146 group_names.push(k);
147 group_stats.push(row_stat);
148 }
149
150 Ok((group_names, group_stats))
151}
152
153pub fn collect_row_stat(
157 data: &dyn SparseIo<IndexIter = Vec<usize>>,
158 block_size: Option<usize>,
159) -> anyhow::Result<SparseRunningStatistics<f32>> {
160 let nrows = data.num_rows().unwrap_or(0);
161 let mut row_stat = SparseRunningStatistics::new(nrows);
162 let arc_stat = Arc::new(Mutex::new(&mut row_stat));
163
164 let jobs = create_jobs(data.num_columns().unwrap_or(0), nrows, block_size);
165
166 jobs.par_iter()
167 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
168 .for_each(|&(lb, ub)| {
169 let csc = data
170 .read_columns_csc((lb..ub).collect())
171 .expect("failed to read data");
172 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
173 stat.add_csc(&csc);
174 });
175
176 Ok(row_stat)
177}
178
179pub fn collect_column_stat_across_vec(
184 data: &SparseIoVec,
185 select_rows: Option<&[usize]>,
186 block_size: Option<usize>,
187) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
188 let ncols = data.num_columns();
189 let nrows_total = data.num_rows();
190
191 let row_mask: Option<Vec<bool>> = select_rows.map(|sel| {
192 let mut m = vec![false; nrows_total];
193 for &r in sel {
194 if r < nrows_total {
195 m[r] = true;
196 }
197 }
198 m
199 });
200 let nrows_denom = row_mask
201 .as_ref()
202 .map(|m| m.iter().filter(|x| **x).count())
203 .unwrap_or(nrows_total);
204
205 let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows_denom);
206 data.visit_columns_by_block(&col_stat_visitor, &row_mask, &mut col_stat, block_size)?;
207 Ok(col_stat)
208}
209
210pub fn collect_column_stat(
214 data: &dyn SparseIo<IndexIter = Vec<usize>>,
215 block_size: Option<usize>,
216) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
217 let ncols = data.num_columns().unwrap_or(0);
218 let nrows = data.num_rows().unwrap_or(0);
219 let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows);
220 let arc_stat = Arc::new(Mutex::new(&mut col_stat));
221
222 let jobs = create_jobs(ncols, nrows, block_size);
223
224 jobs.par_iter()
225 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
226 .for_each(|&(lb, ub)| {
227 let csc = data
228 .read_columns_csc((lb..ub).collect())
229 .expect("failed to read data");
230 let mut stat = arc_stat.lock().expect("failed to lock col_stat");
231 stat.add_csc(&csc, lb);
232 });
233
234 Ok(col_stat)
235}
236
237struct EmptyArgs {}
238
239fn row_stat_vec_visitor(
240 job: (usize, usize),
241 data: &SparseIoVec,
242 _: &EmptyArgs,
243 arc_stat: Arc<Mutex<&mut SparseRunningStatistics<f32>>>,
244) -> anyhow::Result<()> {
245 let (lb, ub) = job;
246 let csc = data.read_columns_csc(lb..ub)?;
247
248 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
249 stat.add_csc(&csc);
250 Ok(())
251}
252
253fn col_stat_visitor(
254 job: (usize, usize),
255 data: &SparseIoVec,
256 row_mask: &Option<Vec<bool>>,
257 arc_stat: Arc<Mutex<&mut SparseColumnRunningStatistics<f32>>>,
258) -> anyhow::Result<()> {
259 let (lb, ub) = job;
260 let csc = data.read_columns_csc(lb..ub)?;
261 let mut stat = arc_stat.lock().expect("failed to lock col_stat");
262 match row_mask {
263 Some(mask) => stat.add_csc_masked(&csc, lb, mask),
264 None => stat.add_csc(&csc, lb),
265 }
266 Ok(())
267}
268
269const TROUGH_BIN: f64 = 0.05;
276const TROUGH_SMOOTH_BINS: f64 = 3.0;
278const TROUGH_MAX_DEPTH: f64 = 0.25;
280const TROUGH_MIN_PEAK_RATIO: f64 = 10.0;
283const TROUGH_MIN_SIDE: usize = 20;
285
286pub fn suggest_nnz_cutoff(nnz: &[f32]) -> Option<usize> {
303 let n = nnz.len();
304 let min_side = TROUGH_MIN_SIDE.max(n / 1000);
305 if n < 2 * min_side {
306 return None;
307 }
308
309 let mut vals: Vec<u64> = nnz.iter().map(|&x| x.max(0.0).round() as u64).collect();
310 vals.sort_unstable();
311 let (vmin, vmax) = (vals[0], vals[n - 1]);
312 if vmin == vmax {
313 return None;
314 }
315
316 let edge = |v: u64, half: f64| (v as f64 + 1.0 + half).ln();
318 let lo = edge(vmin, -0.5);
319 let nbins = ((edge(vmax, 0.5) - lo) / TROUGH_BIN).ceil() as usize;
320 let mut hist = vec![0.0_f64; nbins];
321 let mut i = 0;
322 while i < n {
323 let v = vals[i];
324 let mut j = i;
325 while j < n && vals[j] == v {
326 j += 1;
327 }
328 let a = (edge(v, -0.5) - lo) / TROUGH_BIN;
329 let b = (edge(v, 0.5) - lo) / TROUGH_BIN;
330 let mass = (j - i) as f64 / (b - a);
331 for (k, h) in hist
332 .iter_mut()
333 .enumerate()
334 .take((b.ceil() as usize).min(nbins))
335 .skip(a.floor() as usize)
336 {
337 let overlap = b.min(k as f64 + 1.0) - a.max(k as f64);
338 if overlap > 0.0 {
339 *h += mass * overlap;
340 }
341 }
342 i = j;
343 }
344
345 let radius = (3.0 * TROUGH_SMOOTH_BINS).ceil() as isize;
347 let kernel: Vec<f64> = (-radius..=radius)
348 .map(|d| (-0.5 * (d as f64 / TROUGH_SMOOTH_BINS).powi(2)).exp())
349 .collect();
350 let smooth: Vec<f64> = (0..nbins as isize)
351 .map(|k| {
352 (-radius..=radius)
353 .filter_map(|d| {
354 let t = k + d;
355 (0..nbins as isize)
356 .contains(&t)
357 .then(|| hist[t as usize] * kernel[(d + radius) as usize])
358 })
359 .sum()
360 })
361 .collect();
362
363 let mut below = vec![0.0_f64; nbins + 1];
365 for k in 0..nbins {
366 below[k + 1] = below[k] + hist[k];
367 }
368 let mut left_peak = vec![(0.0_f64, 0usize); nbins];
369 for k in 0..nbins {
370 let prev = if k > 0 { left_peak[k - 1] } else { (-1.0, 0) };
371 left_peak[k] = if smooth[k] > prev.0 {
372 (smooth[k], k)
373 } else {
374 prev
375 };
376 }
377 let mut right_peak = vec![(0.0_f64, 0usize); nbins];
378 for k in (0..nbins).rev() {
379 let next = if k + 1 < nbins {
380 right_peak[k + 1]
381 } else {
382 (-1.0, k)
383 };
384 right_peak[k] = if smooth[k] >= next.0 {
385 (smooth[k], k)
386 } else {
387 next
388 };
389 }
390
391 let min_bins_apart = TROUGH_MIN_PEAK_RATIO.ln() / TROUGH_BIN;
392 let total = below[nbins];
393 let mut best: Option<(f64, usize, usize)> = None; for t in 1..nbins.saturating_sub(1) {
395 let (l_mass, r_mass) = (below[t], total - below[t + 1]);
396 if l_mass < min_side as f64 || r_mass < min_side as f64 {
397 continue;
398 }
399 let ((l_h, l_k), (r_h, r_k)) = (left_peak[t - 1], right_peak[t + 1]);
400 if ((r_k - l_k) as f64) < min_bins_apart || l_h <= 0.0 || r_h <= 0.0 {
401 continue;
402 }
403 let depth = smooth[t] / l_h.min(r_h);
404 match best {
405 Some((d, first, last)) if depth == d && last + 1 == t => best = Some((d, first, t)),
406 Some((d, _, _)) if depth >= d => {}
407 _ => best = Some((depth, t, t)),
408 }
409 }
410
411 let Some((depth, first, last)) = best else {
412 log::info!("nnz cell-calling: no two peaks a decade apart → unimodal, no cutoff");
413 return None;
414 };
415 let x = lo + ((first + last) as f64 / 2.0 + 0.5) * TROUGH_BIN;
417 let cutoff = (x.exp() - 1.0).ceil().max(1.0) as usize;
418 let favors_cut = depth <= TROUGH_MAX_DEPTH;
419 log::info!(
420 "nnz cell-calling: deepest trough at nnz {} (depth {:.3}) → {}",
421 cutoff,
422 depth,
423 if favors_cut {
424 format!("bimodal, cutoff at nnz {cutoff}")
425 } else {
426 "unimodal, no cutoff".to_string()
427 }
428 );
429 favors_cut.then_some(cutoff)
430}
431
432pub fn pct(part: usize, total: usize) -> f64 {
434 if total > 0 {
435 100.0 * part as f64 / total as f64
436 } else {
437 0.0
438 }
439}
440
441pub fn median_of_sorted(sorted: &[f32]) -> f32 {
443 let n = sorted.len();
444 match n {
445 0 => 0.0,
446 _ if n.is_multiple_of(2) => (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0,
447 _ => sorted[n / 2],
448 }
449}
450
451struct HistBin {
454 val_min: f32,
455 val_max: f32,
456 count: usize,
457 is_cutoff: bool,
458}
459
460const PRINTED_BINS: usize = 40;
462
463fn create_log_histogram(values: &[f32], cutoff: usize) -> Vec<HistBin> {
468 let axis = Axis::fit(values, true).including(cutoff as f64);
469 let mut bins: Vec<(usize, f32, f32)> =
470 vec![(0, f32::INFINITY, f32::NEG_INFINITY); PRINTED_BINS];
471 for &val in values.iter().filter(|v| v.is_finite()) {
472 let b = &mut bins[axis.bin(val as f64, PRINTED_BINS)];
473 b.0 += 1;
474 b.1 = b.1.min(val);
475 b.2 = b.2.max(val);
476 }
477 let cutoff_bin = (cutoff > 0)
481 .then(|| {
482 let at = axis.bin(cutoff as f64, PRINTED_BINS);
483 (at..PRINTED_BINS).find(|&k| bins[k].0 > 0)
484 })
485 .flatten();
486 bins.into_iter()
487 .enumerate()
488 .filter(|(_, b)| b.0 > 0)
489 .map(|(k, (count, val_min, val_max))| HistBin {
490 val_min,
491 val_max,
492 count,
493 is_cutoff: Some(k) == cutoff_bin,
494 })
495 .collect()
496}
497
498pub fn fmt_stat(v: f32, decimals: usize) -> String {
502 if v.fract() == 0.0 {
503 (v as i64).to_string()
504 } else {
505 format!("{:.*}", decimals, v)
506 }
507}
508
509fn fmt_sig(v: f32) -> String {
512 if v.fract() == 0.0 {
513 return (v as i64).to_string();
514 }
515 let places = (2 - (v.abs() as f64).log10().floor() as i32).max(0) as usize;
516 format!("{:.*}", places, v)
517}
518
519pub fn print_nnz_summary(
527 label: &str,
528 metric: &str,
529 values: &[f32],
530 cutoff: usize,
531 suggested: Option<usize>,
532) {
533 let _ = write_nnz_summary(
534 &mut std::io::stdout().lock(),
535 label,
536 metric,
537 values,
538 cutoff,
539 suggested,
540 );
541}
542
543pub fn eprint_nnz_summary(
547 label: &str,
548 metric: &str,
549 values: &[f32],
550 cutoff: usize,
551 suggested: Option<usize>,
552) {
553 let _ = write_nnz_summary(
554 &mut std::io::stderr().lock(),
555 label,
556 metric,
557 values,
558 cutoff,
559 suggested,
560 );
561}
562
563pub fn write_nnz_summary(
565 out: &mut impl std::io::Write,
566 label: &str,
567 metric: &str,
568 values: &[f32],
569 cutoff: usize,
570 suggested: Option<usize>,
571) -> std::io::Result<()> {
572 const MAX_BAR_WIDTH: usize = 50; let total = values.len();
575 let below_cutoff = values
576 .iter()
577 .filter(|&&x| below_nnz_cutoff(x, cutoff))
578 .count();
579 let pct_removed = pct(below_cutoff, total);
580
581 let min = values.iter().copied().fold(f32::INFINITY, f32::min);
583 let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
584 let sum: f32 = values.iter().sum();
585 let mean = if total > 0 { sum / total as f32 } else { 0.0 };
586
587 let mut sorted = values.to_vec();
588 sorted.sort_unstable_by(f32::total_cmp);
589 let median = median_of_sorted(&sorted);
590
591 writeln!(out, "{} {} distribution:", label, metric)?;
592 writeln!(out, " Total: {}", total)?;
593 writeln!(
594 out,
595 " Min: {}, Max: {}, Mean: {:.2}, Median: {:.2}",
596 fmt_stat(min, 2),
597 fmt_stat(max, 2),
598 mean,
599 median
600 )?;
601 if cutoff > 0 {
602 writeln!(
603 out,
604 " Cutoff: {} (removes {} / {} = {:.2}%)",
605 cutoff, below_cutoff, total, pct_removed
606 )?;
607 }
608 if let Some(s) = suggested {
609 let below_s = values.iter().filter(|&&x| below_nnz_cutoff(x, s)).count();
610 let pct_s = pct(below_s, total);
611 writeln!(
612 out,
613 " Suggested cutoff (histogram trough of {}): {} (would remove {} / {} = {:.2}%)",
614 metric, s, below_s, total, pct_s
615 )?;
616 }
617
618 let hist = create_log_histogram(values, cutoff);
620
621 let max_log_count = hist
623 .iter()
624 .map(|b| ((b.count as f64) + 1.0).log10())
625 .fold(0.0_f64, f64::max)
626 .max(1e-9);
627
628 writeln!(
629 out,
630 " Histogram (x: {m} in log-spaced bins, each its range; bar: log10(count+1)):",
631 m = metric
632 )?;
633 for b in hist {
634 let marker = if b.is_cutoff { " <-- CUTOFF" } else { "" };
635 let log_count = ((b.count as f64) + 1.0).log10();
636 let bar_width = ((log_count / max_log_count) * MAX_BAR_WIDTH as f64).round() as usize;
637 let bar_width = if b.count > 0 { bar_width.max(1) } else { 0 };
638 let bar = "█".repeat(bar_width);
639 let range = if b.val_min == b.val_max {
640 fmt_sig(b.val_min)
641 } else {
642 format!("{}-{}", fmt_sig(b.val_min), fmt_sig(b.val_max))
643 };
644 writeln!(out, " {:>17}: {:>6} {}{}", range, b.count, bar, marker)?;
645 }
646 Ok(())
647}
648
649#[cfg(test)]
650mod tests;