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 below_nnz_cutoff(nnz: f32, cutoff: usize) -> bool {
25 (nnz as usize) < cutoff
26}
27
28pub fn squeeze_by_nnz(
30 data: &dyn SparseIo<IndexIter = Vec<usize>>,
31 cutoffs: SqueezeCutoffs,
32 block_size: Option<usize>,
33 preload: bool,
34) -> anyhow::Result<()> {
35 let col_stat = collect_column_stat(data, block_size)?;
36 let row_stat = collect_row_stat(data, block_size)?;
37
38 let file = data.get_backend_file_name();
39 let backend = data.backend_type();
40
41 let mut data = open_sparse_matrix(file, &backend)?;
42 if preload {
43 data.preload_columns()?;
44 }
45
46 fn nnz_index(nnz: &[f32], cutoff: usize) -> Option<Vec<usize>> {
47 let ret: Vec<usize> = nnz
48 .iter()
49 .enumerate()
50 .filter(|&(_, &x)| !below_nnz_cutoff(x, cutoff))
51 .map(|(i, _)| i)
52 .collect();
53
54 (!ret.is_empty()).then_some(ret)
55 }
56
57 let row_nnz_vec = row_stat.count_positives();
58 let col_nnz_vec = col_stat.count_positives();
59 let row_idx = nnz_index(&row_nnz_vec, cutoffs.row);
60 let col_idx = nnz_index(&col_nnz_vec, cutoffs.column);
61
62 if row_idx.is_none() {
63 warn!(
64 "No rows can be kept with this cutoff {}!\n\
65 \n\
66 We will stop squeezing on the rows.\n\
67 \n",
68 cutoffs.row
69 );
70 }
71
72 if col_idx.is_none() {
73 warn!(
74 "No columns can be kept with this cutoff {}!\n\
75 \n\
76 We will stop squeezing on the columns.\n\
77 \n",
78 cutoffs.column
79 );
80 }
81
82 data.subset_columns_rows(col_idx.as_ref(), row_idx.as_ref())
83}
84
85pub fn collect_row_stat_across_vec(
89 data: &SparseIoVec,
90 block_size: Option<usize>,
91) -> anyhow::Result<SparseRunningStatistics<f32>> {
92 let mut row_stat = SparseRunningStatistics::new(data.num_rows());
93 data.visit_columns_by_block(
94 &row_stat_vec_visitor,
95 &EmptyArgs {},
96 &mut row_stat,
97 block_size,
98 )?;
99 Ok(row_stat)
100}
101
102#[allow(clippy::type_complexity)]
107pub fn collect_stratified_row_stat_across_vec(
108 data: &SparseIoVec,
109 column_membership: &HashMap<Box<str>, Box<str>>,
110 block_size: Option<usize>,
111) -> anyhow::Result<(Vec<Box<str>>, Vec<SparseRunningStatistics<f32>>)> {
112 let column_names = data.column_names()?;
113 let default = "".to_string().into_boxed_str();
114 let membership = column_names
115 .into_iter()
116 .map(|k| column_membership.get(&k).unwrap_or(&default).clone())
117 .collect::<Vec<_>>();
118
119 let partitions = partition_by_membership(&membership, None);
120 let mut group_names = Vec::with_capacity(partitions.len());
121 let mut group_stats = Vec::with_capacity(partitions.len());
122 let num_features = data.num_rows();
123
124 for (k, cols) in partitions {
125 let jobs = create_jobs(cols.len(), num_features, block_size);
126 let mut row_stat = SparseRunningStatistics::new(data.num_rows());
127 let arc_stat = Arc::new(Mutex::new(&mut row_stat));
128
129 jobs.par_iter()
130 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
131 .for_each(|&(lb, ub)| {
132 let cols_sub = cols[lb..ub].iter().cloned();
133 let csc = data
134 .read_columns_csc(cols_sub)
135 .expect("failed to read data");
136 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
137 stat.add_csc(&csc);
138 });
139
140 group_names.push(k);
141 group_stats.push(row_stat);
142 }
143
144 Ok((group_names, group_stats))
145}
146
147pub fn collect_row_stat(
151 data: &dyn SparseIo<IndexIter = Vec<usize>>,
152 block_size: Option<usize>,
153) -> anyhow::Result<SparseRunningStatistics<f32>> {
154 let nrows = data.num_rows().unwrap_or(0);
155 let mut row_stat = SparseRunningStatistics::new(nrows);
156 let arc_stat = Arc::new(Mutex::new(&mut row_stat));
157
158 let jobs = create_jobs(data.num_columns().unwrap_or(0), nrows, block_size);
159
160 jobs.par_iter()
161 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
162 .for_each(|&(lb, ub)| {
163 let csc = data
164 .read_columns_csc((lb..ub).collect())
165 .expect("failed to read data");
166 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
167 stat.add_csc(&csc);
168 });
169
170 Ok(row_stat)
171}
172
173pub fn collect_column_stat_across_vec(
178 data: &SparseIoVec,
179 select_rows: Option<&[usize]>,
180 block_size: Option<usize>,
181) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
182 let ncols = data.num_columns();
183 let nrows_total = data.num_rows();
184
185 let row_mask: Option<Vec<bool>> = select_rows.map(|sel| {
186 let mut m = vec![false; nrows_total];
187 for &r in sel {
188 if r < nrows_total {
189 m[r] = true;
190 }
191 }
192 m
193 });
194 let nrows_denom = row_mask
195 .as_ref()
196 .map(|m| m.iter().filter(|x| **x).count())
197 .unwrap_or(nrows_total);
198
199 let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows_denom);
200 data.visit_columns_by_block(&col_stat_visitor, &row_mask, &mut col_stat, block_size)?;
201 Ok(col_stat)
202}
203
204pub fn collect_column_stat(
208 data: &dyn SparseIo<IndexIter = Vec<usize>>,
209 block_size: Option<usize>,
210) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
211 let ncols = data.num_columns().unwrap_or(0);
212 let nrows = data.num_rows().unwrap_or(0);
213 let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows);
214 let arc_stat = Arc::new(Mutex::new(&mut col_stat));
215
216 let jobs = create_jobs(ncols, nrows, block_size);
217
218 jobs.par_iter()
219 .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
220 .for_each(|&(lb, ub)| {
221 let csc = data
222 .read_columns_csc((lb..ub).collect())
223 .expect("failed to read data");
224 let mut stat = arc_stat.lock().expect("failed to lock col_stat");
225 stat.add_csc(&csc, lb);
226 });
227
228 Ok(col_stat)
229}
230
231struct EmptyArgs {}
232
233fn row_stat_vec_visitor(
234 job: (usize, usize),
235 data: &SparseIoVec,
236 _: &EmptyArgs,
237 arc_stat: Arc<Mutex<&mut SparseRunningStatistics<f32>>>,
238) -> anyhow::Result<()> {
239 let (lb, ub) = job;
240 let csc = data.read_columns_csc(lb..ub)?;
241
242 let mut stat = arc_stat.lock().expect("failed to lock row_stat");
243 stat.add_csc(&csc);
244 Ok(())
245}
246
247fn col_stat_visitor(
248 job: (usize, usize),
249 data: &SparseIoVec,
250 row_mask: &Option<Vec<bool>>,
251 arc_stat: Arc<Mutex<&mut SparseColumnRunningStatistics<f32>>>,
252) -> anyhow::Result<()> {
253 let (lb, ub) = job;
254 let csc = data.read_columns_csc(lb..ub)?;
255 let mut stat = arc_stat.lock().expect("failed to lock col_stat");
256 match row_mask {
257 Some(mask) => stat.add_csc_masked(&csc, lb, mask),
258 None => stat.add_csc(&csc, lb),
259 }
260 Ok(())
261}
262
263const TROUGH_BIN: f64 = 0.05;
270const TROUGH_SMOOTH_BINS: f64 = 3.0;
272const TROUGH_MAX_DEPTH: f64 = 0.25;
274const TROUGH_MIN_PEAK_RATIO: f64 = 10.0;
277const TROUGH_MIN_SIDE: usize = 20;
279
280pub fn suggest_nnz_cutoff(nnz: &[f32]) -> Option<usize> {
297 let n = nnz.len();
298 let min_side = TROUGH_MIN_SIDE.max(n / 1000);
299 if n < 2 * min_side {
300 return None;
301 }
302
303 let mut vals: Vec<u64> = nnz.iter().map(|&x| x.max(0.0).round() as u64).collect();
304 vals.sort_unstable();
305 let (vmin, vmax) = (vals[0], vals[n - 1]);
306 if vmin == vmax {
307 return None;
308 }
309
310 let edge = |v: u64, half: f64| (v as f64 + 1.0 + half).ln();
312 let lo = edge(vmin, -0.5);
313 let nbins = ((edge(vmax, 0.5) - lo) / TROUGH_BIN).ceil() as usize;
314 let mut hist = vec![0.0_f64; nbins];
315 let mut i = 0;
316 while i < n {
317 let v = vals[i];
318 let mut j = i;
319 while j < n && vals[j] == v {
320 j += 1;
321 }
322 let a = (edge(v, -0.5) - lo) / TROUGH_BIN;
323 let b = (edge(v, 0.5) - lo) / TROUGH_BIN;
324 let mass = (j - i) as f64 / (b - a);
325 for (k, h) in hist
326 .iter_mut()
327 .enumerate()
328 .take((b.ceil() as usize).min(nbins))
329 .skip(a.floor() as usize)
330 {
331 let overlap = b.min(k as f64 + 1.0) - a.max(k as f64);
332 if overlap > 0.0 {
333 *h += mass * overlap;
334 }
335 }
336 i = j;
337 }
338
339 let radius = (3.0 * TROUGH_SMOOTH_BINS).ceil() as isize;
341 let kernel: Vec<f64> = (-radius..=radius)
342 .map(|d| (-0.5 * (d as f64 / TROUGH_SMOOTH_BINS).powi(2)).exp())
343 .collect();
344 let smooth: Vec<f64> = (0..nbins as isize)
345 .map(|k| {
346 (-radius..=radius)
347 .filter_map(|d| {
348 let t = k + d;
349 (0..nbins as isize)
350 .contains(&t)
351 .then(|| hist[t as usize] * kernel[(d + radius) as usize])
352 })
353 .sum()
354 })
355 .collect();
356
357 let mut below = vec![0.0_f64; nbins + 1];
359 for k in 0..nbins {
360 below[k + 1] = below[k] + hist[k];
361 }
362 let mut left_peak = vec![(0.0_f64, 0usize); nbins];
363 for k in 0..nbins {
364 let prev = if k > 0 { left_peak[k - 1] } else { (-1.0, 0) };
365 left_peak[k] = if smooth[k] > prev.0 {
366 (smooth[k], k)
367 } else {
368 prev
369 };
370 }
371 let mut right_peak = vec![(0.0_f64, 0usize); nbins];
372 for k in (0..nbins).rev() {
373 let next = if k + 1 < nbins {
374 right_peak[k + 1]
375 } else {
376 (-1.0, k)
377 };
378 right_peak[k] = if smooth[k] >= next.0 {
379 (smooth[k], k)
380 } else {
381 next
382 };
383 }
384
385 let min_bins_apart = TROUGH_MIN_PEAK_RATIO.ln() / TROUGH_BIN;
386 let total = below[nbins];
387 let mut best: Option<(f64, usize, usize)> = None; for t in 1..nbins.saturating_sub(1) {
389 let (l_mass, r_mass) = (below[t], total - below[t + 1]);
390 if l_mass < min_side as f64 || r_mass < min_side as f64 {
391 continue;
392 }
393 let ((l_h, l_k), (r_h, r_k)) = (left_peak[t - 1], right_peak[t + 1]);
394 if ((r_k - l_k) as f64) < min_bins_apart || l_h <= 0.0 || r_h <= 0.0 {
395 continue;
396 }
397 let depth = smooth[t] / l_h.min(r_h);
398 match best {
399 Some((d, first, last)) if depth == d && last + 1 == t => best = Some((d, first, t)),
400 Some((d, _, _)) if depth >= d => {}
401 _ => best = Some((depth, t, t)),
402 }
403 }
404
405 let Some((depth, first, last)) = best else {
406 log::info!("nnz cell-calling: no two peaks a decade apart → unimodal, no cutoff");
407 return None;
408 };
409 let x = lo + ((first + last) as f64 / 2.0 + 0.5) * TROUGH_BIN;
411 let cutoff = (x.exp() - 1.0).ceil().max(1.0) as usize;
412 let favors_cut = depth <= TROUGH_MAX_DEPTH;
413 log::info!(
414 "nnz cell-calling: deepest trough at nnz {} (depth {:.3}) → {}",
415 cutoff,
416 depth,
417 if favors_cut {
418 format!("bimodal, cutoff at nnz {cutoff}")
419 } else {
420 "unimodal, no cutoff".to_string()
421 }
422 );
423 favors_cut.then_some(cutoff)
424}
425
426pub fn log_bin_key(x: f64) -> i32 {
429 ((x + 1.0).log10() * 10.0).round() as i32
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 {
453 val_min: f32,
454 val_max: f32,
455 log_val: f64,
456 count: usize,
457 is_cutoff: bool,
458}
459
460fn create_log_histogram(values: &[f32], cutoff: usize) -> Vec<HistBin> {
464 let cutoff_log = log_bin_key(cutoff as f64);
465
466 let mut bins: std::collections::BTreeMap<i32, (usize, f32, f32)> =
468 std::collections::BTreeMap::new();
469
470 for &val in values {
471 let log_val = log_bin_key(val as f64);
472 let entry = bins
473 .entry(log_val)
474 .or_insert((0, f32::INFINITY, f32::NEG_INFINITY));
475 entry.0 += 1;
476 entry.1 = entry.1.min(val);
477 entry.2 = entry.2.max(val);
478 }
479
480 let cutoff_bin = (cutoff > 0)
484 .then(|| bins.keys().copied().find(|&b| b >= cutoff_log))
485 .flatten();
486
487 bins.into_iter()
488 .map(|(bin, (count, val_min, val_max))| HistBin {
489 val_min,
490 val_max,
491 log_val: bin as f64 / 10.0,
492 count,
493 is_cutoff: Some(bin) == 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
509pub fn print_nnz_summary(
517 label: &str,
518 metric: &str,
519 values: &[f32],
520 cutoff: usize,
521 suggested: Option<usize>,
522) {
523 const MAX_BAR_WIDTH: usize = 50; let total = values.len();
526 let below_cutoff = values
527 .iter()
528 .filter(|&&x| below_nnz_cutoff(x, cutoff))
529 .count();
530 let pct_removed = pct(below_cutoff, total);
531
532 let min = values.iter().copied().fold(f32::INFINITY, f32::min);
534 let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
535 let sum: f32 = values.iter().sum();
536 let mean = if total > 0 { sum / total as f32 } else { 0.0 };
537
538 let mut sorted = values.to_vec();
539 sorted.sort_unstable_by(f32::total_cmp);
540 let median = median_of_sorted(&sorted);
541
542 println!("{} {} distribution:", label, metric);
543 println!(" Total: {}", total);
544 println!(
545 " Min: {}, Max: {}, Mean: {:.2}, Median: {:.2}",
546 fmt_stat(min, 2),
547 fmt_stat(max, 2),
548 mean,
549 median
550 );
551 if cutoff > 0 {
552 println!(
553 " Cutoff: {} (removes {} / {} = {:.2}%)",
554 cutoff, below_cutoff, total, pct_removed
555 );
556 }
557 if let Some(s) = suggested {
558 let below_s = values.iter().filter(|&&x| below_nnz_cutoff(x, s)).count();
559 let pct_s = pct(below_s, total);
560 println!(
561 " Suggested cutoff (histogram trough of {}): {} (would remove {} / {} = {:.2}%)",
562 metric, s, below_s, total, pct_s
563 );
564 }
565
566 let hist = create_log_histogram(values, cutoff);
568
569 let max_log_count = hist
571 .iter()
572 .map(|b| ((b.count as f64) + 1.0).log10())
573 .fold(0.0_f64, f64::max)
574 .max(1e-9);
575
576 println!(
577 " Histogram (x: actual {m} range [log10({m}+1)], bar: log10(count+1)):",
578 m = metric
579 );
580 for b in hist {
581 let marker = if b.is_cutoff { " <-- CUTOFF" } else { "" };
582 let log_count = ((b.count as f64) + 1.0).log10();
583 let bar_width = ((log_count / max_log_count) * MAX_BAR_WIDTH as f64).round() as usize;
584 let bar_width = if b.count > 0 { bar_width.max(1) } else { 0 };
585 let bar = "█".repeat(bar_width);
586 let range = if b.val_min == b.val_max {
587 fmt_stat(b.val_min, 2)
588 } else {
589 format!("{}-{}", fmt_stat(b.val_min, 2), fmt_stat(b.val_max, 2))
590 };
591 println!(
592 " {:>9} [{:>4.2}]: {:>6} {}{}",
593 range, b.log_val, b.count, bar, marker
594 );
595 }
596}
597
598#[cfg(test)]
599mod tests;