Skip to main content

burn_optim/optim/
adam.rs

1use burn_core as burn;
2
3use crate::RecordState;
4
5use burn::config::Config;
6use burn::tensor::Device;
7use burn::tensor::Tensor;
8
9use super::{
10    Optimizer,
11    decay::{WeightDecay, WeightDecayConfig},
12    module_optimizer::ModuleOptimizer,
13};
14use crate::{LearningRate, grad_clipping::GradientClippingConfig};
15
16#[cfg(not(feature = "std"))]
17#[allow(unused_imports)]
18use num_traits::Float as _;
19
20/// Adam configuration.
21#[derive(Config, Debug)]
22pub struct AdamConfig {
23    /// Parameter for Adam.
24    #[config(default = 0.9)]
25    beta_1: f32,
26    /// Parameter for Adam.
27    #[config(default = 0.999)]
28    beta_2: f32,
29    /// A value required for numerical stability.
30    #[config(default = 1e-5)]
31    epsilon: f32,
32    /// Whether to use AMSGrad algorithm
33    #[config(default = false)]
34    amsgrad: bool,
35    /// [Weight decay](WeightDecayConfig) config.
36    weight_decay: Option<WeightDecayConfig>,
37    /// [Gradient Clipping](GradientClippingConfig) config.
38    grad_clipping: Option<GradientClippingConfig>,
39}
40
41/// Adam optimizer.
42///
43/// See:
44/// - [Adam: A Method for Stochastic Optimization](https://arxiv.org/pdf/1412.6980.pdf).
45/// - [On the Convergence of Adam and Beyond](https://openreview.net/forum?id=ryQu7f-RZ)
46#[derive(Clone)]
47pub struct Adam {
48    momentum: AdaptiveMomentum,
49    weight_decay: Option<WeightDecay>,
50}
51
52/// Adam state.
53#[derive(RecordState, Clone, new)]
54pub struct AdamState<const D: usize> {
55    /// The current adaptive momentum.
56    pub momentum: AdaptiveMomentumState<D>,
57}
58
59impl Optimizer for Adam {
60    type State<const D: usize> = AdamState<D>;
61
62    fn step<const D: usize>(
63        &self,
64        lr: LearningRate,
65        tensor: Tensor<D>,
66        mut grad: Tensor<D>,
67        state: Option<Self::State<D>>,
68    ) -> (Tensor<D>, Option<Self::State<D>>) {
69        let mut state_momentum = None;
70
71        if let Some(state) = state {
72            state_momentum = Some(state.momentum);
73        }
74
75        if let Some(weight_decay) = &self.weight_decay {
76            grad = weight_decay.transform(grad, tensor.clone());
77        }
78
79        let (grad, state_momentum) = self.momentum.transform(grad, state_momentum);
80
81        let state = AdamState::new(state_momentum);
82        let delta = grad.mul_scalar(lr);
83
84        (tensor - delta, Some(state))
85    }
86
87    fn to_device<const D: usize>(mut state: Self::State<D>, device: &Device) -> Self::State<D> {
88        state.momentum = state.momentum.to_device(device);
89        state
90    }
91}
92
93impl AdamConfig {
94    /// Build an [`Adam`] from the config.
95    ///
96    /// The bare optimizer, which
97    /// [`ModuleOptimizer::with_group`](crate::ModuleOptimizer::with_group) takes to
98    /// optimize one parameter group. [`init`](Self::init) is the whole-module
99    /// counterpart, and the only one that applies the configured gradient clipping.
100    pub fn build(&self) -> Adam {
101        Adam {
102            momentum: AdaptiveMomentum {
103                beta_1: self.beta_1,
104                beta_2: self.beta_2,
105                epsilon: self.epsilon,
106                amsgrad: self.amsgrad,
107            },
108            weight_decay: self.weight_decay.as_ref().map(WeightDecay::new),
109        }
110    }
111
112    /// Initialize Adam optimizer.
113    ///
114    /// # Returns
115    ///
116    /// Returns an optimizer that can be used to optimize a module.
117    pub fn init(&self) -> ModuleOptimizer {
118        let mut optim = ModuleOptimizer::from(self.build());
119        if let Some(config) = &self.grad_clipping {
120            optim = optim.with_grad_clipping(config.init());
121        }
122        optim
123    }
124}
125
126/// Adaptive momentum state.
127#[derive(RecordState, new, Clone)]
128pub struct AdaptiveMomentumState<const D: usize> {
129    /// The number of iterations aggregated.
130    pub time: usize,
131    /// The first order momentum.
132    pub moment_1: Tensor<D>,
133    /// The second order momentum.
134    pub moment_2: Tensor<D>,
135    /// Max of second  order momentum (for AMSGrad)
136    #[new(default)]
137    pub max_moment_2: Option<Tensor<D>>,
138}
139
140#[derive(Clone)]
141struct AdaptiveMomentum {
142    beta_1: f32,
143    beta_2: f32,
144    epsilon: f32,
145    amsgrad: bool,
146}
147
148impl AdaptiveMomentum {
149    pub fn transform<const D: usize>(
150        &self,
151        grad: Tensor<D>,
152        momentum_state: Option<AdaptiveMomentumState<D>>,
153    ) -> (Tensor<D>, AdaptiveMomentumState<D>) {
154        let state = if let Some(mut state) = momentum_state {
155            let factor = 1.0 - self.beta_1;
156            state.moment_1 = state
157                .moment_1
158                .mul_scalar(self.beta_1)
159                .add(grad.clone().mul_scalar(factor));
160
161            let factor = 1.0 - self.beta_2;
162            state.moment_2 = state
163                .moment_2
164                .mul_scalar(self.beta_2)
165                .add(grad.square().mul_scalar(factor));
166            if self.amsgrad {
167                let max_v = state
168                    .max_moment_2
169                    .take()
170                    .unwrap_or_else(|| state.moment_2.clone());
171
172                let new_max = max_v.max_pair(state.moment_2.clone());
173                state.max_moment_2 = Some(new_max);
174            }
175
176            state.time += 1;
177
178            state
179        } else {
180            let factor = 1.0 - self.beta_1;
181            let moment_1 = grad.clone().mul_scalar(factor);
182
183            let factor = 1.0 - self.beta_2;
184            let moment_2 = grad.square().mul_scalar(factor);
185            let max_moment_2 = self.amsgrad.then(|| moment_2.clone());
186            AdaptiveMomentumState {
187                time: 1,
188                moment_1,
189                moment_2,
190                max_moment_2,
191            }
192        };
193
194        let time = state.time as i32;
195        let bias_correction2_sqrt = (1.0 - self.beta_2.powi(time)).sqrt();
196        let combined_factor = bias_correction2_sqrt / (1.0 - self.beta_1.powi(time));
197
198        let v_to_use = if self.amsgrad {
199            state.max_moment_2.as_ref().unwrap_or(&state.moment_2)
200        } else {
201            &state.moment_2
202        };
203
204        let grad = state.moment_1.clone().mul_scalar(combined_factor).div(
205            v_to_use
206                .clone()
207                .sqrt()
208                .add_scalar(self.epsilon * bias_correction2_sqrt),
209        );
210        (grad, state)
211    }
212}
213
214impl<const D: usize> AdaptiveMomentumState<D> {
215    /// Move state to device.
216    ///
217    /// # Arguments
218    ///
219    /// * `device` - Device to move state to.
220    ///
221    /// # Returns
222    ///
223    /// Returns state moved to device.
224    pub fn to_device(mut self, device: &Device) -> Self {
225        self.moment_1 = self.moment_1.to_device(device);
226        self.moment_2 = self.moment_2.to_device(device);
227        self.max_moment_2 = self.max_moment_2.map(|tensor| tensor.to_device(device));
228        self
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use burn::tensor::Tolerance;
235
236    use super::*;
237    use crate::GradientsParams;
238    use burn::module::Param;
239    use burn::tensor::{Distribution, Tensor, TensorData};
240    use burn_nn::{Linear, LinearConfig};
241
242    const LEARNING_RATE: LearningRate = 0.01;
243
244    #[test]
245    fn test_adam_optimizer_save_load_state() {
246        let device = Device::default().autodiff();
247        let linear = LinearConfig::new(6, 6).init(&device);
248        let x = Tensor::<2>::random([2, 6], Distribution::Default, &device);
249        let mut optimizer = create_adam();
250        let grads = linear.forward(x).backward();
251        let grads = GradientsParams::from_grads(grads, &linear);
252        let _linear = optimizer.step(LEARNING_RATE, linear, grads);
253
254        let bytes = optimizer.into_bytes().unwrap();
255        assert!(!bytes.is_empty());
256
257        #[cfg(feature = "std")]
258        optimizer
259            .save(std::env::temp_dir().as_path().join("test_optim_adam"))
260            .unwrap();
261
262        let state_optim_before = optimizer.to_record();
263        let optimizer = create_adam().from_bytes(bytes).unwrap();
264        let state_optim_after = optimizer.to_record();
265
266        assert_eq!(state_optim_before.len(), state_optim_after.len());
267    }
268
269    /// A burnpack round-trip must restore the full state — the moment tensors, amsgrad's optional
270    /// `max_moment_2`, and the `time` step counter — so that a subsequent step produces identical
271    /// parameters whether taken on the original or the reloaded optimizer.
272    #[test]
273    fn test_adam_state_survives_burnpack_round_trip() {
274        let device = Device::default().autodiff();
275        let mut linear = LinearConfig::new(6, 6).init(&device);
276        let mut optimizer = AdamConfig::new().with_amsgrad(true).init();
277
278        // Warm up the optimizer state over a few steps.
279        for i in 1..=3 {
280            let x = Tensor::<2>::ones([2, 6], &device)
281                .mul_scalar(i as f32 * 0.1)
282                .require_grad();
283            let grads = linear.forward(x).backward();
284            let grads = GradientsParams::from_grads(grads, &linear);
285            linear = optimizer.step(LEARNING_RATE, linear, grads);
286        }
287
288        // Round-trip the optimizer state through the burnpack format. No device is needed on load:
289        // each parameter's state is migrated to that parameter's device on the next step.
290        let bytes = optimizer.into_bytes().unwrap();
291        let mut reloaded = AdamConfig::new()
292            .with_amsgrad(true)
293            .init()
294            .from_bytes(bytes)
295            .unwrap();
296
297        // One more identical step on each optimizer must yield identical parameters.
298        let x = Tensor::<2>::ones([2, 6], &device)
299            .mul_scalar(0.4)
300            .require_grad();
301        let grads_original =
302            GradientsParams::from_grads(linear.forward(x.clone()).backward(), &linear);
303        let grads_reloaded = GradientsParams::from_grads(linear.forward(x).backward(), &linear);
304
305        let from_original = optimizer.step(LEARNING_RATE, linear.clone(), grads_original);
306        let from_reloaded = reloaded.step(LEARNING_RATE, linear, grads_reloaded);
307
308        let weight_original = from_original.weight.to_data();
309        let weight_reloaded = from_reloaded.weight.to_data();
310        weight_original.assert_approx_eq::<f32>(&weight_reloaded, Tolerance::absolute(1e-6));
311    }
312
313    #[test]
314    fn test_adam_optimizer_with_amsgrad_50_steps() {
315        let device = Device::default().autodiff();
316        let mut linear = given_linear_layer(
317            TensorData::from([
318                [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671],
319                [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922],
320                [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130],
321                [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626],
322                [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304],
323                [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833],
324            ]),
325            TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]),
326            &device,
327        );
328
329        let mut optimizer = AdamConfig::new()
330            .with_epsilon(1e-8)
331            .with_beta_1(0.9)
332            .with_beta_2(0.999)
333            .with_amsgrad(true)
334            .with_weight_decay(Some(WeightDecayConfig::new(0.5)))
335            .init();
336
337        for i in 1..=50 {
338            let x = Tensor::<2>::ones([2, 6], &device)
339                .mul_scalar(i as f32 * 0.1)
340                .require_grad();
341
342            let grads = linear.forward(x).backward();
343            let grads = GradientsParams::from_grads(grads, &linear);
344            linear = optimizer.step(LEARNING_RATE, linear, grads);
345        }
346
347        let state_updated = linear;
348        let weight_updated = state_updated.weight.to_data();
349        let bias_updated = state_updated.bias.unwrap().to_data();
350
351        let weights_expected = TensorData::from([
352            [
353                -0.9125810265541077,
354                -0.45855265855789185,
355                -0.1915993094444275,
356                -0.2759990692138672,
357                -0.5099529027938843,
358                -0.5287043452262878,
359            ],
360            [
361                -0.5181325674057007,
362                -0.6139854788780212,
363                -0.9574727416038513,
364                -0.34102925658226013,
365                -0.400514155626297,
366                -0.8847861886024475,
367            ],
368            [
369                -0.614483118057251,
370                -0.5611032247543335,
371                -0.8887064456939697,
372                -0.34762972593307495,
373                -0.8708556890487671,
374                -0.2830044627189636,
375            ],
376            [
377                -0.8904699683189392,
378                -0.8151527643203735,
379                -0.9621278643608093,
380                -0.8905676603317261,
381                -0.671261191368103,
382                -0.4333854615688324,
383            ],
384            [
385                -0.26599061489105225,
386                -0.8119961023330688,
387                -0.22424538433551788,
388                -0.7672406435012817,
389                -0.2163349837064743,
390                -0.6258266568183899,
391            ],
392            [
393                -0.611397922039032,
394                -0.6075160503387451,
395                -0.4701341986656189,
396                -0.4039117991924286,
397                -0.5663845539093018,
398                -0.21262989938259125,
399            ],
400        ]);
401        let bias_expected = TensorData::from([
402            -0.8817203044891357,
403            -0.4038999378681183,
404            -0.5889149308204651,
405            -0.37475723028182983,
406            -0.3557940721511841,
407            -0.47914788126945496,
408        ]);
409
410        let tolerance = Tolerance::absolute(1e-5);
411        weight_updated.assert_approx_eq::<f32>(&weights_expected, tolerance);
412        bias_updated.assert_approx_eq::<f32>(&bias_expected, tolerance);
413    }
414    #[test]
415    fn test_adam_optimizer_with_numbers() {
416        let device = Device::default().autodiff();
417        let linear = given_linear_layer(
418            TensorData::from([
419                [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671],
420                [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922],
421                [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130],
422                [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626],
423                [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304],
424                [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833],
425            ]),
426            TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]),
427            &device,
428        );
429        let x_1 = Tensor::<2>::from_floats(
430            [
431                [0.6294, 0.0940, 0.8176, 0.8824, 0.5228, 0.4310],
432                [0.7152, 0.9559, 0.7893, 0.5684, 0.5939, 0.8883],
433            ],
434            &device,
435        )
436        .require_grad();
437        let x_2 = Tensor::<2>::from_floats(
438            [
439                [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528],
440                [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085],
441            ],
442            &device,
443        )
444        .require_grad();
445
446        let mut optimizer = AdamConfig::new()
447            .with_epsilon(1e-8)
448            .with_beta_1(0.9)
449            .with_beta_2(0.999)
450            .with_weight_decay(Some(WeightDecayConfig::new(0.5)))
451            .init();
452
453        let grads = linear.forward(x_1).backward();
454        let grads = GradientsParams::from_grads(grads, &linear);
455        let linear = optimizer.step(LEARNING_RATE, linear, grads);
456
457        let grads = linear.forward(x_2).backward();
458        let grads = GradientsParams::from_grads(grads, &linear);
459        let linear = optimizer.step(LEARNING_RATE, linear, grads);
460
461        let state_updated = linear;
462        let weights_expected = TensorData::from([
463            [-0.340528, 0.118929, 0.384336, 0.300010, 0.066034, 0.047154],
464            [
465                0.057757, -0.036690, -0.386649, 0.235010, 0.175624, -0.312133,
466            ],
467            [
468                -0.038940, 0.016306, -0.316151, 0.228410, -0.297819, 0.293047,
469            ],
470            [
471                -0.317929, -0.239100, -0.391449, -0.318087, -0.095948, 0.142651,
472            ],
473            [
474                0.310050, -0.235909, 0.351736, -0.192888, 0.359710, -0.050343,
475            ],
476            [-0.035840, -0.030203, 0.105840, 0.172110, 0.009440, 0.363346],
477        ]);
478        let bias_expected = TensorData::from([
479            -0.410499, 0.068401, -0.116999, 0.097601, 0.116601, -0.006999,
480        ]);
481
482        let (weight_updated, bias_updated) = (
483            state_updated.weight.to_data(),
484            state_updated.bias.unwrap().to_data(),
485        );
486
487        let tolerance = Tolerance::absolute(1e-2);
488        bias_updated.assert_approx_eq::<f32>(&bias_expected, tolerance);
489        weight_updated.assert_approx_eq::<f32>(&weights_expected, tolerance);
490    }
491
492    #[test]
493    fn test_adam_optimizer_no_nan() {
494        let device = Device::default().autodiff();
495        let linear = given_linear_layer(
496            TensorData::from([
497                [-0.3206, 0.1374, 0.4043, 0.3200, 0.0859, 0.0671],
498                [0.0777, -0.0185, -0.3667, 0.2550, 0.1955, -0.2922],
499                [-0.0190, 0.0346, -0.2962, 0.2484, -0.2780, 0.3130],
500                [-0.2980, -0.2214, -0.3715, -0.2981, -0.0761, 0.1626],
501                [0.3300, -0.2182, 0.3717, -0.1729, 0.3796, -0.0304],
502                [-0.0159, -0.0120, 0.1258, 0.1921, 0.0293, 0.3833],
503            ]),
504            TensorData::from([-0.3905, 0.0884, -0.0970, 0.1176, 0.1366, 0.0130]),
505            &device,
506        );
507
508        let x = Tensor::<2>::from_floats(
509            [
510                [0.8491, 0.2108, 0.8939, 0.4433, 0.5527, 0.2528],
511                [0.3270, 0.0412, 0.5538, 0.9605, 0.3195, 0.9085],
512            ],
513            &device,
514        )
515        .require_grad();
516
517        let mut optimizer = AdamConfig::new()
518            .with_epsilon(1e-8)
519            .with_beta_1(0.9)
520            .with_beta_2(0.999)
521            .with_weight_decay(Some(WeightDecayConfig::new(0.5)))
522            .init();
523
524        let grads = linear.forward(x.clone()).backward();
525        let grads = GradientsParams::from_grads(grads, &linear);
526        let linear = optimizer.step(LEARNING_RATE, linear, grads);
527
528        let grads = linear.forward(x).backward();
529        let grads = GradientsParams::from_grads(grads, &linear);
530        let linear = optimizer.step(LEARNING_RATE, linear, grads);
531
532        let state_updated = linear;
533        assert!(!state_updated.weight.to_data().as_slice::<f32>().unwrap()[0].is_nan());
534    }
535
536    fn given_linear_layer(weight: TensorData, bias: TensorData, device: &Device) -> Linear {
537        Linear {
538            weight: Param::from_data(weight, device),
539            bias: Some(Param::from_data(bias, device)),
540        }
541    }
542
543    fn create_adam() -> ModuleOptimizer {
544        let config = AdamConfig::new();
545        Adam {
546            momentum: AdaptiveMomentum {
547                beta_1: config.beta_1,
548                beta_2: config.beta_2,
549                epsilon: config.epsilon,
550                amsgrad: config.amsgrad,
551            },
552            weight_decay: config.weight_decay.as_ref().map(WeightDecay::new),
553        }
554        .into()
555    }
556}