use std::f64::consts::PI;
use num_traits::{One, Signed, Zero};
use super::common::{
check_confidence, check_unit_open, chi_squared_sf, ex, f_sf, f_sf_rational, invalid, qi, qu,
};
use super::data::{self, Q};
use super::hypothesis::{self, Alternative, PValue, TestResult, p_value_accessors};
use super::numdist::norm::{cdf as norm_cdf, pdf as norm_pdf};
use super::regression::ols;
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::errors::SymplexError;
use crate::base::interval::{Bounds, Interval};
use crate::base::numeric::ratio_to_f64;
use crate::domains::exact_matrix::QMatrix;
use crate::domains::optimize::{RootOpts, brent_root, grow_bracket};
use crate::output::codegen::numeric_rt::lgamma;
fn failed(op: &'static str, reason: impl Into<String>) -> SymplexError {
SymplexError::computation_failed(op, reason)
}
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())
}
pub(crate) struct GroupSums {
pub(crate) ss_between: Q,
pub(crate) ss_within: Q,
pub(crate) n: usize,
pub(crate) k: usize,
}
pub(crate) fn sums_of_squares(
op: &'static str,
groups: &[Vec<Q>],
) -> Result<GroupSums, 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(GroupSums {
ss_between,
ss_within,
n: all.len(),
k,
})
}
#[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()
}
}
impl PValue for AnovaResult {
fn p_value_ex(&self) -> &Ex {
&self.p_value
}
}
p_value_accessors!(AnovaResult);
pub fn anova_one_way(ctx: &Context, groups: &[Vec<Q>]) -> Result<AnovaResult, SymplexError> {
const OP: &str = "anova_one_way";
let GroupSums {
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,
})
}
#[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> {
self.tested_p_value("AnovaRow::p_value_f64")?.eval_f64()
}
pub fn p_value_log10(&self) -> Result<f64, SymplexError> {
hypothesis::p_value_log10_of(self.tested_p_value("AnovaRow::p_value_log10")?)
}
pub fn p_value_ln(&self) -> Result<f64, SymplexError> {
hypothesis::p_value_ln_of(self.tested_p_value("AnovaRow::p_value_ln")?)
}
pub fn p_value_decimal(&self, digits: u32) -> Result<String, SymplexError> {
self.tested_p_value("AnovaRow::p_value_decimal")?
.eval_decimal(digits)
}
fn tested_p_value(&self, op: &'static str) -> Result<&Ex, SymplexError> {
self.p_value
.as_ref()
.ok_or_else(|| invalid(op, 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, df, 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()
}
}
impl PValue for Mauchly {
fn p_value_ex(&self) -> &Ex {
&self.p_value
}
}
p_value_accessors!(Mauchly);
#[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()
}
pub fn p_value_gg_log10(&self) -> Result<f64, SymplexError> {
hypothesis::p_value_log10_of(&self.p_value_gg)
}
pub fn p_value_hf_log10(&self) -> Result<Option<f64>, SymplexError> {
self.p_value_hf
.as_ref()
.map(hypothesis::p_value_log10_of)
.transpose()
}
#[must_use]
pub fn rows(&self) -> Vec<&AnovaRow> {
vec![&self.conditions, &self.subjects, &self.error, &self.total]
}
}
impl PValue for RepeatedMeasuresAnova {
fn p_value_ex(&self) -> &Ex {
&self.p_value
}
}
p_value_accessors!(RepeatedMeasuresAnova);
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_rational(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,
})
}
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_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| norm_pdf(z) * (norm_cdf(z + w) - norm_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 stirling_remainder(x: f64) -> f64 {
if x < 10.0 {
lgamma(x) - ((x - 0.5) * x.ln() - x + 0.5 * (2.0 * PI).ln())
} else {
let x2 = x * x;
(1.0 / 12.0
- (1.0 / 360.0 - (1.0 / 1260.0 - (1.0 / 1680.0 - 1.0 / (1188.0 * x2)) / x2) / x2) / x2)
/ x
}
}
fn ln1p_minus_u(u: f64) -> f64 {
if u.abs() >= 0.25 {
return u.ln_1p() - u;
}
let mut term = u * u;
let mut sum = 0.0;
for n in 2..=40 {
let contribution = term / n as f64;
sum += if n % 2 == 0 {
-contribution
} else {
contribution
};
term *= u;
if contribution.abs() <= 1e-18 * sum.abs() {
break;
}
}
sum
}
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 x = 0.5 * nu;
let log_prefactor =
std::f64::consts::LN_2 + 0.5 * (x / (2.0 * PI)).ln() - stirling_remainder(x);
let log_density = |u: f64| log_prefactor + 2.0 * x * ln1p_minus_u(u) - u.ln_1p() - x * u * u;
let f = |u: f64| log_density(u).exp() * normal_range_cdf(q * (1.0 + u), k, &rule);
let sigma = 1.0 / (2.0 * nu).sqrt();
let u_lo = (-12.0 * sigma).max(-1.0);
let u_hi = 12.0 * sigma;
let panel_width = (3.0 * sigma).min(3.0 / q);
let panels = ((u_hi - u_lo) / panel_width).ceil() as usize;
rule.integrate(&f, u_lo, u_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)?;
check_unit_open(OP, "p", p)?;
let g = |q: f64| studentized_range_cdf_impl(q, k, df) - p;
let bracket = grow_bracket(g, 0.0, 2.0, Bounds::at_least(0.0), 21).map_err(|e| {
failed(
OP,
format!("no bracket for the studentized range quantile: {e}"),
)
})?;
let opts = RootOpts {
xtol: 1e-10,
..RootOpts::default()
};
let root = brent_root(g, bracket.lower, bracket.upper, &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())
}