use num_bigint::BigInt;
use num_traits::{One, Signed, Zero};
use super::agreement::{RatingTable, confusion_matrix};
use super::data::{self, Ddof, Q};
use super::hypothesis::{Alternative, TestResult};
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::errors::SymplexError;
use crate::output::codegen::numeric_rt::erfcinv;
fn invalid(op: &'static str, reason: impl Into<String>) -> SymplexError {
SymplexError::invalid_argument(op, reason)
}
fn qi(n: i64) -> Q {
Q::from_integer(BigInt::from(n))
}
fn qu(n: usize) -> Q {
Q::from_integer(BigInt::from(n))
}
fn ex(ctx: &Context, q: &Q) -> Ex {
ctx.from_ratio(q.clone())
}
fn exu(ctx: &Context, n: usize) -> Ex {
ex(ctx, &qu(n))
}
fn sum(values: impl IntoIterator<Item = Q>) -> Q {
values.into_iter().fold(Q::zero(), |acc, x| acc + x)
}
fn square(x: &Q) -> Q {
x * x
}
fn q_to_f64(q: &Q) -> f64 {
data::to_f64(std::slice::from_ref(q))
.first()
.copied()
.unwrap_or(f64::NAN)
}
fn norm_isf(alpha: f64) -> f64 {
std::f64::consts::SQRT_2 * erfcinv(2.0 * alpha)
}
fn check_confidence(op: &'static str, confidence: f64) -> Result<(), SymplexError> {
if confidence > 0.0 && confidence < 1.0 {
Ok(())
} else {
Err(invalid(
op,
format!("the confidence level must lie strictly between 0 and 1, got {confidence}"),
))
}
}
fn check_same_len(op: &'static str, x: &[Q], y: &[Q]) -> Result<(), SymplexError> {
if x.len() != y.len() {
return Err(invalid(
op,
format!(
"the two variables must have the same length ({} and {})",
x.len(),
y.len()
),
));
}
Ok(())
}
fn check_dichotomous<'a>(
op: &'static str,
values: impl IntoIterator<Item = &'a Q>,
what: &str,
) -> Result<(), SymplexError> {
for v in values {
if !(v.is_zero() || v.is_one()) {
return Err(invalid(
op,
format!("{what} must be scored 0 or 1, found {v}"),
));
}
}
Ok(())
}
fn pearson_of(
ctx: &Context,
op: &'static str,
x: &[Q],
y: &[Q],
what: &str,
) -> Result<Ex, SymplexError> {
data::pearson(ctx, x, y).map_err(|_| {
invalid(
op,
format!("{what} is constant, so its correlation is undefined"),
)
})
}
fn chi_squared_sf(ctx: &Context, df: usize, x: &Q) -> Ex {
if !x.is_positive() {
return ctx.one();
}
let half_df = exu(ctx, df) / ctx.int(2);
(ex(ctx, x) / ctx.int(2)).uppergamma(&half_df) / half_df.gamma()
}
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 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 normal_test(ctx: &Context, num: &Q, var: &Q, alt: Alternative) -> TestResult {
let statistic = (ex(ctx, num) / ex(ctx, var).sqrt()).simplify();
let half_z2 = square(num) / var / qi(2);
let two_sided = ex(ctx, &half_z2).sqrt().erfc();
let tail = in_tail(num.is_negative(), num.is_positive(), alt);
TestResult {
statistic,
p_value: one_sided_from_symmetric(ctx, two_sided, tail, alt),
df: None,
alternative: alt,
}
}
fn score_matrix(op: &'static str, table: &RatingTable) -> Result<Vec<Vec<Q>>, SymplexError> {
table
.rows()
.iter()
.enumerate()
.map(|(i, r)| {
r.iter()
.cloned()
.map(|c| {
c.ok_or_else(|| {
invalid(
op,
format!(
"respondent {i} has a missing item score (drop incomplete rows first)"
),
)
})
})
.collect()
})
.collect()
}
fn complete_score_rows(table: &RatingTable) -> Vec<Vec<Q>> {
table
.rows()
.iter()
.filter_map(|r| r.iter().cloned().collect::<Option<Vec<Q>>>())
.collect()
}
fn columns(rows: &[Vec<Q>]) -> Vec<Vec<Q>> {
let k = rows.first().map_or(0, Vec::len);
(0..k)
.map(|j| rows.iter().map(|r| r[j].clone()).collect())
.collect()
}
fn row_sums(rows: &[Vec<Q>]) -> Vec<Q> {
rows.iter().map(|r| data::sum(r)).collect()
}
fn check_scale(
op: &'static str,
rows: &[Vec<Q>],
min_items: usize,
) -> Result<(usize, usize), SymplexError> {
let n = rows.len();
let k = rows.first().map_or(0, Vec::len);
if n < 2 {
return Err(invalid(
op,
format!("needs at least two complete respondents (rows), got {n}"),
));
}
if k < min_items {
return Err(invalid(
op,
format!("needs at least {min_items} items (columns), got {k}"),
));
}
Ok((n, k))
}
fn alpha_of_rows(op: &'static str, rows: &[Vec<Q>]) -> Result<Q, SymplexError> {
let (_, k) = check_scale(op, rows, 2)?;
let total_var = data::variance(&row_sums(rows), Ddof::Sample)?;
if total_var.is_zero() {
return Err(invalid(
op,
"every respondent has the same total score (zero total variance), α is undefined",
));
}
let item_var = columns(rows)
.iter()
.map(|c| data::variance(c, Ddof::Sample))
.collect::<Result<Vec<Q>, SymplexError>>()?;
Ok(qu(k) / qu(k - 1) * (Q::one() - sum(item_var) / total_var))
}
pub fn cronbach_alpha(table: &RatingTable) -> Result<Q, SymplexError> {
const OP: &str = "cronbach_alpha";
alpha_of_rows(OP, &score_matrix(OP, table)?)
}
pub fn cronbach_alpha_complete(table: &RatingTable) -> Result<Q, SymplexError> {
alpha_of_rows("cronbach_alpha_complete", &complete_score_rows(table))
}
fn inter_item_correlations(
ctx: &Context,
op: &'static str,
rows: &[Vec<Q>],
) -> Result<Vec<Ex>, SymplexError> {
let (_, k) = check_scale(op, rows, 2)?;
let items = columns(rows);
let mut out = Vec::with_capacity(k * (k - 1) / 2);
for i in 0..k {
for j in i + 1..k {
out.push(pearson_of(
ctx,
op,
&items[i],
&items[j],
&format!("item {i} or item {j}"),
)?);
}
}
Ok(out)
}
fn mean_ex(ctx: &Context, values: Vec<Ex>) -> Ex {
let m = values.len();
let total = values.into_iter().fold(ctx.zero(), |acc, r| acc + r);
total / exu(ctx, m)
}
pub fn average_inter_item_correlation(
ctx: &Context,
table: &RatingTable,
) -> Result<Ex, SymplexError> {
const OP: &str = "average_inter_item_correlation";
let rows = score_matrix(OP, table)?;
Ok(mean_ex(ctx, inter_item_correlations(ctx, OP, &rows)?))
}
pub fn standardized_alpha(ctx: &Context, table: &RatingTable) -> Result<Ex, SymplexError> {
const OP: &str = "standardized_alpha";
let rows = score_matrix(OP, table)?;
let k = rows[0].len();
let r = mean_ex(ctx, inter_item_correlations(ctx, OP, &rows)?);
Ok(spearman_brown(ctx, &r, k))
}
pub fn kr20(table: &RatingTable) -> Result<Q, SymplexError> {
const OP: &str = "kr20";
let rows = score_matrix(OP, table)?;
check_dichotomous(OP, rows.iter().flatten(), "every item")?;
let (_, k) = check_scale(OP, &rows, 2)?;
let total_var = data::variance(&row_sums(&rows), Ddof::Population)?;
if total_var.is_zero() {
return Err(invalid(
OP,
"every respondent has the same total score (zero total variance), KR-20 is undefined",
));
}
let pq = columns(&rows)
.iter()
.map(|c| {
let p = data::mean(c)?;
Ok(&p * (Q::one() - &p))
})
.collect::<Result<Vec<Q>, SymplexError>>()?;
Ok(qu(k) / qu(k - 1) * (Q::one() - sum(pq) / total_var))
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SplitHalf {
OddEven,
FirstLast,
Custom(Vec<bool>),
}
impl SplitHalf {
fn mask(&self, op: &'static str, k: usize) -> Result<Vec<bool>, SymplexError> {
let mask: Vec<bool> = match self {
SplitHalf::OddEven => (0..k).map(|i| i % 2 == 0).collect(),
SplitHalf::FirstLast => (0..k).map(|i| i < k / 2).collect(),
SplitHalf::Custom(m) => {
if m.len() != k {
return Err(invalid(
op,
format!(
"the split has {} flags but the table has {k} items",
m.len()
),
));
}
m.clone()
}
};
let first = mask.iter().filter(|&&b| b).count();
if first == 0 || first == k {
return Err(invalid(op, "both halves must contain at least one item"));
}
Ok(mask)
}
}
fn split_totals(rows: &[Vec<Q>], mask: &[bool]) -> (Vec<Q>, Vec<Q>) {
rows.iter()
.map(|r| {
let mut first = Q::zero();
let mut second = Q::zero();
for (x, &in_first) in r.iter().zip(mask) {
if in_first {
first += x;
} else {
second += x;
}
}
(first, second)
})
.unzip()
}
pub fn split_half_correlation(
ctx: &Context,
table: &RatingTable,
split: &SplitHalf,
) -> Result<Ex, SymplexError> {
const OP: &str = "split_half_correlation";
let rows = score_matrix(OP, table)?;
let (_, k) = check_scale(OP, &rows, 2)?;
let mask = split.mask(OP, k)?;
let (h1, h2) = split_totals(&rows, &mask);
pearson_of(ctx, OP, &h1, &h2, "one of the half-scale totals")
}
pub fn split_half(
ctx: &Context,
table: &RatingTable,
split: &SplitHalf,
) -> Result<Ex, SymplexError> {
let r = split_half_correlation(ctx, table, split)?;
Ok(spearman_brown(ctx, &r, 2))
}
pub fn spearman_brown(ctx: &Context, r: &Ex, k: usize) -> Ex {
let kq = exu(ctx, k);
(&kq * r / (ctx.one() + (kq - ctx.one()) * r)).simplify()
}
pub fn alpha_if_deleted(table: &RatingTable) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "alpha_if_deleted";
let rows = score_matrix(OP, table)?;
let (_, k) = check_scale(OP, &rows, 3)?;
(0..k)
.map(|j| {
let reduced: Vec<Vec<Q>> = rows
.iter()
.map(|r| {
r.iter()
.enumerate()
.filter(|&(i, _)| i != j)
.map(|(_, x)| x.clone())
.collect()
})
.collect();
alpha_of_rows(OP, &reduced)
})
.collect()
}
pub fn guttman_lambda2(ctx: &Context, table: &RatingTable) -> Result<Ex, SymplexError> {
const OP: &str = "guttman_lambda2";
let rows = score_matrix(OP, table)?;
let (_, k) = check_scale(OP, &rows, 2)?;
let total_var = data::variance(&row_sums(&rows), Ddof::Sample)?;
if total_var.is_zero() {
return Err(invalid(
OP,
"every respondent has the same total score (zero total variance), λ₂ is undefined",
));
}
let items = columns(&rows);
let mut trace = Q::zero();
let mut off = Q::zero();
for i in 0..k {
trace += data::variance(&items[i], Ddof::Sample)?;
for j in 0..k {
if i != j {
off += square(&data::covariance(&items[i], &items[j], Ddof::Sample)?);
}
}
}
let root = ex(ctx, &(qu(k) / qu(k - 1) * off)).sqrt();
Ok(((ex(ctx, &(&total_var - trace)) + root) / ex(ctx, &total_var)).simplify())
}
pub fn total_scores(table: &RatingTable) -> Result<Vec<Q>, SymplexError> {
Ok(row_sums(&score_matrix("total_scores", table)?))
}
pub fn item_difficulty(table: &RatingTable) -> Result<Vec<Q>, SymplexError> {
let rows = score_matrix("item_difficulty", table)?;
columns(&rows).iter().map(|c| data::mean(c)).collect()
}
fn extreme_groups(
op: &'static str,
rows: &[Vec<Q>],
) -> Result<(Vec<usize>, Vec<usize>), SymplexError> {
let n = rows.len();
let g = n / 3;
if g == 0 {
return Err(invalid(
op,
format!("the thirds rule needs at least three respondents, got {n}"),
));
}
let totals = row_sums(rows);
let mut asc: Vec<usize> = (0..n).collect();
asc.sort_by(|&a, &b| totals[a].cmp(&totals[b]));
let mut desc: Vec<usize> = (0..n).collect();
desc.sort_by(|&a, &b| totals[b].cmp(&totals[a]));
Ok((desc[..g].to_vec(), asc[..g].to_vec()))
}
fn group_mean(rows: &[Vec<Q>], group: &[usize], j: usize) -> Result<Q, SymplexError> {
let v: Vec<Q> = group.iter().map(|&i| rows[i][j].clone()).collect();
data::mean(&v)
}
pub fn item_discrimination_index(table: &RatingTable) -> Result<Vec<Q>, SymplexError> {
const OP: &str = "item_discrimination_index";
let rows = score_matrix(OP, table)?;
let (upper, lower) = extreme_groups(OP, &rows)?;
let k = rows[0].len();
(0..k)
.map(|j| Ok(group_mean(&rows, &upper, j)? - group_mean(&rows, &lower, j)?))
.collect()
}
pub fn point_biserial(ctx: &Context, item: &[Q], total: &[Q]) -> Result<Ex, SymplexError> {
const OP: &str = "point_biserial";
check_same_len(OP, item, total)?;
if item.len() < 2 {
return Err(invalid(OP, "needs at least two respondents"));
}
check_dichotomous(OP, item, "the item")?;
pearson_of(ctx, OP, item, total, "the item or the score")
}
pub fn item_total_correlation(ctx: &Context, table: &RatingTable) -> Result<Vec<Ex>, SymplexError> {
const OP: &str = "item_total_correlation";
let rows = score_matrix(OP, table)?;
check_scale(OP, &rows, 2)?;
let totals = row_sums(&rows);
columns(&rows)
.iter()
.enumerate()
.map(|(j, c)| pearson_of(ctx, OP, c, &totals, &format!("item {j} or the total")))
.collect()
}
pub fn corrected_item_total_correlation(
ctx: &Context,
table: &RatingTable,
) -> Result<Vec<Ex>, SymplexError> {
const OP: &str = "corrected_item_total_correlation";
let rows = score_matrix(OP, table)?;
check_scale(OP, &rows, 2)?;
let totals = row_sums(&rows);
columns(&rows)
.iter()
.enumerate()
.map(|(j, c)| {
let rest: Vec<Q> = totals.iter().zip(c).map(|(t, x)| t - x).collect();
pearson_of(
ctx,
OP,
c,
&rest,
&format!("item {j} or the rest of the scale"),
)
})
.collect()
}
#[derive(Clone, Debug, PartialEq)]
pub struct ItemSummary {
pub difficulty: Q,
pub discrimination: Q,
pub item_total: Option<Ex>,
pub corrected_item_total: Option<Ex>,
pub alpha_if_deleted: Option<Q>,
}
pub fn item_response_summary(
ctx: &Context,
table: &RatingTable,
) -> Result<Vec<ItemSummary>, SymplexError> {
const OP: &str = "item_response_summary";
let rows = score_matrix(OP, table)?;
check_scale(OP, &rows, 2)?;
let (upper, lower) = extreme_groups(OP, &rows)?;
let totals = row_sums(&rows);
let deleted: Option<Vec<Q>> = alpha_if_deleted(table).ok();
columns(&rows)
.iter()
.enumerate()
.map(|(j, c)| {
let rest: Vec<Q> = totals.iter().zip(c).map(|(t, x)| t - x).collect();
Ok(ItemSummary {
difficulty: data::mean(c)?,
discrimination: group_mean(&rows, &upper, j)? - group_mean(&rows, &lower, j)?,
item_total: data::pearson(ctx, c, &totals).ok(),
corrected_item_total: data::pearson(ctx, c, &rest).ok(),
alpha_if_deleted: deleted.as_ref().and_then(|d| d.get(j).cloned()),
})
})
.collect()
}
struct KappaMoments {
kappa: Q,
var: Q,
var0: Q,
max: Q,
}
fn kappa_moments(op: &'static str, table: &[Vec<usize>]) -> Result<KappaMoments, SymplexError> {
let k = table.len();
if k == 0 || table.iter().any(|r| r.len() != k) {
return Err(invalid(
op,
"the confusion matrix must be square and non-empty",
));
}
let n: usize = table.iter().flatten().sum();
if n == 0 {
return Err(invalid(op, "the confusion matrix is empty"));
}
let nq = qu(n);
let p: Vec<Vec<Q>> = table
.iter()
.map(|r| r.iter().map(|&c| qu(c) / &nq).collect())
.collect();
let pr: Vec<Q> = p.iter().map(|r| sum(r.iter().cloned())).collect();
let pc: Vec<Q> = (0..k)
.map(|j| sum(p.iter().map(|r| r[j].clone())))
.collect();
let po = sum((0..k).map(|i| p[i][i].clone()));
let pe = sum(pr.iter().zip(&pc).map(|(r, c)| r * c));
let one_minus_pe = Q::one() - &pe;
if one_minus_pe.is_zero() {
return Err(invalid(
op,
"the expected agreement is 1 (a single category), κ is undefined",
));
}
let kappa = (&po - &pe) / &one_minus_pe;
let one_minus_k = Q::one() - κ
let term_a = sum((0..k).map(|i| {
let d = Q::one() - (&pr[i] + &pc[i]) * &one_minus_k;
&p[i][i] * square(&d)
}));
let mut term_b = Q::zero();
for i in 0..k {
for j in 0..k {
if i != j {
term_b += &p[i][j] * square(&(&pc[i] + &pr[j]));
}
}
}
term_b *= square(&one_minus_k);
let term_c = square(&(&kappa - &pe * &one_minus_k));
let scale = square(&one_minus_pe) * &nq;
let var = (term_a + term_b - term_c) / &scale;
let marg = sum(pr.iter().zip(&pc).map(|(r, c)| r * c * (r + c)));
let var0 = (&pe + square(&pe) - marg) / scale;
let p_max = sum(pr.iter().zip(&pc).map(|(r, c)| r.min(c).clone()));
let max = (p_max - &pe) / one_minus_pe;
Ok(KappaMoments {
kappa,
var,
var0,
max,
})
}
fn observed_categories(a: &[Q], b: &[Q]) -> Vec<Q> {
let mut v: Vec<Q> = a.iter().chain(b).cloned().collect();
v.sort();
v.dedup();
v
}
fn confusion_of(op: &'static str, a: &[Q], b: &[Q]) -> Result<Vec<Vec<usize>>, SymplexError> {
check_same_len(op, a, b)?;
if a.is_empty() {
return Err(invalid(op, "needs at least one item"));
}
confusion_matrix(a, b, &observed_categories(a, b))
}
#[derive(Clone, Debug, PartialEq)]
pub struct KappaCi {
pub kappa: Q,
pub variance: Q,
pub se: Ex,
pub lower: f64,
pub upper: f64,
pub confidence: f64,
}
fn kappa_ci_of(
ctx: &Context,
op: &'static str,
table: &[Vec<usize>],
confidence: f64,
) -> Result<KappaCi, SymplexError> {
check_confidence(op, confidence)?;
let m = kappa_moments(op, table)?;
if m.var.is_negative() {
return Err(SymplexError::computation_failed(
op,
"the large-sample variance of κ came out negative",
));
}
let z = norm_isf((1.0 - confidence) / 2.0);
let delta = z * q_to_f64(&m.var).sqrt();
let kappa_f = q_to_f64(&m.kappa);
Ok(KappaCi {
se: ex(ctx, &m.var).sqrt(),
lower: kappa_f - delta,
upper: kappa_f + delta,
confidence,
kappa: m.kappa,
variance: m.var,
})
}
pub fn cohen_kappa_ci(
ctx: &Context,
a: &[Q],
b: &[Q],
confidence: f64,
) -> Result<KappaCi, SymplexError> {
const OP: &str = "cohen_kappa_ci";
kappa_ci_of(ctx, OP, &confusion_of(OP, a, b)?, confidence)
}
pub fn kappa_ci_from_confusion(
ctx: &Context,
table: &[Vec<usize>],
confidence: f64,
) -> Result<KappaCi, SymplexError> {
kappa_ci_of(ctx, "kappa_ci_from_confusion", table, confidence)
}
fn kappa_test_of(
ctx: &Context,
op: &'static str,
table: &[Vec<usize>],
alt: Alternative,
) -> Result<TestResult, SymplexError> {
let m = kappa_moments(op, table)?;
if !m.var0.is_positive() {
return Err(invalid(
op,
"the null variance of κ is zero, the test is undefined",
));
}
Ok(normal_test(ctx, &m.kappa, &m.var0, alt))
}
pub fn kappa_test(
ctx: &Context,
a: &[Q],
b: &[Q],
alt: Alternative,
) -> Result<TestResult, SymplexError> {
const OP: &str = "kappa_test";
kappa_test_of(ctx, OP, &confusion_of(OP, a, b)?, alt)
}
pub fn kappa_test_from_confusion(
ctx: &Context,
table: &[Vec<usize>],
alt: Alternative,
) -> Result<TestResult, SymplexError> {
kappa_test_of(ctx, "kappa_test_from_confusion", table, alt)
}
pub fn cohen_kappa_maximum(a: &[Q], b: &[Q]) -> Result<Q, SymplexError> {
const OP: &str = "cohen_kappa_maximum";
Ok(kappa_moments(OP, &confusion_of(OP, a, b)?)?.max)
}
pub fn kappa_maximum_from_confusion(table: &[Vec<usize>]) -> Result<Q, SymplexError> {
Ok(kappa_moments("kappa_maximum_from_confusion", table)?.max)
}
pub fn cochrans_q(ctx: &Context, table: &RatingTable) -> Result<TestResult, SymplexError> {
const OP: &str = "cochrans_q";
let rows = score_matrix(OP, table)?;
check_dichotomous(OP, rows.iter().flatten(), "every response")?;
let (_, k) = check_scale(OP, &rows, 2)?;
let row_tot = row_sums(&rows);
let col_tot: Vec<Q> = columns(&rows).iter().map(|c| data::sum(c)).collect();
let total = data::sum(&row_tot);
let denom = qu(k) * &total - sum(row_tot.iter().map(square));
if denom.is_zero() {
return Err(invalid(
OP,
"every subject responds identically under every treatment, Q is undefined",
));
}
let num = qu(k) * sum(col_tot.iter().map(square)) - square(&total);
let statistic = qu(k - 1) * num / denom;
Ok(TestResult {
p_value: chi_squared_sf(ctx, k - 1, &statistic),
statistic: ex(ctx, &statistic),
df: Some(exu(ctx, k - 1)),
alternative: Alternative::TwoSided,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ConcordanceCounts {
pub concordant: usize,
pub discordant: usize,
pub ties_x: usize,
pub ties_y: usize,
pub ties_both: usize,
}
impl ConcordanceCounts {
#[must_use]
pub fn pairs(&self) -> usize {
self.concordant + self.discordant + self.ties_x + self.ties_y + self.ties_both
}
}
pub fn concordance_counts(x: &[Q], y: &[Q]) -> Result<ConcordanceCounts, SymplexError> {
const OP: &str = "concordance_counts";
check_same_len(OP, x, y)?;
let n = x.len();
if n < 2 {
return Err(invalid(OP, "needs at least two observations"));
}
let mut c = ConcordanceCounts {
concordant: 0,
discordant: 0,
ties_x: 0,
ties_y: 0,
ties_both: 0,
};
for i in 0..n {
for j in i + 1..n {
let sx = x[i].cmp(&x[j]);
let sy = y[i].cmp(&y[j]);
match (sx, sy) {
(std::cmp::Ordering::Equal, std::cmp::Ordering::Equal) => c.ties_both += 1,
(std::cmp::Ordering::Equal, _) => c.ties_x += 1,
(_, std::cmp::Ordering::Equal) => c.ties_y += 1,
_ if sx == sy => c.concordant += 1,
_ => c.discordant += 1,
}
}
}
Ok(c)
}
pub fn goodman_kruskal_gamma(x: &[Q], y: &[Q]) -> Result<Q, SymplexError> {
let c = concordance_counts(x, y)?;
let untied = c.concordant + c.discordant;
if untied == 0 {
return Err(invalid(
"goodman_kruskal_gamma",
"every pair is tied on one of the variables, γ is undefined",
));
}
Ok((qu(c.concordant) - qu(c.discordant)) / qu(untied))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Dependent {
Y,
X,
Symmetric,
}
pub fn somers_d(x: &[Q], y: &[Q], dependent: Dependent) -> Result<Q, SymplexError> {
let c = concordance_counts(x, y)?;
let diff = qu(c.concordant) - qu(c.discordant);
let untied = c.concordant + c.discordant;
let denom = match dependent {
Dependent::Y => qu(untied + c.ties_y),
Dependent::X => qu(untied + c.ties_x),
Dependent::Symmetric => qu(2 * untied + c.ties_x + c.ties_y) / qi(2),
};
if denom.is_zero() {
return Err(invalid(
"somers_d",
"the independent variable is constant, Somers' D is undefined",
));
}
Ok(diff / denom)
}
pub fn kendall_tau_c(x: &[Q], y: &[Q]) -> Result<Q, SymplexError> {
let c = concordance_counts(x, y)?;
let m = data::frequencies(x).len().min(data::frequencies(y).len());
if m < 2 {
return Err(invalid(
"kendall_tau_c",
"a constant variable has no rank correlation",
));
}
let n = x.len();
let diff = qu(c.concordant) - qu(c.discordant);
Ok(qu(2 * m) * diff / (qu(n * n) * qu(m - 1)))
}
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)
}
type Expected = (Vec<Vec<Q>>, Vec<Q>, Vec<Q>, 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 expected = rows
.iter()
.map(|r| cols.iter().map(|c| r * c / &total).collect())
.collect();
Ok((expected, rows, cols, total))
}
pub fn expected_counts(table: &[Vec<Q>]) -> Result<Vec<Vec<Q>>, SymplexError> {
Ok(expected_of("expected_counts", table)?.0)
}
pub fn chi2_contributions(table: &[Vec<Q>]) -> Result<Vec<Vec<Q>>, SymplexError> {
let (expected, ..) = expected_of("chi2_contributions", table)?;
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)?;
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, 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())
}
#[must_use]
pub fn fisher_z(r: &Ex) -> Ex {
r.atanh()
}
pub fn pearson_ci(r: f64, n: usize, confidence: f64) -> Result<(f64, f64), SymplexError> {
const OP: &str = "pearson_ci";
if !r.is_finite() || r.abs() >= 1.0 {
return Err(invalid(
OP,
format!("the correlation must lie strictly between −1 and 1, got {r}"),
));
}
if n < 4 {
return Err(invalid(
OP,
format!("Fisher's z interval needs at least four observations, got {n}"),
));
}
check_confidence(OP, confidence)?;
let z = r.atanh();
let se = 1.0 / ((n - 3) as f64).sqrt();
let zc = norm_isf((1.0 - confidence) / 2.0);
Ok(((z - zc * se).tanh(), (z + zc * se).tanh()))
}
fn cross_moments(
op: &'static str,
x: &[Q],
y: &[Q],
min_n: usize,
) -> Result<(Q, Q, Q), 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((sxy, sxx, syy))
}
pub fn pearson_t_statistic(ctx: &Context, x: &[Q], y: &[Q]) -> Result<Ex, SymplexError> {
const OP: &str = "pearson_t_statistic";
let (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 (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,
})
}