Neural networks
nuvec-nn: modules as named parameter trees, Linear, quantized and LoRA layers, norms, RoPE, losses, and SGD, Adam, AdamW, Lion and Muon optimizers with schedules.
nuvec-nn follows MLX’s mlx.nn and mlx.optimizers. A model is a plain Rust struct of layers with #[derive(Module)]: its parameters are named by the field names, as MLX’s (layers.0.weight), and the core transforms differentiate it directly, returning gradients of the model’s own type.
Modules🔗
use nuvec::{Array, Result, random, value_and_grad};
use nuvec_nn::optim::{Adam, Optimizer};
use nuvec_nn::{Linear, Module, losses, relu};
#[derive(Clone, Module)]
struct Mlp { hidden: Linear, out: Linear }
impl Mlp {
fn forward(&self, x: &Array) -> Array {
self.out.forward(&relu(&self.hidden.forward(x)))
}
}
fn train(x: &Array, y: &Array) -> Result<()> {
random::seed(0);
let mut model = Mlp { hidden: Linear::new(784, 512), out: Linear::new(512, 10) };
let mut opt = Adam::new(1e-3);
let loss = |m: &Mlp| losses::cross_entropy(&m.forward(x), y, losses::Reduction::Mean);
for _ in 0..100 {
let (_value, grads) = value_and_grad(loss)(&model)?; // grads: Mlp
opt.update(&mut model, &grads)?;
}
println!("{}", model.display()); // Mlp (407,050 params, 407,050 trainable) ...
Ok(())
}
# fn main() -> Result<()> {
# let x = random::normal([8, 784], nuvec::DType::F32, 0.0, 1.0, None);
# train(&x, &nuvec::array![0u32, 1, 2, 3, 4, 5, 6, 7])
# }Write the closure’s parameter type (|m: &Mlp|): the transforms are generic, so Rust cannot infer it (E0282 otherwise).
Fields and attributes🔗
use nuvec::{Array, Param};
use nuvec_nn::{Linear, Module, RmsNorm};
#[derive(Clone, Module)]
struct Attention {
q_proj: Linear, // a submodule: q_proj.weight, q_proj.bias
#[tree(rename = "q_norm")] qn: Option<RmsNorm>, // `None` contributes nothing
#[tree(skip)] heads: i32, // configuration, hashed into the structure
#[tree(skip)] freqs: Array, // a computed constant: not saved, not loaded
#[tree(buffer)] running_mean: Array, // saved and loaded, never trained
gate: Param, // a raw parameter
}
# fn uses(a: &Attention) { let _ = (&a.q_proj, &a.qn, a.heads, &a.freqs, &a.running_mean, &a.gate); }- A field typed
Arrayneeds a role:Param(a parameter),#[tree(buffer)]or#[tree(skip)]; the derive rejects a bare one. The check is syntactic (a type alias ofArrayis not caught). - A
#[tree(skip)]field must beStatic(numbers, strings,#[derive(Static)]types): its value is part of the model’s structure, so a compiled step retraces when it changes.#[tree(skip, static_unchecked)]opts out (the value is then baked into compiled traces). - Enums work as modules: a newtype variant is transparent (
Dense(Linear)namesweight),#[tree(rename = "self_attn")]adds a segment. There are no trait-object fields: use an enum of the concrete layers. - Generated code names only
::nuvec::__derive; a crate that depends onnuvec_corealone writes#[tree(crate = "nuvec_core")].
Using a model🔗
- Names and counts:
parameters(),trainable_parameters(),num_params(),num_trainable(),summary()(a table of path, shape, dtype, trainable, bytes) anddisplay()(the nested modules with their counts). - Freezing by path:
model.freeze(|_| true), thenmodel.unfreeze(|p| p.contains("lora_")); both return how many parameters changed. Quantized weights are buffers and never become trainable. model.train(false)switches every dropout layer to evaluation mode (train(true)back;training()reads it).- Weights by name:
load_weights(path_or_named_arrays)is strict and reports every missing, unknown and mismatched name in one error, leaving the model unchanged;load_weights_partialignores unknown names;save_weights(path)writes MLX-compatible safetensors (save_trainableonly the trainable leaves). map_modules::<T>(filter, f)edits or replaces every submodule of typeTwhose path matches (applying LoRA, say).
Layers🔗
Constructors take no bool and return no Result; initialisation draws MLX’s values for a seed.
Linear::new(i, o)(.without_bias()),QuantizedLinear::from_linear(&l)(MLX’s affine 64/4;from_linear_with(&l, Quant::affine(32, 8))orQuantMode::Mxfp4),Embedding::new(n, d),QuantizedEmbedding::from_embedding(&e);RmsNorm::new(d).eps(e),LayerNorm::new(d).eps(e).affine(false)/.without_bias()andRope::new(d).traditional().base(b).scale(s)on the fused kernels;LoraLinear::from_base(base, rank)over a full-precision or quantized base layer (LoRA and QLoRA), with.scale(s),.dropout(p)and.rslora();Dropout::new(p), as MLX’snn.Dropout;- activations:
relu,silu,gelu_approx,gelu_fast_approx.
Losses and optimizers🔗
losses::cross_entropywith mean, sum or no reduction, on the fused kernel.optim:Sgd(momentum,.nesterov(), weight decay),Adam(.betas,.eps,.bias_correction()),AdamW(MLX’s default decay 0.01),LionandMuon.opt.update(&mut model, &grads)updates the trainable leaves from a gradient tree of the model’s type.- The optimizer’s state is a tree:
step,learning_rate(0-D arrays, as MLX’soptimizer.state) and the slots by parameter path (slots.layers.0.weight.m). Freezing or unfreezing never moves or resets state.opt.init(&model)creates it up front;set_learning_rate(lr)writes the rate.Box<dyn Optimizer>works. - Parameter groups (MLX’s
MultiOptimizer):MultiOptimizer::new(Sgd::new(1e-2)).group(|p| p.contains("lora_a"), Adam::new(1e-3)); the first matching group wins. - Learning-rate schedules: exponential, step and cosine decay, linear warm-up, and joined schedules, as float32 functions of the step array.
clip_grad_norm(&grads, max_norm)returns the clipped gradient tree and the total norm.
A compiled training step🔗
compile_mut compiles the whole step (forward, gradients, update) with the model and the optimizer as its state, as MLX’s mx.compile(step, inputs=state, outputs=state):
use nuvec::{Array, Result, array, compile_mut, random, value_and_grad};
use nuvec_nn::losses::{Reduction, cross_entropy};
use nuvec_nn::optim::{Optimizer, Sgd};
use nuvec_nn::{Linear, Module};
#[derive(Clone, Module)]
struct Mlp { layers: Vec<Linear> }
impl Mlp {
fn forward(&self, x: &Array) -> Array {
let mut h = x.clone();
for (i, layer) in self.layers.iter().enumerate() {
h = layer.forward(&h);
if i + 1 < self.layers.len() {
h = nuvec_nn::relu(&h);
}
}
h
}
}
# fn main() -> Result<()> {
random::seed(0);
let mut state = (Mlp { layers: vec![Linear::new(4, 8), Linear::new(8, 3)] }, Sgd::new(0.1));
state.1.init(&state.0);
let step = compile_mut(|(model, opt): &mut (Mlp, Sgd), (x, y): &(Array, Array)| {
let loss_fn = |m: &Mlp| cross_entropy(&m.forward(x), y, Reduction::Mean);
let (loss, grads) = value_and_grad(loss_fn)(model)?;
opt.update(model, &grads)?;
Ok(loss)
});
let x = random::normal([4, 4], nuvec::DType::F32, 0.0, 1.0, None);
let batch = (x, array![0u32, 1, 2, 0]);
for _ in 0..10 {
let _loss = step(&mut state, &batch)?; // traces once, then replays
}
# Ok(())
# }The step traces once: the learning rate and step counter are arrays in the optimizer’s state, and dropout inside draws fresh masks every call (the random key is threaded through). Switching train(false), freezing a layer or changing a hyperparameter retraces once.
Checkpoints🔗
A whole run, model and optimizer, saves and resumes by path in two lines:
# use nuvec::Result;
# use nuvec_nn::optim::{Adam, Optimizer};
# use nuvec_nn::{Linear, Module};
# fn main() -> Result<()> {
# let mut state = (Linear::new(4, 2), Adam::new(1e-3));
nuvec::save("run.safetensors", &state)?;
// ... later, in a fresh process:
state.1.init(&state.0);
nuvec::load_into("run.safetensors", &mut state)?;
# Ok(())
# }save_weights writes only the model (MLX’s save_weights; MLX loads it), save_trainable only what is trained (an adapter). For LoRA on LLMs, nuvec_lm::lora::linear_to_lora_layers replaces projections by path and save_adapters/load_adapters read and write mlx-lm’s adapter directories.
Results match MLX’s layers, optimizers and dropout masks on recorded fixtures. The repository’s just mnist trains MLX’s MNIST example model (with a compile_mut step) and compares it with MLX.