1use faer::{Mat, MatRef};
35use rand::prelude::*;
36use rand_distr::{Beta, Binomial, Distribution, Gamma, LogNormal, Normal, Poisson};
37use rayon::prelude::*;
38
39use crate::errors::CoreErrors;
40use crate::prelude::BixverseFloat;
41
42const DEFAULT_MEAN_EXP_SHAPE: f64 = 5.0;
49
50const DEFAULT_MEAN_EXP_SCALE: f64 = 10.0;
52
53const DEFAULT_DISP_INTERCEPT: f64 = 0.2;
55
56const DEFAULT_DISP_SLOPE: f64 = 0.3;
58
59const DEFAULT_NOISE_STD: f64 = 0.3;
61
62const DEFAULT_NORMAL_FACTOR_STD: f64 = 0.3;
65
66const DEFAULT_GAMMA_FACTOR_SHAPE: f64 = 2.0;
69
70const DEFAULT_GAMMA_FACTOR_SCALE: f64 = 0.3;
72
73const DEFAULT_LAPLACE_FACTOR_SCALE: f64 = 0.3;
76
77const DEFAULT_LOADING_LN_MU: f64 = 0.0;
79
80const DEFAULT_LOADING_LN_SIGMA: f64 = 0.7;
84
85const DEFAULT_HUB_PERCENTILE: f64 = 0.1;
87
88const DEFAULT_TARGET_LIB_SIZE: f64 = 20_000.0;
90
91const DEFAULT_CAPTURE_SIGMA: f64 = 0.5;
93
94#[derive(Clone, Debug)]
101pub struct LoadingLogNormal<T: BixverseFloat> {
102 pub mu: T,
104 pub sigma: T,
106 pub hub_percentile: T,
108}
109
110impl<T: BixverseFloat> Default for LoadingLogNormal<T> {
111 fn default() -> Self {
112 Self {
113 mu: T::from_f64(DEFAULT_LOADING_LN_MU).unwrap(),
114 sigma: T::from_f64(DEFAULT_LOADING_LN_SIGMA).unwrap(),
115 hub_percentile: T::from_f64(DEFAULT_HUB_PERCENTILE).unwrap(),
116 }
117 }
118}
119
120#[derive(Clone, Debug)]
125pub struct ModularConfig<T: BixverseFloat> {
126 pub factor_std: T,
128}
129
130impl<T: BixverseFloat> Default for ModularConfig<T> {
131 fn default() -> Self {
132 Self {
133 factor_std: T::from_f64(DEFAULT_NORMAL_FACTOR_STD).unwrap(),
134 }
135 }
136}
137
138#[derive(Clone, Debug)]
144pub struct HubModularConfig<T: BixverseFloat> {
145 pub factor_std: T,
147 pub loading: LoadingLogNormal<T>,
149}
150
151impl<T: BixverseFloat> Default for HubModularConfig<T> {
152 fn default() -> Self {
153 Self {
154 factor_std: T::from_f64(DEFAULT_NORMAL_FACTOR_STD).unwrap(),
155 loading: LoadingLogNormal::default(),
156 }
157 }
158}
159
160#[derive(Clone, Debug)]
168pub struct NonNegativeFactorConfig<T: BixverseFloat> {
169 pub factor_shape: T,
171 pub factor_scale: T,
173 pub loading: LoadingLogNormal<T>,
175}
176
177impl<T: BixverseFloat> Default for NonNegativeFactorConfig<T> {
178 fn default() -> Self {
179 Self {
180 factor_shape: T::from_f64(DEFAULT_GAMMA_FACTOR_SHAPE).unwrap(),
181 factor_scale: T::from_f64(DEFAULT_GAMMA_FACTOR_SCALE).unwrap(),
182 loading: LoadingLogNormal::default(),
183 }
184 }
185}
186
187#[derive(Clone, Debug)]
193pub struct NonGaussianFactorConfig<T: BixverseFloat> {
194 pub factor_scale: T,
197 pub loading: LoadingLogNormal<T>,
199}
200
201impl<T: BixverseFloat> Default for NonGaussianFactorConfig<T> {
202 fn default() -> Self {
203 Self {
204 factor_scale: T::from_f64(DEFAULT_LAPLACE_FACTOR_SCALE).unwrap(),
205 loading: LoadingLogNormal::default(),
206 }
207 }
208}
209
210#[derive(Clone, Debug)]
213pub enum BulkDataGenerator<T: BixverseFloat> {
214 Modular(ModularConfig<T>),
217 HubModular(HubModularConfig<T>),
220 NonNegativeFactor(NonNegativeFactorConfig<T>),
223 NonGaussianFactor(NonGaussianFactorConfig<T>),
226}
227
228impl<T: BixverseFloat> Default for BulkDataGenerator<T> {
229 fn default() -> Self {
230 BulkDataGenerator::HubModular(HubModularConfig::default())
231 }
232}
233
234impl<T: BixverseFloat> BulkDataGenerator<T> {
235 fn loading(&self) -> Option<&LoadingLogNormal<T>> {
241 match self {
242 BulkDataGenerator::Modular(_) => None,
243 BulkDataGenerator::HubModular(c) => Some(&c.loading),
244 BulkDataGenerator::NonNegativeFactor(c) => Some(&c.loading),
245 BulkDataGenerator::NonGaussianFactor(c) => Some(&c.loading),
246 }
247 }
248
249 fn label(&self) -> &'static str {
255 match self {
256 BulkDataGenerator::Modular(_) => "modular",
257 BulkDataGenerator::HubModular(_) => "hub_modular",
258 BulkDataGenerator::NonNegativeFactor(_) => "non_negative_factor",
259 BulkDataGenerator::NonGaussianFactor(_) => "non_gaussian_factor",
260 }
261 }
262}
263
264pub fn parse_bulk_data_generator<T: BixverseFloat>(s: &str) -> Option<BulkDataGenerator<T>> {
276 match s.to_lowercase().as_str() {
277 "modular" => Some(BulkDataGenerator::Modular(ModularConfig::default())),
278 "hub_modular" | "hubmodular" | "hub-modular" => {
279 Some(BulkDataGenerator::HubModular(HubModularConfig::default()))
280 }
281 "non_negative_factor" | "nonnegative" | "nmf" => Some(
282 BulkDataGenerator::NonNegativeFactor(NonNegativeFactorConfig::default()),
283 ),
284 "non_gaussian_factor" | "nongaussian" | "ica" => Some(
285 BulkDataGenerator::NonGaussianFactor(NonGaussianFactorConfig::default()),
286 ),
287 _ => None,
288 }
289}
290
291#[derive(Clone, Copy, Debug, PartialEq, Eq)]
293pub enum DropoutStrategy {
294 SequencingDepth,
298}
299
300pub fn parse_dropout_strategy(s: &str) -> Option<DropoutStrategy> {
310 match s.to_lowercase().as_str() {
311 "seq_depth" | "sequencing_depth" | "sequencingdepth" => {
312 Some(DropoutStrategy::SequencingDepth)
313 }
314 _ => None,
315 }
316}
317
318#[derive(Clone, Debug)]
328pub struct SyntheticRnaSeqParams<T: BixverseFloat> {
329 pub num_samples: usize,
331 pub num_genes: usize,
333 pub seed: u64,
335 pub module_sizes: Vec<usize>,
338 pub generator: BulkDataGenerator<T>,
341 pub mean_exp_gamma_shape: T,
343 pub mean_exp_gamma_scale: T,
345 pub disp_intercept: T,
347 pub disp_slope: T,
349 pub noise_std: T,
352}
353
354impl<T: BixverseFloat> Default for SyntheticRnaSeqParams<T> {
355 fn default() -> Self {
356 Self {
357 num_samples: 100,
358 num_genes: 2_000,
359 seed: 42,
360 module_sizes: vec![300, 250, 200, 300, 500],
361 generator: BulkDataGenerator::default(),
362 mean_exp_gamma_shape: T::from_f64(DEFAULT_MEAN_EXP_SHAPE).unwrap(),
363 mean_exp_gamma_scale: T::from_f64(DEFAULT_MEAN_EXP_SCALE).unwrap(),
364 disp_intercept: T::from_f64(DEFAULT_DISP_INTERCEPT).unwrap(),
365 disp_slope: T::from_f64(DEFAULT_DISP_SLOPE).unwrap(),
366 noise_std: T::from_f64(DEFAULT_NOISE_STD).unwrap(),
367 }
368 }
369}
370
371impl<T: BixverseFloat> SyntheticRnaSeqParams<T> {
372 pub fn validate(&self) -> Result<(), CoreErrors> {
378 if self.num_samples == 0 {
379 return Err(CoreErrors::InvalidArgument(
380 "num_samples must be > 0".into(),
381 ));
382 }
383 if self.num_genes == 0 {
384 return Err(CoreErrors::InvalidArgument("num_genes must be > 0".into()));
385 }
386 let total: usize = self.module_sizes.iter().sum();
387 if total > self.num_genes {
388 return Err(CoreErrors::InvalidArgument(format!(
389 "sum(module_sizes)={total} exceeds num_genes={}",
390 self.num_genes
391 )));
392 }
393 if self.mean_exp_gamma_shape.to_f64().unwrap() <= 0.0
394 || self.mean_exp_gamma_scale.to_f64().unwrap() <= 0.0
395 {
396 return Err(CoreErrors::InvalidArgument(
397 "mean_exp gamma shape/scale must be > 0".into(),
398 ));
399 }
400 if self.noise_std.to_f64().unwrap() < 0.0 {
401 return Err(CoreErrors::InvalidArgument("noise_std must be >= 0".into()));
402 }
403 match &self.generator {
405 BulkDataGenerator::Modular(c) => {
406 if c.factor_std.to_f64().unwrap() <= 0.0 {
407 return Err(CoreErrors::InvalidArgument(
408 "modular: factor_std must be > 0".into(),
409 ));
410 }
411 }
412 BulkDataGenerator::HubModular(c) => {
413 if c.factor_std.to_f64().unwrap() <= 0.0 {
414 return Err(CoreErrors::InvalidArgument(
415 "hub_modular: factor_std must be > 0".into(),
416 ));
417 }
418 validate_loading(&c.loading, "hub_modular")?;
419 }
420 BulkDataGenerator::NonNegativeFactor(c) => {
421 if c.factor_shape.to_f64().unwrap() <= 0.0
422 || c.factor_scale.to_f64().unwrap() <= 0.0
423 {
424 return Err(CoreErrors::InvalidArgument(
425 "non_negative_factor: factor_shape/factor_scale must be > 0".into(),
426 ));
427 }
428 validate_loading(&c.loading, "non_negative_factor")?;
429 }
430 BulkDataGenerator::NonGaussianFactor(c) => {
431 if c.factor_scale.to_f64().unwrap() <= 0.0 {
432 return Err(CoreErrors::InvalidArgument(
433 "non_gaussian_factor: factor_scale must be > 0".into(),
434 ));
435 }
436 validate_loading(&c.loading, "non_gaussian_factor")?;
437 }
438 }
439 Ok(())
440 }
441}
442
443fn validate_loading<T: BixverseFloat>(
445 l: &LoadingLogNormal<T>,
446 label: &str,
447) -> Result<(), CoreErrors> {
448 if l.sigma.to_f64().unwrap() <= 0.0 {
449 return Err(CoreErrors::InvalidArgument(format!(
450 "{label}: loading.sigma must be > 0"
451 )));
452 }
453 let hub_p = l.hub_percentile.to_f64().unwrap();
454 if !(hub_p > 0.0 && hub_p <= 1.0) {
455 return Err(CoreErrors::InvalidArgument(format!(
456 "{label}: loading.hub_percentile must be in (0, 1]"
457 )));
458 }
459 Ok(())
460}
461
462#[derive(Clone, Debug)]
469pub struct SparsityParams<T: BixverseFloat> {
470 pub strategy: DropoutStrategy,
472 pub target_library_size: T,
475 pub capture_efficiency_sigma: T,
477 pub seed: u64,
479}
480
481impl<T: BixverseFloat> Default for SparsityParams<T> {
482 fn default() -> Self {
483 Self {
484 strategy: DropoutStrategy::SequencingDepth,
485 target_library_size: T::from_f64(DEFAULT_TARGET_LIB_SIZE).unwrap(),
486 capture_efficiency_sigma: T::from_f64(DEFAULT_CAPTURE_SIGMA).unwrap(),
487 seed: 42,
488 }
489 }
490}
491
492impl<T: BixverseFloat> SparsityParams<T> {
493 pub fn validate(&self) -> Result<(), CoreErrors> {
499 if self.target_library_size.to_f64().unwrap() <= 0.0 {
500 return Err(CoreErrors::InvalidArgument(
501 "target_library_size must be > 0".into(),
502 ));
503 }
504 if self.capture_efficiency_sigma.to_f64().unwrap() <= 0.0 {
505 return Err(CoreErrors::InvalidArgument(
506 "capture_efficiency_sigma must be > 0".into(),
507 ));
508 }
509 Ok(())
510 }
511}
512
513#[derive(Clone, Debug)]
526pub struct SyntheticRnaSeqData<T: BixverseFloat> {
527 pub count_matrix: Mat<T>,
529 pub gene_modules: Vec<usize>,
531 pub module_hubs: Vec<usize>,
534 pub loadings: Vec<T>,
536 pub module_factors: Mat<T>,
539 pub sigma_noise: T,
542}
543
544pub fn generate_bulk_rnaseq<T>(
571 params: &SyntheticRnaSeqParams<T>,
572) -> Result<SyntheticRnaSeqData<T>, CoreErrors>
573where
574 T: BixverseFloat,
575{
576 params.validate()?;
577
578 let num_samples = params.num_samples;
579 let num_genes = params.num_genes;
580 let seed = params.seed;
581 let sigma_noise_f64 = params.noise_std.to_f64().unwrap();
582
583 let mut rng = StdRng::seed_from_u64(seed);
584
585 let gamma_mean = Gamma::new(
587 params.mean_exp_gamma_shape.to_f64().unwrap(),
588 params.mean_exp_gamma_scale.to_f64().unwrap(),
589 )
590 .map_err(|e| CoreErrors::InvalidArgument(format!("gamma(mean) init: {e}")))?;
591 let mean_exp: Vec<f64> = (0..num_genes)
592 .map(|_| gamma_mean.sample(&mut rng))
593 .collect();
594 let disp_intercept = params.disp_intercept.to_f64().unwrap();
595 let disp_slope = params.disp_slope.to_f64().unwrap();
596 let dispersion: Vec<f64> = mean_exp
597 .iter()
598 .map(|&m| 1.0 / (disp_intercept + disp_slope * m))
599 .collect();
600
601 let mut gene_modules = vec![0usize; num_genes];
603 let mut gene_idx = 0usize;
604 for (m_id, &size) in params.module_sizes.iter().enumerate() {
605 for _ in 0..size {
606 if gene_idx >= num_genes {
607 break;
608 }
609 gene_modules[gene_idx] = m_id + 1;
610 gene_idx += 1;
611 }
612 }
613 let num_modules = params.module_sizes.len();
614
615 let module_factor_vecs = draw_factors(num_modules, num_samples, ¶ms.generator, &mut rng)
620 .map_err(|e| CoreErrors::InvalidArgument(format!("factor draw: {e}")))?;
621
622 let loadings_f64 = draw_loadings(&gene_modules, num_genes, ¶ms.generator, &mut rng)?;
624
625 let module_hubs = match ¶ms.generator {
627 BulkDataGenerator::Modular(_) => Vec::new(),
628 _ => {
629 let hub_p = params
630 .generator
631 .loading()
632 .map(|l| l.hub_percentile.to_f64().unwrap())
633 .unwrap_or(DEFAULT_HUB_PERCENTILE);
634 identify_hubs(&gene_modules, &loadings_f64, hub_p)
635 }
636 };
637
638 let inv_num_samples = 1.0 / num_samples as f64;
640 let has_modules = num_modules > 0;
641
642 let gene_data: Vec<Vec<f64>> = (0..num_genes)
643 .into_par_iter()
644 .map(|i| {
645 let mut local_rng = StdRng::seed_from_u64(seed.wrapping_add((i as u64) * 1000 + 1));
646 let mut counts = Vec::with_capacity(num_samples);
647
648 let module_id = gene_modules[i];
649 let in_module = has_modules && module_id > 0;
650
651 if in_module {
652 let m = module_id - 1;
653 let loading = loadings_f64[i];
654 let factor = &module_factor_vecs[m];
655
656 let noise = if sigma_noise_f64 > 0.0 {
658 Some(Normal::new(0.0, sigma_noise_f64).unwrap())
659 } else {
660 None
661 };
662 let mut latent_exp = Vec::with_capacity(num_samples);
663 let mut sum_signal = 0.0;
664 for j in 0..num_samples {
665 let eps = match noise {
666 Some(n) => n.sample(&mut local_rng),
667 None => 0.0,
668 };
669 let z = loading * factor[j] + eps;
670 let s = z.exp();
671 latent_exp.push(s);
672 sum_signal += s;
673 }
674 let mean_signal = sum_signal * inv_num_samples;
675 let scale = if mean_signal > 0.0 {
676 mean_exp[i] / mean_signal
677 } else {
678 1.0
679 };
680
681 let r = 1.0 / dispersion[i];
682 for j in 0..num_samples {
683 let mu_ij = latent_exp[j] * scale;
684 let count = nb_sample(r, mu_ij, &mut local_rng);
685 counts.push(count);
686 }
687 } else {
688 let r = 1.0 / dispersion[i];
690 for _ in 0..num_samples {
691 let count = nb_sample(r, mean_exp[i], &mut local_rng);
692 counts.push(count);
693 }
694 }
695
696 counts
697 })
698 .collect();
699
700 let mut count_matrix: Mat<T> = Mat::zeros(num_genes, num_samples);
702 for (i, gene_counts) in gene_data.into_iter().enumerate() {
703 for (j, c) in gene_counts.into_iter().enumerate() {
704 count_matrix[(i, j)] = T::from_f64(c).unwrap();
705 }
706 }
707
708 let mut module_factors: Mat<T> = Mat::zeros(num_modules, num_samples);
709 for (m, row) in module_factor_vecs.iter().enumerate() {
710 for (j, &v) in row.iter().enumerate() {
711 module_factors[(m, j)] = T::from_f64(v).unwrap();
712 }
713 }
714
715 let loadings: Vec<T> = loadings_f64
716 .into_iter()
717 .map(|v| T::from_f64(v).unwrap())
718 .collect();
719
720 let _label = params.generator.label(); Ok(SyntheticRnaSeqData {
723 count_matrix,
724 gene_modules,
725 module_hubs,
726 loadings,
727 module_factors,
728 sigma_noise: params.noise_std,
729 })
730}
731
732fn draw_factors<T: BixverseFloat>(
753 num_modules: usize,
754 num_samples: usize,
755 generator: &BulkDataGenerator<T>,
756 rng: &mut StdRng,
757) -> Result<Vec<Vec<f64>>, String> {
758 let mut out = Vec::with_capacity(num_modules);
759 match generator {
760 BulkDataGenerator::Modular(c) => {
761 let normal = Normal::new(0.0, c.factor_std.to_f64().unwrap())
762 .map_err(|e| format!("normal init: {e}"))?;
763 for _ in 0..num_modules {
764 out.push((0..num_samples).map(|_| normal.sample(rng)).collect());
765 }
766 }
767 BulkDataGenerator::HubModular(c) => {
768 let normal = Normal::new(0.0, c.factor_std.to_f64().unwrap())
769 .map_err(|e| format!("normal init: {e}"))?;
770 for _ in 0..num_modules {
771 out.push((0..num_samples).map(|_| normal.sample(rng)).collect());
772 }
773 }
774 BulkDataGenerator::NonNegativeFactor(c) => {
775 let gamma = Gamma::new(
776 c.factor_shape.to_f64().unwrap(),
777 c.factor_scale.to_f64().unwrap(),
778 )
779 .map_err(|e| format!("gamma(factor) init: {e}"))?;
780 for _ in 0..num_modules {
781 out.push((0..num_samples).map(|_| gamma.sample(rng)).collect());
782 }
783 }
784 BulkDataGenerator::NonGaussianFactor(c) => {
785 let b = c.factor_scale.to_f64().unwrap();
786 for _ in 0..num_modules {
787 out.push(
788 (0..num_samples)
789 .map(|_| laplace_sample(0.0, b, rng))
790 .collect(),
791 );
792 }
793 }
794 }
795 Ok(out)
796}
797
798#[inline]
811fn laplace_sample(mu: f64, b: f64, rng: &mut StdRng) -> f64 {
812 let mut u: f64 = rng.random::<f64>() - 0.5;
814 if u == -0.5 {
815 u = -0.5 + f64::EPSILON;
816 }
817 let sign = if u >= 0.0 { -1.0 } else { 1.0 };
818 mu + sign * b * (1.0 - 2.0 * u.abs()).ln()
819}
820
821fn draw_loadings<T: BixverseFloat>(
837 gene_modules: &[usize],
838 num_genes: usize,
839 generator: &BulkDataGenerator<T>,
840 rng: &mut StdRng,
841) -> Result<Vec<f64>, CoreErrors> {
842 let mut loadings = vec![0.0f64; num_genes];
843 match generator {
844 BulkDataGenerator::Modular(_) => {
845 let beta =
846 Beta::new(5.0, 2.0).map_err(|e| CoreErrors::BetaDistribution(e.to_string()))?;
847 for (i, &m) in gene_modules.iter().enumerate() {
848 if m > 0 {
849 loadings[i] = beta.sample(rng);
850 }
851 }
852 }
853 _ => {
854 let l = generator.loading().expect("non-Modular has loading");
855 let ln = LogNormal::new(l.mu.to_f64().unwrap(), l.sigma.to_f64().unwrap())
856 .map_err(|e| CoreErrors::InvalidArgument(format!("lognormal init: {e}")))?;
857 for (i, &m) in gene_modules.iter().enumerate() {
858 if m > 0 {
859 loadings[i] = ln.sample(rng);
860 }
861 }
862 }
863 }
864 Ok(loadings)
865}
866
867fn identify_hubs(gene_modules: &[usize], loadings: &[f64], hub_percentile: f64) -> Vec<usize> {
884 let mut module_idx: Vec<usize> = gene_modules
885 .iter()
886 .enumerate()
887 .filter_map(|(i, &m)| if m > 0 { Some(i) } else { None })
888 .collect();
889 if module_idx.is_empty() {
890 return Vec::new();
891 }
892 module_idx.sort_by(|&a, &b| {
893 loadings[b]
894 .partial_cmp(&loadings[a])
895 .unwrap_or(std::cmp::Ordering::Equal)
896 });
897 let k = ((module_idx.len() as f64 * hub_percentile).ceil() as usize).max(1);
898 let mut hubs: Vec<usize> = module_idx.into_iter().take(k).collect();
899 hubs.sort_unstable();
900 hubs
901}
902
903#[inline]
921fn nb_sample(r: f64, mu: f64, rng: &mut StdRng) -> f64 {
922 let min_mu = r * f64::MIN_POSITIVE.max(1e-300) * 1e6;
926 if mu <= min_mu || !mu.is_finite() {
927 return 0.0;
928 }
929 let p = r / (r + mu);
930 let scale = (1.0 - p) / p;
931 let gamma = match Gamma::new(r, scale) {
932 Ok(g) => g,
933 Err(_) => return 0.0,
934 };
935 let lambda = gamma.sample(rng);
936 if !(lambda > 0.0 && lambda.is_finite()) {
937 return 0.0;
938 }
939 match Poisson::new(lambda) {
940 Ok(p) => p.sample(rng),
941 Err(_) => 0.0,
942 }
943}
944
945pub fn apply_dropout<T>(counts: MatRef<T>, params: &SparsityParams<T>) -> Result<Mat<T>, CoreErrors>
968where
969 T: BixverseFloat,
970{
971 params.validate()?;
972 match params.strategy {
973 DropoutStrategy::SequencingDepth => apply_dropout_seq_depth(counts, params),
974 }
975}
976
977fn apply_dropout_seq_depth<T>(
994 counts: MatRef<T>,
995 params: &SparsityParams<T>,
996) -> Result<Mat<T>, CoreErrors>
997where
998 T: BixverseFloat,
999{
1000 let (n_genes, n_samples) = counts.shape();
1001 let seed = params.seed;
1002 let target_lib = params.target_library_size.to_f64().unwrap();
1003 let sigma_s = params.capture_efficiency_sigma.to_f64().unwrap();
1004
1005 let mut rng = StdRng::seed_from_u64(seed);
1007 let ln = LogNormal::new(0.0, sigma_s)
1008 .map_err(|e| CoreErrors::InvalidArgument(format!("lognormal(seq_depth) init: {e}")))?;
1009 let size_factors: Vec<f64> = (0..n_samples).map(|_| ln.sample(&mut rng)).collect();
1010 let current_lib: Vec<f64> = (0..n_samples)
1011 .map(|j| {
1012 (0..n_genes)
1013 .map(|i| counts.get(i, j).to_f64().unwrap())
1014 .sum()
1015 })
1016 .collect();
1017
1018 let retention: Vec<f64> = (0..n_samples)
1020 .map(|j| {
1021 let target_j = target_lib * size_factors[j];
1022 if current_lib[j] > 0.0 {
1023 (target_j / current_lib[j]).min(1.0)
1024 } else {
1025 0.0
1026 }
1027 })
1028 .collect();
1029
1030 let per_sample: Vec<Vec<T>> = (0..n_samples)
1033 .into_par_iter()
1034 .map(|j| {
1035 let mut local_rng = StdRng::seed_from_u64(seed.wrapping_add((j as u64) * 1000 + 7));
1036 let p = retention[j];
1037 let mut col = Vec::with_capacity(n_genes);
1038 for i in 0..n_genes {
1039 let c = counts.get(i, j).to_f64().unwrap();
1040 let n = c.max(0.0).floor() as u64;
1041 let out = if n == 0 || p <= 0.0 {
1042 0.0
1043 } else if p >= 1.0 {
1044 n as f64
1045 } else {
1046 let b = Binomial::new(n, p).unwrap();
1047 b.sample(&mut local_rng) as f64
1048 };
1049 col.push(T::from_f64(out).unwrap());
1050 }
1051 col
1052 })
1053 .collect();
1054
1055 let mut out: Mat<T> = Mat::zeros(n_genes, n_samples);
1056 for (j, col) in per_sample.into_iter().enumerate() {
1057 for (i, v) in col.into_iter().enumerate() {
1058 out[(i, j)] = v;
1059 }
1060 }
1061 Ok(out)
1062}
1063
1064#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 fn log1p_mat(counts: &Mat<f64>) -> Mat<f64> {
1076 let (r, c) = counts.shape();
1077 let mut out = Mat::<f64>::zeros(r, c);
1078 for i in 0..r {
1079 for j in 0..c {
1080 out[(i, j)] = (counts[(i, j)] + 1.0).ln();
1081 }
1082 }
1083 out
1084 }
1085
1086 fn pearson(x: &[f64], y: &[f64]) -> f64 {
1088 let n = x.len() as f64;
1089 let mx = x.iter().sum::<f64>() / n;
1090 let my = y.iter().sum::<f64>() / n;
1091 let mut num = 0.0;
1092 let mut sx = 0.0;
1093 let mut sy = 0.0;
1094 for (a, b) in x.iter().zip(y.iter()) {
1095 let da = *a - mx;
1096 let db = *b - my;
1097 num += da * db;
1098 sx += da * da;
1099 sy += db * db;
1100 }
1101 let denom = (sx * sy).sqrt();
1102 if denom > 0.0 { num / denom } else { 0.0 }
1103 }
1104
1105 fn gene_gene_corr(log_mat: &Mat<f64>) -> Mat<f64> {
1107 let (n, s) = log_mat.shape();
1108 let mut rows: Vec<Vec<f64>> = Vec::with_capacity(n);
1109 for i in 0..n {
1110 rows.push((0..s).map(|j| log_mat[(i, j)]).collect());
1111 }
1112 let mut out = Mat::<f64>::zeros(n, n);
1113 for i in 0..n {
1114 for j in i..n {
1115 let r = pearson(&rows[i], &rows[j]);
1116 out[(i, j)] = r;
1117 out[(j, i)] = r;
1118 }
1119 }
1120 out
1121 }
1122
1123 fn take_row(m: &Mat<f64>, i: usize) -> Vec<f64> {
1125 (0..m.ncols()).map(|j| m[(i, j)]).collect()
1126 }
1127
1128 fn excess_kurtosis(x: &[f64]) -> f64 {
1130 let n = x.len() as f64;
1131 let m = x.iter().sum::<f64>() / n;
1132 let mut m2 = 0.0;
1133 let mut m4 = 0.0;
1134 for &v in x {
1135 let d = v - m;
1136 m2 += d * d;
1137 m4 += d * d * d * d;
1138 }
1139 m2 /= n;
1140 m4 /= n;
1141 if m2 > 0.0 { m4 / (m2 * m2) - 3.0 } else { 0.0 }
1142 }
1143
1144 fn hub_modular_default() -> BulkDataGenerator<f64> {
1145 BulkDataGenerator::HubModular(HubModularConfig::default())
1146 }
1147
1148 #[test]
1152 fn dims_no_modules() {
1153 let params = SyntheticRnaSeqParams::<f64> {
1154 num_samples: 10,
1155 num_genes: 50,
1156 module_sizes: vec![],
1157 ..Default::default()
1158 };
1159 let data = generate_bulk_rnaseq(¶ms).unwrap();
1160 assert_eq!(data.count_matrix.nrows(), 50);
1161 assert_eq!(data.count_matrix.ncols(), 10);
1162 assert_eq!(data.gene_modules.len(), 50);
1163 assert!(data.gene_modules.iter().all(|&m| m == 0));
1164 assert!(data.module_hubs.is_empty());
1165 assert_eq!(data.module_factors.nrows(), 0);
1166 assert_eq!(data.module_factors.ncols(), 10);
1167 }
1168
1169 #[test]
1171 fn dims_with_modules() {
1172 let params = SyntheticRnaSeqParams::<f64> {
1173 num_samples: 20,
1174 num_genes: 100,
1175 module_sizes: vec![20, 30],
1176 generator: hub_modular_default(),
1177 ..Default::default()
1178 };
1179 let data = generate_bulk_rnaseq(¶ms).unwrap();
1180 assert_eq!(data.count_matrix.nrows(), 100);
1181 let counts_by_id = |k: usize| data.gene_modules.iter().filter(|&&m| m == k).count();
1182 assert_eq!(counts_by_id(1), 20);
1183 assert_eq!(counts_by_id(2), 30);
1184 assert_eq!(counts_by_id(0), 50);
1185 assert_eq!(data.module_factors.nrows(), 2);
1186 assert_eq!(data.module_factors.ncols(), 20);
1187 assert!(!data.module_hubs.is_empty());
1188 assert!(data.module_hubs.iter().all(|&i| data.gene_modules[i] > 0));
1189 }
1190
1191 #[test]
1195 fn invalid_module_sizes_rejected() {
1196 let params = SyntheticRnaSeqParams::<f64> {
1197 num_samples: 5,
1198 num_genes: 10,
1199 module_sizes: vec![20],
1200 ..Default::default()
1201 };
1202 assert!(matches!(
1203 generate_bulk_rnaseq(¶ms),
1204 Err(CoreErrors::InvalidArgument(_))
1205 ));
1206 }
1207
1208 #[test]
1212 fn within_module_corr_stronger_than_cross() {
1213 let params = SyntheticRnaSeqParams::<f64> {
1214 num_samples: 200,
1215 num_genes: 110,
1216 seed: 7,
1217 module_sizes: vec![30, 30],
1218 generator: hub_modular_default(),
1219 ..Default::default()
1220 };
1221 let data = generate_bulk_rnaseq(¶ms).unwrap();
1222 let log = log1p_mat(&data.count_matrix);
1223 let corr = gene_gene_corr(&log);
1224 let mods = &data.gene_modules;
1225
1226 let (mut in_sum, mut in_n) = (0.0, 0usize);
1227 let (mut xr_sum, mut xr_n) = (0.0, 0usize);
1228 for i in 0..mods.len() {
1229 for j in (i + 1)..mods.len() {
1230 if mods[i] == 0 || mods[j] == 0 {
1231 continue;
1232 }
1233 if mods[i] == mods[j] {
1234 in_sum += corr[(i, j)];
1235 in_n += 1;
1236 } else {
1237 xr_sum += corr[(i, j)];
1238 xr_n += 1;
1239 }
1240 }
1241 }
1242 let in_mean = in_sum / in_n as f64;
1243 let cross_mean = xr_sum / xr_n as f64;
1244 assert!(
1245 in_mean - cross_mean > 0.15,
1246 "within-module mean {in_mean} - cross-module mean {cross_mean} < 0.15"
1247 );
1248 }
1249
1250 #[test]
1252 fn hubs_have_higher_degree_than_non_hubs() {
1253 let params = SyntheticRnaSeqParams::<f64> {
1254 num_samples: 200,
1255 num_genes: 100,
1256 seed: 11,
1257 module_sizes: vec![60],
1258 generator: hub_modular_default(),
1259 ..Default::default()
1260 };
1261 let data = generate_bulk_rnaseq(¶ms).unwrap();
1262 let log = log1p_mat(&data.count_matrix);
1263 let corr = gene_gene_corr(&log);
1264 let n = corr.nrows();
1265
1266 let threshold = 0.5;
1267 let mut degrees = vec![0usize; n];
1268 for i in 0..n {
1269 for j in 0..n {
1270 if i != j && corr[(i, j)].abs() >= threshold {
1271 degrees[i] += 1;
1272 }
1273 }
1274 }
1275
1276 let hubs: std::collections::HashSet<usize> = data.module_hubs.iter().copied().collect();
1277 let mut hub_sum = 0.0;
1278 let mut hub_n = 0.0;
1279 let mut non_hub_sum = 0.0;
1280 let mut non_hub_n = 0.0;
1281 for (i, &m) in data.gene_modules.iter().enumerate() {
1282 if m == 0 {
1283 continue;
1284 }
1285 if hubs.contains(&i) {
1286 hub_sum += degrees[i] as f64;
1287 hub_n += 1.0;
1288 } else {
1289 non_hub_sum += degrees[i] as f64;
1290 non_hub_n += 1.0;
1291 }
1292 }
1293 let hub_mean = hub_sum / hub_n;
1294 let non_hub_mean = non_hub_sum / non_hub_n;
1295 assert!(
1296 hub_mean > non_hub_mean,
1297 "hub mean degree {hub_mean} not > non-hub {non_hub_mean}"
1298 );
1299 }
1300
1301 #[test]
1305 fn same_seed_same_output() {
1306 let params = SyntheticRnaSeqParams::<f64> {
1307 num_samples: 12,
1308 num_genes: 40,
1309 seed: 999,
1310 module_sizes: vec![10, 10],
1311 ..Default::default()
1312 };
1313 let a = generate_bulk_rnaseq(¶ms).unwrap();
1314 let b = generate_bulk_rnaseq(¶ms).unwrap();
1315 for i in 0..a.count_matrix.nrows() {
1316 for j in 0..a.count_matrix.ncols() {
1317 assert_eq!(a.count_matrix[(i, j)], b.count_matrix[(i, j)]);
1318 }
1319 }
1320 for m in 0..a.module_factors.nrows() {
1321 for j in 0..a.module_factors.ncols() {
1322 assert_eq!(a.module_factors[(m, j)], b.module_factors[(m, j)]);
1323 }
1324 }
1325 }
1326
1327 #[test]
1331 fn module_pc1_tracks_ground_truth_factor() {
1332 let params = SyntheticRnaSeqParams::<f64> {
1333 num_samples: 150,
1334 num_genes: 60,
1335 seed: 21,
1336 module_sizes: vec![60],
1337 generator: hub_modular_default(),
1338 noise_std: 0.3,
1339 ..Default::default()
1340 };
1341 let data = generate_bulk_rnaseq(¶ms).unwrap();
1342 let log = log1p_mat(&data.count_matrix);
1343 let (n, s) = log.shape();
1344 let mut centered = Mat::<f64>::zeros(n, s);
1345 for i in 0..n {
1346 let mut m = 0.0;
1347 for j in 0..s {
1348 m += log[(i, j)];
1349 }
1350 m /= s as f64;
1351 for j in 0..s {
1352 centered[(i, j)] = log[(i, j)] - m;
1353 }
1354 }
1355 let svd = centered.thin_svd().expect("thin_svd");
1356 let v = svd.V();
1357 let pc1: Vec<f64> = (0..s).map(|j| v[(j, 0)]).collect();
1358 let factor = take_row(
1359 &Mat::<f64>::from_fn(1, s, |_, j| data.module_factors[(0, j)]),
1360 0,
1361 );
1362 let r = pearson(&pc1, &factor).abs();
1363 assert!(r > 0.7, "PC1 vs ground-truth factor |r|={r} <= 0.7");
1364 }
1365
1366 #[test]
1370 fn non_negative_factor_is_non_negative() {
1371 let params = SyntheticRnaSeqParams::<f64> {
1372 num_samples: 100,
1373 num_genes: 60,
1374 seed: 15,
1375 module_sizes: vec![30],
1376 generator: BulkDataGenerator::NonNegativeFactor(NonNegativeFactorConfig::default()),
1377 ..Default::default()
1378 };
1379 let data = generate_bulk_rnaseq(¶ms).unwrap();
1380 for m in 0..data.module_factors.nrows() {
1381 for j in 0..data.module_factors.ncols() {
1382 assert!(
1383 data.module_factors[(m, j)] >= 0.0,
1384 "factor {} at (m={m}, j={j}) is negative",
1385 data.module_factors[(m, j)]
1386 );
1387 }
1388 }
1389 let log = log1p_mat(&data.count_matrix);
1391 let corr = gene_gene_corr(&log);
1392 let mods = &data.gene_modules;
1393 let (mut in_sum, mut in_n) = (0.0, 0usize);
1394 for i in 0..mods.len() {
1395 for j in (i + 1)..mods.len() {
1396 if mods[i] > 0 && mods[i] == mods[j] {
1397 in_sum += corr[(i, j)];
1398 in_n += 1;
1399 }
1400 }
1401 }
1402 assert!(
1403 in_sum / in_n as f64 > 0.1,
1404 "non-negative variant not producing correlation"
1405 );
1406 }
1407
1408 #[test]
1412 fn non_gaussian_factor_is_non_gaussian() {
1413 let params = SyntheticRnaSeqParams::<f64> {
1414 num_samples: 500,
1415 num_genes: 60,
1416 seed: 23,
1417 module_sizes: vec![30],
1418 generator: BulkDataGenerator::NonGaussianFactor(NonGaussianFactorConfig::default()),
1419 ..Default::default()
1420 };
1421 let data = generate_bulk_rnaseq(¶ms).unwrap();
1422 let f: Vec<f64> = (0..data.module_factors.ncols())
1423 .map(|j| data.module_factors[(0, j)])
1424 .collect();
1425 let ex_k = excess_kurtosis(&f);
1427 assert!(
1428 ex_k > 1.0,
1429 "excess kurtosis {ex_k} not clearly non-Gaussian (Laplace expected ~3)"
1430 );
1431 }
1432
1433 #[test]
1437 fn dropout_reduces_total_counts() {
1438 let base = SyntheticRnaSeqParams::<f64> {
1439 num_samples: 30,
1440 num_genes: 100,
1441 seed: 3,
1442 module_sizes: vec![],
1443 ..Default::default()
1444 };
1445 let data = generate_bulk_rnaseq(&base).unwrap();
1446 let params = SparsityParams::<f64> {
1447 strategy: DropoutStrategy::SequencingDepth,
1448 target_library_size: 100.0,
1449 capture_efficiency_sigma: 0.5,
1450 seed: 4,
1451 };
1452 let dropped = apply_dropout(data.count_matrix.as_ref(), ¶ms).unwrap();
1453 let mut sum_before = 0.0;
1454 for i in 0..data.count_matrix.nrows() {
1455 for j in 0..data.count_matrix.ncols() {
1456 sum_before += data.count_matrix[(i, j)];
1457 }
1458 }
1459 let mut sum_after = 0.0;
1460 for i in 0..dropped.nrows() {
1461 for j in 0..dropped.ncols() {
1462 sum_after += dropped[(i, j)];
1463 }
1464 }
1465 assert!(
1466 sum_after < sum_before,
1467 "sum_after {sum_after} >= sum_before {sum_before}"
1468 );
1469 }
1470
1471 #[test]
1473 fn dropout_preserves_within_module_correlation_sign() {
1474 let base = SyntheticRnaSeqParams::<f64> {
1475 num_samples: 200,
1476 num_genes: 80,
1477 seed: 5,
1478 module_sizes: vec![40],
1479 generator: hub_modular_default(),
1480 ..Default::default()
1481 };
1482 let data = generate_bulk_rnaseq(&base).unwrap();
1483 let params = SparsityParams::<f64> {
1484 strategy: DropoutStrategy::SequencingDepth,
1485 target_library_size: 1_000.0,
1486 capture_efficiency_sigma: 0.2,
1487 seed: 6,
1488 };
1489 let dropped = apply_dropout(data.count_matrix.as_ref(), ¶ms).unwrap();
1490 let log = log1p_mat(&dropped);
1491 let corr = gene_gene_corr(&log);
1492 let mods = &data.gene_modules;
1493 let (mut in_sum, mut in_n) = (0.0, 0usize);
1494 for i in 0..mods.len() {
1495 for j in (i + 1)..mods.len() {
1496 if mods[i] > 0 && mods[i] == mods[j] {
1497 in_sum += corr[(i, j)];
1498 in_n += 1;
1499 }
1500 }
1501 }
1502 let mean = in_sum / in_n as f64;
1503 assert!(
1504 mean > 0.0,
1505 "within-module mean corr {mean} not positive after dropout"
1506 );
1507 }
1508
1509 #[test]
1511 fn dropout_reproducible() {
1512 let base = SyntheticRnaSeqParams::<f64> {
1513 num_samples: 15,
1514 num_genes: 40,
1515 seed: 8,
1516 module_sizes: vec![],
1517 ..Default::default()
1518 };
1519 let data = generate_bulk_rnaseq(&base).unwrap();
1520 let params = SparsityParams::<f64> {
1521 strategy: DropoutStrategy::SequencingDepth,
1522 target_library_size: 500.0,
1523 capture_efficiency_sigma: 0.5,
1524 seed: 99,
1525 };
1526 let a = apply_dropout(data.count_matrix.as_ref(), ¶ms).unwrap();
1527 let b = apply_dropout(data.count_matrix.as_ref(), ¶ms).unwrap();
1528 for i in 0..a.nrows() {
1529 for j in 0..a.ncols() {
1530 assert_eq!(a[(i, j)], b[(i, j)]);
1531 }
1532 }
1533 }
1534
1535 #[test]
1539 fn parse_helpers_work() {
1540 assert!(matches!(
1541 parse_bulk_data_generator::<f64>("modular"),
1542 Some(BulkDataGenerator::Modular(_))
1543 ));
1544 assert!(matches!(
1545 parse_bulk_data_generator::<f64>("Hub_Modular"),
1546 Some(BulkDataGenerator::HubModular(_))
1547 ));
1548 assert!(matches!(
1549 parse_bulk_data_generator::<f64>("nmf"),
1550 Some(BulkDataGenerator::NonNegativeFactor(_))
1551 ));
1552 assert!(matches!(
1553 parse_bulk_data_generator::<f64>("ica"),
1554 Some(BulkDataGenerator::NonGaussianFactor(_))
1555 ));
1556 assert!(parse_bulk_data_generator::<f64>("nope").is_none());
1557 assert_eq!(
1558 parse_dropout_strategy("seq_depth"),
1559 Some(DropoutStrategy::SequencingDepth)
1560 );
1561 assert_eq!(parse_dropout_strategy("nope"), None);
1562 }
1563}