Skip to main content

optirs_core/optimizers/
sgd.rs

1// Stochastic Gradient Descent optimizer
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/// Stochastic Gradient Descent optimizer
11///
12/// Implements the classic SGD algorithm with support for momentum and weight decay.
13///
14/// Formula:
15/// v_t = momentum * v_{t-1} + learning_rate * (gradient + weight_decay * param)
16/// param_t = param_{t-1} - v_t
17///
18/// # Examples
19///
20/// ```
21/// use scirs2_core::ndarray::Array1;
22/// use optirs_core::optimizers::{SGD, Optimizer};
23///
24/// // Initialize parameters and gradients
25/// let params = Array1::zeros(5);
26/// let gradients = Array1::from_vec(vec![0.1, 0.2, -0.3, 0.0, 0.5]);
27///
28/// // Create an SGD optimizer with learning rate 0.01 and momentum 0.9
29/// let mut optimizer = SGD::new_with_config(0.01, 0.9, 0.0);
30///
31/// // Update parameters
32/// let new_params = optimizer.step(&params, &gradients).expect("optimizer.step succeeds");
33/// ```
34#[derive(Debug, Clone)]
35pub struct SGD<A: Float + ScalarOperand + Debug> {
36    /// Learning rate
37    learning_rate: A,
38    /// Momentum factor (0.0 means no momentum)
39    momentum: A,
40    /// Weight decay factor (L2 regularization)
41    weight_decay: A,
42    /// Velocity (momentum state), one slot per parameter-tensor index
43    velocity: Option<Vec<Array<A, IxDyn>>>,
44}
45
46impl<A: Float + ScalarOperand + Debug + Send + Sync> SGD<A> {
47    /// Creates a new SGD optimizer with the given learning rate and no momentum/weight decay
48    ///
49    /// # Arguments
50    ///
51    /// * `learning_rate` - The learning rate for parameter updates
52    pub fn new(learning_rate: A) -> Self {
53        Self {
54            learning_rate,
55            momentum: A::zero(),
56            weight_decay: A::zero(),
57            velocity: None,
58        }
59    }
60
61    /// Creates a new SGD optimizer with the full configuration
62    ///
63    /// # Arguments
64    ///
65    /// * `learning_rate` - The learning rate for parameter updates
66    /// * `momentum` - The momentum factor (0.0 means no momentum)
67    /// * `weight_decay` - The weight decay factor (L2 regularization)
68    pub fn new_with_config(learning_rate: A, momentum: A, weight_decay: A) -> Self {
69        Self {
70            learning_rate,
71            momentum,
72            weight_decay,
73            velocity: None,
74        }
75    }
76
77    /// Sets the momentum factor
78    ///
79    /// # Arguments
80    ///
81    /// * `momentum` - The momentum factor (0.0 means no momentum)
82    pub fn set_momentum(&mut self, momentum: A) -> &mut Self {
83        self.momentum = momentum;
84        self
85    }
86
87    /// Builder method to set momentum and return self
88    ///
89    /// # Arguments
90    ///
91    /// * `momentum` - The momentum factor (0.0 means no momentum)
92    pub fn with_momentum(mut self, momentum: A) -> Self {
93        self.momentum = momentum;
94        self
95    }
96
97    /// Gets the current momentum factor
98    pub fn get_momentum(&self) -> A {
99        self.momentum
100    }
101
102    /// Gets the current learning rate
103    pub fn learning_rate(&self) -> A {
104        self.learning_rate
105    }
106
107    /// Sets the weight decay factor
108    ///
109    /// # Arguments
110    ///
111    /// * `weight_decay` - The weight decay factor (L2 regularization)
112    pub fn set_weight_decay(&mut self, weight_decay: A) -> &mut Self {
113        self.weight_decay = weight_decay;
114        self
115    }
116
117    /// Builder method to set weight decay and return self
118    ///
119    /// # Arguments
120    ///
121    /// * `weight_decay` - The weight decay factor (L2 regularization)
122    pub fn with_weight_decay(mut self, weight_decay: A) -> Self {
123        self.weight_decay = weight_decay;
124        self
125    }
126
127    /// Gets the current weight decay factor
128    pub fn get_weight_decay(&self) -> A {
129        self.weight_decay
130    }
131
132    /// Drops the momentum state
133    pub fn reset(&mut self) {
134        self.velocity = None;
135    }
136
137    /// Ensures a velocity slot exists for `index` and matches `dim`
138    fn ensure_state(&mut self, index: usize, dim: &IxDyn) {
139        let velocity = self.velocity.get_or_insert_with(Vec::new);
140        while velocity.len() <= index {
141            velocity.push(Array::zeros(dim.clone()));
142        }
143        if velocity[index].raw_dim() != *dim {
144            velocity[index] = Array::zeros(dim.clone());
145        }
146    }
147
148    /// Applies an SGD update in place for the parameter tensor at `index`
149    ///
150    /// This is the allocation-free hot path: velocity and parameters are updated in a
151    /// single fused [`Zip`] traversal, so no temporary arrays are created per step.
152    pub fn step_inplace_indexed<D: Dimension>(
153        &mut self,
154        index: usize,
155        params: &mut Array<A, D>,
156        gradients: &Array<A, D>,
157    ) -> Result<()> {
158        if params.shape() != gradients.shape() {
159            return Err(OptimError::DimensionMismatch(format!(
160                "Incompatible shapes: parameters have shape {:?}, gradients have shape {:?}",
161                params.shape(),
162                gradients.shape()
163            )));
164        }
165
166        let dim = params.raw_dim().into_dyn();
167        self.ensure_state(index, &dim);
168
169        let momentum = self.momentum;
170        let lr = self.learning_rate;
171        let weight_decay = self.weight_decay;
172        let use_weight_decay = weight_decay > A::zero();
173        let use_momentum = momentum > A::zero();
174
175        let velocity = self
176            .velocity
177            .as_mut()
178            .ok_or_else(|| OptimError::InvalidConfig("SGD state not initialized".to_string()))?;
179
180        let mut params_view = params.view_mut().into_dyn();
181        let gradients_view = gradients.view().into_dyn();
182
183        Zip::from(&mut params_view)
184            .and(&gradients_view)
185            .and(&mut velocity[index])
186            .for_each(|p, &g, v| {
187                let grad = if use_weight_decay {
188                    g + weight_decay * *p
189                } else {
190                    g
191                };
192                *v = if use_momentum {
193                    *v * momentum + grad * lr
194                } else {
195                    grad * lr
196                };
197                *p = *p - *v;
198            });
199
200        Ok(())
201    }
202
203    /// Applies an SGD update in place using the state slot of the first parameter tensor
204    pub fn step_inplace<D: Dimension>(
205        &mut self,
206        params: &mut Array<A, D>,
207        gradients: &Array<A, D>,
208    ) -> Result<()> {
209        self.step_inplace_indexed(0, params, gradients)
210    }
211
212    /// Performs an SGD update for the parameter tensor at `index`
213    ///
214    /// Each `index` owns an independent momentum slot, so several parameter tensors
215    /// can be optimized by a single `SGD` instance without their velocities mixing.
216    pub fn step_indexed<D: Dimension>(
217        &mut self,
218        index: usize,
219        params: &Array<A, D>,
220        gradients: &Array<A, D>,
221    ) -> Result<Array<A, D>> {
222        let mut updated = params.to_owned();
223        self.step_inplace_indexed(index, &mut updated, gradients)?;
224        Ok(updated)
225    }
226}
227
228impl<A, D> Optimizer<A, D> for SGD<A>
229where
230    A: Float + ScalarOperand + Debug + Send + Sync,
231    D: Dimension,
232{
233    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
234        self.step_indexed(0, params, gradients)
235    }
236
237    fn step_list(
238        &mut self,
239        params_list: &[&Array<A, D>],
240        gradients_list: &[&Array<A, D>],
241    ) -> Result<Vec<Array<A, D>>> {
242        if params_list.len() != gradients_list.len() {
243            return Err(OptimError::InvalidConfig(format!(
244                "Number of parameter arrays ({}) does not match number of gradient arrays ({})",
245                params_list.len(),
246                gradients_list.len()
247            )));
248        }
249
250        let mut results = Vec::with_capacity(params_list.len());
251        for (index, (params, grads)) in params_list.iter().zip(gradients_list.iter()).enumerate() {
252            results.push(self.step_indexed(index, params, grads)?);
253        }
254        Ok(results)
255    }
256
257    fn get_learning_rate(&self) -> A {
258        self.learning_rate
259    }
260
261    fn set_learning_rate(&mut self, learning_rate: A) {
262        self.learning_rate = learning_rate;
263    }
264}