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)(¶meters)
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}