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}