use std::cmp::Ordering;
use num_bigint::BigInt;
use num_traits::{One, Signed, ToPrimitive, Zero};
use super::data::{self, Ddof, Q};
use super::family::Distribution;
use super::sample::Rng;
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::errors::SymplexError;
use crate::calculus::definite::{QuadOpts, quadrature};
use crate::domains::optimize::{RootOpts, brent_root};
use crate::output::codegen::numeric_rt::{erfc, erfcinv, lgamma};
fn invalid(op: &'static str, reason: impl Into<String>) -> SymplexError {
SymplexError::invalid_argument(op, reason)
}
fn qi(n: i64) -> Q {
Q::from_integer(BigInt::from(n))
}
fn qu(n: usize) -> Q {
Q::from_integer(BigInt::from(n))
}
fn ex(ctx: &Context, q: &Q) -> Ex {
ctx.from_ratio(q.clone())
}
fn q_to_f64(q: &Q) -> f64 {
data::to_f64(std::slice::from_ref(q))
.first()
.copied()
.unwrap_or(f64::NAN)
}
fn usize_to_i64(op: &'static str, n: usize) -> Result<i64, SymplexError> {
i64::try_from(n).map_err(|_| invalid(op, format!("{n} does not fit in an i64")))
}
fn factorial_big(n: usize) -> BigInt {
(1..=n).fold(BigInt::one(), |acc, k| acc * BigInt::from(k))
}
fn sum_big(v: &[BigInt]) -> BigInt {
v.iter().fold(BigInt::zero(), |acc, x| acc + x)
}
fn check_finite(op: &'static str, name: &str, v: f64) -> Result<(), SymplexError> {
if v.is_finite() {
Ok(())
} else {
Err(invalid(op, format!("{name} must be finite, got {v}")))
}
}
fn check_unit_open(op: &'static str, name: &str, v: f64) -> Result<(), SymplexError> {
if v > 0.0 && v < 1.0 {
Ok(())
} else {
Err(invalid(
op,
format!("{name} must lie strictly between 0 and 1, got {v}"),
))
}
}
fn check_sample(op: &'static str, name: &str, x: &[Q], min: usize) -> Result<(), SymplexError> {
if x.len() < min {
return Err(invalid(
op,
format!(
"{name} needs at least {min} observation{}, got {}",
if min == 1 { "" } else { "s" },
x.len()
),
));
}
Ok(())
}
fn check_same_len(op: &'static str, x: &[Q], y: &[Q]) -> Result<(), SymplexError> {
if x.len() != y.len() {
return Err(invalid(
op,
format!(
"paired samples must have the same size ({} and {})",
x.len(),
y.len()
),
));
}
Ok(())
}
fn norm_cdf(x: f64) -> f64 {
0.5 * erfc(-x / std::f64::consts::SQRT_2)
}
fn norm_sf(x: f64) -> f64 {
0.5 * erfc(x / std::f64::consts::SQRT_2)
}
fn norm_isf(alpha: f64) -> f64 {
std::f64::consts::SQRT_2 * erfcinv(2.0 * alpha)
}
fn student_t_cdf_f64(ctx: &Context, df: f64, t: f64) -> Result<f64, SymplexError> {
let nu = ctx.from_f64(df)?;
let z = &nu / (ctx.from_f64(t * t)? + &nu);
let tail = ctx.rational(1, 2)
* z.betainc_regularized(&(&nu / ctx.int(2)), &ctx.rational(1, 2), &ctx.zero());
let tail = tail.eval_f64()?;
Ok(if t < 0.0 { tail } else { 1.0 - tail })
}
fn student_t_quantile_f64(
op: &'static str,
ctx: &Context,
df: f64,
p: f64,
) -> Result<f64, SymplexError> {
if p < 0.5 {
return student_t_quantile_f64(op, ctx, df, 1.0 - p).map(|t| -t);
}
if p == 0.5 {
return Ok(0.0);
}
let g = |t: f64| student_t_cdf_f64(ctx, df, t).unwrap_or(f64::NAN) - p;
let mut hi = 1.0;
for _ in 0..64 {
let v = g(hi);
if v.is_nan() {
return Err(SymplexError::computation_failed(
op,
"the Student-t distribution function could not be evaluated",
));
}
if v >= 0.0 {
break;
}
hi *= 2.0;
}
let root = brent_root(g, 0.0, hi, &RootOpts::default())
.map_err(|e| SymplexError::computation_failed(op, e.to_string()))?;
if root.is_finite() {
Ok(root)
} else {
Err(SymplexError::computation_failed(
op,
"the Student-t quantile did not converge",
))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Alternative {
TwoSided,
Less,
Greater,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TestResult {
pub statistic: Ex,
pub p_value: Ex,
pub df: Option<Ex>,
pub alternative: Alternative,
}
impl TestResult {
pub fn p_value_f64(&self) -> Result<f64, SymplexError> {
self.p_value.eval_f64()
}
pub fn statistic_f64(&self) -> Result<f64, SymplexError> {
self.statistic.eval_f64()
}
#[must_use]
pub fn p_value_exact(&self) -> Option<Q> {
self.p_value.as_rational()
}
#[must_use]
pub fn statistic_exact(&self) -> Option<Q> {
self.statistic.as_rational()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RankMethod {
Exact,
Asymptotic {
continuity: bool,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct ChiSquareResult {
pub statistic: Q,
pub df: usize,
pub p_value: Ex,
pub expected: Vec<Vec<Q>>,
}
impl ChiSquareResult {
pub fn p_value_f64(&self) -> Result<f64, SymplexError> {
self.p_value.eval_f64()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AnovaResult {
pub f: Q,
pub df_between: usize,
pub df_within: usize,
pub p_value: Ex,
pub ss_between: Q,
pub ss_within: Q,
pub eta_squared: Q,
}
impl AnovaResult {
pub fn p_value_f64(&self) -> Result<f64, SymplexError> {
self.p_value.eval_f64()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct RatioEstimate {
pub estimate: Q,
pub ci: (f64, f64),
pub confidence: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KsResult {
pub statistic: f64,
pub p_value: f64,
pub alternative: Alternative,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Adjusted {
pub p_adjusted: Vec<f64>,
pub reject: Vec<bool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BootstrapMethod {
Percentile,
Basic,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PermutationResult {
pub statistic: f64,
pub p_value: f64,
}
struct RootRatio {
num: Q,
var: Q,
}
impl RootRatio {
fn to_ex(&self, ctx: &Context) -> Ex {
(ex(ctx, &self.num) / ex(ctx, &self.var).sqrt()).simplify()
}
fn square(&self) -> Q {
&self.num * &self.num / &self.var
}
fn in_tail(&self, alt: Alternative) -> bool {
match alt {
Alternative::Greater | Alternative::TwoSided => !self.num.is_negative(),
Alternative::Less => !self.num.is_positive(),
}
}
}
fn student_p_value(ctx: &Context, df: &Q, stat: &RootRatio, alt: Alternative) -> Ex {
let t2 = stat.square();
let z = df / (&t2 + df);
let two_sided =
ex(ctx, &z).betainc_regularized(&ex(ctx, &(df / qi(2))), &ctx.rational(1, 2), &ctx.zero());
one_sided_from_symmetric(ctx, two_sided, stat.in_tail(alt), alt)
}
fn normal_p_value(ctx: &Context, stat: &RootRatio, alt: Alternative) -> Ex {
let two_sided = ex(ctx, &(stat.square() / qi(2))).sqrt().erfc();
one_sided_from_symmetric(ctx, two_sided, stat.in_tail(alt), alt)
}
fn one_sided_from_symmetric(ctx: &Context, two_sided: Ex, in_tail: bool, alt: Alternative) -> Ex {
match alt {
Alternative::TwoSided => two_sided,
Alternative::Greater | Alternative::Less => {
let half = ctx.rational(1, 2) * two_sided;
if in_tail { half } else { ctx.one() - half }
}
}
}
fn chi_squared_sf(ctx: &Context, df: usize, x: &Ex) -> Ex {
let half_df = ctx.rational(df as i64, 2);
(x / ctx.int(2)).uppergamma(&half_df) / half_df.gamma()
}
fn chi_squared_sf_q(ctx: &Context, df: usize, x: &Q) -> Ex {
if !x.is_positive() {
return ctx.one();
}
chi_squared_sf(ctx, df, &ex(ctx, x))
}
fn f_sf(ctx: &Context, d1: usize, d2: usize, f: &Q) -> Ex {
if !f.is_positive() {
return ctx.one();
}
let z = qu(d2) / (qu(d2) + qu(d1) * f);
ex(ctx, &z).betainc_regularized(
&ctx.rational(d2 as i64, 2),
&ctx.rational(d1 as i64, 2),
&ctx.zero(),
)
}
fn result(
ctx: &Context,
statistic: Ex,
p_value: Q,
df: Option<Ex>,
alt: Alternative,
) -> TestResult {
TestResult {
statistic,
p_value: ex(ctx, &p_value),
df,
alternative: alt,
}
}
fn binomial_pmf_table(n: usize, p: &Q) -> Vec<Q> {
let q = Q::one() - p;
let mut pp = vec![Q::one(); n + 1];
let mut qq = vec![Q::one(); n + 1];
for i in 1..=n {
pp[i] = &pp[i - 1] * p;
qq[i] = &qq[i - 1] * &q;
}
let mut coef = Q::one();
(0..=n)
.map(|i| {
if i > 0 {
coef = &coef * qu(n + 1 - i) / qu(i);
}
&coef * &pp[i] * &qq[n - i]
})
.collect()
}
fn mass_where(pmf: &[Q], keep: impl Fn(usize, &Q) -> bool) -> Q {
pmf.iter()
.enumerate()
.filter(|(i, m)| keep(*i, m))
.fold(Q::zero(), |acc, (_, m)| acc + m)
}
fn two_sided_mass(pmf: &[Q], observed: usize) -> Q {
let at = &pmf[observed];
mass_where(pmf, |_, m| m <= at).min(Q::one())
}
pub fn binomial_test(
ctx: &Context,
k: usize,
n: usize,
p0: &Q,
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "binomial_test";
if n == 0 {
return Err(invalid(OP, "the number of trials must be positive"));
}
if k > n {
return Err(invalid(OP, format!("{k} successes exceed {n} trials")));
}
if p0.is_negative() || *p0 > Q::one() {
return Err(invalid(OP, "the null proportion must lie in [0, 1]"));
}
let pmf = binomial_pmf_table(n, p0);
let p = match alt {
Alternative::Less => mass_where(&pmf, |i, _| i <= k),
Alternative::Greater => mass_where(&pmf, |i, _| i >= k),
Alternative::TwoSided => two_sided_mass(&pmf, k),
};
Ok(result(ctx, ex(ctx, &(qu(k) / qu(n))), p, None, alt))
}
fn hypergeometric_pmf_table(n1: usize, n2: usize, n: usize) -> (usize, Vec<Q>) {
let lo = n.saturating_sub(n2);
let hi = n.min(n1);
let total = data::binomial_q(n1 + n2, n);
let pmf = (lo..=hi)
.map(|x| data::binomial_q(n1, x) * data::binomial_q(n2, n - x) / &total)
.collect();
(lo, pmf)
}
pub fn fisher_exact(
ctx: &Context,
table: [[usize; 2]; 2],
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "fisher_exact";
let [[a, b], [c, d]] = table;
let (n1, n2, n) = (a + b, c + d, a + c);
if n1 == 0 || n2 == 0 || n == 0 || b + d == 0 {
return Err(invalid(OP, "a row or a column of the table is empty"));
}
let (lo, pmf) = hypergeometric_pmf_table(n1, n2, n);
let idx = a - lo;
let p = match alt {
Alternative::Less => mass_where(&pmf, |i, _| i <= idx),
Alternative::Greater => mass_where(&pmf, |i, _| i >= idx),
Alternative::TwoSided => two_sided_mass(&pmf, idx),
};
let odds = if b * c == 0 {
ctx.infinity()
} else {
ex(ctx, &(qu(a * d) / qu(b * c)))
};
Ok(result(ctx, odds, p, None, alt))
}
pub fn mcnemar_test(
ctx: &Context,
b: usize,
c: usize,
exact: bool,
correction: bool,
) -> Result<TestResult, SymplexError> {
const OP: &str = "mcnemar_test";
let n = b + c;
if n == 0 {
return Err(invalid(OP, "there are no discordant pairs"));
}
if exact {
let k = b.min(c);
let pmf = binomial_pmf_table(n, &Q::new(BigInt::one(), BigInt::from(2)));
let p = (mass_where(&pmf, |i, _| i <= k) * qi(2)).min(Q::one());
return Ok(result(
ctx,
ctx.int(k as i64),
p,
None,
Alternative::TwoSided,
));
}
let diff = qu(b.max(c) - b.min(c)) - if correction { Q::one() } else { Q::zero() };
let stat = &diff * &diff / qu(n);
Ok(TestResult {
statistic: ex(ctx, &stat),
p_value: chi_squared_sf_q(ctx, 1, &stat),
df: Some(ctx.one()),
alternative: Alternative::TwoSided,
})
}
pub fn sign_test(
ctx: &Context,
x: &[Q],
mu0: &Q,
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "sign_test";
let pos = x.iter().filter(|v| *v > mu0).count();
let neg = x.iter().filter(|v| *v < mu0).count();
if pos + neg == 0 {
return Err(invalid(OP, "every observation equals the null median"));
}
let half = Q::new(BigInt::one(), BigInt::from(2));
let inner = binomial_test(ctx, pos, pos + neg, &half, alt)?;
let m = (qu(pos) - qu(neg)) / qi(2);
Ok(TestResult {
statistic: ex(ctx, &m),
..inner
})
}
#[must_use]
pub fn counts(rows: &[&[i64]]) -> Vec<Vec<Q>> {
rows.iter().map(|r| data::from_i64(r)).collect()
}
fn check_table(op: &'static str, table: &[Vec<Q>]) -> Result<(usize, usize), SymplexError> {
let r = table.len();
let c = table.first().map_or(0, Vec::len);
if r == 0 || c == 0 {
return Err(invalid(op, "the table is empty"));
}
if table.iter().any(|row| row.len() != c) {
return Err(invalid(op, "the table is not rectangular"));
}
if table.iter().flatten().any(Signed::is_negative) {
return Err(invalid(op, "counts must be non-negative"));
}
Ok((r, c))
}
fn margins(table: &[Vec<Q>]) -> (Vec<Q>, Vec<Q>, Q) {
let cols = table[0].len();
let rows: Vec<Q> = table.iter().map(|r| data::sum(r)).collect();
let col_sums: Vec<Q> = (0..cols)
.map(|j| table.iter().fold(Q::zero(), |acc, r| acc + &r[j]))
.collect();
let total = data::sum(&rows);
(rows, col_sums, total)
}
fn expected_counts(op: &'static str, table: &[Vec<Q>]) -> Result<Vec<Vec<Q>>, SymplexError> {
let (rows, cols, total) = margins(table);
if rows.iter().any(Zero::is_zero) || cols.iter().any(Zero::is_zero) {
return Err(invalid(
op,
"a row or a column of the table is empty, so an expected count is zero",
));
}
Ok(rows
.iter()
.map(|r| cols.iter().map(|c| r * c / &total).collect())
.collect())
}
fn pearson_statistic(observed: &[Vec<Q>], expected: &[Vec<Q>], shift: &Q) -> Q {
observed
.iter()
.zip(expected)
.flat_map(|(o, e)| o.iter().zip(e))
.fold(Q::zero(), |acc, (o, e)| {
let d = (o - e).abs() - shift;
acc + &d * &d / e
})
}
pub fn chi_square_independence(
ctx: &Context,
table: &[Vec<Q>],
correction: bool,
) -> Result<ChiSquareResult, SymplexError> {
const OP: &str = "chi_square_independence";
let (r, c) = check_table(OP, table)?;
if r < 2 || c < 2 {
return Err(invalid(
OP,
"the table needs at least two rows and two columns",
));
}
let expected = expected_counts(OP, table)?;
let df = (r - 1) * (c - 1);
let shift = if correction && df == 1 {
Q::new(BigInt::one(), BigInt::from(2))
} else {
Q::zero()
};
let statistic = pearson_statistic(table, &expected, &shift);
Ok(ChiSquareResult {
p_value: chi_squared_sf_q(ctx, df, &statistic),
statistic,
df,
expected,
})
}
pub fn chi_square_goodness_of_fit(
ctx: &Context,
observed: &[Q],
expected: Option<&[Q]>,
ddof: usize,
) -> Result<ChiSquareResult, SymplexError> {
const OP: &str = "chi_square_goodness_of_fit";
let k = observed.len();
if k < 2 {
return Err(invalid(OP, "at least two categories are needed"));
}
if observed.iter().any(Signed::is_negative) {
return Err(invalid(OP, "observed counts must be non-negative"));
}
let expected: Vec<Q> = match expected {
Some(e) => {
if e.len() != k {
return Err(invalid(OP, "observed and expected have different lengths"));
}
if e.iter().any(|v| !v.is_positive()) {
return Err(invalid(OP, "expected counts must be positive"));
}
if data::sum(e) != data::sum(observed) {
return Err(invalid(
OP,
"observed and expected counts have different totals",
));
}
e.to_vec()
}
None => {
let m = data::mean(observed)?;
if !m.is_positive() {
return Err(invalid(OP, "the observed counts are all zero"));
}
vec![m; k]
}
};
if ddof + 1 >= k {
return Err(invalid(
OP,
format!("ddof = {ddof} leaves no degrees of freedom"),
));
}
let df = k - 1 - ddof;
let statistic = observed
.iter()
.zip(&expected)
.fold(Q::zero(), |acc, (o, e)| {
let d = o - e;
acc + &d * &d / e
});
Ok(ChiSquareResult {
p_value: chi_squared_sf_q(ctx, df, &statistic),
statistic,
df,
expected: vec![expected],
})
}
pub fn g_test(ctx: &Context, table: &[Vec<Q>]) -> Result<TestResult, SymplexError> {
const OP: &str = "g_test";
let (r, c) = check_table(OP, table)?;
if r < 2 || c < 2 {
return Err(invalid(
OP,
"the table needs at least two rows and two columns",
));
}
let expected = expected_counts(OP, table)?;
let df = (r - 1) * (c - 1);
let mut terms: Vec<Ex> = Vec::new();
for (o_row, e_row) in table.iter().zip(&expected) {
for (o, e) in o_row.iter().zip(e_row) {
if o.is_zero() || o == e {
continue;
}
terms.push(ex(ctx, o) * (ex(ctx, o) / ex(ctx, e)).ln());
}
}
if terms.is_empty() {
return Ok(TestResult {
statistic: ctx.zero(),
p_value: ctx.one(),
df: Some(ctx.int(df as i64)),
alternative: Alternative::TwoSided,
});
}
let sum = terms.into_iter().fold(ctx.zero(), |acc, t| acc + t);
let statistic = ctx.int(2) * sum;
Ok(TestResult {
p_value: chi_squared_sf(ctx, df, &statistic),
statistic,
df: Some(ctx.int(df as i64)),
alternative: Alternative::TwoSided,
})
}
pub fn cramers_v(ctx: &Context, table: &[Vec<Q>]) -> Result<Ex, SymplexError> {
let r = chi_square_independence(ctx, table, false)?;
let (rows, cols) = (table.len(), table[0].len());
let (_, _, total) = margins(table);
let k = qu((rows - 1).min(cols - 1));
Ok(ex(ctx, &(r.statistic / (total * k))).sqrt().simplify())
}
fn check_2x2_margins(op: &'static str, table: [[usize; 2]; 2]) -> Result<(), SymplexError> {
let [[a, b], [c, d]] = table;
if a + b == 0 || c + d == 0 || a + c == 0 || b + d == 0 {
return Err(invalid(op, "a row or a column of the table is empty"));
}
Ok(())
}
pub fn phi_coefficient(ctx: &Context, table: [[usize; 2]; 2]) -> Result<Ex, SymplexError> {
const OP: &str = "phi_coefficient";
check_2x2_margins(OP, table)?;
let [[a, b], [c, d]] = table;
let num = qu(a * d) - qu(b * c);
let den = qu((a + b) * (c + d) * (a + c) * (b + d));
Ok((ex(ctx, &num) / ex(ctx, &den).sqrt()).simplify())
}
fn log_wald_ci(estimate: &Q, se: f64, confidence: f64) -> (f64, f64) {
let z = norm_isf((1.0 - confidence) / 2.0);
let log = q_to_f64(estimate).ln();
((log - z * se).exp(), (log + z * se).exp())
}
pub fn odds_ratio(table: [[usize; 2]; 2], confidence: f64) -> Result<RatioEstimate, SymplexError> {
const OP: &str = "odds_ratio";
check_unit_open(OP, "confidence", confidence)?;
let [[a, b], [c, d]] = table;
if a == 0 || b == 0 || c == 0 || d == 0 {
return Err(invalid(
OP,
"every cell must be positive for a finite odds ratio",
));
}
let estimate = qu(a * d) / qu(b * c);
let se = (1.0 / a as f64 + 1.0 / b as f64 + 1.0 / c as f64 + 1.0 / d as f64).sqrt();
Ok(RatioEstimate {
ci: log_wald_ci(&estimate, se, confidence),
estimate,
confidence,
})
}
pub fn relative_risk(
table: [[usize; 2]; 2],
confidence: f64,
) -> Result<RatioEstimate, SymplexError> {
const OP: &str = "relative_risk";
check_unit_open(OP, "confidence", confidence)?;
let [[a, b], [c, d]] = table;
let (n1, n2) = (a + b, c + d);
if a == 0 || c == 0 {
return Err(invalid(
OP,
"both case counts must be positive for a finite relative risk",
));
}
let estimate = (qu(a) / qu(n1)) / (qu(c) / qu(n2));
let se = (1.0 / a as f64 - 1.0 / n1 as f64 + 1.0 / c as f64 - 1.0 / n2 as f64).sqrt();
Ok(RatioEstimate {
ci: log_wald_ci(&estimate, se, confidence),
estimate,
confidence,
})
}
pub fn cohens_h(ctx: &Context, p1: &Q, p2: &Q) -> Result<Ex, SymplexError> {
const OP: &str = "cohens_h";
for p in [p1, p2] {
if p.is_negative() || *p > Q::one() {
return Err(invalid(OP, "proportions must lie in [0, 1]"));
}
}
let two = ctx.int(2);
Ok(&two * ex(ctx, p1).sqrt().asin() - &two * ex(ctx, p2).sqrt().asin())
}
fn student_result(ctx: &Context, df: &Q, stat: &RootRatio, alt: Alternative) -> TestResult {
TestResult {
statistic: stat.to_ex(ctx),
p_value: student_p_value(ctx, df, stat, alt),
df: Some(ex(ctx, df)),
alternative: alt,
}
}
fn normal_result(ctx: &Context, stat: &RootRatio, alt: Alternative) -> TestResult {
TestResult {
statistic: stat.to_ex(ctx),
p_value: normal_p_value(ctx, stat, alt),
df: None,
alternative: alt,
}
}
pub fn t_test_one_sample(
ctx: &Context,
x: &[Q],
mu0: &Q,
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "t_test_one_sample";
check_sample(OP, "the sample", x, 2)?;
let n = x.len();
let var = data::variance(x, Ddof::Sample)?;
if var.is_zero() {
return Err(invalid(OP, "the sample is constant (zero variance)"));
}
let stat = RootRatio {
num: data::mean(x)? - mu0,
var: var / qu(n),
};
Ok(student_result(ctx, &qu(n - 1), &stat, alt))
}
pub fn t_test_two_sample(
ctx: &Context,
x: &[Q],
y: &[Q],
equal_var: bool,
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "t_test_two_sample";
check_sample(OP, "the first sample", x, 2)?;
check_sample(OP, "the second sample", y, 2)?;
let (n1, n2) = (x.len(), y.len());
let (v1, v2) = (
data::variance(x, Ddof::Sample)?,
data::variance(y, Ddof::Sample)?,
);
let num = data::mean(x)? - data::mean(y)?;
if v1.is_zero() && v2.is_zero() {
return Err(invalid(OP, "both samples are constant (zero variance)"));
}
if equal_var {
let df = qu(n1 + n2 - 2);
let pooled = (qu(n1 - 1) * &v1 + qu(n2 - 1) * &v2) / &df;
let var = pooled * (qu(n1).recip() + qu(n2).recip());
Ok(student_result(ctx, &df, &RootRatio { num, var }, alt))
} else {
let (a, b) = (&v1 / qu(n1), &v2 / qu(n2));
let var = &a + &b;
let df = &var * &var / (&a * &a / qu(n1 - 1) + &b * &b / qu(n2 - 1));
Ok(student_result(ctx, &df, &RootRatio { num, var }, alt))
}
}
pub fn t_test_paired(
ctx: &Context,
x: &[Q],
y: &[Q],
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "t_test_paired";
check_same_len(OP, x, y)?;
let d: Vec<Q> = x.iter().zip(y).map(|(a, b)| a - b).collect();
check_sample(OP, "the paired sample", &d, 2)?;
if data::variance(&d, Ddof::Sample)?.is_zero() {
return Err(invalid(OP, "the differences are constant (zero variance)"));
}
t_test_one_sample(ctx, &d, &Q::zero(), alt)
}
pub fn z_test_proportion(
ctx: &Context,
k: usize,
n: usize,
p0: &Q,
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "z_test_proportion";
if n == 0 {
return Err(invalid(OP, "the number of trials must be positive"));
}
if k > n {
return Err(invalid(OP, format!("{k} successes exceed {n} trials")));
}
if !p0.is_positive() || *p0 >= Q::one() {
return Err(invalid(
OP,
"the null proportion must lie strictly between 0 and 1",
));
}
let stat = RootRatio {
num: qu(k) / qu(n) - p0,
var: p0 * (Q::one() - p0) / qu(n),
};
Ok(normal_result(ctx, &stat, alt))
}
pub fn two_proportion_z_test(
ctx: &Context,
k1: usize,
n1: usize,
k2: usize,
n2: usize,
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "two_proportion_z_test";
if n1 == 0 || n2 == 0 {
return Err(invalid(OP, "both samples must be non-empty"));
}
if k1 > n1 || k2 > n2 {
return Err(invalid(OP, "successes exceed trials"));
}
let pooled = qu(k1 + k2) / qu(n1 + n2);
if pooled.is_zero() || pooled.is_one() {
return Err(invalid(
OP,
"the pooled proportion is 0 or 1 (zero variance)",
));
}
let stat = RootRatio {
num: qu(k1) / qu(n1) - qu(k2) / qu(n2),
var: &pooled * (Q::one() - &pooled) * (qu(n1).recip() + qu(n2).recip()),
};
Ok(normal_result(ctx, &stat, alt))
}
fn sums_of_squares(
op: &'static str,
groups: &[Vec<Q>],
) -> Result<(Q, Q, usize, usize), SymplexError> {
let k = groups.len();
if k < 2 {
return Err(invalid(op, "at least two groups are needed"));
}
if groups.iter().any(Vec::is_empty) {
return Err(invalid(op, "every group must be non-empty"));
}
let all: Vec<Q> = groups.iter().flatten().cloned().collect();
let grand = data::mean(&all)?;
let mut ss_between = Q::zero();
let mut ss_within = Q::zero();
for g in groups {
let m = data::mean(g)?;
let d = &m - &grand;
ss_between += qu(g.len()) * &d * &d;
ss_within += data::sum_of_squares(g)?;
}
Ok((ss_between, ss_within, all.len(), k))
}
pub fn anova_one_way(ctx: &Context, groups: &[Vec<Q>]) -> Result<AnovaResult, SymplexError> {
const OP: &str = "anova_one_way";
let (ss_between, ss_within, n, k) = sums_of_squares(OP, groups)?;
if n <= k {
return Err(invalid(
OP,
"at least one group needs more than one observation",
));
}
if ss_within.is_zero() {
return Err(invalid(OP, "the within-group variance is zero"));
}
let (df_between, df_within) = (k - 1, n - k);
let f = (&ss_between / qu(df_between)) / (&ss_within / qu(df_within));
let total = &ss_between + &ss_within;
let eta_squared = &ss_between / &total;
Ok(AnovaResult {
p_value: f_sf(ctx, df_between, df_within, &f),
f,
df_between,
df_within,
ss_between,
ss_within,
eta_squared,
})
}
pub fn confidence_interval_mean(
ctx: &Context,
x: &[Q],
confidence: f64,
) -> Result<(f64, f64), SymplexError> {
const OP: &str = "confidence_interval_mean";
check_sample(OP, "the sample", x, 2)?;
check_unit_open(OP, "confidence", confidence)?;
let n = x.len();
let mean = q_to_f64(&data::mean(x)?);
let sem = q_to_f64(&(data::variance(x, Ddof::Sample)? / qu(n))).sqrt();
let t = student_t_quantile_f64(OP, ctx, (n - 1) as f64, (1.0 + confidence) / 2.0)?;
Ok((mean - t * sem, mean + t * sem))
}
fn tie_term(x: &[Q]) -> Q {
data::tie_sizes(x)
.into_iter()
.fold(Q::zero(), |acc, t| acc + qu(t * t * t - t))
}
fn mann_whitney_frequencies(m: usize, n: usize) -> Vec<BigInt> {
let size = m * n + 1;
let mut c = vec![BigInt::zero(); size];
c[0] = BigInt::one();
for i in 1..=m {
let shift = n + i;
for t in (shift..size).rev() {
let v = c[t - shift].clone();
c[t] -= v;
}
for t in i..size {
let v = c[t - i].clone();
c[t] += v;
}
}
c
}
fn signed_rank_frequencies(n: usize) -> Vec<BigInt> {
let size = n * (n + 1) / 2 + 1;
let mut c = vec![BigInt::zero(); size];
c[0] = BigInt::one();
for k in 1..=n {
for s in (k..size).rev() {
let v = c[s - k].clone();
c[s] += v;
}
}
c
}
fn inversion_counts(n: usize, cmax: usize) -> Vec<BigInt> {
let mut c = vec![BigInt::zero(); cmax + 1];
c[0] = BigInt::one();
for j in 2..=n {
let mut s = c.clone();
for t in 1..=cmax {
let v = s[t - 1].clone();
s[t] += v;
}
for k in 0..=cmax {
c[k] = if k >= j {
&s[k] - &s[k - j]
} else {
s[k].clone()
};
}
}
c
}
fn cdf_of(freq: &[BigInt], total: &BigInt, upto: usize) -> Q {
let upto = upto.min(freq.len().saturating_sub(1));
Q::new(sum_big(&freq[..=upto]), total.clone())
}
fn sf_of(freq: &[BigInt], total: &BigInt, from: usize) -> Q {
if from >= freq.len() {
return Q::zero();
}
Q::new(sum_big(&freq[from..]), total.clone())
}
fn rank_statistic_to_index(op: &'static str, v: &Q) -> Result<usize, SymplexError> {
if !v.is_integer() || v.is_negative() {
return Err(invalid(
op,
"the exact distribution needs an integer statistic",
));
}
v.to_integer()
.to_usize()
.ok_or_else(|| invalid(op, "the statistic is too large"))
}
fn continuity_shift(num: &Q, alt: Alternative) -> Q {
let half = Q::new(BigInt::one(), BigInt::from(2));
match alt {
Alternative::Greater => num - half,
Alternative::Less => num + half,
Alternative::TwoSided => match num.cmp(&Q::zero()) {
Ordering::Greater => num - half,
Ordering::Less => num + half,
Ordering::Equal => num.clone(),
},
}
}
pub fn mann_whitney_u(
ctx: &Context,
x: &[Q],
y: &[Q],
alt: Alternative,
method: RankMethod,
) -> Result<TestResult, SymplexError> {
const OP: &str = "mann_whitney_u";
check_sample(OP, "the first sample", x, 1)?;
check_sample(OP, "the second sample", y, 1)?;
let (n1, n2) = (x.len(), y.len());
let pooled: Vec<Q> = x.iter().chain(y).cloned().collect();
let ranks = data::ranks(&pooled);
let r1 = data::sum(&ranks[..n1]);
let u1 = r1 - qu(n1 * (n1 + 1) / 2);
let u2 = qu(n1 * n2) - &u1;
let statistic = ex(ctx, &u1);
match method {
RankMethod::Exact => {
if !data::tie_sizes(&pooled).is_empty() {
return Err(invalid(
OP,
"the exact method needs a pooled sample without ties",
));
}
let u = match alt {
Alternative::Greater => &u1,
Alternative::Less => &u2,
Alternative::TwoSided => (&u1).max(&u2),
};
let k = rank_statistic_to_index(OP, u)?;
let freq = mann_whitney_frequencies(n1, n2);
let total = sum_big(&freq);
let sf = sf_of(&freq, &total, k);
let p = match alt {
Alternative::TwoSided => (sf * qi(2)).min(Q::one()),
_ => sf,
};
Ok(result(ctx, statistic, p, None, alt))
}
RankMethod::Asymptotic { continuity } => {
let n = n1 + n2;
let var = qu(n1 * n2) / qi(12) * (qu(n + 1) - tie_term(&pooled) / qu(n * (n - 1)));
if !var.is_positive() {
return Err(invalid(OP, "every observation is identical"));
}
let mut num = &u1 - qu(n1 * n2) / qi(2);
if continuity {
num = continuity_shift(&num, alt);
}
Ok(TestResult {
statistic,
p_value: normal_p_value(ctx, &RootRatio { num, var }, alt),
df: None,
alternative: alt,
})
}
}
}
pub fn wilcoxon_signed_rank(
ctx: &Context,
x: &[Q],
y: Option<&[Q]>,
alt: Alternative,
method: RankMethod,
) -> Result<TestResult, SymplexError> {
const OP: &str = "wilcoxon_signed_rank";
let d: Vec<Q> = match y {
Some(y) => {
check_same_len(OP, x, y)?;
x.iter().zip(y).map(|(a, b)| a - b).collect()
}
None => x.to_vec(),
};
let d: Vec<Q> = d.into_iter().filter(|v| !v.is_zero()).collect();
let n = d.len();
if n == 0 {
return Err(invalid(OP, "every difference is zero"));
}
let abs: Vec<Q> = d.iter().map(Signed::abs).collect();
let ranks = data::ranks(&abs);
let (mut r_plus, mut r_minus) = (Q::zero(), Q::zero());
for (v, r) in d.iter().zip(&ranks) {
if v.is_positive() {
r_plus += r;
} else {
r_minus += r;
}
}
let statistic = match alt {
Alternative::TwoSided => ex(ctx, (&r_plus).min(&r_minus)),
_ => ex(ctx, &r_plus),
};
match method {
RankMethod::Exact => {
if !data::tie_sizes(&abs).is_empty() {
return Err(invalid(
OP,
"the exact method needs |differences| without ties",
));
}
let k = rank_statistic_to_index(OP, &r_plus)?;
let freq = signed_rank_frequencies(n);
let total = BigInt::one() << n;
let (cdf, sf) = (cdf_of(&freq, &total, k), sf_of(&freq, &total, k));
let p = match alt {
Alternative::Less => cdf,
Alternative::Greater => sf,
Alternative::TwoSided => (cdf.min(sf) * qi(2)).min(Q::one()),
};
Ok(result(ctx, statistic, p, None, alt))
}
RankMethod::Asymptotic { continuity } => {
let var = (qu(n * (n + 1) * (2 * n + 1)) - tie_term(&abs) / qi(2)) / qi(24);
if !var.is_positive() {
return Err(invalid(OP, "the variance of the rank sum is zero"));
}
let mut num = &r_plus - qu(n * (n + 1)) / qi(4);
if continuity {
num = continuity_shift(&num, alt);
}
Ok(TestResult {
statistic,
p_value: normal_p_value(ctx, &RootRatio { num, var }, alt),
df: None,
alternative: alt,
})
}
}
}
pub fn kruskal_wallis(ctx: &Context, groups: &[Vec<Q>]) -> Result<TestResult, SymplexError> {
const OP: &str = "kruskal_wallis";
let k = groups.len();
if k < 2 {
return Err(invalid(OP, "at least two groups are needed"));
}
if groups.iter().any(Vec::is_empty) {
return Err(invalid(OP, "every group must be non-empty"));
}
let pooled: Vec<Q> = groups.iter().flatten().cloned().collect();
let n = pooled.len();
let ranks = data::ranks(&pooled);
let mut ssbn = Q::zero();
let mut start = 0;
for g in groups {
let r = data::sum(&ranks[start..start + g.len()]);
ssbn += &r * &r / qu(g.len());
start += g.len();
}
let h = qi(12) / qu(n * (n + 1)) * ssbn - qu(3 * (n + 1));
let correction = Q::one() - tie_term(&pooled) / qu(n * n * n - n);
if correction.is_zero() {
return Err(invalid(OP, "every observation is identical"));
}
let h = h / correction;
Ok(TestResult {
p_value: chi_squared_sf_q(ctx, k - 1, &h),
statistic: ex(ctx, &h),
df: Some(ctx.int(usize_to_i64(OP, k - 1)?)),
alternative: Alternative::TwoSided,
})
}
pub fn friedman(ctx: &Context, blocks: &[Vec<Q>]) -> Result<TestResult, SymplexError> {
const OP: &str = "friedman";
let n = blocks.len();
if n == 0 {
return Err(invalid(OP, "at least one block is needed"));
}
let k = blocks[0].len();
if k < 3 {
return Err(invalid(OP, "at least three treatments are needed"));
}
if blocks.iter().any(|b| b.len() != k) {
return Err(invalid(
OP,
"every block must hold the same number of treatments",
));
}
let mut column_sums = vec![Q::zero(); k];
let mut ties = Q::zero();
for b in blocks {
let r = data::ranks(b);
for (s, v) in column_sums.iter_mut().zip(&r) {
*s += v;
}
ties += tie_term(b);
}
let correction = Q::one() - ties / qu(k * (k * k - 1) * n);
if correction.is_zero() {
return Err(invalid(OP, "every block is constant"));
}
let ssbn = column_sums.iter().fold(Q::zero(), |acc, r| acc + r * r);
let stat = (qi(12) / qu(k * n * (k + 1)) * ssbn - qu(3 * n * (k + 1))) / correction;
Ok(TestResult {
p_value: chi_squared_sf_q(ctx, k - 1, &stat),
statistic: ex(ctx, &stat),
df: Some(ctx.int(usize_to_i64(OP, k - 1)?)),
alternative: Alternative::TwoSided,
})
}
pub fn spearman_test(
ctx: &Context,
x: &[Q],
y: &[Q],
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "spearman_test";
check_same_len(OP, x, y)?;
check_sample(OP, "the paired sample", x, 3)?;
let n = x.len();
let (rx, ry) = (data::ranks(x), data::ranks(y));
let sxy = data::covariance(&rx, &ry, Ddof::Population)?;
let sxx = data::variance(&rx, Ddof::Population)?;
let syy = data::variance(&ry, Ddof::Population)?;
if sxx.is_zero() || syy.is_zero() {
return Err(invalid(OP, "a constant sample has no rank correlation"));
}
let rho = data::spearman(ctx, x, y)?;
let df = qu(n - 2);
let r2 = &sxy * &sxy / (&sxx * &syy);
let one_minus = Q::one() - &r2;
let p_value = if one_minus.is_zero() {
let extreme = match alt {
Alternative::TwoSided => true,
Alternative::Greater => sxy.is_positive(),
Alternative::Less => sxy.is_negative(),
};
if extreme { ctx.zero() } else { ctx.one() }
} else {
let stat = RootRatio {
num: sxy,
var: &sxx * &syy * &one_minus / &df,
};
student_p_value(ctx, &df, &stat, alt)
};
Ok(TestResult {
statistic: rho,
p_value,
df: Some(ex(ctx, &df)),
alternative: alt,
})
}
pub fn kendall_test(
ctx: &Context,
x: &[Q],
y: &[Q],
alt: Alternative,
exact: bool,
) -> Result<TestResult, SymplexError> {
const OP: &str = "kendall_test";
check_same_len(OP, x, y)?;
check_sample(OP, "the paired sample", x, 2)?;
let n = x.len();
let (mut con, mut dis) = (0usize, 0usize);
for i in 0..n {
for j in i + 1..n {
let (sx, sy) = (x[i].cmp(&x[j]), y[i].cmp(&y[j]));
if sx == Ordering::Equal || sy == Ordering::Equal {
continue;
}
if sx == sy {
con += 1;
} else {
dis += 1;
}
}
}
let tot = n * (n - 1) / 2;
let tie_stats = |t: &[usize]| -> (usize, Q, Q) {
t.iter().fold((0, Q::zero(), Q::zero()), |(p, a, b), &t| {
(
p + t * (t - 1) / 2,
a + qu(t * (t - 1) * (t - 2)),
b + qu(t * (t - 1) * (2 * t + 5)),
)
})
};
let (xtie, x0, x1) = tie_stats(&data::tie_sizes(x));
let (ytie, y0, y1) = tie_stats(&data::tie_sizes(y));
if xtie == tot || ytie == tot {
return Err(invalid(OP, "a constant sample has no rank correlation"));
}
let tau = data::kendall_tau(ctx, x, y)?;
if !exact {
let m = qu(n * (n - 1));
let mut var = (&m * qu(2 * n + 5) - &x1 - &y1) / qi(18) + qi(2) * qu(xtie * ytie) / &m;
if !x0.is_zero() && !y0.is_zero() {
var += &x0 * &y0 / (qi(9) * &m * qu(n - 2));
}
if !var.is_positive() {
return Err(invalid(OP, "the variance of the statistic is zero"));
}
let stat = RootRatio {
num: qu(con) - qu(dis),
var,
};
return Ok(TestResult {
statistic: tau,
p_value: normal_p_value(ctx, &stat, alt),
df: None,
alternative: alt,
});
}
if xtie > 0 || ytie > 0 {
return Err(invalid(OP, "the exact method needs samples without ties"));
}
let c = tot - dis;
let in_right_tail = c >= tot - c;
let cmin = c.min(tot - c);
let freq = inversion_counts(n, cmin);
let total = factorial_big(n);
let left = Q::new(sum_big(&freq), total.clone());
let at = Q::new(freq[cmin].clone(), total);
let p = match alt {
Alternative::TwoSided => (left * qi(2)).min(Q::one()),
Alternative::Greater | Alternative::Less => {
if in_right_tail == (alt == Alternative::Greater) {
left
} else {
Q::one() - left + at
}
}
};
Ok(result(ctx, tau, p, None, alt))
}
fn kolmogorov_sf(x: f64) -> f64 {
if x <= 0.0 {
return 1.0;
}
if x < 1.0 {
let mut s = 0.0;
for k in 1..=20 {
let m = f64::from(2 * k - 1);
s += (-m * m * std::f64::consts::PI * std::f64::consts::PI / (8.0 * x * x)).exp();
}
1.0 - (2.0 * std::f64::consts::PI).sqrt() / x * s
} else {
let mut s = 0.0;
for k in 1..=200 {
let kf = f64::from(k);
let term = (-2.0 * kf * kf * x * x).exp();
s += if k % 2 == 1 { term } else { -term };
if term < 1e-18 {
break;
}
}
(2.0 * s).clamp(0.0, 1.0)
}
}
fn smirnov_sf(n: usize, d: f64) -> f64 {
if d <= 0.0 {
return 1.0;
}
if d >= 1.0 {
return 0.0;
}
let nf = n as f64;
let log_n_fact = lgamma(nf + 1.0);
let mut sum = 0.0;
for j in 0..=n {
let jf = j as f64;
let a = 1.0 - d - jf / nf;
if a <= 0.0 {
break;
}
let log_term = if j == 0 {
nf * a.ln() - d.ln()
} else {
log_n_fact - lgamma(jf + 1.0) - lgamma(nf - jf + 1.0)
+ (nf - jf) * a.ln()
+ (jf - 1.0) * (d + jf / nf).ln()
};
sum += log_term.exp();
}
(d * sum).clamp(0.0, 1.0)
}
pub fn ks_one_sample(
x: &[Q],
dist: &Distribution,
alt: Alternative,
) -> Result<KsResult, SymplexError> {
const OP: &str = "ks_one_sample";
check_sample(OP, "the sample", x, 1)?;
if !dist.is_continuous() {
return Err(invalid(OP, "the reference distribution must be continuous"));
}
let ctx = dist.context();
let sorted = data::sorted(x);
let n = sorted.len() as f64;
let (mut d_plus, mut d_minus) = (0.0f64, 0.0f64);
for (i, v) in sorted.iter().enumerate() {
let f = dist.cdf(&ex(&ctx, v)).eval_f64()?;
d_plus = d_plus.max((i + 1) as f64 / n - f);
d_minus = d_minus.max(f - i as f64 / n);
}
let (statistic, p_value) = match alt {
Alternative::TwoSided => {
let d = d_plus.max(d_minus);
(d, kolmogorov_sf(n.sqrt() * d))
}
Alternative::Greater => (d_plus, smirnov_sf(sorted.len(), d_plus)),
Alternative::Less => (d_minus, smirnov_sf(sorted.len(), d_minus)),
};
Ok(KsResult {
statistic,
p_value,
alternative: alt,
})
}
pub fn cohens_d(ctx: &Context, x: &[Q], y: &[Q], pooled: bool) -> Result<Ex, SymplexError> {
const OP: &str = "cohens_d";
check_sample(OP, "the first sample", x, 2)?;
check_sample(OP, "the second sample", y, 2)?;
let (n1, n2) = (x.len(), y.len());
let (v1, v2) = (
data::variance(x, Ddof::Sample)?,
data::variance(y, Ddof::Sample)?,
);
let var = if pooled {
(qu(n1 - 1) * &v1 + qu(n2 - 1) * &v2) / qu(n1 + n2 - 2)
} else {
(&v1 + &v2) / qi(2)
};
if var.is_zero() {
return Err(invalid(OP, "both samples are constant (zero variance)"));
}
let num = data::mean(x)? - data::mean(y)?;
Ok(RootRatio { num, var }.to_ex(ctx))
}
pub fn hedges_g(ctx: &Context, x: &[Q], y: &[Q]) -> Result<Ex, SymplexError> {
let d = cohens_d(ctx, x, y, true)?;
let n = usize_to_i64("hedges_g", x.len() + y.len())?;
let j = ctx.one() - ctx.rational(3, 4 * n - 9);
Ok((d * j).simplify())
}
pub fn glass_delta(ctx: &Context, x: &[Q], y: &[Q]) -> Result<Ex, SymplexError> {
const OP: &str = "glass_delta";
check_sample(OP, "the first sample", x, 1)?;
check_sample(OP, "the control sample", y, 2)?;
let var = data::variance(y, Ddof::Sample)?;
if var.is_zero() {
return Err(invalid(
OP,
"the control sample is constant (zero variance)",
));
}
let num = data::mean(x)? - data::mean(y)?;
Ok(RootRatio { num, var }.to_ex(ctx))
}
pub fn rank_biserial(u1: &Q, n1: usize, n2: usize) -> Result<Q, SymplexError> {
const OP: &str = "rank_biserial";
if n1 == 0 || n2 == 0 {
return Err(invalid(OP, "both samples must be non-empty"));
}
if u1.is_negative() || *u1 > qu(n1 * n2) {
return Err(invalid(OP, "U must lie in [0, n₁n₂]"));
}
Ok(qi(2) * u1 / qu(n1 * n2) - Q::one())
}
pub fn eta_squared(groups: &[Vec<Q>]) -> Result<Q, SymplexError> {
const OP: &str = "eta_squared";
let (ss_between, ss_within, _, _) = sums_of_squares(OP, groups)?;
let total = &ss_between + &ss_within;
if total.is_zero() {
return Err(invalid(OP, "every observation is identical"));
}
Ok(ss_between / total)
}
pub fn cliffs_delta(x: &[Q], y: &[Q]) -> Result<Q, SymplexError> {
const OP: &str = "cliffs_delta";
check_sample(OP, "the first sample", x, 1)?;
check_sample(OP, "the second sample", y, 1)?;
let mut diff = 0i64;
for a in x {
for b in y {
diff += match a.cmp(b) {
Ordering::Greater => 1,
Ordering::Less => -1,
Ordering::Equal => 0,
};
}
}
Ok(qi(diff) / qu(x.len() * y.len()))
}
fn check_pvalues(op: &'static str, p: &[f64], alpha: f64) -> Result<(), SymplexError> {
if p.is_empty() {
return Err(invalid(op, "no p-values"));
}
if let Some(bad) = p.iter().find(|&&v| !(0.0..=1.0).contains(&v)) {
return Err(invalid(
op,
format!("p-values must lie in [0, 1], got {bad}"),
));
}
check_unit_open(op, "alpha", alpha)
}
fn ascending_order(p: &[f64]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..p.len()).collect();
idx.sort_by(|&a, &b| p[a].total_cmp(&p[b]));
idx
}
fn unsort(order: &[usize], sorted_p: Vec<f64>, sorted_reject: Vec<bool>) -> Adjusted {
let mut p_adjusted = vec![0.0; order.len()];
let mut reject = vec![false; order.len()];
for (rank, &i) in order.iter().enumerate() {
p_adjusted[i] = sorted_p[rank].min(1.0);
reject[i] = sorted_reject[rank];
}
Adjusted { p_adjusted, reject }
}
pub fn bonferroni(p: &[f64], alpha: f64) -> Result<Adjusted, SymplexError> {
check_pvalues("bonferroni", p, alpha)?;
let m = p.len() as f64;
let p_adjusted: Vec<f64> = p.iter().map(|&v| (v * m).min(1.0)).collect();
let reject = p_adjusted.iter().map(|&v| v <= alpha).collect();
Ok(Adjusted { p_adjusted, reject })
}
pub fn holm(p: &[f64], alpha: f64) -> Result<Adjusted, SymplexError> {
check_pvalues("holm", p, alpha)?;
let m = p.len();
let order = ascending_order(p);
let mut sorted_p = Vec::with_capacity(m);
let mut running = 0.0f64;
for (rank, &i) in order.iter().enumerate() {
running = running.max(p[i] * (m - rank) as f64);
sorted_p.push(running);
}
let sorted_reject = sorted_p.iter().map(|&v| v.min(1.0) <= alpha).collect();
Ok(unsort(&order, sorted_p, sorted_reject))
}
fn fdr_step_up(
op: &'static str,
p: &[f64],
alpha: f64,
scale: f64,
) -> Result<Adjusted, SymplexError> {
check_pvalues(op, p, alpha)?;
let m = p.len();
let order = ascending_order(p);
let factor: Vec<f64> = (1..=m).map(|i| i as f64 / m as f64 / scale).collect();
let mut sorted_p = vec![0.0; m];
let mut running = f64::INFINITY;
for rank in (0..m).rev() {
running = running.min(p[order[rank]] / factor[rank]);
sorted_p[rank] = running;
}
let last = (0..m)
.rev()
.find(|&rank| p[order[rank]] <= alpha * factor[rank]);
let sorted_reject = (0..m).map(|rank| last.is_some_and(|l| rank <= l)).collect();
Ok(unsort(&order, sorted_p, sorted_reject))
}
pub fn benjamini_hochberg(p: &[f64], alpha: f64) -> Result<Adjusted, SymplexError> {
fdr_step_up("benjamini_hochberg", p, alpha, 1.0)
}
pub fn benjamini_yekutieli(p: &[f64], alpha: f64) -> Result<Adjusted, SymplexError> {
let harmonic: f64 = (1..=p.len()).map(|j| 1.0 / j as f64).sum();
fdr_step_up("benjamini_yekutieli", p, alpha, harmonic)
}
fn quantile_sorted(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let h = (n - 1) as f64 * p;
let lo = (h.floor() as usize).min(n - 1);
let hi = (lo + 1).min(n - 1);
let frac = h - lo as f64;
sorted[lo] + frac * (sorted[hi] - sorted[lo])
}
fn check_f64_data(
op: &'static str,
name: &str,
data: &[f64],
min: usize,
) -> Result<(), SymplexError> {
if data.len() < min {
return Err(invalid(
op,
format!("{name} needs at least {min} observations"),
));
}
if let Some(bad) = data.iter().find(|v| !v.is_finite()) {
return Err(invalid(
op,
format!("{name} contains a non-finite value {bad}"),
));
}
Ok(())
}
pub fn bootstrap_ci(
data: &[f64],
statistic: impl Fn(&[f64]) -> f64,
n_resamples: usize,
confidence: f64,
rng: &mut Rng,
method: BootstrapMethod,
) -> Result<(f64, f64), SymplexError> {
const OP: &str = "bootstrap_ci";
check_f64_data(OP, "the data", data, 1)?;
check_unit_open(OP, "confidence", confidence)?;
if n_resamples == 0 {
return Err(invalid(OP, "at least one resample is needed"));
}
let n = data.len();
let observed = statistic(data);
let mut resample = vec![0.0; n];
let mut stats = Vec::with_capacity(n_resamples);
for _ in 0..n_resamples {
for slot in &mut resample {
*slot = data[rng.below(n)];
}
let s = statistic(&resample);
if !s.is_finite() {
return Err(SymplexError::computation_failed(
OP,
"the statistic of a resample is not finite",
));
}
stats.push(s);
}
stats.sort_by(f64::total_cmp);
let alpha = 1.0 - confidence;
let (lo, hi) = (
quantile_sorted(&stats, alpha / 2.0),
quantile_sorted(&stats, 1.0 - alpha / 2.0),
);
Ok(match method {
BootstrapMethod::Percentile => (lo, hi),
BootstrapMethod::Basic => (2.0 * observed - hi, 2.0 * observed - lo),
})
}
pub fn permutation_test(
x: &[f64],
y: &[f64],
statistic: impl Fn(&[f64], &[f64]) -> f64,
n_permutations: usize,
rng: &mut Rng,
alt: Alternative,
) -> Result<PermutationResult, SymplexError> {
const OP: &str = "permutation_test";
check_f64_data(OP, "the first sample", x, 1)?;
check_f64_data(OP, "the second sample", y, 1)?;
if n_permutations == 0 {
return Err(invalid(OP, "at least one permutation is needed"));
}
let observed = statistic(x, y);
if !observed.is_finite() {
return Err(SymplexError::computation_failed(
OP,
"the observed statistic is not finite",
));
}
let n1 = x.len();
let mut pooled: Vec<f64> = x.iter().chain(y).copied().collect();
let slack = 1e-14 * observed.abs();
let (mut count_ge, mut count_le) = (0usize, 0usize);
for _ in 0..n_permutations {
for i in (1..pooled.len()).rev() {
let j = rng.below(i + 1);
pooled.swap(i, j);
}
let s = statistic(&pooled[..n1], &pooled[n1..]);
if !s.is_finite() {
return Err(SymplexError::computation_failed(
OP,
"the statistic of a permutation is not finite",
));
}
if s >= observed - slack {
count_ge += 1;
}
if s <= observed + slack {
count_le += 1;
}
}
let denom = (n_permutations + 1) as f64;
let greater = (count_ge + 1) as f64 / denom;
let less = (count_le + 1) as f64 / denom;
let p_value = match alt {
Alternative::Greater => greater,
Alternative::Less => less,
Alternative::TwoSided => (2.0 * greater.min(less)).min(1.0),
};
Ok(PermutationResult {
statistic: observed,
p_value,
})
}
pub fn sample_size_for_proportion(
margin: f64,
confidence: f64,
p: f64,
) -> Result<usize, SymplexError> {
const OP: &str = "sample_size_for_proportion";
check_finite(OP, "margin", margin)?;
if margin <= 0.0 {
return Err(invalid(OP, "the margin must be positive"));
}
check_unit_open(OP, "confidence", confidence)?;
check_unit_open(OP, "p", p)?;
let z = norm_isf((1.0 - confidence) / 2.0);
let n = z * z * p * (1.0 - p) / (margin * margin);
Ok(n.ceil() as usize)
}
fn normal_power_two_sided(effect: f64, n_per_group: f64, alpha: f64) -> f64 {
let crit = norm_isf(alpha / 2.0);
let shift = effect * (n_per_group / 2.0).sqrt();
norm_sf(crit - shift) + norm_cdf(-crit - shift)
}
fn check_proportions_and_alpha(
op: &'static str,
p1: f64,
p2: f64,
alpha: f64,
) -> Result<(), SymplexError> {
check_unit_open(op, "p1", p1)?;
check_unit_open(op, "p2", p2)?;
check_unit_open(op, "alpha", alpha)
}
pub fn power_two_proportions(
p1: f64,
p2: f64,
n_per_group: usize,
alpha: f64,
) -> Result<f64, SymplexError> {
const OP: &str = "power_two_proportions";
check_proportions_and_alpha(OP, p1, p2, alpha)?;
if n_per_group == 0 {
return Err(invalid(OP, "the group size must be positive"));
}
let h = 2.0 * p1.sqrt().asin() - 2.0 * p2.sqrt().asin();
Ok(normal_power_two_sided(h, n_per_group as f64, alpha))
}
fn smallest_n_with_power(
op: &'static str,
start: usize,
target: f64,
power: impl Fn(usize) -> Result<f64, SymplexError>,
) -> Result<usize, SymplexError> {
const CAP: usize = 1 << 40;
let (mut lo, mut hi) = (start, start);
while power(hi)? < target {
lo = hi;
hi = hi.saturating_mul(2);
if hi > CAP {
return Err(SymplexError::computation_failed(
op,
"the required sample size exceeds 2^40",
));
}
}
if lo == hi {
return Ok(hi);
}
while hi - lo > 1 {
let mid = lo + (hi - lo) / 2;
if power(mid)? >= target {
hi = mid;
} else {
lo = mid;
}
}
Ok(hi)
}
pub fn sample_size_two_proportions(
p1: f64,
p2: f64,
alpha: f64,
power: f64,
) -> Result<usize, SymplexError> {
const OP: &str = "sample_size_two_proportions";
check_proportions_and_alpha(OP, p1, p2, alpha)?;
check_unit_open(OP, "power", power)?;
if power <= alpha {
return Err(invalid(OP, "the target power must exceed alpha"));
}
if p1 == p2 {
return Err(invalid(OP, "equal proportions have no finite sample size"));
}
smallest_n_with_power(OP, 1, power, |n| power_two_proportions(p1, p2, n, alpha))
}
pub fn power_t_test_two_sample(
effect_size: f64,
n_per_group: usize,
alpha: f64,
) -> Result<f64, SymplexError> {
const OP: &str = "power_t_test_two_sample";
check_finite(OP, "effect_size", effect_size)?;
check_unit_open(OP, "alpha", alpha)?;
if n_per_group < 2 {
return Err(invalid(OP, "each group needs at least two observations"));
}
let df = (2 * n_per_group - 2) as f64;
let delta = effect_size * (n_per_group as f64 / 2.0).sqrt();
let ctx = Context::new();
let t_crit = student_t_quantile_f64(OP, &ctx, df, 1.0 - alpha / 2.0)?;
let half = df / 2.0;
let log_norm = half * std::f64::consts::LN_2 + lgamma(half);
let density = move |v: f64| -> f64 {
if v <= 0.0 {
return 0.0;
}
((half - 1.0) * v.ln() - v / 2.0 - log_norm).exp()
};
let integrand = move |v: f64| -> f64 {
let scale = (v / df).sqrt();
(norm_cdf(t_crit * scale - delta) - norm_cdf(-t_crit * scale - delta)) * density(v)
};
let sd = (2.0 * df).sqrt();
let lo = (df - 40.0 * sd).max(0.0);
let hi = df + 40.0 * sd + 50.0;
let opts = QuadOpts::default();
let (accept, _) = quadrature(&integrand, lo, hi, &opts)?;
Ok((1.0 - accept).clamp(0.0, 1.0))
}
pub fn sample_size_t_test_two_sample(
effect_size: f64,
alpha: f64,
power: f64,
) -> Result<usize, SymplexError> {
const OP: &str = "sample_size_t_test_two_sample";
check_finite(OP, "effect_size", effect_size)?;
check_unit_open(OP, "alpha", alpha)?;
check_unit_open(OP, "power", power)?;
if power <= alpha {
return Err(invalid(OP, "the target power must exceed alpha"));
}
if effect_size == 0.0 {
return Err(invalid(OP, "a zero effect has no finite sample size"));
}
smallest_n_with_power(OP, 2, power, |n| {
power_t_test_two_sample(effect_size, n, alpha)
})
}