Skip to main content

optirs_core/optimizers/
radam.rs

1// RAdam (Rectified Adam) optimizer implementation
2//
3// RAdam is an improved variant of Adam with a rectified adaptive learning rate.
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/// RAdam (Rectified Adam) optimizer
13///
14/// Implements the RAdam algorithm from the paper:
15/// "On the Variance of the Adaptive Learning Rate and Beyond" by Liu et al. (2019).
16///
17/// RAdam improves upon Adam by addressing the early-stage training instability with
18/// a rectified variance term. It eliminates the need for a warmup period and often
19/// leads to better convergence.
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///
27/// rho_inf = 2 / (1 - beta2) - 1
28/// rho_t   = rho_inf - 2 * t * beta2^t / (1 - beta2^t)
29///
30/// If rho_t > 4 (the variance of the adaptive learning rate is tractable):
31///   r_t = sqrt( ((rho_t - 4)(rho_t - 2) rho_inf) / ((rho_inf - 4)(rho_inf - 2) rho_t) )
32///   theta_t = theta_{t-1} - lr * r_t * m_hat_t / (sqrt(v_hat_t) + epsilon)
33/// Else:
34///   theta_t = theta_{t-1} - lr * m_hat_t (non-adaptive, SGD-with-momentum-like)
35///
36/// The rectification term `r_t` tends to 1 as `t -> infinity`, so late training
37/// behaves like Adam. See Liu et al. (2019), Algorithm 2.
38///
39/// # Examples
40///
41/// ```
42/// use scirs2_core::ndarray::Array1;
43/// use optirs_core::optimizers::{RAdam, Optimizer};
44///
45/// // Initialize parameters and gradients
46/// let params = Array1::zeros(5);
47/// let gradients = Array1::from_vec(vec![0.1, 0.2, -0.3, 0.0, 0.5]);
48///
49/// // Create a RAdam optimizer with default hyperparameters
50/// let mut optimizer = RAdam::new(0.001);
51///
52/// // Update parameters
53/// let new_params = optimizer.step(&params, &gradients).expect("optimizer.step succeeds");
54/// ```
55#[derive(Debug, Clone)]
56pub struct RAdam<A: Float + ScalarOperand + Debug> {
57    /// Learning rate
58    learning_rate: A,
59    /// Exponential decay rate for the first moment estimates
60    beta1: A,
61    /// Exponential decay rate for the second moment estimates
62    beta2: A,
63    /// Small constant for numerical stability
64    epsilon: A,
65    /// Weight decay factor
66    weight_decay: A,
67    /// First moment vectors, one slot per parameter-tensor index
68    m: Option<Vec<Array<A, IxDyn>>>,
69    /// Second moment vectors, one slot per parameter-tensor index
70    v: Option<Vec<Array<A, IxDyn>>>,
71    /// Per-parameter-index timestep counters
72    t: Vec<usize>,
73    /// Rho infinity (precomputed constant)
74    rho_inf: A,
75}
76
77impl<A: Float + ScalarOperand + Debug + Send + Sync> RAdam<A> {
78    /// Creates a new RAdam optimizer with the given learning rate and default settings
79    ///
80    /// # Arguments
81    ///
82    /// * `learning_rate` - The learning rate for parameter updates
83    pub fn new(learning_rate: A) -> Self {
84        let beta2 = A::from(0.999).expect("RAdam: default beta2 (0.999) must fit in A");
85        Self {
86            learning_rate,
87            beta1: A::from(0.9).expect("RAdam: default beta1 (0.9) must fit in A"),
88            beta2,
89            epsilon: A::from(1e-8).expect("RAdam: default epsilon (1e-8) must fit in A"),
90            weight_decay: A::zero(),
91            m: None,
92            v: None,
93            t: Vec::new(),
94            rho_inf: A::from(2.0).expect("RAdam: integer literal 2.0 must fit in A")
95                / (A::one() - beta2)
96                - A::one(),
97        }
98    }
99
100    /// Creates a new RAdam optimizer with the full configuration
101    ///
102    /// # Arguments
103    ///
104    /// * `learning_rate` - The learning rate for parameter updates
105    /// * `beta1` - Exponential decay rate for the first moment estimates (default: 0.9)
106    /// * `beta2` - Exponential decay rate for the second moment estimates (default: 0.999)
107    /// * `epsilon` - Small constant for numerical stability (default: 1e-8)
108    /// * `weight_decay` - Weight decay factor (default: 0.0)
109    pub fn new_with_config(
110        learning_rate: A,
111        beta1: A,
112        beta2: A,
113        epsilon: A,
114        weight_decay: A,
115    ) -> Self {
116        Self {
117            learning_rate,
118            beta1,
119            beta2,
120            epsilon,
121            weight_decay,
122            m: None,
123            v: None,
124            t: Vec::new(),
125            rho_inf: A::from(2.0).expect("RAdam: integer literal 2.0 must fit in A")
126                / (A::one() - beta2)
127                - A::one(),
128        }
129    }
130
131    /// Sets the beta1 parameter
132    pub fn set_beta1(&mut self, beta1: A) -> &mut Self {
133        self.beta1 = beta1;
134        self
135    }
136
137    /// Gets the beta1 parameter
138    pub fn get_beta1(&self) -> A {
139        self.beta1
140    }
141
142    /// Sets the beta2 parameter
143    pub fn set_beta2(&mut self, beta2: A) -> &mut Self {
144        self.beta2 = beta2;
145        // Update rho_inf based on new beta2
146        self.rho_inf = A::from(2.0).expect("RAdam: integer literal 2.0 must fit in A")
147            / (A::one() - beta2)
148            - A::one();
149        self
150    }
151
152    /// Gets the beta2 parameter
153    pub fn get_beta2(&self) -> A {
154        self.beta2
155    }
156
157    /// Sets the epsilon parameter
158    pub fn set_epsilon(&mut self, epsilon: A) -> &mut Self {
159        self.epsilon = epsilon;
160        self
161    }
162
163    /// Gets the epsilon parameter
164    pub fn get_epsilon(&self) -> A {
165        self.epsilon
166    }
167
168    /// Sets the weight decay parameter
169    pub fn set_weight_decay(&mut self, weight_decay: A) -> &mut Self {
170        self.weight_decay = weight_decay;
171        self
172    }
173
174    /// Gets the weight decay parameter
175    pub fn get_weight_decay(&self) -> A {
176        self.weight_decay
177    }
178
179    /// Gets the current learning rate
180    pub fn learning_rate(&self) -> A {
181        self.learning_rate
182    }
183
184    /// Sets the learning rate
185    pub fn set_lr(&mut self, lr: A) {
186        self.learning_rate = lr;
187    }
188
189    /// Resets the internal state of the optimizer
190    pub fn reset(&mut self) {
191        self.m = None;
192        self.v = None;
193        self.t.clear();
194    }
195
196    /// Returns the timestep recorded for the parameter tensor at `index`
197    ///
198    /// Returns `0` when the index has never been stepped.
199    pub fn timestep(&self, index: usize) -> usize {
200        self.t.get(index).copied().unwrap_or(0)
201    }
202
203    /// Returns rho_infinity, the maximum length of the approximated SMA
204    pub fn rho_inf(&self) -> A {
205        self.rho_inf
206    }
207
208    /// Computes rho_t, the length of the approximated simple moving average at step `t`
209    ///
210    /// Returns `None` when `t == 0` (no step has been taken yet).
211    pub fn rho_t(&self, t: usize) -> Option<A> {
212        if t == 0 {
213            return None;
214        }
215        let exp = i32::try_from(t).ok()?;
216        let two = A::one() + A::one();
217        let t_f = A::from(t)?;
218        let beta2_t = self.beta2.powi(exp);
219        Some(self.rho_inf - two * t_f * beta2_t / (A::one() - beta2_t))
220    }
221
222    /// Computes the RAdam rectification term `r_t` for step `t`
223    ///
224    /// Returns `None` when the variance is not yet tractable (`rho_t <= 4`), in which
225    /// case RAdam falls back to a non-adaptive, SGD-like update.
226    ///
227    /// `r_t` converges to `1` as `t -> infinity`.
228    pub fn rectification_term(&self, t: usize) -> Option<A> {
229        let rho_t = self.rho_t(t)?;
230        let two = A::one() + A::one();
231        let four = two + two;
232        if rho_t <= four {
233            return None;
234        }
235        let rho_inf = self.rho_inf;
236        let numerator = (rho_t - four) * (rho_t - two) * rho_inf;
237        let denominator = (rho_inf - four) * (rho_inf - two) * rho_t;
238        if denominator <= A::zero() {
239            return None;
240        }
241        Some((numerator / denominator).sqrt())
242    }
243
244    /// Ensures state slots exist for `index` and match `dim`, then advances its timestep
245    fn advance_state(&mut self, index: usize, dim: &IxDyn) -> Result<usize> {
246        let m = self.m.get_or_insert_with(Vec::new);
247        let v = self.v.get_or_insert_with(Vec::new);
248        while m.len() <= index {
249            m.push(Array::zeros(dim.clone()));
250        }
251        while v.len() <= index {
252            v.push(Array::zeros(dim.clone()));
253        }
254        while self.t.len() <= index {
255            self.t.push(0);
256        }
257
258        // Reset the slot when the parameter shape for this index changed
259        if m[index].raw_dim() != *dim || v[index].raw_dim() != *dim {
260            m[index] = Array::zeros(dim.clone());
261            v[index] = Array::zeros(dim.clone());
262            self.t[index] = 0;
263        }
264
265        let next = self.t[index].checked_add(1).ok_or_else(|| {
266            OptimError::InvalidConfig(
267                "Timestep counter overflow - too many optimization steps".to_string(),
268            )
269        })?;
270        self.t[index] = next;
271        Ok(next)
272    }
273
274    /// Applies a RAdam update in place for the parameter tensor at `index`
275    pub fn step_inplace_indexed<D: Dimension>(
276        &mut self,
277        index: usize,
278        params: &mut Array<A, D>,
279        gradients: &Array<A, D>,
280    ) -> Result<()> {
281        if params.shape() != gradients.shape() {
282            return Err(OptimError::DimensionMismatch(format!(
283                "Incompatible shapes: parameters have shape {:?}, gradients have shape {:?}",
284                params.shape(),
285                gradients.shape()
286            )));
287        }
288
289        let dim = params.raw_dim().into_dyn();
290        let t = self.advance_state(index, &dim)?;
291        let exp = i32::try_from(t).map_err(|_| {
292            OptimError::InvalidConfig(
293                "Timestep too large for bias correction calculation".to_string(),
294            )
295        })?;
296
297        let beta1 = self.beta1;
298        let beta2 = self.beta2;
299        let lr = self.learning_rate;
300        let eps = self.epsilon;
301        let weight_decay = self.weight_decay;
302        let use_weight_decay = weight_decay > A::zero();
303        let one = A::one();
304        let bias_correction1 = one - beta1.powi(exp);
305        let bias_correction2 = one - beta2.powi(exp);
306
307        // Rectification term; `None` means the variance is not yet tractable and the
308        // update falls back to the non-adaptive (SGD-like) branch.
309        let rect = self.rectification_term(t);
310
311        let m = self
312            .m
313            .as_mut()
314            .ok_or_else(|| OptimError::InvalidConfig("RAdam state not initialized".to_string()))?;
315        let v = self
316            .v
317            .as_mut()
318            .ok_or_else(|| OptimError::InvalidConfig("RAdam state not initialized".to_string()))?;
319
320        let mut params_view = params.view_mut().into_dyn();
321        let gradients_view = gradients.view().into_dyn();
322
323        Zip::from(&mut params_view)
324            .and(&gradients_view)
325            .and(&mut m[index])
326            .and(&mut v[index])
327            .for_each(|p, &g, m_i, v_i| {
328                let grad = if use_weight_decay {
329                    g + weight_decay * *p
330                } else {
331                    g
332                };
333                *m_i = *m_i * beta1 + grad * (one - beta1);
334                *v_i = *v_i * beta2 + grad * grad * (one - beta2);
335                let m_hat = *m_i / bias_correction1;
336                match rect {
337                    Some(r_t) => {
338                        let v_hat = *v_i / bias_correction2;
339                        *p = *p - lr * r_t * m_hat / (v_hat.sqrt() + eps);
340                    }
341                    None => {
342                        *p = *p - lr * m_hat;
343                    }
344                }
345            });
346
347        Ok(())
348    }
349
350    /// Applies a RAdam update in place using the state slot of the first parameter tensor
351    pub fn step_inplace<D: Dimension>(
352        &mut self,
353        params: &mut Array<A, D>,
354        gradients: &Array<A, D>,
355    ) -> Result<()> {
356        self.step_inplace_indexed(0, params, gradients)
357    }
358
359    /// Performs a RAdam update for the parameter tensor at `index`
360    ///
361    /// Each `index` owns an independent moment/timestep slot.
362    pub fn step_indexed<D: Dimension>(
363        &mut self,
364        index: usize,
365        params: &Array<A, D>,
366        gradients: &Array<A, D>,
367    ) -> Result<Array<A, D>> {
368        let mut updated = params.to_owned();
369        self.step_inplace_indexed(index, &mut updated, gradients)?;
370        Ok(updated)
371    }
372}
373
374impl<A, D> Optimizer<A, D> for RAdam<A>
375where
376    A: Float + ScalarOperand + Debug + Send + Sync + std::convert::From<f64>,
377    D: Dimension,
378{
379    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
380        self.step_indexed(0, params, gradients)
381    }
382
383    fn step_list(
384        &mut self,
385        params_list: &[&Array<A, D>],
386        gradients_list: &[&Array<A, D>],
387    ) -> Result<Vec<Array<A, D>>> {
388        if params_list.len() != gradients_list.len() {
389            return Err(OptimError::InvalidConfig(format!(
390                "Number of parameter arrays ({}) does not match number of gradient arrays ({})",
391                params_list.len(),
392                gradients_list.len()
393            )));
394        }
395
396        let mut results = Vec::with_capacity(params_list.len());
397        for (index, (params, grads)) in params_list.iter().zip(gradients_list.iter()).enumerate() {
398            results.push(self.step_indexed(index, params, grads)?);
399        }
400        Ok(results)
401    }
402
403    fn get_learning_rate(&self) -> A {
404        self.learning_rate
405    }
406
407    fn set_learning_rate(&mut self, learning_rate: A) {
408        self.learning_rate = learning_rate;
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use scirs2_core::ndarray::Array1;
416
417    #[test]
418    fn test_radam_step() {
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 = RAdam::new(0.01);
425
426        // Run one step
427        let new_params = optimizer
428            .step(&params, &gradients)
429            .expect("optimizer.step succeeds in test_radam_step");
430
431        // Check that parameters have been updated
432        assert!(new_params.iter().all(|&x| x != 0.0));
433
434        // Due to rectification, early steps should behave more like SGD
435        // Verify gradient direction - larger gradients should result in larger updates
436        for i in 1..3 {
437            assert!(new_params[i].abs() > new_params[i - 1].abs());
438        }
439    }
440
441    #[test]
442    fn test_radam_multiple_steps() {
443        // Create parameters and gradients
444        let mut params = Array1::zeros(3);
445        let gradients = Array1::from_vec(vec![0.1, 0.2, 0.3]);
446
447        // Create optimizer with small learning rate
448        let mut optimizer = RAdam::new(0.01);
449
450        // Run multiple steps to move past the adaptive phase
451        for _ in 0..100 {
452            params = optimizer
453                .step(&params, &gradients)
454                .expect("optimizer.step succeeds in test_radam_multiple_steps");
455        }
456
457        // Parameters should continue to move in the direction of the gradients
458        // with larger updates for larger gradients
459        for i in 1..3 {
460            assert!(params[i].abs() > params[i - 1].abs());
461        }
462    }
463
464    #[test]
465    fn test_radam_weight_decay() {
466        // Create parameters with non-zero values and gradients
467        let params = Array1::from_vec(vec![0.1, 0.2, 0.3]);
468        let gradients = Array1::from_vec(vec![0.01, 0.01, 0.01]);
469
470        // Create optimizer with weight decay
471        let mut optimizer = RAdam::new_with_config(
472            0.01, 0.9, 0.999, 1e-8, 0.1, // Add weight decay
473        );
474
475        // Run one step
476        let new_params = optimizer
477            .step(&params, &gradients)
478            .expect("optimizer.step succeeds in test_radam_weight_decay");
479
480        // Weight decay should reduce parameter magnitudes
481        for i in 0..3 {
482            assert!(new_params[i].abs() < params[i].abs());
483        }
484    }
485
486    // Test commented out to fix compilation
487    // #[test]
488    // fn test_radam_config() {
489    //     let optimizer = RAdam::new_with_config(
490    //         0.02.into(),
491    //         0.8.into(),
492    //         0.9,
493    //         1e-10.into(),
494    //         0.05.into(),
495    //     );
496
497    //     assert_eq!(optimizer.get_learning_rate(), 0.02.into());
498    //     assert_eq!(optimizer.get_beta1(), 0.8.into());
499    //     assert_eq!(optimizer.get_beta2(), 0.9.into());
500    //     assert_eq!(optimizer.get_epsilon(), 1e-10.into());
501    //     assert_eq!(optimizer.get_weight_decay(), 0.05.into());
502    // }
503
504    #[test]
505    fn test_radam_reset() {
506        // Create parameters and gradients
507        let params = Array1::zeros(3);
508        let gradients = Array1::from_vec(vec![0.1, 0.2, 0.3]);
509
510        // Create optimizer
511        let mut optimizer = RAdam::new(0.01);
512
513        // Run one step
514        optimizer
515            .step(&params, &gradients)
516            .expect("optimizer.step succeeds in test_radam_reset");
517        assert_eq!(optimizer.timestep(0), 1);
518        assert!(optimizer.m.is_some());
519        assert!(optimizer.v.is_some());
520
521        // Reset optimizer
522        optimizer.reset();
523        assert_eq!(optimizer.timestep(0), 0);
524        assert!(optimizer.m.is_none());
525        assert!(optimizer.v.is_none());
526    }
527}