rustyml 0.17.0

Machine learning models, neural networks, metrics, and data utilities in Rust
Documentation
//! Stochastic Gradient Descent (SGD) optimizer
//!
//! Holds the [`SGD`] struct, its constructor, its [`SGD::with_global_clipnorm`] builder method, and
//! its [`Optimizer`] implementation. Weight decay is
//! decoupled (SGDW-style), and gradient clipping stays off until a caller opts in
//!
//! [`Optimizer`]: crate::neural_network::traits::Optimizer

use crate::error::Error;
use crate::neural_network::LayerPath;
use crate::neural_network::ctx::Grads;
use crate::neural_network::optimizers::kernels;
use crate::neural_network::optimizers::validation::{
    validate_global_clipnorm, validate_learning_rate, validate_non_negative_finite,
};
use crate::neural_network::traits::{LayerBase, Optimizer, ParamId};
use ahash::AHashMap;

/// SGD (Stochastic Gradient Descent) optimizer
///
/// Updates parameters in the direction of the negative gradient, scaled by the learning rate, with
/// optional momentum / Nesterov acceleration and decoupled weight decay
#[derive(Debug)]
pub struct SGD {
    /// Learning rate controlling the size of parameter updates
    learning_rate: f32,
    /// Momentum coefficient. `0.0` disables momentum (plain SGD)
    momentum: f32,
    /// Whether to use Nesterov-accelerated momentum (only meaningful when `momentum > 0`)
    nesterov: bool,
    /// Decoupled (SGDW-style) weight decay coefficient. `0.0` disables it
    weight_decay: f32,
    /// Optional clip-by-global-norm threshold. `None` disables gradient clipping
    global_clipnorm: Option<f32>,
    /// Per-parameter momentum buffers, allocated lazily when `momentum > 0` and keyed by the
    /// address of the parameter. A buffer therefore follows its own tensor, whatever else the
    /// model does
    velocities: AHashMap<ParamId, Vec<f32>>,
}

impl SGD {
    /// Creates a new SGD optimizer
    ///
    /// # Parameters
    ///
    /// - `learning_rate` - step size for parameter updates
    /// - `momentum` - momentum coefficient (typically 0.9). `0.0` is plain SGD
    /// - `nesterov` - use Nesterov-accelerated momentum (only meaningful when `momentum > 0`)
    /// - `weight_decay` - decoupled (SGDW-style) weight-decay coefficient applied directly to the
    ///   parameters. `0.0` disables it
    ///
    /// # Returns
    ///
    /// - `Result<Self, Error>` - a new SGD optimizer instance or an error
    ///
    /// # Notes
    ///
    /// Gradient clipping is disabled by default. Enable it with [`SGD::with_global_clipnorm`]
    ///
    /// # Errors
    ///
    /// - `Error::InvalidParameter` - if `learning_rate` is not positive and finite, or
    ///   `momentum`/`weight_decay` is negative or not finite
    pub fn new(
        learning_rate: f32,
        momentum: f32,
        nesterov: bool,
        weight_decay: f32,
    ) -> Result<Self, Error> {
        validate_learning_rate(learning_rate)?;
        validate_non_negative_finite(momentum, "momentum")?;
        validate_non_negative_finite(weight_decay, "weight_decay")?;

        Ok(Self {
            learning_rate,
            momentum,
            nesterov,
            weight_decay,
            global_clipnorm: None,
            velocities: AHashMap::new(),
        })
    }

    /// Enables a global gradient norm limit (disabled by default)
    ///
    /// The training loop scales gradients when their finite global L2 norm exceeds the
    /// threshold. A non-finite norm leaves gradients unchanged.
    ///
    /// # Parameters
    ///
    /// - `global_clipnorm` - clip-by-global-norm threshold. Must be positive and finite
    ///
    /// # Returns
    ///
    /// - `Result<Self, Error>` - the updated optimizer, or an error if `global_clipnorm` is not
    ///   positive and finite
    ///
    /// # Errors
    ///
    /// - `Error::InvalidParameter` - If the threshold is not positive and finite.
    pub fn with_global_clipnorm(mut self, global_clipnorm: f32) -> Result<Self, Error> {
        validate_global_clipnorm(Some(global_clipnorm))?;
        self.global_clipnorm = Some(global_clipnorm);
        Ok(self)
    }
}

impl Optimizer for SGD {
    fn global_clipnorm(&self) -> Option<f32> {
        self.global_clipnorm
    }

    fn learning_rate(&self) -> f32 {
        self.learning_rate
    }

    fn set_learning_rate(&mut self, learning_rate: f32) {
        self.learning_rate = learning_rate;
    }

    fn update(
        &mut self,
        path: &LayerPath,
        layer: &mut dyn LayerBase,
        grads: &Grads,
        grad_scale: f32,
    ) {
        for pg in layer.parameters_mut() {
            let Some(grad) = grads.get(&path.param(pg.name)) else {
                continue;
            };
            let grad = grad
                .as_slice()
                .expect("a stored gradient is in the standard memory order");
            debug_assert_eq!(grad.len(), pg.value.len());
            let grad = kernels::scaled_grad(grad, grad_scale);
            // Decoupled weight decay shrinks the parameter before the gradient step (weights only,
            // biases and normalization gamma/beta excluded)
            if pg.decays {
                kernels::apply_weight_decay(pg.value, self.learning_rate, self.weight_decay);
            }

            if self.momentum == 0.0 {
                kernels::sgd_step(pg.value, &grad, self.learning_rate);
            } else {
                let velocity = self.velocities.entry(path.param(pg.name)).or_default();
                if velocity.len() != pg.value.len() {
                    // The tensor was resized under its own name: start the buffer again
                    *velocity = vec![0.0; pg.value.len()];
                }
                kernels::sgd_momentum_step(
                    pg.value,
                    &grad,
                    velocity,
                    self.learning_rate,
                    self.momentum,
                    self.nesterov,
                );
            }
        }
    }
}