Skip to main content

flow_pacmap/
gradient.rs

1//! PaCMAP loss gradient computation (Algorithm 1, Wang et al. 2021).
2//!
3//! Loss terms (where d̃_ab = ‖ya − yb‖² + 1):
4//!   L_NB  = d̃_ij / (10   + d̃_ij)     attractive — near neighbours
5//!   L_MN  = d̃_ik / (10000 + d̃_ik)    attractive — mid-near pairs
6//!   L_FP  = 1     / (1    + d̃_il)     repulsive  — further pairs
7//!
8//! Gradients are accumulated per point into a pre-allocated buffer.
9
10use crate::weights::Weights;
11use rayon::prelude::*;
12
13/// Accumulate gradients from one pair list into `grad`.
14///
15/// `pairs` is a flat `&[[u32; 2]]`.
16/// `w` is the weight for this pair type.
17/// `denom_const` and `weight_const` are the constants for the loss term:
18///   loss   = w · d̃ / (denom_const + d̃)  for attractive
19///   grad_i = w · 2 · weight_const · (yi − yj) / (denom_const + d̃)²
20/// For further pairs the form is 1/(1+d̃), handled via `is_repulsive`.
21fn accumulate_pairs(
22    embedding: &[[f32; 2]],
23    pairs: &[[u32; 2]],
24    w: f32,
25    denom: f32,
26    weight_const: f32,
27    is_repulsive: bool,
28    grad: &mut [[f32; 2]],
29    loss_acc: &mut f32,
30) {
31    for pair in pairs {
32        let i = pair[0] as usize;
33        let j = pair[1] as usize;
34        let yi = embedding[i];
35        let yj = embedding[j];
36        let dx = yi[0] - yj[0];
37        let dy = yi[1] - yj[1];
38        let d_sq = dx * dx + dy * dy;
39        let d_tilde = d_sq + 1.0;
40
41        let (loss, grad_scale) = if is_repulsive {
42            // L_FP = 1 / (1 + d̃); grad = w · 2 · (yi − yj) / (1 + d̃)²
43            let denom_sq = d_tilde * d_tilde;
44            let l = 1.0 / d_tilde;
45            let g = w * 2.0 / denom_sq;
46            (l, g)
47        } else {
48            // L_NB/MN = d̃ / (C + d̃); grad = −w · 2 · C · (yi − yj) / (C + d̃)²
49            let c_plus_d = denom + d_tilde;
50            let l = d_tilde / c_plus_d;
51            // Attractive: gradient pushes i toward j (negative of d̃/(C+d̃) wrt yi)
52            // ∂L/∂yi = C · 2(yi-yj) / (C+d̃)²  (positive for attractive means move away?)
53            // Actually: ∂/∂yi d̃/(C+d̃) = 2(yi-yj)·C/(C+d̃)²
54            // We want gradient descent, so we subtract this from yi to attract.
55            let g = w * weight_const / (c_plus_d * c_plus_d);
56            (l, g)
57        };
58
59        *loss_acc += w * loss;
60
61        let gi0 = grad_scale * dx;
62        let gi1 = grad_scale * dy;
63
64        if is_repulsive {
65            // Repulsive: push i away from j
66            grad[i][0] -= gi0;
67            grad[i][1] -= gi1;
68            grad[j][0] += gi0;
69            grad[j][1] += gi1;
70        } else {
71            // Attractive: pull i toward j
72            grad[i][0] += gi0;
73            grad[i][1] += gi1;
74            grad[j][0] -= gi0;
75            grad[j][1] -= gi1;
76        }
77    }
78}
79
80/// Compute the full gradient over all three pair types for the current embedding.
81///
82/// Returns `(gradient: Vec<[f32; 2]>, total_loss: f32)`.
83/// The gradient buffer is reused via `grad_buf` to avoid per-iteration allocation.
84///
85/// Rayon is used to process chunks of pairs in parallel, then results are summed.
86/// Each chunk produces an independent gradient contribution that is added to the
87/// shared accumulator — safe because each chunk slice is read-only and additions
88/// are commutative.
89pub fn compute_gradient(
90    embedding: &[[f32; 2]],
91    near: &[[u32; 2]],
92    mid_near: &[[u32; 2]],
93    further: &[[u32; 2]],
94    weights: &Weights,
95    n: usize,
96) -> (Vec<[f32; 2]>, f32) {
97    let chunk_size = 128 * 1024;
98
99    // Process each pair type in parallel chunks, accumulate per-chunk gradients,
100    // then sum. Each chunk has its own grad buffer to avoid races.
101    // Note: Rayon `fold` buffer reuse was A/B'd (see PERFORMANCE_NOTES) and
102    // regressed wall time @ 50k; keep per-chunk map + reduce.
103    let process_pairs = |pairs: &[[u32; 2]],
104                         w: f32,
105                         denom: f32,
106                         wc: f32,
107                         is_rep: bool|
108     -> (Vec<[f32; 2]>, f32) {
109        let (grad_sum, loss_sum) = pairs
110            .par_chunks(chunk_size)
111            .map(|chunk| {
112                let mut grad = vec![[0.0_f32; 2]; n];
113                let mut loss = 0.0_f32;
114                accumulate_pairs(embedding, chunk, w, denom, wc, is_rep, &mut grad, &mut loss);
115                (grad, loss)
116            })
117            .reduce(
118                || (vec![[0.0_f32; 2]; n], 0.0_f32),
119                |(mut g1, l1), (g2, l2)| {
120                    for (a, b) in g1.iter_mut().zip(g2.iter()) {
121                        a[0] += b[0];
122                        a[1] += b[1];
123                    }
124                    (g1, l1 + l2)
125                },
126            );
127        (grad_sum, loss_sum)
128    };
129
130    // Near: attractive, denom=10, weight_const=20 (= 2 × denom for the C·2/(C+d̃)² form)
131    let (g_nb, l_nb) = process_pairs(near, weights.w_nb, 10.0, 20.0, false);
132    // Mid-near: attractive, denom=10000
133    let (g_mn, l_mn) = process_pairs(mid_near, weights.w_mn, 10000.0, 20000.0, false);
134    // Further: repulsive
135    let (g_fp, l_fp) = process_pairs(further, weights.w_fp, 1.0, 2.0, true);
136
137    // Sum all gradient contributions
138    let mut grad = g_nb;
139    for (a, b) in grad.iter_mut().zip(g_mn.iter()) {
140        a[0] += b[0];
141        a[1] += b[1];
142    }
143    for (a, b) in grad.iter_mut().zip(g_fp.iter()) {
144        a[0] += b[0];
145        a[1] += b[1];
146    }
147
148    (grad, l_nb + l_mn + l_fp)
149}