pub mod adam;
pub mod config;
pub mod error;
pub mod gradient;
pub mod knn;
pub mod pairs;
pub mod pca;
pub mod weights;
pub use config::{
DistanceMetric, HnswParams, Init, KnnMethod, PaCMAPConfig, Quantization,
};
pub use error::PaCMAPError;
use adam::{AdamState, adam_step};
use gradient::compute_gradient;
use knn::compute_knn;
use pairs::build_pairs;
use pca::pca_init;
use weights::weights_at;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
};
#[derive(Debug, Clone)]
pub struct PaCMAPProgress {
pub phase: u8,
pub iter: usize,
pub total_iters: usize,
pub loss: f32,
}
pub fn fit_transform(
data: &[f32],
n: usize,
d: usize,
config: PaCMAPConfig,
progress: Option<mpsc::Sender<PaCMAPProgress>>,
cancel: Option<Arc<AtomicBool>>,
) -> Result<Vec<[f32; 2]>, PaCMAPError> {
if n < 2 {
return Err(PaCMAPError::DatasetTooSmall { n });
}
if data.len() != n * d {
return Err(PaCMAPError::DimensionMismatch { len: data.len(), d });
}
if n > u32::MAX as usize {
return Err(PaCMAPError::DatasetTooLarge { n });
}
let n_nb = config.n_neighbors.min(n - 1);
let n_mn = config.n_mn();
let n_fp = config.n_fp();
let total_iters = config.total_iters();
let k_candidates = (n_nb + 50).min(n - 1);
let knn = compute_knn(data, n, d, k_candidates, &config.knn_method, config.distance_metric)?;
let pairs = build_pairs(&knn, data, n, d, n_nb, n_mn, n_fp, config.seed)?;
drop(knn);
let mut embedding: Vec<[f32; 2]> = match &config.init {
Init::Pca => pca_init(data, n, d)?,
Init::Random(seed) => {
use rand::{RngExt, SeedableRng, rngs::SmallRng};
let mut rng = match seed {
Some(s) => SmallRng::seed_from_u64(*s),
None => rand::make_rng::<SmallRng>(),
};
let scale = (1e-4_f32).sqrt();
(0..n)
.map(|_| [rng.random::<f32>() * scale, rng.random::<f32>() * scale])
.collect()
}
};
let mut adam = AdamState::new(n);
let mut global_iter = 0usize;
for (phase_idx, &phase_len) in config.phase_iters.iter().enumerate() {
let phase = (phase_idx + 1) as u8;
for local_iter in 0..phase_len {
if let Some(ref cancel) = cancel {
if cancel.load(Ordering::Relaxed) {
return Err(PaCMAPError::Cancelled);
}
}
global_iter += 1;
let w = weights_at(global_iter, &config.phase_iters);
let (grad, loss) = compute_gradient(
&embedding,
&pairs.near,
&pairs.mid_near,
&pairs.further,
&w,
n,
);
adam_step(&mut embedding, &grad, &mut adam, global_iter, config.learning_rate);
if let Some(ref tx) = progress {
if local_iter == 0 || local_iter == phase_len - 1 || global_iter % 10 == 0 {
let _ = tx.send(PaCMAPProgress {
phase,
iter: global_iter,
total_iters,
loss,
});
}
}
}
}
Ok(embedding)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_cluster_separation() {
use rand::{RngExt, SeedableRng, rngs::SmallRng};
let mut rng = SmallRng::seed_from_u64(0);
let n_per_cluster = 100;
let n = n_per_cluster * 2;
let d = 10;
let mut data = Vec::with_capacity(n * d);
for _ in 0..n_per_cluster {
for _ in 0..d {
data.push(rng.random::<f32>() * 0.5);
}
}
for _ in 0..n_per_cluster {
for _ in 0..d {
data.push(5.0 + rng.random::<f32>() * 0.5);
}
}
let config = PaCMAPConfig {
n_neighbors: 5,
phase_iters: [50, 50, 100],
knn_method: KnnMethod::Exact,
init: Init::Random(Some(42)),
..Default::default()
};
let emb = fit_transform(&data, n, d, config, None, None).unwrap();
assert_eq!(emb.len(), n);
let c_a: [f32; 2] = {
let sx: f32 = emb[..n_per_cluster].iter().map(|p| p[0]).sum();
let sy: f32 = emb[..n_per_cluster].iter().map(|p| p[1]).sum();
[sx / n_per_cluster as f32, sy / n_per_cluster as f32]
};
let c_b: [f32; 2] = {
let sx: f32 = emb[n_per_cluster..].iter().map(|p| p[0]).sum();
let sy: f32 = emb[n_per_cluster..].iter().map(|p| p[1]).sum();
[sx / n_per_cluster as f32, sy / n_per_cluster as f32]
};
let sep = ((c_a[0] - c_b[0]).powi(2) + (c_a[1] - c_b[1]).powi(2)).sqrt();
assert!(
sep > 0.5,
"cluster centroids should be separated in embedding (got {sep:.3})"
);
}
}