use std::f64::consts::{PI, SQRT_2};
use num_bigint::BigInt;
use num_traits::{One, Signed, Zero};
use super::data::{self, Q};
use super::hypothesis::{self, Alternative, TestResult};
use super::regression::ols;
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::errors::SymplexError;
use crate::base::interval::Interval;
use crate::base::numeric::ratio_to_f64;
use crate::domains::exact_matrix::QMatrix;
use crate::domains::optimize::{RootOpts, brent_root};
use crate::output::codegen::numeric_rt::{erfc, lgamma};
fn invalid(op: &'static str, reason: impl Into<String>) -> SymplexError {
SymplexError::invalid_argument(op, reason)
}
fn failed(op: &'static str, reason: impl Into<String>) -> SymplexError {
SymplexError::computation_failed(op, reason)
}
fn qu(n: usize) -> Q {
Q::from_integer(BigInt::from(n))
}
fn qi(n: i64) -> Q {
Q::from_integer(BigInt::from(n))
}
fn ex(ctx: &Context, q: &Q) -> Ex {
ctx.from_ratio(q.clone())
}
fn to_f64(op: &'static str, q: &Q) -> Result<f64, SymplexError> {
ratio_to_f64(q).ok_or_else(|| failed(op, format!("{q} does not fit in an f64")))
}
fn centred_ss(x: &[Q]) -> Q {
data::sum_of_squares(x).unwrap_or_else(|_| Q::zero())
}
fn f_sf(ctx: &Context, d1: &Q, d2: &Q, f: &Q) -> Ex {
if !f.is_positive() {
return ctx.one();
}
let z = d2 / (d2 + d1 * f);
ex(ctx, &z).betainc_regularized(
&ex(ctx, &(d2 / qu(2))),
&ex(ctx, &(d1 / qu(2))),
&ctx.zero(),
)
}
fn chi_squared_sf(ctx: &Context, df: usize, x: &Ex) -> Ex {
let half_df = ex(ctx, &(qu(df) / qu(2)));
(x / ctx.int(2)).uppergamma(&half_df) / half_df.gamma()
}
fn check_confidence(op: &'static str, confidence: f64) -> Result<(), SymplexError> {
if confidence > 0.0 && confidence < 1.0 {
Ok(())
} else {
Err(invalid(
op,
format!("confidence must lie strictly between 0 and 1, got {confidence}"),
))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Source {
FactorA,
FactorB,
Interaction,
Conditions,
Subjects,
Residual,
Total,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AnovaRow {
pub source: Source,
pub ss: Q,
pub df: usize,
pub ms: Option<Q>,
pub f: Option<Q>,
pub p_value: Option<Ex>,
pub eta_squared: Option<Q>,
pub partial_eta_squared: Option<Q>,
}
impl AnovaRow {
fn effect(
ctx: &Context,
source: Source,
ss: Q,
df: usize,
ss_resid: &Q,
df_resid: usize,
ss_total: &Q,
) -> Self {
let test = FTest::of(ctx, &ss, df, ss_resid, df_resid);
Self::with_test(source, ss, df, test, ss_resid, ss_total)
}
fn with_test(
source: Source,
ss: Q,
df: usize,
test: FTest,
ss_resid: &Q,
ss_total: &Q,
) -> Self {
Self {
source,
eta_squared: Some(&ss / ss_total),
partial_eta_squared: Some(&ss / (&ss + ss_resid)),
ms: Some(&ss / qu(df)),
ss,
df,
f: Some(test.f),
p_value: Some(test.p_value),
}
}
fn untested(source: Source, ss: Q, df: usize) -> Self {
Self {
source,
ms: Some(&ss / qu(df)),
ss,
df,
f: None,
p_value: None,
eta_squared: None,
partial_eta_squared: None,
}
}
fn total(ss: Q, df: usize) -> Self {
Self {
source: Source::Total,
ss,
df,
ms: None,
f: None,
p_value: None,
eta_squared: None,
partial_eta_squared: None,
}
}
pub fn p_value_f64(&self) -> Result<f64, SymplexError> {
match &self.p_value {
Some(p) => p.eval_f64(),
None => Err(invalid(
"AnovaRow::p_value_f64",
format!("the {:?} row has no F test", self.source),
)),
}
}
}
struct FTest {
f: Q,
p_value: Ex,
}
impl FTest {
fn of(ctx: &Context, ss: &Q, df: usize, ss_resid: &Q, df_resid: usize) -> Self {
let f = (ss / qu(df)) / (ss_resid / qu(df_resid));
let p_value = f_sf(ctx, &qu(df), &qu(df_resid), &f);
Self { f, p_value }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Observation {
pub a: usize,
pub b: usize,
pub y: Q,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TwoWayData {
a_levels: usize,
b_levels: usize,
cells: Vec<Vec<Vec<Q>>>,
}
impl TwoWayData {
pub fn from_cells(cells: Vec<Vec<Vec<Q>>>) -> Result<Self, SymplexError> {
const OP: &str = "TwoWayData::from_cells";
let a_levels = cells.len();
if a_levels < 2 {
return Err(invalid(OP, "factor A needs at least two levels"));
}
let b_levels = cells.first().map_or(0, Vec::len);
if b_levels < 2 {
return Err(invalid(OP, "factor B needs at least two levels"));
}
for (a, row) in cells.iter().enumerate() {
if row.len() != b_levels {
return Err(invalid(
OP,
format!(
"level {a} of A has {} cells, expected {b_levels}",
row.len()
),
));
}
if let Some((b, _)) = row.iter().enumerate().find(|(_, c)| c.is_empty()) {
return Err(invalid(
OP,
format!("cell (A = {a}, B = {b}) has no observations"),
));
}
}
Ok(Self {
a_levels,
b_levels,
cells,
})
}
pub fn from_i64(cells: &[&[&[i64]]]) -> Result<Self, SymplexError> {
Self::from_cells(
cells
.iter()
.map(|row| row.iter().map(|c| data::from_i64(c)).collect())
.collect(),
)
}
pub fn from_long(rows: &[Observation]) -> Result<Self, SymplexError> {
const OP: &str = "TwoWayData::from_long";
if rows.is_empty() {
return Err(invalid(OP, "no observations"));
}
let a_levels = rows.iter().map(|o| o.a).max().unwrap_or(0) + 1;
let b_levels = rows.iter().map(|o| o.b).max().unwrap_or(0) + 1;
let mut cells = vec![vec![Vec::new(); b_levels]; a_levels];
for o in rows {
if let Some(cell) = cells.get_mut(o.a).and_then(|row| row.get_mut(o.b)) {
cell.push(o.y.clone());
}
}
Self::from_cells(cells).map_err(|e| match e {
SymplexError::InvalidArgument { reason, .. } => invalid(OP, reason),
other => other,
})
}
#[must_use]
pub fn a_levels(&self) -> usize {
self.a_levels
}
#[must_use]
pub fn b_levels(&self) -> usize {
self.b_levels
}
#[must_use]
pub fn n_obs(&self) -> usize {
self.cells.iter().flatten().map(Vec::len).sum()
}
#[must_use]
pub fn cell(&self, a: usize, b: usize) -> Option<&[Q]> {
self.cells.get(a)?.get(b).map(Vec::as_slice)
}
#[must_use]
pub fn cells(&self) -> &[Vec<Vec<Q>>] {
&self.cells
}
#[must_use]
pub fn is_balanced(&self) -> bool {
let mut sizes = self.cells.iter().flatten().map(Vec::len);
match sizes.next() {
Some(first) => sizes.all(|s| s == first),
None => true,
}
}
fn flatten(&self) -> Vec<Q> {
self.cells.iter().flatten().flatten().cloned().collect()
}
fn a_slice(&self, a: usize) -> Vec<Q> {
self.cells
.get(a)
.map(|row| row.iter().flatten().cloned().collect())
.unwrap_or_default()
}
fn b_slice(&self, b: usize) -> Vec<Q> {
self.cells
.iter()
.filter_map(|row| row.get(b))
.flatten()
.cloned()
.collect()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SsType {
TypeI,
TypeII,
TypeIII,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TwoWayAnova {
pub ss_type: SsType,
pub factor_a: AnovaRow,
pub factor_b: AnovaRow,
pub interaction: AnovaRow,
pub residual: AnovaRow,
pub total: AnovaRow,
pub grand_mean: Q,
pub cell_means: Vec<Vec<Q>>,
}
impl TwoWayAnova {
#[must_use]
pub fn rows(&self) -> Vec<&AnovaRow> {
vec![
&self.factor_a,
&self.factor_b,
&self.interaction,
&self.residual,
&self.total,
]
}
}
#[derive(Clone, Copy)]
struct Terms {
a: bool,
b: bool,
ab: bool,
}
#[derive(Clone, Copy)]
enum Coding {
Treatment,
Sum,
}
fn codes(level: usize, n_levels: usize, coding: Coding) -> Vec<Q> {
(1..n_levels)
.map(|i| match coding {
Coding::Treatment => {
if level == i {
Q::one()
} else {
Q::zero()
}
}
Coding::Sum => {
if level == i {
Q::one()
} else if level == 0 {
-Q::one()
} else {
Q::zero()
}
}
})
.collect()
}
fn reduced_rss(
op: &'static str,
data: &TwoWayData,
y: &[Q],
terms: Terms,
coding: Coding,
) -> Result<Q, SymplexError> {
let mut rows: Vec<Vec<Q>> = Vec::with_capacity(y.len());
for (a, row) in data.cells.iter().enumerate() {
let ca = codes(a, data.a_levels, coding);
for (b, cell) in row.iter().enumerate() {
let cb = codes(b, data.b_levels, coding);
let mut r = Vec::new();
if terms.a {
r.extend(ca.iter().cloned());
}
if terms.b {
r.extend(cb.iter().cloned());
}
if terms.ab {
for x in &ca {
for z in &cb {
r.push(x * z);
}
}
}
rows.extend(std::iter::repeat_n(r, cell.len()));
}
}
ols(y, &rows, true).map(|fit| fit.ssr).map_err(|e| {
failed(
op,
format!("least-squares fit of a reduced model failed: {e}"),
)
})
}
pub fn anova_two_way(ctx: &Context, data: &TwoWayData) -> Result<TwoWayAnova, SymplexError> {
anova_two_way_with(ctx, data, SsType::TypeII)
}
pub fn anova_two_way_with(
ctx: &Context,
data: &TwoWayData,
ss_type: SsType,
) -> Result<TwoWayAnova, SymplexError> {
const OP: &str = "anova_two_way";
let (a, b) = (data.a_levels, data.b_levels);
let n = data.n_obs();
if n <= a * b {
return Err(invalid(
OP,
"every cell has a single observation: no residual degrees of freedom",
));
}
let df_resid = n - a * b;
let y = data.flatten();
let grand_mean = data::mean(&y)?;
let ss_total = centred_ss(&y);
if ss_total.is_zero() {
return Err(invalid(OP, "the response is constant"));
}
let cell_means = data
.cells
.iter()
.map(|row| {
row.iter()
.map(|c| data::mean(c))
.collect::<Result<Vec<_>, _>>()
})
.collect::<Result<Vec<_>, _>>()?;
let rss_full = data
.cells
.iter()
.flatten()
.fold(Q::zero(), |acc, c| acc + centred_ss(c));
if rss_full.is_zero() {
return Err(invalid(
OP,
"the within-cell variance is zero: F is undefined",
));
}
let rss_a = (0..a).fold(Q::zero(), |acc, i| acc + centred_ss(&data.a_slice(i)));
let rss_b = (0..b).fold(Q::zero(), |acc, j| acc + centred_ss(&data.b_slice(j)));
let additive = Terms {
a: true,
b: true,
ab: false,
};
let rss_ab = reduced_rss(OP, data, &y, additive, Coding::Treatment)?;
let (ss_a, ss_b) = match ss_type {
SsType::TypeI => (&ss_total - &rss_a, &rss_a - &rss_ab),
SsType::TypeII => (&rss_b - &rss_ab, &rss_a - &rss_ab),
SsType::TypeIII => {
let without_a = Terms {
a: false,
b: true,
ab: true,
};
let without_b = Terms {
a: true,
b: false,
ab: true,
};
let rss_no_a = reduced_rss(OP, data, &y, without_a, Coding::Sum)?;
let rss_no_b = reduced_rss(OP, data, &y, without_b, Coding::Sum)?;
(rss_no_a - &rss_full, rss_no_b - &rss_full)
}
};
let ss_ab = &rss_ab - &rss_full;
Ok(TwoWayAnova {
ss_type,
factor_a: AnovaRow::effect(
ctx,
Source::FactorA,
ss_a,
a - 1,
&rss_full,
df_resid,
&ss_total,
),
factor_b: AnovaRow::effect(
ctx,
Source::FactorB,
ss_b,
b - 1,
&rss_full,
df_resid,
&ss_total,
),
interaction: AnovaRow::effect(
ctx,
Source::Interaction,
ss_ab,
(a - 1) * (b - 1),
&rss_full,
df_resid,
&ss_total,
),
residual: AnovaRow::untested(Source::Residual, rss_full, df_resid),
total: AnovaRow::total(ss_total, n - 1),
grand_mean,
cell_means,
})
}
#[derive(Clone, Debug, PartialEq)]
pub struct Mauchly {
pub w: Q,
pub chi_squared: Ex,
pub df: usize,
pub p_value: Ex,
}
impl Mauchly {
pub fn chi_squared_f64(&self) -> Result<f64, SymplexError> {
self.chi_squared.eval_f64()
}
pub fn p_value_f64(&self) -> Result<f64, SymplexError> {
self.p_value.eval_f64()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct RepeatedMeasuresAnova {
pub n_subjects: usize,
pub n_conditions: usize,
pub conditions: AnovaRow,
pub subjects: AnovaRow,
pub error: AnovaRow,
pub total: AnovaRow,
pub f: Q,
pub p_value: Ex,
pub epsilon_gg: Q,
pub epsilon_hf: Option<Q>,
pub p_value_gg: Ex,
pub p_value_hf: Option<Ex>,
pub mauchly: Option<Mauchly>,
pub grand_mean: Q,
pub condition_means: Vec<Q>,
pub subject_means: Vec<Q>,
}
impl RepeatedMeasuresAnova {
pub fn p_value_f64(&self) -> Result<f64, SymplexError> {
self.p_value.eval_f64()
}
pub fn p_value_gg_f64(&self) -> Result<f64, SymplexError> {
self.p_value_gg.eval_f64()
}
pub fn p_value_hf_f64(&self) -> Result<Option<f64>, SymplexError> {
self.p_value_hf.as_ref().map(Ex::eval_f64).transpose()
}
#[must_use]
pub fn rows(&self) -> Vec<&AnovaRow> {
vec![&self.conditions, &self.subjects, &self.error, &self.total]
}
}
fn mauchly(
ctx: &Context,
s: &[Vec<Q>],
trace: &Q,
n: usize,
k: usize,
) -> Result<Option<Mauchly>, SymplexError> {
const OP: &str = "anova_repeated_measures";
let d = k - 1;
let m = QMatrix::new(
(0..k)
.map(|row| {
(0..d)
.map(|j| {
if row == j {
Q::one()
} else if row == j + 1 {
-Q::one()
} else {
Q::zero()
}
})
.collect()
})
.collect(),
)
.map_err(|e| failed(OP, e.to_string()))?;
let s_mat = QMatrix::new(s.to_vec()).map_err(|e| failed(OP, e.to_string()))?;
let mt = m.transpose();
let mtsm = mt.matmul(&s_mat)?.matmul(&m)?;
let mtm = mt.matmul(&m)?;
let product = mtsm.det()? / mtm.det()?;
if !product.is_positive() {
return Ok(None);
}
let mean_eigenvalue = trace / qu(d);
let denominator = (0..d).fold(Q::one(), |acc, _| acc * &mean_eigenvalue);
let w = product / denominator;
let (dq, n1) = (qu(d), qu(n - 1));
let rho = Q::one() - (qi(2) * &dq * &dq + &dq + qi(2)) / (qi(6) * &dq * &n1);
if !rho.is_positive() {
return Ok(None);
}
let scale = &n1 * &dq * ρ
let omega2 = (&dq + qi(2))
* (&dq - qi(1))
* (&dq - qi(2))
* (qi(2) * &dq * &dq * &dq + qi(6) * &dq * &dq + qi(3) * &dq + qi(2))
/ (qi(288) * &scale * &scale);
let df = d * (d + 1) / 2 - 1;
let chi_squared = ex(ctx, &(-(&n1 * &rho))) * ex(ctx, &w).ln();
let p1 = chi_squared_sf(ctx, df, &chi_squared);
let p2 = chi_squared_sf(ctx, df + 4, &chi_squared);
let p_value = &p1 + ex(ctx, &omega2) * (p2 - &p1);
Ok(Some(Mauchly {
w,
chi_squared,
df,
p_value,
}))
}
pub fn anova_repeated_measures(
ctx: &Context,
subjects_by_condition: &[Vec<Q>],
) -> Result<RepeatedMeasuresAnova, SymplexError> {
const OP: &str = "anova_repeated_measures";
let n = subjects_by_condition.len();
if n < 2 {
return Err(invalid(OP, "at least two subjects are needed"));
}
let k = subjects_by_condition.first().map_or(0, Vec::len);
if k < 2 {
return Err(invalid(OP, "at least two conditions are needed"));
}
if let Some((i, row)) = subjects_by_condition
.iter()
.enumerate()
.find(|(_, r)| r.len() != k)
{
return Err(invalid(
OP,
format!(
"subject {i} has {} observations, expected {k} (one per condition)",
row.len()
),
));
}
let all: Vec<Q> = subjects_by_condition.iter().flatten().cloned().collect();
let grand_mean = data::mean(&all)?;
let ss_total = centred_ss(&all);
let condition_means: Vec<Q> = (0..k)
.map(|j| {
subjects_by_condition
.iter()
.fold(Q::zero(), |acc, r| acc + &r[j])
/ qu(n)
})
.collect();
let subject_means: Vec<Q> = subjects_by_condition
.iter()
.map(|r| data::sum(r) / qu(k))
.collect();
let sq = |x: &Q| x * x;
let ss_conditions = qu(n)
* condition_means
.iter()
.fold(Q::zero(), |acc, m| acc + sq(&(m - &grand_mean)));
let ss_subjects = qu(k)
* subject_means
.iter()
.fold(Q::zero(), |acc, m| acc + sq(&(m - &grand_mean)));
let ss_error = &ss_total - &ss_conditions - &ss_subjects;
if !ss_error.is_positive() {
return Err(invalid(
OP,
"the error variance is zero (every subject's profile is a shift of the condition means): F is undefined",
));
}
let (df_c, df_s, df_e) = (k - 1, n - 1, (k - 1) * (n - 1));
let test = FTest::of(ctx, &ss_conditions, df_c, &ss_error, df_e);
let (f, p_value) = (test.f.clone(), test.p_value.clone());
let conditions = AnovaRow::with_test(
Source::Conditions,
ss_conditions,
df_c,
test,
&ss_error,
&ss_total,
);
let s: Vec<Vec<Q>> = (0..k)
.map(|a| {
(0..k)
.map(|b| {
subjects_by_condition.iter().fold(Q::zero(), |acc, r| {
acc + (&r[a] - &condition_means[a]) * (&r[b] - &condition_means[b])
}) / qu(n - 1)
})
.collect()
})
.collect();
let row_means: Vec<Q> = s.iter().map(|r| data::sum(r) / qu(k)).collect();
let all_mean = data::sum(&row_means) / qu(k);
let s_tilde: Vec<Vec<Q>> = (0..k)
.map(|a| {
(0..k)
.map(|b| &s[a][b] - &row_means[a] - &row_means[b] + &all_mean)
.collect()
})
.collect();
let trace = (0..k).fold(Q::zero(), |acc, a| acc + &s_tilde[a][a]);
let trace_sq = s_tilde
.iter()
.flatten()
.fold(Q::zero(), |acc, v| acc + sq(v));
if !trace_sq.is_positive() {
return Err(failed(
OP,
"the double-centred covariance vanished although SS_error > 0",
));
}
let epsilon_gg = sq(&trace) / (qu(df_c) * &trace_sq);
let hf_denominator = qu(df_c) * (qu(df_s) - qu(df_c) * &epsilon_gg);
let epsilon_hf = hf_denominator
.is_positive()
.then(|| (qu(n) * qu(df_c) * &epsilon_gg - qi(2)) / hf_denominator);
let corrected = |eps: &Q| f_sf(ctx, &(qu(df_c) * eps), &(qu(df_e) * eps), &f);
let p_value_gg = corrected(&epsilon_gg);
let one = Q::one();
let p_value_hf = epsilon_hf
.as_ref()
.map(|e| corrected(if *e > one { &one } else { e }));
let mauchly = if k >= 3 {
mauchly(ctx, &s, &trace, n, k)?
} else {
None
};
Ok(RepeatedMeasuresAnova {
n_subjects: n,
n_conditions: k,
conditions,
subjects: AnovaRow::untested(Source::Subjects, ss_subjects, df_s),
error: AnovaRow::untested(Source::Residual, ss_error, df_e),
total: AnovaRow::total(ss_total, n * k - 1),
f,
p_value,
epsilon_gg,
epsilon_hf,
p_value_gg,
p_value_hf,
mauchly,
grand_mean,
condition_means,
subject_means,
})
}
const SQRT_2PI: f64 = 2.506_628_274_631_000_5;
struct GaussLegendre {
nodes: Vec<f64>,
weights: Vec<f64>,
}
impl GaussLegendre {
fn new(m: usize) -> Self {
let mut nodes = Vec::with_capacity(m);
let mut weights = Vec::with_capacity(m);
for i in 0..m {
let mut x = (PI * (i as f64 + 0.75) / (m as f64 + 0.5)).cos();
let mut dp = 1.0;
for _ in 0..100 {
let (p, d) = legendre(m, x);
dp = d;
let step = p / d;
x -= step;
if step.abs() < 1e-15 {
break;
}
}
nodes.push(x);
weights.push(2.0 / ((1.0 - x * x) * dp * dp));
}
Self { nodes, weights }
}
fn integrate(&self, f: &dyn Fn(f64) -> f64, a: f64, b: f64, panels: usize) -> f64 {
let panels = panels.max(1);
let h = (b - a) / panels as f64;
let half = 0.5 * h;
let mut total = 0.0;
for p in 0..panels {
let mid = a + (p as f64 + 0.5) * h;
let mut acc = 0.0;
for (x, w) in self.nodes.iter().zip(&self.weights) {
acc += w * f(mid + half * x);
}
total += half * acc;
}
total
}
}
fn legendre(m: usize, x: f64) -> (f64, f64) {
let mut p0 = 1.0;
let mut p1 = x;
for j in 2..=m {
let jf = j as f64;
let p2 = ((2.0 * jf - 1.0) * x * p1 - (jf - 1.0) * p0) / jf;
p0 = p1;
p1 = p2;
}
let dp = m as f64 * (x * p1 - p0) / (x * x - 1.0);
(p1, dp)
}
fn normal_cdf(x: f64) -> f64 {
0.5 * erfc(-x / SQRT_2)
}
fn normal_pdf(x: f64) -> f64 {
(-0.5 * x * x).exp() / SQRT_2PI
}
fn normal_range_cdf(w: f64, k: usize, rule: &GaussLegendre) -> f64 {
if w <= 0.0 {
return 0.0;
}
let power = (k - 1) as i32;
let f = |z: f64| normal_pdf(z) * (normal_cdf(z + w) - normal_cdf(z)).powi(power);
let panel_width = (3.0 / (k as f64).sqrt() * 1.5).min(3.0);
let panels = (18.0 / panel_width).ceil() as usize;
k as f64 * rule.integrate(&f, -9.0, 9.0, panels)
}
fn studentized_range_cdf_impl(q: f64, k: usize, nu: f64) -> f64 {
if q <= 0.0 {
return 0.0;
}
let rule = GaussLegendre::new(16);
let log_c = 0.5 * nu * nu.ln() - lgamma(0.5 * nu) - (0.5 * nu - 1.0) * std::f64::consts::LN_2;
let density = |s: f64| (log_c + (nu - 1.0) * s.ln() - 0.5 * nu * s * s).exp();
let f = |s: f64| density(s) * normal_range_cdf(q * s, k, &rule);
let sigma = 1.0 / (2.0 * nu).sqrt();
let s_lo = (1.0 - 12.0 * sigma).max(0.0);
let s_hi = 1.0 + 12.0 * sigma;
let panel_width = (3.0 * sigma).min(3.0 / q);
let panels = ((s_hi - s_lo) / panel_width).ceil() as usize;
rule.integrate(&f, s_lo, s_hi, panels.clamp(1, 400))
.clamp(0.0, 1.0)
}
fn check_studentized_range_args(op: &'static str, k: usize, df: f64) -> Result<(), SymplexError> {
if k < 2 {
return Err(invalid(op, format!("k must be at least 2, got {k}")));
}
if !(df.is_finite() && df >= 1.0) {
return Err(invalid(
op,
format!("the degrees of freedom must be finite and at least 1, got {df}"),
));
}
Ok(())
}
pub fn studentized_range_cdf(q: f64, k: usize, df: f64) -> Result<f64, SymplexError> {
const OP: &str = "studentized_range_cdf";
check_studentized_range_args(OP, k, df)?;
if q.is_nan() {
return Err(invalid(OP, "q must not be NaN"));
}
Ok(studentized_range_cdf_impl(q, k, df))
}
pub fn studentized_range_sf(q: f64, k: usize, df: f64) -> Result<f64, SymplexError> {
studentized_range_cdf(q, k, df).map(|c| 1.0 - c)
}
pub fn studentized_range_quantile(p: f64, k: usize, df: f64) -> Result<f64, SymplexError> {
const OP: &str = "studentized_range_quantile";
check_studentized_range_args(OP, k, df)?;
if !(p > 0.0 && p < 1.0) {
return Err(invalid(
OP,
format!("p must lie strictly between 0 and 1, got {p}"),
));
}
let g = |q: f64| studentized_range_cdf_impl(q, k, df) - p;
let mut hi = 2.0;
let mut steps = 0;
while g(hi) < 0.0 {
hi *= 2.0;
steps += 1;
if steps > 20 {
return Err(failed(OP, "no bracket for the studentized range quantile"));
}
}
let opts = RootOpts {
xtol: 1e-10,
..RootOpts::default()
};
let root = brent_root(g, 0.0, hi, &opts).map_err(|e| failed(OP, e.to_string()))?;
if root.is_finite() {
Ok(root)
} else {
Err(failed(
OP,
"the studentized range quantile did not converge",
))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct PairwiseComparison {
pub i: usize,
pub j: usize,
pub diff: Q,
pub se: Ex,
pub statistic: Ex,
pub p_adj: f64,
pub ci: Interval<f64>,
}
pub fn tukey_hsd(
ctx: &Context,
groups: &[Vec<Q>],
confidence: f64,
) -> Result<Vec<PairwiseComparison>, SymplexError> {
const OP: &str = "tukey_hsd";
check_confidence(OP, confidence)?;
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 n: usize = groups.iter().map(Vec::len).sum();
if n <= k {
return Err(invalid(
OP,
"at least one group needs more than one observation",
));
}
let df = n - k;
let means = groups
.iter()
.map(|g| data::mean(g))
.collect::<Result<Vec<_>, _>>()?;
let ss_within = groups.iter().fold(Q::zero(), |acc, g| acc + centred_ss(g));
if ss_within.is_zero() {
return Err(invalid(OP, "the within-group variance is zero"));
}
let mse = ss_within / qu(df);
let df_f = df as f64;
let q_crit = studentized_range_quantile(confidence, k, df_f)?;
let mut out = Vec::with_capacity(k * (k - 1) / 2);
for i in 0..k {
for j in i + 1..k {
let diff = &means[i] - &means[j];
let var = &mse / qi(2) * (qu(groups[i].len()).recip() + qu(groups[j].len()).recip());
let se = ex(ctx, &var).sqrt();
let statistic = ex(ctx, &diff.abs()) / &se;
let (diff_f, se_f) = (to_f64(OP, &diff)?, to_f64(OP, &var)?.sqrt());
let stat_f = to_f64(OP, &(&diff * &diff / &var))?.sqrt();
let p_adj = (1.0 - studentized_range_cdf_impl(stat_f, k, df_f)).max(0.0);
out.push(PairwiseComparison {
i,
j,
diff,
se,
statistic,
p_adj,
ci: Interval::closed(diff_f - q_crit * se_f, diff_f + q_crit * se_f),
});
}
}
Ok(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Adjustment {
Bonferroni,
Holm,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PairwiseTTest {
pub i: usize,
pub j: usize,
pub diff: Q,
pub test: TestResult,
pub p_adj: f64,
pub reject: bool,
}
pub fn pairwise_t_tests(
ctx: &Context,
groups: &[Vec<Q>],
adjustment: Adjustment,
alpha: f64,
) -> Result<Vec<PairwiseTTest>, SymplexError> {
const OP: &str = "pairwise_t_tests";
let k = groups.len();
if k < 2 {
return Err(invalid(OP, "at least two groups are needed"));
}
let mut pairs = Vec::with_capacity(k * (k - 1) / 2);
let mut p_values = Vec::with_capacity(k * (k - 1) / 2);
for i in 0..k {
for j in i + 1..k {
let test = hypothesis::t_test_two_sample(
ctx,
&groups[i],
&groups[j],
false,
Alternative::TwoSided,
)?;
p_values.push(test.p_value_f64()?);
let diff = data::mean(&groups[i])? - data::mean(&groups[j])?;
pairs.push((i, j, diff, test));
}
}
let adjusted = match adjustment {
Adjustment::Bonferroni => hypothesis::bonferroni(&p_values, alpha)?,
Adjustment::Holm => hypothesis::holm(&p_values, alpha)?,
};
Ok(pairs
.into_iter()
.zip(adjusted.p_adjusted)
.zip(adjusted.reject)
.map(|(((i, j, diff, test), p_adj), reject)| PairwiseTTest {
i,
j,
diff,
test,
p_adj,
reject,
})
.collect())
}