Skip to main content

optirs_core/optimizers/
lamb.rs

1// LAMB optimizer implementation
2//
3// Based on the paper "Large Batch Optimization for Deep Learning: Training BERT in 76 minutes"
4// by You et al. (2019).
5
6use scirs2_core::ndarray::{Array, Dimension, IxDyn, ScalarOperand, Zip};
7use scirs2_core::numeric::Float;
8use std::fmt::Debug;
9
10use crate::error::{OptimError, Result};
11use crate::optimizers::Optimizer;
12
13/// LAMB (Layer-wise Adaptive Moments) optimizer
14///
15/// LAMB is designed for large batch optimization. It extends AdamW with layer-wise
16/// adaptive learning rates, making it particularly effective for training large models
17/// with high batch sizes.
18///
19/// Formula:
20/// m_t = beta1 * m_{t-1} + (1 - beta1) * g_t
21/// v_t = beta2 * v_{t-1} + (1 - beta2) * g_t^2
22/// m_hat_t = m_t / (1 - beta1^t)
23/// v_hat_t = v_t / (1 - beta2^t)
24/// r1 = ||theta_t||
25/// g' = m_hat_t / (sqrt(v_hat_t) + epsilon) + lambda * theta_t
26/// r2 = ||g'||
27/// ratio = r1/r2 if r1 > 0 and r2 > 0, else 1.0
28/// theta_t = theta_{t-1} - lr * ratio * g'
29///
30/// # Examples
31///
32/// ```
33/// use scirs2_core::ndarray::Array1;
34/// use optirs_core::optimizers::{LAMB, Optimizer};
35///
36/// // Initialize parameters and gradients
37/// let params = Array1::zeros(5);
38/// let gradients = Array1::from_vec(vec![0.1, 0.2, -0.3, 0.0, 0.5]);
39///
40/// // Create a LAMB optimizer with default hyperparameters
41/// let mut optimizer = LAMB::new(0.001);
42///
43/// // Update parameters
44/// let new_params = optimizer.step(&params, &gradients).expect("optimizer.step succeeds");
45/// ```
46#[derive(Debug, Clone)]
47pub struct LAMB<A: Float + ScalarOperand + Debug> {
48    /// Learning rate
49    learning_rate: A,
50    /// Exponential decay rate for the first moment estimates
51    beta1: A,
52    /// Exponential decay rate for the second moment estimates
53    beta2: A,
54    /// Small constant for numerical stability
55    epsilon: A,
56    /// Weight decay factor (L2 regularization)
57    weight_decay: A,
58    /// Whether to use bias correction
59    bias_correction: bool,
60    /// First moment vectors, one slot per parameter-tensor index
61    m: Option<Vec<Array<A, IxDyn>>>,
62    /// Second moment vectors, one slot per parameter-tensor index
63    v: Option<Vec<Array<A, IxDyn>>>,
64    /// Per-parameter-index timestep counters
65    t: Vec<usize>,
66}
67
68impl<A: Float + ScalarOperand + Debug + Send + Sync> LAMB<A> {
69    /// Creates a new LAMB optimizer with the given learning rate and default settings
70    ///
71    /// # Arguments
72    ///
73    /// * `learning_rate` - The learning rate for parameter updates
74    pub fn new(learning_rate: A) -> Self {
75        Self {
76            learning_rate,
77            beta1: A::from(0.9).expect("LAMB: default beta1 (0.9) must fit in A"),
78            beta2: A::from(0.999).expect("LAMB: default beta2 (0.999) must fit in A"),
79            epsilon: A::from(1e-6).expect("LAMB: default epsilon (1e-6) must fit in A"),
80            weight_decay: A::zero(),
81            bias_correction: true,
82            m: None,
83            v: None,
84            t: Vec::new(),
85        }
86    }
87
88    /// Creates a new LAMB optimizer with the full configuration
89    ///
90    /// # Arguments
91    ///
92    /// * `learning_rate` - The learning rate for parameter updates
93    /// * `beta1` - Exponential decay rate for the first moment estimates (default: 0.9)
94    /// * `beta2` - Exponential decay rate for the second moment estimates (default: 0.999)
95    /// * `epsilon` - Small constant for numerical stability (default: 1e-6)
96    /// * `weight_decay` - Weight decay factor for L2 regularization (default: 0.0)
97    /// * `bias_correction` - Whether to use bias correction (default: true)
98    pub fn new_with_config(
99        learning_rate: A,
100        beta1: A,
101        beta2: A,
102        epsilon: A,
103        weight_decay: A,
104        bias_correction: bool,
105    ) -> Self {
106        Self {
107            learning_rate,
108            beta1,
109            beta2,
110            epsilon,
111            weight_decay,
112            bias_correction,
113            m: None,
114            v: None,
115            t: Vec::new(),
116        }
117    }
118
119    /// Sets the beta1 parameter
120    pub fn set_beta1(&mut self, beta1: A) -> &mut Self {
121        self.beta1 = beta1;
122        self
123    }
124
125    /// Gets the beta1 parameter
126    pub fn get_beta1(&self) -> A {
127        self.beta1
128    }
129
130    /// Sets the beta2 parameter
131    pub fn set_beta2(&mut self, beta2: A) -> &mut Self {
132        self.beta2 = beta2;
133        self
134    }
135
136    /// Gets the beta2 parameter
137    pub fn get_beta2(&self) -> A {
138        self.beta2
139    }
140
141    /// Sets the epsilon parameter
142    pub fn set_epsilon(&mut self, epsilon: A) -> &mut Self {
143        self.epsilon = epsilon;
144        self
145    }
146
147    /// Gets the epsilon parameter
148    pub fn get_epsilon(&self) -> A {
149        self.epsilon
150    }
151
152    /// Sets the weight decay parameter
153    pub fn set_weight_decay(&mut self, weight_decay: A) -> &mut Self {
154        self.weight_decay = weight_decay;
155        self
156    }
157
158    /// Gets the weight decay parameter
159    pub fn get_weight_decay(&self) -> A {
160        self.weight_decay
161    }
162
163    /// Gets the current learning rate
164    pub fn learning_rate(&self) -> A {
165        self.learning_rate
166    }
167
168    /// Sets the learning rate
169    pub fn set_lr(&mut self, lr: A) {
170        self.learning_rate = lr;
171    }
172
173    /// Resets the internal state of the optimizer
174    pub fn reset(&mut self) {
175        self.m = None;
176        self.v = None;
177        self.t.clear();
178    }
179
180    /// Returns the timestep recorded for the parameter tensor at `index`
181    ///
182    /// Returns `0` when the index has never been stepped.
183    pub fn timestep(&self, index: usize) -> usize {
184        self.t.get(index).copied().unwrap_or(0)
185    }
186
187    /// Ensures state slots exist for `index` and match `dim`, then advances its timestep
188    fn advance_state(&mut self, index: usize, dim: &IxDyn) -> Result<usize> {
189        let m = self.m.get_or_insert_with(Vec::new);
190        let v = self.v.get_or_insert_with(Vec::new);
191        while m.len() <= index {
192            m.push(Array::zeros(dim.clone()));
193        }
194        while v.len() <= index {
195            v.push(Array::zeros(dim.clone()));
196        }
197        while self.t.len() <= index {
198            self.t.push(0);
199        }
200
201        // Reset the slot when the parameter shape for this index changed
202        if m[index].raw_dim() != *dim || v[index].raw_dim() != *dim {
203            m[index] = Array::zeros(dim.clone());
204            v[index] = Array::zeros(dim.clone());
205            self.t[index] = 0;
206        }
207
208        let next = self.t[index].checked_add(1).ok_or_else(|| {
209            OptimError::InvalidConfig(
210                "Timestep counter overflow - too many optimization steps".to_string(),
211            )
212        })?;
213        self.t[index] = next;
214        Ok(next)
215    }
216
217    /// Performs a LAMB update for the parameter tensor at `index`
218    ///
219    /// Each `index` owns an independent moment/timestep slot, so several parameter
220    /// tensors can be optimized by a single `LAMB` instance without interference.
221    /// LAMB's trust ratio is computed per tensor, which is exactly the layer-wise
222    /// adaptation the algorithm prescribes.
223    pub fn step_indexed<D: Dimension>(
224        &mut self,
225        index: usize,
226        params: &Array<A, D>,
227        gradients: &Array<A, D>,
228    ) -> Result<Array<A, D>> {
229        if params.shape() != gradients.shape() {
230            return Err(OptimError::DimensionMismatch(format!(
231                "Incompatible shapes: parameters have shape {:?}, gradients have shape {:?}",
232                params.shape(),
233                gradients.shape()
234            )));
235        }
236
237        let dim = params.raw_dim().into_dyn();
238        let t = self.advance_state(index, &dim)?;
239        let exp = i32::try_from(t).map_err(|_| {
240            OptimError::InvalidConfig(
241                "Timestep too large for bias correction calculation".to_string(),
242            )
243        })?;
244
245        let beta1 = self.beta1;
246        let beta2 = self.beta2;
247        let eps = self.epsilon;
248        let weight_decay = self.weight_decay;
249        let use_weight_decay = weight_decay > A::zero();
250        let one = A::one();
251        let (bias_correction1, bias_correction2) = if self.bias_correction {
252            (one - beta1.powi(exp), one - beta2.powi(exp))
253        } else {
254            (one, one)
255        };
256
257        let m = self
258            .m
259            .as_mut()
260            .ok_or_else(|| OptimError::InvalidConfig("LAMB state not initialized".to_string()))?;
261        let v = self
262            .v
263            .as_mut()
264            .ok_or_else(|| OptimError::InvalidConfig("LAMB state not initialized".to_string()))?;
265
266        let params_view = params.view().into_dyn();
267        let gradients_view = gradients.view().into_dyn();
268
269        // Build the (weight-decayed) adaptive update direction, and accumulate the
270        // two norms needed for the layer-wise trust ratio in the same traversal.
271        let mut update: Array<A, IxDyn> = Array::zeros(dim.clone());
272        let mut weight_norm_sq = A::zero();
273        let mut update_norm_sq = A::zero();
274
275        Zip::from(&mut update)
276            .and(&params_view)
277            .and(&gradients_view)
278            .and(&mut m[index])
279            .and(&mut v[index])
280            .for_each(|u, &p, &g, m_i, v_i| {
281                *m_i = *m_i * beta1 + g * (one - beta1);
282                *v_i = *v_i * beta2 + g * g * (one - beta2);
283                let m_hat = *m_i / bias_correction1;
284                let v_hat = *v_i / bias_correction2;
285                let mut direction = m_hat / (v_hat.sqrt() + eps);
286                if use_weight_decay {
287                    direction = direction + p * weight_decay;
288                }
289                *u = direction;
290                weight_norm_sq = weight_norm_sq + p * p;
291                update_norm_sq = update_norm_sq + direction * direction;
292            });
293
294        let weight_norm = weight_norm_sq.sqrt();
295        let update_norm = update_norm_sq.sqrt();
296        let trust_ratio = if weight_norm > A::zero() && update_norm > A::zero() {
297            weight_norm / update_norm
298        } else {
299            one
300        };
301
302        let scale = self.learning_rate * trust_ratio;
303        let mut updated = params.to_owned();
304        let mut updated_view = updated.view_mut().into_dyn();
305        Zip::from(&mut updated_view).and(&update).for_each(|p, &u| {
306            *p = *p - u * scale;
307        });
308        drop(updated_view);
309
310        Ok(updated)
311    }
312}
313
314impl<A, D> Optimizer<A, D> for LAMB<A>
315where
316    A: Float + ScalarOperand + Debug + Send + Sync,
317    D: Dimension,
318{
319    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
320        self.step_indexed(0, params, gradients)
321    }
322
323    fn step_list(
324        &mut self,
325        params_list: &[&Array<A, D>],
326        gradients_list: &[&Array<A, D>],
327    ) -> Result<Vec<Array<A, D>>> {
328        if params_list.len() != gradients_list.len() {
329            return Err(OptimError::InvalidConfig(format!(
330                "Number of parameter arrays ({}) does not match number of gradient arrays ({})",
331                params_list.len(),
332                gradients_list.len()
333            )));
334        }
335
336        let mut results = Vec::with_capacity(params_list.len());
337        for (index, (params, grads)) in params_list.iter().zip(gradients_list.iter()).enumerate() {
338            results.push(self.step_indexed(index, params, grads)?);
339        }
340        Ok(results)
341    }
342
343    fn get_learning_rate(&self) -> A {
344        self.learning_rate
345    }
346
347    fn set_learning_rate(&mut self, learning_rate: A) {
348        self.learning_rate = learning_rate;
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use approx::assert_abs_diff_eq;
356    use scirs2_core::ndarray::Array1;
357
358    #[test]
359    fn test_lamb_basic_creation() {
360        let optimizer: LAMB<f64> = LAMB::new(0.001);
361        assert_abs_diff_eq!(optimizer.learning_rate(), 0.001);
362        assert_abs_diff_eq!(optimizer.get_beta1(), 0.9);
363        assert_abs_diff_eq!(optimizer.get_beta2(), 0.999);
364        assert_abs_diff_eq!(optimizer.get_epsilon(), 1e-6);
365        assert_abs_diff_eq!(optimizer.get_weight_decay(), 0.0);
366        assert!(optimizer.bias_correction);
367    }
368
369    #[test]
370    fn test_lamb_convergence() {
371        let mut optimizer: LAMB<f64> = LAMB::new(0.1);
372
373        // Minimize a simple quadratic function: f(x) = x^2 + y^2
374        let mut params = Array1::from_vec(vec![5.0, 3.0]);
375
376        for _ in 0..50 {
377            // Gradient of x^2 + y^2 is (2x, 2y)
378            let gradients = Array1::from_vec(vec![2.0 * params[0], 2.0 * params[1]]);
379            params = optimizer
380                .step(&params, &gradients)
381                .expect("optimizer.step succeeds in test_lamb_convergence");
382        }
383
384        // Should converge towards (0, 0)
385        assert!(params[0].abs() < 1.0);
386        assert!(params[1].abs() < 1.0);
387    }
388
389    #[test]
390    fn test_lamb_with_weight_decay() {
391        let mut optimizer: LAMB<f64> = LAMB::new_with_config(
392            0.1,   // learning_rate
393            0.9,   // beta1
394            0.999, // beta2
395            1e-6,  // epsilon
396            0.1,   // weight_decay
397            true,  // bias_correction
398        );
399
400        // Start from (1.0, 1.0)
401        let mut params = Array1::from_vec(vec![1.0, 1.0]);
402
403        // Run optimization with small gradients
404        for _ in 0..20 {
405            let gradients = Array1::from_vec(vec![0.1, 0.1]);
406            params = optimizer
407                .step(&params, &gradients)
408                .expect("optimizer.step succeeds in test_lamb_with_weight_decay");
409        }
410
411        // With weight decay, parameters should decrease
412        assert!(params[0] < 1.0);
413        assert!(params[1] < 1.0);
414    }
415
416    #[test]
417    fn test_lamb_reset() {
418        let mut optimizer: LAMB<f64> = LAMB::new(0.1);
419
420        // Perform a step to initialize state
421        let params = Array1::from_vec(vec![1.0]);
422        let gradients = Array1::from_vec(vec![0.5]);
423        let _ = optimizer
424            .step(&params, &gradients)
425            .expect("optimizer.step succeeds in test_lamb_reset");
426
427        // State should exist
428        assert!(optimizer.m.is_some());
429        assert!(optimizer.v.is_some());
430        assert_eq!(optimizer.timestep(0), 1);
431
432        // Reset
433        optimizer.reset();
434
435        // State should be cleared
436        assert!(optimizer.m.is_none());
437        assert!(optimizer.v.is_none());
438        assert_eq!(optimizer.timestep(0), 0);
439    }
440
441    #[test]
442    fn test_lamb_trust_ratio() {
443        // Test with normal gradient and parameters
444        let mut optimizer: LAMB<f64> = LAMB::new(0.1);
445        let params = Array1::from_vec(vec![2.0, 3.0]);
446        let gradients = Array1::from_vec(vec![0.4, 0.6]);
447
448        let new_params = optimizer
449            .step(&params, &gradients)
450            .expect("optimizer.step succeeds in test_lamb_trust_ratio");
451
452        // Parameters should be updated
453        assert_ne!(new_params[0], params[0]);
454        assert_ne!(new_params[1], params[1]);
455
456        // Check they moved in the right direction
457        assert!(new_params[0] < params[0]); // gradient was positive
458        assert!(new_params[1] < params[1]); // gradient was positive
459    }
460}