Skip to main content

optirs_core/optimizers/
adam.rs

1// Adam optimizer implementation
2
3use scirs2_core::ndarray::{Array, Dimension, IxDyn, ScalarOperand, Zip};
4use scirs2_core::numeric::Float;
5use std::fmt::Debug;
6
7use crate::error::{OptimError, Result};
8use crate::optimizers::Optimizer;
9
10/// Adam optimizer
11///
12/// Implements the Adam optimization algorithm from the paper:
13/// "Adam: A Method for Stochastic Optimization" by Kingma and Ba (2014).
14///
15/// Formula:
16/// m_t = beta1 * m_{t-1} + (1 - beta1) * g_t
17/// v_t = beta2 * v_{t-1} + (1 - beta2) * g_t^2
18/// m_hat_t = m_t / (1 - beta1^t)
19/// v_hat_t = v_t / (1 - beta2^t)
20/// theta_t = theta_{t-1} - alpha * m_hat_t / (sqrt(v_hat_t) + epsilon)
21///
22/// # Examples
23///
24/// ```
25/// use scirs2_core::ndarray::Array1;
26/// use optirs_core::optimizers::{Adam, Optimizer};
27///
28/// // Initialize parameters and gradients
29/// let params = Array1::zeros(5);
30/// let gradients = Array1::from_vec(vec![0.1, 0.2, -0.3, 0.0, 0.5]);
31///
32/// // Create an Adam optimizer with default hyperparameters
33/// let mut optimizer = Adam::new(0.001);
34///
35/// // Update parameters
36/// let new_params = optimizer.step(&params, &gradients).expect("optimizer.step succeeds");
37/// ```
38#[derive(Debug, Clone)]
39pub struct Adam<A: Float + ScalarOperand + Debug> {
40    /// Learning rate
41    learning_rate: A,
42    /// Exponential decay rate for the first moment estimates
43    beta1: A,
44    /// Exponential decay rate for the second moment estimates
45    beta2: A,
46    /// Small constant for numerical stability
47    epsilon: A,
48    /// Weight decay factor (L2 regularization)
49    weight_decay: A,
50    /// First moment vectors, one slot per parameter-tensor index
51    m: Option<Vec<Array<A, IxDyn>>>,
52    /// Second moment vectors, one slot per parameter-tensor index
53    v: Option<Vec<Array<A, IxDyn>>>,
54    /// Per-parameter-index timestep counters
55    ///
56    /// Each parameter tensor passed through [`Optimizer::step_list`] keeps its own
57    /// timestep so that bias correction is computed independently per tensor.
58    t: Vec<usize>,
59}
60
61impl<A: Float + ScalarOperand + Debug + Send + Sync> Adam<A> {
62    /// Creates a new Adam optimizer with the given learning rate and default settings
63    ///
64    /// # Arguments
65    ///
66    /// * `learning_rate` - The learning rate for parameter updates
67    pub fn new(learning_rate: A) -> Self {
68        Self {
69            learning_rate,
70            beta1: A::from(0.9)
71                .expect("Adam: default beta1 (0.9) must be representable in A (f32/f64)"),
72            beta2: A::from(0.999)
73                .expect("Adam: default beta2 (0.999) must be representable in A (f32/f64)"),
74            epsilon: A::from(1e-8)
75                .expect("Adam: default epsilon (1e-8) must be representable in A (f32/f64)"),
76            weight_decay: A::zero(),
77            m: None,
78            v: None,
79            t: Vec::new(),
80        }
81    }
82
83    /// Creates a new Adam optimizer with the full configuration
84    ///
85    /// # Arguments
86    ///
87    /// * `learning_rate` - The learning rate for parameter updates
88    /// * `beta1` - Exponential decay rate for the first moment estimates (default: 0.9)
89    /// * `beta2` - Exponential decay rate for the second moment estimates (default: 0.999)
90    /// * `epsilon` - Small constant for numerical stability (default: 1e-8)
91    /// * `weight_decay` - Weight decay factor for L2 regularization (default: 0.0)
92    pub fn new_with_config(
93        learning_rate: A,
94        beta1: A,
95        beta2: A,
96        epsilon: A,
97        weight_decay: A,
98    ) -> Self {
99        Self {
100            learning_rate,
101            beta1,
102            beta2,
103            epsilon,
104            weight_decay,
105            m: None,
106            v: None,
107            t: Vec::new(),
108        }
109    }
110
111    /// Sets the beta1 parameter
112    pub fn set_beta1(&mut self, beta1: A) -> &mut Self {
113        self.beta1 = beta1;
114        self
115    }
116
117    /// Builder method to set beta1 and return self
118    pub fn with_beta1(mut self, beta1: A) -> 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    /// Builder method to set beta2 and return self
135    pub fn with_beta2(mut self, beta2: A) -> Self {
136        self.beta2 = beta2;
137        self
138    }
139
140    /// Gets the beta2 parameter
141    pub fn get_beta2(&self) -> A {
142        self.beta2
143    }
144
145    /// Sets the epsilon parameter
146    pub fn set_epsilon(&mut self, epsilon: A) -> &mut Self {
147        self.epsilon = epsilon;
148        self
149    }
150
151    /// Builder method to set epsilon and return self
152    pub fn with_epsilon(mut self, epsilon: A) -> Self {
153        self.epsilon = epsilon;
154        self
155    }
156
157    /// Gets the epsilon parameter
158    pub fn get_epsilon(&self) -> A {
159        self.epsilon
160    }
161
162    /// Sets the weight decay parameter
163    pub fn set_weight_decay(&mut self, weight_decay: A) -> &mut Self {
164        self.weight_decay = weight_decay;
165        self
166    }
167
168    /// Builder method to set weight decay and return self
169    pub fn with_weight_decay(mut self, weight_decay: A) -> 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    /// Ensures state slots exist for `index` and match `dim`, then advances its timestep
204    ///
205    /// Returns the new (1-based) timestep for that index.
206    fn advance_state(&mut self, index: usize, dim: &IxDyn) -> Result<usize> {
207        let m = self.m.get_or_insert_with(Vec::new);
208        let v = self.v.get_or_insert_with(Vec::new);
209        while m.len() <= index {
210            m.push(Array::zeros(dim.clone()));
211        }
212        while v.len() <= index {
213            v.push(Array::zeros(dim.clone()));
214        }
215        while self.t.len() <= index {
216            self.t.push(0);
217        }
218
219        // Reset the slot when the parameter shape for this index changed
220        if m[index].raw_dim() != *dim || v[index].raw_dim() != *dim {
221            m[index] = Array::zeros(dim.clone());
222            v[index] = Array::zeros(dim.clone());
223            self.t[index] = 0;
224        }
225
226        let next = self.t[index].checked_add(1).ok_or_else(|| {
227            OptimError::InvalidConfig(
228                "Timestep counter overflow - too many optimization steps".to_string(),
229            )
230        })?;
231        self.t[index] = next;
232        Ok(next)
233    }
234
235    /// Applies an Adam update in place for the parameter tensor at `index`
236    ///
237    /// This is the allocation-free hot path: the moments and the parameters are
238    /// updated with a single fused [`Zip`] traversal, so no temporary arrays are
239    /// created per step.
240    pub fn step_inplace_indexed<D: Dimension>(
241        &mut self,
242        index: usize,
243        params: &mut Array<A, D>,
244        gradients: &Array<A, D>,
245    ) -> Result<()> {
246        if params.shape() != gradients.shape() {
247            return Err(OptimError::DimensionMismatch(format!(
248                "Incompatible shapes: parameters have shape {:?}, gradients have shape {:?}",
249                params.shape(),
250                gradients.shape()
251            )));
252        }
253
254        let dim = params.raw_dim().into_dyn();
255        let t = self.advance_state(index, &dim)?;
256
257        let exp = i32::try_from(t).map_err(|_| {
258            OptimError::InvalidConfig(
259                "Timestep too large for bias correction calculation".to_string(),
260            )
261        })?;
262
263        let beta1 = self.beta1;
264        let beta2 = self.beta2;
265        let lr = self.learning_rate;
266        let eps = self.epsilon;
267        let weight_decay = self.weight_decay;
268        let one = A::one();
269        let bias_correction1 = one - beta1.powi(exp);
270        let bias_correction2 = one - beta2.powi(exp);
271        let use_weight_decay = weight_decay > A::zero();
272
273        let m = self
274            .m
275            .as_mut()
276            .ok_or_else(|| OptimError::InvalidConfig("Adam state not initialized".to_string()))?;
277        let v = self
278            .v
279            .as_mut()
280            .ok_or_else(|| OptimError::InvalidConfig("Adam state not initialized".to_string()))?;
281
282        let mut params_view = params.view_mut().into_dyn();
283        let gradients_view = gradients.view().into_dyn();
284
285        Zip::from(&mut params_view)
286            .and(&gradients_view)
287            .and(&mut m[index])
288            .and(&mut v[index])
289            .for_each(|p, &g, m_i, v_i| {
290                let grad = if use_weight_decay {
291                    g + weight_decay * *p
292                } else {
293                    g
294                };
295                *m_i = *m_i * beta1 + grad * (one - beta1);
296                *v_i = *v_i * beta2 + grad * grad * (one - beta2);
297                let m_hat = *m_i / bias_correction1;
298                let v_hat = *v_i / bias_correction2;
299                *p = *p - lr * m_hat / (v_hat.sqrt() + eps);
300            });
301
302        Ok(())
303    }
304
305    /// Applies an Adam update in place using the state slot of the first parameter tensor
306    pub fn step_inplace<D: Dimension>(
307        &mut self,
308        params: &mut Array<A, D>,
309        gradients: &Array<A, D>,
310    ) -> Result<()> {
311        self.step_inplace_indexed(0, params, gradients)
312    }
313
314    /// Performs an Adam update for the parameter tensor at `index`
315    ///
316    /// Each `index` owns an independent moment/timestep slot, so several parameter
317    /// tensors of different shapes can be optimized by a single `Adam` instance
318    /// without their state interfering.
319    pub fn step_indexed<D: Dimension>(
320        &mut self,
321        index: usize,
322        params: &Array<A, D>,
323        gradients: &Array<A, D>,
324    ) -> Result<Array<A, D>> {
325        let mut updated = params.to_owned();
326        self.step_inplace_indexed(index, &mut updated, gradients)?;
327        Ok(updated)
328    }
329}
330
331impl<A, D> Optimizer<A, D> for Adam<A>
332where
333    A: Float + ScalarOperand + Debug + Send + Sync,
334    D: Dimension,
335{
336    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
337        self.step_indexed(0, params, gradients)
338    }
339
340    fn step_list(
341        &mut self,
342        params_list: &[&Array<A, D>],
343        gradients_list: &[&Array<A, D>],
344    ) -> Result<Vec<Array<A, D>>> {
345        if params_list.len() != gradients_list.len() {
346            return Err(OptimError::InvalidConfig(format!(
347                "Number of parameter arrays ({}) does not match number of gradient arrays ({})",
348                params_list.len(),
349                gradients_list.len()
350            )));
351        }
352
353        let mut results = Vec::with_capacity(params_list.len());
354        for (index, (params, grads)) in params_list.iter().zip(gradients_list.iter()).enumerate() {
355            results.push(self.step_indexed(index, params, grads)?);
356        }
357        Ok(results)
358    }
359
360    fn get_learning_rate(&self) -> A {
361        self.learning_rate
362    }
363
364    fn set_learning_rate(&mut self, learning_rate: A) {
365        self.learning_rate = learning_rate;
366    }
367}