Skip to main content

flow_pacmap/
lib.rs

1//! # flow-pacmap
2//!
3//! First-party implementation of PaCMAP (Pairwise Controlled Manifold
4//! Approximation Projection) from Wang et al. 2021 (JMLR 22, Algorithm 1).
5//!
6//! Designed for large-n flow cytometry data:
7//! - No ndarray version conflicts — pure `&[f32]` / `Vec<[f32;2]>` API
8//! - PCA via faer SVD on the d×d covariance matrix (O(n·d²), no large intermediates)
9//! - HNSW KNN via usearch (C++ FFI, hardware SIMD, optional f16 quantization)
10//! - All pair counts use `checked_mul`; no debug-mode overflow panics
11//! - Progress reporting via `mpsc::Sender<PaCMAPProgress>` (per phase + every 10 iters)
12//! - Cancellation via `Arc<AtomicBool>`
13//! - Staged KNN: [`compute_knn`] → [`KnnGraph`] → optional input to [`fit_transform`]
14
15pub mod adam;
16pub mod config;
17pub mod error;
18pub mod gradient;
19#[cfg(feature = "cubecl")]
20pub mod gpu;
21pub mod knn;
22pub mod pairs;
23pub mod pca;
24pub mod weights;
25
26pub use config::{
27    DistanceMetric, HnswParams, Init, KnnMethod, OptimizeBackend, PaCMAPConfig, Quantization,
28};
29pub use error::PaCMAPError;
30pub use knn::{KnnGraph, NeighborList, compute_knn, read_knn_graph, validate_knn_for_pacmap, write_knn_graph};
31
32use adam::{AdamState, adam_step};
33use gradient::compute_gradient;
34use pairs::build_pairs;
35use pca::pca_init;
36use weights::weights_at;
37
38use std::sync::{
39    Arc,
40    atomic::{AtomicBool, Ordering},
41    mpsc,
42};
43
44/// Progress event emitted during optimization.
45#[derive(Debug, Clone)]
46pub struct PaCMAPProgress {
47    /// Current optimization phase (1, 2, or 3).
48    pub phase: u8,
49    /// Current iteration number (1-indexed).
50    pub iter: usize,
51    /// Total iterations across all phases.
52    pub total_iters: usize,
53    /// Loss value at this iteration.
54    pub loss: f32,
55}
56
57/// Embed `n × d` row-major f32 data into 2 dimensions.
58///
59/// # Arguments
60/// - `data`: flat row-major slice, `len = n * d`
61/// - `n`: number of points; must be ≥ 2 and ≤ `u32::MAX`
62/// - `d`: number of dimensions per point; must be ≥ 1
63/// - `config`: algorithm configuration
64/// - `knn`: optional precomputed [`KnnGraph`]. When `None`, KNN is computed
65///   internally (`n_neighbors + 50` candidates, capped by `n − 1`). When
66///   `Some`, search is skipped after validating `n` / `k` / metric.
67/// - `progress`: optional channel for per-iteration progress events
68/// - `cancel`: optional cancellation token; checked once per iteration
69///
70/// # Returns
71/// `n` `[f32; 2]` pairs aligned with the input rows, or a `PaCMAPError`.
72///
73/// # Breaking change
74/// As of 0.1.2 the signature inserts `knn: Option<&KnnGraph>` after `config`.
75/// One-shot callers pass `None`; staged callers pass `Some(&graph)` from
76/// [`compute_knn`].
77pub fn fit_transform(
78    data: &[f32],
79    n: usize,
80    d: usize,
81    config: PaCMAPConfig,
82    knn: Option<&KnnGraph>,
83    progress: Option<mpsc::Sender<PaCMAPProgress>>,
84    cancel: Option<Arc<AtomicBool>>,
85) -> Result<Vec<[f32; 2]>, PaCMAPError> {
86    // ── Input validation ──────────────────────────────────────────────────
87    if n < 2 {
88        return Err(PaCMAPError::DatasetTooSmall { n });
89    }
90    if data.len() != n * d {
91        return Err(PaCMAPError::DimensionMismatch { len: data.len(), d });
92    }
93    if n > u32::MAX as usize {
94        return Err(PaCMAPError::DatasetTooLarge { n });
95    }
96
97    let n_nb = config.n_neighbors.min(n - 1);
98    let n_mn = config.n_mn();
99    let n_fp = config.n_fp();
100    let total_iters = config.total_iters();
101
102    // ── KNN construction (or reuse) + pair sampling ───────────────────────
103    // Request min(n_nb + 50, n-1) candidates; rerank by scaled distance inside build_pairs.
104    // Owned graphs are dropped when this block ends (before embedding allocation).
105    let pairs = {
106        let k_candidates = KnnGraph::required_k_for_pacmap(n, config.n_neighbors);
107        let owned_knn;
108        let knn_graph: &KnnGraph = if let Some(graph) = knn {
109            validate_knn_for_pacmap(graph, n, config.n_neighbors, config.distance_metric)?;
110            graph
111        } else {
112            owned_knn = compute_knn(
113                data,
114                n,
115                d,
116                k_candidates,
117                &config.knn_method,
118                config.distance_metric,
119            )?;
120            &owned_knn
121        };
122        build_pairs(
123            &knn_graph.neighbors,
124            data,
125            n,
126            d,
127            n_nb,
128            n_mn,
129            n_fp,
130            config.seed,
131        )?
132    };
133
134    // ── Initialisation ────────────────────────────────────────────────────
135    let mut embedding: Vec<[f32; 2]> = match &config.init {
136        Init::Pca => pca_init(data, n, d)?,
137        Init::Random(seed) => {
138            use rand::{RngExt, SeedableRng, rngs::SmallRng};
139            let mut rng = match seed {
140                Some(s) => SmallRng::seed_from_u64(*s),
141                None => rand::make_rng::<SmallRng>(),
142            };
143            let scale = (1e-4_f32).sqrt();
144            (0..n)
145                .map(|_| [rng.random::<f32>() * scale, rng.random::<f32>() * scale])
146                .collect()
147        }
148    };
149
150    // ── Optimization (Adam, 3-phase weight schedule) ──────────────────────
151    #[cfg(feature = "cubecl")]
152    if matches!(config.optimize_backend, OptimizeBackend::Gpu) {
153        crate::gpu::optimize_embedding_gpu(
154            &mut embedding,
155            &pairs.near,
156            &pairs.mid_near,
157            &pairs.further,
158            &config.phase_iters,
159            config.learning_rate,
160            cancel,
161        )?;
162        return Ok(embedding);
163    }
164
165    let mut adam = AdamState::new(n);
166    let mut global_iter = 0usize;
167
168    for (phase_idx, &phase_len) in config.phase_iters.iter().enumerate() {
169        let phase = (phase_idx + 1) as u8;
170
171        for local_iter in 0..phase_len {
172            // Cancellation check
173            if let Some(ref cancel) = cancel
174                && cancel.load(Ordering::Relaxed)
175            {
176                return Err(PaCMAPError::Cancelled);
177            }
178
179            global_iter += 1;
180            let w = weights_at(global_iter, &config.phase_iters);
181
182            let (grad, loss) = compute_gradient(
183                &embedding,
184                &pairs.near,
185                &pairs.mid_near,
186                &pairs.further,
187                &w,
188                n,
189            );
190
191            adam_step(
192                &mut embedding,
193                &grad,
194                &mut adam,
195                global_iter,
196                config.learning_rate,
197            );
198
199            // Emit progress every 10 iterations and at phase boundaries
200            if let Some(ref tx) = progress
201                && (local_iter == 0
202                    || local_iter == phase_len - 1
203                    || global_iter.is_multiple_of(10))
204            {
205                let _ = tx.send(PaCMAPProgress {
206                    phase,
207                    iter: global_iter,
208                    total_iters,
209                    loss,
210                });
211            }
212        }
213    }
214
215    Ok(embedding)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn two_cluster_data(n_per_cluster: usize, d: usize) -> (Vec<f32>, usize) {
223        use rand::{RngExt, SeedableRng, rngs::SmallRng};
224        let mut rng = SmallRng::seed_from_u64(0);
225        let n = n_per_cluster * 2;
226        let mut data = Vec::with_capacity(n * d);
227        for _ in 0..n_per_cluster {
228            for _ in 0..d {
229                data.push(rng.random::<f32>() * 0.5);
230            }
231        }
232        for _ in 0..n_per_cluster {
233            for _ in 0..d {
234                data.push(5.0 + rng.random::<f32>() * 0.5);
235            }
236        }
237        (data, n)
238    }
239
240    fn base_config() -> PaCMAPConfig {
241        PaCMAPConfig {
242            n_neighbors: 5,
243            phase_iters: [50, 50, 100],
244            knn_method: KnnMethod::Exact,
245            init: Init::Random(Some(42)),
246            seed: Some(7),
247            ..Default::default()
248        }
249    }
250
251    /// Smoke test: two well-separated Gaussian clusters should remain separated
252    /// after embedding.
253    #[test]
254    fn two_cluster_separation() {
255        let n_per_cluster = 100;
256        let d = 10;
257        let (data, n) = two_cluster_data(n_per_cluster, d);
258        let config = base_config();
259
260        let emb = fit_transform(&data, n, d, config, None, None, None).unwrap();
261        assert_eq!(emb.len(), n);
262
263        let c_a: [f32; 2] = {
264            let sx: f32 = emb[..n_per_cluster].iter().map(|p| p[0]).sum();
265            let sy: f32 = emb[..n_per_cluster].iter().map(|p| p[1]).sum();
266            [sx / n_per_cluster as f32, sy / n_per_cluster as f32]
267        };
268        let c_b: [f32; 2] = {
269            let sx: f32 = emb[n_per_cluster..].iter().map(|p| p[0]).sum();
270            let sy: f32 = emb[n_per_cluster..].iter().map(|p| p[1]).sum();
271            [sx / n_per_cluster as f32, sy / n_per_cluster as f32]
272        };
273        let sep = ((c_a[0] - c_b[0]).powi(2) + (c_a[1] - c_b[1]).powi(2)).sqrt();
274        assert!(
275            sep > 0.5,
276            "cluster centroids should be separated in embedding (got {sep:.3})"
277        );
278    }
279
280    #[test]
281    fn precomputed_knn_matches_internal() {
282        let (data, n) = two_cluster_data(40, 8);
283        let mut config = base_config();
284        config.knn_method = KnnMethod::Exact;
285        let k = KnnGraph::required_k_for_pacmap(n, config.n_neighbors);
286        let graph = compute_knn(
287            &data,
288            n,
289            8,
290            k,
291            &KnnMethod::Exact,
292            Default::default(),
293        )
294        .unwrap();
295        let a = fit_transform(&data, n, 8, config.clone(), None, None, None).unwrap();
296        let b = fit_transform(&data, n, 8, config, Some(&graph), None, None).unwrap();
297        for (p, q) in a.iter().zip(b.iter()) {
298            assert!((p[0] - q[0]).abs() < 1e-4 && (p[1] - q[1]).abs() < 1e-4);
299        }
300    }
301
302    #[cfg(feature = "cubecl")]
303    #[test]
304    fn gpu_optimize_runs_when_adapter_available() {
305        if !crate::gpu::gpu_context_available() {
306            eprintln!("skipping gpu_optimize_runs_when_adapter_available: no WGPU adapter");
307            return;
308        }
309        let (data, n) = two_cluster_data(80, 6);
310        let mut config = base_config();
311        config.phase_iters = [8, 8, 16];
312        config.optimize_backend = OptimizeBackend::Gpu;
313        let emb = fit_transform(&data, n, 6, config, None, None, None).unwrap();
314        assert_eq!(emb.len(), n);
315        assert!(emb.iter().all(|p| p[0].is_finite() && p[1].is_finite()));
316    }
317
318    #[cfg(feature = "cubecl")]
319    #[test]
320    fn gpu_and_cpu_embeddings_correlate() {
321        if !crate::gpu::gpu_context_available() {
322            eprintln!("skipping gpu_and_cpu_embeddings_correlate: no WGPU adapter");
323            return;
324        }
325        let (data, n) = two_cluster_data(60, 5);
326        let mut cpu_cfg = base_config();
327        cpu_cfg.phase_iters = [20, 20, 40];
328        cpu_cfg.optimize_backend = OptimizeBackend::Cpu;
329        let mut gpu_cfg = cpu_cfg.clone();
330        gpu_cfg.optimize_backend = OptimizeBackend::Gpu;
331
332        let cpu = fit_transform(&data, n, 5, cpu_cfg, None, None, None).unwrap();
333        let gpu = fit_transform(&data, n, 5, gpu_cfg, None, None, None).unwrap();
334
335        // Same init + pairs → embeddings should be close (FP / schedule differences allowed).
336        let mut max_delta = 0.0f32;
337        for (a, b) in cpu.iter().zip(gpu.iter()) {
338            max_delta = max_delta.max((a[0] - b[0]).abs()).max((a[1] - b[1]).abs());
339        }
340        assert!(
341            max_delta < 2.0,
342            "cpu vs gpu embedding max |Δ|={max_delta} (expected loose agreement)"
343        );
344    }
345
346    #[test]
347    fn precomputed_knn_rejects_bad_n_and_k() {
348        let (data, n) = two_cluster_data(30, 4);
349        let d = 4;
350        let config = base_config();
351        let k = KnnGraph::required_k_for_pacmap(n, config.n_neighbors);
352        let graph = compute_knn(
353            &data,
354            n,
355            d,
356            k,
357            &KnnMethod::Exact,
358            DistanceMetric::Euclidean,
359        )
360        .unwrap();
361
362        let mut bad_n = graph.clone();
363        bad_n.n = n + 1;
364        assert!(matches!(
365            fit_transform(&data, n, d, config.clone(), Some(&bad_n), None, None),
366            Err(PaCMAPError::KnnGraphSizeMismatch { .. })
367        ));
368
369        let mut bad_k = graph.clone();
370        bad_k.k = 1;
371        assert!(matches!(
372            fit_transform(&data, n, d, config.clone(), Some(&bad_k), None, None),
373            Err(PaCMAPError::KnnGraphInsufficientK { .. })
374        ));
375
376        let mut bad_metric = graph;
377        bad_metric.metric = DistanceMetric::Cosine;
378        assert!(matches!(
379            fit_transform(&data, n, d, config, Some(&bad_metric), None, None),
380            Err(PaCMAPError::KnnGraphMetricMismatch { .. })
381        ));
382    }
383
384    #[test]
385    fn one_knn_graph_reused_across_configs() {
386        let (data, n) = two_cluster_data(30, 4);
387        let d = 4;
388        let n_neighbors = 5;
389        let k = KnnGraph::required_k_for_pacmap(n, n_neighbors);
390        let graph = compute_knn(
391            &data,
392            n,
393            d,
394            k,
395            &KnnMethod::Exact,
396            DistanceMetric::Euclidean,
397        )
398        .unwrap();
399
400        let config_a = PaCMAPConfig {
401            n_neighbors,
402            phase_iters: [20, 20, 40],
403            knn_method: KnnMethod::Exact,
404            init: Init::Random(Some(1)),
405            seed: Some(11),
406            learning_rate: 1.0,
407            ..Default::default()
408        };
409        let config_b = PaCMAPConfig {
410            n_neighbors,
411            phase_iters: [10, 10, 20],
412            knn_method: KnnMethod::Exact,
413            init: Init::Random(Some(2)),
414            seed: Some(22),
415            learning_rate: 0.5,
416            ..Default::default()
417        };
418
419        let emb_a = fit_transform(&data, n, d, config_a, Some(&graph), None, None).unwrap();
420        let emb_b = fit_transform(&data, n, d, config_b, Some(&graph), None, None).unwrap();
421        assert_eq!(emb_a.len(), n);
422        assert_eq!(emb_b.len(), n);
423        // Different configs should generally produce different embeddings.
424        let differs = emb_a
425            .iter()
426            .zip(emb_b.iter())
427            .any(|(a, b)| (a[0] - b[0]).abs() > 1e-3 || (a[1] - b[1]).abs() > 1e-3);
428        assert!(differs, "distinct configs should not yield identical embeddings");
429    }
430}