use num_traits::{Signed, Zero};
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::errors::SymplexError;
use crate::domains::exact_matrix::QMatrix;
use crate::domains::matrix::Matrix;
use super::data::{self, Ddof, Q};
use super::family::Distribution;
use super::sample::Rng;
const OP: &str = "stats::multivariate";
fn invalid(reason: impl Into<String>) -> SymplexError {
SymplexError::invalid_argument(OP, reason)
}
fn failed(reason: impl Into<String>) -> SymplexError {
SymplexError::computation_failed(OP, reason)
}
#[derive(Clone, Debug, PartialEq)]
pub struct MultivariateNormal {
pub mean: Vec<Ex>,
pub cov: Matrix,
}
impl MultivariateNormal {
pub fn try_new(mean: Vec<Ex>, cov: Matrix) -> Result<Self, SymplexError> {
let k = mean.len();
if k == 0 {
return Err(invalid("the mean vector must have at least one coordinate"));
}
if cov.shape() != (k, k) {
return Err(invalid(format!(
"the covariance must be {k}×{k} for a mean of length {k}, got {}×{}",
cov.nrows(),
cov.ncols()
)));
}
if cov.is_symmetric() == Some(false) {
return Err(invalid("the covariance matrix must be symmetric"));
}
match QMatrix::try_from(&cov) {
Ok(q) => match q.ldl_psd() {
Some((_, d)) if d.iter().all(|v| v.is_positive()) => {}
_ => {
return Err(invalid(
"the covariance matrix must be positive definite (exact L·D·Lᵀ test failed)",
));
}
},
Err(_) => {
if cov.is_positive_definite() == Some(false) {
return Err(invalid(
"the covariance matrix must be positive definite (a leading minor is not positive)",
));
}
}
}
Ok(MultivariateNormal { mean, cov })
}
pub fn new(mean: Vec<Ex>, cov: Matrix) -> Self {
MultivariateNormal { mean, cov }
}
pub fn dim(&self) -> usize {
self.mean.len()
}
pub fn context(&self) -> Context {
self.cov.context()
}
fn check_point(&self, x: &[Ex], what: &str) -> Result<(), SymplexError> {
if x.len() != self.dim() {
return Err(invalid(format!(
"{what}: expected a point of dimension {}, got {}",
self.dim(),
x.len()
)));
}
Ok(())
}
pub fn precision(&self) -> Result<Matrix, SymplexError> {
self.cov
.inv()
.map_err(|e| failed(format!("the covariance matrix is not invertible: {e}")))
}
fn quadratic_form(&self, x: &[Ex]) -> Result<Ex, SymplexError> {
let prec = self.precision()?;
let d: Vec<Ex> = x.iter().zip(&self.mean).map(|(xi, mi)| xi - mi).collect();
let mut acc = self.context().zero();
for (i, di) in d.iter().enumerate() {
for (j, dj) in d.iter().enumerate() {
acc += di * prec.get(i, j) * dj;
}
}
Ok(acc.simplify())
}
pub fn mahalanobis_squared(&self, x: &[Ex]) -> Result<Ex, SymplexError> {
self.check_point(x, "mahalanobis")?;
self.quadratic_form(x)
}
pub fn mahalanobis(&self, x: &[Ex]) -> Result<Ex, SymplexError> {
Ok(self.mahalanobis_squared(x)?.sqrt())
}
pub fn density(&self, x: &[Ex]) -> Result<Ex, SymplexError> {
self.check_point(x, "density")?;
let ctx = self.context();
let quad = self.quadratic_form(x)?;
let det = self
.cov
.det()
.map_err(|e| failed(format!("determinant of the covariance: {e}")))?
.simplify();
let k = i64::try_from(self.dim()).map_err(|_| invalid("dimension too large"))?;
let two_pi_k = (ctx.int(2) * ctx.pi()).powi(k);
Ok((-quad / 2).exp() / (two_pi_k * det).sqrt())
}
pub fn entropy(&self) -> Result<Ex, SymplexError> {
let ctx = self.context();
let det = self
.cov
.det()
.map_err(|e| failed(format!("determinant of the covariance: {e}")))?
.simplify();
let k = i64::try_from(self.dim()).map_err(|_| invalid("dimension too large"))?;
Ok(ctx.rational(1, 2) * ((ctx.int(2) * ctx.pi() * ctx.e()).powi(k) * det).ln())
}
fn check_indices(&self, indices: &[usize], what: &str) -> Result<(), SymplexError> {
if indices.is_empty() {
return Err(invalid(format!(
"{what}: at least one coordinate is needed"
)));
}
for (pos, &i) in indices.iter().enumerate() {
if i >= self.dim() {
return Err(invalid(format!(
"{what}: coordinate index {i} out of range for dimension {}",
self.dim()
)));
}
if indices[..pos].contains(&i) {
return Err(invalid(format!(
"{what}: coordinate index {i} listed twice"
)));
}
}
Ok(())
}
pub fn marginal(&self, indices: &[usize]) -> Result<MultivariateNormal, SymplexError> {
self.check_indices(indices, "marginal")?;
let mean = indices.iter().map(|&i| self.mean[i].clone()).collect();
let cov = self
.cov
.extract(indices, indices)
.map_err(|e| failed(format!("marginal: {e}")))?;
Ok(MultivariateNormal { mean, cov })
}
pub fn marginal_1d(&self, i: usize) -> Result<Distribution, SymplexError> {
self.check_indices(&[i], "marginal_1d")?;
let std = self.cov.get(i, i).sqrt().simplify();
Ok(Distribution::normal(self.mean[i].clone(), std))
}
pub fn conditional(&self, given: &[(usize, Ex)]) -> Result<MultivariateNormal, SymplexError> {
let b: Vec<usize> = given.iter().map(|(i, _)| *i).collect();
self.check_indices(&b, "conditional")?;
let a: Vec<usize> = (0..self.dim()).filter(|i| !b.contains(i)).collect();
if a.is_empty() {
return Err(invalid(
"conditional: conditioning on every coordinate leaves nothing to distribute",
));
}
let wrap = |e: SymplexError| failed(format!("conditional: {e}"));
let s_aa = self.cov.extract(&a, &a).map_err(wrap)?;
let s_ab = self.cov.extract(&a, &b).map_err(wrap)?;
let s_ba = self.cov.extract(&b, &a).map_err(wrap)?;
let s_bb = self.cov.extract(&b, &b).map_err(wrap)?;
let k = s_ab.matmul(&s_bb.inv().map_err(wrap)?).map_err(wrap)?;
let shift = Matrix::col_vector(
given
.iter()
.map(|(i, v)| (v - &self.mean[*i]).simplify())
.collect(),
);
let mean_shift = k.matmul(&shift).map_err(wrap)?;
let mean = a
.iter()
.enumerate()
.map(|(pos, &i)| (&self.mean[i] + mean_shift.get(pos, 0)).simplify())
.collect();
let cov = s_aa
.sub(&k.matmul(&s_ba).map_err(wrap)?)
.map_err(wrap)?
.simplify();
Ok(MultivariateNormal { mean, cov })
}
pub fn affine(&self, a: &Matrix, b: &[Ex]) -> Result<MultivariateNormal, SymplexError> {
if a.ncols() != self.dim() {
return Err(invalid(format!(
"affine: A has {} columns but the distribution has dimension {}",
a.ncols(),
self.dim()
)));
}
if b.len() != a.nrows() {
return Err(invalid(format!(
"affine: b has {} entries but A has {} rows",
b.len(),
a.nrows()
)));
}
let wrap = |e: SymplexError| failed(format!("affine: {e}"));
let mu = Matrix::col_vector(self.mean.clone());
let a_mu = a.matmul(&mu).map_err(wrap)?;
let mean = b
.iter()
.enumerate()
.map(|(i, bi)| (a_mu.get(i, 0) + bi).simplify())
.collect();
let cov = a
.matmul(&self.cov)
.map_err(wrap)?
.matmul(&a.transpose())
.map_err(wrap)?
.simplify();
Ok(MultivariateNormal { mean, cov })
}
pub fn sample(&self, n: usize, rng: &mut Rng) -> Result<Vec<Vec<f64>>, SymplexError> {
let k = self.dim();
let mean: Vec<f64> = self
.mean
.iter()
.map(Ex::eval_f64)
.collect::<Result<_, _>>()?;
let cov = self.cov.eval_f64()?;
let l = cholesky_f64(&cov)?;
let ctx = self.context();
let mut z = Distribution::normal(ctx.zero(), ctx.one()).sampler()?;
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let zs: Vec<f64> = (0..k).map(|_| z(rng)).collect();
let x: Vec<f64> = (0..k)
.map(|i| mean[i] + (0..=i).map(|j| l[i][j] * zs[j]).sum::<f64>())
.collect();
out.push(x);
}
Ok(out)
}
}
fn cholesky_f64(a: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, SymplexError> {
let n = a.len();
let mut l = vec![vec![0.0; n]; n];
for j in 0..n {
let d = a[j][j] - l[j][..j].iter().map(|v| v * v).sum::<f64>();
if d <= 0.0 || !d.is_finite() {
return Err(failed(format!(
"sample: the covariance is not numerically positive definite (pivot {j} = {d})"
)));
}
let ljj = d.sqrt();
l[j][j] = ljj;
for i in (j + 1)..n {
let s: f64 = l[i][..j].iter().zip(&l[j][..j]).map(|(x, y)| x * y).sum();
l[i][j] = (a[i][j] - s) / ljj;
}
}
Ok(l)
}
fn check_data(data: &[Vec<Q>], what: &str) -> Result<(usize, usize), SymplexError> {
let n = data.len();
if n == 0 {
return Err(invalid(format!("{what}: no observations")));
}
let p = data[0].len();
if p == 0 {
return Err(invalid(format!("{what}: observations have no variables")));
}
if let Some((i, row)) = data.iter().enumerate().find(|(_, r)| r.len() != p) {
return Err(invalid(format!(
"{what}: observation {i} has {} variables, expected {p}",
row.len()
)));
}
Ok((n, p))
}
fn column(data: &[Vec<Q>], j: usize) -> Vec<Q> {
data.iter().map(|row| row[j].clone()).collect()
}
pub fn covariance_matrix(data: &[Vec<Q>], ddof: Ddof) -> Result<QMatrix, SymplexError> {
let (_, p) = check_data(data, "covariance_matrix")?;
let cols: Vec<Vec<Q>> = (0..p).map(|j| column(data, j)).collect();
let mut rows = vec![vec![Q::zero(); p]; p];
for i in 0..p {
for j in i..p {
let c = data::covariance(&cols[i], &cols[j], ddof)?;
rows[i][j] = c.clone();
rows[j][i] = c;
}
}
QMatrix::new(rows)
}
pub fn correlation_matrix(ctx: &Context, data: &[Vec<Q>]) -> Result<Matrix, SymplexError> {
let (_, p) = check_data(data, "correlation_matrix")?;
let cols: Vec<Vec<Q>> = (0..p).map(|j| column(data, j)).collect();
let mut rows = vec![vec![ctx.one(); p]; p];
for i in 0..p {
for j in (i + 1)..p {
let r = data::pearson(ctx, &cols[i], &cols[j])?;
rows[i][j] = r.clone();
rows[j][i] = r;
}
}
Matrix::new(rows)
}
#[derive(Clone, Debug, PartialEq)]
pub struct Pca {
pub eigenvalues: Vec<Ex>,
pub components: Vec<Vec<Ex>>,
pub explained_variance_ratio: Vec<Ex>,
}
pub fn pca(ctx: &Context, cov: &QMatrix) -> Result<Pca, SymplexError> {
if !cov.is_symmetric() {
return Err(invalid("pca: the covariance matrix must be symmetric"));
}
if !cov.is_positive_semidefinite() {
return Err(invalid(
"pca: the covariance matrix must be positive semidefinite",
));
}
let n = cov.nrows();
let trace = cov.diagonal().iter().fold(Q::zero(), |acc, v| acc + v);
if trace.is_zero() {
return Err(invalid("pca: the covariance matrix is zero"));
}
let m = cov.to_matrix(ctx);
let eigen = m
.eigenvects()
.map_err(|e| failed(format!("pca: eigen-decomposition failed: {e}")))?;
let mut items: Vec<(f64, Ex, Vec<Ex>)> = Vec::with_capacity(n);
for (value, mult, vecs) in eigen {
if vecs.len() != mult {
return Err(failed(format!(
"pca: eigenvalue {value} has geometric multiplicity {} < algebraic {mult}",
vecs.len()
)));
}
let approx = value
.eval_f64()
.map_err(|e| failed(format!("pca: cannot order eigenvalue {value}: {e}")))?;
let raw: Vec<Vec<Ex>> = vecs.iter().map(|v| v.col(0)).collect();
for v in gram_schmidt(ctx, &raw) {
items.push((approx, value.clone(), v));
}
}
if items.len() != n {
return Err(failed(format!(
"pca: found {} eigenvectors for dimension {n}",
items.len()
)));
}
items.sort_by(|a, b| b.0.total_cmp(&a.0));
let trace_ex = ctx.from_ratio(trace);
Ok(Pca {
explained_variance_ratio: items
.iter()
.map(|(_, v, _)| (v / &trace_ex).simplify())
.collect(),
eigenvalues: items.iter().map(|(_, v, _)| v.clone()).collect(),
components: items.into_iter().map(|(_, _, c)| c).collect(),
})
}
fn gram_schmidt(ctx: &Context, vecs: &[Vec<Ex>]) -> Vec<Vec<Ex>> {
let dot = |a: &[Ex], b: &[Ex]| -> Ex {
a.iter()
.zip(b)
.fold(ctx.zero(), |acc, (x, y)| acc + x * y)
.simplify()
};
let tidy = |e: &Ex| e.rationalize_denom().simplify();
let mut basis: Vec<Vec<Ex>> = Vec::with_capacity(vecs.len());
for v in vecs {
let mut w: Vec<Ex> = v.iter().map(tidy).collect();
for u in &basis {
let proj = dot(&w, u);
w = w
.iter()
.zip(u)
.map(|(wi, ui)| tidy(&(wi - &proj * ui)))
.collect();
}
let norm = dot(&w, &w).sqrt().simplify();
if norm.is_zero() == Some(true) {
continue;
}
let mut unit: Vec<Ex> = w.iter().map(|wi| tidy(&(wi / &norm))).collect();
let negative_lead = unit
.iter()
.find(|c| c.is_zero() != Some(true))
.is_some_and(is_negative_constant);
if negative_lead {
unit = unit.iter().map(|c| (-c).simplify()).collect();
}
basis.push(unit);
}
basis
}
fn is_negative_constant(e: &Ex) -> bool {
match e.is_negative() {
Some(b) => b,
None => e.eval_f64().is_ok_and(|v| v < 0.0),
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct PcaF64 {
pub eigenvalues: Vec<f64>,
pub components: Vec<Vec<f64>>,
pub explained_variance_ratio: Vec<f64>,
}
pub fn pca_f64(cov: &[Vec<f64>]) -> Result<PcaF64, SymplexError> {
let n = cov.len();
if n == 0 {
return Err(invalid("pca_f64: empty matrix"));
}
if cov.iter().any(|r| r.len() != n) {
return Err(invalid("pca_f64: the matrix must be square"));
}
let scale = cov
.iter()
.flatten()
.fold(0.0_f64, |m, v| m.max(v.abs()))
.max(1.0);
if !scale.is_finite() {
return Err(invalid("pca_f64: non-finite entry"));
}
for (i, row) in cov.iter().enumerate() {
for (j, &below) in row.iter().enumerate().take(i) {
let above = cov[j][i];
if (below - above).abs() > 1e-12 * scale {
return Err(invalid(format!(
"pca_f64: not symmetric at ({i}, {j}): {below} vs {above}"
)));
}
}
}
let (values, vectors) = jacobi_eigen(cov)?;
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| values[b].total_cmp(&values[a]));
let total: f64 = values.iter().sum();
let mut eigenvalues = Vec::with_capacity(n);
let mut components = Vec::with_capacity(n);
let mut ratio = Vec::with_capacity(n);
for &idx in &order {
let mut v: Vec<f64> = vectors.iter().map(|row| row[idx]).collect();
if v.iter()
.find(|c| c.abs() > 1e-12)
.is_some_and(|lead| *lead < 0.0)
{
for c in &mut v {
*c = -*c;
}
}
eigenvalues.push(values[idx]);
components.push(v);
ratio.push(if total != 0.0 {
values[idx] / total
} else {
f64::NAN
});
}
Ok(PcaF64 {
eigenvalues,
components,
explained_variance_ratio: ratio,
})
}
fn jacobi_eigen(a: &[Vec<f64>]) -> Result<(Vec<f64>, Vec<Vec<f64>>), SymplexError> {
let n = a.len();
let mut m: Vec<Vec<f64>> = a.to_vec();
let mut v = vec![vec![0.0; n]; n];
for (i, row) in v.iter_mut().enumerate() {
row[i] = 1.0;
}
let frob: f64 = m.iter().flatten().map(|x| x * x).sum::<f64>().sqrt();
let tol = 1e-15 * frob.max(f64::MIN_POSITIVE);
for _sweep in 0..100 {
let off: f64 = (0..n)
.flat_map(|i| (0..n).filter(move |&j| j != i).map(move |j| (i, j)))
.map(|(i, j)| m[i][j] * m[i][j])
.sum::<f64>()
.sqrt();
if off <= tol {
let values = (0..n).map(|i| m[i][i]).collect();
return Ok((values, v));
}
for p in 0..n {
for q in (p + 1)..n {
if m[p][q].abs() <= f64::MIN_POSITIVE {
continue;
}
let theta = (m[q][q] - m[p][p]) / (2.0 * m[p][q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let c = 1.0 / (t * t + 1.0).sqrt();
let s = t * c;
for row in m.iter_mut() {
let (mkp, mkq) = (row[p], row[q]);
row[p] = c * mkp - s * mkq;
row[q] = s * mkp + c * mkq;
}
let (top, bottom) = m.split_at_mut(q);
for (mpk, mqk) in top[p].iter_mut().zip(bottom[0].iter_mut()) {
let (a, b) = (*mpk, *mqk);
*mpk = c * a - s * b;
*mqk = s * a + c * b;
}
for row in v.iter_mut() {
let (vkp, vkq) = (row[p], row[q]);
row[p] = c * vkp - s * vkq;
row[q] = s * vkp + c * vkq;
}
}
}
}
Err(failed(
"pca_f64: the Jacobi sweeps did not converge in 100 iterations",
))
}
impl Pca {
pub fn explained_variance_ratio_f64(&self) -> Result<Vec<f64>, SymplexError> {
self.explained_variance_ratio
.iter()
.map(Ex::eval_f64)
.collect()
}
}