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, Copy, Debug, PartialEq, Eq)]
enum Singular {
Refuse,
Defer,
}
#[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 mvn = MultivariateNormal { mean, cov };
mvn.check_parameters(OP, Singular::Refuse)?;
Ok(mvn)
}
pub fn new(mean: Vec<Ex>, cov: Matrix) -> Self {
MultivariateNormal { mean, cov }
}
fn check_parameters(&self, op: &'static str, singular: Singular) -> Result<(), SymplexError> {
let (k, cov) = (self.dim(), &self.cov);
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"));
}
let defer = singular == Singular::Defer;
match QMatrix::try_from(cov) {
Ok(q) => match q.ldl_psd() {
Some((_, d)) if defer || d.iter().all(|v| v.is_positive()) => Ok(()),
_ => Err(invalid(
op,
"the covariance matrix must be positive definite (exact L·D·Lᵀ test failed)",
)),
},
Err(_) => {
if cov.is_positive_definite() == Some(false)
&& !(defer
&& cov
.det()
.is_ok_and(|d| d.simplify().is_zero() == Some(true)))
{
return Err(invalid(
op,
"the covariance matrix must be positive definite (a leading minor is not positive)",
));
}
Ok(())
}
}
}
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.precision_for("MultivariateNormal::precision")
}
fn precision_for(&self, op: &'static str) -> Result<Matrix, SymplexError> {
self.check_parameters(op, Singular::Defer)?;
self.cov
.inv()
.map_err(|e| failed(op, format!("the covariance matrix is not invertible: {e}")))
}
fn quadratic_form(&self, op: &'static str, x: &[Ex]) -> Result<Ex, SymplexError> {
let prec = self.precision_for(op)?;
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> {
const OP: &str = "MultivariateNormal::mahalanobis_squared";
self.check_point(OP, x)?;
self.quadratic_form(OP, 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(OP, 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";
self.check_parameters(OP, Singular::Defer)?;
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(),
)
.map_err(wrap)?;
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()).map_err(wrap)?;
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> {
const OP: &str = "MultivariateNormal::sample";
self.check_parameters(OP, Singular::Defer)?;
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(OP, "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::new();
out.try_reserve_exact(n)
.map_err(|e| invalid(OP, format!("cannot store {n} samples: {e}")))?;
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> {
const OP: &str = "correlation_matrix";
let (_, p) = check_data(OP, data)?;
let cols: Vec<Vec<Q>> = (0..p).map(|j| column(data, j)).collect();
if let Some(j) = cols.iter().position(|c| c.iter().all(|v| v == &c[0])) {
return Err(invalid(
OP,
format!("variable {j} is constant, so its correlations are undefined"),
));
}
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<(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()
),
));
}
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) {
let mut pos = items.len();
while pos > 0 {
let before = &items[pos - 1].0;
match before.compare_numeric(&value) {
Some(std::cmp::Ordering::Less) => pos -= 1,
Some(_) => break,
None => {
return Err(failed(
OP,
format!("cannot order the eigenvalues {before} and {value}"),
));
}
}
}
items.insert(pos, (value.clone(), v));
}
}
if items.len() != n {
return Err(failed(
OP,
format!("found {} eigenvectors for dimension {n}", items.len()),
));
}
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"));
}
if let Some(v) = cov.iter().flatten().find(|v| !v.is_finite()) {
return Err(invalid(OP, format!("non-finite entry {v}")));
}
let scale = cov.iter().flatten().fold(0.0_f64, |m, v| m.max(v.abs()));
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 e = if scale > 0.0 {
scale.log2().floor() as i32 + 1
} else {
0
};
let to_unit = |v: f64| v * 2f64.powi(-e / 2) * 2f64.powi(-(e - e / 2));
let scaled: Vec<f64> = cov.iter().flatten().map(|&v| to_unit(v)).collect();
let trace: f64 = (0..n).map(|i| scaled[i * n + i]).sum();
if trace <= 0.0 {
return Err(invalid(
OP,
format!(
"the trace (total variance) must be positive, got {}",
trace * 2f64.powi(e)
),
));
}
let dense_f64::SymEigen { values, vectors } =
dense_f64::sym_eigen(&scaled, 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 from_unit = |v: f64| v * 2f64.powi(e / 2) * 2f64.powi(e - e / 2);
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;
}
}
let value = from_unit(values[idx]);
if !value.is_finite() {
return Err(failed(
OP,
format!("an eigenvalue ({} · 2^{e}) overflows f64", values[idx]),
));
}
eigenvalues.push(value);
components.push(v);
ratio.push(values[idx] / trace);
}
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()
}
}