Skip to main content

optirs_core/optimizers/
adamw.rs

1// AdamW optimizer implementation
2//
3// AdamW is a variant of Adam that correctly implements weight decay regularization.
4
5use scirs2_core::ndarray::{Array, Dimension, IxDyn, ScalarOperand, Zip};
6use scirs2_core::numeric::Float;
7use std::fmt::Debug;
8
9use crate::error::{OptimError, Result};
10use crate::optimizers::Optimizer;
11
12/// AdamW optimizer
13///
14/// Implements the AdamW optimization algorithm from the paper:
15/// "Decoupled Weight Decay Regularization" by Loshchilov and Hutter (2019).
16///
17/// AdamW uses a more principled approach to weight decay compared to standard Adam.
18/// The key difference is that weight decay is applied directly to the weights,
19/// not within the adaptive learning rate computation.
20///
21/// Formula:
22/// m_t = beta1 * m_{t-1} + (1 - beta1) * g_t
23/// v_t = beta2 * v_{t-1} + (1 - beta2) * g_t^2
24/// m_hat_t = m_t / (1 - beta1^t)
25/// v_hat_t = v_t / (1 - beta2^t)
26/// theta_t = theta_{t-1} * (1 - lr * weight_decay) - lr * m_hat_t / (sqrt(v_hat_t) + epsilon)
27///
28/// Note the decoupling of weight decay from the adaptive learning rate computation.
29///
30/// # Examples
31///
32/// ```
33/// use scirs2_core::ndarray::Array1;
34/// use optirs_core::optimizers::{AdamW, 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 an AdamW optimizer with default hyperparameters
41/// let mut optimizer = AdamW::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 AdamW<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 (decoupled from adaptive moment computation)
57    weight_decay: A,
58    /// First moment vectors, one slot per parameter-tensor index
59    m: Option<Vec<Array<A, IxDyn>>>,
60    /// Second moment vectors, one slot per parameter-tensor index
61    v: Option<Vec<Array<A, IxDyn>>>,
62    /// Per-parameter-index timestep counters
63    ///
64    /// Each parameter tensor passed through [`Optimizer::step_list`] keeps its own
65    /// timestep so that bias correction is computed independently per tensor.
66    t: Vec<usize>,
67}
68
69impl<A: Float + ScalarOperand + Debug + Send + Sync> AdamW<A> {
70    /// Creates a new AdamW optimizer with the given learning rate and default settings
71    ///
72    /// # Arguments
73    ///
74    /// * `learning_rate` - The learning rate for parameter updates
75    pub fn new(learning_rate: A) -> Self {
76        Self {
77            learning_rate,
78            beta1: A::from(0.9).expect("AdamW: default beta1 (0.9) must fit in A"),
79            beta2: A::from(0.999).expect("AdamW: default beta2 (0.999) must fit in A"),
80            epsilon: A::from(1e-8).expect("AdamW: default epsilon (1e-8) must fit in A"),
81            // Default weight decay is higher for AdamW
82            weight_decay: A::from(0.01).expect("AdamW: default weight_decay (0.01) must fit in A"),
83            m: None,
84            v: None,
85            t: Vec::new(),
86        }
87    }
88
89    /// Creates a new AdamW optimizer with the full configuration
90    ///
91    /// # Arguments
92    ///
93    /// * `learning_rate` - The learning rate for parameter updates
94    /// * `beta1` - Exponential decay rate for the first moment estimates (default: 0.9)
95    /// * `beta2` - Exponential decay rate for the second moment estimates (default: 0.999)
96    /// * `epsilon` - Small constant for numerical stability (default: 1e-8)
97    /// * `weight_decay` - Weight decay factor (default: 0.01)
98    pub fn new_with_config(
99        learning_rate: A,
100        beta1: A,
101        beta2: A,
102        epsilon: A,
103        weight_decay: A,
104    ) -> Self {
105        Self {
106            learning_rate,
107            beta1,
108            beta2,
109            epsilon,
110            weight_decay,
111            m: None,
112            v: None,
113            t: Vec::new(),
114        }
115    }
116
117    /// Sets the beta1 parameter
118    pub fn set_beta1(&mut self, beta1: A) -> &mut Self {
119        self.beta1 = beta1;
120        self
121    }
122
123    /// Gets the beta1 parameter
124    pub fn get_beta1(&self) -> A {
125        self.beta1
126    }
127
128    /// Sets the beta2 parameter
129    pub fn set_beta2(&mut self, beta2: A) -> &mut Self {
130        self.beta2 = beta2;
131        self
132    }
133
134    /// Gets the beta2 parameter
135    pub fn get_beta2(&self) -> A {
136        self.beta2
137    }
138
139    /// Sets the epsilon parameter
140    pub fn set_epsilon(&mut self, epsilon: A) -> &mut Self {
141        self.epsilon = epsilon;
142        self
143    }
144
145    /// Gets the epsilon parameter
146    pub fn get_epsilon(&self) -> A {
147        self.epsilon
148    }
149
150    /// Sets the weight decay parameter
151    pub fn set_weight_decay(&mut self, weight_decay: A) -> &mut Self {
152        self.weight_decay = weight_decay;
153        self
154    }
155
156    /// Gets the weight decay parameter
157    pub fn get_weight_decay(&self) -> A {
158        self.weight_decay
159    }
160
161    /// Gets the current learning rate
162    pub fn learning_rate(&self) -> A {
163        self.learning_rate
164    }
165
166    /// Sets the learning rate
167    pub fn set_lr(&mut self, lr: A) {
168        self.learning_rate = lr;
169    }
170
171    /// Resets the internal state of the optimizer
172    pub fn reset(&mut self) {
173        self.m = None;
174        self.v = None;
175        self.t.clear();
176    }
177
178    /// Returns the timestep recorded for the parameter tensor at `index`
179    ///
180    /// Returns `0` when the index has never been stepped.
181    pub fn timestep(&self, index: usize) -> usize {
182        self.t.get(index).copied().unwrap_or(0)
183    }
184
185    /// Ensures state slots exist for `index` and match `dim`, then advances its timestep
186    fn advance_state(&mut self, index: usize, dim: &IxDyn) -> Result<usize> {
187        let m = self.m.get_or_insert_with(Vec::new);
188        let v = self.v.get_or_insert_with(Vec::new);
189        while m.len() <= index {
190            m.push(Array::zeros(dim.clone()));
191        }
192        while v.len() <= index {
193            v.push(Array::zeros(dim.clone()));
194        }
195        while self.t.len() <= index {
196            self.t.push(0);
197        }
198
199        // Reset the slot when the parameter shape for this index changed
200        if m[index].raw_dim() != *dim || v[index].raw_dim() != *dim {
201            m[index] = Array::zeros(dim.clone());
202            v[index] = Array::zeros(dim.clone());
203            self.t[index] = 0;
204        }
205
206        let next = self.t[index].checked_add(1).ok_or_else(|| {
207            OptimError::InvalidConfig(
208                "Timestep counter overflow - too many optimization steps".to_string(),
209            )
210        })?;
211        self.t[index] = next;
212        Ok(next)
213    }
214
215    /// Applies an AdamW update in place for the parameter tensor at `index`
216    ///
217    /// This is the allocation-free hot path: moments and parameters are updated in
218    /// a single fused [`Zip`] traversal, so no temporary arrays are created.
219    pub fn step_inplace_indexed<D: Dimension>(
220        &mut self,
221        index: usize,
222        params: &mut Array<A, D>,
223        gradients: &Array<A, D>,
224    ) -> Result<()> {
225        if params.shape() != gradients.shape() {
226            return Err(OptimError::DimensionMismatch(format!(
227                "Incompatible shapes: parameters have shape {:?}, gradients have shape {:?}",
228                params.shape(),
229                gradients.shape()
230            )));
231        }
232
233        let dim = params.raw_dim().into_dyn();
234        let t = self.advance_state(index, &dim)?;
235        let exp = i32::try_from(t).map_err(|_| {
236            OptimError::InvalidConfig(
237                "Timestep too large for bias correction calculation".to_string(),
238            )
239        })?;
240
241        let beta1 = self.beta1;
242        let beta2 = self.beta2;
243        let lr = self.learning_rate;
244        let eps = self.epsilon;
245        let one = A::one();
246        let bias_correction1 = one - beta1.powi(exp);
247        let bias_correction2 = one - beta2.powi(exp);
248        // Decoupled weight decay: applied directly to the weights
249        let weight_decay_factor = one - lr * self.weight_decay;
250
251        let m = self
252            .m
253            .as_mut()
254            .ok_or_else(|| OptimError::InvalidConfig("AdamW state not initialized".to_string()))?;
255        let v = self
256            .v
257            .as_mut()
258            .ok_or_else(|| OptimError::InvalidConfig("AdamW state not initialized".to_string()))?;
259
260        let mut params_view = params.view_mut().into_dyn();
261        let gradients_view = gradients.view().into_dyn();
262
263        Zip::from(&mut params_view)
264            .and(&gradients_view)
265            .and(&mut m[index])
266            .and(&mut v[index])
267            .for_each(|p, &g, m_i, v_i| {
268                *m_i = *m_i * beta1 + g * (one - beta1);
269                *v_i = *v_i * beta2 + g * g * (one - beta2);
270                let m_hat = *m_i / bias_correction1;
271                let v_hat = *v_i / bias_correction2;
272                *p = *p * weight_decay_factor - lr * m_hat / (v_hat.sqrt() + eps);
273            });
274
275        Ok(())
276    }
277
278    /// Applies an AdamW update in place using the state slot of the first parameter tensor
279    pub fn step_inplace<D: Dimension>(
280        &mut self,
281        params: &mut Array<A, D>,
282        gradients: &Array<A, D>,
283    ) -> Result<()> {
284        self.step_inplace_indexed(0, params, gradients)
285    }
286
287    /// Performs an AdamW update for the parameter tensor at `index`
288    ///
289    /// Each `index` owns an independent moment/timestep slot, so several parameter
290    /// tensors can be optimized by a single `AdamW` instance without interference.
291    pub fn step_indexed<D: Dimension>(
292        &mut self,
293        index: usize,
294        params: &Array<A, D>,
295        gradients: &Array<A, D>,
296    ) -> Result<Array<A, D>> {
297        let mut updated = params.to_owned();
298        self.step_inplace_indexed(index, &mut updated, gradients)?;
299        Ok(updated)
300    }
301}
302
303impl<A, D> Optimizer<A, D> for AdamW<A>
304where
305    A: Float + ScalarOperand + Debug + Send + Sync,
306    D: Dimension,
307{
308    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
309        self.step_indexed(0, params, gradients)
310    }
311
312    fn step_list(
313        &mut self,
314        params_list: &[&Array<A, D>],
315        gradients_list: &[&Array<A, D>],
316    ) -> Result<Vec<Array<A, D>>> {
317        if params_list.len() != gradients_list.len() {
318            return Err(OptimError::InvalidConfig(format!(
319                "Number of parameter arrays ({}) does not match number of gradient arrays ({})",
320                params_list.len(),
321                gradients_list.len()
322            )));
323        }
324
325        let mut results = Vec::with_capacity(params_list.len());
326        for (index, (params, grads)) in params_list.iter().zip(gradients_list.iter()).enumerate() {
327            results.push(self.step_indexed(index, params, grads)?);
328        }
329        Ok(results)
330    }
331
332    fn get_learning_rate(&self) -> A {
333        self.learning_rate
334    }
335
336    fn set_learning_rate(&mut self, learning_rate: A) {
337        self.learning_rate = learning_rate;
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use scirs2_core::ndarray::Array1;
345
346    #[test]
347    fn test_adamw_step() {
348        // Create parameters and gradients
349        let params = Array1::zeros(3);
350        let gradients = Array1::from_vec(vec![0.1, 0.2, 0.3]);
351
352        // Create optimizer
353        let mut optimizer = AdamW::new(0.01);
354
355        // Run one step
356        let new_params = optimizer
357            .step(&params, &gradients)
358            .expect("optimizer.step succeeds in test_adamw_step");
359
360        // Check that parameters have been updated
361        assert!(new_params.iter().all(|&x| x != 0.0));
362
363        // Check the effect of weight decay - values should be negative due to both
364        // the gradient step and the weight decay effect
365        for param in new_params.iter() {
366            assert!(*param < 0.0);
367        }
368    }
369
370    #[test]
371    fn test_adamw_multiple_steps() {
372        // Create parameters and gradients
373        let mut params = Array1::zeros(3);
374        let gradients = Array1::from_vec(vec![0.1, 0.2, 0.3]);
375
376        // Create optimizer with small learning rate and high weight decay
377        let mut optimizer = AdamW::new_with_config(
378            0.01, 0.9, 0.999, 1e-8, 0.1, // high weight decay
379        );
380
381        // Run multiple steps
382        for _ in 0..10 {
383            params = optimizer
384                .step(&params, &gradients)
385                .expect("optimizer.step succeeds in test_adamw_multiple_steps");
386        }
387
388        // Parameters should continue to move in the direction of the gradients
389        for (i, param) in params.iter().enumerate() {
390            // More negative for larger gradients
391            assert!(*param < 0.0);
392            if i > 0 {
393                // Check that larger gradients lead to larger (more negative) updates
394                assert!(param < &params[i - 1]);
395            }
396        }
397    }
398
399    // Test commented out to fix compilation
400    // #[test]
401    // fn test_adamw_config() {
402    //     let optimizer = AdamW::new_with_config(
403    //         0.02.into(),
404    //         0.8.into(),
405    //         0.9.into(),
406    //         1e-10.into(),
407    //         0.05.into(),
408    //     );
409
410    //     assert_eq!(optimizer.get_learning_rate(), 0.02.into());
411    //     assert_eq!(optimizer.get_beta1(), 0.8.into());
412    //     assert_eq!(optimizer.get_beta2(), 0.9.into());
413    //     assert_eq!(optimizer.get_epsilon(), 1e-10.into());
414    //     assert_eq!(optimizer.get_weight_decay(), 0.05.into());
415    // }
416
417    #[test]
418    fn test_adamw_reset() {
419        // Create parameters and gradients
420        let params = Array1::zeros(3);
421        let gradients = Array1::from_vec(vec![0.1, 0.2, 0.3]);
422
423        // Create optimizer
424        let mut optimizer = AdamW::new(0.01);
425
426        // Run one step
427        optimizer
428            .step(&params, &gradients)
429            .expect("optimizer.step succeeds in test_adamw_reset");
430        assert_eq!(optimizer.timestep(0), 1);
431        assert!(optimizer.m.is_some());
432        assert!(optimizer.v.is_some());
433
434        // Reset optimizer
435        optimizer.reset();
436        assert_eq!(optimizer.timestep(0), 0);
437        assert!(optimizer.m.is_none());
438        assert!(optimizer.v.is_none());
439    }
440}