Autograd by composition, not by tape
PyTorch's autograd works by recording a tape of operations as you do them, then walking the tape backward. MLX (and JAX) take a different route — they expose transformations that take a function and return a new function. mx.grad(f) doesn't differentiate f's tape after the fact; it returns a new function that, when called, computes the gradient.
That sounds like a small distinction. It is not. It means your gradients are first-class composable values — you can wrap them in other transforms, JIT-compile them, vmap them across a batch. None of that requires a special "training mode" or a tape that stays live.
The three you'll use most
mx.grad(f) — given a scalar-valued function, returns a function that computes the gradient with respect to its first argument. Use it when you want gradients but don't care about the loss value.
mx.value_and_grad(f) — same as mx.grad, but returns both the loss value and the gradient in one call. Use this in training loops; you almost always want to log the loss.
mx.vmap(f) — turns a function-of-one-example into a function-of-a-batch. Composes naturally with grad. Use it when you want efficient batched computation without manually broadcasting.
Composability is the point
Because each transform takes a function and returns a function, you can stack them. mx.vmap(mx.grad(loss_fn)) gives you the per-example gradient across a batch. mx.grad(mx.grad(f)) gives you the second derivative. mx.compile(mx.value_and_grad(loss_fn)) JIT-compiles the gradient computation. Other frameworks make these tricks possible; MLX makes them obvious.
nn.value_and_grad — the model-aware variant
When you have an nn.Module with parameters, you usually want gradients with respect to the model's parameters, not just the function's first argument. The nn module exposes nn.value_and_grad(model, loss_fn) which is the canonical pattern for training. Lesson 6 will use it.