use num_traits::{Signed, Zero};
use crate::api::context::Context;
use crate::api::expr::Ex;
use crate::base::dense_f64::{self, EigenOpts, EigenTol, OnExhaust};
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;
use super::common::invalid;
fn failed(op: &'static str, 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> {
const OP: &str = "MultivariateNormal::try_new";
let k = mean.len();
if k == 0 {
return Err(invalid(
OP,
"the mean vector must have at least one coordinate",
));
}
if cov.shape() != (k, k) {
return Err(invalid(
OP,
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(OP, "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(
OP,
"the covariance matrix must be positive definite (exact L·D·Lᵀ test failed)",
));
}
},
Err(_) => {
if cov.is_positive_definite() == Some(false) {
return Err(invalid(
OP,
"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, op: &'static str, x: &[Ex]) -> Result<(), SymplexError> {
if x.len() != self.dim() {
return Err(invalid(
op,
format!(
"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(
"MultivariateNormal::precision",
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("MultivariateNormal::mahalanobis_squared", x)?;
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> {
const OP: &str = "MultivariateNormal::density";
self.check_point(OP, x)?;
let ctx = self.context();
let quad = self.quadratic_form(x)?;
let det = self
.cov
.det()
.map_err(|e| failed(OP, format!("determinant of the covariance: {e}")))?
.simplify();
let k = i64::try_from(self.dim()).map_err(|_| invalid(OP, "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> {
const OP: &str = "MultivariateNormal::entropy";
let ctx = self.context();
let det = self
.cov
.det()
.map_err(|e| failed(OP, format!("determinant of the covariance: {e}")))?
.simplify();
let k = i64::try_from(self.dim()).map_err(|_| invalid(OP, "dimension too large"))?;
Ok(ctx.rational(1, 2) * ((ctx.int(2) * ctx.pi() * ctx.e()).powi(k) * det).ln())
}
fn check_indices(&self, op: &'static str, indices: &[usize]) -> Result<(), SymplexError> {
if indices.is_empty() {
return Err(invalid(op, "at least one coordinate is needed"));
}
for (pos, &i) in indices.iter().enumerate() {
if i >= self.dim() {
return Err(invalid(
op,
format!(
"coordinate index {i} out of range for dimension {}",
self.dim()
),
));
}
if indices[..pos].contains(&i) {
return Err(invalid(op, format!("coordinate index {i} listed twice")));
}
}
Ok(())
}
pub fn marginal(&self, indices: &[usize]) -> Result<MultivariateNormal, SymplexError> {
const OP: &str = "MultivariateNormal::marginal";
self.check_indices(OP, indices)?;
let mean = indices.iter().map(|&i| self.mean[i].clone()).collect();
let cov = self
.cov
.extract(indices, indices)
.map_err(|e| failed(OP, e.to_string()))?;
Ok(MultivariateNormal { mean, cov })
}
pub fn marginal_1d(&self, i: usize) -> Result<Distribution, SymplexError> {
self.check_indices("MultivariateNormal::marginal_1d", &[i])?;
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> {
const OP: &str = "MultivariateNormal::conditional";
let b: Vec<usize> = given.iter().map(|(i, _)| *i).collect();
self.check_indices(OP, &b)?;
let a: Vec<usize> = (0..self.dim()).filter(|i| !b.contains(i)).collect();
if a.is_empty() {
return Err(invalid(
OP,
"conditioning on every coordinate leaves nothing to distribute",
));
}
let wrap = |e: SymplexError| failed(OP, e.to_string());
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> {
const OP: &str = "MultivariateNormal::affine";
if a.ncols() != self.dim() {
return Err(invalid(
OP,
format!(
"A has {} columns but the distribution has dimension {}",
a.ncols(),
self.dim()
),
));
}
if b.len() != a.nrows() {
return Err(invalid(
OP,
format!("b has {} entries but A has {} rows", b.len(), a.nrows()),
));
}
let wrap = |e: SymplexError| failed(OP, e.to_string());
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 = dense_f64::cholesky(&dense_f64::flatten(&cov), k, 0.0).ok_or_else(|| {
failed(
"MultivariateNormal::sample",
"the covariance is not numerically positive definite",
)
})?;
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] + dense_f64::dot(&l[i * k..i * k + i + 1], &zs))
.collect();
out.push(x);
}
Ok(out)
}
}
fn check_data(op: &'static str, data: &[Vec<Q>]) -> Result<(usize, usize), SymplexError> {
let n = data.len();
if n == 0 {
return Err(invalid(op, "no observations"));
}
let p = data[0].len();
if p == 0 {
return Err(invalid(op, "observations have no variables"));
}
if let Some((i, row)) = data.iter().enumerate().find(|(_, r)| r.len() != p) {
return Err(invalid(
op,
format!("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("covariance_matrix", data)?;
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("correlation_matrix", data)?;
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> {
const OP: &str = "pca";
if !cov.is_symmetric() {
return Err(invalid(OP, "the covariance matrix must be symmetric"));
}
if !cov.is_positive_semidefinite() {
return Err(invalid(
OP,
"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(OP, "the covariance matrix is zero"));
}
let m = cov.to_matrix(ctx);
let eigen = m
.eigenvects()
.map_err(|e| failed(OP, format!("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(
OP,
format!(
"eigenvalue {value} has geometric multiplicity {} < algebraic {mult}",
vecs.len()
),
));
}
let approx = value
.eval_f64()
.map_err(|e| failed(OP, format!("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(
OP,
format!("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> {
const OP: &str = "pca_f64";
let n = cov.len();
if n == 0 {
return Err(invalid(OP, "empty matrix"));
}
if cov.iter().any(|r| r.len() != n) {
return Err(invalid(OP, "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(OP, "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(
OP,
format!("not symmetric at ({i}, {j}): {below} vs {above}"),
));
}
}
}
let dense_f64::SymEigen { values, vectors } =
dense_f64::sym_eigen(&dense_f64::flatten(cov), n, &PCA_EIGEN)
.map_err(|e| failed(OP, e.to_string()))?;
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> = (0..n).map(|i| vectors[i * n + 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,
})
}
const PCA_EIGEN: EigenOpts = EigenOpts {
tol: EigenTol::RelativeFrobenius(1e-15),
max_sweeps: 100,
on_exhaust: OnExhaust::Error,
};
impl Pca {
pub fn explained_variance_ratio_f64(&self) -> Result<Vec<f64>, SymplexError> {
self.explained_variance_ratio
.iter()
.map(Ex::eval_f64)
.collect()
}
}