use crate::error::{StatsError, StatsResult};
use scirs2_core::ndarray::{Array2, ArrayView2, Axis};
use scirs2_core::numeric::Float;
#[allow(dead_code)]
pub fn chi2_contingency<F>(
observed: &ArrayView2<F>,
correction: bool,
lambda_: Option<&str>,
) -> StatsResult<(F, F, usize, Array2<F>)>
where
F: Float
+ std::iter::Sum<F>
+ std::ops::Div<Output = F>
+ std::fmt::Debug
+ std::marker::Send
+ std::marker::Sync
+ 'static
+ std::fmt::Display,
{
if observed.ndim() != 2 {
return Err(StatsError::InvalidArgument(format!(
"observed must be a 2D array, got {}D",
observed.ndim()
)));
}
let nrows = observed.nrows();
let ncols = observed.ncols();
if nrows < 2 || ncols < 2 {
return Err(StatsError::InvalidArgument(format!(
"observed contingency table must be at least 2x2, got {}x{}",
nrows, ncols
)));
}
let row_sums = observed.sum_axis(Axis(1));
let col_sums = observed.sum_axis(Axis(0));
let total: F = row_sums.iter().copied().sum();
if total <= F::zero() {
return Err(StatsError::InvalidArgument(
"The contingency table is empty or contains only zeros".to_string(),
));
}
let mut expected = Array2::<F>::zeros((nrows, ncols));
for i in 0..nrows {
for j in 0..ncols {
expected[[i, j]] = row_sums[i] * col_sums[j] / total;
}
}
let mut chi2 = F::zero();
if let Some(lambda_str) = lambda_ {
if lambda_str == "log-likelihood" {
for i in 0..nrows {
for j in 0..ncols {
let obs = observed[[i, j]];
let exp = expected[[i, j]];
if obs > F::zero() {
chi2 = chi2 + obs * (obs / exp).ln();
}
}
}
chi2 = chi2 * F::from(2.0).expect("Failed to convert constant to float");
} else {
return Err(StatsError::InvalidArgument(format!(
"lambda_ must be \"log-likelihood\" or None, got {:?}",
lambda_str
)));
}
} else {
for i in 0..nrows {
for j in 0..ncols {
let obs = observed[[i, j]];
let exp = expected[[i, j]];
if exp > F::zero() {
let mut diff = obs - exp;
if correction && nrows == 2 && ncols == 2 {
diff = (diff.abs()
- F::from(0.5).expect("Failed to convert constant to float"))
.max(F::zero())
* diff.signum();
}
chi2 = chi2 + diff * diff / exp;
} else if obs > F::zero() {
return Err(StatsError::InvalidArgument(
"Expected frequency is zero while observed frequency is non-zero"
.to_string(),
));
}
}
}
}
let dof = (nrows - 1) * (ncols - 1);
let p_value = match crate::distributions::chi2(
F::from(dof).expect("Failed to convert to float"),
F::zero(),
F::one(),
) {
Ok(dist) => F::one() - dist.cdf(chi2),
Err(_) => F::zero(), };
Ok((chi2, p_value, dof, expected))
}
#[allow(dead_code)]
pub fn fisher_exact<F>(table: &ArrayView2<F>, alternative: &str) -> StatsResult<(F, F)>
where
F: Float
+ std::iter::Sum<F>
+ std::ops::Div<Output = F>
+ std::fmt::Debug
+ std::marker::Send
+ std::marker::Sync
+ 'static
+ scirs2_core::numeric::FloatConst
+ std::fmt::Display,
{
if table.nrows() != 2 || table.ncols() != 2 {
return Err(StatsError::InvalidArgument(format!(
"_table must be a 2x2 array, got {}x{}",
table.nrows(),
table.ncols()
)));
}
if !["two-sided", "less", "greater"].contains(&alternative) {
return Err(StatsError::InvalidArgument(format!(
"alternative must be one of \"two-sided\", \"less\", \"greater\", got {:?}",
alternative
)));
}
let a = table[[0, 0]];
let b = table[[0, 1]];
let c = table[[1, 0]];
let d = table[[1, 1]];
if a < F::zero() || b < F::zero() || c < F::zero() || d < F::zero() {
return Err(StatsError::InvalidArgument(
"All values in _table must be non-negative".to_string(),
));
}
let odds_ratio = if b * c > F::zero() {
(a * d) / (b * c)
} else if a > F::zero() && d > F::zero() {
F::infinity()
} else {
F::zero()
};
let to_count = |v: F| -> StatsResult<usize> {
let rounded = v.round();
let as_f64: f64 = scirs2_core::numeric::NumCast::from(rounded).ok_or_else(|| {
StatsError::ComputationError(
"Failed to convert _table entry to an integer count".to_string(),
)
})?;
if as_f64 < 0.0 || !as_f64.is_finite() {
return Err(StatsError::InvalidArgument(
"All values in _table must be finite, non-negative counts".to_string(),
));
}
Ok(as_f64.round() as usize)
};
let a_n = to_count(a)?;
let b_n = to_count(b)?;
let c_n = to_count(c)?;
let d_n = to_count(d)?;
let row1 = a_n + b_n;
let row2 = c_n + d_n;
let col1 = a_n + c_n;
let col2 = b_n + d_n;
let total = row1 + row2;
if total == 0 {
return Err(StatsError::InvalidArgument(
"The contingency table is empty or contains only zeros".to_string(),
));
}
let dist = crate::distributions::Hypergeometric::<F>::new(total, col1, row1, F::zero())?;
let a_val = F::from(a_n).expect("Failed to convert table count to float");
let p_value = match alternative {
"less" => dist.cdf(a_val),
"greater" => {
if a_n == 0 {
F::one()
} else {
F::one() - dist.cdf(F::from(a_n - 1).expect("Failed to convert to float"))
}
}
_ => {
let p_observed = dist.pmf(a_val);
let epsilon = F::from(1e-7).expect("Failed to convert constant to float");
let threshold = p_observed * (F::one() + epsilon);
let k_min = (row1 + col1).saturating_sub(total);
let k_max = row1.min(col1);
let mut sum = F::zero();
for k in k_min..=k_max {
let pk = dist.pmf(F::from(k).expect("Failed to convert to float"));
if pk <= threshold {
sum = sum + pk;
}
}
if sum > F::one() {
F::one()
} else {
sum
}
}
};
Ok((odds_ratio, p_value))
}
#[allow(dead_code)]
pub fn association<F>(table: &ArrayView2<F>, measure: &str) -> StatsResult<F>
where
F: Float
+ std::iter::Sum<F>
+ std::ops::Div<Output = F>
+ std::fmt::Debug
+ std::marker::Send
+ std::marker::Sync
+ 'static
+ std::fmt::Display,
{
if table.ndim() != 2 {
return Err(StatsError::InvalidArgument(format!(
"_table must be a 2D array, got {}D",
table.ndim()
)));
}
let nrows = table.nrows();
let ncols = table.ncols();
if nrows < 2 || ncols < 2 {
return Err(StatsError::InvalidArgument(format!(
"_table must be at least 2x2, got {}x{}",
nrows, ncols
)));
}
match measure {
"cramer" => {
let (chi2, _, _, _) = chi2_contingency(table, false, None)?;
let total: F = table.iter().copied().sum();
if total <= F::zero() {
return Err(StatsError::InvalidArgument(
"The contingency _table is empty or contains only zeros".to_string(),
));
}
let min_dim = F::from((nrows - 1).min(ncols - 1)).expect("Operation failed");
let cramer_v = (chi2 / (total * min_dim)).sqrt();
Ok(cramer_v)
}
_ => Err(StatsError::InvalidArgument(format!(
"measure must be \"cramer\", got {:?}",
measure
))),
}
}
#[allow(dead_code)]
pub fn relative_risk<F>(table: &ArrayView2<F>) -> StatsResult<F>
where
F: Float
+ std::iter::Sum<F>
+ std::ops::Div<Output = F>
+ std::fmt::Debug
+ std::marker::Send
+ std::marker::Sync
+ 'static
+ std::fmt::Display,
{
if table.nrows() != 2 || table.ncols() != 2 {
return Err(StatsError::InvalidArgument(format!(
"_table must be a 2x2 array, got {}x{}",
table.nrows(),
table.ncols()
)));
}
let a = table[[0, 0]]; let b = table[[0, 1]]; let c = table[[1, 0]]; let d = table[[1, 1]];
if a < F::zero() || b < F::zero() || c < F::zero() || d < F::zero() {
return Err(StatsError::InvalidArgument(
"All values in _table must be non-negative".to_string(),
));
}
let exposed_total = a + b;
if exposed_total <= F::zero() {
return Err(StatsError::ComputationError(
"No exposed subjects in the _table".to_string(),
));
}
let risk_exposed = a / exposed_total;
let unexposed_total = c + d;
if unexposed_total <= F::zero() {
return Err(StatsError::ComputationError(
"No unexposed subjects in the _table".to_string(),
));
}
let risk_unexposed = c / unexposed_total;
if risk_unexposed <= F::zero() {
if risk_exposed <= F::zero() {
return Err(StatsError::ComputationError(
"Relative risk is undefined when both risks are zero".to_string(),
));
} else {
return Ok(F::infinity());
}
}
Ok(risk_exposed / risk_unexposed)
}
#[allow(dead_code)]
pub fn odds_ratio<F>(table: &ArrayView2<F>) -> StatsResult<F>
where
F: Float
+ std::iter::Sum<F>
+ std::ops::Div<Output = F>
+ std::fmt::Debug
+ std::marker::Send
+ std::marker::Sync
+ 'static
+ std::fmt::Display,
{
if table.nrows() != 2 || table.ncols() != 2 {
return Err(StatsError::InvalidArgument(format!(
"_table must be a 2x2 array, got {}x{}",
table.nrows(),
table.ncols()
)));
}
let a = table[[0, 0]]; let b = table[[0, 1]]; let c = table[[1, 0]]; let d = table[[1, 1]];
if a < F::zero() || b < F::zero() || c < F::zero() || d < F::zero() {
return Err(StatsError::InvalidArgument(
"All values in _table must be non-negative".to_string(),
));
}
if b * c <= F::zero() {
if a * d <= F::zero() {
return Err(StatsError::ComputationError(
"Odds ratio is undefined when both products (a*d) and (b*c) are zero".to_string(),
));
} else {
return Ok(F::infinity());
}
}
Ok((a * d) / (b * c))
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
#[test]
fn test_fisher_exact_two_sided_matches_scipy() {
let table = array![[10.0f64, 20.0], [30.0, 40.0]];
let (or_, p) = fisher_exact(&table.view(), "two-sided").expect("fisher_exact ok");
assert!((or_ - 0.6666666666666666).abs() < 1e-9);
assert!(
(p - 0.5044757698516285).abs() < 1e-6,
"expected p~=0.50448, got {p}"
);
let table2 = array![[3.0f64, 1.0], [1.0, 3.0]];
let (or2, p2) = fisher_exact(&table2.view(), "two-sided").expect("fisher_exact ok");
assert!((or2 - 9.0).abs() < 1e-9);
assert!(
(p2 - 0.48571428571428565).abs() < 1e-9,
"expected p~=0.485714, got {p2}"
);
let table3 = array![[1.0f64, 9.0], [11.0, 3.0]];
let (or3, p3) = fisher_exact(&table3.view(), "two-sided").expect("fisher_exact ok");
assert!((or3 - 0.030303030303030304).abs() < 1e-9);
assert!(
(p3 - 0.0027594561852200836).abs() < 1e-6,
"expected p~=0.0027595, got {p3}"
);
}
#[test]
fn test_fisher_exact_small_table_not_chi_square_approximation() {
let table = array![[3.0f64, 1.0], [1.0, 3.0]];
let (_, p) = fisher_exact(&table.view(), "two-sided").expect("fisher_exact ok");
assert!(
(p - 0.48571428571428565).abs() < 1e-6,
"p={p} is not the exact hypergeometric two-sided value"
);
assert!(
(p - 0.47950012218695337).abs() > 1e-4,
"p={p} looks suspiciously close to the old chi-square approximation"
);
}
#[test]
fn test_fisher_exact_one_sided_matches_scipy() {
let table = array![[10.0f64, 20.0], [30.0, 40.0]];
let (_, p_less) = fisher_exact(&table.view(), "less").expect("fisher_exact ok");
let (_, p_greater) = fisher_exact(&table.view(), "greater").expect("fisher_exact ok");
assert!((p_less - 0.2533310713617557).abs() < 1e-6);
assert!((p_greater - 0.8676419647894413).abs() < 1e-6);
let table2 = array![[3.0f64, 1.0], [1.0, 3.0]];
let (_, p_less2) = fisher_exact(&table2.view(), "less").expect("fisher_exact ok");
let (_, p_greater2) = fisher_exact(&table2.view(), "greater").expect("fisher_exact ok");
assert!((p_less2 - 0.9857142857142858).abs() < 1e-9);
assert!((p_greater2 - 0.24285714285714283).abs() < 1e-9);
}
#[test]
fn test_fisher_exact_extreme_table_matches_scipy() {
let table = array![[0.0f64, 5.0], [5.0, 0.0]];
let (or_, p) = fisher_exact(&table.view(), "two-sided").expect("fisher_exact ok");
assert_eq!(or_, 0.0);
assert!((p - 0.007936507936507938).abs() < 1e-9);
let table2 = array![[5.0f64, 0.0], [0.0, 5.0]];
let (or2, p2) = fisher_exact(&table2.view(), "two-sided").expect("fisher_exact ok");
assert!(or2.is_infinite());
assert!((p2 - 0.007936507936507938).abs() < 1e-9);
}
#[test]
fn test_fisher_exact_p_value_in_bounds() {
let table = array![[2.0f64, 7.0], [8.0, 2.0]];
let (or_, p) = fisher_exact(&table.view(), "two-sided").expect("fisher_exact ok");
assert!(or_ > 0.0);
assert!((0.0..=1.0).contains(&p));
assert!((or_ - 0.07142857142857142).abs() < 1e-9);
assert!((p - 0.02301413756522116).abs() < 1e-6);
}
#[test]
fn test_fisher_exact_invalid_alternative() {
let table = array![[1.0f64, 2.0], [3.0, 4.0]];
assert!(fisher_exact(&table.view(), "bogus").is_err());
}
#[test]
fn test_fisher_exact_wrong_shape() {
let table = array![[1.0f64, 2.0, 3.0], [4.0, 5.0, 6.0]];
assert!(fisher_exact(&table.view(), "two-sided").is_err());
}
#[test]
fn test_relative_risk_not_hardcoded_fake_value() {
let table = array![[10.0f64, 90.0], [5.0, 195.0]];
let rr = relative_risk(&table.view()).expect("relative_risk ok");
assert!(
(rr - 4.0).abs() < 1e-9,
"expected the real relative risk (4.0), got {rr} (2.0 would indicate the old fabricated special-case)"
);
}
#[test]
fn test_relative_risk_other_tables_unaffected() {
let table = array![[20.0f64, 30.0], [10.0, 90.0]];
let rr = relative_risk(&table.view()).expect("relative_risk ok");
assert!((rr - 4.0).abs() < 1e-9);
}
}