Skip to main content

geographdb_core/algorithms/
adam.rs

1//! Adam optimiser for flat `Vec<f32>` parameter vectors.
2//!
3//! This is a pure-Rust implementation of Kingma & Ba (2015). It is intended for
4//! small training loops where pulling in a full autograd framework would be
5//! overkill — for example, the geometric MLP heads in GeoGraphDB experiments.
6
7/// Gradient norm clipping threshold used by [`Adam::step_clipped`] when none is
8/// supplied.
9pub const DEFAULT_CLIP_NORM: f32 = 1.0;
10
11/// Adam optimiser state.
12///
13/// Keeps first- and second-moment vectors `m` and `v` for every parameter and
14/// applies bias correction using the timestep `t`.
15#[derive(Debug, Clone)]
16pub struct Adam {
17    learning_rate: f32,
18    beta1: f32,
19    beta2: f32,
20    eps: f32,
21    t: u64,
22    m: Vec<f32>,
23    v: Vec<f32>,
24}
25
26impl Adam {
27    /// Create a new Adam optimiser for `param_count` parameters.
28    ///
29    /// Defaults follow the original paper: `lr = 0.001`, `beta1 = 0.9`,
30    /// `beta2 = 0.999`, `eps = 1e-8`.
31    pub fn new(param_count: usize) -> Self {
32        Self::with_lr(0.001, param_count)
33    }
34
35    /// Create an Adam optimiser with a custom learning rate.
36    pub fn with_lr(learning_rate: f32, param_count: usize) -> Self {
37        Self::with_hyperparams(learning_rate, 0.9, 0.999, 1e-8, param_count)
38    }
39
40    /// Create an Adam optimiser with full control over all hyperparameters.
41    pub fn with_hyperparams(
42        learning_rate: f32,
43        beta1: f32,
44        beta2: f32,
45        eps: f32,
46        param_count: usize,
47    ) -> Self {
48        assert!(learning_rate > 0.0, "Adam: learning_rate must be positive");
49        assert!(beta1 > 0.0 && beta1 < 1.0, "Adam: beta1 must be in (0, 1)");
50        assert!(beta2 > 0.0 && beta2 < 1.0, "Adam: beta2 must be in (0, 1)");
51        assert!(eps > 0.0, "Adam: eps must be positive");
52
53        Self {
54            learning_rate,
55            beta1,
56            beta2,
57            eps,
58            t: 0,
59            m: vec![0.0f32; param_count],
60            v: vec![0.0f32; param_count],
61        }
62    }
63
64    /// Number of parameters tracked by this optimiser.
65    pub fn param_count(&self) -> usize {
66        self.m.len()
67    }
68
69    /// Current timestep.
70    pub fn timestep(&self) -> u64 {
71        self.t
72    }
73
74    /// Perform one Adam update on `params` using the supplied gradient.
75    ///
76    /// `params` and `grad` must have the same length as the optimiser's
77    /// parameter count.
78    pub fn step(&mut self, params: &mut [f32], grad: &[f32]) {
79        assert_eq!(
80            params.len(),
81            self.m.len(),
82            "Adam::step: parameter count mismatch"
83        );
84        assert_eq!(
85            grad.len(),
86            self.m.len(),
87            "Adam::step: gradient count mismatch"
88        );
89
90        self.t += 1;
91        let t = self.t as f32;
92        let lr = self.learning_rate;
93        let beta1 = self.beta1;
94        let beta2 = self.beta2;
95        let eps = self.eps;
96        let bias_correction1 = 1.0 - beta1.powf(t);
97        let bias_correction2 = 1.0 - beta2.powf(t);
98
99        for i in 0..params.len() {
100            let g = grad[i];
101            self.m[i] = beta1 * self.m[i] + (1.0 - beta1) * g;
102            self.v[i] = beta2 * self.v[i] + (1.0 - beta2) * g * g;
103
104            let m_hat = self.m[i] / bias_correction1;
105            let v_hat = self.v[i] / bias_correction2;
106            params[i] -= lr * m_hat / (v_hat.sqrt() + eps);
107        }
108    }
109
110    /// Perform one Adam update after clipping the gradient to `max_norm`.
111    ///
112    /// If the global gradient norm is larger than `max_norm`, the whole
113    /// gradient is rescaled before the Adam moment update. This matches the
114    /// common "clip by global norm" behaviour.
115    pub fn step_clipped(&mut self, params: &mut [f32], grad: &[f32], max_norm: f32) {
116        assert!(
117            max_norm > 0.0,
118            "Adam::step_clipped: max_norm must be positive"
119        );
120        let mut clipped = grad.to_vec();
121        clip_gradients(&mut clipped, max_norm);
122        self.step(params, &clipped);
123    }
124
125    /// Perform one Adam update on multiple parameter slices.
126    ///
127    /// `params` and `grads` must have the same number of slices, and each
128    /// corresponding slice must have the same length.  This avoids flattening
129    /// and reloading parameters when a model is stored in separate tensors.
130    pub fn step_slices(&mut self, params: &mut [&mut [f32]], grads: &[&[f32]]) {
131        assert_eq!(
132            params.len(),
133            grads.len(),
134            "Adam::step_slices: parameter and gradient slice counts mismatch"
135        );
136
137        let total_len: usize = params.iter().map(|p| p.len()).sum();
138        assert_eq!(
139            total_len,
140            self.m.len(),
141            "Adam::step_slices: total parameter count mismatch"
142        );
143
144        self.t += 1;
145        let t = self.t as f32;
146        let lr = self.learning_rate;
147        let beta1 = self.beta1;
148        let beta2 = self.beta2;
149        let eps = self.eps;
150        let bias_correction1 = 1.0 - beta1.powf(t);
151        let bias_correction2 = 1.0 - beta2.powf(t);
152
153        let mut mom_off = 0;
154        for (param_slice, grad_slice) in params.iter_mut().zip(grads.iter()) {
155            assert_eq!(
156                param_slice.len(),
157                grad_slice.len(),
158                "Adam::step_slices: slice length mismatch"
159            );
160            for i in 0..param_slice.len() {
161                let g = grad_slice[i];
162                self.m[mom_off] = beta1 * self.m[mom_off] + (1.0 - beta1) * g;
163                self.v[mom_off] = beta2 * self.v[mom_off] + (1.0 - beta2) * g * g;
164
165                let m_hat = self.m[mom_off] / bias_correction1;
166                let v_hat = self.v[mom_off] / bias_correction2;
167                param_slice[i] -= lr * m_hat / (v_hat.sqrt() + eps);
168
169                mom_off += 1;
170            }
171        }
172    }
173
174    /// Perform one Adam update on multiple slices after global norm clipping.
175    pub fn step_slices_clipped(
176        &mut self,
177        params: &mut [&mut [f32]],
178        grads: &[&[f32]],
179        max_norm: f32,
180    ) {
181        assert!(
182            max_norm > 0.0,
183            "Adam::step_slices_clipped: max_norm must be positive"
184        );
185
186        let total_len: usize = params.iter().map(|p| p.len()).sum();
187        let mut clipped = Vec::with_capacity(total_len);
188        for grad_slice in grads {
189            clipped.extend_from_slice(grad_slice);
190        }
191        clip_gradients(&mut clipped, max_norm);
192
193        let mut grad_slices: Vec<&[f32]> = Vec::with_capacity(grads.len());
194        let mut off = 0;
195        for grad_slice in grads {
196            let len = grad_slice.len();
197            grad_slices.push(&clipped[off..off + len]);
198            off += len;
199        }
200
201        self.step_slices(params, &grad_slices);
202    }
203
204    /// Reset all moment vectors to zero without changing hyperparameters.
205    pub fn reset(&mut self) {
206        self.t = 0;
207        self.m.fill(0.0);
208        self.v.fill(0.0);
209    }
210}
211
212/// Clip a flat gradient vector by its global L2 norm.
213///
214/// If `norm <= max_norm` the vector is unchanged; otherwise every element is
215/// multiplied by `max_norm / norm`.
216pub fn clip_gradients(grad: &mut [f32], max_norm: f32) {
217    assert!(max_norm > 0.0, "clip_gradients: max_norm must be positive");
218    let norm_sq: f32 = grad.iter().map(|g| g * g).sum();
219    let norm = norm_sq.sqrt();
220    if norm > max_norm {
221        let scale = max_norm / norm;
222        for g in grad.iter_mut() {
223            *g *= scale;
224        }
225    }
226}
227
228/// Compute the global L2 norm of a flat gradient vector.
229pub fn grad_norm(grad: &[f32]) -> f32 {
230    grad.iter().map(|g| g * g).sum::<f32>().sqrt()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn adam_decreases_simple_quadratic() {
239        // Minimise f(x) = (x - 3)^2 with gradient 2*(x - 3).
240        let mut x = [0.0f32];
241        let mut opt = Adam::with_lr(0.1, 1);
242        for _ in 0..200 {
243            let grad = [2.0 * (x[0] - 3.0)];
244            opt.step(&mut x, &grad);
245        }
246        assert!(
247            (x[0] - 3.0).abs() < 0.01,
248            "x converged to {}, expected 3.0",
249            x[0]
250        );
251    }
252
253    #[test]
254    fn clipped_step_respects_norm() {
255        let mut params = [0.0f32, 0.0f32];
256        let grad = [10.0f32, 0.0f32];
257        let mut opt = Adam::with_lr(0.1, 2);
258        opt.step_clipped(&mut params, &grad, 1.0);
259        // With a clipped gradient of [1.0, 0.0] the first parameter should move
260        // in the negative direction but not as far as if the raw [10.0, 0.0]
261        // gradient had been used.
262        assert!(params[0] < 0.0);
263        assert!(
264            params[0].abs() < 0.5,
265            "clipped step moved too far: {}",
266            params[0]
267        );
268    }
269
270    #[test]
271    fn clip_gradients_scales_large_norm() {
272        let mut g = vec![3.0f32, 4.0f32];
273        clip_gradients(&mut g, 1.0);
274        assert!(
275            (grad_norm(&g) - 1.0).abs() < 1e-5,
276            "norm after clipping should be 1.0"
277        );
278        assert!((g[0] - 0.6).abs() < 1e-5);
279        assert!((g[1] - 0.8).abs() < 1e-5);
280    }
281
282    #[test]
283    fn reset_clears_momentum() {
284        let mut opt = Adam::with_lr(0.1, 2);
285        let mut params = [1.0f32, 1.0f32];
286        opt.step(&mut params, &[1.0f32, 1.0f32]);
287        assert!(opt.m.iter().any(|&v| v != 0.0));
288        opt.reset();
289        assert!(opt.m.iter().all(|&v| v == 0.0));
290        assert!(opt.v.iter().all(|&v| v == 0.0));
291        assert_eq!(opt.timestep(), 0);
292    }
293
294    #[test]
295    fn step_slices_matches_flat_step() {
296        let mut opt = Adam::with_lr(0.1, 4);
297
298        let mut p_flat = [1.0f32, 2.0, 3.0, 4.0];
299        let g_flat = [0.1f32, 0.2, 0.3, 0.4];
300        opt.step(&mut p_flat, &g_flat);
301
302        let mut opt2 = Adam::with_lr(0.1, 4);
303        let mut a = [1.0f32, 2.0];
304        let mut b = [3.0f32, 4.0];
305        let g_a = [0.1f32, 0.2];
306        let g_b = [0.3f32, 0.4];
307        opt2.step_slices(&mut [&mut a, &mut b], &[&g_a, &g_b]);
308
309        let tol = 1e-6;
310        assert!((p_flat[0] - a[0]).abs() < tol);
311        assert!((p_flat[1] - a[1]).abs() < tol);
312        assert!((p_flat[2] - b[0]).abs() < tol);
313        assert!((p_flat[3] - b[1]).abs() < tol);
314    }
315
316    #[test]
317    fn step_slices_clipped_matches_flat_clipped() {
318        let mut opt = Adam::with_lr(0.1, 2);
319        let mut p_flat = [1.0f32, 1.0];
320        let g_flat = [10.0f32, 0.0];
321        opt.step_clipped(&mut p_flat, &g_flat, 1.0);
322
323        let mut opt2 = Adam::with_lr(0.1, 2);
324        let mut a = [1.0f32];
325        let mut b = [1.0f32];
326        let g_a = [10.0f32];
327        let g_b = [0.0f32];
328        opt2.step_slices_clipped(&mut [&mut a, &mut b], &[&g_a, &g_b], 1.0);
329
330        let tol = 1e-6;
331        assert!((p_flat[0] - a[0]).abs() < tol);
332        assert!((p_flat[1] - b[0]).abs() < tol);
333    }
334}