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//! A model is a plain struct of layers with `#[derive(Module)]`: its parameters get MLX's
4//! names from the field names (`layers.0.weight`), and every model is a [`Tree`], so the
5//! core transforms differentiate it and return gradients of the model's own type
6//! (`grads.layers[0].weight`).
7//!
8//! - [`Module`]: names and counts ([`Module::parameters`], [`Module::num_params`],
9//!   [`Module::summary`], [`Module::display`]), freezing by path ([`Module::freeze`],
10//!   [`Module::unfreeze`]), train/eval mode ([`Module::train`]), loading and saving by name
11//!   ([`Module::load_weights`], [`Module::save_weights`]) and replacing submodules
12//!   ([`Module::map_modules`]).
13//! - Layers ([`layers`]): [`Linear`], [`QuantizedLinear`], [`Embedding`],
14//!   [`QuantizedEmbedding`], [`RmsNorm`], [`LayerNorm`], [`Rope`], [`LoraLinear`] and
15//!   [`Dropout`]; activations [`relu`], [`silu`], [`gelu_approx`] and [`gelu_fast_approx`].
16//!   Constructors take no `bool` and return no `Result`: `Linear::new(784, 32)`,
17//!   `Linear::new(32, 10).without_bias()`.
18//! - [`losses::cross_entropy`] and [`optim`]: SGD, Adam, AdamW, Lion and Muon with
19//!   learning-rate schedules, parameter groups ([`optim::MultiOptimizer`]) and gradient
20//!   clipping; the optimizer state is a tree keyed by parameter path.
21//!
22//! Layers' `forward`s, activations and losses return an [`Array`], as every nuvec op: an
23//! invalid input gives a failed array whose error surfaces at a boundary (`check()`,
24//! `eval()`, `item()`, a transform) and reports where `forward` was called.
25//!
26//! ```
27//! use nuvec::{Array, Result, array, random, value_and_grad};
28//! use nuvec_nn::optim::{Optimizer, Sgd};
29//! use nuvec_nn::{Linear, Module, Tree, losses, relu};
30//!
31//! #[derive(Clone, Module)]
32//! struct Mlp {
33//!     hidden: Linear,
34//!     out: Linear,
35//! }
36//!
37//! impl Mlp {
38//!     fn forward(&self, x: &Array) -> Array {
39//!         self.out.forward(&relu(&self.hidden.forward(x)))
40//!     }
41//! }
42//!
43//! # fn main() -> Result<()> {
44//! random::seed(0);
45//! let mut model = Mlp { hidden: Linear::new(2, 8), out: Linear::new(8, 2) };
46//! assert_eq!(model.paths(), ["hidden.weight", "hidden.bias", "out.weight", "out.bias"]);
47//!
48//! let (x, y) = (array![[1.0, -1.0], [0.5, 2.0]], array![0u32, 1]);
49//! let loss = |m: &Mlp| losses::cross_entropy(&m.forward(&x), &y, losses::Reduction::Mean);
50//! let mut opt = Sgd::new(0.5);
51//! let (before, _) = value_and_grad(loss)(&model)?;
52//! for _ in 0..20 {
53//!     let (_, grads) = value_and_grad(loss)(&model)?;   // grads: Mlp
54//!     opt.update(&mut model, &grads)?;
55//! }
56//! let (after, _) = value_and_grad(loss)(&model)?;
57//! assert!(after.item::<f32>()? < before.item::<f32>()?);
58//! # Ok(())
59//! # }
60//! ```
61//!
62//! The derive is syntactic: a field typed `Array` (or `Option<Array>`, `Vec<Array>`)
63//! needs a role (`Param` for a parameter, `#[tree(buffer)]`, `#[tree(skip)]` for a computed
64//! constant); a type alias of `Array` is not recognised. Trait-object fields are not
65//! trees: use an enum of the concrete layer types.
66//!
67//! **Stability:** no promise when this crate is depended on directly; depend on `nuvec`,
68//! whose re-exports are semver-tracked.
69//!
70//! **Status:** pre-alpha.
71
72#![forbid(unsafe_code)]
73
74use std::any::Any;
75use std::path::Path;
76
77use nuvec::tree::{LeafKind, LoadMode, Visit, VisitMut, Visitor, VisitorMut};
78use nuvec::{Array, Category, Error, Result};
79
80mod display;
81pub mod layers;
82pub mod losses;
83pub mod optim;
84mod weights;
85
86pub use display::{ModuleDisplay, Summary, SummaryRow};
87pub use layers::{
88    Dropout, Embedding, LayerNorm, Linear, LoraBase, LoraLinear, QuantizedEmbedding,
89    QuantizedLinear, RmsNorm, Rope,
90};
91/// Derives a model's [`Tree`] impl, with a check against bare `Array` fields (see the
92/// crate documentation). Every tree is a [`Module`] (a blanket impl).
93pub use nuvec::macros::Module;
94pub use nuvec::{Param, Tree};
95pub use weights::WeightSource;
96
97/// The Rust snippets of the user guide's neural-network page, run as doctests.
98#[cfg(doctest)]
99mod site_doctests {
100    #[doc = include_str!("../../../site/content/docs/neural-networks.md")]
101    pub struct NeuralNetworks;
102}
103
104/// The version of this crate.
105pub const VERSION: &str = env!("CARGO_PKG_VERSION");
106
107/// Model operations, as MLX's `nn.Module`, for every [`Tree`] (a blanket impl): a model is
108/// a struct of layers with `#[derive(Module)]`, and its leaves' paths are MLX's names.
109///
110/// Leaves are parameters ([`Param`], trainable unless frozen), buffers (never trained,
111/// such as quantized weights) or plain arrays.
112pub trait Module: Tree {
113    /// Every leaf (parameters, buffers and plain arrays) with its path, in order.
114    fn parameters(&self) -> Vec<(String, Array)> {
115        self.flatten()
116    }
117
118    /// The trainable leaves with their paths, in order.
119    fn trainable_parameters(&self) -> Vec<(String, Array)> {
120        let mut out = Vec::new();
121        visit_leaves(self, |p, a, _, trainable| {
122            if trainable {
123                out.push((p.to_owned(), a.clone()));
124            }
125        });
126        out
127    }
128
129    /// The number of elements over every leaf (MLX's `tree_flatten` total).
130    fn num_params(&self) -> usize {
131        let mut n = 0;
132        visit_leaves(self, |_, a, _, _| n += a.size());
133        n
134    }
135
136    /// The number of elements over the trainable leaves.
137    fn num_trainable(&self) -> usize {
138        let mut n = 0;
139        visit_leaves(self, |_, a, _, trainable| {
140            if trainable {
141                n += a.size();
142            }
143        });
144        n
145    }
146
147    /// A table of the leaves (path, shape, dtype, trainable, bytes) with totals.
148    fn summary(&self) -> Summary {
149        Summary::of(self)
150    }
151
152    /// The nested modules with their parameter counts, for printing:
153    /// `println!("{}", model.display())`.
154    fn display(&self) -> ModuleDisplay<'_, Self> {
155        ModuleDisplay::new(self)
156    }
157
158    /// Freezes the parameters whose path matches `filter`, returning how many changed.
159    /// A matching plain `Array` leaf cannot be frozen (only [`Param`]s can): that is an
160    /// error naming the paths, and nothing changes. Buffers are never trainable anyway.
161    fn freeze(&mut self, filter: impl Fn(&str) -> bool) -> Result<usize> {
162        set_frozen(self, &filter, true)
163    }
164
165    /// Unfreezes the parameters whose path matches `filter`, returning how many changed.
166    /// Buffers (quantized weights) and integer parameters stay non-trainable.
167    fn unfreeze(&mut self, filter: impl Fn(&str) -> bool) -> Result<usize> {
168        set_frozen(self, &filter, false)
169    }
170
171    /// Sets every mode flag of the tree: training (`true`, the default) or evaluation
172    /// (`false`, where dropout is the identity), as MLX's `Module.train(mode)`. (There is
173    /// no `eval()`: it would read like `x.eval()`, which evaluates.)
174    fn train(&mut self, on: bool) {
175        struct Train(bool);
176        impl VisitorMut for Train {
177            fn leaf(&mut self, _: &str, _: &mut Array, _: LeafKind, _: Option<&mut bool>) {}
178            fn training(&mut self, _: &str, flag: &mut bool) {
179                *flag = self.0;
180            }
181        }
182        self.visit_mut(&mut VisitMut::new(&mut Train(on)));
183    }
184
185    /// Whether the tree is in training mode (its first mode flag; `true` without any).
186    fn training(&self) -> bool {
187        struct Mode(Option<bool>);
188        impl Visitor for Mode {
189            fn leaf(&mut self, _: &str, _: &Array, _: LeafKind, _: bool) {}
190            fn mode(&mut self, _: &str, training: bool) {
191                self.0.get_or_insert(training);
192            }
193        }
194        let mut m = Mode(None);
195        self.visit(&mut Visit::new(&mut m));
196        m.0.unwrap_or(true)
197    }
198
199    /// Replaces leaves by name, strictly (MLX's `load_weights(strict=True)`): every leaf
200    /// must be given with its shape and nothing else (loaded dtypes are kept). On any
201    /// problem the error lists every missing, unknown and mismatched name, and the model
202    /// is unchanged. `src` is a safetensors path or named arrays.
203    fn load_weights(&mut self, src: impl WeightSource) -> Result<()> {
204        weights::load(self, src, LoadMode::Strict)
205    }
206
207    /// Replaces the leaves given by name (MLX's `load_weights(strict=False)`): unknown
208    /// names are ignored, missing leaves keep their values, shapes must match.
209    fn load_weights_partial(&mut self, src: impl WeightSource) -> Result<()> {
210        weights::load(self, src, LoadMode::Partial)
211    }
212
213    /// Writes every leaf as safetensors with MLX's names and dtypes (MLX's
214    /// `save_weights`), evaluating them. Unloaded placeholders are an error naming them.
215    fn save_weights(&self, path: impl AsRef<Path>) -> Result<()> {
216        weights::save(self, path.as_ref(), false)
217    }
218
219    /// Writes only the trainable leaves (an adapter's weights, say).
220    fn save_trainable(&self, path: impl AsRef<Path>) -> Result<()> {
221        weights::save(self, path.as_ref(), true)
222    }
223
224    /// Calls `f(path, submodule)` on every submodule of type `T` whose path matches
225    /// `filter` (`f` may replace it, as when applying LoRA), returning how many. It stops at
226    /// the first error from `f`; submodules changed before it keep their change. Matching
227    /// submodules are not searched for further matches.
228    fn map_modules<T: Tree>(
229        &mut self,
230        filter: impl Fn(&str) -> bool,
231        mut f: impl FnMut(&str, &mut T) -> Result<()>,
232    ) -> Result<usize> {
233        struct Map<'a, T> {
234            filter: &'a dyn Fn(&str) -> bool,
235            f: &'a mut dyn FnMut(&str, &mut T) -> Result<()>,
236            count: usize,
237            error: Option<Error>,
238        }
239        impl<T: 'static> VisitorMut for Map<'_, T> {
240            fn leaf(&mut self, _: &str, _: &mut Array, _: LeafKind, _: Option<&mut bool>) {}
241            fn child(&mut self, path: &str, child: &mut dyn Any) -> bool {
242                if self.error.is_some() {
243                    return false;
244                }
245                match child.downcast_mut::<T>() {
246                    Some(m) if (self.filter)(path) => {
247                        match (self.f)(path, m) {
248                            Ok(()) => self.count += 1,
249                            Err(e) => self.error = Some(e),
250                        }
251                        false
252                    }
253                    _ => true,
254                }
255            }
256        }
257        let mut map = Map {
258            filter: &filter,
259            f: &mut f,
260            count: 0,
261            error: None,
262        };
263        self.visit_mut(&mut VisitMut::new(&mut map));
264        match map.error {
265            Some(e) => Err(e),
266            None => Ok(map.count),
267        }
268    }
269}
270
271impl<T: Tree> Module for T {}
272
273/// Calls `f(path, leaf, kind, trainable)` for every leaf.
274fn visit_leaves<T: Tree>(tree: &T, f: impl FnMut(&str, &Array, LeafKind, bool)) {
275    struct V<F>(F);
276    impl<F: FnMut(&str, &Array, LeafKind, bool)> Visitor for V<F> {
277        fn leaf(&mut self, path: &str, a: &Array, kind: LeafKind, trainable: bool) {
278            (self.0)(path, a, kind, trainable);
279        }
280    }
281    tree.visit(&mut Visit::new(&mut V(f)));
282}
283
284fn set_frozen<T: Tree>(tree: &mut T, filter: &dyn Fn(&str) -> bool, frozen: bool) -> Result<usize> {
285    if frozen {
286        let mut plain = Vec::new();
287        visit_leaves(tree, |p, _, kind, _| {
288            if kind == LeafKind::Array && filter(p) {
289                plain.push(p.to_owned());
290            }
291        });
292        if !plain.is_empty() {
293            return Err(Error::InvalidArgument(format!(
294                "freeze: plain arrays cannot be frozen (make them `Param`s): {}",
295                plain.join(", ")
296            )));
297        }
298    }
299    struct Set<'a> {
300        filter: &'a dyn Fn(&str) -> bool,
301        frozen: bool,
302        changed: usize,
303    }
304    impl VisitorMut for Set<'_> {
305        fn leaf(&mut self, path: &str, a: &mut Array, kind: LeafKind, flag: Option<&mut bool>) {
306            let Some(flag) = flag else { return };
307            if kind != LeafKind::Param || !(self.filter)(path) || *flag == self.frozen {
308                return;
309            }
310            // Integer parameters (packed weights, say) are never trained.
311            if !self.frozen && !matches!(a.dtype().category(), Category::Float | Category::Complex)
312            {
313                return;
314            }
315            *flag = self.frozen;
316            self.changed += 1;
317        }
318    }
319    let mut set = Set {
320        filter,
321        frozen,
322        changed: 0,
323    };
324    tree.visit_mut(&mut VisitMut::new(&mut set));
325    Ok(set.changed)
326}
327
328/// `max(x, 0)`, as MLX's `nn.relu`.
329#[track_caller]
330pub fn relu(x: &Array) -> Array {
331    x.maximum(0.0)
332}
333
334/// `x · sigmoid(x)`, as MLX's `nn.silu`.
335#[track_caller]
336pub fn silu(x: &Array) -> Array {
337    x.multiply(x.sigmoid())
338}
339
340/// GELU with the tanh approximation, as MLX's `nn.gelu_approx`:
341/// `0.5 x (1 + tanh(√(2/π) (x + 0.044715 x³)))`.
342#[track_caller]
343pub fn gelu_approx(x: &Array) -> Array {
344    let cube = x.multiply(x).multiply(x);
345    let inner = x
346        .add(cube.multiply(0.044715))
347        .multiply((2.0 / std::f64::consts::PI).sqrt());
348    x.multiply(0.5).multiply(inner.tanh().add(1.0))
349}
350
351/// `x · sigmoid(1.702 x)`, as MLX's `nn.gelu_fast_approx`.
352#[track_caller]
353pub fn gelu_fast_approx(x: &Array) -> Array {
354    x.multiply(x.multiply(1.702).sigmoid())
355}