use crate::algorithms::decomposition::{mean_center, pca};
use crate::rng::SplitMix64;
const EPOCHS: usize = 500;
const CURVE_A: f64 = 1.577;
const CURVE_B: f64 = 0.895;
const LEARNING_RATE: f64 = 1.0;
const NEGATIVE_SAMPLES: usize = 5;
const CLAMP: f64 = 4.0;
#[must_use]
pub fn umap(
data: &[Vec<f64>],
n_components: usize,
n_neighbors: usize,
seed: u64,
) -> Vec<Vec<f64>> {
let n = data.len();
if n == 0 || n_components == 0 {
return Vec::new();
}
let k = n_neighbors.min(n.saturating_sub(1)).max(1);
let edges = fuzzy_edges(data, k);
let mut embedding = pca_init(data, n_components, seed);
optimize_layout(&mut embedding, &edges, n, n_components, seed);
embedding
}
struct Edge {
from: usize,
to: usize,
weight: f64,
}
fn fuzzy_edges(data: &[Vec<f64>], k: usize) -> Vec<Edge> {
let n = data.len();
let neighbours: Vec<Vec<(usize, f64)>> = (0..n)
.map(|i| {
let mut list: Vec<(usize, f64)> = (0..n)
.filter(|&j| j != i)
.map(|j| (j, distance(data.get(i), data.get(j))))
.collect();
list.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
list.truncate(k);
list
})
.collect();
let mut strength = vec![0.0_f64; n * n];
for (i, list) in neighbours.iter().enumerate() {
let rho = list.first().map_or(0.0, |&(_, d)| d);
let sigma = smooth_sigma(list, rho, k);
for &(j, d) in list {
let value = (-(d - rho).max(0.0) / sigma).exp();
if let Some(slot) = strength.get_mut(i * n + j) {
*slot = value;
}
}
}
let mut edges = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
let a = strength.get(i * n + j).copied().unwrap_or(0.0);
let b = strength.get(j * n + i).copied().unwrap_or(0.0);
let weight = a + b - a * b;
if weight > 1e-12 {
edges.push(Edge {
from: i,
to: j,
weight,
});
}
}
}
edges
}
fn smooth_sigma(list: &[(usize, f64)], rho: f64, k: usize) -> f64 {
let target = count_to_f64(k).log2().max(1e-3);
let mut lo = 0.0_f64;
let mut hi = f64::INFINITY;
let mut sigma = 1.0_f64;
for _ in 0..64 {
let sum: f64 = list
.iter()
.map(|&(_, d)| (-(d - rho).max(0.0) / sigma).exp())
.sum();
if (sum - target).abs() < 1e-5 {
break;
}
if sum > target {
hi = sigma;
sigma = f64::midpoint(lo, hi);
} else {
lo = sigma;
sigma = if hi.is_infinite() {
sigma * 2.0
} else {
f64::midpoint(lo, hi)
};
}
}
sigma.max(1e-3)
}
fn distance(a: Option<&Vec<f64>>, b: Option<&Vec<f64>>) -> f64 {
match (a, b) {
(Some(pa), Some(pb)) => pa
.iter()
.zip(pb)
.map(|(&x, &y)| {
let d = x - y;
d * d
})
.sum::<f64>()
.sqrt(),
_ => f64::INFINITY,
}
}
fn pca_init(data: &[Vec<f64>], n_components: usize, seed: u64) -> Vec<Vec<f64>> {
let dim = data.first().map_or(0, Vec::len);
let (centered, _means) = mean_center(data, dim);
let result = pca(data, n_components);
let mut rng = SplitMix64::new(seed);
centered
.iter()
.map(|row| {
result
.components
.iter()
.map(|component| {
let score: f64 = row.iter().zip(component).map(|(&x, &c)| x * c).sum();
1e-3_f64.mul_add(rng.standard_normal(), score)
})
.collect()
})
.collect()
}
fn optimize_layout(embedding: &mut [Vec<f64>], edges: &[Edge], n: usize, dim: usize, seed: u64) {
let mut rng = SplitMix64::new(seed ^ 0x5DEE_CE66);
for epoch in 0..EPOCHS {
let alpha = LEARNING_RATE * (1.0 - count_ratio(epoch, EPOCHS));
for edge in edges {
if rng.next_f64() > edge.weight {
continue;
}
attract(embedding, edge.from, edge.to, dim, alpha);
for _ in 0..NEGATIVE_SAMPLES {
let target = uniform_index(&mut rng, n);
if target != edge.from {
repel(embedding, edge.from, target, dim, alpha);
}
}
}
}
}
fn attract(embedding: &mut [Vec<f64>], i: usize, j: usize, dim: usize, alpha: f64) {
let dist_sq = pair_dist_sq(embedding, i, j, dim);
let denom = CURVE_A.mul_add(dist_sq.powf(CURVE_B), 1.0);
let coeff = -2.0 * CURVE_A * CURVE_B * dist_sq.max(1e-12).powf(CURVE_B - 1.0) / denom;
apply_force(embedding, i, j, dim, coeff, alpha);
}
fn repel(embedding: &mut [Vec<f64>], i: usize, j: usize, dim: usize, alpha: f64) {
let dist_sq = pair_dist_sq(embedding, i, j, dim).max(1e-12);
let denom = CURVE_A.mul_add(dist_sq.powf(CURVE_B), 1.0) * dist_sq;
let coeff = 2.0 * CURVE_B / denom;
apply_force(embedding, i, j, dim, coeff, alpha);
}
fn apply_force(embedding: &mut [Vec<f64>], i: usize, j: usize, dim: usize, coeff: f64, alpha: f64) {
for d in 0..dim {
let yi = coord(embedding, i, d);
let yj = coord(embedding, j, d);
let delta = (coeff * (yi - yj)).clamp(-CLAMP, CLAMP) * alpha;
set_coord(embedding, i, d, yi + delta);
set_coord(embedding, j, d, yj - delta);
}
}
fn pair_dist_sq(embedding: &[Vec<f64>], i: usize, j: usize, dim: usize) -> f64 {
(0..dim)
.map(|d| {
let delta = coord(embedding, i, d) - coord(embedding, j, d);
delta * delta
})
.sum()
}
fn coord(embedding: &[Vec<f64>], point: usize, dim: usize) -> f64 {
embedding
.get(point)
.and_then(|r| r.get(dim))
.copied()
.unwrap_or(0.0)
}
fn set_coord(embedding: &mut [Vec<f64>], point: usize, dim: usize, value: f64) {
if let Some(slot) = embedding.get_mut(point).and_then(|r| r.get_mut(dim)) {
*slot = value;
}
}
fn uniform_index(rng: &mut SplitMix64, n: usize) -> usize {
if n == 0 {
return 0;
}
let scaled = rng.next_f64() * count_to_f64(n);
let mut idx = 0_usize;
let mut bound = 1.0_f64;
while bound <= scaled && idx + 1 < n {
idx += 1;
bound += 1.0;
}
idx
}
fn count_to_f64(n: usize) -> f64 {
crate::algorithms::decomposition::count_to_f64(n)
}
fn count_ratio(epoch: usize, total: usize) -> f64 {
if total == 0 {
return 0.0;
}
count_to_f64(epoch) / count_to_f64(total)
}
#[cfg(test)]
mod tests {
use super::*;
fn two_clusters() -> Vec<Vec<f64>> {
(0_usize..20)
.map(|i| {
let base = if i < 10 { 0.0 } else { 20.0 };
let jitter = count_to_f64(i % 10) * 0.05;
vec![base + jitter, jitter, 0.0]
})
.collect()
}
#[test]
fn keeps_clusters_separated() {
let data = two_clusters();
let embedding = umap(&data, 2, 5, 7);
assert_eq!(embedding.len(), 20, "embedding row count");
let within = pair_distance(&embedding, 0, 1);
let across = pair_distance(&embedding, 0, 15);
assert!(within < across, "within {within} not < across {across}");
}
#[test]
fn deterministic_for_fixed_seed() {
let data = two_clusters();
let a = umap(&data, 2, 5, 3);
let b = umap(&data, 2, 5, 3);
let fa = a.first().and_then(|r| r.first()).copied().unwrap_or(0.0);
let fb = b.first().and_then(|r| r.first()).copied().unwrap_or(0.0);
assert_eq!(fa.to_bits(), fb.to_bits(), "non-deterministic embedding");
}
#[test]
fn empty_input_is_empty() {
assert!(umap(&[], 2, 5, 0).is_empty());
}
fn pair_distance(embedding: &[Vec<f64>], i: usize, j: usize) -> f64 {
pair_dist_sq(embedding, i, j, 2).sqrt()
}
}