use std::cmp::Ordering;
use std::f64::consts::LN_10;
use num_bigint::BigInt;
use num_traits::{One, Signed, ToPrimitive, Zero};
use super::common::{
check_alpha, check_confidence, check_finite, check_sample, check_unit_open, chi_squared_sf,
chi_squared_sf_q, ex, invalid, norm_cdf, norm_isf, norm_sf, q_to_f64, qi, qu,
student_t_quantile_f64, usize_to_i64,
};
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::base::interval::Interval;
use crate::calculus::definite::{QuadOpts, quadrature};
use crate::domains::optimize::partition_point_by;
use crate::output::codegen::numeric_rt::lgamma;
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 square(x: &Q) -> Q {
x * x
}
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(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Alternative {
TwoSided,
Less,
Greater,
}
pub trait PValue {
fn p_value_ex(&self) -> &Ex;
fn p_value_f64(&self) -> Result<f64, SymplexError> {
self.p_value_ex().eval_f64()
}
fn p_value_log10(&self) -> Result<f64, SymplexError> {
p_value_log10_of(self.p_value_ex())
}
fn p_value_ln(&self) -> Result<f64, SymplexError> {
p_value_ln_of(self.p_value_ex())
}
fn p_value_decimal(&self, digits: u32) -> Result<String, SymplexError> {
self.p_value_ex().eval_decimal(digits)
}
}
pub(crate) fn p_value_ln_of(p: &Ex) -> Result<f64, SymplexError> {
let reduced = p.eval();
match reduced.as_rational() {
Some(q) if q.is_zero() => Ok(f64::NEG_INFINITY),
Some(q) if q.is_one() => Ok(0.0),
_ => reduced.ln().eval_f64(),
}
}
pub(crate) fn p_value_log10_of(p: &Ex) -> Result<f64, SymplexError> {
p_value_ln_of(p).map(|ln_p| ln_p / LN_10)
}
macro_rules! p_value_accessors {
($ty:ty) => {
impl $ty {
pub fn p_value_log10(&self) -> Result<f64, $crate::base::errors::SymplexError> {
<Self as $crate::stats::PValue>::p_value_log10(self)
}
pub fn p_value_ln(&self) -> Result<f64, $crate::base::errors::SymplexError> {
<Self as $crate::stats::PValue>::p_value_ln(self)
}
pub fn p_value_decimal(
&self,
digits: u32,
) -> Result<String, $crate::base::errors::SymplexError> {
<Self as $crate::stats::PValue>::p_value_decimal(self, digits)
}
}
};
}
pub(crate) use p_value_accessors;
#[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()
}
}
impl PValue for TestResult {
fn p_value_ex(&self) -> &Ex {
&self.p_value
}
}
p_value_accessors!(TestResult);
#[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()
}
}
impl PValue for ChiSquareResult {
fn p_value_ex(&self) -> &Ex {
&self.p_value
}
}
p_value_accessors!(ChiSquareResult);
#[derive(Clone, Debug, PartialEq)]
pub struct RatioEstimate {
pub estimate: Q,
pub ci: Interval<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 {
in_tail(self.num.is_negative(), self.num.is_positive(), alt)
}
}
fn in_tail(num_is_negative: bool, num_is_positive: bool, alt: Alternative) -> bool {
match alt {
Alternative::Greater | Alternative::TwoSided => !num_is_negative,
Alternative::Less => !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 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))
}
struct HypergeomExact {
lo: usize,
weights: Vec<BigInt>,
total: BigInt,
}
impl HypergeomExact {
fn new(n1: usize, n2: usize, n: usize) -> Self {
let lo = n.saturating_sub(n2);
let hi = n.min(n1);
let total = data::binomial_q(n1 + n2, n).to_integer();
let mut w = (data::binomial_q(n1, lo) * data::binomial_q(n2, n - lo)).to_integer();
let mut weights = Vec::with_capacity(hi - lo + 1);
for x in lo..hi {
weights.push(w.clone());
w = w * BigInt::from(n1 - x) * BigInt::from(n - x)
/ (BigInt::from(x + 1) * BigInt::from(n2 + x + 1 - n));
}
weights.push(w);
Self { lo, weights, total }
}
fn mass_where(&self, keep: impl Fn(usize, &BigInt) -> bool) -> Q {
let sum = self
.weights
.iter()
.enumerate()
.filter(|(i, w)| keep(*i, w))
.fold(BigInt::zero(), |acc, (_, w)| acc + w);
Q::new(sum, self.total.clone())
}
fn p_value(&self, a: usize, alt: Alternative) -> Q {
let idx = a - self.lo;
match alt {
Alternative::Less => self.mass_where(|i, _| i <= idx),
Alternative::Greater => self.mass_where(|i, _| i >= idx),
Alternative::TwoSided => {
let at = &self.weights[idx];
self.mass_where(|_, w| w <= at).min(Q::one())
}
}
}
}
pub const FISHER_EXACT_NUMERIC_THRESHOLD: usize = 2_000;
const HYPERGEOM_MAX_WALK: usize = 2_000_000;
const HYPERGEOM_TIE_TOL: f64 = 1e-11;
const HYPERGEOM_TAIL_CUTOFF: f64 = 1e-22;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Walk {
Down,
Up,
}
struct HypergeomNumeric {
n1: usize,
n2: usize,
n: usize,
lo: usize,
hi: usize,
mode: usize,
ln_total: f64,
}
impl HypergeomNumeric {
fn new(n1: usize, n2: usize, n: usize) -> Self {
let lo = n.saturating_sub(n2);
let hi = n.min(n1);
let wide = |v: usize| v as u128;
let mode = (wide(n) + 1) * (wide(n1) + 1) / (wide(n1) + wide(n2) + 2);
let mode = usize::try_from(mode).unwrap_or(hi).clamp(lo, hi);
let mut me = Self {
n1,
n2,
n,
lo,
hi,
mode,
ln_total: 0.0,
};
let below = if mode > lo {
me.ln_outer_tail(mode - 1, Walk::Down).exp()
} else {
0.0
};
let above = if mode < hi {
me.ln_outer_tail(mode + 1, Walk::Up).exp()
} else {
0.0
};
me.ln_total = (1.0 + below + above).ln();
me
}
fn symmetric(&self) -> bool {
self.n1 == self.n2 || 2 * self.n == self.n1 + self.n2
}
fn ratio_up(&self, x: usize) -> f64 {
let num = (self.n1 - x) as f64 * (self.n - x) as f64;
let den = (x + 1) as f64 * (self.n2 + x + 1 - self.n) as f64;
num / den
}
fn ln_binom(n: usize, k: usize) -> f64 {
lgamma(n as f64 + 1.0) - lgamma(k as f64 + 1.0) - lgamma((n - k) as f64 + 1.0)
}
fn ln_shape_stirling(&self, x: usize) -> f64 {
let (n1, n2, n, m) = (self.n1, self.n2, self.n, self.mode);
Self::ln_binom(n1, x) - Self::ln_binom(n1, m) + Self::ln_binom(n2, n - x)
- Self::ln_binom(n2, n - m)
}
fn ln_shape(&self, x: usize) -> f64 {
let m = self.mode;
if x.abs_diff(m) > HYPERGEOM_MAX_WALK {
return self.ln_shape_stirling(x);
}
let mut acc = LogProduct::default();
if x > m {
for y in m..x {
acc.mul(self.ratio_up(y));
}
} else {
for y in (x..m).rev() {
acc.div(self.ratio_up(y));
}
}
acc.ln()
}
fn ln_outer_tail(&self, start: usize, dir: Walk) -> f64 {
let ln_start = self.ln_shape(start);
let mut sum = 1.0_f64;
let mut term = 1.0_f64;
let mut y = start;
loop {
match dir {
Walk::Up => {
if y >= self.hi {
break;
}
term *= self.ratio_up(y);
y += 1;
}
Walk::Down => {
if y <= self.lo {
break;
}
term /= self.ratio_up(y - 1);
y -= 1;
}
}
sum += term;
if term < HYPERGEOM_TAIL_CUTOFF * sum {
break;
}
}
ln_start + sum.ln()
}
fn ln_tail(&self, a: usize, dir: Walk) -> f64 {
let m = self.mode;
match dir {
Walk::Down if a >= self.hi => 0.0,
Walk::Up if a <= self.lo => 0.0,
Walk::Down if a < m => self.ln_outer_tail(a, Walk::Down) - self.ln_total,
Walk::Up if a > m => self.ln_outer_tail(a, Walk::Up) - self.ln_total,
Walk::Down => (-(self.ln_outer_tail(a + 1, Walk::Up) - self.ln_total).exp()).ln_1p(),
Walk::Up => (-(self.ln_outer_tail(a - 1, Walk::Down) - self.ln_total).exp()).ln_1p(),
}
}
fn cutoff_above(&self, a: usize) -> Option<usize> {
if self.symmetric() {
return Some(self.lo + self.hi - a);
}
let target = self.ln_shape(a) + HYPERGEOM_TIE_TOL;
let m = self.mode;
let mut acc = LogProduct::default();
let mut y = m;
loop {
if acc.ln() <= target {
return Some(y);
}
if y >= self.hi {
return None;
}
if y - m >= HYPERGEOM_MAX_WALK {
if self.ln_shape_stirling(self.hi) > target {
return None;
}
return Some(partition_point_by(y + 1, self.hi, |g| {
self.ln_shape_stirling(g) <= target
}));
}
acc.mul(self.ratio_up(y));
y += 1;
}
}
fn cutoff_below(&self, a: usize) -> Option<usize> {
if self.symmetric() {
return Some(self.lo + self.hi - a);
}
let target = self.ln_shape(a) + HYPERGEOM_TIE_TOL;
let m = self.mode;
let mut acc = LogProduct::default();
let mut y = m;
loop {
if acc.ln() <= target {
return Some(y);
}
if y <= self.lo {
return None;
}
if m - y >= HYPERGEOM_MAX_WALK {
if self.ln_shape_stirling(self.lo) > target {
return None;
}
let first_above =
partition_point_by(self.lo, y, |g| self.ln_shape_stirling(g) > target);
return Some(first_above - 1);
}
acc.div(self.ratio_up(y - 1));
y -= 1;
}
}
fn ln_p_value(&self, a: usize, alt: Alternative) -> f64 {
match alt {
Alternative::Less => self.ln_tail(a, Walk::Down),
Alternative::Greater => self.ln_tail(a, Walk::Up),
Alternative::TwoSided => {
let m = self.mode;
if a == m {
return 0.0;
}
let (near, far) = if a < m {
(
self.ln_tail(a, Walk::Down),
self.cutoff_above(a).map(|g| self.ln_tail(g, Walk::Up)),
)
} else {
(
self.ln_tail(a, Walk::Up),
self.cutoff_below(a).map(|g| self.ln_tail(g, Walk::Down)),
)
};
match far {
Some(far) => log_add_exp(near, far).min(0.0),
None => near,
}
}
}
}
}
#[derive(Clone, Copy)]
struct LogProduct {
acc: f64,
shift: f64,
}
impl Default for LogProduct {
fn default() -> Self {
Self {
acc: 1.0,
shift: 0.0,
}
}
}
impl LogProduct {
fn renormalise(&mut self) {
if self.acc < 1e-250 || self.acc > 1e250 {
self.shift += self.acc.ln();
self.acc = 1.0;
}
}
fn mul(&mut self, r: f64) {
self.acc *= r;
self.renormalise();
}
fn div(&mut self, r: f64) {
self.acc /= r;
self.renormalise();
}
fn ln(&self) -> f64 {
self.shift + self.acc.ln()
}
}
fn log_add_exp(a: f64, b: f64) -> f64 {
if a == f64::NEG_INFINITY {
return b;
}
if b == f64::NEG_INFINITY {
return a;
}
let (hi, lo) = if a >= b { (a, b) } else { (b, a) };
hi + (lo - hi).exp().ln_1p()
}
fn numeric_p_value(ctx: &Context, ln_p: f64) -> Result<Ex, SymplexError> {
if ln_p == f64::NEG_INFINITY {
return Ok(ctx.zero());
}
let p = ln_p.exp().min(1.0);
if p >= f64::MIN_POSITIVE {
ctx.from_f64(p)
} else {
Ok(ctx.from_f64(ln_p)?.exp())
}
}
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 margin = |x: usize, y: usize| {
x.checked_add(y)
.ok_or_else(|| invalid(OP, "a margin of the table overflows usize"))
};
let (n1, n2, n, n_col2) = (margin(a, b)?, margin(c, d)?, margin(a, c)?, margin(b, d)?);
margin(n1, n2)?;
if n1 == 0 || n2 == 0 || n == 0 || n_col2 == 0 {
return Err(invalid(OP, "a row or a column of the table is empty"));
}
let odds = if b == 0 || c == 0 {
ctx.infinity()
} else {
ex(ctx, &(qu(a) * qu(d) / (qu(b) * qu(c))))
};
let lo = n.saturating_sub(n2);
let hi = n.min(n1);
if hi - lo >= FISHER_EXACT_NUMERIC_THRESHOLD {
let ln_p = HypergeomNumeric::new(n1, n2, n).ln_p_value(a, alt);
return Ok(TestResult {
statistic: odds,
p_value: numeric_p_value(ctx, ln_p)?,
df: None,
alternative: alt,
});
}
let p = HypergeomExact::new(n1, n2, n).p_value(a, alt);
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()
}
#[must_use]
pub fn counts_usize(rows: &[Vec<usize>]) -> Vec<Vec<Q>> {
rows.iter()
.map(|r| r.iter().map(|&c| qu(c)).collect())
.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)
}
struct Expected {
cells: Vec<Vec<Q>>,
rows: Vec<Q>,
cols: Vec<Q>,
total: Q,
}
fn expected_of(op: &'static str, table: &[Vec<Q>]) -> Result<Expected, SymplexError> {
check_table(op, table)?;
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",
));
}
let cells = rows
.iter()
.map(|r| cols.iter().map(|c| r * c / &total).collect())
.collect();
Ok(Expected {
cells,
rows,
cols,
total,
})
}
pub fn expected_counts(table: &[Vec<Q>]) -> Result<Vec<Vec<Q>>, SymplexError> {
Ok(expected_of("expected_counts", table)?.cells)
}
pub fn chi2_contributions(table: &[Vec<Q>]) -> Result<Vec<Vec<Q>>, SymplexError> {
let expected = expected_of("chi2_contributions", table)?.cells;
Ok(table
.iter()
.zip(&expected)
.map(|(o, e)| o.iter().zip(e).map(|(o, e)| square(&(o - e)) / e).collect())
.collect())
}
pub fn standardized_residuals(
ctx: &Context,
table: &[Vec<Q>],
) -> Result<Vec<Vec<Ex>>, SymplexError> {
let expected = expected_of("standardized_residuals", table)?.cells;
Ok(table
.iter()
.zip(&expected)
.map(|(o, e)| {
o.iter()
.zip(e)
.map(|(o, e)| (ex(ctx, &(o - e)) / ex(ctx, e).sqrt()).simplify())
.collect()
})
.collect())
}
pub fn adjusted_residuals(ctx: &Context, table: &[Vec<Q>]) -> Result<Vec<Vec<Ex>>, SymplexError> {
const OP: &str = "adjusted_residuals";
let Expected {
cells: expected,
rows,
cols,
total,
} = expected_of(OP, table)?;
if rows.len() < 2 || cols.len() < 2 {
return Err(invalid(
OP,
"adjusted residuals need at least two rows and two columns",
));
}
let row_f: Vec<Q> = rows.iter().map(|r| Q::one() - r / &total).collect();
let col_f: Vec<Q> = cols.iter().map(|c| Q::one() - c / &total).collect();
Ok(table
.iter()
.zip(&expected)
.zip(&row_f)
.map(|((o, e), rf)| {
o.iter()
.zip(e)
.zip(&col_f)
.map(|((o, e), cf)| {
let var = e * rf * cf;
(ex(ctx, &(o - e)) / ex(ctx, &var).sqrt()).simplify()
})
.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).max(Q::zero());
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_of(OP, table)?.cells;
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_of(OP, table)?.cells;
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.map(|row| row.map(qu));
let num = &a * &d - &b * &c;
let den = (&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) -> Interval<f64> {
let z = norm_isf((1.0 - confidence) / 2.0);
let log = q_to_f64(estimate).ln();
Interval::closed((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_confidence(OP, 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) * qu(d) / (qu(b) * qu(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_confidence(OP, 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 z_test_two_proportions(
ctx: &Context,
k1: usize,
n1: usize,
k2: usize,
n2: usize,
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "z_test_two_proportions";
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))
}
struct CrossMoments {
sxy: Q,
sxx: Q,
syy: Q,
}
fn cross_moments(
op: &'static str,
x: &[Q],
y: &[Q],
min_n: usize,
) -> Result<CrossMoments, SymplexError> {
check_same_len(op, x, y)?;
if x.len() < min_n {
return Err(invalid(
op,
format!(
"needs at least {min_n} paired observations, got {}",
x.len()
),
));
}
let sxy = data::covariance(x, y, Ddof::Population)?;
let sxx = data::variance(x, Ddof::Population)?;
let syy = data::variance(y, Ddof::Population)?;
if sxx.is_zero() || syy.is_zero() {
return Err(invalid(op, "a constant sample has no correlation"));
}
Ok(CrossMoments { sxy, sxx, syy })
}
pub fn pearson_t_statistic(ctx: &Context, x: &[Q], y: &[Q]) -> Result<Ex, SymplexError> {
const OP: &str = "pearson_t_statistic";
let CrossMoments { sxy, sxx, syy } = cross_moments(OP, x, y, 3)?;
let resid = &sxx * &syy - square(&sxy);
if resid.is_zero() {
return Err(invalid(OP, "|r| = 1, the t statistic is infinite"));
}
let var = resid / qu(x.len() - 2);
Ok((ex(ctx, &sxy) / ex(ctx, &var).sqrt()).simplify())
}
pub fn pearson_test(
ctx: &Context,
x: &[Q],
y: &[Q],
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "pearson_test";
let CrossMoments { sxy, sxx, syy } = cross_moments(OP, x, y, 3)?;
let r = (ex(ctx, &sxy) / (ex(ctx, &sxx) * ex(ctx, &syy)).sqrt()).simplify();
let df = qu(x.len() - 2);
let r2 = square(&sxy) / (&sxx * &syy);
let one_minus = Q::one() - &r2;
let tail = in_tail(sxy.is_negative(), sxy.is_positive(), alt);
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 t2 = &r2 * &df / &one_minus;
let z = &df / (&t2 + &df);
let two_sided = ex(ctx, &z).betainc_regularized(
&(ex(ctx, &df) / ctx.int(2)),
&ctx.rational(1, 2),
&ctx.zero(),
);
one_sided_from_symmetric(ctx, two_sided, tail, alt)
};
Ok(TestResult {
statistic: r,
p_value,
df: Some(ex(ctx, &df)),
alternative: alt,
})
}
pub fn compare_two_correlations(
ctx: &Context,
r1: f64,
n1: usize,
r2: f64,
n2: usize,
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "compare_two_correlations";
for (name, r, n) in [("first", r1, n1), ("second", r2, n2)] {
if !r.is_finite() || r.abs() >= 1.0 {
return Err(invalid(
OP,
format!("the {name} correlation must lie strictly between −1 and 1, got {r}"),
));
}
if n < 4 {
return Err(invalid(
OP,
format!("the {name} sample needs at least four observations, got {n}"),
));
}
}
let diff = ctx.from_f64(r1)?.atanh() - ctx.from_f64(r2)?.atanh();
let var = Q::one() / qu(n1 - 3) + Q::one() / qu(n2 - 3);
let statistic = diff / ex(ctx, &var).sqrt();
let two_sided = (statistic.abs() / ctx.int(2).sqrt()).erfc();
let sign = r1.atanh() - r2.atanh();
let tail = in_tail(sign < 0.0, sign > 0.0, alt);
Ok(TestResult {
statistic,
p_value: one_sided_from_symmetric(ctx, two_sided, tail, alt),
df: None,
alternative: alt,
})
}
pub fn tie_term(tie_sizes: &[usize]) -> Q {
tie_sizes
.iter()
.fold(Q::zero(), |acc, &t| acc + cubic_minus_linear(t))
}
fn tie_term_of(x: &[Q]) -> Q {
tie_term(&data::tie_sizes(x))
}
fn cubic_minus_linear(n: usize) -> Q {
let n = qu(n);
&n * &n * &n - &n
}
fn qmul(a: usize, b: usize) -> Q {
qu(a) * qu(b)
}
fn pronic(n: usize) -> Q {
qmul(n, n + 1)
}
fn mann_whitney_frequencies(m: usize, n: usize) -> Option<Vec<BigInt>> {
let size = m.checked_mul(n)?.checked_add(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;
}
}
Some(c)
}
fn signed_rank_frequencies(n: usize) -> Option<Vec<BigInt>> {
let size = n.checked_mul(n + 1)?.checked_div(2)?.checked_add(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;
}
}
Some(c)
}
fn exact_table_too_large(op: &'static str) -> SymplexError {
invalid(op, "the exact distribution's table is too large for usize")
}
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 - pronic(n1) / qi(2);
let n1n2 = qmul(n1, n2);
let u2 = &n1n2 - &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).ok_or_else(|| exact_table_too_large(OP))?;
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 = &n1n2 / qi(12) * (qu(n + 1) - tie_term_of(&pooled) / qmul(n, n - 1));
if !var.is_positive() {
return Err(invalid(OP, "every observation is identical"));
}
let mut num = &u1 - &n1n2 / 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).ok_or_else(|| exact_table_too_large(OP))?;
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 = (pronic(n) * (qi(2) * qu(n) + Q::one()) - tie_term_of(&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 - pronic(n) / 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) / pronic(n) * ssbn - qi(3) * qu(n + 1);
let correction = Q::one() - tie_term_of(&pooled) / cubic_minus_linear(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_of(b);
}
let correction = Q::one() - ties / (cubic_minus_linear(k) * qu(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 nk1 = qmul(n, k + 1);
let stat = (qi(12) / (&nk1 * qu(k)) * ssbn - qi(3) * &nk1) / 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"));
}
let n1n2 = qmul(n1, n2);
if u1.is_negative() || *u1 > n1n2 {
return Err(invalid(OP, "U must lie in [0, n₁n₂]"));
}
Ok(qi(2) * u1 / n1n2 - Q::one())
}
pub fn eta_squared(groups: &[Vec<Q>]) -> Result<Q, SymplexError> {
const OP: &str = "eta_squared";
let super::anova::GroupSums {
ss_between,
ss_within,
..
} = super::anova::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) / qmul(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_alpha(op, 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<Interval<f64>, SymplexError> {
const OP: &str = "bootstrap_ci";
check_f64_data(OP, "the data", data, 1)?;
check_confidence(OP, 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 => Interval::closed(lo, hi),
BootstrapMethod::Basic => Interval::closed(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_confidence(OP, 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_alpha(op, 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 = if usize::BITS >= 41 {
1 << 40
} else {
usize::MAX / 2
};
let mut error = None;
let n = partition_point_by(start, CAP, |n| match power(n) {
Ok(pw) => pw >= target,
Err(e) => {
error.get_or_insert(e);
true
}
});
if let Some(e) = error {
return Err(e);
}
if n >= CAP {
return Err(SymplexError::computation_failed(
op,
"the required sample size exceeds 2^40",
));
}
Ok(n)
}
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_alpha(OP, 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 t_crit = student_t_quantile_f64(OP, 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)?.value;
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_alpha(OP, 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)
})
}