Skip to main content

burn_optim/optim/
lion.rs

1use burn_core as burn;
2
3use crate::{LearningRate, RecordState, grad_clipping::GradientClippingConfig};
4use burn::config::Config;
5use burn::tensor::{Device, Tensor};
6
7use super::{Optimizer, module_optimizer::ModuleOptimizer};
8
9/// Configuration for the [`Lion`] optimizer.
10#[derive(Config, Debug)]
11pub struct LionConfig {
12    /// Interpolation factor used to compute the update direction.
13    #[config(default = 0.9)]
14    beta_1: f32,
15    /// Decay factor for the momentum state.
16    #[config(default = 0.99)]
17    beta_2: f32,
18    /// Decoupled weight decay factor.
19    #[config(default = 0.0)]
20    weight_decay: f32,
21    /// [Gradient clipping](GradientClippingConfig) configuration.
22    grad_clipping: Option<GradientClippingConfig>,
23}
24
25/// Lion (EvoLved Sign Momentum) optimizer.
26///
27/// Lion keeps a single momentum tensor and updates parameters with the sign of an
28/// interpolation between that momentum and the current gradient. Weight decay is
29/// decoupled from the gradient, as in AdamW.
30///
31/// Lion commonly needs a learning rate smaller than AdamW's and a larger decoupled
32/// weight decay. These hyperparameters should be tuned together.
33///
34/// See [Symbolic Discovery of Optimization Algorithms](https://arxiv.org/abs/2302.06675).
35#[derive(Clone)]
36pub struct Lion {
37    beta_1: f32,
38    beta_2: f32,
39    weight_decay: f32,
40}
41
42/// State of the [`Lion`] optimizer.
43#[derive(RecordState, Clone, new)]
44pub struct LionState<const D: usize> {
45    /// Exponential moving average of the gradients.
46    pub momentum: Tensor<D>,
47}
48
49impl LionConfig {
50    /// Build a [`Lion`] from the config.
51    ///
52    /// The bare optimizer, which
53    /// [`ModuleOptimizer::with_group`](crate::ModuleOptimizer::with_group) takes to
54    /// optimize one parameter group. [`init`](Self::init) is the whole-module
55    /// counterpart, and the only one that applies the configured gradient clipping.
56    pub fn build(&self) -> Lion {
57        Lion {
58            beta_1: self.beta_1,
59            beta_2: self.beta_2,
60            weight_decay: self.weight_decay,
61        }
62    }
63
64    /// Initialize a Lion optimizer for a module.
65    pub fn init(&self) -> ModuleOptimizer {
66        let mut optim = ModuleOptimizer::from(self.build());
67        if let Some(config) = &self.grad_clipping {
68            optim = optim.with_grad_clipping(config.init());
69        }
70        optim
71    }
72}
73
74impl Optimizer for Lion {
75    type State<const D: usize> = LionState<D>;
76
77    fn step<const D: usize>(
78        &self,
79        lr: LearningRate,
80        tensor: Tensor<D>,
81        grad: Tensor<D>,
82        state: Option<Self::State<D>>,
83    ) -> (Tensor<D>, Option<Self::State<D>>) {
84        let (update, momentum) = match state {
85            Some(state) => {
86                let update = state
87                    .momentum
88                    .clone()
89                    .mul_scalar(self.beta_1)
90                    .add(grad.clone().mul_scalar(1.0 - self.beta_1))
91                    .sign();
92                let momentum = state
93                    .momentum
94                    .mul_scalar(self.beta_2)
95                    .add(grad.mul_scalar(1.0 - self.beta_2));
96
97                (update, momentum)
98            }
99            None => {
100                let update = grad.clone().mul_scalar(1.0 - self.beta_1).sign();
101                let momentum = grad.mul_scalar(1.0 - self.beta_2);
102
103                (update, momentum)
104            }
105        };
106
107        let decay = 1.0 - lr * self.weight_decay as f64;
108        let tensor = if decay == 1.0 {
109            tensor
110        } else {
111            tensor.mul_scalar(decay)
112        };
113        let tensor = tensor - update.mul_scalar(lr);
114
115        (tensor, Some(LionState::new(momentum)))
116    }
117
118    fn to_device<const D: usize>(mut state: Self::State<D>, device: &Device) -> Self::State<D> {
119        state.momentum = state.momentum.to_device(device);
120        state
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::optim::test_utils::assert_optimizer_resume;
128    use crate::{AdamWConfig, GradientsParams, ModuleOptimizer};
129    use burn::module::Param;
130    use burn::tensor::{TensorData, Tolerance};
131    use burn_nn::loss::{MseLoss, Reduction};
132    use burn_nn::{Linear, LinearConfig};
133
134    #[test]
135    fn test_lion_two_steps_with_weight_decay() {
136        let device = Device::default();
137        let optimizer = LionConfig::new()
138            .with_beta_1(0.9)
139            .with_beta_2(0.99)
140            .with_weight_decay(0.1)
141            .build();
142        let tensor = Tensor::<1>::from_floats([1.0, -2.0, 3.0], &device);
143        let grad = Tensor::<1>::from_floats([0.5, -0.25, 0.0], &device);
144
145        let (tensor, state) = optimizer.step(0.1, tensor, grad, None);
146        tensor.clone().into_data().assert_approx_eq::<f32>(
147            &TensorData::from([0.89, -1.88, 2.97]),
148            Tolerance::absolute(1e-6),
149        );
150        let state = state.unwrap();
151        state.momentum.clone().into_data().assert_approx_eq::<f32>(
152            &TensorData::from([0.005, -0.0025, 0.0]),
153            Tolerance::absolute(1e-6),
154        );
155
156        let grad = Tensor::<1>::from_floats([-0.1, 0.5, -2.0], &device);
157        let (tensor, state) = optimizer.step(0.1, tensor, grad, Some(state));
158        tensor.into_data().assert_approx_eq::<f32>(
159            &TensorData::from([0.9811, -1.9612, 3.0403]),
160            Tolerance::absolute(1e-6),
161        );
162        state.unwrap().momentum.into_data().assert_approx_eq::<f32>(
163            &TensorData::from([0.00395, 0.002525, -0.02]),
164            Tolerance::absolute(1e-6),
165        );
166    }
167
168    #[test]
169    fn test_lion_optimizer_save_load_state() {
170        let device = Device::default().autodiff();
171        let linear = LinearConfig::new(4, 3).init(&device);
172        assert_optimizer_resume(|| LionConfig::new().init(), linear, 1e-4);
173    }
174
175    // A resource-light analogue of the paper's training experiments. It is intentionally
176    // deterministic and only verifies that Lion works end-to-end with the paper's guidance of
177    // using a lower learning rate than AdamW; it is not an accuracy reproduction of ImageNet.
178    #[test]
179    fn test_paper_smoke_lion_trains_tiny_regression() {
180        let device = Device::default().autodiff();
181        let lion_model = tiny_linear_model(&device);
182        let adamw_model = tiny_linear_model(&device);
183
184        let (lion_initial, lion_final) =
185            train_tiny_regression(lion_model, LionConfig::new().init(), 0.01, &device);
186        let (adamw_initial, adamw_final) = train_tiny_regression(
187            adamw_model,
188            AdamWConfig::new().with_weight_decay(0.0).init(),
189            0.05,
190            &device,
191        );
192
193        println!(
194            "tiny regression: Lion {lion_initial:.6e} -> {lion_final:.6e}; \
195             AdamW {adamw_initial:.6e} -> {adamw_final:.6e}"
196        );
197        assert!(
198            lion_final < lion_initial * 1e-3,
199            "Lion loss did not decrease enough: {lion_initial} -> {lion_final}"
200        );
201        assert!(adamw_final < adamw_initial * 1e-3);
202    }
203
204    // The paper reports that Lion halves the additional optimizer-state memory because it stores
205    // one moment instead of AdamW's two. Burnpack size is a stable, backend-independent proxy for
206    // the actual tensor payload held by ModuleOptimizer.
207    #[test]
208    fn test_paper_smoke_lion_state_is_smaller_than_adamw() {
209        let device = Device::default().autodiff();
210        let model = LinearConfig::new(64, 64).with_bias(false).init(&device);
211        let input = Tensor::<2>::ones([2, 64], &device);
212
213        let mut lion = LionConfig::new().init();
214        let grads = GradientsParams::from_grads(
215            model.clone().forward(input.clone()).sum().backward(),
216            &model,
217        );
218        let _model = lion.step(1e-4, model, grads);
219        let lion_state_bytes = lion.into_bytes().unwrap().len();
220
221        let model = LinearConfig::new(64, 64).with_bias(false).init(&device);
222        let mut adamw = AdamWConfig::new().with_weight_decay(0.0).init();
223        let grads =
224            GradientsParams::from_grads(model.clone().forward(input).sum().backward(), &model);
225        let _model = adamw.step(1e-3, model, grads);
226        let adamw_state_bytes = adamw.into_bytes().unwrap().len();
227
228        println!(
229            "serialized optimizer state: Lion {lion_state_bytes} bytes; \
230             AdamW {adamw_state_bytes} bytes"
231        );
232        assert!(
233            lion_state_bytes < adamw_state_bytes,
234            "expected Lion state ({lion_state_bytes} bytes) to be smaller than AdamW state \
235             ({adamw_state_bytes} bytes)"
236        );
237    }
238
239    fn tiny_linear_model(device: &Device) -> Linear {
240        Linear {
241            weight: Param::from_data(TensorData::from([[0.0], [0.0]]), device),
242            bias: None,
243        }
244    }
245
246    fn train_tiny_regression(
247        mut model: Linear,
248        mut optimizer: ModuleOptimizer,
249        peak_lr: LearningRate,
250        device: &Device,
251    ) -> (f32, f32) {
252        let input =
253            Tensor::<2>::from_floats([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [-1.0, 1.0]], device);
254        let target = Tensor::<2>::from_floats([[0.5], [-0.75], [-0.25], [-1.25]], device);
255        let loss = MseLoss::new();
256        let initial = loss
257            .forward(
258                model.forward(input.clone()),
259                target.clone(),
260                Reduction::Mean,
261            )
262            .into_scalar::<f32>();
263        let steps = 600;
264
265        for step in 0..steps {
266            let output = model.forward(input.clone());
267            let value = loss.forward(output, target.clone(), Reduction::Mean);
268            let grads = GradientsParams::from_grads(value.backward(), &model);
269            let lr = peak_lr * (1.0 - step as f64 / steps as f64);
270            model = optimizer.step(lr, model, grads);
271        }
272
273        let final_loss = loss
274            .forward(model.forward(input), target, Reduction::Mean)
275            .into_scalar::<f32>();
276        (initial, final_loss)
277    }
278}