use std::collections::BTreeMap;
use num_bigint::BigInt;
use num_traits::{One, Signed, ToPrimitive, Zero};
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::errors::SymplexError;
use crate::domains::ntheory::factorint_bounded;
use super::data::Q;
use super::family::Distribution;
const OP: &str = "stats::information";
fn invalid(reason: impl Into<String>) -> SymplexError {
SymplexError::invalid_argument(OP, reason)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Base {
Nats,
Bits,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Given {
Row,
Column,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Norm {
Arithmetic,
Geometric,
Min,
Max,
}
#[derive(Clone, Debug, Default)]
struct LogSum {
terms: BTreeMap<BigInt, Q>,
}
impl LogSum {
fn add(&mut self, c: &Q, r: &Q) {
self.add_int(c, r.numer());
self.add_int(&(-c), r.denom());
}
fn add_int(&mut self, c: &Q, n: &BigInt) {
if n.is_one() || c.is_zero() {
return;
}
let (factors, cofactor) = factorint_bounded(n, 64);
for (p, e) in factors {
*self.terms.entry(p).or_insert_with(Q::zero) += c * Q::from_integer(BigInt::from(e));
}
if !cofactor.is_one() {
*self.terms.entry(cofactor).or_insert_with(Q::zero) += c;
}
}
fn scaled(mut self, k: &Q) -> Self {
for v in self.terms.values_mut() {
*v *= k;
}
self
}
fn plus(mut self, other: &LogSum) -> Self {
for (base, coeff) in &other.terms {
*self.terms.entry(base.clone()).or_insert_with(Q::zero) += coeff;
}
self
}
fn negated(self) -> Self {
self.scaled(&-Q::one())
}
fn nonzero(&self) -> impl Iterator<Item = (&BigInt, &Q)> {
self.terms.iter().filter(|(_, c)| !c.is_zero())
}
fn is_zero(&self) -> bool {
self.nonzero().next().is_none()
}
fn single(&self) -> Option<(&BigInt, &Q)> {
let mut it = self.nonzero();
let first = it.next()?;
it.next().is_none().then_some(first)
}
fn to_nats(&self, ctx: &Context) -> Ex {
let mut acc = ctx.zero();
for (base, coeff) in self.nonzero() {
acc += ctx.from_ratio(coeff.clone()) * ctx.from_bigint(base.clone()).ln();
}
acc
}
fn to_bits(&self, ctx: &Context) -> Ex {
let two = BigInt::from(2);
let ln2 = ctx.int(2).ln();
let mut acc = ctx.zero();
for (base, coeff) in self.nonzero() {
if *base == two {
acc += ctx.from_ratio(coeff.clone());
} else {
acc += ctx.from_ratio(coeff.clone()) * ctx.from_bigint(base.clone()).ln() / &ln2;
}
}
acc
}
fn in_base(&self, ctx: &Context, base: Base) -> Ex {
match base {
Base::Nats => self.to_nats(ctx),
Base::Bits => self.to_bits(ctx),
}
}
fn to_f64(&self) -> f64 {
self.nonzero()
.map(|(b, c)| c.to_f64().unwrap_or(f64::NAN) * b.to_f64().unwrap_or(f64::NAN).ln())
.sum()
}
}
fn ratio_of(ctx: &Context, num: &LogSum, den: &LogSum) -> Ex {
if let (Some((qn, cn)), Some((qd, cd))) = (num.single(), den.single())
&& qn == qd
{
return ctx.from_ratio(cn / cd);
}
(num.to_nats(ctx) / den.to_nats(ctx)).simplify()
}
fn geometric_ratio(ctx: &Context, num: &LogSum, a: &LogSum, b: &LogSum) -> Ex {
if let (Some((qn, cn)), Some((qa, ca)), Some((qb, cb))) = (num.single(), a.single(), b.single())
&& qn == qa
&& qa == qb
{
return (ctx.from_ratio(cn.clone()) / ctx.from_ratio(ca * cb).sqrt()).simplify();
}
(num.to_nats(ctx) / (a.to_nats(ctx) * b.to_nats(ctx)).sqrt()).simplify()
}
fn check_vector(p: &[Q], name: &str) -> Result<(), SymplexError> {
if p.is_empty() {
return Err(invalid(format!("{name} is empty")));
}
if let Some((i, v)) = p.iter().enumerate().find(|(_, v)| v.is_negative()) {
return Err(invalid(format!("{name}[{i}] = {v} is negative")));
}
let total = p.iter().fold(Q::zero(), |acc, v| acc + v);
if !total.is_one() {
return Err(invalid(format!("{name} sums to {total}, not 1")));
}
Ok(())
}
fn check_pair(p: &[Q], q: &[Q]) -> Result<(), SymplexError> {
check_vector(p, "p")?;
check_vector(q, "q")?;
if p.len() != q.len() {
return Err(invalid(format!(
"p and q have different lengths ({} and {})",
p.len(),
q.len()
)));
}
Ok(())
}
fn check_joint(joint: &[Vec<Q>]) -> Result<(usize, usize), SymplexError> {
let r = joint.len();
if r == 0 {
return Err(invalid("the joint table is empty"));
}
let c = joint[0].len();
if c == 0 {
return Err(invalid("the joint table has no columns"));
}
if let Some((i, row)) = joint.iter().enumerate().find(|(_, row)| row.len() != c) {
return Err(invalid(format!(
"joint row {i} has {} entries, expected {c}",
row.len()
)));
}
let mut total = Q::zero();
for (i, row) in joint.iter().enumerate() {
for (j, v) in row.iter().enumerate() {
if v.is_negative() {
return Err(invalid(format!("joint[{i}][{j}] = {v} is negative")));
}
total += v;
}
}
if !total.is_one() {
return Err(invalid(format!("the joint table sums to {total}, not 1")));
}
Ok((r, c))
}
fn entropy_sum(p: &[Q]) -> LogSum {
let mut acc = LogSum::default();
for v in p.iter().filter(|v| v.is_positive()) {
acc.add(&(-v), v);
}
acc
}
fn kl_sum(p: &[Q], q: &[Q], what: &str) -> Result<LogSum, SymplexError> {
let mut acc = LogSum::default();
for (i, (pi, qi)) in p.iter().zip(q).enumerate() {
if pi.is_zero() {
continue;
}
if qi.is_zero() {
return Err(invalid(format!(
"{what} is infinite: q[{i}] = 0 while p[{i}] = {pi} > 0"
)));
}
acc.add(pi, &(pi / qi));
}
Ok(acc)
}
pub fn entropy(ctx: &Context, p: &[Q], base: Base) -> Result<Ex, SymplexError> {
check_vector(p, "p")?;
Ok(entropy_sum(p).in_base(ctx, base))
}
pub fn perplexity(ctx: &Context, p: &[Q]) -> Result<Ex, SymplexError> {
check_vector(p, "p")?;
let mut acc = ctx.one();
for v in p.iter().filter(|v| v.is_positive()) {
let e = ctx.from_ratio(v.clone());
acc *= e.pow(&(-&e));
}
Ok(acc.simplify())
}
pub fn probability_vector(dist: &Distribution) -> Result<Vec<Q>, SymplexError> {
let support = dist.support();
if dist.is_continuous() {
return Err(invalid(format!(
"{}: a probability vector needs a discrete distribution",
dist.name()
)));
}
let values: Vec<Ex> = if let Some(points) = support.as_points() {
points
} else if let Some(iv) = support.as_interval() {
match (iv.lower.eval().as_i64(), iv.upper.eval().as_i64()) {
(Some(lo), Some(hi)) if lo <= hi && hi - lo < 1_000_000 => {
let ctx = dist.context();
(lo..=hi).map(|v| ctx.int(v)).collect()
}
_ => {
return Err(invalid(format!(
"{}: the support is not a finite range of integers",
dist.name()
)));
}
}
} else {
return Err(invalid(format!(
"{}: the support is not a finite set of values",
dist.name()
)));
};
values
.iter()
.map(|v| {
dist.density(v).eval().as_rational().ok_or_else(|| {
invalid(format!(
"{}: the probability of `{v}` is not a rational number",
dist.name()
))
})
})
.collect()
}
pub fn kl_divergence(ctx: &Context, p: &[Q], q: &[Q], base: Base) -> Result<Ex, SymplexError> {
check_pair(p, q)?;
Ok(kl_sum(p, q, "KL divergence")?.in_base(ctx, base))
}
pub fn cross_entropy(ctx: &Context, p: &[Q], q: &[Q], base: Base) -> Result<Ex, SymplexError> {
check_pair(p, q)?;
let mut acc = LogSum::default();
for (i, (pi, qi)) in p.iter().zip(q).enumerate() {
if pi.is_zero() {
continue;
}
if qi.is_zero() {
return Err(invalid(format!(
"cross-entropy is infinite: q[{i}] = 0 while p[{i}] = {pi} > 0"
)));
}
acc.add(&(-pi), qi);
}
Ok(acc.in_base(ctx, base))
}
pub fn js_divergence(ctx: &Context, p: &[Q], q: &[Q], base: Base) -> Result<Ex, SymplexError> {
check_pair(p, q)?;
let two = Q::from_integer(2.into());
let half = Q::one() / &two;
let m: Vec<Q> = p.iter().zip(q).map(|(a, b)| (a + b) / &two).collect();
let js = kl_sum(p, &m, "JS divergence")?
.plus(&kl_sum(q, &m, "JS divergence")?)
.scaled(&half);
Ok(js.in_base(ctx, base))
}
pub fn total_variation(p: &[Q], q: &[Q]) -> Result<Q, SymplexError> {
check_pair(p, q)?;
let sum = p
.iter()
.zip(q)
.fold(Q::zero(), |acc, (a, b)| acc + (a - b).abs());
Ok(sum / Q::from_integer(2.into()))
}
pub fn bhattacharyya_coefficient(ctx: &Context, p: &[Q], q: &[Q]) -> Result<Ex, SymplexError> {
check_pair(p, q)?;
let mut acc = ctx.zero();
for (a, b) in p.iter().zip(q) {
if a.is_positive() && b.is_positive() {
acc += ctx.from_ratio(a * b).sqrt();
}
}
Ok(acc.simplify())
}
pub fn bhattacharyya_distance(ctx: &Context, p: &[Q], q: &[Q]) -> Result<Ex, SymplexError> {
let bc = bhattacharyya_coefficient(ctx, p, q)?;
if bc.is_zero() == Some(true) {
return Err(invalid(
"Bhattacharyya distance is infinite: p and q have disjoint supports",
));
}
Ok((-bc.ln()).simplify())
}
pub fn hellinger(ctx: &Context, p: &[Q], q: &[Q]) -> Result<Ex, SymplexError> {
let bc = bhattacharyya_coefficient(ctx, p, q)?;
Ok((ctx.one() - bc).sqrt().simplify())
}
pub fn joint_from_counts(counts: &[Vec<usize>]) -> Result<Vec<Vec<Q>>, SymplexError> {
if counts.is_empty() || counts[0].is_empty() {
return Err(invalid("the count table is empty"));
}
let c = counts[0].len();
if let Some((i, row)) = counts.iter().enumerate().find(|(_, row)| row.len() != c) {
return Err(invalid(format!(
"count row {i} has {} entries, expected {c}",
row.len()
)));
}
let total: usize = counts.iter().flatten().sum();
if total == 0 {
return Err(invalid("the count table is all zeros"));
}
let total = Q::from_integer(total.into());
Ok(counts
.iter()
.map(|row| {
row.iter()
.map(|&n| Q::from_integer(n.into()) / &total)
.collect()
})
.collect())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Marginals {
pub rows: Vec<Q>,
pub cols: Vec<Q>,
}
pub fn marginals(joint: &[Vec<Q>]) -> Result<Marginals, SymplexError> {
let (_, c) = check_joint(joint)?;
let rows = joint
.iter()
.map(|row| row.iter().fold(Q::zero(), |acc, v| acc + v))
.collect();
let cols = (0..c)
.map(|j| joint.iter().fold(Q::zero(), |acc, row| acc + &row[j]))
.collect();
Ok(Marginals { rows, cols })
}
fn flatten(joint: &[Vec<Q>]) -> Vec<Q> {
joint.iter().flatten().cloned().collect()
}
pub fn joint_entropy(ctx: &Context, joint: &[Vec<Q>], base: Base) -> Result<Ex, SymplexError> {
check_joint(joint)?;
Ok(entropy_sum(&flatten(joint)).in_base(ctx, base))
}
fn mutual_information_sum(joint: &[Vec<Q>]) -> Result<(LogSum, Marginals), SymplexError> {
let m = marginals(joint)?;
let mut acc = LogSum::default();
for (i, row) in joint.iter().enumerate() {
for (j, v) in row.iter().enumerate() {
if v.is_positive() {
acc.add(v, &(v / (&m.rows[i] * &m.cols[j])));
}
}
}
Ok((acc, m))
}
pub fn mutual_information(ctx: &Context, joint: &[Vec<Q>], base: Base) -> Result<Ex, SymplexError> {
let (i, _) = mutual_information_sum(joint)?;
Ok(i.in_base(ctx, base))
}
pub fn information_gain(ctx: &Context, joint: &[Vec<Q>], base: Base) -> Result<Ex, SymplexError> {
mutual_information(ctx, joint, base)
}
pub fn conditional_entropy(
ctx: &Context,
joint: &[Vec<Q>],
given: Given,
base: Base,
) -> Result<Ex, SymplexError> {
let Marginals { rows, cols } = marginals(joint)?;
let known = match given {
Given::Row => rows,
Given::Column => cols,
};
let h = entropy_sum(&flatten(joint)).plus(&entropy_sum(&known).negated());
Ok(h.in_base(ctx, base))
}
pub fn normalized_mutual_information(
ctx: &Context,
joint: &[Vec<Q>],
norm: Norm,
) -> Result<Ex, SymplexError> {
let (i, m) = mutual_information_sum(joint)?;
let hx = entropy_sum(&m.rows);
let hy = entropy_sum(&m.cols);
let degenerate = match norm {
Norm::Min => hx.is_zero() || hy.is_zero(),
Norm::Arithmetic | Norm::Geometric | Norm::Max => hx.is_zero() && hy.is_zero(),
};
if degenerate {
return Err(invalid(
"normalised mutual information is undefined: the normalising entropy is zero",
));
}
let half = Q::one() / Q::from_integer(2.into());
Ok(match norm {
Norm::Arithmetic => ratio_of(ctx, &i, &hx.clone().plus(&hy).scaled(&half)),
Norm::Geometric => geometric_ratio(ctx, &i, &hx, &hy),
Norm::Min => {
if hx.to_f64() <= hy.to_f64() {
ratio_of(ctx, &i, &hx)
} else {
ratio_of(ctx, &i, &hy)
}
}
Norm::Max => {
if hx.to_f64() >= hy.to_f64() {
ratio_of(ctx, &i, &hx)
} else {
ratio_of(ctx, &i, &hy)
}
}
})
}