use ndarray::{Array1, ArrayView1};
use skel::Manifold;
#[derive(Debug, Clone)]
pub struct FisherRaoSimplex {
tol: f64,
}
impl FisherRaoSimplex {
pub fn new(tol: f64) -> Self {
Self { tol }
}
}
impl Default for FisherRaoSimplex {
fn default() -> Self {
Self { tol: 1e-12 }
}
}
fn to_sphere(p: &ArrayView1<f64>) -> Array1<f64> {
p.mapv(|pi| pi.max(0.0).sqrt())
}
fn to_simplex(theta: &Array1<f64>) -> Array1<f64> {
let sq = theta.mapv(|t| t * t);
let s: f64 = sq.sum();
if s > 0.0 {
sq / s
} else {
sq
}
}
fn tangent_lift(p: &ArrayView1<f64>, v: &ArrayView1<f64>) -> Array1<f64> {
Array1::from_iter(p.iter().zip(v.iter()).map(|(&pi, &vi)| {
let sq = pi.max(1e-300).sqrt();
vi / (2.0 * sq)
}))
}
fn tangent_drop(p: &ArrayView1<f64>, u: &Array1<f64>) -> Array1<f64> {
Array1::from_iter(
p.iter()
.zip(u.iter())
.map(|(&pi, &ui)| 2.0 * pi.max(0.0).sqrt() * ui),
)
}
impl Manifold for FisherRaoSimplex {
fn exp_map(&self, x: &ArrayView1<f64>, v: &ArrayView1<f64>) -> Array1<f64> {
let theta = to_sphere(x);
let u = tangent_lift(x, v);
let norm = u.dot(&u).sqrt();
if norm < self.tol {
return x.to_owned();
}
let result = &theta * norm.cos() + &u * (norm.sin() / norm);
to_simplex(&result)
}
fn log_map(&self, x: &ArrayView1<f64>, y: &ArrayView1<f64>) -> Array1<f64> {
let theta_x = to_sphere(x);
let theta_y = to_sphere(y);
let dot = theta_x.dot(&theta_y).clamp(-1.0, 1.0);
let omega = dot.acos();
if omega < self.tol {
return Array1::zeros(x.len());
}
let u_unnorm = &theta_y - &theta_x * dot;
let u_norm = u_unnorm.dot(&u_unnorm).sqrt();
if u_norm < self.tol {
return Array1::zeros(x.len());
}
let u = &u_unnorm * (omega / u_norm);
tangent_drop(x, &u)
}
fn parallel_transport(
&self,
x: &ArrayView1<f64>,
y: &ArrayView1<f64>,
v: &ArrayView1<f64>,
) -> Array1<f64> {
let theta_x = to_sphere(x);
let theta_y = to_sphere(y);
let u = tangent_lift(x, v);
let dot_xy = theta_x.dot(&theta_y).clamp(-1.0, 1.0);
let omega = dot_xy.acos();
if omega < self.tol {
return v.to_owned();
}
let proj = &theta_y - &theta_x * dot_xy;
let proj_norm = proj.dot(&proj).sqrt();
if proj_norm < self.tol {
return v.to_owned();
}
let e = &proj / proj_norm;
let eu = e.dot(&u);
let xu = theta_x.dot(&u);
let transported = &u
+ &e * (-omega.sin() * xu + (omega.cos() - 1.0) * eu)
+ &theta_x * (-omega.sin() * eu - (omega.cos() - 1.0) * xu);
tangent_drop(y, &transported)
}
fn project(&self, x: &ArrayView1<f64>) -> Array1<f64> {
let clamped = x.mapv(|v| v.max(0.0));
let s: f64 = clamped.sum();
if s > 0.0 {
clamped / s
} else {
let n = x.len() as f64;
Array1::from_elem(x.len(), 1.0 / n)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
use proptest::prelude::*;
fn manifold() -> FisherRaoSimplex {
FisherRaoSimplex::new(1e-12)
}
fn positive_simplex(len: usize) -> impl Strategy<Value = Array1<f64>> {
prop::collection::vec(0.01f64..10.0, len).prop_map(|v| {
let s: f64 = v.iter().sum();
Array1::from_iter(v.iter().map(|&x| x / s))
})
}
proptest! {
#[test]
fn round_trip_exp_log(
p in positive_simplex(5),
q in positive_simplex(5),
) {
let m = manifold();
let v = m.log_map(&p.view(), &q.view());
let q_recovered = m.exp_map(&p.view(), &v.view());
for i in 0..5 {
prop_assert!((q_recovered[i] - q[i]).abs() < 1e-8,
"round-trip failed at {i}: recovered={} expected={}", q_recovered[i], q[i]);
}
}
#[test]
fn round_trip_log_exp(
p in positive_simplex(5),
q in positive_simplex(5),
) {
let m = manifold();
let v = m.log_map(&p.view(), &q.view());
let q2 = m.exp_map(&p.view(), &v.view());
let v2 = m.log_map(&p.view(), &q2.view());
for i in 0..5 {
prop_assert!((v2[i] - v[i]).abs() < 1e-8,
"log-exp round-trip failed at {i}: v2={} v={}", v2[i], v[i]);
}
}
#[test]
fn log_norm_equals_rao_distance(
p in positive_simplex(6),
q in positive_simplex(6),
) {
let m = manifold();
let v = m.log_map(&p.view(), &q.view());
let sphere_u = tangent_lift(&p.view(), &v.view());
let log_norm = sphere_u.dot(&sphere_u).sqrt();
let rao = crate::rao_distance_categorical(
p.as_slice().unwrap(), q.as_slice().unwrap(), 1e-12
).unwrap();
prop_assert!((log_norm - rao / 2.0).abs() < 1e-8,
"norm mismatch: log_norm={log_norm} rao/2={}", rao / 2.0);
}
#[test]
fn transport_preserves_norm(
p in positive_simplex(5),
q in positive_simplex(5),
r in positive_simplex(5),
) {
let m = manifold();
let v = m.log_map(&p.view(), &r.view());
let tv = m.parallel_transport(&p.view(), &q.view(), &v.view());
let u_p = tangent_lift(&p.view(), &v.view());
let u_q = tangent_lift(&q.view(), &tv.view());
let norm_p = u_p.dot(&u_p).sqrt();
let norm_q = u_q.dot(&u_q).sqrt();
prop_assert!((norm_p - norm_q).abs() < 1e-6,
"transport changed norm: {norm_p} -> {norm_q}");
}
#[test]
fn self_transport_identity(
p in positive_simplex(5),
q in positive_simplex(5),
) {
let m = manifold();
let v = m.log_map(&p.view(), &q.view());
let tv = m.parallel_transport(&p.view(), &p.view(), &v.view());
for i in 0..5 {
prop_assert!((tv[i] - v[i]).abs() < 1e-10,
"self-transport not identity at {i}: tv={} v={}", tv[i], v[i]);
}
}
}
#[test]
fn exp_zero_is_identity() {
let m = manifold();
let p = array![0.3, 0.5, 0.2];
let zero = Array1::zeros(3);
let result = m.exp_map(&p.view(), &zero.view());
for i in 0..3 {
assert!((result[i] - p[i]).abs() < 1e-14);
}
}
#[test]
fn log_self_is_zero() {
let m = manifold();
let p = array![0.3, 0.5, 0.2];
let v = m.log_map(&p.view(), &p.view());
for i in 0..3 {
assert!(v[i].abs() < 1e-14, "log(p,p)[{i}] = {}", v[i]);
}
}
#[test]
fn project_renormalizes() {
let m = manifold();
let x = array![0.3, 0.5, -0.1, 0.4];
let p = m.project(&x.view());
assert!(p.iter().all(|&v| v >= 0.0));
assert!(((p.sum()) - 1.0).abs() < 1e-14);
}
}