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//! An optimizer updates the trainable leaves of a model from a gradient tree of the
5//! model's type (as `nuvec::value_and_grad` returns it): `opt.update(&mut model, &grads)`.
6//! Its state is a tree (`step`, `learning_rate`, and the slots by parameter path), so an
7//! optimizer is part of a compiled training step's state and of a checkpoint
8//! (`nuvec::save(path, &(model, opt))`):
9//!
10//! - state is keyed by parameter path: freezing, unfreezing or replacing parameters never
11//!   moves state between them, and the state of a parameter that is not updated is kept;
12//! - the step counter and the learning rate are 0-D arrays (as MLX's
13//!   `optimizer.state["learning_rate"]`); a schedule writes the rate from the step at every
14//!   update (evaluated before the step is counted, in float32), so a compiled step sees
15//!   every new step and rate;
16//! - hyperparameters are static fields of the tree: changing one retraces a compiled step.
17
18use std::collections::{BTreeMap, HashMap, HashSet};
19use std::sync::Arc;
20
21use nuvec::tree::{Leaves, Visit, VisitMut};
22use nuvec::{Array, Category, DType, Error, OpErrorKind, Result, Tree, ops};
23
24use crate::Module;
25
26/// Updates a model's trainable leaves from their gradients, as MLX's `Optimizer`.
27///
28/// The trait is object-safe (`Box<dyn Optimizer>` is an optimizer and a tree too). Its
29/// state is reached only through [`Optimizer::visit_state`] and
30/// [`Optimizer::visit_state_mut`] (each optimizer has its own slot type).
31pub trait Optimizer {
32    /// One step over named parameters (the path keys each parameter's state) and their
33    /// gradients: the updated parameters, in order. Increments the step once.
34    fn apply(&mut self, params: &[(&str, &Array)], grads: &[&Array]) -> Result<Vec<Array>>;
35
36    /// Creates zero state for `params` that have none (paths with state are kept).
37    fn init_params(&mut self, params: &[(&str, &Array)]);
38
39    /// Visits the state (`step`, `learning_rate`, slots) and the hyperparameters.
40    fn visit_state(&self, v: &mut Visit<'_>);
41
42    /// Visits the state mutably.
43    fn visit_state_mut(&mut self, v: &mut VisitMut<'_>);
44
45    /// The learning rate: the 0-D float32 array in the state.
46    fn learning_rate(&self) -> Array;
47
48    /// Sets the learning rate array (in a compiled step it is an input: no retrace).
49    fn set_learning_rate(&mut self, lr: f32);
50
51    /// A boxed copy (object-safe `Clone`).
52    fn clone_box(&self) -> Box<dyn Optimizer>;
53
54    /// Creates the state of every trainable leaf of `model` now (instead of at the first
55    /// update), so a compiled step's state structure is fixed from the start; also needed
56    /// before loading a checkpoint into a fresh optimizer.
57    fn init<M: Tree>(&mut self, model: &M)
58    where
59        Self: Sized,
60    {
61        let params = model.trainable_parameters();
62        let refs: Vec<(&str, &Array)> = params.iter().map(|(p, a)| (p.as_str(), a)).collect();
63        self.init_params(&refs);
64    }
65
66    /// Applies one step to the trainable leaves of `model` from `grads`, a gradient tree of
67    /// the model's type with the model's paths (else an error). Frozen parameters and
68    /// buffers are left alone; their state is kept.
69    fn update<M: Tree>(&mut self, model: &mut M, grads: &M) -> Result<()>
70    where
71        Self: Sized,
72    {
73        let (params, gradients) = trainable_with_grads(model, grads)?;
74        let p: Vec<(&str, &Array)> = params.iter().map(|(n, a)| (n.as_str(), a)).collect();
75        let g: Vec<&Array> = gradients.iter().collect();
76        let updated = self.apply(&p, &g)?;
77        let mut updated: HashMap<&str, Array> = params
78            .iter()
79            .map(|(n, _)| n.as_str())
80            .zip(updated)
81            .collect();
82        *model = model.map(|path, a| updated.remove(path).unwrap_or_else(|| a.clone()));
83        Ok(())
84    }
85}
86
87/// Named parameters and their gradients.
88type Trainable = (Vec<(String, Array)>, Vec<Array>);
89
90/// The trainable leaves of `model` with their paths, and their gradients from `grads`
91/// (which must have the model's leaf paths).
92fn trainable_with_grads<M: Tree>(model: &M, grads: &M) -> Result<Trainable> {
93    let (paths, gpaths) = (model.paths(), grads.paths());
94    if paths != gpaths {
95        let first = paths.iter().zip(&gpaths).find(|(a, b)| a != b).map_or_else(
96            || "a different number of leaves".to_owned(),
97            |(a, b)| format!("{b:?} where the model has {a:?}"),
98        );
99        return Err(Error::InvalidArgument(format!(
100            "update: the gradients do not have the model's paths: {first}"
101        )));
102    }
103    let trainable: HashSet<String> = model
104        .trainable_parameters()
105        .into_iter()
106        .map(|(p, _)| p)
107        .collect();
108    let mut params = Vec::new();
109    let mut gs = Vec::new();
110    for ((path, a), g) in model.flatten().into_iter().zip(grads.leaves()) {
111        if trainable.contains(&path) {
112            params.push((path, a));
113            gs.push(g);
114        }
115    }
116    Ok((params, gs))
117}
118
119impl Optimizer for Box<dyn Optimizer> {
120    fn apply(&mut self, params: &[(&str, &Array)], grads: &[&Array]) -> Result<Vec<Array>> {
121        (**self).apply(params, grads)
122    }
123    fn init_params(&mut self, params: &[(&str, &Array)]) {
124        (**self).init_params(params);
125    }
126    fn visit_state(&self, v: &mut Visit<'_>) {
127        (**self).visit_state(v);
128    }
129    fn visit_state_mut(&mut self, v: &mut VisitMut<'_>) {
130        (**self).visit_state_mut(v);
131    }
132    fn learning_rate(&self) -> Array {
133        (**self).learning_rate()
134    }
135    fn set_learning_rate(&mut self, lr: f32) {
136        (**self).set_learning_rate(lr);
137    }
138    fn clone_box(&self) -> Box<dyn Optimizer> {
139        (**self).clone_box()
140    }
141}
142
143impl Clone for Box<dyn Optimizer> {
144    fn clone(&self) -> Self {
145        (**self).clone_box()
146    }
147}
148
149/// A boxed optimizer is a tree through its state: the one hand-written tree impl of
150/// nuvec-nn (a trait object cannot be derived).
151impl Leaves for Box<dyn Optimizer> {
152    fn visit(&self, v: &mut Visit<'_>) {
153        (**self).visit_state(v);
154    }
155}
156
157impl Tree for Box<dyn Optimizer> {
158    fn visit_mut(&mut self, v: &mut VisitMut<'_>) {
159        (**self).visit_state_mut(v);
160    }
161}
162
163impl std::fmt::Debug for dyn Optimizer {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str("dyn Optimizer")
166    }
167}
168
169/// A boxed optimizer (a tree through [`Optimizer::visit_state`]).
170type Boxed = Box<dyn Optimizer>;
171
172/// An optimizer's state: the step counter and the learning rate as 0-D arrays, and one
173/// slot (momenta, moment estimates) per parameter path.
174#[derive(Debug, Clone, Tree)]
175pub struct OptimizerState<S: Tree> {
176    /// Steps taken (a 0-D uint64 array).
177    pub step: Array,
178    /// The learning rate (a 0-D float32 array).
179    pub learning_rate: Array,
180    /// The slots by parameter path.
181    pub slots: BTreeMap<String, S>,
182}
183
184impl<S: Tree> OptimizerState<S> {
185    fn new(lr: f32) -> Self {
186        Self {
187            step: Array::from(0u64),
188            learning_rate: Array::from(lr),
189            slots: BTreeMap::new(),
190        }
191    }
192}
193
194/// A learning rate: a constant or a function of the step (a 0-D array, from 0) returning a
195/// 0-D array, so a compiled step sees every new rate.
196#[derive(Clone)]
197pub enum LearningRate {
198    /// The same rate at every step (it initialises the state's rate array).
199    Constant(f32),
200    /// The rate at each step, written into the state before each update.
201    Schedule(Arc<dyn Fn(&Array) -> Array + Send + Sync>),
202}
203
204impl LearningRate {
205    /// The rate at `step` (a 0-D array), in float32.
206    pub fn at(&self, step: &Array) -> Array {
207        match self {
208            Self::Constant(lr) => Array::from(*lr),
209            Self::Schedule(f) => f(step).astype(DType::F32),
210        }
211    }
212
213    fn initial(&self) -> Array {
214        self.at(&Array::from(0u64))
215    }
216}
217
218impl std::fmt::Debug for LearningRate {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        match self {
221            Self::Constant(lr) => write!(f, "Constant({lr})"),
222            Self::Schedule(_) => f.write_str("Schedule"),
223        }
224    }
225}
226
227impl From<f64> for LearningRate {
228    fn from(lr: f64) -> Self {
229        Self::Constant(lr as f32)
230    }
231}
232
233impl From<f32> for LearningRate {
234    fn from(lr: f32) -> Self {
235        Self::Constant(lr)
236    }
237}
238
239/// Learning-rate schedules, as MLX's `mlx.optimizers` schedulers: float32 array functions of
240/// the step.
241pub mod schedules {
242    use std::sync::Arc;
243
244    use nuvec::{Array, DType};
245
246    use super::LearningRate;
247
248    fn schedule(f: impl Fn(&Array) -> Array + Send + Sync + 'static) -> LearningRate {
249        LearningRate::Schedule(Arc::new(f))
250    }
251
252    /// The step in float32.
253    fn steps(step: &Array) -> Array {
254        step.astype(DType::F32)
255    }
256
257    /// `b^t` for a float32 array `t` (`exp(t ln b)`, in float32, `b` rounded to float32 as
258    /// MLX does).
259    fn power(b: f64, t: &Array) -> Array {
260        t.multiply(f64::from(b as f32).ln()).exp()
261    }
262
263    /// `init · decay_rate^step`.
264    pub fn exponential_decay(init: f64, decay_rate: f64) -> LearningRate {
265        schedule(move |step| power(decay_rate, &steps(step)).multiply(init))
266    }
267
268    /// `init · decay_rate^⌊step / step_size⌋`.
269    pub fn step_decay(init: f64, decay_rate: f64, step_size: u64) -> LearningRate {
270        let size = step_size.max(1) as f64;
271        schedule(move |step| {
272            let k = steps(step)
273                .divide(size)
274                .astype(DType::U32)
275                .astype(DType::F32);
276            power(decay_rate, &k).multiply(init)
277        })
278    }
279
280    /// From `init` to `end` along half a cosine over `decay_steps`, then `end`.
281    pub fn cosine_decay(init: f64, decay_steps: u64, end: f64) -> LearningRate {
282        let decay_steps = decay_steps.max(1) as f64;
283        let rate = std::f64::consts::PI / decay_steps;
284        schedule(move |step| {
285            let s = steps(step).minimum(decay_steps);
286            let decay = s.multiply(rate).cos().add(1.0).multiply(0.5);
287            decay.multiply(init - end).add(end)
288        })
289    }
290
291    /// From `init` to `end` linearly over `steps`, then `end`.
292    pub fn linear_schedule(init: f64, end: f64, steps_total: u64) -> LearningRate {
293        let n = steps_total.max(1) as f64;
294        let slope = (end - init) / n;
295        schedule(move |step| steps(step).minimum(n).multiply(slope).add(init))
296    }
297
298    /// `schedules[0]` until `boundaries[0]`, then `schedules[1]` (counting its steps
299    /// from the boundary), and so on. `boundaries` must have one entry fewer than
300    /// `schedules` (an error otherwise).
301    pub fn join_schedules(
302        schedules: Vec<LearningRate>,
303        boundaries: Vec<u64>,
304    ) -> nuvec::Result<LearningRate> {
305        if schedules.len() != boundaries.len() + 1 {
306            return Err(nuvec::Error::InvalidArgument(format!(
307                "join_schedules: {} schedules need {} boundaries, got {}",
308                schedules.len(),
309                schedules.len().saturating_sub(1),
310                boundaries.len()
311            )));
312        }
313        Ok(schedule(move |step| {
314            let t = steps(step);
315            let mut out = schedules[0].at(&t);
316            for (b, s) in boundaries.iter().zip(&schedules[1..]) {
317                let b = *b as f64;
318                let later = s.at(&t.subtract(b).maximum(0.0));
319                out = t.less(b).select(&out, &later);
320            }
321            out
322        }))
323    }
324}
325
326/// The learning rate cast to the gradient's dtype.
327fn rate(lr: &Array, like: &Array) -> Array {
328    lr.astype(like.dtype())
329}
330
331/// One step of an optimizer with slot type `S`: checks the arguments, writes the scheduled
332/// rate, increments the step, and updates each parameter with `f(p, g, slot, lr, step)`
333/// (`step` counted already). The state changes only on success.
334fn step<S: Tree>(
335    state: &mut OptimizerState<S>,
336    schedule: &LearningRate,
337    params: &[(&str, &Array)],
338    grads: &[&Array],
339    new_slot: impl Fn(&Array) -> S,
340    mut f: impl FnMut(&Array, &Array, &mut S, &Array, &Array) -> Array,
341) -> Result<Vec<Array>> {
342    if params.len() != grads.len() {
343        return Err(Error::InvalidArgument(format!(
344            "{} gradients for {} parameters",
345            grads.len(),
346            params.len()
347        )));
348    }
349    let mut next = state.clone();
350    if let LearningRate::Schedule(s) = schedule {
351        next.learning_rate = s(&next.step).astype(DType::F32);
352    }
353    next.step = next.step.add(1u64);
354    let mut seen = HashSet::new();
355    let mut out = Vec::with_capacity(params.len());
356    for (&(path, p), &g) in params.iter().zip(grads) {
357        if !seen.insert(path) {
358            return Err(Error::InvalidArgument(format!(
359                "parameter {path:?} given twice"
360            )));
361        }
362        let slot = next
363            .slots
364            .entry(path.to_owned())
365            .or_insert_with(|| new_slot(p));
366        let mut mismatch = false;
367        slot.for_each(|_, s| mismatch |= s.shape() != p.shape());
368        if mismatch {
369            return Err(Error::InvalidArgument(format!(
370                "parameter {path:?} changed its shape to {:?}",
371                p.shape()
372            )));
373        }
374        out.push(f(p, g, slot, &next.learning_rate, &next.step).check()?);
375    }
376    *state = next;
377    Ok(out)
378}
379
380/// Implements the object-safe part of [`Optimizer`] for an optimizer with a `state` field.
381macro_rules! state_access {
382    ($slot:ty) => {
383        fn init_params(&mut self, params: &[(&str, &Array)]) {
384            for &(path, p) in params {
385                self.state
386                    .slots
387                    .entry(path.to_owned())
388                    .or_insert_with(|| <$slot>::zeros(p));
389            }
390        }
391        fn visit_state(&self, v: &mut Visit<'_>) {
392            v.static_field("optimizer", &std::any::type_name::<Self>());
393            Leaves::visit(self, v);
394        }
395        fn visit_state_mut(&mut self, v: &mut VisitMut<'_>) {
396            Tree::visit_mut(self, v);
397        }
398        fn learning_rate(&self) -> Array {
399            self.state.learning_rate.clone()
400        }
401        fn set_learning_rate(&mut self, lr: f32) {
402            self.state.learning_rate = Array::from(lr);
403        }
404        fn clone_box(&self) -> Box<dyn Optimizer> {
405            Box::new(self.clone())
406        }
407    };
408}
409
410/// One state array like `p` (zeros).
411fn zeros(p: &Array) -> Array {
412    ops::zeros(p.shape(), p.dtype())
413}
414
415/// SGD's state: the momentum buffer.
416#[derive(Debug, Clone, Tree)]
417pub struct SgdSlot {
418    /// The momentum.
419    pub v: Array,
420}
421
422impl SgdSlot {
423    fn zeros(p: &Array) -> Self {
424        Self { v: zeros(p) }
425    }
426}
427
428/// Stochastic gradient descent with optional momentum, dampening, Nesterov momentum and
429/// weight decay, as MLX's `optimizers.SGD`.
430#[derive(Debug, Clone, Tree)]
431pub struct Sgd {
432    #[tree(skip, static_unchecked)]
433    schedule: LearningRate,
434    #[tree(skip)]
435    momentum: f64,
436    #[tree(skip)]
437    weight_decay: f64,
438    #[tree(skip)]
439    dampening: f64,
440    #[tree(skip)]
441    nesterov: bool,
442    #[tree(rename = "")]
443    state: OptimizerState<SgdSlot>,
444}
445
446/// A fresh state whose rate array is the schedule's at step 0.
447fn fresh<S: Tree>(schedule: &LearningRate) -> OptimizerState<S> {
448    let mut state = OptimizerState::new(0.0);
449    state.learning_rate = schedule.initial();
450    state
451}
452
453impl Sgd {
454    /// Plain SGD.
455    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
456        let schedule = learning_rate.into();
457        Self {
458            state: fresh(&schedule),
459            schedule,
460            momentum: 0.0,
461            weight_decay: 0.0,
462            dampening: 0.0,
463            nesterov: false,
464        }
465    }
466
467    /// Sets the momentum.
468    pub fn momentum(mut self, momentum: f64) -> Self {
469        self.momentum = momentum;
470        self
471    }
472
473    /// Sets the (L2) weight decay added to the gradient.
474    pub fn weight_decay(mut self, weight_decay: f64) -> Self {
475        self.weight_decay = weight_decay;
476        self
477    }
478
479    /// Sets the dampening of the momentum.
480    pub fn dampening(mut self, dampening: f64) -> Self {
481        self.dampening = dampening;
482        self
483    }
484
485    /// Enables Nesterov momentum.
486    pub fn nesterov(mut self) -> Self {
487        self.nesterov = true;
488        self
489    }
490}
491
492impl Optimizer for Sgd {
493    fn apply(&mut self, params: &[(&str, &Array)], grads: &[&Array]) -> Result<Vec<Array>> {
494        let (momentum, wd, damp, nesterov) = (
495            self.momentum,
496            self.weight_decay,
497            self.dampening,
498            self.nesterov,
499        );
500        let sgd = |p: &Array, g: &Array, s: &mut SgdSlot, lr: &Array, _: &Array| {
501            let mut g = g.clone();
502            if wd != 0.0 {
503                g = g.add(p.multiply(wd));
504            }
505            let lr = rate(lr, &g);
506            if momentum <= 0.0 {
507                return p.subtract(lr.multiply(&g));
508            }
509            let mut next = s.v.multiply(momentum);
510            next = if damp > 0.0 {
511                next.add(g.multiply(1.0 - damp))
512            } else {
513                next.add(&g)
514            };
515            let update = if nesterov {
516                g.add(next.multiply(momentum))
517            } else {
518                next.clone()
519            };
520            s.v = next;
521            p.subtract(lr.multiply(&update))
522        };
523        step(
524            &mut self.state,
525            &self.schedule,
526            params,
527            grads,
528            SgdSlot::zeros,
529            sgd,
530        )
531    }
532
533    state_access!(SgdSlot);
534}
535
536/// Adam's (and AdamW's) state: the first and second moment estimates.
537#[derive(Debug, Clone, Tree)]
538pub struct AdamSlot {
539    /// The first moment.
540    pub m: Array,
541    /// The second moment.
542    pub v: Array,
543}
544
545impl AdamSlot {
546    fn zeros(p: &Array) -> Self {
547        Self {
548            m: zeros(p),
549            v: zeros(p),
550        }
551    }
552}
553
554/// `1 - b^step` in float32, an array function of the step (MLX's bias correction, with `b`
555/// rounded to float32 as MLX does). Computed as `-expm1(step · ln b)` with Kahan's
556/// `expm1(x) = (u - 1) · x / log(u)`, `u = exp(x)`, which avoids the cancellation of
557/// `1 - exp(x)` for `b` near 1 (0.999).
558fn one_minus_power(b: f64, step: &Array) -> Array {
559    let ln_b = f64::from(b as f32).ln();
560    let x = step.astype(DType::F32).multiply(ln_b);
561    let u = x.exp();
562    let kahan = u.subtract(1.0).multiply(&x).divide(u.log());
563    u.equal(1.0).select(&x, &kahan).negative()
564}
565
566/// Adam's hyperparameters.
567#[derive(Debug, Clone, Copy)]
568struct AdamParams {
569    betas: (f64, f64),
570    eps: f64,
571    bias_correction: bool,
572    /// AdamW's decoupled weight decay (0 for Adam).
573    decay: f64,
574}
575
576/// One Adam step.
577fn adam_step(
578    p: &Array,
579    g: &Array,
580    s: &mut AdamSlot,
581    lr: &Array,
582    step: &Array,
583    h: AdamParams,
584) -> Array {
585    let (b1, b2) = h.betas;
586    let lr = rate(lr, g);
587    let p = if h.decay != 0.0 {
588        // MLX's AdamW: `parameter * (1 - lr * weight_decay)`.
589        p.multiply(ops::subtract(1.0, lr.multiply(h.decay)))
590    } else {
591        p.clone()
592    };
593    s.m = s.m.multiply(b1).add(g.multiply(1.0 - b1));
594    s.v = s.v.multiply(b2).add(g.multiply(g).multiply(1.0 - b2));
595    if h.bias_correction {
596        // In float32, as MLX (`1 - b ** step` on a float32 array).
597        let c1 = lr.divide(one_minus_power(b1, step)).astype(g.dtype());
598        let c2 =
599            ops::divide(Array::from(1.0f32), one_minus_power(b2, step).sqrt()).astype(g.dtype());
600        let denominator = s.v.sqrt().multiply(&c2).add(h.eps);
601        p.subtract(c1.multiply(&s.m).divide(&denominator))
602    } else {
603        p.subtract(lr.multiply(&s.m).divide(s.v.sqrt().add(h.eps)))
604    }
605}
606
607/// Adam, as MLX's `optimizers.Adam` (no weight decay; bias correction off unless
608/// enabled, as in MLX). [`AdamW`] adds decoupled weight decay.
609#[derive(Debug, Clone, Tree)]
610pub struct Adam {
611    #[tree(skip, static_unchecked)]
612    schedule: LearningRate,
613    #[tree(skip)]
614    betas: (f64, f64),
615    #[tree(skip)]
616    eps: f64,
617    #[tree(skip)]
618    bias_correction: bool,
619    #[tree(rename = "")]
620    state: OptimizerState<AdamSlot>,
621}
622
623impl Adam {
624    /// Adam with betas `(0.9, 0.999)` and `eps = 1e-8`.
625    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
626        let schedule = learning_rate.into();
627        Self {
628            state: fresh(&schedule),
629            schedule,
630            betas: (0.9, 0.999),
631            eps: 1e-8,
632            bias_correction: false,
633        }
634    }
635
636    /// Sets the betas.
637    pub fn betas(mut self, b1: f64, b2: f64) -> Self {
638        self.betas = (b1, b2);
639        self
640    }
641
642    /// Sets `eps`, added to the denominator.
643    pub fn eps(mut self, eps: f64) -> Self {
644        self.eps = eps;
645        self
646    }
647
648    /// Enables bias correction.
649    pub fn bias_correction(mut self) -> Self {
650        self.bias_correction = true;
651        self
652    }
653}
654
655impl Optimizer for Adam {
656    fn apply(&mut self, params: &[(&str, &Array)], grads: &[&Array]) -> Result<Vec<Array>> {
657        let h = AdamParams {
658            betas: self.betas,
659            eps: self.eps,
660            bias_correction: self.bias_correction,
661            decay: 0.0,
662        };
663        let f = |p: &Array, g: &Array, s: &mut AdamSlot, lr: &Array, t: &Array| {
664            adam_step(p, g, s, lr, t, h)
665        };
666        step(
667            &mut self.state,
668            &self.schedule,
669            params,
670            grads,
671            AdamSlot::zeros,
672            f,
673        )
674    }
675
676    state_access!(AdamSlot);
677}
678
679/// AdamW, as MLX's `optimizers.AdamW`: Adam with decoupled weight decay (MLX's default
680/// 0.01).
681#[derive(Debug, Clone, Tree)]
682pub struct AdamW {
683    #[tree(skip, static_unchecked)]
684    schedule: LearningRate,
685    #[tree(skip)]
686    betas: (f64, f64),
687    #[tree(skip)]
688    eps: f64,
689    #[tree(skip)]
690    bias_correction: bool,
691    #[tree(skip)]
692    weight_decay: f64,
693    #[tree(rename = "")]
694    state: OptimizerState<AdamSlot>,
695}
696
697impl AdamW {
698    /// AdamW with MLX's defaults: betas `(0.9, 0.999)`, `eps = 1e-8`, weight decay 0.01.
699    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
700        let schedule = learning_rate.into();
701        Self {
702            state: fresh(&schedule),
703            schedule,
704            betas: (0.9, 0.999),
705            eps: 1e-8,
706            bias_correction: false,
707            weight_decay: 0.01,
708        }
709    }
710
711    /// Sets the betas.
712    pub fn betas(mut self, b1: f64, b2: f64) -> Self {
713        self.betas = (b1, b2);
714        self
715    }
716
717    /// Sets `eps`, added to the denominator.
718    pub fn eps(mut self, eps: f64) -> Self {
719        self.eps = eps;
720        self
721    }
722
723    /// Sets the decoupled weight decay.
724    pub fn weight_decay(mut self, weight_decay: f64) -> Self {
725        self.weight_decay = weight_decay;
726        self
727    }
728
729    /// Enables bias correction.
730    pub fn bias_correction(mut self) -> Self {
731        self.bias_correction = true;
732        self
733    }
734}
735
736impl Optimizer for AdamW {
737    fn apply(&mut self, params: &[(&str, &Array)], grads: &[&Array]) -> Result<Vec<Array>> {
738        let h = AdamParams {
739            betas: self.betas,
740            eps: self.eps,
741            bias_correction: self.bias_correction,
742            decay: self.weight_decay,
743        };
744        let f = |p: &Array, g: &Array, s: &mut AdamSlot, lr: &Array, t: &Array| {
745            adam_step(p, g, s, lr, t, h)
746        };
747        step(
748            &mut self.state,
749            &self.schedule,
750            params,
751            grads,
752            AdamSlot::zeros,
753            f,
754        )
755    }
756
757    state_access!(AdamSlot);
758}
759
760/// Lion's (and Muon's) state: one momentum.
761#[derive(Debug, Clone, Tree)]
762pub struct MomentumSlot {
763    /// The momentum.
764    pub m: Array,
765}
766
767impl MomentumSlot {
768    fn zeros(p: &Array) -> Self {
769        Self { m: zeros(p) }
770    }
771}
772
773/// Lion, as MLX's `optimizers.Lion`: the sign of an interpolated momentum, with optional
774/// decoupled weight decay.
775#[derive(Debug, Clone, Tree)]
776pub struct Lion {
777    #[tree(skip, static_unchecked)]
778    schedule: LearningRate,
779    #[tree(skip)]
780    betas: (f64, f64),
781    #[tree(skip)]
782    weight_decay: f64,
783    #[tree(rename = "")]
784    state: OptimizerState<MomentumSlot>,
785}
786
787impl Lion {
788    /// Lion with betas `(0.9, 0.99)` and no weight decay.
789    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
790        let schedule = learning_rate.into();
791        Self {
792            state: fresh(&schedule),
793            schedule,
794            betas: (0.9, 0.99),
795            weight_decay: 0.0,
796        }
797    }
798
799    /// Sets the betas (interpolation, momentum).
800    pub fn betas(mut self, b1: f64, b2: f64) -> Self {
801        self.betas = (b1, b2);
802        self
803    }
804
805    /// Sets the decoupled weight decay.
806    pub fn weight_decay(mut self, weight_decay: f64) -> Self {
807        self.weight_decay = weight_decay;
808        self
809    }
810}
811
812impl Optimizer for Lion {
813    fn apply(&mut self, params: &[(&str, &Array)], grads: &[&Array]) -> Result<Vec<Array>> {
814        let ((b1, b2), wd) = (self.betas, self.weight_decay);
815        let lion = |p: &Array, g: &Array, s: &mut MomentumSlot, lr: &Array, _: &Array| {
816            let lr = rate(lr, g);
817            let c = s.m.multiply(b1).add(g.multiply(1.0 - b1));
818            s.m = s.m.multiply(b2).add(g.multiply(1.0 - b2));
819            let p = if wd > 0.0 {
820                ops::subtract(1.0, lr.multiply(wd)).multiply(p)
821            } else {
822                p.clone()
823            };
824            p.subtract(lr.multiply(c.sign()))
825        };
826        step(
827            &mut self.state,
828            &self.schedule,
829            params,
830            grads,
831            MomentumSlot::zeros,
832            lion,
833        )
834    }
835
836    state_access!(MomentumSlot);
837}
838
839/// Muon, as MLX's `optimizers.Muon`: momentum (Nesterov by default) whose update is
840/// orthogonalised by Newton–Schulz iterations for parameters of two or more dimensions.
841#[derive(Debug, Clone, Tree)]
842pub struct Muon {
843    #[tree(skip, static_unchecked)]
844    schedule: LearningRate,
845    #[tree(skip)]
846    momentum: f64,
847    #[tree(skip)]
848    weight_decay: f64,
849    #[tree(skip)]
850    nesterov: bool,
851    #[tree(skip)]
852    ns_steps: usize,
853    #[tree(rename = "")]
854    state: OptimizerState<MomentumSlot>,
855}
856
857impl Muon {
858    /// Muon with MLX's defaults: momentum 0.95, weight decay 0.01, Nesterov, 5 steps.
859    pub fn new(learning_rate: impl Into<LearningRate>) -> Self {
860        let schedule = learning_rate.into();
861        Self {
862            state: fresh(&schedule),
863            schedule,
864            momentum: 0.95,
865            weight_decay: 0.01,
866            nesterov: true,
867            ns_steps: 5,
868        }
869    }
870
871    /// Sets the momentum.
872    pub fn momentum(mut self, momentum: f64) -> Self {
873        self.momentum = momentum;
874        self
875    }
876
877    /// Sets the weight decay added to the gradient.
878    pub fn weight_decay(mut self, weight_decay: f64) -> Self {
879        self.weight_decay = weight_decay;
880        self
881    }
882
883    /// Uses plain momentum instead of Nesterov's (MLX's default).
884    pub fn without_nesterov(mut self) -> Self {
885        self.nesterov = false;
886        self
887    }
888
889    /// Sets the number of Newton–Schulz iterations.
890    pub fn ns_steps(mut self, n: usize) -> Self {
891        self.ns_steps = n;
892        self
893    }
894}
895
896impl Optimizer for Muon {
897    fn apply(&mut self, params: &[(&str, &Array)], grads: &[&Array]) -> Result<Vec<Array>> {
898        let (momentum, wd, nesterov, ns) = (
899            self.momentum,
900            self.weight_decay,
901            self.nesterov,
902            self.ns_steps,
903        );
904        let muon = |p: &Array, g: &Array, s: &mut MomentumSlot, lr: &Array, _: &Array| {
905            let mut g = g.clone();
906            if wd != 0.0 {
907                g = g.add(p.multiply(wd));
908            }
909            s.m = s.m.multiply(momentum).add(g.multiply(1.0 - momentum));
910            let mut update = if nesterov {
911                g.multiply(1.0 - momentum).add(s.m.multiply(momentum))
912            } else {
913                s.m.clone()
914            };
915            let mut lr = rate(lr, &g);
916            if update.ndim() >= 2 {
917                let shape = update.shape().to_vec();
918                let flat = update.reshape([shape[0], -1]);
919                update = orthogonalize(&flat, ns).reshape(&shape[..]);
920                let (rows, cols) = (shape[shape.len() - 2], shape[shape.len() - 1]);
921                lr = lr.multiply((f64::from(rows) / f64::from(cols)).max(1.0).sqrt());
922            }
923            p.subtract(lr.multiply(&update))
924        };
925        step(
926            &mut self.state,
927            &self.schedule,
928            params,
929            grads,
930            MomentumSlot::zeros,
931            muon,
932        )
933    }
934
935    state_access!(MomentumSlot);
936}
937
938/// The nearest semi-orthogonal matrix to `x` (2-D), by MLX's quintic Newton–Schulz
939/// iteration.
940fn orthogonalize(x: &Array, steps: usize) -> Array {
941    let (a, b, c) = (3.4445, -4.775, 2.0315);
942    let transpose = x.shape()[0] > x.shape()[1];
943    let mut x = if transpose { x.t() } else { x.clone() };
944    let norm = x.multiply(&x).sum_keepdims(..).sqrt();
945    x = x.divide(norm.add(1e-7));
946    for _ in 0..steps {
947        let am = x.matmul(&x.t());
948        let bm = am.multiply(b).addmm(&am, &am, c as f32, 1.0);
949        x = x.multiply(a).addmm(&bm, &x, 1.0, 1.0);
950    }
951    if transpose { x.t() } else { x }
952}
953
954/// Parameter groups, as MLX's `MultiOptimizer`: each group's filter selects parameters by
955/// path (the first matching group wins) and its optimizer updates them; the default
956/// optimizer takes the rest. Every group steps once per update. Its state is a tree:
957/// `groups.0.…`, …, `default.…`.
958///
959/// ```
960/// use nuvec_nn::optim::{Adam, MultiOptimizer, Sgd};
961///
962/// let opt = MultiOptimizer::new(Sgd::new(1e-2).weight_decay(1e-4))
963///     .group(|path| path.contains("lora_a"), Adam::new(1e-3));
964/// # let _ = opt;
965/// ```
966#[derive(Debug, Clone, Tree)]
967pub struct MultiOptimizer {
968    groups: Vec<Boxed>,
969    default: Boxed,
970    #[tree(skip, static_unchecked)]
971    filters: Vec<Filter>,
972}
973
974/// A group's path filter.
975#[derive(Clone)]
976struct Filter(Arc<dyn Fn(&str) -> bool + Send + Sync>);
977
978impl std::fmt::Debug for Filter {
979    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
980        f.write_str("Filter")
981    }
982}
983
984impl MultiOptimizer {
985    /// Groups with `default` for every parameter no group selects.
986    pub fn new(default: impl Optimizer + 'static) -> Self {
987        Self {
988            groups: Vec::new(),
989            default: Box::new(default),
990            filters: Vec::new(),
991        }
992    }
993
994    /// Adds a group: `opt` updates the parameters whose path matches `filter` (and no
995    /// earlier group's).
996    pub fn group(
997        mut self,
998        filter: impl Fn(&str) -> bool + Send + Sync + 'static,
999        opt: impl Optimizer + 'static,
1000    ) -> Self {
1001        self.groups.push(Box::new(opt));
1002        self.filters.push(Filter(Arc::new(filter)));
1003        self
1004    }
1005
1006    /// The group of `path`: an index into `groups`, or `groups.len()` for the default.
1007    fn group_of(&self, path: &str) -> usize {
1008        self.filters
1009            .iter()
1010            .position(|f| (f.0)(path))
1011            .unwrap_or(self.groups.len())
1012    }
1013}
1014
1015impl Optimizer for MultiOptimizer {
1016    fn apply(&mut self, params: &[(&str, &Array)], grads: &[&Array]) -> Result<Vec<Array>> {
1017        if params.len() != grads.len() {
1018            return Err(Error::InvalidArgument(format!(
1019                "{} gradients for {} parameters",
1020                grads.len(),
1021                params.len()
1022            )));
1023        }
1024        let n = self.groups.len() + 1;
1025        let mut members: Vec<Vec<usize>> = vec![Vec::new(); n];
1026        for (i, (path, _)) in params.iter().enumerate() {
1027            members[self.group_of(path)].push(i);
1028        }
1029        let mut next = self.clone();
1030        let mut out: Vec<Option<Array>> = vec![None; params.len()];
1031        for (k, idx) in members.iter().enumerate() {
1032            let p: Vec<(&str, &Array)> = idx.iter().map(|&i| params[i]).collect();
1033            let g: Vec<&Array> = idx.iter().map(|&i| grads[i]).collect();
1034            let opt = if k < next.groups.len() {
1035                &mut next.groups[k]
1036            } else {
1037                &mut next.default
1038            };
1039            let updated = opt.apply(&p, &g)?;
1040            if updated.len() != idx.len() {
1041                return Err(Error::InvalidArgument(format!(
1042                    "MultiOptimizer: a group's optimizer returned {} parameters for {}",
1043                    updated.len(),
1044                    idx.len()
1045                )));
1046            }
1047            for (&i, a) in idx.iter().zip(updated) {
1048                out[i] = Some(a);
1049            }
1050        }
1051        *self = next;
1052        Ok(out
1053            .into_iter()
1054            .map(|a| a.expect("every parameter has a group"))
1055            .collect())
1056    }
1057
1058    fn init_params(&mut self, params: &[(&str, &Array)]) {
1059        let n = self.groups.len();
1060        for k in 0..=n {
1061            let p: Vec<(&str, &Array)> = params
1062                .iter()
1063                .copied()
1064                .filter(|(path, _)| self.group_of(path) == k)
1065                .collect();
1066            if k < n {
1067                self.groups[k].init_params(&p);
1068            } else {
1069                self.default.init_params(&p);
1070            }
1071        }
1072    }
1073
1074    fn visit_state(&self, v: &mut Visit<'_>) {
1075        v.static_field("optimizer", &std::any::type_name::<Self>());
1076        Leaves::visit(self, v);
1077    }
1078
1079    fn visit_state_mut(&mut self, v: &mut VisitMut<'_>) {
1080        Tree::visit_mut(self, v);
1081    }
1082
1083    fn learning_rate(&self) -> Array {
1084        self.default.learning_rate()
1085    }
1086
1087    fn set_learning_rate(&mut self, lr: f32) {
1088        for g in &mut self.groups {
1089            g.set_learning_rate(lr);
1090        }
1091        self.default.set_learning_rate(lr);
1092    }
1093
1094    fn clone_box(&self) -> Box<dyn Optimizer> {
1095        Box::new(self.clone())
1096    }
1097}
1098
1099/// `grads` scaled so that their joint L2 norm is at most `max_norm`, and that norm before
1100/// scaling, as MLX's `optimizers.clip_grad_norm` (float16 norms in float32). A negative
1101/// `max_norm` gives failed arrays.
1102#[track_caller]
1103pub fn clip_grad_norm<T: Tree>(grads: &T, max_norm: f32) -> (T, Array) {
1104    if max_norm < 0.0 {
1105        let failed = Array::failure(
1106            "clip_grad_norm",
1107            OpErrorKind::InvalidArgument(format!("max_norm should be >= 0, got {max_norm}")),
1108        );
1109        let clipped = grads.map(|_, g| failed.astype(g.dtype()).broadcast_to(g.shape()));
1110        return (clipped, failed);
1111    }
1112    let mut squared: Option<Array> = None;
1113    grads.for_each(|_, g| {
1114        let g = if g.dtype() == DType::F16 {
1115            g.astype(DType::F32)
1116        } else {
1117            g.clone()
1118        };
1119        let s = g.multiply(&g).sum(..);
1120        squared = Some(match squared.take() {
1121            Some(acc) => acc.add(&s),
1122            None => s,
1123        });
1124    });
1125    let total = squared.unwrap_or_else(|| Array::from(0.0f32)).sqrt();
1126    let normalizer = ops::divide(f64::from(max_norm), total.add(1e-6)).minimum(1.0);
1127    let clipped = grads.map(|_, g| {
1128        let scaled = g.multiply(&normalizer);
1129        if g.dtype().category() == Category::Float {
1130            scaled.astype(g.dtype())
1131        } else {
1132            scaled
1133        }
1134    });
1135    (clipped, total)
1136}