1use clap::Args;
21use legume_numeric::matrix::dmatrix_util::build_columns_par;
22use legume_numeric::matrix::kmeans::kmeans_centroids_seeded;
23use legume_numeric::matrix::rand_util::mix_seed;
24use legume_numeric::matrix::traits::SampleOps;
25use log::{debug, info};
26use nalgebra::DMatrix;
27use rayon::prelude::*;
28use serde::{Deserialize, Serialize};
29
30type CscMat = nalgebra_sparse::CscMatrix<f32>;
31
32#[derive(Clone, Serialize, Deserialize)]
34pub struct FeatureCoarsening {
35 pub fine_to_coarse: Vec<usize>,
37 pub coarse_to_fine: Vec<Vec<usize>>,
39 pub num_coarse: usize,
41}
42
43impl FeatureCoarsening {
44 pub fn from_fine_to_coarse(
50 fine_to_coarse: Vec<usize>,
51 num_coarse: usize,
52 ) -> anyhow::Result<Self> {
53 let mut coarse_to_fine = vec![Vec::new(); num_coarse];
54 for (f, &c) in fine_to_coarse.iter().enumerate() {
55 anyhow::ensure!(
56 c < num_coarse,
57 "feature coarsening: feature {f} is assigned to group {c} of {num_coarse}"
58 );
59 coarse_to_fine[c].push(f);
60 }
61 Ok(Self {
62 fine_to_coarse,
63 coarse_to_fine,
64 num_coarse,
65 })
66 }
67
68 pub fn grow_by_profile(
84 &self,
85 new_to_old: &[Option<usize>],
86 unit_profiles: &[Vec<f32>],
87 ) -> anyhow::Result<FeatureCoarsening> {
88 let d_new = new_to_old.len();
89 anyhow::ensure!(
90 unit_profiles.len() == d_new,
91 "feature coarsening growth: {} profiles for {d_new} features",
92 unit_profiles.len(),
93 );
94 let k = self.num_coarse;
95 let n_pb = unit_profiles.first().map_or(0, Vec::len);
96
97 let mut fine_to_coarse = vec![usize::MAX; d_new];
100 let mut centroid = DMatrix::<f32>::zeros(n_pb, k);
101 let mut members = vec![0usize; k];
102 for (g, old) in new_to_old.iter().enumerate() {
103 let Some(p) = old else { continue };
104 anyhow::ensure!(
105 *p < self.fine_to_coarse.len(),
106 "feature coarsening growth: feature {g} maps to {p}, beyond the {} covered",
107 self.fine_to_coarse.len(),
108 );
109 let m = self.fine_to_coarse[*p];
110 fine_to_coarse[g] = m;
111 members[m] += 1;
112 for (c, v) in centroid.column_mut(m).iter_mut().zip(&unit_profiles[g]) {
113 *c += v;
114 }
115 }
116 let live: Vec<usize> = (0..k).filter(|&m| members[m] > 0).collect();
117 anyhow::ensure!(
118 !live.is_empty(),
119 "feature coarsening growth: no feature of the new axis is covered, so the groups \
120 cannot be placed on it"
121 );
122 let fallback = live
123 .iter()
124 .copied()
125 .max_by_key(|&m| members[m])
126 .expect("a live group exists");
127 let mut centroid = centroid.select_columns(&live);
128 for mut c in centroid.column_iter_mut() {
129 let nrm = c.norm();
130 if nrm > 0.0 {
131 c /= nrm;
132 }
133 }
134
135 let new: Vec<usize> = (0..d_new).filter(|&g| new_to_old[g].is_none()).collect();
138 let u = DMatrix::from_fn(new.len(), n_pb, |i, j| unit_profiles[new[i]][j]);
139 let scores = u * centroid;
140 for (i, &g) in new.iter().enumerate() {
141 let row = scores.row(i);
142 let best = row
143 .iter()
144 .enumerate()
145 .max_by(|a, b| a.1.total_cmp(b.1))
146 .expect("a live group exists");
147 fine_to_coarse[g] = if row.iter().all(|&v| v == 0.0) {
148 fallback
149 } else {
150 live[best.0]
151 };
152 }
153 debug!(
154 "feature coarsening growth: {} of {d_new} features known, {} placed by profile into \
155 {k} groups ({} groups had no surviving member)",
156 d_new - new.len(),
157 new.len(),
158 k - live.len(),
159 );
160 Self::from_fine_to_coarse(fine_to_coarse, k)
161 }
162
163 pub fn aggregate_columns_nd(&self, data_nd: &DMatrix<f32>) -> DMatrix<f32> {
166 let n = data_nd.nrows();
167 build_columns_par(n, self.num_coarse, |c, col| {
168 for &fine in &self.coarse_to_fine[c] {
169 let src = data_nd.column(fine);
170 for (dst, src_v) in col.iter_mut().zip(src.iter()) {
171 *dst += *src_v;
172 }
173 }
174 })
175 }
176
177 pub fn aggregate_rows_ds(&self, data_ds: &DMatrix<f32>) -> DMatrix<f32> {
180 let s = data_ds.ncols();
181 build_columns_par(self.num_coarse, s, |j, col| {
182 let src_col = data_ds.column(j);
183 for (fine, &coarse) in self.fine_to_coarse.iter().enumerate() {
184 col[coarse] += src_col[fine];
185 }
186 })
187 }
188
189 pub fn expand_log_dict_dk(&self, log_dict_dk: &DMatrix<f32>, d_fine: usize) -> DMatrix<f32> {
197 let k = log_dict_dk.ncols();
198 build_columns_par(d_fine, k, |kk, col| {
199 let src_col = log_dict_dk.column(kk);
200 for (c, fine_indices) in self.coarse_to_fine.iter().enumerate() {
201 let val = src_col[c] - (fine_indices.len() as f32).ln();
202 for &f in fine_indices {
203 col[f] = val;
204 }
205 }
206 })
207 }
208
209 pub fn expand_rows_dk(&self, table_dk: &DMatrix<f32>, d_fine: usize) -> DMatrix<f32> {
219 let k = table_dk.ncols();
220 build_columns_par(d_fine, k, |kk, col| {
221 let src_col = table_dk.column(kk);
222 for (c, fine_indices) in self.coarse_to_fine.iter().enumerate() {
223 for &f in fine_indices {
224 col[f] = src_col[c];
225 }
226 }
227 })
228 }
229
230 pub fn aggregate_sparse_csc(&self, data_dn: &CscMat) -> DMatrix<f32> {
233 let n = data_dn.ncols();
234 build_columns_par(self.num_coarse, n, |j, col| {
235 let src = data_dn.col(j);
236 for (&row, &val) in src.row_indices().iter().zip(src.values().iter()) {
237 col[self.fine_to_coarse[row]] += val;
238 }
239 })
240 }
241}
242
243const INFORMATIVE_Z: f64 = 5.0;
250const KMEANS_ITER: usize = 30;
252const MAX_PROFILE_DIM: usize = 1024;
255const SKETCH_DIM: usize = 64;
256
257pub fn informative_features(counts: &DMatrix<f32>, sizes: &[f32]) -> Vec<bool> {
262 let total_size: f64 = sizes.iter().map(|&n| f64::from(n)).sum();
263 let live: Vec<usize> = (0..sizes.len()).filter(|&s| sizes[s] > 0.0).collect();
264 if live.len() < 2 || total_size <= 0.0 {
265 return vec![false; counts.nrows()];
266 }
267 let df = (live.len() - 1) as f64;
268 (0..counts.nrows())
269 .into_par_iter()
270 .map(|g| {
271 let t: f64 = live.iter().map(|&s| f64::from(counts[(g, s)])).sum();
272 if t <= 0.0 {
273 return false;
274 }
275 let dev: f64 = live
276 .iter()
277 .map(|&s| {
278 let n = f64::from(counts[(g, s)]);
279 let e = t * f64::from(sizes[s]) / total_size;
280 let log_term = if n > 0.0 { n * (n / e).ln() } else { 0.0 };
281 2.0 * (log_term - (n - e))
282 })
283 .sum();
284 (dev - df) / (2.0 * df).sqrt() > INFORMATIVE_Z
285 })
286 .collect()
287}
288
289fn residual_profiles(
293 counts: &DMatrix<f32>,
294 sizes: &[f32],
295 rows: &[usize],
296 seed: u64,
297) -> DMatrix<f32> {
298 let n_pb = sizes.len();
299 let total_size: f32 = sizes.iter().sum();
300 let clip = (n_pb as f32).sqrt();
301 let mut totals = vec![0f32; rows.len()];
305 for col in counts.column_iter() {
306 for (t, &g) in totals.iter_mut().zip(rows) {
307 *t += col[g];
308 }
309 }
310 let mut z = DMatrix::<f32>::zeros(rows.len(), n_pb);
311 if !rows.is_empty() {
312 z.as_mut_slice()
313 .par_chunks_mut(rows.len())
314 .enumerate()
315 .for_each(|(s, z_col)| {
316 let col = counts.column(s);
317 for ((z_is, &t), &g) in z_col.iter_mut().zip(&totals).zip(rows) {
318 let e = t * sizes[s] / total_size.max(f32::MIN_POSITIVE);
319 if e > 0.0 {
320 *z_is = ((col[g] - e) / e.sqrt()).clamp(-clip, clip);
321 }
322 }
323 });
324 }
325 if n_pb > MAX_PROFILE_DIM {
326 let basis = DMatrix::<f32>::rnorm_seeded(n_pb, SKETCH_DIM, mix_seed(seed, 0x5343_4854));
327 z = (&z * basis) / (SKETCH_DIM as f32).sqrt();
328 }
329 unit_rows(&mut z);
330 z
331}
332
333fn unit_rows(z: &mut DMatrix<f32>) {
334 for mut row in z.row_iter_mut() {
335 let norm = row.norm();
336 if norm > 0.0 {
337 row /= norm;
338 }
339 }
340}
341
342pub fn coarsen_features(
367 counts: &DMatrix<f32>,
368 sizes: &[f32],
369 level_targets: &[usize],
370 seed: u64,
371) -> anyhow::Result<Vec<FeatureCoarsening>> {
372 let d = counts.nrows();
373 anyhow::ensure!(
374 counts.ncols() == sizes.len(),
375 "{} count columns for {} pseudobulk sizes",
376 counts.ncols(),
377 sizes.len()
378 );
379 anyhow::ensure!(!level_targets.is_empty(), "no coarsening level requested");
380 anyhow::ensure!(
381 level_targets.windows(2).all(|w| w[0] <= w[1]),
382 "coarsening levels must run coarsest → finest: {level_targets:?}"
383 );
384 let informative = informative_features(counts, sizes);
385 let rows: Vec<usize> = (0..d).filter(|&g| informative[g]).collect();
386 let has_background = rows.len() < d;
387 let slots = |target: usize| target.max(1).saturating_sub(usize::from(has_background));
390 info!(
391 "feature coarsening: {} of {d} features informative, {} in the background group",
392 rows.len(),
393 d - rows.len()
394 );
395
396 let finest = *level_targets.last().expect("checked non-empty");
398 let k_fine = slots(finest).min(rows.len());
399 let (fine_label, centroids) = if rows.is_empty() || k_fine == 0 {
400 (Vec::new(), DMatrix::<f32>::zeros(0, 0))
401 } else {
402 let z = residual_profiles(counts, sizes, &rows, seed);
403 let (mut c, labels) = kmeans_centroids_seeded(&z, k_fine, KMEANS_ITER, seed);
404 unit_rows(&mut c);
405 (labels, c)
406 };
407 let n_fine = centroids.nrows();
408
409 let levels = level_targets
411 .iter()
412 .enumerate()
413 .map(|(l, &target)| {
414 let k = slots(target);
415 let to_level: Vec<usize> = if n_fine == 0 || k == 0 {
416 Vec::new()
417 } else if l + 1 == level_targets.len() || k >= n_fine {
418 (0..n_fine).collect()
419 } else {
420 kmeans_centroids_seeded(¢roids, k, KMEANS_ITER, mix_seed(seed, l as u64)).1
421 };
422 let level = if to_level.is_empty() {
424 level_from_labels(d, &[], &[], &[], has_background)
425 } else {
426 level_from_labels(d, &rows, &fine_label, &to_level, has_background)
427 };
428 info!(
429 "feature coarsening level {l} (target {target}): {}",
430 group_sizes(&level, has_background)
431 );
432 level
433 })
434 .collect();
435 Ok(levels)
436}
437
438fn level_from_labels(
442 d: usize,
443 rows: &[usize],
444 fine_label: &[usize],
445 to_level: &[usize],
446 has_background: bool,
447) -> FeatureCoarsening {
448 let mut in_rows = vec![false; d];
449 let mut compact = std::collections::HashMap::<usize, usize>::new();
450 let mut fine_to_coarse = vec![0usize; d];
451 for (&g, &f) in rows.iter().zip(fine_label) {
452 in_rows[g] = true;
453 let next = compact.len();
454 fine_to_coarse[g] = *compact.entry(to_level[f]).or_insert(next);
455 }
456 let mut num_coarse = compact.len();
457 if has_background {
458 for (g, &inside) in in_rows.iter().enumerate() {
459 if !inside {
460 fine_to_coarse[g] = num_coarse;
461 }
462 }
463 num_coarse += 1;
464 }
465 let mut coarse_to_fine = vec![Vec::new(); num_coarse];
466 for (g, &c) in fine_to_coarse.iter().enumerate() {
467 coarse_to_fine[c].push(g);
468 }
469 FeatureCoarsening {
470 fine_to_coarse,
471 coarse_to_fine,
472 num_coarse,
473 }
474}
475
476fn group_sizes(level: &FeatureCoarsening, has_background: bool) -> String {
480 let n_inf = level.num_coarse - usize::from(has_background);
481 let mut sizes: Vec<usize> = level.coarse_to_fine[..n_inf].iter().map(Vec::len).collect();
482 sizes.sort_unstable();
483 format!(
484 "{} informative group(s), sizes min {} / median {} / max {}, {} singleton(s); \
485 background {}",
486 sizes.len(),
487 sizes.first().copied().unwrap_or(0),
488 sizes.get(sizes.len() / 2).copied().unwrap_or(0),
489 sizes.last().copied().unwrap_or(0),
490 sizes.iter().filter(|&&n| n == 1).count(),
491 if has_background {
492 level.coarse_to_fine[n_inf].len()
493 } else {
494 0
495 }
496 )
497}
498
499#[derive(Clone, Debug, Default)]
502pub struct PartitionOptions {
503 pub min_group_size: usize,
509 pub block: Option<Vec<u32>>,
514}
515
516#[derive(Clone, Debug)]
518pub struct FeaturePartition {
519 pub labels: Vec<usize>,
522 pub num_groups: usize,
523 pub background: Option<usize>,
525 pub informative: Vec<bool>,
528}
529
530const SPLIT_SEED_SALT: u64 = 0x5350_4c54;
532
533fn kmeans_groups(
537 counts: &DMatrix<f32>,
538 sizes: &[f32],
539 rows: &[usize],
540 k: usize,
541 seed: u64,
542) -> Vec<Vec<usize>> {
543 let k = k.min(rows.len());
544 if k == 0 {
545 return Vec::new();
546 }
547 let z = residual_profiles(counts, sizes, rows, seed);
548 let (_, labels) = kmeans_centroids_seeded(&z, k, KMEANS_ITER, seed);
549 let mut slot = vec![usize::MAX; k];
550 let mut groups: Vec<Vec<usize>> = Vec::new();
551 for (&r, &l) in rows.iter().zip(&labels) {
552 if slot[l] == usize::MAX {
553 slot[l] = groups.len();
554 groups.push(Vec::new());
555 }
556 groups[slot[l]].push(r);
557 }
558 groups
559}
560
561fn informative_groups(
566 counts: &DMatrix<f32>,
567 sizes: &[f32],
568 rows: &[usize],
569 informative: &[bool],
570 n_groups: usize,
571 seed: u64,
572 min_size: usize,
573) -> Vec<Vec<usize>> {
574 let inf: Vec<usize> = rows.iter().copied().filter(|&r| informative[r]).collect();
575 let has_empty = inf.len() < rows.len();
576 let slots = n_groups.max(1).saturating_sub(usize::from(has_empty));
577 let groups = kmeans_groups(counts, sizes, &inf, slots, seed);
578 if min_size <= 1 {
579 return groups;
580 }
581 let (mut groups, scattered): (Vec<Vec<usize>>, Vec<Vec<usize>>) =
582 groups.into_iter().partition(|g| g.len() >= min_size);
583 let has_bg = has_empty || !scattered.is_empty();
584 let mut free = n_groups.saturating_sub(groups.len() + usize::from(has_bg));
585 let mut heap: std::collections::BinaryHeap<(usize, usize)> = groups
586 .iter()
587 .enumerate()
588 .map(|(i, g)| (g.len(), i))
589 .collect();
590 while free > 0 {
591 let Some((len, i)) = heap.pop() else { break };
592 if len < 2 * min_size {
593 break;
594 }
595 let mut halves = kmeans_groups(
596 counts,
597 sizes,
598 &groups[i],
599 2,
600 mix_seed(seed, SPLIT_SEED_SALT + i as u64),
601 );
602 if halves.len() == 2 && halves.iter().all(|h| h.len() >= min_size) {
603 let b = halves.pop().expect("two halves");
604 groups[i] = halves.pop().expect("two halves");
605 heap.push((groups[i].len(), i));
606 heap.push((b.len(), groups.len()));
607 groups.push(b);
608 free -= 1;
609 }
610 }
611 groups
612}
613
614fn block_shares(weight: &[usize], budget: usize) -> Vec<usize> {
618 if weight.is_empty() {
619 return Vec::new();
620 }
621 let total: usize = weight.iter().sum();
622 let mut alloc: Vec<usize> = weight
623 .iter()
624 .map(|&c| (budget * c / total.max(1)).max(1))
625 .collect();
626 while alloc.iter().sum::<usize>() > budget {
627 let i = (0..alloc.len())
628 .max_by_key(|&i| alloc[i])
629 .expect("non-empty");
630 alloc[i] -= 1;
631 }
632 while alloc.iter().sum::<usize>() < budget {
633 let i = (0..alloc.len())
634 .max_by(|&a, &b| (weight[a] * alloc[b]).cmp(&(weight[b] * alloc[a])))
635 .expect("non-empty");
636 alloc[i] += 1;
637 }
638 alloc
639}
640
641pub fn partition_features(
647 counts: &DMatrix<f32>,
648 sizes: &[f32],
649 n_groups: usize,
650 seed: u64,
651 opts: &PartitionOptions,
652) -> anyhow::Result<FeaturePartition> {
653 let d = counts.nrows();
654 anyhow::ensure!(
655 counts.ncols() == sizes.len(),
656 "{} count columns for {} pseudobulk sizes",
657 counts.ncols(),
658 sizes.len()
659 );
660 let informative = informative_features(counts, sizes);
661 let min_size = opts.min_group_size;
662 let mut rows_of = std::collections::BTreeMap::<u32, Vec<usize>>::new();
663 match &opts.block {
664 Some(block) => {
665 anyhow::ensure!(
666 block.len() == d,
667 "{} block ids for {d} features",
668 block.len()
669 );
670 for (r, &b) in block.iter().enumerate() {
671 rows_of.entry(b).or_default().push(r);
672 }
673 }
674 None => {
675 rows_of.insert(0, (0..d).collect());
676 }
677 }
678
679 let groups: Vec<Vec<usize>> = if rows_of.len() <= 1 {
680 let rows: Vec<usize> = (0..d).collect();
681 informative_groups(counts, sizes, &rows, &informative, n_groups, seed, min_size)
682 } else {
683 let n_inf = |rows: &[usize]| rows.iter().filter(|&&r| informative[r]).count();
684 let live: Vec<(u32, usize)> = rows_of
685 .iter()
686 .map(|(&b, rows)| (b, n_inf(rows)))
687 .filter(|&(_, c)| c > 0)
688 .collect();
689 let budget = n_groups.saturating_sub(1);
690 anyhow::ensure!(
691 live.len() <= budget,
692 "{} blocks hold informative features but only {budget} group slots: widen the blocks",
693 live.len()
694 );
695 let weight: Vec<usize> = live.iter().map(|&(_, c)| c).collect();
696 let alloc = block_shares(&weight, budget);
697 live.iter()
698 .zip(&alloc)
699 .flat_map(|(&(b, _), &k)| {
700 let rows = &rows_of[&b];
701 let has_empty = rows.len() > n_inf(rows);
704 informative_groups(
705 counts,
706 sizes,
707 rows,
708 &informative,
709 k + usize::from(has_empty),
710 mix_seed(seed, u64::from(b)),
711 min_size,
712 )
713 })
714 .collect()
715 };
716
717 let bg = groups.len();
719 let mut labels = vec![bg; d];
720 for (i, g) in groups.iter().enumerate() {
721 for &r in g {
722 labels[r] = i;
723 }
724 }
725 let n_bg = d - groups.iter().map(Vec::len).sum::<usize>();
726 let n_flat = informative.iter().filter(|&&i| !i).count();
727 let mut group_sizes: Vec<usize> = groups.iter().map(Vec::len).collect();
728 group_sizes.sort_unstable();
729 info!(
730 "feature partition: {bg} group(s) over {} block(s), sizes min {} / median {} / max {}; \
731 background {n_bg} ({n_flat} flat, {} scattered)",
732 rows_of.len(),
733 group_sizes.first().copied().unwrap_or(0),
734 group_sizes.get(group_sizes.len() / 2).copied().unwrap_or(0),
735 group_sizes.last().copied().unwrap_or(0),
736 n_bg - n_flat,
737 );
738 Ok(FeaturePartition {
739 labels,
740 num_groups: bg + usize::from(n_bg > 0),
741 background: (n_bg > 0).then_some(bg),
742 informative,
743 })
744}
745
746#[derive(Args, Debug, Clone, Serialize, Deserialize)]
754#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
755pub struct FeatureCoarseningArgs {
756 #[arg(
757 long,
758 default_value_t = 1000,
759 value_name = "N",
760 help = "Group co-expressed features into at most N coarse features; 0 = every feature",
761 long_help = "Group co-expressed features into at most N coarse features,\n\
762 so the model answers for a group instead of for each feature.\n\
763 Groups come from the finest pseudobulk profiles, nested per\n\
764 level with log-spaced widths. The dictionary is expanded back\n\
765 to every feature on output, so what you read is unchanged.\n\
766 \n\
767 --no-feature-coarsening trains on every feature instead. So\n\
768 does 0 here, kept because recorded runs and existing scripts\n\
769 spell it that way.\n\
770 \n\
771 WHAT THE GROUPING APPLIES TO DEPENDS ON THE COMMAND:\n\
772 \n\
773 `topic` and `vae` group both sides. The encoder reads coarse\n\
774 features and every decoder answers for them, so 0 makes each\n\
775 per-step tensor as wide as the feature axis.\n\
776 \n\
777 `masked-topic`, `masked-vae` and `masked-sbp` group the decoder\n\
778 targets only. The encoder keeps its feature-level context and\n\
779 embedding either way.\n\
780 \n\
781 `joint-topic` groups per modality, or on the reference modality\n\
782 and shares it, following --decoder-type.\n\
783 \n\
784 This is a COARSENING, not a module. senna tells the two kinds\n\
785 of grouping apart by one question: does training move the\n\
786 membership? A coarsening's does not. It is read off the data\n\
787 before the first step and fixed for the life of the model,\n\
788 which is what lets every decoder be keyed to it and what lets a\n\
789 continued fit inherit it. A module's does: --gene-modules learns\n\
790 its centroids, so which features group together changes as the\n\
791 fit proceeds.\n\
792 \n\
793 What a coarsening fixes is the assignment, not the meaning. A\n\
794 group's embedding is the mean of its members' and moves at every\n\
795 step, so grouping the targets does not freeze what the model can\n\
796 learn about a feature."
797 )]
798 pub max_coarse_features: usize,
799
800 #[arg(
801 long,
802 conflicts_with = "max_coarse_features",
803 help = "Train on every feature, with no grouping",
804 long_help = "Train on every feature. The named form of\n\
805 --max-coarse-features 0, which reads as a request for zero\n\
806 features when it means the opposite.\n\
807 \n\
808 Every per-step tensor is then as wide as the feature axis, so\n\
809 the fit costs considerably more at the same epoch count. It is\n\
810 also fixed for the life of a model: a continued fit inherits\n\
811 the groups the source run trained on, so a chain that starts\n\
812 ungrouped stays ungrouped and one that starts grouped cannot\n\
813 be switched over partway."
814 )]
815 pub no_feature_coarsening: bool,
816}
817
818impl FeatureCoarseningArgs {
819 #[must_use]
825 pub fn cap(&self) -> Option<std::num::NonZeroUsize> {
826 if self.no_feature_coarsening {
827 return None;
828 }
829 std::num::NonZeroUsize::new(self.max_coarse_features)
830 }
831}
832
833#[cfg(test)]
834#[path = "feature_coarsening_tests.rs"]
835mod tests;