Skip to main content

nuvec_nn/
optim.rs

1//! Optimizers, learning-rate schedules and gradient clipping, with MLX's formulas
2//! (`mlx.optimizers`).
3//!
4//! Every optimizer updates the trainable parameters of a [`Module`] from gradients in the
5//! order of [`Module::trainable_parameters`] (as [`crate::value_and_grad`] returns them).
6//! The learning rate is a constant or a [`schedules`] function of the step, evaluated
7//! before the step is counted and kept in float32, as in MLX.
8
9use std::sync::Arc;
10
11use nuvec::{
12    Array, DType, Error, Result, add, addmm, divide, matmul, minimum, multiply, reshape, sign,
13    sqrt, subtract, sum,
14};
15
16use crate::Module;
17
18/// Updates a model's trainable parameters from their gradients.
19pub trait Optimizer {
20    /// Applies one step to `model`: `gradients` are in the order of
21    /// [`Module::trainable_parameters`].
22    fn update<M: Module>(&mut self, model: &mut M, gradients: &[Array]) -> Result<()> {
23        let parameters: Vec<Array> = model
24            .trainable_parameters()
25            .into_iter()
26            .map(|(_, a)| a)
27            .collect();
28        if parameters.len() != gradients.len() {
29            return Err(Error::InvalidArgument(format!(
30                "{} gradients for {} parameters",
31                gradients.len(),
32                parameters.len()
33            )));
34        }
35        let updated = self.apply(&parameters, gradients)?;
36        model.update_trainable(&updated)
37    }
38
39    /// One step: the updated parameters.
40    fn apply(&mut self, parameters: &[Array], gradients: &[Array]) -> Result<Vec<Array>>;
41}
42
43/// A learning rate: a constant or a function of the step (from 0).
44#[derive(Clone)]
45pub enum LearningRate {
46    /// The same rate at every step.
47    Constant(f32),
48    /// The rate at each step.
49    Schedule(Arc<dyn Fn(u64) -> f32 + Send + Sync>),
50}
51
52impl LearningRate {
53    /// The rate at `step`.
54    pub fn at(&self, step: u64) -> f32 {
55        match self {
56            Self::Constant(lr) => *lr,
57            Self::Schedule(f) => f(step),
58        }
59    }
60}
61
62impl std::fmt::Debug for LearningRate {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::Constant(lr) => write!(f, "Constant({lr})"),
66            Self::Schedule(_) => f.write_str("Schedule"),
67        }
68    }
69}
70
71impl From<f64> for LearningRate {
72    fn from(lr: f64) -> Self {
73        Self::Constant(lr as f32)
74    }
75}
76
77impl From<f32> for LearningRate {
78    fn from(lr: f32) -> Self {
79        Self::Constant(lr)
80    }
81}
82
83/// Learning-rate schedules, as MLX's `mlx.optimizers` schedulers (computed in float32).
84pub mod schedules {
85    use std::sync::Arc;
86
87    use super::LearningRate;
88
89    fn schedule(f: impl Fn(u64) -> f32 + Send + Sync + 'static) -> LearningRate {
90        LearningRate::Schedule(Arc::new(f))
91    }
92
93    /// `init · decay_rate^step`.
94    pub fn exponential_decay(init: f64, decay_rate: f64) -> LearningRate {
95        schedule(move |step| init as f32 * (decay_rate as f32).powf(step as f32))
96    }
97
98    /// `init · decay_rate^⌊step / step_size⌋`.
99    pub fn step_decay(init: f64, decay_rate: f64, step_size: u64) -> LearningRate {
100        let step_size = step_size.max(1);
101        schedule(move |step| init as f32 * (decay_rate as f32).powf((step / step_size) as f32))
102    }
103
104    /// From `init` to `end` along half a cosine over `decay_steps`, then `end`.
105    pub fn cosine_decay(init: f64, decay_steps: u64, end: f64) -> LearningRate {
106        let decay_steps = decay_steps.max(1);
107        let rate = (std::f64::consts::PI / decay_steps as f64) as f32;
108        schedule(move |step| {
109            let s = step.min(decay_steps) as f32;
110            let decay = 0.5f32 * (1.0 + (rate * s).cos());
111            end as f32 + decay * (init - end) as f32
112        })
113    }
114
115    /// From `init` to `end` linearly over `steps`, then `end`.
116    pub fn linear_schedule(init: f64, end: f64, steps: u64) -> LearningRate {
117        let steps = steps.max(1);
118        let slope = ((end - init) / steps as f64) as f32;
119        schedule(move |step| step.min(steps) as f32 * slope + init as f32)
120    }
121
122    /// `schedules[0]` until `boundaries[0]`, then `schedules[1]` (counting its steps
123    /// from the boundary), and so on (`boundaries` has one entry fewer).
124    pub fn join_schedules(schedules: Vec<LearningRate>, boundaries: Vec<u64>) -> LearningRate {
125        assert_eq!(
126            schedules.len(),
127            boundaries.len() + 1,
128            "join_schedules: one boundary between consecutive schedules"
129        );
130        schedule(move |step| {
131            let mut out = schedules[0].at(step);
132            for (b, s) in boundaries.iter().zip(&schedules[1..]) {
133                if step >= *b {
134                    out = s.at(step - b);
135                }
136            }
137            out
138        })
139    }
140}
141
142/// The learning rate as MLX stores it (float32) cast to the gradient's dtype.
143fn rate(lr: f32, like: &Array) -> Result<Array> {
144    Array::scalar(lr).astype(like.dtype())
145}
146
147/// Zeros shaped like each parameter, created on first use.
148fn zeros_like(state: &mut Vec<Array>, parameters: &[Array]) -> Result<()> {
149    if state.len() != parameters.len() {
150        *state = parameters
151            .iter()
152            .map(|p| nuvec::zeros(p.shape(), p.dtype()))
153            .collect::<Result<_>>()?;
154    }
155    Ok(())
156}
157
158/// Stochastic gradient descent with optional momentum, dampening, Nesterov momentum and
159/// weight decay, as MLX's `optimizers.SGD`.
160#[derive(Debug, Clone)]
161pub struct Sgd {
162    /// The learning rate.
163    pub learning_rate: LearningRate,
164    /// The momentum strength (0 disables momentum).
165    pub momentum: f64,
166    /// L2 weight decay added to the gradient.
167    pub weight_decay: f64,
168    /// Dampening for momentum.
169    pub dampening: f64,
170    /// Nesterov momentum.
171    pub nesterov: bool,
172    step: u64,
173    velocity: Vec<Array>,
174}
175
176impl Sgd {
177    /// Plain SGD.
178    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
179        Self {
180            learning_rate: learning_rate.into(),
181            momentum: 0.0,
182            weight_decay: 0.0,
183            dampening: 0.0,
184            nesterov: false,
185            step: 0,
186            velocity: Vec::new(),
187        }
188    }
189
190    /// Sets the momentum.
191    pub fn momentum(mut self, momentum: f64) -> Self {
192        self.momentum = momentum;
193        self
194    }
195
196    /// Sets the weight decay.
197    pub fn weight_decay(mut self, weight_decay: f64) -> Self {
198        self.weight_decay = weight_decay;
199        self
200    }
201
202    /// Enables Nesterov momentum.
203    pub fn nesterov(mut self, nesterov: bool) -> Self {
204        self.nesterov = nesterov;
205        self
206    }
207}
208
209impl Optimizer for Sgd {
210    fn apply(&mut self, parameters: &[Array], gradients: &[Array]) -> Result<Vec<Array>> {
211        zeros_like(&mut self.velocity, parameters)?;
212        let lr_now = self.learning_rate.at(self.step);
213        self.step += 1;
214        let mut out = Vec::with_capacity(parameters.len());
215        for ((p, g), v) in parameters.iter().zip(gradients).zip(&mut self.velocity) {
216            let mut g = g.clone();
217            if self.weight_decay != 0.0 {
218                g = add(&g, multiply(p, self.weight_decay)?)?;
219            }
220            let lr = rate(lr_now, &g)?;
221            if self.momentum <= 0.0 {
222                out.push(subtract(p, multiply(&lr, &g)?)?);
223                continue;
224            }
225            let mut next = multiply(self.momentum, &*v)?;
226            next = if self.dampening > 0.0 {
227                add(&next, multiply(1.0 - self.dampening, &g)?)?
228            } else {
229                add(&next, &g)?
230            };
231            let update = if self.nesterov {
232                add(&g, multiply(self.momentum, &next)?)?
233            } else {
234                next.clone()
235            };
236            *v = next;
237            out.push(subtract(p, multiply(&lr, &update)?)?);
238        }
239        Ok(out)
240    }
241}
242
243/// Adam, as MLX's `optimizers.Adam` (bias correction off unless enabled, as in MLX), and
244/// AdamW (decoupled weight decay) with [`Adam::weight_decay`].
245#[derive(Debug, Clone)]
246pub struct Adam {
247    /// The learning rate.
248    pub learning_rate: LearningRate,
249    /// Decay rates of the first and second moment estimates.
250    pub betas: (f64, f64),
251    /// Added to the denominator for numerical stability.
252    pub eps: f64,
253    /// Whether to correct the moment estimates' bias toward zero.
254    pub bias_correction: bool,
255    /// Decoupled weight decay (AdamW); 0 for Adam.
256    pub weight_decay: f64,
257    step: u64,
258    m: Vec<Array>,
259    v: Vec<Array>,
260}
261
262impl Adam {
263    /// Adam with betas `(0.9, 0.999)` and `eps = 1e-8`.
264    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
265        Self {
266            learning_rate: learning_rate.into(),
267            betas: (0.9, 0.999),
268            eps: 1e-8,
269            bias_correction: false,
270            weight_decay: 0.0,
271            step: 0,
272            m: Vec::new(),
273            v: Vec::new(),
274        }
275    }
276
277    /// AdamW, as MLX's `optimizers.AdamW`: Adam with decoupled weight decay (default
278    /// 0.01).
279    pub fn adamw(learning_rate: impl Into<LearningRate>) -> Self {
280        Self::new(learning_rate).weight_decay(0.01)
281    }
282
283    /// Sets the betas.
284    pub fn betas(mut self, b1: f64, b2: f64) -> Self {
285        self.betas = (b1, b2);
286        self
287    }
288
289    /// Sets the decoupled weight decay (AdamW).
290    pub fn weight_decay(mut self, weight_decay: f64) -> Self {
291        self.weight_decay = weight_decay;
292        self
293    }
294
295    /// Enables bias correction.
296    pub fn bias_correction(mut self, enabled: bool) -> Self {
297        self.bias_correction = enabled;
298        self
299    }
300}
301
302impl Optimizer for Adam {
303    fn apply(&mut self, parameters: &[Array], gradients: &[Array]) -> Result<Vec<Array>> {
304        zeros_like(&mut self.m, parameters)?;
305        zeros_like(&mut self.v, parameters)?;
306        let lr_now = self.learning_rate.at(self.step);
307        self.step += 1;
308        let (b1, b2) = self.betas;
309        let mut out = Vec::with_capacity(parameters.len());
310        for (((p, g), m), v) in parameters
311            .iter()
312            .zip(gradients)
313            .zip(&mut self.m)
314            .zip(&mut self.v)
315        {
316            let lr = rate(lr_now, g)?;
317            let p = if self.weight_decay != 0.0 {
318                // MLX's AdamW: `parameter * (1 - lr * weight_decay)`.
319                multiply(p, subtract(1.0, multiply(&lr, self.weight_decay)?)?)?
320            } else {
321                p.clone()
322            };
323            *m = add(multiply(b1, &*m)?, multiply(1.0 - b1, g)?)?;
324            *v = add(multiply(b2, &*v)?, multiply(1.0 - b2, multiply(g, g)?)?)?;
325            let updated = if self.bias_correction {
326                // In float32, as MLX (`1 - b ** step` on a float32 array).
327                let step = self.step as f32;
328                let one_minus = |b: f64| Array::scalar(1.0 - (b as f32).powf(step));
329                let c1 = divide(&lr, one_minus(b1))?.astype(g.dtype())?;
330                let c2 = divide(1.0f32, sqrt(&one_minus(b2))?)?.astype(g.dtype())?;
331                let denominator = add(multiply(sqrt(&*v)?, &c2)?, self.eps)?;
332                subtract(&p, divide(multiply(&c1, &*m)?, &denominator)?)?
333            } else {
334                subtract(&p, divide(multiply(&lr, &*m)?, add(sqrt(&*v)?, self.eps)?)?)?
335            };
336            out.push(updated);
337        }
338        Ok(out)
339    }
340}
341
342/// Lion, as MLX's `optimizers.Lion`: the sign of an interpolated momentum, with optional
343/// decoupled weight decay.
344#[derive(Debug, Clone)]
345pub struct Lion {
346    /// The learning rate.
347    pub learning_rate: LearningRate,
348    /// Interpolation (`b1`) and momentum (`b2`) rates; MLX's default `(0.9, 0.99)`.
349    pub betas: (f64, f64),
350    /// Decoupled weight decay.
351    pub weight_decay: f64,
352    step: u64,
353    m: Vec<Array>,
354}
355
356impl Lion {
357    /// Lion with betas `(0.9, 0.99)` and no weight decay.
358    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
359        Self {
360            learning_rate: learning_rate.into(),
361            betas: (0.9, 0.99),
362            weight_decay: 0.0,
363            step: 0,
364            m: Vec::new(),
365        }
366    }
367
368    /// Sets the decoupled weight decay.
369    pub fn weight_decay(mut self, weight_decay: f64) -> Self {
370        self.weight_decay = weight_decay;
371        self
372    }
373}
374
375impl Optimizer for Lion {
376    fn apply(&mut self, parameters: &[Array], gradients: &[Array]) -> Result<Vec<Array>> {
377        zeros_like(&mut self.m, parameters)?;
378        let lr_now = self.learning_rate.at(self.step);
379        self.step += 1;
380        let (b1, b2) = self.betas;
381        let mut out = Vec::with_capacity(parameters.len());
382        for ((p, g), m) in parameters.iter().zip(gradients).zip(&mut self.m) {
383            let lr = rate(lr_now, g)?;
384            let c = add(multiply(b1, &*m)?, multiply(1.0 - b1, g)?)?;
385            *m = add(multiply(b2, &*m)?, multiply(1.0 - b2, g)?)?;
386            let p = if self.weight_decay > 0.0 {
387                multiply(subtract(1.0, multiply(&lr, self.weight_decay)?)?, p)?
388            } else {
389                p.clone()
390            };
391            out.push(subtract(&p, multiply(&lr, sign(&c)?)?)?);
392        }
393        Ok(out)
394    }
395}
396
397/// Muon, as MLX's `optimizers.Muon`: momentum (Nesterov by default) whose update is
398/// orthogonalised by Newton–Schulz iterations for parameters of two or more dimensions.
399#[derive(Debug, Clone)]
400pub struct Muon {
401    /// The learning rate.
402    pub learning_rate: LearningRate,
403    /// The momentum (MLX's default 0.95).
404    pub momentum: f64,
405    /// Weight decay added to the gradient (MLX's default 0.01).
406    pub weight_decay: f64,
407    /// Nesterov momentum (MLX's default).
408    pub nesterov: bool,
409    /// Newton–Schulz iterations (MLX's default 5).
410    pub ns_steps: usize,
411    step: u64,
412    v: Vec<Array>,
413}
414
415impl Muon {
416    /// Muon with MLX's defaults.
417    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
418        Self {
419            learning_rate: learning_rate.into(),
420            momentum: 0.95,
421            weight_decay: 0.01,
422            nesterov: true,
423            ns_steps: 5,
424            step: 0,
425            v: Vec::new(),
426        }
427    }
428}
429
430impl Optimizer for Muon {
431    fn apply(&mut self, parameters: &[Array], gradients: &[Array]) -> Result<Vec<Array>> {
432        zeros_like(&mut self.v, parameters)?;
433        let lr_now = self.learning_rate.at(self.step);
434        self.step += 1;
435        let mut out = Vec::with_capacity(parameters.len());
436        for ((p, g), v) in parameters.iter().zip(gradients).zip(&mut self.v) {
437            let mut g = g.clone();
438            if self.weight_decay != 0.0 {
439                g = add(&g, multiply(self.weight_decay, p)?)?;
440            }
441            *v = add(
442                multiply(self.momentum, &*v)?,
443                multiply(1.0 - self.momentum, &g)?,
444            )?;
445            let mut update = if self.nesterov {
446                add(
447                    multiply(&g, 1.0 - self.momentum)?,
448                    multiply(&*v, self.momentum)?,
449                )?
450            } else {
451                v.clone()
452            };
453            let mut lr = rate(lr_now, &g)?;
454            if update.ndim() >= 2 {
455                let shape = update.shape().to_vec();
456                let flat = reshape(&update, &[shape[0] as isize, -1])?;
457                let isize_shape: Vec<isize> = shape.iter().map(|&d| d as isize).collect();
458                update = reshape(&orthogonalize(&flat, self.ns_steps)?, &isize_shape)?;
459                let (rows, cols) = (shape[shape.len() - 2], shape[shape.len() - 1]);
460                lr = multiply(&lr, (rows as f64 / cols as f64).max(1.0).sqrt())?;
461            }
462            out.push(subtract(p, multiply(&lr, &update)?)?);
463        }
464        Ok(out)
465    }
466}
467
468/// The nearest semi-orthogonal matrix to `x` (2-D), by MLX's quintic Newton–Schulz
469/// iteration.
470fn orthogonalize(x: &Array, steps: usize) -> Result<Array> {
471    let (a, b, c) = (3.4445, -4.775, 2.0315);
472    let transpose = x.shape()[0] > x.shape()[1];
473    let mut x = if transpose { x.t()? } else { x.clone() };
474    let norm = sqrt(&sum(&multiply(&x, &x)?, &[], true)?)?;
475    x = divide(&x, add(&norm, 1e-7)?)?;
476    for _ in 0..steps {
477        let am = matmul(&x, &x.t()?)?;
478        let bm = addmm(&multiply(&am, b)?, &am, &am, c as f32, 1.0)?;
479        x = addmm(&multiply(&x, a)?, &bm, &x, 1.0, 1.0)?;
480    }
481    if transpose { x.t() } else { Ok(x) }
482}
483
484/// `gradients` scaled so that their joint L2 norm is at most `max_norm`, and that norm
485/// before scaling, as MLX's `optimizers.clip_grad_norm` (float16 norms in float32).
486pub fn clip_grad_norm(gradients: &[Array], max_norm: f64) -> Result<(Vec<Array>, Array)> {
487    if max_norm < 0.0 {
488        return Err(Error::InvalidArgument(format!(
489            "clip_grad_norm: max_norm should be >= 0, got {max_norm}"
490        )));
491    }
492    let mut squared: Option<Array> = None;
493    for g in gradients {
494        let g = if g.dtype() == DType::F16 {
495            g.astype(DType::F32)?
496        } else {
497            g.clone()
498        };
499        let s = sum(&multiply(&g, &g)?, &[], false)?;
500        squared = Some(match squared {
501            Some(acc) => add(&acc, &s)?,
502            None => s,
503        });
504    }
505    let total = sqrt(&squared.unwrap_or_else(|| Array::scalar(0.0f32)))?;
506    let normalizer = minimum(divide(max_norm, add(&total, 1e-6)?)?, 1.0)?;
507    let clipped = gradients
508        .iter()
509        .map(|g| {
510            let scaled = multiply(g, &normalizer)?;
511            if g.dtype().category() == nuvec::Category::Float {
512                scaled.astype(g.dtype())
513            } else {
514                Ok(scaled)
515            }
516        })
517        .collect::<Result<_>>()?;
518    Ok((clipped, total))
519}