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}