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    let process_pairs = |pairs: &[[u32; 2]],
102                         w: f32,
103                         denom: f32,
104                         wc: f32,
105                         is_rep: bool|
106     -> (Vec<[f32; 2]>, f32) {
107        let (grad_sum, loss_sum) = pairs
108            .par_chunks(chunk_size)
109            .map(|chunk| {
110                let mut grad = vec![[0.0_f32; 2]; n];
111                let mut loss = 0.0_f32;
112                accumulate_pairs(embedding, chunk, w, denom, wc, is_rep, &mut grad, &mut loss);
113                (grad, loss)
114            })
115            .reduce(
116                || (vec![[0.0_f32; 2]; n], 0.0_f32),
117                |(mut g1, l1), (g2, l2)| {
118                    for (a, b) in g1.iter_mut().zip(g2.iter()) {
119                        a[0] += b[0];
120                        a[1] += b[1];
121                    }
122                    (g1, l1 + l2)
123                },
124            );
125        (grad_sum, loss_sum)
126    };
127
128    // Near: attractive, denom=10, weight_const=20 (= 2 × denom for the C·2/(C+d̃)² form)
129    let (g_nb, l_nb) = process_pairs(near, weights.w_nb, 10.0, 20.0, false);
130    // Mid-near: attractive, denom=10000
131    let (g_mn, l_mn) = process_pairs(mid_near, weights.w_mn, 10000.0, 20000.0, false);
132    // Further: repulsive
133    let (g_fp, l_fp) = process_pairs(further, weights.w_fp, 1.0, 2.0, true);
134
135    // Sum all gradient contributions
136    let mut grad = g_nb;
137    for (a, b) in grad.iter_mut().zip(g_mn.iter()) {
138        a[0] += b[0];
139        a[1] += b[1];
140    }
141    for (a, b) in grad.iter_mut().zip(g_fp.iter()) {
142        a[0] += b[0];
143        a[1] += b[1];
144    }
145
146    (grad, l_nb + l_mn + l_fp)
147}