1use faer::{Mat, linalg::solvers::DenseSolveCore};
4use rand::rngs::StdRng;
5use rand::{Rng, SeedableRng};
6use rayon::prelude::*;
7use statrs::distribution::FisherSnedecor;
8use statrs::distribution::{Continuous, ContinuousCDF, Normal};
9use statrs::function::gamma::ln_gamma;
10
11use crate::core::base::kernel_smooth::bw_sj;
12use crate::core::math::MAD_SCALE;
13use crate::core::math::distributions::{chisq_sf, f_sf, norm_sf, t_pval_two_sided};
14use crate::core::math::special::digamma;
15use crate::core::math::vector_helpers::*;
16use crate::prelude::*;
17
18const OUTLIER_EPS_MULT: f64 = 10.0;
26
27const GAMMA_MLE_MAX_ITER: usize = 100;
34
35const GAMMA_MLE_MIN_LOG_SPREAD: f64 = 1e-12;
43
44const GAMMA_MLE_TOL: f64 = 1e-12;
49
50const KS_MAX_TERMS: usize = 100;
55
56const KS_SERIES_EPS: f64 = 1e-14;
58
59const KS_SERIES_CROSSOVER: f64 = 1.0;
66
67const KS_STEPHENS_A: f64 = 0.12;
74
75const KS_STEPHENS_B: f64 = 0.11;
77
78pub type EffectSizeRes<T> = (Vec<T>, Vec<T>);
89
90pub fn hedge_g_effect<T>(
108 mean_a: &[T],
109 mean_b: &[T],
110 std_a: &[T],
111 std_b: &[T],
112 n_a: usize,
113 n_b: usize,
114 small_sample_correction: bool,
115) -> EffectSizeRes<T>
116where
117 T: BixverseFloat,
118{
119 assert_same_len!(mean_a, mean_b, std_a, std_b);
120
121 let n_a_t = T::from_usize(n_a).unwrap();
122 let n_b_t = T::from_usize(n_b).unwrap();
123 let total_n = T::from_usize(n_a + n_b).unwrap();
124 let two = T::from_usize(2).unwrap();
125 let three = T::from_usize(3).unwrap();
126
127 let (effect_sizes, standard_errors): (Vec<T>, Vec<T>) = mean_a
128 .par_iter()
129 .zip(mean_b.par_iter())
130 .zip(std_a.par_iter())
131 .zip(std_b.par_iter())
132 .map(|(((mean_a, mean_b), std_a), std_b)| {
133 let pooled_sd = (((n_a_t - T::one()) * std_a.powi(2)
134 + (n_b_t - T::one()) * std_b.powi(2))
135 / (total_n - two))
136 .sqrt();
137
138 let mut effect_size = (*mean_a - *mean_b) / pooled_sd;
139
140 if small_sample_correction {
141 let correction_factor = ((total_n - three)
142 / (total_n - T::from_f64(2.25).unwrap()))
143 * ((total_n - two) / total_n).sqrt();
144 effect_size = correction_factor * effect_size;
145 }
146
147 let standard_error =
148 ((total_n / (n_a_t * n_b_t)) + (effect_size.powi(2) / (two * total_n))).sqrt();
149
150 (effect_size, standard_error)
151 })
152 .unzip();
153
154 (effect_sizes, standard_errors)
155}
156
157#[derive(Clone, Debug, Default)]
163pub enum TestAlternative {
164 #[default]
166 TwoSided,
167 Greater,
169 Less,
171}
172
173pub fn get_test_alternative(s: &str) -> Option<TestAlternative> {
183 match s.to_lowercase().as_str() {
184 "twosided" => Some(TestAlternative::TwoSided),
185 "greater" => Some(TestAlternative::Greater),
186 "less" => Some(TestAlternative::Less),
187 _ => None,
188 }
189}
190
191pub fn z_scores_to_pval<T>(z_scores: &[T], test_alternative: &str) -> Vec<T>
201where
202 T: BixverseFloat,
203{
204 let test_alternative = get_test_alternative(test_alternative).unwrap_or_default();
205
206 let normal = Normal::new(0.0, 1.0).unwrap();
207
208 let one = T::one();
209 let two = T::from_usize(2).unwrap();
210 let six = T::from_usize(6).unwrap();
211
212 z_scores
213 .iter()
214 .map(|&z| match test_alternative {
215 TestAlternative::TwoSided => {
216 let abs_z = z.abs();
217 if abs_z > six {
218 let abs_z_f64 = abs_z.to_f64().unwrap();
219 let pdf = T::from_f64(normal.pdf(abs_z_f64)).unwrap();
220 let p = pdf / abs_z * (one - one / (abs_z * abs_z));
221 two * p
222 } else {
223 let abs_z_f64 = abs_z.to_f64().unwrap();
224 let cdf = T::from_f64(normal.cdf(abs_z_f64)).unwrap();
225 two * (one - cdf)
226 }
227 }
228 TestAlternative::Greater => {
229 if z > six {
230 let z_f64 = z.to_f64().unwrap();
231 let pdf = T::from_f64(normal.pdf(z_f64)).unwrap();
232 pdf / z * (one - one / (z * z))
233 } else {
234 let z_f64 = z.to_f64().unwrap();
235 let cdf = T::from_f64(normal.cdf(z_f64)).unwrap();
236 one - cdf
237 }
238 }
239 TestAlternative::Less => {
240 let neg_six = -six;
241 if z < neg_six {
242 let abs_z = z.abs();
243 let abs_z_f64 = abs_z.to_f64().unwrap();
244 let pdf = T::from_f64(normal.pdf(abs_z_f64)).unwrap();
245 pdf / abs_z * (one - one / (abs_z * abs_z))
246 } else {
247 let z_f64 = z.to_f64().unwrap();
248 T::from_f64(normal.cdf(z_f64)).unwrap()
249 }
250 }
251 })
252 .collect()
253}
254
255pub fn hypergeom_pval<T>(q: usize, m: usize, n: usize, k: usize) -> T
268where
269 T: BixverseFloat,
270{
271 let population = m + n;
272 let (n_f, m_f, k_f) = (
273 T::from_usize(n).unwrap(),
274 T::from_usize(m).unwrap(),
275 T::from_usize(k).unwrap(),
276 );
277 let population_f = T::from_usize(population).unwrap();
278
279 let upper = k.min(m);
280 if q + 1 > upper {
281 return T::zero();
282 }
283
284 let m_f64 = m_f.to_f64().unwrap();
287 let n_f64 = n_f.to_f64().unwrap();
288 let k_f64 = k_f.to_f64().unwrap();
289 let pop_f64 = population_f.to_f64().unwrap();
290 let invariant = ln_gamma(m_f64 + 1.0) + ln_gamma(n_f64 + 1.0)
291 - (ln_gamma(pop_f64 + 1.0)
292 - ln_gamma(k_f64 + 1.0)
293 - ln_gamma((population_f - k_f).to_f64().unwrap() + 1.0));
294
295 let log_pmf = |i: usize| -> T {
296 let i_f = T::from_usize(i).unwrap();
297 let log_pmf_f64 = invariant
298 - ln_gamma(i_f.to_f64().unwrap() + 1.0)
299 - ln_gamma((m_f - i_f).to_f64().unwrap() + 1.0)
300 - ln_gamma((k_f - i_f).to_f64().unwrap() + 1.0)
301 - ln_gamma((n_f - (k_f - i_f)).to_f64().unwrap() + 1.0);
302 T::from_f64(log_pmf_f64).unwrap()
303 };
304
305 let max_log_prob = ((q + 1)..=upper)
306 .map(log_pmf)
307 .fold(T::neg_infinity(), |a, b| a.max(b));
308
309 let mut sum = T::zero();
310 for i in (q + 1)..=upper {
311 sum += (log_pmf(i) - max_log_prob).exp();
312 }
313
314 sum * max_log_prob.exp()
315}
316
317pub fn p_adjust_holm<T>(pvals: &[T]) -> Vec<T>
332where
333 T: BixverseFloat,
334{
335 let n = pvals.len();
336 let mut order: Vec<usize> = (0..n).collect();
337 order.sort_by(|&a, &b| {
338 pvals[a]
339 .partial_cmp(&pvals[b])
340 .unwrap_or(std::cmp::Ordering::Equal)
341 });
342
343 let one = T::one();
344 let mut out = vec![T::zero(); n];
345 let mut running = T::zero();
346 for (rank, &idx) in order.iter().enumerate() {
347 let scaled = T::from_usize(n - rank).unwrap() * pvals[idx];
348 running = running.max(scaled);
349 out[idx] = running.min(one);
350 }
351 out
352}
353
354pub fn p_adjust_fdr<T>(pvals: &[T]) -> Vec<T>
367where
368 T: BixverseFloat,
369{
370 let n = pvals.len();
371 if n == 0 {
372 return Vec::new();
373 }
374 let n_t = T::from_usize(n).unwrap();
375 let one = T::one();
376
377 let mut indexed_pval: Vec<(usize, T)> =
378 pvals.par_iter().enumerate().map(|(i, &x)| (i, x)).collect();
379
380 indexed_pval
382 .par_sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
383
384 let adj_pvals_tmp: Vec<T> = indexed_pval
385 .par_iter()
386 .enumerate()
387 .map(|(i, (_, p))| {
388 let i_t = T::from_usize(i + 1).unwrap();
389 (n_t / i_t) * *p
390 })
391 .collect();
392
393 let mut current_min = adj_pvals_tmp[n - 1].min(one);
394 let mut monotonic_adj = vec![current_min; n];
395
396 for i in (0..n - 1).rev() {
397 current_min = current_min.min(adj_pvals_tmp[i]).min(one);
398 monotonic_adj[i] = current_min;
399 }
400
401 let mut adj_pvals = vec![T::zero(); n];
402
403 for (i, &(original_idx, _)) in indexed_pval.iter().enumerate() {
404 adj_pvals[original_idx] = monotonic_adj[i];
405 }
406
407 adj_pvals
408}
409
410#[deprecated(since = "0.4.8", note = "Renamed to p_adjust_fdr()")]
412pub fn calc_fdr<T>(pvals: &[T]) -> Vec<T>
413where
414 T: BixverseFloat,
415{
416 p_adjust_fdr(pvals)
417}
418
419#[derive(Debug, Clone)]
425#[allow(dead_code)]
426pub struct ManovaResult<T>
427where
428 T: BixverseFloat,
429{
430 pub sscp_between: Mat<T>,
432 pub sscp_within: Mat<T>,
434 pub sscp_total: Mat<T>,
436 pub df_between: usize,
438 pub df_within: usize,
440 pub df_total: usize,
442 pub n_vars: usize,
444 pub group_means: Vec<Vec<T>>,
446 pub overall_mean: Vec<T>,
448}
449
450#[allow(dead_code)]
451impl<T> ManovaResult<T>
452where
453 T: BixverseFloat + std::iter::Sum,
454{
455 fn rank1_eigenvalue(&self) -> T {
465 debug_assert_eq!(
466 self.df_between, 1,
467 "rank1_eigenvalue assumes two-group MANOVA (df_between == 1)"
468 );
469 let e_inv = self.sscp_within.partial_piv_lu().inverse();
470 let prod = &e_inv * &self.sscp_between;
471 let trace: T = prod.diagonal().column_vector().iter().copied().sum::<T>();
472 trace.max(T::zero())
474 }
475
476 pub fn wilks_lambda(&self) -> T {
484 let lambda = self.rank1_eigenvalue();
485 T::one() / (T::one() + lambda)
486 }
487
488 pub fn pillai_trace(&self) -> T {
496 let lambda = self.rank1_eigenvalue();
497 lambda / (T::one() + lambda)
498 }
499
500 fn two_group_f_test(&self) -> (T, T) {
507 let lambda = self.rank1_eigenvalue();
508 let p = T::from_usize(self.n_vars).unwrap();
509 let n = T::from_usize(self.df_within + self.df_between + 1).unwrap();
510 let df2 = n - p - T::one();
511
512 if !lambda.is_finite() || df2 <= T::zero() {
513 return (T::nan(), T::one());
514 }
515
516 let f_stat = (df2 / p) * lambda;
517
518 let df1_f64 = p.to_f64().unwrap();
519 let df2_f64 = df2.to_f64().unwrap();
520 let f_f64 = f_stat.to_f64().unwrap();
521
522 if !f_f64.is_finite() || f_f64 < 0.0 {
523 return (f_stat, T::one());
524 }
525
526 let f_dist = match FisherSnedecor::new(df1_f64, df2_f64) {
527 Ok(d) => d,
528 Err(_) => return (f_stat, T::one()),
529 };
530 let p_value = T::from_f64(1.0 - f_dist.cdf(f_f64)).unwrap();
531 (f_stat, p_value)
532 }
533
534 pub fn wilks_f_test(&self) -> (T, T) {
542 self.two_group_f_test()
543 }
544
545 pub fn pillai_f_test(&self) -> (T, T) {
553 self.two_group_f_test()
554 }
555}
556
557#[derive(Debug)]
559pub struct ManovaSummary<T>
560where
561 T: BixverseFloat,
562{
563 pub wilks_lambda: T,
565 pub pillai_trace: T,
567 pub df_between: usize,
569 pub df_within: usize,
571 pub f_stat_wilk: T,
573 pub p_val_wilk: T,
575 pub f_stat_pillai: T,
577 pub p_val_pillai: T,
579}
580
581impl<T> ManovaSummary<T>
582where
583 T: BixverseFloat + std::iter::Sum,
584{
585 pub fn from_manova_res(res: &ManovaResult<T>) -> Self {
595 let (f_stat_wilk, p_val_wilk) = res.wilks_f_test();
596 let (f_stat_pillai, p_val_pillai) = res.pillai_f_test();
597
598 ManovaSummary {
599 wilks_lambda: res.wilks_lambda(),
600 pillai_trace: res.pillai_trace(),
601 df_between: res.df_between,
602 df_within: res.df_within,
603 f_stat_wilk,
604 p_val_wilk,
605 f_stat_pillai,
606 p_val_pillai,
607 }
608 }
609}
610
611#[derive(Debug, Clone)]
617#[allow(dead_code)]
618pub struct AnovaSummary<T>
619where
620 T: BixverseFloat,
621{
622 pub variable_index: usize,
624 pub ss_between: T,
626 pub ss_within: T,
628 pub ms_between: T,
630 pub ms_within: T,
632 pub f_stat: T,
634 pub p_val: T,
636}
637
638pub fn summary_aov<T>(res: &ManovaResult<T>) -> Vec<AnovaSummary<T>>
648where
649 T: BixverseFloat,
650{
651 let mut aov_res = Vec::with_capacity(res.n_vars);
652 let df_between_t = T::from_usize(res.df_between).unwrap();
653 let df_within_t = T::from_usize(res.df_within).unwrap();
654
655 let f_dist = FisherSnedecor::new(
656 df_between_t.to_f64().unwrap(),
657 df_within_t.to_f64().unwrap(),
658 )
659 .ok();
660
661 for var_idx in 0..res.n_vars {
662 let ss_between = res.sscp_between[(var_idx, var_idx)];
663 let ss_within = res.sscp_within[(var_idx, var_idx)];
664 let ms_between = ss_between / df_between_t;
665 let ms_within = ss_within / df_within_t;
666
667 let (f_stat, p_val) = if ms_within <= T::zero() {
668 (T::nan(), T::one())
669 } else {
670 let f = ms_between / ms_within;
671 let f_f64 = f.to_f64().unwrap();
672 let pv = match (&f_dist, f_f64.is_finite() && f_f64 >= 0.0) {
673 (Some(d), true) => T::from_f64(1.0 - d.cdf(f_f64)).unwrap(),
674 _ => T::one(),
675 };
676 (f, pv)
677 };
678
679 aov_res.push(AnovaSummary {
680 variable_index: var_idx,
681 ss_between,
682 ss_within,
683 ms_between,
684 ms_within,
685 f_stat,
686 p_val,
687 });
688 }
689
690 aov_res
691}
692
693pub fn trigamma<T: BixverseFloat>(x: T) -> T {
707 let mut x = x;
708 let mut result = T::zero();
709
710 if x <= T::from_f64(5.0).unwrap() {
711 while x < T::from_f64(5.0).unwrap() {
712 result += T::one() / (x * x);
713 x += T::one();
714 }
715 }
716
717 let xx = x * x;
718 result += T::one() / x
719 + T::one() / (T::from_f64(2.0).unwrap() * xx)
720 + T::one() / (T::from_f64(6.0).unwrap() * xx * x);
721
722 let xxx = xx * x;
723 result += -T::one() / (T::from_f64(30.0).unwrap() * xxx * x)
724 + T::one() / (T::from_f64(42.0).unwrap() * xxx * xx * x)
725 - T::one() / (T::from_f64(30.0).unwrap() * xxx * xxx * x);
726
727 result
728}
729
730pub fn logit<T>(p: T) -> T
744where
745 T: BixverseFloat,
746{
747 (p / (T::one() - p)).ln()
748}
749
750pub fn inv_logit<T>(q: T) -> T
760where
761 T: BixverseFloat,
762{
763 q.exp() / (T::one() + q.exp())
764}
765
766pub fn calculate_critval<T: BixverseFloat>(
783 values: &[T],
784 sample_size: usize,
785 alpha: &T,
786 seed: usize,
787) -> T {
788 let mut rng = StdRng::seed_from_u64(seed as u64);
789 let mut random_sample: Vec<T> = (0..sample_size)
790 .map(|_| {
791 let index = rng.random_range(0..values.len());
792 values[index]
793 })
794 .collect();
795 random_sample.sort_by(|a, b| b.partial_cmp(a).unwrap());
796 let index = (*alpha * T::from_usize(random_sample.len()).unwrap())
797 .ceil()
798 .to_usize()
799 .unwrap();
800 random_sample[index + 1]
801}
802
803pub fn fit_gamma_mle(x: &[f64]) -> Result<(f64, f64), CoreErrors> {
831 if x.is_empty() {
832 return Err(CoreErrors::InvalidArgument(
833 "Cannot fit a gamma distribution to an empty sample.".to_string(),
834 ));
835 }
836
837 let n = x.len() as f64;
838 let mut sum = 0.0;
839 let mut sum_log = 0.0;
840 for &value in x {
841 if !(value.is_finite() && value > 0.0) {
842 return Err(CoreErrors::InvalidArgument(format!(
843 "Gamma fitting needs finite, strictly positive observations; got {value}."
844 )));
845 }
846 sum += value;
847 sum_log += value.ln();
848 }
849
850 let mean = sum / n;
851 let s = mean.ln() - sum_log / n;
853
854 if !s.is_finite() || s <= GAMMA_MLE_MIN_LOG_SPREAD {
855 return Err(CoreErrors::InvalidArgument(format!(
856 "Gamma fitting needs spread on the log scale; got {s}, which is at or below {GAMMA_MLE_MIN_LOG_SPREAD}."
857 )));
858 }
859
860 let mut shape = (3.0 - s + ((3.0 - s) * (3.0 - s) + 24.0 * s).sqrt()) / (12.0 * s);
862
863 for _ in 0..GAMMA_MLE_MAX_ITER {
864 let score = shape.ln() - digamma(shape) - s;
865 let derivative = 1.0 / shape - trigamma(shape);
866 if derivative.abs() < f64::MIN_POSITIVE {
867 break;
868 }
869 let next = shape - score / derivative;
870 let next = if next.is_finite() && next > 0.0 {
873 next
874 } else {
875 0.5 * shape
876 };
877 let step = (next - shape).abs();
878 shape = next;
879 if step <= GAMMA_MLE_TOL * shape {
880 break;
881 }
882 }
883
884 Ok((shape, mean / shape))
885}
886
887#[derive(Clone, Copy, Debug)]
893pub struct KsTestRes {
894 pub statistic: f64,
897 pub pval: f64,
900}
901
902fn kolmogorov_sf(lambda: f64) -> f64 {
925 if lambda <= 0.0 {
926 return 1.0;
927 }
928
929 if lambda >= KS_SERIES_CROSSOVER {
930 let a = -2.0 * lambda * lambda;
931 let mut sum = 0.0;
932 let mut sign = 1.0;
933
934 for j in 1..=KS_MAX_TERMS {
935 let term = (a * (j * j) as f64).exp();
936 sum += sign * term;
937 if term <= KS_SERIES_EPS * sum.abs() {
938 break;
939 }
940 sign = -sign;
941 }
942
943 return (2.0 * sum).clamp(0.0, 1.0);
944 }
945
946 let a = -std::f64::consts::PI * std::f64::consts::PI / (8.0 * lambda * lambda);
948 let mut sum = 0.0;
949
950 for k in 1..=KS_MAX_TERMS {
951 let odd = (2 * k - 1) as f64;
952 let term = (a * odd * odd).exp();
953 sum += term;
954 if term <= KS_SERIES_EPS * sum {
955 break;
956 }
957 }
958
959 (1.0 - (2.0 * std::f64::consts::PI).sqrt() / lambda * sum).clamp(0.0, 1.0)
960}
961
962pub fn ks_test_1samp<F>(x: &[f64], cdf: F) -> Result<KsTestRes, CoreErrors>
990where
991 F: Fn(f64) -> f64,
992{
993 if x.is_empty() {
994 return Err(CoreErrors::InvalidArgument(
995 "The Kolmogorov-Smirnov test needs a non-empty sample.".to_string(),
996 ));
997 }
998
999 let mut sorted = x.to_vec();
1000 if let Some(bad) = sorted.iter().find(|v| !v.is_finite()) {
1001 return Err(CoreErrors::InvalidArgument(format!(
1002 "The Kolmogorov-Smirnov test needs finite observations; got {bad}."
1003 )));
1004 }
1005 sorted.sort_unstable_by(f64::total_cmp);
1006
1007 let n = sorted.len();
1008 let inv_n = 1.0 / n as f64;
1009 let mut statistic = 0.0f64;
1010
1011 for (i, &value) in sorted.iter().enumerate() {
1012 let theoretical = cdf(value).clamp(0.0, 1.0);
1013 let above = (i + 1) as f64 * inv_n - theoretical;
1016 let below = theoretical - i as f64 * inv_n;
1017 statistic = statistic.max(above).max(below);
1018 }
1019
1020 let root_n = (n as f64).sqrt();
1021 let lambda = (root_n + KS_STEPHENS_A + KS_STEPHENS_B / root_n) * statistic;
1022
1023 Ok(KsTestRes {
1024 statistic,
1025 pval: kolmogorov_sf(lambda),
1026 })
1027}
1028
1029#[derive(Clone, Debug, Default)]
1035pub enum OutlierDirection {
1036 #[default]
1038 Both,
1039 Below,
1041 Above,
1043}
1044
1045pub fn parse_outlier_type(s: &str) -> Option<OutlierDirection> {
1055 match s.to_lowercase().as_str() {
1056 "below" => Some(OutlierDirection::Below),
1057 "above" => Some(OutlierDirection::Above),
1058 "twosided" => Some(OutlierDirection::Both),
1059 _ => None,
1060 }
1061}
1062
1063pub fn mad_outlier<T>(
1082 x: &[T],
1083 threshold: T,
1084 direction: OutlierDirection,
1085 r_standard: bool,
1086) -> (Vec<bool>, T)
1087where
1088 T: BixverseFloat,
1089{
1090 let median_val = match median(x) {
1091 Some(m) => m,
1092 None => return (vec![], T::zero()),
1093 };
1094 let scale = r_standard.then(|| T::from_f64(MAD_SCALE).unwrap());
1095 let mad_val = match mad(x, scale) {
1096 Some(m) => m,
1097 None => return (vec![], T::zero()),
1098 };
1099 let margin = threshold * mad_val;
1100 let res = x
1101 .iter()
1102 .map(|&v| match direction {
1103 OutlierDirection::Below => v < median_val - margin,
1104 OutlierDirection::Above => v > median_val + margin,
1105 OutlierDirection::Both => (v - median_val).abs() > margin,
1106 })
1107 .collect::<Vec<bool>>();
1108 (res, margin)
1109}
1110
1111pub fn robust_scale_binned(y: &[f64], x: &[f64], breaks: &[f64]) -> Result<Vec<f64>, CoreErrors> {
1135 if x.len() != y.len() {
1136 return Err(CoreErrors::LengthMismatch {
1137 name: "y",
1138 expected: x.len(),
1139 found: y.len(),
1140 });
1141 }
1142 if breaks.len() < 2 {
1143 return Err(CoreErrors::LengthMismatch {
1144 name: "breaks",
1145 expected: 2,
1146 found: breaks.len(),
1147 });
1148 }
1149
1150 let n_bins = breaks.len() - 1;
1151 let mut members: Vec<Vec<usize>> = vec![Vec::new(); n_bins];
1152
1153 for (i, &xi) in x.iter().enumerate() {
1154 if xi <= breaks[0] || xi > breaks[n_bins] {
1156 continue;
1157 }
1158 let b = breaks.partition_point(|&e| e < xi) - 1;
1160 members[b.min(n_bins - 1)].push(i);
1161 }
1162
1163 let mut score = vec![0.0_f64; y.len()];
1164 for bin in &members {
1165 if bin.is_empty() {
1166 continue;
1167 }
1168 let vals: Vec<f64> = bin.iter().map(|&i| y[i]).collect();
1169 let med = median(&vals).expect("non-empty bin");
1171 let scale = mad(&vals, Some(MAD_SCALE)).expect("non-empty bin") + f64::EPSILON;
1172 for (&i, v) in bin.iter().zip(vals.iter()) {
1173 score[i] = (v - med) / scale;
1174 }
1175 }
1176
1177 Ok(score)
1178}
1179
1180pub fn is_outlier(y: &[f64], x: &[f64], th: f64) -> Result<Vec<bool>, CoreErrors> {
1204 if x.len() != y.len() {
1205 return Err(CoreErrors::LengthMismatch {
1206 name: "y",
1207 expected: x.len(),
1208 found: y.len(),
1209 });
1210 }
1211
1212 let (min, max) = x
1213 .iter()
1214 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1215 (lo.min(v), hi.max(v))
1216 });
1217 let bin_width = (max - min) * bw_sj(x)? / 2.0;
1218 if !bin_width.is_finite() || bin_width <= 0.0 {
1219 return Err(CoreErrors::BandwidthNoBracket);
1220 }
1221 let eps = f64::EPSILON * OUTLIER_EPS_MULT;
1222
1223 let seq_by = |from: f64, to: f64, by: f64| -> Vec<f64> {
1225 let n = ((to - from) / by).floor() as usize;
1226 (0..=n).map(|i| from + i as f64 * by).collect()
1227 };
1228
1229 let breaks1 = seq_by(min - eps, max + bin_width, bin_width);
1230 let breaks2 = seq_by(min - eps - bin_width / 2.0, max + bin_width, bin_width);
1231
1232 let score1 = robust_scale_binned(y, x, &breaks1)?;
1233 let score2 = robust_scale_binned(y, x, &breaks2)?;
1234
1235 Ok(score1
1236 .iter()
1237 .zip(score2.iter())
1238 .map(|(a, b)| a.abs().min(b.abs()) > th)
1239 .collect())
1240}
1241
1242pub fn one_way_anova<T: BixverseFloat>(
1264 values: &[T],
1265 groups: &[usize],
1266 n_groups: usize,
1267) -> Result<(T, T), CoreErrors> {
1268 if values.len() != groups.len() {
1269 return Err(CoreErrors::ShapeMismatch {
1270 expected: (values.len(), 1),
1271 got: (groups.len(), 1),
1272 });
1273 }
1274 let n = values.len();
1275 let mut counts = vec![0_usize; n_groups];
1276 let mut sums = vec![0.0_f64; n_groups];
1277 for (v, &g) in values.iter().zip(groups.iter()) {
1278 if g >= n_groups {
1279 return Err(CoreErrors::InvalidArgument(format!(
1280 "group code {g} is outside 0..{n_groups}."
1281 )));
1282 }
1283 counts[g] += 1;
1284 sums[g] += v.to_f64().unwrap_or(f64::NAN);
1285 }
1286
1287 let n_used: usize = counts.iter().filter(|&&c| c > 0).count();
1288 if n_used < 2 {
1289 return Err(CoreErrors::InvalidArgument(
1290 "one-way ANOVA needs at least two non-empty groups.".to_string(),
1291 ));
1292 }
1293 let df_between = (n_used - 1) as f64;
1294 let df_within = (n - n_used) as f64;
1295 if df_within <= 0.0 {
1296 return Err(CoreErrors::InvalidArgument(
1297 "one-way ANOVA has no residual degrees of freedom.".to_string(),
1298 ));
1299 }
1300
1301 let grand_mean: f64 = sums.iter().sum::<f64>() / n as f64;
1302 let ss_between: f64 = counts
1303 .iter()
1304 .zip(sums.iter())
1305 .filter(|(c, _)| **c > 0)
1306 .map(|(&c, &s)| {
1307 let d = s / c as f64 - grand_mean;
1308 c as f64 * d * d
1309 })
1310 .sum();
1311 let group_means: Vec<f64> = sums
1312 .iter()
1313 .zip(counts.iter())
1314 .map(|(&s, &c)| s / c as f64)
1315 .collect();
1316 let ss_within: f64 = values
1317 .iter()
1318 .zip(groups.iter())
1319 .map(|(v, &g)| {
1320 let d = v.to_f64().unwrap_or(f64::NAN) - group_means[g];
1321 d * d
1322 })
1323 .sum();
1324
1325 if !ss_within.is_finite() || !ss_between.is_finite() {
1328 return Err(CoreErrors::InvalidArgument(
1329 "the values contain a non-finite entry.".to_string(),
1330 ));
1331 }
1332 let f_stat = if ss_within > 0.0 {
1336 (ss_between / df_between) / (ss_within / df_within)
1337 } else if ss_between > 0.0 {
1338 f64::INFINITY
1339 } else {
1340 0.0
1341 };
1342 let p = f_sf(f_stat, df_between, df_within)?;
1343
1344 Ok((
1345 T::from_f64(f_stat).unwrap_or_else(T::nan),
1346 T::from_f64(p).unwrap_or_else(T::nan),
1347 ))
1348}
1349
1350pub fn partial_correlation<T: BixverseFloat + Sync>(
1376 x: &[T],
1377 y: &[T],
1378 z: &[T],
1379 spearman: bool,
1380) -> Result<(T, T), CoreErrors> {
1381 let n = x.len();
1382 if y.len() != n {
1383 return Err(CoreErrors::ShapeMismatch {
1384 expected: (n, 3),
1385 got: (y.len(), 3),
1386 });
1387 }
1388 if z.len() != n {
1389 return Err(CoreErrors::ShapeMismatch {
1390 expected: (n, 3),
1391 got: (z.len(), 3),
1392 });
1393 }
1394 if n < 4 {
1395 return Err(CoreErrors::InvalidArgument(format!(
1396 "a first-order partial correlation needs at least 4 observations; got {n}."
1397 )));
1398 }
1399
1400 let (xv, yv, zv) = if spearman {
1401 (rank_vector(x), rank_vector(y), rank_vector(z))
1402 } else {
1403 (x.to_vec(), y.to_vec(), z.to_vec())
1404 };
1405
1406 let r_xy = pearson_correlation(&xv, &yv).unwrap_or(f64::NAN);
1407 let r_xz = pearson_correlation(&xv, &zv).unwrap_or(f64::NAN);
1408 let r_yz = pearson_correlation(&yv, &zv).unwrap_or(f64::NAN);
1409
1410 let denom = ((1.0 - r_xz * r_xz) * (1.0 - r_yz * r_yz)).sqrt();
1411 if !(denom.is_finite() && denom > 0.0) {
1412 return Ok((T::nan(), T::nan()));
1413 }
1414 let r = (r_xy - r_xz * r_yz) / denom;
1415
1416 let df = (n - 3) as f64;
1417 let p = if r.abs() >= 1.0 {
1419 0.0
1420 } else {
1421 let t_stat = r * (df / (1.0 - r * r)).sqrt();
1422 t_pval_two_sided(t_stat, df)?
1423 };
1424
1425 Ok((
1426 T::from_f64(r).unwrap_or_else(T::nan),
1427 T::from_f64(p).unwrap_or_else(T::nan),
1428 ))
1429}
1430
1431pub fn fisher_combine<T: BixverseFloat>(pvals: &[T]) -> Result<T, CoreErrors> {
1453 let kept: Vec<f64> = pvals
1454 .iter()
1455 .filter_map(|p| p.to_f64())
1456 .filter(|p| !p.is_nan())
1457 .collect();
1458 match kept.len() {
1459 0 => Ok(T::nan()),
1460 1 => Ok(T::from_f64(kept[0]).unwrap_or_else(T::nan)),
1461 m => {
1462 let stat: f64 = -2.0 * kept.iter().map(|p| p.ln()).sum::<f64>();
1463 let p = chisq_sf(stat, 2.0 * m as f64)?;
1464 Ok(T::from_f64(p).unwrap_or_else(T::nan))
1465 }
1466 }
1467}
1468
1469pub fn wilcox_rank_sum_greater_approx<T: BixverseFloat>(x: &[T], y: &[T]) -> Result<T, CoreErrors> {
1490 let n = x.len();
1491 let m = y.len();
1492 if n == 0 || m == 0 {
1493 return Err(CoreErrors::InvalidArgument(
1494 "the Wilcoxon rank sum needs a non-empty sample on both sides.".to_string(),
1495 ));
1496 }
1497 let total = n + m;
1498
1499 let mut pooled: Vec<T> = Vec::with_capacity(total);
1500 pooled.extend_from_slice(x);
1501 pooled.extend_from_slice(y);
1502 let ranks = rank_vector(&pooled);
1503
1504 let rank_sum: f64 = ranks[..n].iter().filter_map(|r| r.to_f64()).sum();
1505 let w = rank_sum - (n * (n + 1)) as f64 / 2.0;
1506
1507 let mut sorted: Vec<f64> = ranks.iter().filter_map(|r| r.to_f64()).collect();
1509 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1510 let mut tie_term = 0.0_f64;
1511 let mut i = 0;
1512 while i < total {
1513 let mut j = i + 1;
1514 while j < total && sorted[j] == sorted[i] {
1515 j += 1;
1516 }
1517 let t = (j - i) as f64;
1518 tie_term += t * t * t - t;
1519 i = j;
1520 }
1521
1522 let nm = (n * m) as f64;
1523 let total_f = total as f64;
1524 let variance = (nm / 12.0) * ((total_f + 1.0) - tie_term / (total_f * (total_f - 1.0)));
1525 if !(variance.is_finite() && variance > 0.0) {
1526 return Err(CoreErrors::InvalidArgument(
1527 "the Wilcoxon rank sum has zero variance; every observation is tied.".to_string(),
1528 ));
1529 }
1530
1531 let z = (w - nm / 2.0 - 0.5) / variance.sqrt();
1532 Ok(T::from_f64(norm_sf(z)).unwrap_or_else(T::nan))
1533}
1534
1535#[cfg(test)]
1540mod tests {
1541 use super::*;
1542 use approx::assert_relative_eq;
1543
1544 use crate::core::math::distributions::gamma_cdf;
1545
1546 fn lcg_unifs(state: &mut u64, n: usize) -> Vec<f64> {
1550 (0..n)
1551 .map(|_| {
1552 *state = (1_664_525_u64
1553 .wrapping_mul(*state)
1554 .wrapping_add(1_013_904_223))
1555 % 4_294_967_296;
1556 *state as f64 / 4_294_967_296.0
1557 })
1558 .collect()
1559 }
1560
1561 fn outlier_sample() -> (Vec<f64>, Vec<f64>) {
1564 let mut state = 20_260_101_u64;
1565 let x: Vec<f64> = lcg_unifs(&mut state, 600)
1566 .iter()
1567 .map(|u| u * 4.0 - 2.0)
1568 .collect();
1569 let mut y: Vec<f64> = lcg_unifs(&mut state, 600)
1570 .iter()
1571 .map(|u| u * 2.0 - 1.0)
1572 .collect();
1573 y[6] = 50.0;
1574 y[122] = -40.0;
1575 y[454] = 80.0;
1576 (x, y)
1577 }
1578
1579 #[test]
1584 fn test_robust_scale_binned_matches_sctransform() {
1585 let (x, y) = outlier_sample();
1586 let breaks: Vec<f64> = (0..=7).map(|i| -2.1 + 0.6 * i as f64).collect();
1587
1588 let got = robust_scale_binned(&y, &x, &breaks).unwrap();
1589
1590 let first_ten = [
1591 -0.075_797_230_741_805_32,
1592 -1.194_174_449_336_404_2,
1593 0.347_967_802_118_819_17,
1594 0.508_437_503_502_156_7,
1595 -0.375_397_206_147_169_8,
1596 -0.672_986_180_212_602,
1597 78.088_159_810_413_9,
1598 1.169_443_742_595_915,
1599 -1.467_419_443_801_975_6,
1600 -0.428_119_355_105_514_74,
1601 ];
1602 for (g, e) in got.iter().take(10).zip(first_ten.iter()) {
1603 assert_relative_eq!(*g, *e, max_relative = 1e-5);
1604 }
1605 assert_relative_eq!(
1606 got.iter().sum::<f64>(),
1607 145.563_245_444_483_98,
1608 max_relative = 1e-5
1609 );
1610 assert_relative_eq!(got[6], 78.088_159_810_413_9, max_relative = 1e-5);
1611 assert_relative_eq!(got[122], -58.804_104_377_993_6, max_relative = 1e-5);
1612 assert_relative_eq!(got[454], 113.340_405_508_264_04, max_relative = 1e-5);
1613 }
1614
1615 #[test]
1618 fn test_is_outlier_matches_sctransform() {
1619 let (x, y) = outlier_sample();
1620
1621 let got = is_outlier(&y, &x, 10.0).unwrap();
1622
1623 let flagged: Vec<usize> = got
1624 .iter()
1625 .enumerate()
1626 .filter(|&(_, &f)| f)
1627 .map(|(i, _)| i)
1628 .collect();
1629 assert_eq!(flagged, vec![6, 122, 454]);
1630 }
1631
1632 #[test]
1635 fn test_robust_scale_binned_leaves_out_of_range_at_zero() {
1636 let x = [0.0, 1.0, 2.0, 99.0];
1637 let y = [1.0, 5.0, 9.0, 1000.0];
1638
1639 let got = robust_scale_binned(&y, &x, &[-0.5, 1.5, 2.5]).unwrap();
1640
1641 assert_eq!(got[3], 0.0);
1642 }
1643
1644 #[test]
1645 fn test_binned_helpers_reject_length_mismatch() {
1646 assert!(matches!(
1647 robust_scale_binned(&[1.0], &[1.0, 2.0], &[0.0, 3.0]),
1648 Err(CoreErrors::LengthMismatch { .. })
1649 ));
1650 assert!(matches!(
1651 is_outlier(&[1.0], &[1.0, 2.0], 10.0),
1652 Err(CoreErrors::LengthMismatch { .. })
1653 ));
1654 }
1655
1656 #[test]
1658 fn test_one_way_anova_matches_r_aov() {
1659 let values: Vec<f64> = vec![
1661 -0.962, -0.293, 0.259, -1.152, 1.696, 1.53, 1.585, 2.617, 0.281, 2.767, -1.245, -1.631,
1662 -1.216, -0.247, -0.348,
1663 ];
1664 let groups: Vec<usize> = vec![0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2];
1665
1666 let (f_stat, p) = one_way_anova(&values, &groups, 3).unwrap();
1667
1668 assert_relative_eq!(f_stat, 20.398607696686263, max_relative = 1e-12);
1669 assert_relative_eq!(p, 0.00013785463402486508, max_relative = 1e-11);
1670 }
1671
1672 #[test]
1674 fn test_one_way_anova_null_case() {
1675 let values: Vec<f64> = vec![
1677 -0.767, -0.816, -0.142, -0.278, 0.436, -1.187, 1.192, -0.018, -0.248, -0.363, 1.278,
1678 -0.469, 0.071, -0.266, 1.845, -0.839,
1679 ];
1680 let groups: Vec<usize> = vec![0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1];
1681
1682 let (f_stat, p) = one_way_anova(&values, &groups, 2).unwrap();
1683
1684 assert_relative_eq!(f_stat, 0.577_110_410_366_241_4, max_relative = 1e-12);
1685 assert_relative_eq!(p, 0.4600488747180288, max_relative = 1e-12);
1686 }
1687
1688 #[test]
1691 fn test_one_way_anova_ignores_empty_groups() {
1692 let values: Vec<f64> = vec![1.0, 2.0, 5.0, 6.0];
1693 let groups: Vec<usize> = vec![0, 0, 2, 2];
1694 let widened = one_way_anova(&values, &groups, 5).unwrap();
1695 let tight = one_way_anova(&values, &[0, 0, 1, 1], 2).unwrap();
1696 assert_relative_eq!(widened.0, tight.0, max_relative = 1e-14);
1697 assert_relative_eq!(widened.1, tight.1, max_relative = 1e-14);
1698 }
1699
1700 #[test]
1707 fn test_one_way_anova_rejects_non_finite_values() {
1708 let groups = vec![0usize, 0, 0, 1, 1, 1];
1709 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1710 let values: Vec<f64> = vec![1.0, 2.0, bad, 5.0, 6.0, 7.0];
1711 assert!(
1712 one_way_anova::<f64>(&values, &groups, 2).is_err(),
1713 "accepted {bad} and reported a verdict"
1714 );
1715 }
1716 }
1717
1718 #[test]
1721 fn test_one_way_anova_degenerate_variance_branches() {
1722 let groups = vec![0usize, 0, 1, 1];
1723
1724 let separated = vec![1.0, 1.0, 5.0, 5.0];
1725 let (f_stat, p): (f64, f64) = one_way_anova(&separated, &groups, 2).unwrap();
1726 assert!(f_stat.is_infinite() && f_stat > 0.0);
1727 assert_eq!(p, 0.0);
1728
1729 let constant = vec![3.0, 3.0, 3.0, 3.0];
1730 let (f_stat, p): (f64, f64) = one_way_anova(&constant, &groups, 2).unwrap();
1731 assert_eq!(f_stat, 0.0);
1732 assert_eq!(p, 1.0);
1733 }
1734
1735 #[test]
1736 fn test_one_way_anova_rejects_degenerate_input() {
1737 let values: Vec<f64> = vec![1.0, 2.0, 3.0];
1738 assert!(one_way_anova(&values, &[0, 0, 0], 1).is_err());
1740 assert!(one_way_anova(&values, &[0, 1], 2).is_err());
1742 assert!(one_way_anova(&values, &[0, 1, 2], 3).is_err());
1744 }
1745
1746 #[test]
1750 fn test_p_adjust_holm_matches_r() {
1751 let got: Vec<f64> = p_adjust_holm(&[0.001, 0.02, 0.03, 0.04, 0.5]);
1753 let expected = [0.005, 0.08, 0.09, 0.09, 0.5];
1754 for (g, e) in got.iter().zip(expected.iter()) {
1755 assert_relative_eq!(g, e, max_relative = 1e-12);
1756 }
1757 }
1758
1759 #[test]
1762 fn test_p_adjust_holm_is_monotone_and_handles_ties() {
1763 let got: Vec<f64> = p_adjust_holm(&[0.01, 0.01, 0.04]);
1765 for (g, e) in got.iter().zip([0.03, 0.03, 0.04].iter()) {
1766 assert_relative_eq!(g, e, max_relative = 1e-12);
1767 }
1768 assert!(p_adjust_holm::<f64>(&[]).is_empty());
1769 let p = [0.2, 0.3, 0.4];
1771 let holm: Vec<f64> = p_adjust_holm(&p);
1772 let bh: Vec<f64> = p_adjust_fdr(&p);
1773 assert!(holm.iter().all(|v| *v <= 1.0));
1774 assert!(holm.iter().zip(bh.iter()).all(|(h, b)| h >= b));
1775 }
1776
1777 #[allow(clippy::type_complexity)]
1779 fn pcor_fixture() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
1780 let x = vec![
1782 -0.466, 0.173, 1.413, -0.47, 2.738, 0.383, -0.147, -0.7, 0.902, 0.874, -1.788, -0.305,
1783 -0.468, 0.01, 1.708, 1.331, 0.838, 1.904, -0.397, -1.11, -0.035, 1.094, -0.952, -1.223,
1784 0.17, 1.205, 0.253, -1.441, -0.02, 0.115,
1785 ];
1786 let y = vec![
1787 0.201, 0.87, -0.214, -1.769, 2.378, 0.736, -1.232, 0.596, -0.193, -0.953, -1.238,
1788 -0.229, -0.035, -0.948, 0.638, 0.959, 0.51, 1.172, -0.878, -0.078, 0.903, 0.24, -0.782,
1789 -0.877, -0.096, -0.073, -0.017, -1.358, -0.913, 0.349,
1790 ];
1791 let z = vec![
1792 0.793, 0.522, 1.746, -1.271, 2.197, 0.433, -1.57, -0.935, 0.063, -0.002, -2.277, 0.757,
1793 -0.548, 0.173, 0.563, 1.512, 0.659, 1.122, -0.785, -0.426, 0.393, 0.037, -1.032,
1794 -1.265, -0.227, 0.746, 0.333, -1.124, -0.706, -0.728,
1795 ];
1796 (x, y, z)
1797 }
1798
1799 #[test]
1801 fn test_partial_correlation_pearson_matches_ppcor() {
1802 let (x, y, z) = pcor_fixture();
1803 let (est, p) = partial_correlation(&x, &y, &z, false).unwrap();
1804 assert_relative_eq!(est, 0.13928951527176736, max_relative = 1e-12);
1805 assert_relative_eq!(p, 0.47113917629636237, max_relative = 1e-11);
1806 }
1807
1808 #[test]
1811 fn test_partial_correlation_spearman_matches_ppcor() {
1812 let (x, y, z) = pcor_fixture();
1813 let (est, p) = partial_correlation(&x, &y, &z, true).unwrap();
1814 assert_relative_eq!(est, 0.11596978538690578, max_relative = 1e-12);
1815 assert_relative_eq!(p, 0.549_124_448_784_373_9, max_relative = 1e-11);
1816 }
1817
1818 #[test]
1821 fn test_partial_correlation_removes_a_shared_driver() {
1822 let z: Vec<f64> = (0..40).map(|i| i as f64 * 0.1).collect();
1823 let x: Vec<f64> = z
1825 .iter()
1826 .enumerate()
1827 .map(|(i, v)| v + ((i % 7) as f64) * 0.01)
1828 .collect();
1829 let y: Vec<f64> = z
1830 .iter()
1831 .enumerate()
1832 .map(|(i, v)| v + ((i % 5) as f64) * 0.01)
1833 .collect();
1834
1835 let raw = pearson_correlation(&x, &y).unwrap();
1836 let (partial, _) = partial_correlation(&x, &y, &z, false).unwrap();
1837
1838 assert!(raw > 0.99, "raw correlation was {raw}");
1839 assert!(partial.abs() < 0.5, "partial correlation was {partial}");
1840 }
1841
1842 #[test]
1843 fn test_partial_correlation_rejects_short_and_ragged_input() {
1844 let a = vec![1.0, 2.0, 3.0];
1845 assert!(partial_correlation(&a, &a, &a, false).is_err());
1846 let b = vec![1.0, 2.0, 3.0, 4.0];
1847 assert!(partial_correlation(&b, &a, &b, false).is_err());
1848 }
1849
1850 #[test]
1854 fn test_fisher_combine_matches_r() {
1855 let p = vec![0.01, 0.2, 0.5, 0.03];
1856 let got: f64 = fisher_combine(&p).unwrap();
1857 assert_relative_eq!(got, 0.007_616_871_850_449_079, max_relative = 1e-12);
1858 }
1859
1860 #[test]
1862 fn test_fisher_combine_skips_nan() {
1863 let p = vec![0.4, f64::NAN, 0.9];
1864 let got: f64 = fisher_combine(&p).unwrap();
1865 assert_relative_eq!(got, 0.727_794_449_111_513_4, max_relative = 1e-12);
1866 }
1867
1868 #[test]
1871 fn test_fisher_combine_single_value_passes_through() {
1872 let got: f64 = fisher_combine(&[0.037]).unwrap();
1873 assert_eq!(got, 0.037);
1874 let got2: f64 = fisher_combine(&[f64::NAN, 0.037, f64::NAN]).unwrap();
1875 assert_eq!(got2, 0.037);
1876 }
1877
1878 #[test]
1881 fn test_fisher_combine_empty_is_nan() {
1882 let got: f64 = fisher_combine(&[]).unwrap();
1883 assert!(got.is_nan());
1884 let got2: f64 = fisher_combine(&[f64::NAN, f64::NAN]).unwrap();
1885 assert!(got2.is_nan());
1886 }
1887
1888 fn wilcox_nulls() -> Vec<f64> {
1892 vec![
1893 -0.8409, 1.3844, -1.2555, 0.0701, 1.7114, -0.6029, -0.4722, -0.6354, -0.2858, 0.1381,
1894 1.2276, -0.8018, -1.0804, -0.1575, -1.0718, -0.139, -0.5973, -2.184, 0.2408, -0.2594,
1895 0.9005, 0.9419, 1.468, 0.7068, 0.819, -0.2935, 1.4186, 1.4988, -0.6571, -0.8528,
1896 0.3159, 1.1097, 2.2155, 1.2171, 1.4792, 0.9516, -1.0095, -2.0005, -1.7622, -0.1426,
1897 1.5501, -0.8024, -0.0746, 1.8957, -0.4566, 0.5622, -0.887, -0.4602, -0.7243, -0.0692,
1898 1.4632, 0.1877, 1.022, -0.5918, -0.1122, -0.925, 0.7533, -0.1126, -0.0641, 0.2333,
1899 -1.1366, 0.8548, -0.5784, 0.4964, -0.7601, -0.3414, -2.1023, -0.3017, -1.2724, -0.2797,
1900 -0.2041, -0.2256, 0.347, 0.0324, 0.4135, -0.1553, 0.9735, 0.1211, 0.1892, -0.5629,
1901 0.4984, -1.7423, 0.9755, -0.0241, 0.6757, -0.7103, 2.3872, -0.4734, -0.0758, -0.5218,
1902 0.926, -1.0624, 0.557, 0.9007, 0.9899, 0.3836, -0.3466, -0.5402, -0.1826,
1903 ]
1904 }
1905
1906 #[test]
1914 fn test_wilcox_rank_sum_greater_beats_every_null() {
1915 let nulls = wilcox_nulls();
1916 let real = nulls.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + 0.1;
1917 let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
1918 assert_relative_eq!(p, 0.044_801_589_130_970_6, max_relative = 1e-11);
1920 }
1921
1922 #[test]
1924 fn test_wilcox_rank_sum_greater_at_the_ninetieth_null() {
1925 let nulls = wilcox_nulls();
1926 let mut sorted = nulls.clone();
1927 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
1928 let real = sorted[89];
1929 let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
1930 assert_relative_eq!(p, 0.085_594_599_535_642_33, max_relative = 1e-11);
1931 assert!(p < 0.1);
1932 }
1933
1934 #[test]
1936 fn test_wilcox_rank_sum_greater_at_the_median() {
1937 let nulls = wilcox_nulls();
1938 let mut sorted = nulls.clone();
1939 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
1940 let real = sorted[49];
1941 let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
1942 assert_relative_eq!(p, 0.506_909_903_708_803, max_relative = 1e-11);
1943 }
1944
1945 #[test]
1947 fn test_wilcox_rank_sum_greater_small_samples() {
1948 let a = [3.1, 4.5, 2.2, 6.0];
1949 let b = [1.0, 2.5, 3.9, 0.4, 5.5];
1950 let p: f64 = wilcox_rank_sum_greater_approx(&a, &b).unwrap();
1951 assert_relative_eq!(p, 0.195_633_639_641_319_7, max_relative = 1e-11);
1952 }
1953
1954 #[test]
1956 fn test_wilcox_rank_sum_greater_with_ties() {
1957 let a = [1.0, 2.0, 3.0];
1958 let b = [2.0, 3.0, 4.0, 5.0];
1959 let p: f64 = wilcox_rank_sum_greater_approx(&a, &b).unwrap();
1960 assert_relative_eq!(p, 0.947_403_747_439_979_2, max_relative = 1e-11);
1961 }
1962
1963 #[test]
1964 fn test_wilcox_rank_sum_rejects_degenerate_input() {
1965 let a = [1.0, 2.0];
1966 let empty: [f64; 0] = [];
1967 assert!(wilcox_rank_sum_greater_approx(&a, &empty).is_err());
1968 assert!(wilcox_rank_sum_greater_approx(&empty, &a).is_err());
1969 assert!(wilcox_rank_sum_greater_approx(&[1.0, 1.0], &[1.0, 1.0]).is_err());
1971 }
1972
1973 #[test]
1975 fn test_logit_and_inv_logit() {
1976 let p: f64 = 0.5;
1977 let log_odds: f64 = logit(p);
1978 assert!((log_odds - 0.0).abs() < 1e-6); let recovered_p = inv_logit(log_odds);
1981 assert!((recovered_p - 0.5).abs() < 1e-6);
1982 }
1983
1984 #[test]
1986 fn test_z_scores_to_pval() {
1987 let z_scores: Vec<f64> = vec![0.0, 1.95996398454]; let pvals_two_sided = z_scores_to_pval(&z_scores, "twosided");
1990 assert!((pvals_two_sided[0] - 1.0).abs() < 1e-6);
1992 assert!((pvals_two_sided[1] - 0.05).abs() < 1e-5);
1994 }
1995
1996 #[test]
2003 fn test_calc_fdr_is_invariant_to_tie_order() {
2004 let mut pvals: Vec<f64> = vec![0.02; 500];
2006 pvals.extend(vec![0.5; 50]);
2007 pvals.extend([0.001, 0.3, 0.7, 0.9, 0.02, 0.5]);
2008
2009 let first = p_adjust_fdr(&pvals);
2010 let second = p_adjust_fdr(&pvals);
2011 assert_eq!(first, second, "calc_fdr is not deterministic");
2012
2013 for (i, p) in pvals.iter().enumerate() {
2015 for (j, q) in pvals.iter().enumerate() {
2016 if p == q {
2017 assert_eq!(
2018 first[i], first[j],
2019 "tied p-values {p} got different FDRs at {i} and {j}"
2020 );
2021 }
2022 }
2023 }
2024
2025 let mut reversed = pvals.clone();
2027 reversed.reverse();
2028 let rev_fdr = p_adjust_fdr(&reversed);
2029 let mut a = first.clone();
2030 let mut b = rev_fdr;
2031 a.sort_by(|x, y| x.partial_cmp(y).unwrap());
2032 b.sort_by(|x, y| x.partial_cmp(y).unwrap());
2033 for (x, y) in a.iter().zip(b.iter()) {
2034 assert!((x - y).abs() < 1e-15, "{x} vs {y}");
2035 }
2036 }
2037
2038 #[test]
2046 fn test_upper_tail_pval_monotone_in_z() {
2047 let mut zs: Vec<f64> = vec![
2048 -8.0, -6.0, -1.0, 0.0, 1.0, 3.0, 5.0, 5.9, 5.9999, 6.0, 6.0001, 6.1, 7.0, 10.0, 20.0,
2049 37.0, 39.0,
2050 ];
2051 for i in 0..200 {
2053 zs.push(5.5 + i as f64 * 0.005);
2054 }
2055 zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
2056
2057 let pvals = z_scores_to_pval(&zs, "greater");
2058 for w in pvals.windows(2) {
2059 assert!(
2060 w[1] <= w[0],
2061 "upper-tail p-value increased: {} then {}",
2062 w[0],
2063 w[1]
2064 );
2065 }
2066 }
2067
2068 #[test]
2070 fn test_calc_fdr() {
2071 let pvals: Vec<f64> = vec![0.01, 0.04, 0.03];
2073 let fdr = p_adjust_fdr(&pvals);
2078
2079 assert!((fdr[0] - 0.03).abs() < 1e-6);
2080 assert!((fdr[1] - 0.04).abs() < 1e-6);
2081 assert!((fdr[2] - 0.04).abs() < 1e-6);
2082 }
2083
2084 #[test]
2087 fn test_mad_outlier() {
2088 let vec: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 100.0];
2089 let (outliers, margin) = mad_outlier(&vec, 3.0, OutlierDirection::Both, true);
2091
2092 assert!((margin - 3.0 * MAD_SCALE).abs() < 1e-12);
2093 assert_eq!(outliers, vec![false, false, false, false, true]);
2094 }
2095
2096 #[test]
2100 fn test_mad_outlier_matches_r_scaled_and_raw_mad() {
2101 let vec: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 9.0];
2102
2103 let (outliers, margin) = mad_outlier(&vec, 3.0, OutlierDirection::Both, true);
2104 assert!((margin - 6.6717).abs() < 1e-4);
2106 assert_eq!(outliers, vec![false; 6]);
2107
2108 let (outliers, margin) = mad_outlier(&vec, 3.0, OutlierDirection::Both, false);
2109 assert!((margin - 4.5).abs() < 1e-12);
2110 assert_eq!(outliers, vec![false, false, false, false, false, true]);
2111 }
2112
2113 #[test]
2115 fn test_parse_helpers() {
2116 assert!(matches!(
2117 get_test_alternative("twosided"),
2118 Some(TestAlternative::TwoSided)
2119 ));
2120 assert!(matches!(
2121 get_test_alternative("GREATER"),
2122 Some(TestAlternative::Greater)
2123 ));
2124
2125 assert!(matches!(
2126 parse_outlier_type("below"),
2127 Some(OutlierDirection::Below)
2128 ));
2129 assert!(matches!(
2130 parse_outlier_type("Twosided"),
2131 Some(OutlierDirection::Both)
2132 ));
2133 }
2134
2135 #[test]
2138 fn test_manova_no_overflow_large_p() {
2139 let p = 80;
2143 let n_total = 2949usize;
2144 let sigma = (n_total as f64).powi(3) / 12.0;
2145
2146 let sscp_within = Mat::from_fn(p, p, |i, j| if i == j { sigma } else { sigma * 0.3 });
2147 let sscp_between = Mat::from_fn(
2149 p,
2150 p,
2151 |i, j| {
2152 if i == j { sigma * 1.5e-3 } else { sigma * 5e-4 }
2153 },
2154 );
2155 let sscp_total = &sscp_within + &sscp_between;
2156
2157 let res = ManovaResult::<f64> {
2158 sscp_between,
2159 sscp_within,
2160 sscp_total,
2161 df_between: 1,
2162 df_within: n_total - 2,
2163 df_total: n_total - 1,
2164 n_vars: p,
2165 group_means: vec![vec![0.0; p], vec![0.0; p]],
2166 overall_mean: vec![0.0; p],
2167 };
2168
2169 let wilks = res.wilks_lambda();
2170 let pillai = res.pillai_trace();
2171 let (f_w, p_w) = res.wilks_f_test();
2172 let (f_p, p_p) = res.pillai_f_test();
2173
2174 assert!(wilks.is_finite() && wilks > 0.0 && wilks <= 1.0);
2175 assert!((0.0..1.0).contains(&pillai) && pillai.is_finite());
2176 assert!((wilks + pillai - 1.0).abs() < 1e-9);
2177 assert!(f_w.is_finite() && f_w >= 0.0);
2178 assert!(f_p.is_finite() && f_p >= 0.0);
2179 assert!((0.0..=1.0).contains(&p_w));
2180 assert!((0.0..=1.0).contains(&p_p));
2181 assert!((f_w - f_p).abs() < 1e-9);
2183 assert!((p_w - p_p).abs() < 1e-9);
2184 }
2185
2186 #[test]
2188 fn test_summary_aov_constant_column() {
2189 let p = 2;
2191 let sscp_within = Mat::from_fn(p, p, |i, j| {
2192 if i == 0 && j == 0 {
2193 0.0
2194 } else if i == j {
2195 10.0
2196 } else {
2197 0.0
2198 }
2199 });
2200 let sscp_between = Mat::from_fn(p, p, |i, j| if i == j { 5.0 } else { 0.0 });
2201 let sscp_total = &sscp_within + &sscp_between;
2202
2203 let res = ManovaResult::<f64> {
2204 sscp_between,
2205 sscp_within,
2206 sscp_total,
2207 df_between: 1,
2208 df_within: 10,
2209 df_total: 11,
2210 n_vars: p,
2211 group_means: vec![vec![0.0; p], vec![0.0; p]],
2212 overall_mean: vec![0.0; p],
2213 };
2214
2215 let aov = summary_aov(&res);
2216 assert_eq!(aov.len(), 2);
2217 assert!(aov[0].f_stat.is_nan());
2218 assert!((aov[0].p_val - 1.0).abs() < 1e-12);
2219 assert!(aov[1].f_stat.is_finite() && aov[1].f_stat > 0.0);
2220 assert!((0.0..=1.0).contains(&aov[1].p_val));
2221 }
2222
2223 #[test]
2236 fn test_fit_gamma_mle_matches_the_exact_root() {
2237 let y = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0];
2238
2239 let (shape, scale) = fit_gamma_mle(&y).unwrap();
2240
2241 assert_relative_eq!(shape, 1.934_295_921_801_258_3, max_relative = 1e-13);
2242 assert_relative_eq!(scale, 1.731_896_325_811_62, max_relative = 1e-13);
2243 }
2244
2245 #[test]
2248 fn test_fit_gamma_mle_preserves_mean() {
2249 let y = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0];
2250 let mean = y.iter().sum::<f64>() / y.len() as f64;
2251
2252 let (shape, scale) = fit_gamma_mle(&y).unwrap();
2253
2254 assert_relative_eq!(shape * scale, mean, max_relative = 1e-12);
2255 }
2256
2257 #[test]
2260 fn test_fit_gamma_mle_solves_score_equation() {
2261 let y: Vec<f64> = (1..=200).map(|i| (i as f64) * 0.07 + 0.3).collect();
2262 let n = y.len() as f64;
2263 let mean = y.iter().sum::<f64>() / n;
2264 let s = mean.ln() - y.iter().map(|v| v.ln()).sum::<f64>() / n;
2265
2266 let (shape, _) = fit_gamma_mle(&y).unwrap();
2267
2268 assert_relative_eq!(shape.ln() - digamma(shape), s, max_relative = 1e-10);
2269 }
2270
2271 #[test]
2275 fn test_fit_gamma_mle_handles_shape_below_one() {
2276 let y: Vec<f64> = (1..=400).map(|i| (i as f64).powi(6)).collect();
2277 let n = y.len() as f64;
2278 let mean = y.iter().sum::<f64>() / n;
2279 let s = mean.ln() - y.iter().map(|v| v.ln()).sum::<f64>() / n;
2280
2281 let (shape, scale) = fit_gamma_mle(&y).unwrap();
2282
2283 assert!(shape > 0.0 && shape < 1.0, "shape came back as {shape}");
2284 assert!(scale.is_finite() && scale > 0.0);
2285 assert_relative_eq!(shape.ln() - digamma(shape), s, max_relative = 1e-10);
2286 assert_relative_eq!(shape * scale, mean, max_relative = 1e-12);
2287 }
2288
2289 #[test]
2290 fn test_fit_gamma_mle_rejects_bad_samples() {
2291 assert!(fit_gamma_mle(&[]).is_err());
2292 assert!(fit_gamma_mle(&[1.0, -2.0, 3.0]).is_err());
2293 assert!(fit_gamma_mle(&[1.0, f64::NAN]).is_err());
2294 assert!(fit_gamma_mle(&[2.0; 20]).is_err());
2296 }
2297
2298 #[test]
2310 fn test_ks_statistic_matches_r() {
2311 let y = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0];
2312 let (shape, scale) = (1.934_295_921_801_258_3, 1.731_896_325_811_62);
2313
2314 let res = ks_test_1samp(&y, |x| gamma_cdf(x, shape, scale).unwrap()).unwrap();
2315
2316 assert_relative_eq!(
2317 res.statistic,
2318 0.096_888_414_283_814_12,
2319 max_relative = 1e-12
2320 );
2321 assert!(res.pval > 0.9, "a good fit should not be rejected");
2322 }
2323
2324 #[test]
2326 fn test_ks_rejects_wrong_distribution() {
2327 let y: Vec<f64> = (1..=200).map(|i| 50.0 + i as f64 * 0.01).collect();
2328
2329 let res = ks_test_1samp(&y, |x| gamma_cdf(x, 1.0, 1.0).unwrap()).unwrap();
2330
2331 assert!(res.statistic > 0.9);
2332 assert!(res.pval < 1e-6, "got {}", res.pval);
2333 }
2334
2335 #[test]
2338 fn test_ks_statistic_checks_both_sides_of_the_step() {
2339 let y = [0.9, 0.92, 0.94, 0.96, 0.98];
2342
2343 let res = ks_test_1samp(&y, |x| x.clamp(0.0, 1.0)).unwrap();
2344
2345 assert_relative_eq!(res.statistic, 0.9, max_relative = 1e-12);
2346 }
2347
2348 #[test]
2349 fn test_ks_rejects_bad_samples() {
2350 assert!(ks_test_1samp(&[], |x| x).is_err());
2351 assert!(ks_test_1samp(&[1.0, f64::INFINITY], |x| x).is_err());
2352 }
2353
2354 #[test]
2355 fn test_kolmogorov_sf_bounds() {
2356 assert_eq!(kolmogorov_sf(0.0), 1.0);
2357 assert!(kolmogorov_sf(5.0) < 1e-20);
2358 }
2359
2360 #[test]
2362 fn test_kolmogorov_sf_small_lambda_is_one() {
2363 for &lambda in &[1e-6, 1e-3, 0.01, 0.05, 0.1, 0.2] {
2364 let q = kolmogorov_sf(lambda);
2365 assert!(
2366 q >= 0.999,
2367 "Q({lambda}) = {q}, should be indistinguishable from 1"
2368 );
2369 }
2370 }
2371
2372 #[test]
2379 fn test_kolmogorov_sf_series_agree_at_the_crossover() {
2380 const Q_AT_ONE: f64 = 0.269_999_671_677_354_6;
2383
2384 assert_relative_eq!(
2386 kolmogorov_sf(KS_SERIES_CROSSOVER),
2387 Q_AT_ONE,
2388 max_relative = 1e-14
2389 );
2390
2391 assert_relative_eq!(
2394 kolmogorov_sf(KS_SERIES_CROSSOVER - 1e-12),
2395 Q_AT_ONE,
2396 max_relative = 1e-11
2397 );
2398 }
2399
2400 #[test]
2402 fn test_kolmogorov_sf_is_monotone() {
2403 let mut previous = 1.0;
2404 for step in 1..=4000 {
2405 let lambda = step as f64 * 0.001;
2406 let current = kolmogorov_sf(lambda);
2407 assert!(
2408 current <= previous + 1e-12,
2409 "not monotone at lambda = {lambda}"
2410 );
2411 previous = current;
2412 }
2413 }
2414}