use num_traits::{One, Zero};
use super::agreement::RatingTable;
use super::common::{ex, ex_usize, invalid, qu};
use super::data::{self, Ddof, Q};
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::errors::SymplexError;
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 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 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 / ex_usize(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(&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(&r, 2))
}
#[must_use]
pub fn spearman_brown(r: &Ex, k: usize) -> Ex {
let ctx = r.context();
let kq = ex_usize(&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";
if item.len() != total.len() {
return Err(invalid(
OP,
format!(
"the two variables must have the same length ({} and {})",
item.len(),
total.len()
),
));
}
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()
}