Skip to main content

nuvec_nn/
lib.rs

1//! Neural-network building blocks on nuvec, following MLX's `mlx.nn` and `mlx.optimizers`.
2//!
3//! - [`Module`]: named parameter trees (`layers.0.weight`), trainable or frozen, loaded
4//!   from checkpoints by name ([`Module::load_weights`]).
5//! - Layers ([`layers`]): [`Linear`], [`QuantizedLinear`], [`Embedding`],
6//!   [`QuantizedEmbedding`], [`RmsNorm`],
7//!   [`LayerNorm`], [`Rope`] and [`LoraLinear`]; activations [`relu`], [`silu`],
8//!   [`gelu_approx`] and [`gelu_fast_approx`].
9//! - [`losses::cross_entropy`], [`value_and_grad`] for a model's loss, and
10//!   [`optim`]: SGD, Adam, AdamW, Lion and Muon with learning-rate schedules and gradient
11//!   clipping.
12//!
13//! ```
14//! use nuvec::{Array, Result, random};
15//! use nuvec_nn::optim::{Optimizer, Sgd};
16//! use nuvec_nn::{Linear, Module, Parameters, losses, relu, value_and_grad};
17//!
18//! #[derive(Clone)]
19//! struct Mlp {
20//!     hidden: Linear,
21//!     out: Linear,
22//! }
23//!
24//! impl Module for Mlp {
25//!     fn visit(&mut self, p: &mut Parameters<'_>) {
26//!         p.module("hidden", &mut self.hidden);
27//!         p.module("out", &mut self.out);
28//!     }
29//! }
30//!
31//! # fn main() -> Result<()> {
32//! random::seed(0);
33//! let mut model = Mlp { hidden: Linear::new(2, 8, true)?, out: Linear::new(8, 2, true)? };
34//! let names: Vec<String> = model.parameters().into_iter().map(|(n, _)| n).collect();
35//! assert_eq!(names, ["hidden.weight", "hidden.bias", "out.weight", "out.bias"]);
36//!
37//! let x = Array::from_slice(&[1.0f32, -1.0, 0.5, 2.0], &[2, 2])?;
38//! let y = Array::from_slice(&[0u32, 1], &[2])?;
39//! let loss = |m: &Mlp| {
40//!     let logits = m.out.forward(&relu(&m.hidden.forward(&x)?)?)?;
41//!     losses::cross_entropy(&logits, &y, losses::Reduction::Mean)
42//! };
43//! let mut opt = Sgd::new(0.5);
44//! let (before, _) = value_and_grad(&model, loss)?;
45//! for _ in 0..20 {
46//!     let (_, grads) = value_and_grad(&model, loss)?;
47//!     opt.update(&mut model, &grads)?;
48//! }
49//! let (after, _) = value_and_grad(&model, loss)?;
50//! assert!(after.item::<f32>()? < before.item::<f32>()?);
51//! # Ok(())
52//! # }
53//! ```
54//!
55//! **Status:** pre-alpha.
56
57#![forbid(unsafe_code)]
58
59use std::collections::{HashMap, HashSet};
60
61use nuvec::{Array, Error, Result, add, maximum, multiply, sigmoid, tanh};
62
63pub mod layers;
64pub mod losses;
65pub mod optim;
66
67pub use layers::{
68    Embedding, LayerNorm, Linear, LoraLinear, QuantizedEmbedding, QuantizedLinear, RmsNorm, Rope,
69};
70
71/// The version of this crate.
72pub const VERSION: &str = env!("CARGO_PKG_VERSION");
73
74/// A model: a tree of named parameters, as MLX's `nn.Module`.
75///
76/// A module implements [`Module::visit`], reporting each parameter and submodule by name
77/// in a fixed order; everything else (named parameters, trainable parameters, updates,
78/// loading weights) is built on it. Models are cheap to clone: arrays are shared.
79pub trait Module: Clone {
80    /// Reports every parameter and submodule to `p`, frozen ones included.
81    fn visit(&mut self, p: &mut Parameters<'_>);
82
83    /// Every parameter with its path (`layers.0.weight`), in visiting order.
84    fn parameters(&self) -> Vec<(String, Array)> {
85        let mut out = Vec::new();
86        self.clone().visit(&mut Parameters::new(&mut |name, a, _| {
87            out.push((name.to_owned(), a.clone()));
88        }));
89        out
90    }
91
92    /// The parameters that are trained (not frozen), with their paths.
93    fn trainable_parameters(&self) -> Vec<(String, Array)> {
94        let mut out = Vec::new();
95        self.clone()
96            .visit(&mut Parameters::new(&mut |name, a, trainable| {
97                if trainable {
98                    out.push((name.to_owned(), a.clone()));
99                }
100            }));
101        out
102    }
103
104    /// Replaces the trainable parameters, given in the order of
105    /// [`Module::trainable_parameters`] (as optimizers return them).
106    fn update_trainable(&mut self, values: &[Array]) -> Result<()> {
107        let expected = self.trainable_parameters().len();
108        if values.len() != expected {
109            return Err(Error::InvalidArgument(format!(
110                "{} values for {expected} trainable parameters",
111                values.len()
112            )));
113        }
114        let mut next = values.iter();
115        self.visit(&mut Parameters::new(&mut |_, a, trainable| {
116            if trainable {
117                *a = next.next().expect("counted").clone();
118            }
119        }));
120        Ok(())
121    }
122
123    /// Replaces parameters by path, as MLX's `load_weights`: each weight must have its
124    /// parameter's shape (the dtype is kept as loaded). With `strict`, every parameter
125    /// must be given and no unknown names are allowed.
126    fn load_weights(
127        &mut self,
128        weights: impl IntoIterator<Item = (String, Array)>,
129        strict: bool,
130    ) -> Result<()> {
131        let mut weights: HashMap<String, Array> = weights.into_iter().collect();
132        let mut problem: Option<String> = None;
133        let mut seen = HashSet::new();
134        self.visit(&mut Parameters::new(&mut |name, a, _| {
135            seen.insert(name.to_owned());
136            match weights.remove(name) {
137                Some(w) if w.shape() == a.shape() => *a = w,
138                Some(w) => {
139                    problem.get_or_insert(format!(
140                        "{name}: shape {:?}, expected {:?}",
141                        w.shape(),
142                        a.shape()
143                    ));
144                }
145                None if strict => {
146                    problem.get_or_insert(format!("{name}: missing"));
147                }
148                None => {}
149            }
150        }));
151        if let Some(p) = problem {
152            return Err(Error::InvalidArgument(format!("load_weights: {p}")));
153        }
154        if strict && !weights.is_empty() {
155            let mut unknown: Vec<&String> = weights.keys().collect();
156            unknown.sort();
157            return Err(Error::InvalidArgument(format!(
158                "load_weights: unknown parameters {unknown:?}"
159            )));
160        }
161        Ok(())
162    }
163}
164
165/// Receives a module's parameters during [`Module::visit`], with their paths.
166pub struct Parameters<'a> {
167    prefix: String,
168    frozen: bool,
169    f: &'a mut dyn FnMut(&str, &mut Array, bool),
170}
171
172impl<'a> Parameters<'a> {
173    /// A visitor calling `f(path, parameter, trainable)` for each parameter.
174    pub fn new(f: &'a mut dyn FnMut(&str, &mut Array, bool)) -> Self {
175        Self {
176            prefix: String::new(),
177            frozen: false,
178            f,
179        }
180    }
181
182    fn path(&self, name: &str) -> String {
183        if self.prefix.is_empty() {
184            name.to_owned()
185        } else {
186            format!("{}.{name}", self.prefix)
187        }
188    }
189
190    /// A trainable parameter.
191    pub fn param(&mut self, name: &str, a: &mut Array) {
192        let path = self.path(name);
193        (self.f)(&path, a, !self.frozen);
194    }
195
196    /// A trainable parameter that may be absent (an optional bias).
197    pub fn optional(&mut self, name: &str, a: &mut Option<Array>) {
198        if let Some(a) = a {
199            self.param(name, a);
200        }
201    }
202
203    /// A parameter that is never trained (a quantized weight, a LoRA base).
204    pub fn frozen(&mut self, name: &str, a: &mut Array) {
205        let path = self.path(name);
206        (self.f)(&path, a, false);
207    }
208
209    /// A submodule, whose parameters are named `name.…`.
210    pub fn module<M: Module>(&mut self, name: &str, m: &mut M) {
211        let outer = std::mem::take(&mut self.prefix);
212        self.prefix = if outer.is_empty() {
213            name.to_owned()
214        } else {
215            format!("{outer}.{name}")
216        };
217        m.visit(self);
218        self.prefix = outer;
219    }
220
221    /// Visits `f`'s parameters as frozen.
222    pub fn freeze(&mut self, f: impl FnOnce(&mut Self)) {
223        let was = std::mem::replace(&mut self.frozen, true);
224        f(self);
225        self.frozen = was;
226    }
227}
228
229/// A sequence of modules is a module; its parts are named `0`, `1`, … (MLX's lists).
230impl<M: Module> Module for Vec<M> {
231    fn visit(&mut self, p: &mut Parameters<'_>) {
232        for (i, m) in self.iter_mut().enumerate() {
233            p.module(&i.to_string(), m);
234        }
235    }
236}
237
238/// A module whose parameters are all frozen (MLX's `freeze`).
239#[derive(Debug, Clone)]
240pub struct Frozen<M>(pub M);
241
242impl<M: Module> Module for Frozen<M> {
243    fn visit(&mut self, p: &mut Parameters<'_>) {
244        let inner = &mut self.0;
245        p.freeze(|p| inner.visit(p));
246    }
247}
248
249/// The loss of `model` and its gradient with respect to every trainable parameter (in the
250/// order of [`Module::trainable_parameters`]), as MLX's `nn.value_and_grad`.
251///
252/// `loss` receives a copy of the model whose trainable parameters are being
253/// differentiated and must return a 0-D array.
254pub fn value_and_grad<M: Module>(
255    model: &M,
256    loss: impl Fn(&M) -> Result<Array>,
257) -> Result<(Array, Vec<Array>)> {
258    let parameters: Vec<Array> = model
259        .trainable_parameters()
260        .into_iter()
261        .map(|(_, a)| a)
262        .collect();
263    let argnums: Vec<usize> = (0..parameters.len()).collect();
264    let f = |ps: &[Array]| {
265        let mut m = model.clone();
266        m.update_trainable(ps)?;
267        loss(&m)
268    };
269    nuvec::value_and_grad(f, &argnums)(&parameters)
270}
271
272/// `max(x, 0)`, as MLX's `nn.relu`.
273pub fn relu(x: &Array) -> Result<Array> {
274    maximum(x, 0.0)
275}
276
277/// `x · sigmoid(x)`, as MLX's `nn.silu`.
278pub fn silu(x: &Array) -> Result<Array> {
279    multiply(x, sigmoid(x)?)
280}
281
282/// GELU with the tanh approximation, as MLX's `nn.gelu_approx`:
283/// `0.5 x (1 + tanh(√(2/π) (x + 0.044715 x³)))`.
284pub fn gelu_approx(x: &Array) -> Result<Array> {
285    let cube = multiply(multiply(x, x)?, x)?;
286    let inner = multiply(
287        add(x, multiply(cube, 0.044715)?)?,
288        (2.0 / std::f64::consts::PI).sqrt(),
289    )?;
290    multiply(multiply(x, 0.5)?, add(tanh(&inner)?, 1.0)?)
291}
292
293/// `x · sigmoid(1.702 x)`, as MLX's `nn.gelu_fast_approx`.
294pub fn gelu_fast_approx(x: &Array) -> Result<Array> {
295    multiply(x, sigmoid(&multiply(x, 1.702)?)?)
296}