use faer::{Mat, linalg::solvers::DenseSolveCore};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use rayon::prelude::*;
use statrs::distribution::FisherSnedecor;
use statrs::distribution::{Continuous, ContinuousCDF, Normal};
use statrs::function::gamma::ln_gamma;
use crate::core::math::distributions::{chisq_sf, f_sf, norm_sf, t_pval_two_sided};
use crate::core::math::vector_helpers::*;
use crate::prelude::*;
pub type EffectSizeRes<T> = (Vec<T>, Vec<T>);
pub fn hedge_g_effect<T>(
mean_a: &[T],
mean_b: &[T],
std_a: &[T],
std_b: &[T],
n_a: usize,
n_b: usize,
small_sample_correction: bool,
) -> EffectSizeRes<T>
where
T: BixverseFloat,
{
assert_same_len!(mean_a, mean_b, std_a, std_b);
let n_a_t = T::from_usize(n_a).unwrap();
let n_b_t = T::from_usize(n_b).unwrap();
let total_n = T::from_usize(n_a + n_b).unwrap();
let two = T::from_usize(2).unwrap();
let three = T::from_usize(3).unwrap();
let (effect_sizes, standard_errors): (Vec<T>, Vec<T>) = mean_a
.par_iter()
.zip(mean_b.par_iter())
.zip(std_a.par_iter())
.zip(std_b.par_iter())
.map(|(((mean_a, mean_b), std_a), std_b)| {
let pooled_sd = (((n_a_t - T::one()) * std_a.powi(2)
+ (n_b_t - T::one()) * std_b.powi(2))
/ (total_n - two))
.sqrt();
let mut effect_size = (*mean_a - *mean_b) / pooled_sd;
if small_sample_correction {
let correction_factor = ((total_n - three)
/ (total_n - T::from_f64(2.25).unwrap()))
* ((total_n - two) / total_n).sqrt();
effect_size = correction_factor * effect_size;
}
let standard_error =
((total_n / (n_a_t * n_b_t)) + (effect_size.powi(2) / (two * total_n))).sqrt();
(effect_size, standard_error)
})
.unzip();
(effect_sizes, standard_errors)
}
#[derive(Clone, Debug, Default)]
pub enum TestAlternative {
#[default]
TwoSided,
Greater,
Less,
}
pub fn get_test_alternative(s: &str) -> Option<TestAlternative> {
match s.to_lowercase().as_str() {
"twosided" => Some(TestAlternative::TwoSided),
"greater" => Some(TestAlternative::Greater),
"less" => Some(TestAlternative::Less),
_ => None,
}
}
pub fn z_scores_to_pval<T>(z_scores: &[T], test_alternative: &str) -> Vec<T>
where
T: BixverseFloat,
{
let test_alternative = get_test_alternative(test_alternative).unwrap_or_default();
let normal = Normal::new(0.0, 1.0).unwrap();
let one = T::one();
let two = T::from_usize(2).unwrap();
let six = T::from_usize(6).unwrap();
z_scores
.iter()
.map(|&z| match test_alternative {
TestAlternative::TwoSided => {
let abs_z = z.abs();
if abs_z > six {
let abs_z_f64 = abs_z.to_f64().unwrap();
let pdf = T::from_f64(normal.pdf(abs_z_f64)).unwrap();
let p = pdf / abs_z * (one - one / (abs_z * abs_z));
two * p
} else {
let abs_z_f64 = abs_z.to_f64().unwrap();
let cdf = T::from_f64(normal.cdf(abs_z_f64)).unwrap();
two * (one - cdf)
}
}
TestAlternative::Greater => {
if z > six {
let z_f64 = z.to_f64().unwrap();
let pdf = T::from_f64(normal.pdf(z_f64)).unwrap();
pdf / z * (one - one / (z * z))
} else {
let z_f64 = z.to_f64().unwrap();
let cdf = T::from_f64(normal.cdf(z_f64)).unwrap();
one - cdf
}
}
TestAlternative::Less => {
let neg_six = -six;
if z < neg_six {
let abs_z = z.abs();
let abs_z_f64 = abs_z.to_f64().unwrap();
let pdf = T::from_f64(normal.pdf(abs_z_f64)).unwrap();
pdf / abs_z * (one - one / (abs_z * abs_z))
} else {
let z_f64 = z.to_f64().unwrap();
T::from_f64(normal.cdf(z_f64)).unwrap()
}
}
})
.collect()
}
pub fn hypergeom_pval<T>(q: usize, m: usize, n: usize, k: usize) -> T
where
T: BixverseFloat,
{
let population = m + n;
let (n_f, m_f, k_f) = (
T::from_usize(n).unwrap(),
T::from_usize(m).unwrap(),
T::from_usize(k).unwrap(),
);
let population_f = T::from_usize(population).unwrap();
let upper = k.min(m);
let mut log_probs = Vec::new();
for i in (q + 1)..=upper {
let i_f = T::from_usize(i).unwrap();
let log_pmf_f64 = ln_gamma(m_f.to_f64().unwrap() + 1.0)
- ln_gamma(i_f.to_f64().unwrap() + 1.0)
- ln_gamma((m_f - i_f).to_f64().unwrap() + 1.0)
+ ln_gamma(n_f.to_f64().unwrap() + 1.0)
- ln_gamma((k_f - i_f).to_f64().unwrap() + 1.0)
- ln_gamma((n_f - (k_f - i_f)).to_f64().unwrap() + 1.0)
- (ln_gamma(population_f.to_f64().unwrap() + 1.0)
- ln_gamma(k_f.to_f64().unwrap() + 1.0)
- ln_gamma((population_f - k_f).to_f64().unwrap() + 1.0));
log_probs.push(T::from_f64(log_pmf_f64).unwrap());
}
if log_probs.is_empty() {
return T::zero();
}
let max_log_prob = log_probs
.iter()
.cloned()
.fold(T::neg_infinity(), |a, b| a.max(b));
let mut sum = T::zero();
for log_p in log_probs {
sum += (log_p - max_log_prob).exp();
}
sum * max_log_prob.exp()
}
pub fn p_adjust_holm<T>(pvals: &[T]) -> Vec<T>
where
T: BixverseFloat,
{
let n = pvals.len();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| {
pvals[a]
.partial_cmp(&pvals[b])
.unwrap_or(std::cmp::Ordering::Equal)
});
let one = T::one();
let mut out = vec![T::zero(); n];
let mut running = T::zero();
for (rank, &idx) in order.iter().enumerate() {
let scaled = T::from_usize(n - rank).unwrap() * pvals[idx];
running = running.max(scaled);
out[idx] = running.min(one);
}
out
}
pub fn calc_fdr<T>(pvals: &[T]) -> Vec<T>
where
T: BixverseFloat,
{
let n = pvals.len();
if n == 0 {
return Vec::new();
}
let n_t = T::from_usize(n).unwrap();
let one = T::one();
let mut indexed_pval: Vec<(usize, T)> =
pvals.par_iter().enumerate().map(|(i, &x)| (i, x)).collect();
indexed_pval
.par_sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let adj_pvals_tmp: Vec<T> = indexed_pval
.par_iter()
.enumerate()
.map(|(i, (_, p))| {
let i_t = T::from_usize(i + 1).unwrap();
(n_t / i_t) * *p
})
.collect();
let mut current_min = adj_pvals_tmp[n - 1].min(one);
let mut monotonic_adj = vec![current_min; n];
for i in (0..n - 1).rev() {
current_min = current_min.min(adj_pvals_tmp[i]).min(one);
monotonic_adj[i] = current_min;
}
let mut adj_pvals = vec![T::zero(); n];
for (i, &(original_idx, _)) in indexed_pval.iter().enumerate() {
adj_pvals[original_idx] = monotonic_adj[i];
}
adj_pvals
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ManovaResult<T>
where
T: BixverseFloat,
{
pub sscp_between: Mat<T>,
pub sscp_within: Mat<T>,
pub sscp_total: Mat<T>,
pub df_between: usize,
pub df_within: usize,
pub df_total: usize,
pub n_vars: usize,
pub group_means: Vec<Vec<T>>,
pub overall_mean: Vec<T>,
}
#[allow(dead_code)]
impl<T> ManovaResult<T>
where
T: BixverseFloat + std::iter::Sum,
{
fn rank1_eigenvalue(&self) -> T {
debug_assert_eq!(
self.df_between, 1,
"rank1_eigenvalue assumes two-group MANOVA (df_between == 1)"
);
let e_inv = self.sscp_within.partial_piv_lu().inverse();
let prod = &e_inv * &self.sscp_between;
let trace: T = prod.diagonal().column_vector().iter().copied().sum::<T>();
trace.max(T::zero())
}
pub fn wilks_lambda(&self) -> T {
let lambda = self.rank1_eigenvalue();
T::one() / (T::one() + lambda)
}
pub fn pillai_trace(&self) -> T {
let lambda = self.rank1_eigenvalue();
lambda / (T::one() + lambda)
}
fn two_group_f_test(&self) -> (T, T) {
let lambda = self.rank1_eigenvalue();
let p = T::from_usize(self.n_vars).unwrap();
let n = T::from_usize(self.df_within + self.df_between + 1).unwrap();
let df2 = n - p - T::one();
if !lambda.is_finite() || df2 <= T::zero() {
return (T::nan(), T::one());
}
let f_stat = (df2 / p) * lambda;
let df1_f64 = p.to_f64().unwrap();
let df2_f64 = df2.to_f64().unwrap();
let f_f64 = f_stat.to_f64().unwrap();
if !f_f64.is_finite() || f_f64 < 0.0 {
return (f_stat, T::one());
}
let f_dist = match FisherSnedecor::new(df1_f64, df2_f64) {
Ok(d) => d,
Err(_) => return (f_stat, T::one()),
};
let p_value = T::from_f64(1.0 - f_dist.cdf(f_f64)).unwrap();
(f_stat, p_value)
}
pub fn wilks_f_test(&self) -> (T, T) {
self.two_group_f_test()
}
pub fn pillai_f_test(&self) -> (T, T) {
self.two_group_f_test()
}
}
#[derive(Debug)]
pub struct ManovaSummary<T>
where
T: BixverseFloat,
{
pub wilks_lambda: T,
pub pillai_trace: T,
pub df_between: usize,
pub df_within: usize,
pub f_stat_wilk: T,
pub p_val_wilk: T,
pub f_stat_pillai: T,
pub p_val_pillai: T,
}
impl<T> ManovaSummary<T>
where
T: BixverseFloat + std::iter::Sum,
{
pub fn from_manova_res(res: &ManovaResult<T>) -> Self {
let (f_stat_wilk, p_val_wilk) = res.wilks_f_test();
let (f_stat_pillai, p_val_pillai) = res.pillai_f_test();
ManovaSummary {
wilks_lambda: res.wilks_lambda(),
pillai_trace: res.pillai_trace(),
df_between: res.df_between,
df_within: res.df_within,
f_stat_wilk,
p_val_wilk,
f_stat_pillai,
p_val_pillai,
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct AnovaSummary<T>
where
T: BixverseFloat,
{
pub variable_index: usize,
pub ss_between: T,
pub ss_within: T,
pub ms_between: T,
pub ms_within: T,
pub f_stat: T,
pub p_val: T,
}
pub fn summary_aov<T>(res: &ManovaResult<T>) -> Vec<AnovaSummary<T>>
where
T: BixverseFloat,
{
let mut aov_res = Vec::with_capacity(res.n_vars);
let df_between_t = T::from_usize(res.df_between).unwrap();
let df_within_t = T::from_usize(res.df_within).unwrap();
let f_dist = FisherSnedecor::new(
df_between_t.to_f64().unwrap(),
df_within_t.to_f64().unwrap(),
)
.ok();
for var_idx in 0..res.n_vars {
let ss_between = res.sscp_between[(var_idx, var_idx)];
let ss_within = res.sscp_within[(var_idx, var_idx)];
let ms_between = ss_between / df_between_t;
let ms_within = ss_within / df_within_t;
let (f_stat, p_val) = if ms_within <= T::zero() {
(T::nan(), T::one())
} else {
let f = ms_between / ms_within;
let f_f64 = f.to_f64().unwrap();
let pv = match (&f_dist, f_f64.is_finite() && f_f64 >= 0.0) {
(Some(d), true) => T::from_f64(1.0 - d.cdf(f_f64)).unwrap(),
_ => T::one(),
};
(f, pv)
};
aov_res.push(AnovaSummary {
variable_index: var_idx,
ss_between,
ss_within,
ms_between,
ms_within,
f_stat,
p_val,
});
}
aov_res
}
pub fn trigamma<T: BixverseFloat>(x: T) -> T {
let mut x = x;
let mut result = T::zero();
if x <= T::from_f64(5.0).unwrap() {
while x < T::from_f64(5.0).unwrap() {
result += T::one() / (x * x);
x += T::one();
}
}
let xx = x * x;
result += T::one() / x
+ T::one() / (T::from_f64(2.0).unwrap() * xx)
+ T::one() / (T::from_f64(6.0).unwrap() * xx * x);
let xxx = xx * x;
result += -T::one() / (T::from_f64(30.0).unwrap() * xxx * x)
+ T::one() / (T::from_f64(42.0).unwrap() * xxx * xx * x)
- T::one() / (T::from_f64(30.0).unwrap() * xxx * xxx * x);
result
}
pub fn logit<T>(p: T) -> T
where
T: BixverseFloat,
{
(p / (T::one() - p)).ln()
}
pub fn inv_logit<T>(q: T) -> T
where
T: BixverseFloat,
{
q.exp() / (T::one() + q.exp())
}
pub fn calculate_critval<T: BixverseFloat>(
values: &[T],
sample_size: usize,
alpha: &T,
seed: usize,
) -> T {
let mut rng = StdRng::seed_from_u64(seed as u64);
let mut random_sample: Vec<T> = (0..sample_size)
.map(|_| {
let index = rng.random_range(0..values.len());
values[index]
})
.collect();
random_sample.sort_by(|a, b| b.partial_cmp(a).unwrap());
let index = (*alpha * T::from_usize(random_sample.len()).unwrap())
.ceil()
.to_usize()
.unwrap();
random_sample[index + 1]
}
#[derive(Clone, Debug, Default)]
pub enum OutlierDirection {
#[default]
Both,
Below,
Above,
}
pub fn parse_outlier_type(s: &str) -> Option<OutlierDirection> {
match s.to_lowercase().as_str() {
"below" => Some(OutlierDirection::Below),
"above" => Some(OutlierDirection::Above),
"twosided" => Some(OutlierDirection::Both),
_ => None,
}
}
pub fn mad_outlier<T>(x: &[T], threshold: T, direction: OutlierDirection) -> (Vec<bool>, T)
where
T: BixverseFloat,
{
let median_val = match median(x) {
Some(m) => m,
None => return (vec![], T::zero()),
};
let mad_val = match mad(x, None) {
Some(m) => m,
None => return (vec![], T::zero()),
};
let margin = threshold * mad_val;
let res = x
.iter()
.map(|&v| match direction {
OutlierDirection::Below => v < median_val - margin,
OutlierDirection::Above => v > median_val + margin,
OutlierDirection::Both => (v - median_val).abs() > margin,
})
.collect::<Vec<bool>>();
(res, margin)
}
pub fn one_way_anova<T: BixverseFloat>(
values: &[T],
groups: &[usize],
n_groups: usize,
) -> Result<(T, T), BixverseErrors> {
if values.len() != groups.len() {
return Err(BixverseErrors::ShapeMismatch {
expected: (values.len(), 1),
got: (groups.len(), 1),
});
}
let n = values.len();
let mut counts = vec![0_usize; n_groups];
let mut sums = vec![0.0_f64; n_groups];
for (v, &g) in values.iter().zip(groups.iter()) {
if g >= n_groups {
return Err(BixverseErrors::InvalidArgument(format!(
"group code {g} is outside 0..{n_groups}."
)));
}
counts[g] += 1;
sums[g] += v.to_f64().unwrap_or(f64::NAN);
}
let n_used: usize = counts.iter().filter(|&&c| c > 0).count();
if n_used < 2 {
return Err(BixverseErrors::InvalidArgument(
"one-way ANOVA needs at least two non-empty groups.".to_string(),
));
}
let df_between = (n_used - 1) as f64;
let df_within = (n - n_used) as f64;
if df_within <= 0.0 {
return Err(BixverseErrors::InvalidArgument(
"one-way ANOVA has no residual degrees of freedom.".to_string(),
));
}
let grand_mean: f64 = sums.iter().sum::<f64>() / n as f64;
let ss_between: f64 = counts
.iter()
.zip(sums.iter())
.filter(|(c, _)| **c > 0)
.map(|(&c, &s)| {
let d = s / c as f64 - grand_mean;
c as f64 * d * d
})
.sum();
let ss_within: f64 = values
.iter()
.zip(groups.iter())
.map(|(v, &g)| {
let d = v.to_f64().unwrap_or(f64::NAN) - sums[g] / counts[g] as f64;
d * d
})
.sum();
if !ss_within.is_finite() || !ss_between.is_finite() {
return Err(BixverseErrors::InvalidArgument(
"the values contain a non-finite entry.".to_string(),
));
}
let f_stat = if ss_within > 0.0 {
(ss_between / df_between) / (ss_within / df_within)
} else if ss_between > 0.0 {
f64::INFINITY
} else {
0.0
};
let p = f_sf(f_stat, df_between, df_within)?;
Ok((
T::from_f64(f_stat).unwrap_or_else(T::nan),
T::from_f64(p).unwrap_or_else(T::nan),
))
}
pub fn partial_correlation<T: BixverseFloat + Sync>(
x: &[T],
y: &[T],
z: &[T],
spearman: bool,
) -> Result<(T, T), BixverseErrors> {
let n = x.len();
if y.len() != n {
return Err(BixverseErrors::ShapeMismatch {
expected: (n, 3),
got: (y.len(), 3),
});
}
if z.len() != n {
return Err(BixverseErrors::ShapeMismatch {
expected: (n, 3),
got: (z.len(), 3),
});
}
if n < 4 {
return Err(BixverseErrors::InvalidArgument(format!(
"a first-order partial correlation needs at least 4 observations; got {n}."
)));
}
let (xv, yv, zv) = if spearman {
(rank_vector(x), rank_vector(y), rank_vector(z))
} else {
(x.to_vec(), y.to_vec(), z.to_vec())
};
let r_xy = pearson_correlation(&xv, &yv).unwrap_or(f64::NAN);
let r_xz = pearson_correlation(&xv, &zv).unwrap_or(f64::NAN);
let r_yz = pearson_correlation(&yv, &zv).unwrap_or(f64::NAN);
let denom = ((1.0 - r_xz * r_xz) * (1.0 - r_yz * r_yz)).sqrt();
if !(denom.is_finite() && denom > 0.0) {
return Ok((T::nan(), T::nan()));
}
let r = (r_xy - r_xz * r_yz) / denom;
let df = (n - 3) as f64;
let p = if r.abs() >= 1.0 {
0.0
} else {
let t_stat = r * (df / (1.0 - r * r)).sqrt();
t_pval_two_sided(t_stat, df)?
};
Ok((
T::from_f64(r).unwrap_or_else(T::nan),
T::from_f64(p).unwrap_or_else(T::nan),
))
}
pub fn fisher_combine<T: BixverseFloat>(pvals: &[T]) -> Result<T, BixverseErrors> {
let kept: Vec<f64> = pvals
.iter()
.filter_map(|p| p.to_f64())
.filter(|p| !p.is_nan())
.collect();
match kept.len() {
0 => Ok(T::nan()),
1 => Ok(T::from_f64(kept[0]).unwrap_or_else(T::nan)),
m => {
let stat: f64 = -2.0 * kept.iter().map(|p| p.ln()).sum::<f64>();
let p = chisq_sf(stat, 2.0 * m as f64)?;
Ok(T::from_f64(p).unwrap_or_else(T::nan))
}
}
}
pub fn wilcox_rank_sum_greater_approx<T: BixverseFloat>(
x: &[T],
y: &[T],
) -> Result<T, BixverseErrors> {
let n = x.len();
let m = y.len();
if n == 0 || m == 0 {
return Err(BixverseErrors::InvalidArgument(
"the Wilcoxon rank sum needs a non-empty sample on both sides.".to_string(),
));
}
let total = n + m;
let mut pooled: Vec<T> = Vec::with_capacity(total);
pooled.extend_from_slice(x);
pooled.extend_from_slice(y);
let ranks = rank_vector(&pooled);
let rank_sum: f64 = ranks[..n].iter().filter_map(|r| r.to_f64()).sum();
let w = rank_sum - (n * (n + 1)) as f64 / 2.0;
let mut sorted: Vec<f64> = ranks.iter().filter_map(|r| r.to_f64()).collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut tie_term = 0.0_f64;
let mut i = 0;
while i < total {
let mut j = i + 1;
while j < total && sorted[j] == sorted[i] {
j += 1;
}
let t = (j - i) as f64;
tie_term += t * t * t - t;
i = j;
}
let nm = (n * m) as f64;
let total_f = total as f64;
let variance = (nm / 12.0) * ((total_f + 1.0) - tie_term / (total_f * (total_f - 1.0)));
if !(variance.is_finite() && variance > 0.0) {
return Err(BixverseErrors::InvalidArgument(
"the Wilcoxon rank sum has zero variance; every observation is tied.".to_string(),
));
}
let z = (w - nm / 2.0 - 0.5) / variance.sqrt();
Ok(T::from_f64(norm_sf(z)).unwrap_or_else(T::nan))
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_one_way_anova_matches_r_aov() {
let values: Vec<f64> = vec![
-0.962, -0.293, 0.259, -1.152, 1.696, 1.53, 1.585, 2.617, 0.281, 2.767, -1.245, -1.631,
-1.216, -0.247, -0.348,
];
let groups: Vec<usize> = vec![0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2];
let (f_stat, p) = one_way_anova(&values, &groups, 3).unwrap();
assert_relative_eq!(f_stat, 20.398607696686263, max_relative = 1e-12);
assert_relative_eq!(p, 0.00013785463402486508, max_relative = 1e-11);
}
#[test]
fn test_one_way_anova_null_case() {
let values: Vec<f64> = vec![
-0.767, -0.816, -0.142, -0.278, 0.436, -1.187, 1.192, -0.018, -0.248, -0.363, 1.278,
-0.469, 0.071, -0.266, 1.845, -0.839,
];
let groups: Vec<usize> = vec![0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1];
let (f_stat, p) = one_way_anova(&values, &groups, 2).unwrap();
assert_relative_eq!(f_stat, 0.577_110_410_366_241_4, max_relative = 1e-12);
assert_relative_eq!(p, 0.4600488747180288, max_relative = 1e-12);
}
#[test]
fn test_one_way_anova_ignores_empty_groups() {
let values: Vec<f64> = vec![1.0, 2.0, 5.0, 6.0];
let groups: Vec<usize> = vec![0, 0, 2, 2];
let widened = one_way_anova(&values, &groups, 5).unwrap();
let tight = one_way_anova(&values, &[0, 0, 1, 1], 2).unwrap();
assert_relative_eq!(widened.0, tight.0, max_relative = 1e-14);
assert_relative_eq!(widened.1, tight.1, max_relative = 1e-14);
}
#[test]
fn test_one_way_anova_rejects_non_finite_values() {
let groups = vec![0usize, 0, 0, 1, 1, 1];
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let values: Vec<f64> = vec![1.0, 2.0, bad, 5.0, 6.0, 7.0];
assert!(
one_way_anova::<f64>(&values, &groups, 2).is_err(),
"accepted {bad} and reported a verdict"
);
}
}
#[test]
fn test_one_way_anova_degenerate_variance_branches() {
let groups = vec![0usize, 0, 1, 1];
let separated = vec![1.0, 1.0, 5.0, 5.0];
let (f_stat, p): (f64, f64) = one_way_anova(&separated, &groups, 2).unwrap();
assert!(f_stat.is_infinite() && f_stat > 0.0);
assert_eq!(p, 0.0);
let constant = vec![3.0, 3.0, 3.0, 3.0];
let (f_stat, p): (f64, f64) = one_way_anova(&constant, &groups, 2).unwrap();
assert_eq!(f_stat, 0.0);
assert_eq!(p, 1.0);
}
#[test]
fn test_one_way_anova_rejects_degenerate_input() {
let values: Vec<f64> = vec![1.0, 2.0, 3.0];
assert!(one_way_anova(&values, &[0, 0, 0], 1).is_err());
assert!(one_way_anova(&values, &[0, 1], 2).is_err());
assert!(one_way_anova(&values, &[0, 1, 2], 3).is_err());
}
#[test]
fn test_p_adjust_holm_matches_r() {
let got: Vec<f64> = p_adjust_holm(&[0.001, 0.02, 0.03, 0.04, 0.5]);
let expected = [0.005, 0.08, 0.09, 0.09, 0.5];
for (g, e) in got.iter().zip(expected.iter()) {
assert_relative_eq!(g, e, max_relative = 1e-12);
}
}
#[test]
fn test_p_adjust_holm_is_monotone_and_handles_ties() {
let got: Vec<f64> = p_adjust_holm(&[0.01, 0.01, 0.04]);
for (g, e) in got.iter().zip([0.03, 0.03, 0.04].iter()) {
assert_relative_eq!(g, e, max_relative = 1e-12);
}
assert!(p_adjust_holm::<f64>(&[]).is_empty());
let p = [0.2, 0.3, 0.4];
let holm: Vec<f64> = p_adjust_holm(&p);
let bh: Vec<f64> = calc_fdr(&p);
assert!(holm.iter().all(|v| *v <= 1.0));
assert!(holm.iter().zip(bh.iter()).all(|(h, b)| h >= b));
}
#[allow(clippy::type_complexity)]
fn pcor_fixture() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let x = vec![
-0.466, 0.173, 1.413, -0.47, 2.738, 0.383, -0.147, -0.7, 0.902, 0.874, -1.788, -0.305,
-0.468, 0.01, 1.708, 1.331, 0.838, 1.904, -0.397, -1.11, -0.035, 1.094, -0.952, -1.223,
0.17, 1.205, 0.253, -1.441, -0.02, 0.115,
];
let y = vec![
0.201, 0.87, -0.214, -1.769, 2.378, 0.736, -1.232, 0.596, -0.193, -0.953, -1.238,
-0.229, -0.035, -0.948, 0.638, 0.959, 0.51, 1.172, -0.878, -0.078, 0.903, 0.24, -0.782,
-0.877, -0.096, -0.073, -0.017, -1.358, -0.913, 0.349,
];
let z = vec![
0.793, 0.522, 1.746, -1.271, 2.197, 0.433, -1.57, -0.935, 0.063, -0.002, -2.277, 0.757,
-0.548, 0.173, 0.563, 1.512, 0.659, 1.122, -0.785, -0.426, 0.393, 0.037, -1.032,
-1.265, -0.227, 0.746, 0.333, -1.124, -0.706, -0.728,
];
(x, y, z)
}
#[test]
fn test_partial_correlation_pearson_matches_ppcor() {
let (x, y, z) = pcor_fixture();
let (est, p) = partial_correlation(&x, &y, &z, false).unwrap();
assert_relative_eq!(est, 0.13928951527176736, max_relative = 1e-12);
assert_relative_eq!(p, 0.47113917629636237, max_relative = 1e-11);
}
#[test]
fn test_partial_correlation_spearman_matches_ppcor() {
let (x, y, z) = pcor_fixture();
let (est, p) = partial_correlation(&x, &y, &z, true).unwrap();
assert_relative_eq!(est, 0.11596978538690578, max_relative = 1e-12);
assert_relative_eq!(p, 0.549_124_448_784_373_9, max_relative = 1e-11);
}
#[test]
fn test_partial_correlation_removes_a_shared_driver() {
let z: Vec<f64> = (0..40).map(|i| i as f64 * 0.1).collect();
let x: Vec<f64> = z
.iter()
.enumerate()
.map(|(i, v)| v + ((i % 7) as f64) * 0.01)
.collect();
let y: Vec<f64> = z
.iter()
.enumerate()
.map(|(i, v)| v + ((i % 5) as f64) * 0.01)
.collect();
let raw = pearson_correlation(&x, &y).unwrap();
let (partial, _) = partial_correlation(&x, &y, &z, false).unwrap();
assert!(raw > 0.99, "raw correlation was {raw}");
assert!(partial.abs() < 0.5, "partial correlation was {partial}");
}
#[test]
fn test_partial_correlation_rejects_short_and_ragged_input() {
let a = vec![1.0, 2.0, 3.0];
assert!(partial_correlation(&a, &a, &a, false).is_err());
let b = vec![1.0, 2.0, 3.0, 4.0];
assert!(partial_correlation(&b, &a, &b, false).is_err());
}
#[test]
fn test_fisher_combine_matches_r() {
let p = vec![0.01, 0.2, 0.5, 0.03];
let got: f64 = fisher_combine(&p).unwrap();
assert_relative_eq!(got, 0.007_616_871_850_449_079, max_relative = 1e-12);
}
#[test]
fn test_fisher_combine_skips_nan() {
let p = vec![0.4, f64::NAN, 0.9];
let got: f64 = fisher_combine(&p).unwrap();
assert_relative_eq!(got, 0.727_794_449_111_513_4, max_relative = 1e-12);
}
#[test]
fn test_fisher_combine_single_value_passes_through() {
let got: f64 = fisher_combine(&[0.037]).unwrap();
assert_eq!(got, 0.037);
let got2: f64 = fisher_combine(&[f64::NAN, 0.037, f64::NAN]).unwrap();
assert_eq!(got2, 0.037);
}
#[test]
fn test_fisher_combine_empty_is_nan() {
let got: f64 = fisher_combine(&[]).unwrap();
assert!(got.is_nan());
let got2: f64 = fisher_combine(&[f64::NAN, f64::NAN]).unwrap();
assert!(got2.is_nan());
}
fn wilcox_nulls() -> Vec<f64> {
vec![
-0.8409, 1.3844, -1.2555, 0.0701, 1.7114, -0.6029, -0.4722, -0.6354, -0.2858, 0.1381,
1.2276, -0.8018, -1.0804, -0.1575, -1.0718, -0.139, -0.5973, -2.184, 0.2408, -0.2594,
0.9005, 0.9419, 1.468, 0.7068, 0.819, -0.2935, 1.4186, 1.4988, -0.6571, -0.8528,
0.3159, 1.1097, 2.2155, 1.2171, 1.4792, 0.9516, -1.0095, -2.0005, -1.7622, -0.1426,
1.5501, -0.8024, -0.0746, 1.8957, -0.4566, 0.5622, -0.887, -0.4602, -0.7243, -0.0692,
1.4632, 0.1877, 1.022, -0.5918, -0.1122, -0.925, 0.7533, -0.1126, -0.0641, 0.2333,
-1.1366, 0.8548, -0.5784, 0.4964, -0.7601, -0.3414, -2.1023, -0.3017, -1.2724, -0.2797,
-0.2041, -0.2256, 0.347, 0.0324, 0.4135, -0.1553, 0.9735, 0.1211, 0.1892, -0.5629,
0.4984, -1.7423, 0.9755, -0.0241, 0.6757, -0.7103, 2.3872, -0.4734, -0.0758, -0.5218,
0.926, -1.0624, 0.557, 0.9007, 0.9899, 0.3836, -0.3466, -0.5402, -0.1826,
]
}
#[test]
fn test_wilcox_rank_sum_greater_beats_every_null() {
let nulls = wilcox_nulls();
let real = nulls.iter().cloned().fold(f64::NEG_INFINITY, f64::max) + 0.1;
let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
assert_relative_eq!(p, 0.044_801_589_130_970_6, max_relative = 1e-11);
}
#[test]
fn test_wilcox_rank_sum_greater_at_the_ninetieth_null() {
let nulls = wilcox_nulls();
let mut sorted = nulls.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let real = sorted[89];
let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
assert_relative_eq!(p, 0.085_594_599_535_642_33, max_relative = 1e-11);
assert!(p < 0.1);
}
#[test]
fn test_wilcox_rank_sum_greater_at_the_median() {
let nulls = wilcox_nulls();
let mut sorted = nulls.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let real = sorted[49];
let p: f64 = wilcox_rank_sum_greater_approx(&[real], &nulls).unwrap();
assert_relative_eq!(p, 0.506_909_903_708_803, max_relative = 1e-11);
}
#[test]
fn test_wilcox_rank_sum_greater_small_samples() {
let a = [3.1, 4.5, 2.2, 6.0];
let b = [1.0, 2.5, 3.9, 0.4, 5.5];
let p: f64 = wilcox_rank_sum_greater_approx(&a, &b).unwrap();
assert_relative_eq!(p, 0.195_633_639_641_319_7, max_relative = 1e-11);
}
#[test]
fn test_wilcox_rank_sum_greater_with_ties() {
let a = [1.0, 2.0, 3.0];
let b = [2.0, 3.0, 4.0, 5.0];
let p: f64 = wilcox_rank_sum_greater_approx(&a, &b).unwrap();
assert_relative_eq!(p, 0.947_403_747_439_979_2, max_relative = 1e-11);
}
#[test]
fn test_wilcox_rank_sum_rejects_degenerate_input() {
let a = [1.0, 2.0];
let empty: [f64; 0] = [];
assert!(wilcox_rank_sum_greater_approx(&a, &empty).is_err());
assert!(wilcox_rank_sum_greater_approx(&empty, &a).is_err());
assert!(wilcox_rank_sum_greater_approx(&[1.0, 1.0], &[1.0, 1.0]).is_err());
}
#[test]
fn test_logit_and_inv_logit() {
let p: f64 = 0.5;
let log_odds: f64 = logit(p);
assert!((log_odds - 0.0).abs() < 1e-6);
let recovered_p = inv_logit(log_odds);
assert!((recovered_p - 0.5).abs() < 1e-6);
}
#[test]
fn test_z_scores_to_pval() {
let z_scores: Vec<f64> = vec![0.0, 1.95996398454];
let pvals_two_sided = z_scores_to_pval(&z_scores, "twosided");
assert!((pvals_two_sided[0] - 1.0).abs() < 1e-6);
assert!((pvals_two_sided[1] - 0.05).abs() < 1e-5);
}
#[test]
fn test_calc_fdr_is_invariant_to_tie_order() {
let mut pvals: Vec<f64> = vec![0.02; 500];
pvals.extend(vec![0.5; 50]);
pvals.extend([0.001, 0.3, 0.7, 0.9, 0.02, 0.5]);
let first = calc_fdr(&pvals);
let second = calc_fdr(&pvals);
assert_eq!(first, second, "calc_fdr is not deterministic");
for (i, p) in pvals.iter().enumerate() {
for (j, q) in pvals.iter().enumerate() {
if p == q {
assert_eq!(
first[i], first[j],
"tied p-values {p} got different FDRs at {i} and {j}"
);
}
}
}
let mut reversed = pvals.clone();
reversed.reverse();
let rev_fdr = calc_fdr(&reversed);
let mut a = first.clone();
let mut b = rev_fdr;
a.sort_by(|x, y| x.partial_cmp(y).unwrap());
b.sort_by(|x, y| x.partial_cmp(y).unwrap());
for (x, y) in a.iter().zip(b.iter()) {
assert!((x - y).abs() < 1e-15, "{x} vs {y}");
}
}
#[test]
fn test_upper_tail_pval_monotone_in_z() {
let mut zs: Vec<f64> = vec![
-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,
37.0, 39.0,
];
for i in 0..200 {
zs.push(5.5 + i as f64 * 0.005);
}
zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
let pvals = z_scores_to_pval(&zs, "greater");
for w in pvals.windows(2) {
assert!(
w[1] <= w[0],
"upper-tail p-value increased: {} then {}",
w[0],
w[1]
);
}
}
#[test]
fn test_calc_fdr() {
let pvals: Vec<f64> = vec![0.01, 0.04, 0.03];
let fdr = calc_fdr(&pvals);
assert!((fdr[0] - 0.03).abs() < 1e-6);
assert!((fdr[1] - 0.04).abs() < 1e-6);
assert!((fdr[2] - 0.04).abs() < 1e-6);
}
#[test]
fn test_mad_outlier() {
let vec: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 100.0];
let (outliers, margin) = mad_outlier(&vec, 3.0, OutlierDirection::Both);
assert!((margin - 3.0).abs() < 1e-6);
assert_eq!(outliers, vec![false, false, false, false, true]); }
#[test]
fn test_parse_helpers() {
assert!(matches!(
get_test_alternative("twosided"),
Some(TestAlternative::TwoSided)
));
assert!(matches!(
get_test_alternative("GREATER"),
Some(TestAlternative::Greater)
));
assert!(matches!(
parse_outlier_type("below"),
Some(OutlierDirection::Below)
));
assert!(matches!(
parse_outlier_type("Twosided"),
Some(OutlierDirection::Both)
));
}
#[test]
fn test_manova_two_group_analytic() {
let mat = Mat::from_fn(4, 2, |i, j| match (i, j) {
(0, 0) => 1.0f64,
(0, 1) => 4.0,
(1, 0) => 2.0,
(1, 1) => 1.0,
(2, 0) => 5.0,
(2, 1) => 7.0,
(3, 0) => 6.0,
(3, 1) => 3.0,
_ => unreachable!(),
});
let res = crate::enrichment::mitch::manova_mitch(mat.as_ref(), &[2, 3]);
let wilks = res.wilks_lambda();
let pillai = res.pillai_trace();
let recovered_lambda = (1.0 - wilks) / wilks;
assert!((recovered_lambda - 1105.0).abs() < 1e-6);
assert!((wilks + pillai - 1.0).abs() < 1e-12);
assert!((wilks - 1.0 / 1106.0).abs() < 1e-9);
assert!((pillai - 1105.0 / 1106.0).abs() < 1e-9);
let (f_w, _) = res.wilks_f_test();
let (f_p, p_p) = res.pillai_f_test();
assert!((f_w - 552.5).abs() < 1e-6);
assert!((f_p - 552.5).abs() < 1e-6);
assert!((0.0..=1.0).contains(&p_p));
}
#[test]
fn test_manova_no_overflow_large_p() {
let p = 80;
let n_total = 2949usize;
let sigma = (n_total as f64).powi(3) / 12.0;
let sscp_within = Mat::from_fn(p, p, |i, j| if i == j { sigma } else { sigma * 0.3 });
let sscp_between = Mat::from_fn(
p,
p,
|i, j| {
if i == j { sigma * 1.5e-3 } else { sigma * 5e-4 }
},
);
let sscp_total = &sscp_within + &sscp_between;
let res = ManovaResult::<f64> {
sscp_between,
sscp_within,
sscp_total,
df_between: 1,
df_within: n_total - 2,
df_total: n_total - 1,
n_vars: p,
group_means: vec![vec![0.0; p], vec![0.0; p]],
overall_mean: vec![0.0; p],
};
let wilks = res.wilks_lambda();
let pillai = res.pillai_trace();
let (f_w, p_w) = res.wilks_f_test();
let (f_p, p_p) = res.pillai_f_test();
assert!(wilks.is_finite() && wilks > 0.0 && wilks <= 1.0);
assert!((0.0..1.0).contains(&pillai) && pillai.is_finite());
assert!((wilks + pillai - 1.0).abs() < 1e-9);
assert!(f_w.is_finite() && f_w >= 0.0);
assert!(f_p.is_finite() && f_p >= 0.0);
assert!((0.0..=1.0).contains(&p_w));
assert!((0.0..=1.0).contains(&p_p));
assert!((f_w - f_p).abs() < 1e-9);
assert!((p_w - p_p).abs() < 1e-9);
}
#[test]
fn test_summary_aov_constant_column() {
let p = 2;
let sscp_within = Mat::from_fn(p, p, |i, j| {
if i == 0 && j == 0 {
0.0
} else if i == j {
10.0
} else {
0.0
}
});
let sscp_between = Mat::from_fn(p, p, |i, j| if i == j { 5.0 } else { 0.0 });
let sscp_total = &sscp_within + &sscp_between;
let res = ManovaResult::<f64> {
sscp_between,
sscp_within,
sscp_total,
df_between: 1,
df_within: 10,
df_total: 11,
n_vars: p,
group_means: vec![vec![0.0; p], vec![0.0; p]],
overall_mean: vec![0.0; p],
};
let aov = summary_aov(&res);
assert_eq!(aov.len(), 2);
assert!(aov[0].f_stat.is_nan());
assert!((aov[0].p_val - 1.0).abs() < 1e-12);
assert!(aov[1].f_stat.is_finite() && aov[1].f_stat > 0.0);
assert!((0.0..=1.0).contains(&aov[1].p_val));
}
}