What is Micrograd?
Micrograd is Andrej Karpathy’s minimalist autograd engine. It implements backpropagation over a dynamically built computation graph: the same algorithm used in larger deep learning frameworks, just reduced to scalar values.
The Python version is small enough to read in one sitting. I wanted to build the same idea in Rust, where the graph has to be explicit and shared nodes need to be handled carefully.
Why Rust?
- Ownership: the graph is made of nodes that point back to their parents, so shared ownership matters.
- Interior mutability: gradients are updated during backpropagation even when nodes are shared.
- Learning: implementing autograd in Rust makes the data flow harder to hand-wave.
The Value Struct
At the heart of the crate is ValueData, a scalar plus the metadata needed for backpropagation:
| |
The public handle is Value, which wraps Rc<RefCell<ValueData>>:
| |
Rc lets multiple nodes keep references to the same parent. RefCell lets the code update gradients later, during the backward pass. That is the main Rust-specific part of this implementation.
Forward Pass
Creating a leaf node stores the number and marks the operation as None:
| |
Each operation creates a new Value and stores its parents. Addition is direct: the local derivative with respect to both inputs is 1.
| |
Multiplication is the same pattern, but the backward rule needs the other input’s value:
| |
Backward Pass
The backward pass computes gradients using the chain rule:
$$ \frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial x} $$The idea is simple: every node knows how it was created, so it knows how to pass its gradient to its parents.
For this expression:
$$ d = (a \\cdot b) + (a + b) $$Here is the backward pass one step at a time:
d is the final output, so its gradient starts at 1.0:
Then the graph is walked backwards. Since d = e + c, the gradient from d flows unchanged into both e and c:
Now $e = a \cdot b$, so multiplication sends the other input to each parent. a receives b, and b receives a:
And c = a + b, so both parents receive 1.
The important detail is that gradients add up. a is used in both e and c, so its final gradient gets one contribution from the multiply node and one from the add node.
For each node, backward applies the local derivative and adds into each parent’s gradient:
| |
For a multiply node:
$$ y = a \cdot b $$So:
$$ \frac{\partial y}{\partial a} = b $$and:
$$ \frac{\partial y}{\partial b} = a $$That is why the code adds self.gradient() * p2_data to the first parent and self.gradient() * p1_data to the second parent.
Topological Sort
Before running backward, the graph needs to be ordered from the final output back through its dependencies. The crate does this with topo_sort:
| |
The output node starts with a gradient of 1.0, because:
Then back_propogate walks the sorted list and calls backward on each node:
| |
The function name is currently spelled back_propogate in the crate, so the post uses the same spelling.
Example
Here is the example from main.rs:
| |
The expression is:
$$ d = (a \\cdot b) + (a + b) $$With a = 1 and b = 2, the gradients are:
What I Learned
Rc<RefCell<T>>fits this small graph well because nodes need shared ownership and delayed mutation.- Pointer equality matters.
PartialEqusesRc::ptr_eq, so two handles are equal only when they point to the same node. - The topological order is the part that makes the backward pass work. Without it, a node can be processed before its gradient is complete.
Next Steps
- Add more operations like subtraction, division, powers,
relu, andtanh. - Add tests for gradients, not just forward values.
- Build a tiny neural network layer on top of
Value.
This is based on my microgradrs project.