Andrej Karpathy
AI-assisted summary of The spelled-out intro to neural networks and backpropagation: building micrograd — original source. Check the original for full context.
Source published:
Key ideas
- Andrej Karpathy demonstrates how deep neural network backpropagation can be fully implemented from scratch in roughly 150 lines of Python using Micrograd, bypassing complex tensor math to show that automatic differentiation fundamentally operates on scalar directed acyclic graphs. (4:33)
- The core autograd engine relies on reverse topological sorting of computation graphs paired with local gradient accumulation (+=), preventing silent gradient overwrites when variables branch through multiple pathways. (82:28)
- Validating Micrograd against PyTorch confirms numerical gradient equivalence, proving that multi-dimensional tensor frameworks use identical chain-rule calculus under the hood purely optimized for parallelized hardware execution. (99:31)
- Inspecting PyTorch's production codebase reveals that complex production libraries implement the exact same local derivative formulas—such as `a * (1 - b * b)` for tanh backward—across CPU and CUDA kernels. (141:10)
Detailed notes
## Background Published by Andrej Karpathy on YouTube on August 16, 2022, this video serves as a comprehensive, step-by-step introduction to backpropagation and the training of neural networks. The lecture assumes only basic Python fluency and high school calculus, walking viewers through the construction of micrograd—a lightweight scalar autograd engine and neural network library—from scratch in a Jupyter Notebook.
## TL;DR Karpathy demystifies deep learning by deriving core calculus concepts from first principles, beginning with numerical approximations of derivatives and manual multi-variable chain rule evaluations. He builds the `Value` engine incrementally, overloading Python arithmetic operators, tracking dynamic directed acyclic computation graphs, and automating reverse-mode automatic differentiation using topological sorting. After addressing crucial edge cases like gradient accumulation and decomposed mathematical operations, he constructs an object-oriented neural network framework containing neurons, layers, and multi-layer perceptrons. Finally, he demonstrates training an MLP on a binary dataset via gradient descent, diagnoses subtle bugs like gradient zeroing, and validates micrograd's mathematical accuracy directly against PyTorch's underlying C++ and CUDA implementations.
## Atmosphere & style The lecture carries an intimate, masterclass feel, pairing the unvarnished authenticity of live coding with crystal-clear mathematical pedagogy. Karpathy speaks with relaxed authority, candidly admitting his own coding slip-ups, leaving in unscripted bugs, and sharing wry humor when naming throwaway functions or fumbling library names in the outtakes. Watching it feels like sitting beside a world-class engineer at an interactive whiteboard as he strips away modern framework bloat to reveal the simple, elegant calculus ticking beneath frontier artificial intelligence.
## Bulletin - [04:33](https://www.youtube.com/watch?v=VMj-3S1tku0&t=273s) Andrej Karpathy demonstrates how deep neural network backpropagation can be fully implemented from scratch in roughly 150 lines of Python using Micrograd, bypassing complex tensor math to show that automatic differentiation fundamentally operates on scalar directed acyclic graphs. - [01:22:28](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4948s) The core autograd engine relies on reverse topological sorting of computation graphs paired with local gradient accumulation (+=), preventing silent gradient overwrites when variables branch through multiple pathways. - [01:39:31](https://www.youtube.com/watch?v=VMj-3S1tku0&t=5971s) Validating Micrograd against PyTorch confirms numerical gradient equivalence, proving that multi-dimensional tensor frameworks use identical chain-rule calculus under the hood purely optimized for parallelized hardware execution. - [02:21:10](https://www.youtube.com/watch?v=VMj-3S1tku0&t=8470s) Inspecting PyTorch's production codebase reveals that complex production libraries implement the exact same local derivative formulas—such as `a * (1 - b * b)` for tanh backward—across CPU and CUDA kernels.
## Key takeaways - [00:00](https://www.youtube.com/watch?v=VMj-3S1tku0&t=0s) Backpropagation is the recursive application of the calculus chain rule backwards through a computational graph to evaluate partial derivatives of a scalar loss with respect to network parameters. - [04:33](https://www.youtube.com/watch?v=VMj-3S1tku0&t=273s) Neural networks are mathematical expressions parameterized by weights and biases; multidimensional tensors exist solely for hardware execution speed and parallelism, not mathematical necessity. - [09:33](https://www.youtube.com/watch?v=VMj-3S1tku0&t=573s) The analytical derivative represents the instantaneous sensitivity of a function's output to infinitesimal nudges in its inputs, verified numerically via finite differences. - [19:09](https://www.youtube.com/watch?v=VMj-3S1tku0&t=1149s) The Micrograd `Value` object encapsulates scalar data, gradient values, operator references, and child pointers to dynamically build an expression DAG during forward execution. - [38:06](https://www.youtube.com/watch?v=VMj-3S1tku0&t=2286s) Backpropagation operates modularly because individual nodes only need to compute their local derivatives and multiply them by the incoming upstream gradient. - [43:07](https://www.youtube.com/watch?v=VMj-3S1tku0&t=2587s) Addition nodes act as pure gradient distributors, routing upstream gradients unaltered to both inputs, while multiplication nodes swap and scale incoming gradients by operand values. - [01:17:32](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4652s) Automatic backward execution requires ordering expression nodes via topological sort so that each node processes all incoming derivative contributions before backpropagating to its children. - [01:22:28](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4948s) The multivariate chain rule dictates that whenever an intermediate variable is reused across multiple computational pathways, its incoming gradients must accumulate via addition rather than direct assignment. - [01:36:21](https://www.youtube.com/watch?v=VMj-3S1tku0&t=5781s) Autograd engines grant complete flexibility over operation granularity; composite functions like tanh can be evaluated as single atomic kernels or decomposed into elementary operations with identical mathematical outcomes. - [01:43:55](https://www.youtube.com/watch?v=VMj-3S1tku0&t=6235s) A standard multi-layer perceptron (MLP) is built by chaining sets of independent neurons into layers, producing deeply nested DAGs that auto-differentiate seamlessly through the autograd engine. - [01:51:04](https://www.youtube.com/watch?v=VMj-3S1tku0&t=6664s) A scalar loss function maps total network error across training examples into a single objective scalar to drive parameter optimization via gradient descent. - [02:05:38](https://www.youtube.com/watch?v=VMj-3S1tku0&t=7538s) Failing to zero gradients before invoking the backward pass retains historical derivatives from previous iterations, causing bloated update steps and destabilized training. - [02:23:43](https://www.youtube.com/watch?v=VMj-3S1tku0&t=8623s) Custom autograd functions in frameworks like PyTorch only require implementing the forward evaluation and the local backward derivative to plug directly into the broader computational graph.
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=VMj-3S1tku0&t=0s) Introduction to Micrograd and Neural Network Training
Andrej Karpathy introduces the lecture by noting that he has been training deep neural networks for over a decade, yet many practitioners never get to see what occurs under the hood. The goal of this session is to start from a completely blank Jupyter Notebook and build everything up from scratch until a full neural network is defined and trained. Karpathy explains that rather than relying on high-level black-box abstractions, the lecture will walk step by step through the implementation of micrograd—a tiny autograd engine he released on GitHub about two years prior. At that time, he only published the raw source code without commentary, leaving learners to decipher its inner workings on their own.
At its core, micrograd is an automatic gradient (autograd) engine that implements backpropagation. Karpathy frames backpropagation as the essential mathematical foundation powering modern deep learning libraries such as PyTorch or JAX. The algorithm provides an efficient way to evaluate the local derivative of a scalar loss function with respect to every internal weight of a neural network. With those derivatives in hand, one can iteratively nudge the weights in the direction that minimizes the loss, thereby improving the network's predictive accuracy. As Karpathy highlights: “backpropagation would be at the mathematical core of any modern deep neural network library like say pytorch or jax”.
- Karpathy has been training deep neural networks for a bit more than ten years. - Micrograd was released on GitHub approximately two years prior to the lecture as bare source code without an accompanying walkthrough. - The end goal of the lecture is to demystify neural net training entirely by building both the autograd engine and the neural network library from scratch.
[01:24](https://www.youtube.com/watch?v=VMj-3S1tku0&t=84s) Building Expression Graphs and the Micrograd Value Object
To illustrate what micrograd does, Karpathy walks through a motivating example where scalar values are wrapped in a custom Python object called `Value`. Taking two initial inputs, $a = -4.0$ and $b = 2.0$, these values are wrapped into `Value(a)` and `Value(b)` and fed into an arbitrary composite mathematical expression that chains together intermediate nodes $c, d, e, f$, and ultimately an output node $g$. Micrograd supports a broad variety of primitive arithmetic operations on these `Value` objects, including addition, multiplication, exponentiation to constant powers, negation, scalar offsets, division, and squashing functions like ReLU (squashing below zero). As operations are invoked, micrograd silently constructs a directed acyclic expression graph behind the scenes, where every resulting node retains pointers to its child operands and the operation that created it.
Karpathy points out that evaluating the expression to obtain $g = 24.7$ via `g.data` is merely the standard forward pass. The true power lies in calling `g.backward()`. This single call initializes the backpropagation process at node $g$ and recursively traverses the graph in reverse, applying the calculus chain rule at every step. This calculates the derivative of $g$ with respect to every internal node ($e, d, c$) and the original inputs ($a$ and $b$). Inspecting the `.grad` attributes reveals that `a.grad` is 138 and `b.grad` is 645. These values provide crucial sensitivity information: if $a$ is nudged upward by a tiny amount, $g$ will increase with an instantaneous slope of 138, whereas nudging $b$ will cause $g$ to grow at a rate of 645. Karpathy jokes about the arbitrary complexity of the formula he constructed on screen: “and this expression by the way is completely meaningless i just made it up i'm just flexing about the kinds of operations that are supported by micrograd”.
```mermaid flowchart LR accTitle: Micrograd Expression Graph and Backpropagation Flow accDescr: Diagram showing how inputs a and b flow forward through intermediate operations to g, while gradients flow backward. subgraph Inputs["Leaf Inputs"] A["a (-4.0)"] B["b (2.0)"] end subgraph Computation["Expression Graph"] C["c"] D["d"] EF["e, f"] end subgraph Output["Output Root"] G["g (24.7)"] end
A -->|"forward pass"| C B -->|"forward pass"| C C --> D D --> EF EF --> G
G -.->|"g.backward() (chain rule)"| EF EF -.-> D D -.-> C C -.->|"grad = 138"| A C -.->|"grad = 645"| B ```
- Input variables: $a = -4.0$ and $b = 2.0$, wrapped in `Value` instances. - The forward pass produces a final scalar result of $g.data = 24.7$. - Calling `g.backward()` populates `a.grad = 138` and `b.grad = 645`. - Supported primitive operations demonstrated include addition, multiplication, powers, negation, offsets, division, and ReLU squashing.
[04:33](https://www.youtube.com/watch?v=VMj-3S1tku0&t=273s) Neural Networks as Mathematical Expressions and the Scalar vs. Tensor Abstraction
Karpathy demystifies neural networks by explaining that they are not conceptually distinct from the toy expression graph just shown; in fact, they are often simpler and more regular. A neural network is simply a mathematical expression that consumes two things: input feature data and trainable network weights. Its output is either a prediction or a scalar loss value. Because backpropagation is completely agnostic to whether an expression represents a physics simulation, an arbitrary formula, or a deep network, it serves as a universal mechanism for computing exact derivatives through computational graphs.
Addressing why micrograd operates on single scalars rather than the multi-dimensional tensors used in production frameworks, Karpathy explains that this is a deliberate pedagogical choice. Splitting a neural network into the individual atoms of scalar additions and multiplications would be catastrophically slow in production, but dealing with $n$-dimensional tensor shapes and broadcasting rules from the outset obscures the fundamental mechanics of the chain rule. Tensors exist purely for hardware efficiency—batching numbers into contiguous arrays allows computers and GPUs to leverage massive parallelism. The underlying calculus remains identical whether operating on individual scalars or billion-element tensors. Karpathy emphasizes how compact the entire engine really is:
> “my claim is that micrograd is what you need to train your networks and everything else is just efficiency”
The entire micrograd repository consists of only two concise source files: - `engine.py`: The core scalar autograd engine and backpropagation logic, spanning roughly 100 lines of simple, dependency-free Python code. - `nn.py`: A miniature neural network library built on top of the engine, containing around 50 lines of code that define a `Neuron`, a `Layer` of neurons, and a `MLP` (Multi-Layer Perceptron) as a sequence of layers.
[08:08](https://www.youtube.com/watch?v=VMj-3S1tku0&t=488s) Understanding Derivatives: A Single-Input Scalar Function
To ensure viewers build rock-solid intuition before diving into multi-node computation graphs, Karpathy opens a clean notebook and imports `math`, `numpy`, and `matplotlib.pyplot`. He begins by defining a simple, single-input quadratic function $f(x) = 3x^2 - 4x + 5$. Evaluating the function at an arbitrary point such as $x = 3.0$ yields $f(3.0) = 3(9) - 4(3) + 5 = 20.0$.
To visualize the function's parabolic geometry, Karpathy generates an array of scalar inputs using NumPy: ```python xs = np.arange(-5, 5, 0.25) ys = f(xs) plt.plot(xs, ys) ``` Evaluating $f(x)$ over these discrete samples from $-5$ up to (but not including) $5$ in increments of $0.25$ produces a smooth, upward-opening parabola. This visual curve serves as the starting canvas for explaining what a derivative represents geometrically and how small perturbations to the input $x$ alter the output $y$.
- Defined function: $f(x) = 3x^2 - 4x + 5$. - Scalar test evaluation: $f(3.0) = 20.0$. - Input domain for visualization: NumPy range spanning $[-5, 5)$ with step size $0.25$. - Output plotted via `plt.plot(xs, ys)` to inspect the parabola's slope across different regions of $x$.
[09:33](https://www.youtube.com/watch?v=VMj-3S1tku0&t=573s) The Calculus Definition and Numerical Derivative of a Single-Input Function
Evaluating the quadratic function $f(x) = 3x^2 - 4x + 5$ at $x = 3.0$ yields a $y$-coordinate of $20.0$. Karpathy uses this simple curve to explore the foundational meaning of a derivative at any arbitrary point $x$. While high school calculus typically emphasizes memorizing symbolic manipulation rules like the power rule and product rule to write out analytical derivatives on paper, Karpathy explicitly rejects taking a purely symbolic path here. In modern deep learning, models consist of tens of thousands or even billions of operations, making manual symbolic expansion completely unfeasible. As Karpathy observes, “We're not going to actually do that because no one in neural networks actually writes out the expression for the neural net, it would be a massive expression.” Instead, the focus shifts to the fundamental definition of the derivative and what it physically measures about the behavior and sensitivity of a function.
Looking at the textbook definition, the derivative is the limit as $h$ approaches zero of $\frac{f(x + h) - f(x)}{h}$. Intuitively, if you sit at a given point $x$ and slightly nudge it by a minuscule increment $h$, the derivative reveals how the function responds: its sensitivity, whether the curve climbs or drops, and the steepness of that slope. To build an operational understanding, Karpathy approximates this limit numerically in Python by setting $h = 0.001$. Evaluating $f(x + h)$ at $x = 3.0$ gives a value slightly above $20$, indicating a positive response. Dividing this vertical rise by the horizontal run $h$ yields an estimated slope of roughly $14.0$. While choosing an excessively small $h$ with too many zeros eventually causes numerical corruption due to finite floating-point arithmetic precision in computer memory, this finite difference method converges cleanly toward the true slope. Differentiating analytically in one's head ($f'(x) = 6x - 4$, which evaluates to $6(3) - 4 = 14$ at $x = 3$) confirms the numerical estimate is exact.
Karpathy then tests different points along the curve to demonstrate how the sign and magnitude of the derivative reflect the local topography. At $x = -3.0$, nudging $x$ slightly in the positive direction causes the function value to decrease, resulting in a steep negative slope of $-22$ (matching $6(-3) - 4 = -22$). Finally, probing the function around $x = \frac{2}{3}$ reveals a derivative of zero. At that local minimum, nudging $x$ positively leaves the output momentarily unchanged, illustrating what a zero slope means geometrically.
- The test function is $f(x) = 3x^2 - 4x + 5$, which has an analytical derivative of $f'(x) = 6x - 4$. - At $x = 3.0$: $f(3) = 20$, and the estimated numerical slope using $h = 0.001$ converges to $14$. - At $x = -3.0$: $f(-3) = 44$, and nudging $x$ yields a negative slope of $-22$. - At $x = \frac{2}{3} \approx 0.667$: $f'(x) = 0$, representing the turning point where the function shows zero sensitivity to infinitesimal perturbations. - Floating-point representations have finite memory, so driving $h$ arbitrarily close to zero in Python eventually breaks down rather than improving precision.
---
[14:12](https://www.youtube.com/watch?v=VMj-3S1tku0&t=852s) Derivatives with Multiple Inputs and Intuitive Sensitivities
Karpathy steps up the complexity by introducing a multi-input scalar function: $d = a \cdot b + c$. With inputs initialized to $a = 2.0$, $b = -3.0$, and $c = 10.0$, the expression evaluates to an initial baseline output $d_1 = 4.0$. To evaluate the partial derivatives of $d$ with respect to each input variable without formal calculus machinery, Karpathy uses a hacky numerical perturbation harness. He sets a small delta $h = 0.0001$, defines the baseline inputs, nudges one single variable by $h$, re-evaluates the expression to obtain $d_2$, and computes the slope as $(d_2 - d_1) / h$. This setup isolates the exact sensitivity of the output to each separate input knob.
```mermaid flowchart LR accTitle: Numerical Gradient Perturbation Flow accDescr: Diagram showing how nudging inputs a, b, or c by h affects output d to calculate partial derivatives.
subgraph Inputs["Inputs"] A["a = 2.0 (+h)"] B["b = -3.0 (+h)"] C["c = 10.0 (+h)"] end
subgraph Compute["Forward Computation"] M["a * b"] D["d = (a * b) + c"] end
subgraph Sensitivities["Partial Derivatives (Slopes)"] DA["∂d/∂a = -3.0"] DB["∂d/∂b = +2.0"] DC["∂d/∂c = +1.0"] end
A --> M B --> M M --> D C --> D
D -.-> DA D -.-> DB D -.-> DC ```
When bumping input $a$ by $h$, $d_2$ drops from $4.0$ to approximately $3.9996$, resulting in a negative slope of $-3.0$. Karpathy explains the underlying intuition: because $a$ is multiplied by $b$, and $b$ is negative ($-3.0$), making $a$ more positive actually subtracts from the total value of $d$. Analytically, $\frac{\partial d}{\partial a} = b = -3.0$. Repeating the experiment for $b$ by bumping it positively produces a positive slope of $+2.0$; because $a$ is positive ($+2.0$), increasing $b$ scales up the product, matching $\frac{\partial d}{\partial b} = a = 2.0$. Finally, bumping $c$ increases the output dollar-for-dollar because $c$ is purely additive: $a \cdot b$ remains untouched, and adding $h$ to $c$ translates directly into an identical increase in $d$, producing a slope of exactly $1.0$ ($\frac{\partial d}{\partial c} = 1.0$). Summarizing this core lesson, Karpathy states: “Okay so we now have some intuitive sense of what this derivative is telling you about the function and we'd like to move to neural networks.”
- Inputs: $a = 2.0$, $b = -3.0$, $c = 10.0 \implies d_1 = 2 \cdot (-3) + 10 = 4.0$. - Bumping $a$: $d_2 \approx 3.9997 \implies \text{slope} = \frac{d_2 - d_1}{h} = -3.0$ (matches $\frac{\partial d}{\partial a} = b$). - Bumping $b$: $d_2 \approx 4.0002 \implies \text{slope} = \frac{d_2 - d_1}{h} = +2.0$ (matches $\frac{\partial d}{\partial b} = a$). - Bumping $c$: $d_2 = 4.0001 \implies \text{slope} = \frac{d_2 - d_1}{h} = +1.0$ (matches $\frac{\partial d}{\partial c} = 1.0$).
---
[19:09](https://www.youtube.com/watch?v=VMj-3S1tku0&t=1149s) Building the Core Value Object for Micrograd
Transitioning from simple three-variable scripts toward full neural networks requires managing sprawling computational graphs that chain together thousands of operations. To handle this without manual tracking, Karpathy begins building the core engine of micrograd around a dedicated data structure: the `Value` class. He writes out the initial skeleton in Python, designed to wrap a single scalar floating-point number and maintain its state through mathematical transformations.
To give the object human-readable inspectability, Karpathy implements the Python `__repr__` method, which formats the internal state as `Value(data=...)` instead of returning a generic memory address. Initializing `a = Value(2.0)` cleanly prints `Value(data=2.0)`. However, the moment one attempts to combine two `Value` instances using normal Python operators—such as writing `a + b`—Python raises a `TypeError` because it does not natively understand how to perform arithmetic on custom classes. Karpathy sets the stage for operator overloading, showing that special dunder methods like `__add__` must be implemented to teach Python how to propagate values forward and lay the groundwork for backpropagation.
- Neural networks require explicit data structures to track mathematical expression graphs across many layers of computation. - The `Value` class wraps a primitive scalar float stored in its `.data` attribute. - The `__repr__` method provides readable terminal representations (e.g., `Value(data=2.0)`). - Standard arithmetic expressions like `a + b` fail out of the box, requiring operator overloading through Python dunder methods such as `__add__`.
[20:26](https://www.youtube.com/watch?v=VMj-3S1tku0&t=1226s) Overloading Arithmetic Operators in the Value Object
To make expressions with custom objects feel like native Python math, special double-underscore methods must be implemented on the `Value` class. When writing an expression like `a + b`, Python translates that internally into `a.__add__(b)`. Here, `self` represents `a`, and the argument passed in as `other` represents `b`. Inside `__add__`, a new `Value` instance is returned whose underlying `.data` wraps the regular floating-point addition of `self.data` and `other.data`:
```python def __add__(self, other): return Value(self.data + other.data) ```
The operation `self.data + other.data` uses standard Python floating-point addition, not recursive calls on `Value` objects. With `a = Value(2.0)` and `b = Value(-3.0)`, evaluating `a + b` outputs a new `Value` containing `-1.0`.
Implementing multiplication follows the exact same pattern through `__mul__`:
```python def __mul__(self, other): return Value(self.data * other.data) ```
Adding a third node `c = Value(10.0)` allows chaining operations into compound expressions like `a * b + c`. Under the hood, this evaluates sequentially as `(a.__mul__(b)).__add__(c)`. Evaluating `a * b` produces `Value(-6.0)`, and adding `c` results in `Value(4.0)`. Karpathy points out the utility of providing a clean `__repr__` method on the class; without it, printing an object produces cryptic default Python memory addresses rather than clean human-readable text like `Value(data=4.0)`.
- `a = Value(2.0)` and `b = Value(-3.0)` yields `a + b = Value(-1.0)`. - `a * b` yields `Value(-6.0)`. - Adding `c = Value(10.0)` gives `(a * b) + c = Value(4.0)`.
[22:45](https://www.youtube.com/watch?v=VMj-3S1tku0&t=1365s) Tracking the Computational Graph: Children and Operations
Evaluating values alone is insufficient for backpropagation; the connective tissue of the expression graph must be retained. “As I mentioned, we want to keep these expression graphs, so we need to know and keep pointers about what values produce what other values,” Karpathy notes. To establish these backward references, the `Value` constructor accepts an optional `_children` parameter, which defaults to an empty tuple `()`:
```python class Value: def __init__(self, data, _children=(), _op=''): self.data = data self._prev = set(_children) self._op = _op ```
Karpathy admits he cannot recall the exact historical reason from micrograd's original commit—"I believe it was efficiency"—for accepting a tuple in `_children` but storing it as a `set` under `self._prev`. When a node is created directly as a leaf, `_children` remains empty and `self._prev` is an empty set. When a node is created via an operator like `__add__` or `__mul__`, both inputs `(self, other)` are passed into `_children`, and a string identifying the operation (`'+'` or `'*'`) is recorded in `self._op`:
```python def __add__(self, other): out = Value(self.data + other.data, (self, other), '+') return out
def __mul__(self, other): out = Value(self.data * other.data, (self, other), '*') return out ```
Inspecting `d._prev` on `d = a * b + c` reveals a set containing `Value(-6.0)` (the intermediate product of `a * b`) and `Value(10.0)` (the node `c`). Querying `d._op` yields `'+'`. Every intermediate value now retains full lineage information: exactly which inputs generated it and which mathematical operation combined them.
```mermaid flowchart LR accTitle: Micrograd Expression Graph Architecture accDescr: Computational graph tracing inputs a, b, and c through multiplication and addition to output d.
subgraph Inputs["Leaf Nodes"] A["a: Value(2.0)"] B["b: Value(-3.0)"] C["c: Value(10.0)"] end
subgraph Operations["Graph Operations"] MulOp{"*"} E["e: Value(-6.0)"] AddOp{"+"} D["d: Value(4.0)"] end
A --> MulOp B --> MulOp MulOp --> E E --> AddOp C --> AddOp AddOp --> D ```
[24:54](https://www.youtube.com/watch?v=VMj-3S1tku0&t=1494s) Visualizing Expression Graphs with Graphviz
As expressions expand, inspecting raw sets and attributes in the terminal becomes tedious. Karpathy introduces a helper routine called `draw_dot` built on top of Graphviz, an open-source graph layout library. Karpathy pastes in a helper script that traverses the structure using a recursive `trace` function to collect all unique nodes and edges in the computation graph.
```python def trace(root): nodes, edges = set(), set() def build(v): if v not in nodes: nodes.add(v) for child in v._prev: edges.add((child, v)) build(child) build(root) return nodes, edges ```
Inside `draw_dot`, rectangular record nodes represent each actual `Value` object. To make the diagram intuitive, the visualizer also synthesizes explicit circular "operation nodes" (such as a node showing `+` or `*`) sitting between parent and child values. These synthetic operation nodes do not exist as `Value` instances in Python memory; they are injected purely for visual clarity so the flow from arguments to operator to result is unmistakable.
To label graph nodes cleanly (for example, showing `a`, `b`, `c`, `d`), an optional `label` string attribute is added to the `Value` constructor:
```python class Value: def __init__(self, data, _children=(), _op='', label=''): self.data = data self._prev = set(_children) self._op = _op self.label = label ```
Karpathy rewrites the expression explicitly assigning variable names: - `a = Value(2.0, label='a')` - `b = Value(-3.0, label='b')` - `c = Value(10.0, label='c')` - `e = a * b; e.label = 'e'` - `d = e + c; d.label = 'd'`
Calling `draw_dot(d)` renders a layout displaying `a` and `b` feeding into `*` to produce `e`, which then feeds alongside `c` into `+` to produce `d`.
[28:10](https://www.youtube.com/watch?v=VMj-3S1tku0&t=1690s) Deepening the Graph and Introducing the Gradient Field
To prepare for manual backpropagation, the expression graph is extended by one additional level. Karpathy creates a new leaf node `f = Value(-2.0, label='f')`, and computes final output `L = d * f`, setting `L.label = 'L'`. Because `d` equals `4.0` and `f` equals `-2.0`, `L` evaluates to `-8.0`.
This completes the forward pass of the mathematical expression: - Leaves: `a = 2.0`, `b = -3.0`, `c = 10.0`, `f = -2.0` - Intermediates: `e = a * b = -6.0`, `d = e + c = 4.0` - Root output: `L = d * f = -8.0`
Karpathy explains the shift from the forward pass to backpropagation: “Now what we'd like to do next is we'd like to run backpropagation, and in backpropagation we are going to start here at the end and we're going to reverse and calculate the gradient along all these intermediate values.” For every node in the graph, backpropagation computes the derivative of the scalar output $L$ with respect to that node:
$$\frac{\partial L}{\partial v}$$
In deep learning, this scalar $L$ represents the loss function, and the graph inputs correspond to either neural network weights or training data. Karpathy clarifies: “And here of course we have just these variables a, b, c, and f, but some of these will eventually represent the weights of a neural net, and so we'll need to know how those weights are impacting the loss function... the data is fixed, but the weights will be iterated on using the gradient information.”
To store these partial derivatives during the backward pass, every `Value` object requires a new internal property: `self.grad`. Initialized to `0.0`, this value indicates that prior to computing derivatives, each node is assumed to exert zero impact on the final output $L$.
[31:16](https://www.youtube.com/watch?v=VMj-3S1tku0&t=1876s) Setting Up Grad and the Base Case Derivative (dL/dL)
To track derivatives throughout the computation graph, Andrej Karpathy adds a `grad` attribute to the `Value` class, initialized by default to `0.0`. A gradient of zero carries an intuitive operational meaning: changing that variable has no effect on the output or loss function. In the graph visualization, every node now displays both its value (`data`) and its associated gradient (`grad: %.4f`). Karpathy emphasizes what this variable represents before beginning manual backpropagation: “and of course this grad again as I mentioned is representing the derivative of the output in this case L with respect to this value”. The goal is to traverse backward from the final scalar output $L$ all the way to the original inputs, populating each node’s gradient step by step.
```mermaid flowchart LR accTitle: Computation Graph Subgraph accDescr: Visual diagram showing the tail of the arithmetic expression where inputs c and e sum to d, and d multiplies with f to produce L. subgraph Addition ["Addition Node"] C["c"] --> Plus["+"] E["e"] --> Plus Plus --> D["d"] end subgraph Output ["Multiplication Node"] D --> Mult["*"] F["f"] --> Mult Mult --> L["L"] end ```
The process begins at the output node itself: computing $\frac{dL}{dL}$. Intuitively, wiggling $L$ by a tiny quantity $h$ causes $L$ to change by exactly $h$; the ratio of rise over run is $\frac{h}{h} = 1$, giving a derivative of $1.0$. To verify this and every subsequent step without corrupting the notebook's workspace, Karpathy creates a throwaway staging function whimsically named `lol()`:
```python def lol(): h = 0.001
a = Value(2.0, label='a') b = Value(-3.0, label='b') c = Value(10.0, label='c') e = a * b; e.label = 'e' d = e + c; d.label = 'd' f = Value(-2.0, label='f') L = d * f; L.label = 'L' L1 = L.data
# bump variable by h to compute numerical derivative ... ```
Because variables inside `lol()` remain strictly local, Karpathy can safely perturb any parameter by $h$, re-evaluate the forward pass to obtain `L2.data`, and print `(L2.data - L1.data) / h`. Perturbing $L$ yields $1.0$, which Karpathy calls “kind of like the base case of what we are doing here.” With the base case established, he explicitly sets `L.grad = 1.0` and redraws the graph to show the output initialized.
[34:39](https://www.youtube.com/watch?v=VMj-3S1tku0&t=2079s) Differentiating Through Multiplication (dL/dd and dL/df)
Moving one step backward along the graph, Karpathy turns to the multiplication that produced $L$: the relation $L = d \cdot f$. He sets out to determine the partial derivatives $\frac{\partial L}{\partial d}$ and $\frac{\partial L}{\partial f}$. From basic calculus, differentiating a product with respect to one variable leaves the other variable. To prove this from first principles rather than relying on rote memory, Karpathy writes out the limit definition of the derivative:
$$\lim_{h \to 0} \frac{f(x + h) - f(x)}{h}$$
Applying this formula to $d$:
$$\frac{(d + h)f - df}{h} = \frac{df + hf - df}{h} = \frac{hf}{h} = f$$
Symmetrically, perturbing $f$ yields $\frac{\partial L}{\partial f} = d$. Substituting the numerical values from the graph where $d = 4.0$ and $f = -2.0$: - `f.grad` becomes the value of $d$, which is `4.0`. - `d.grad` becomes the value of $f$, which is `-2.0`.
Karpathy manually sets `f.grad = 4.0` and `d.grad = -2.0`, updates the graph visualization, and immediately performs an inline numerical gradient check. Inside the `lol()` function, he adds `h` to `f.data` and runs the numerical difference quotient, confirming that the output prints `4.0` (modulo slight floating-point imprecision). Repeating the test by bumping `d.data` by `h` confirms the result prints `-2.0`. Karpathy notes that pairing analytical derivations with small finite-difference checks like this gives total confidence that the manual backpropagation logic is exact.
[38:06](https://www.youtube.com/watch?v=VMj-3S1tku0&t=2286s) The Crux of Backprop: Local Derivatives and the Chain Rule
With the gradients at $d$ and $f$ settled, Karpathy arrives at the inputs to $d$, where $d = c + e$. He flags this moment as the turning point of the lecture: “so this will be the most important node to understand because if you understand the gradient for this node you understand all of back propagation and all of training of neural nets basically.” The challenge is solving for $\frac{\partial L}{\partial c}$ and $\frac{\partial L}{\partial e}$. While it is already known how sensitive $L$ is to $d$ ($\frac{\partial L}{\partial d} = -2.0$), the question is how a change in $c$ propagates through $d$ to ultimately impact $L$.
Karpathy first isolates the local operation: - The node computes an addition: $d = c + e$. - Differentiating $d$ with respect to $c$ using the limit definition yields $\frac{(c + h + e) - (c + e)}{h} = \frac{h}{h} = 1.0$. - By exact symmetry, $\frac{\partial d}{\partial e} = 1.0$.
Karpathy clarifies the distinction between local and global scope: the addition node knows nothing about the broader network, whether it is sitting next to the loss or embedded inside an enormous graph of millions of parameters. All the node knows locally is its inputs ($c$ and $e$) and that its local derivative with respect to each input is $1.0$.
To link this local rate of change with the global sensitivity of $L$, Karpathy pulls up the Wikipedia page on the chain rule. He admits that textbook calculus notation often overcomplicates the concept, contrasting standard textbook presentations with an intuitive formulation:
> “If a variable $z$ depends on a variable $y$ which itself depends on the variable $x$, then $z$ depends on $x$ as well obviously through the intermediate variable $y$. In this case the chain rule is expressed as: if you want $\frac{dz}{dx}$, then you take the $\frac{dz}{dy}$ and you multiply it by $\frac{dy}{dx}$.”
Fundamentally, backpropagation across function composition is simply multiplying the upstream gradient arriving at a node's output by the node's local derivative with respect to its inputs.
[43:07](https://www.youtube.com/watch?v=VMj-3S1tku0&t=2587s) Intuition for the Chain Rule and Addition Nodes
To build an intuitive foundation for the chain rule, Karpathy shares a classic physical analogy of compounding rates of change: if a car travels twice as fast as a bicycle, and that bicycle moves four times as fast as a walking person, then the car necessarily travels $2 \times 4 = 8$ times as fast as the walking person. This rate composition shows that multiplying intermediate rates of change is the mathematically correct operation. Applying this directly to the computation graph, Karpathy states:
> “And so this makes it very clear that the correct thing to do sort of is to multiply.”
In the graph under evaluation, the objective is to determine $\frac{\partial L}{\partial c}$. The downstream derivative $\frac{\partial L}{\partial d} = -2.0$ has already been computed. Because node $d$ is produced via an addition operation ($d = c + e$), the local rate of change $\frac{\partial d}{\partial c}$ is simply $1.0$. Following the chain rule, $\frac{\partial L}{\partial c} = \frac{\partial L}{\partial d} \cdot \frac{\partial d}{\partial c} = -2.0 \times 1.0 = -2.0$. By identical symmetry, the local derivative $\frac{\partial d}{\partial e}$ is also $1.0$, which yields $\frac{\partial L}{\partial e} = -2.0$. Karpathy observes that addition operations act conceptually as "gradient routers" because multiplying by $1.0$ simply routes the incoming gradient unchanged to all of the operation's children nodes.
Karpathy assigns `c.grad = -2.0` and `e.grad = -2.0`, redraws the graph visualization, and numerically validates the calculus using finite differences. By bumping `c.data` by a small step $h$ and re-evaluating the loss function, the slope $(L_2 - L_1)/h$ returns exactly $-2.0$. Repeating this perturbation on `e.data` yields the exact same $-2.0$, confirming that both internal gradients are correct.
```mermaid flowchart LR accTitle: Addition Node Gradient Flow accDescr: Incoming gradient of -2.0 routes through the addition node to inputs c and e. subgraph AdditionRouting ["Addition Node Gradient Routing"] L["L"] -->|"dL/dd = -2.0"| D["d = c + e"] D -->|"dd/dc = 1.0 -> dL/dc = -2.0"| C["c"] D -->|"dd/de = 1.0 -> dL/de = -2.0"| E["e"] end ```
[47:11](https://www.youtube.com/watch?v=VMj-3S1tku0&t=2831s) Applying Chain Rule to Multiplication Nodes
The backward walk proceeds to the remaining input leaf nodes $a$ and $b$, which feed into intermediate node $e$ via multiplication ($e = a \times b$). With the upstream gradient $\frac{\partial L}{\partial e} = -2.0$ established, the chain rule requires scaling it by the local derivatives of the multiplier node. Karpathy imagines the perspective of that specific gate in a massive network:
> “So I'm a little times node inside a massive graph, and I only know that I did $a \times b$ and I produced an $e$.”
Differentiating $e$ with respect to $a$ yields the value of the other input, $b$ (which is $-3.0$). Similarly, differentiating with respect to $b$ gives $a$ (which is $2.0$). Therefore: - $\frac{\partial L}{\partial a} = \frac{\partial L}{\partial e} \cdot \frac{\partial e}{\partial a} = -2.0 \times -3.0 = 6.0$ - $\frac{\partial L}{\partial b} = \frac{\partial L}{\partial e} \cdot \frac{\partial e}{\partial b} = -2.0 \times 2.0 = -4.0$
Assigning `a.grad = 6.0` and `b.grad = -4.0`, Karpathy runs numerical gradient checks by individually perturbing `a.data` and `b.data` by $h$. The slope for $a$ evaluates to $6.0$, and the slope for $b$ evaluates to $-4.0$ (modulo floating-point rounding quirks).
Karpathy synthesizes the entire manual walkthrough into a unified principle:
> “And that's what backpropagation is, is just a recursive application of chain rule backwards through the computation graph.”
[51:10](https://www.youtube.com/watch?v=VMj-3S1tku0&t=3070s) Previewing Optimization via Gradient Ascent
Having calculated the gradients of every leaf node ($a, b, c, f$), Karpathy demonstrates how these numbers are actually put to work. To drive the final value $L$ upward (making it less negative than its current value of $-8.0$), each leaf input is nudged in the direction of its positive gradient by a small step size:
```python step_size = 0.01 a.data += step_size * a.grad b.data += step_size * b.grad c.data += step_size * c.grad f.data += step_size * f.grad ```
After updating these leaf nodes, he re-runs the forward pass. Because all leaf values were pushed along the gradient, the output $L$ increases from $-8.0$ up to roughly $-7.15$. Karpathy notes:
> “And really, this gradient just gives us some power because we know how to influence the final outcome, and this will be extremely useful for training neural networks as well as you'll see.”
- Starting value of $L$: $-8.0$ - Step size for nudge: $0.01$ - Expected direction: more positive (less negative) - New forward pass value of $L$: approximately $-7.15$
[52:52](https://www.youtube.com/watch?v=VMj-3S1tku0&t=3172s) Mathematical Model of a Biological Neuron and Tanh Activation
Moving beyond toy expressions, Karpathy introduces a biologically inspired machine learning use case: backpropagating through an individual artificial neuron within a multi-layer perceptron (MLP). Biological neurons are enormously complex, but classical connectionist models simplify them into an algebraic abstraction:
1. Inputs ($x_i$) arrive along axons and are scaled multiplicatively by synaptic weights ($w_i$). 2. The scaled inputs are summed inside the cell body along with an innate bias term ($b$), which sets the baseline "trigger-happiness" of the cell regardless of inputs: $\sum_i w_i x_i + b$. 3. The aggregated sum passes through an activation function—typically a smooth squashing function such as a sigmoid or hyperbolic tangent ($\tanh$).
Using NumPy (`np.tanh`), Karpathy plots the curve across a numerical range to illustrate how $\tanh$ squashes any input on the real line $(-\infty, \infty)$ smoothly into the bounded output range $[-1.0, 1.0]$. An input of $0$ produces an activation of $0$; inputs that grow increasingly positive plateau asymptotically at $1.0$, while strongly negative inputs plateau asymptotically at $-1.0$.
```mermaid flowchart LR accTitle: Artificial Neuron Computational Flow accDescr: Inputs are scaled by synaptic weights, summed with a bias, and squashed by tanh. subgraph Inputs ["Inputs & Weights"] X1["x1"] --> M1["*"] W1["w1"] --> M1 X2["x2"] --> M2["*"] W2["w2"] --> M2 end subgraph CellBody ["Cell Body Aggregation"] M1 --> S["+ (Sum)"] M2 --> S B["b (bias)"] --> S end subgraph Activation ["Squashing Function"] S --> T["tanh"] T --> O["Output (capped between -1 and 1)"] end ```
[55:04](https://www.youtube.com/watch?v=VMj-3S1tku0&t=3304s) Building a Two-Dimensional Neuron Graph
Karpathy reviews the basic biological and computational model of a neuron: inputs are weighted by synaptic strengths, summed together with a bias term, and passed through an activation (or "squashing") function such as $\tanh$. He sets up a concrete two-dimensional neuron in Python, defining two inputs ($x_1, x_2$), two weights ($w_1, w_2$), and a bias $b$. To make inspecting and visualizing the intermediate operations easier, he constructs the expression in explicit steps rather than a single compound line:
- $x_1 w_1 = x_1 \cdot w_1$ - $x_2 w_2 = x_2 \cdot w_2$ - $x_1 w_1 + x_2 w_2 = (x_1 w_1) + (x_2 w_2)$ - $n = (x_1 w_1 + x_2 w_2) + b$
Here, $n$ represents the raw cell body activation before the non-linearity is applied. Calling `draw_dot(n)` renders this computation graph cleanly. To produce the final neuron output $o$, Karpathy wants to write `o = n.tanh()`, but notes that the custom `Value` class does not yet support the hyperbolic tangent.
```mermaid flowchart LR accTitle: Two-Dimensional Neuron Forward Graph accDescr: Computational graph of a 2D neuron showing inputs, weights, dot product additions, bias addition, and tanh activation.
subgraph InputsAndWeights ["Inputs & Weights"] X1["x1 (2.0)"] W1["w1 (-3.0)"] X2["x2 (0.0)"] W2["w2 (1.0)"] end
subgraph Mults ["Multiplications"] M1["* (x1*w1)"] M2["* (x2*w2)"] end
subgraph Summation ["Cell Body Summation"] A1["+ (x1w1 + x2w2)"] B["b (bias)"] A2["+ (n)"] end
subgraph Activation ["Squashing"] T["tanh"] O["o (output)"] end
X1 --> M1 W1 --> M1 X2 --> M2 W2 --> M2
M1 --> A1 M2 --> A1
A1 --> A2 B --> A2
A2 --> T T --> O ```
[57:08](https://www.youtube.com/watch?v=VMj-3S1tku0&t=3428s) Implementing tanh as an Arbitrary Abstraction Node
Because $\tanh(x) = \frac{e^{2x} - 1}{e^{2x} + 1}$, evaluating it requires exponentiation alongside addition and division. While one could implement atomic operations like `exp()` and build $\tanh$ from scratch, Karpathy highlights an essential conceptual lesson about automatic differentiation:
> “The only thing that matters it can be arbitrarily complex function as long as you know how to create the local derivative if you know the local derivative of how the inputs impact the output then that's all you need.”
Operations can be wrapped at any level of abstraction. Rather than decomposing $\tanh$ into low-level primitives right away, he implements `tanh` directly as a single method on the `Value` class:
```python def tanh(self): x = self.data t = (math.exp(2 * x) - 1) / (math.exp(2 * x) + 1) out = Value(t, (self,), 'tanh') return out ```
The resulting `out` node stores the output value $t$, records `(self,)` as its single-element tuple of children, and sets its operation label to `'tanh'`.
[01:00:18](https://www.youtube.com/watch?v=VMj-3S1tku0&t=3618s) Graph Visualization and Setting Up Friendly Numbers
With `tanh` added to `Value`, calling `o = n.tanh()` completes the neuron graph, and `draw_dot(o)` renders the full forward pipeline. To demonstrate the squashing behavior, Karpathy points out that if the bias $b$ were set high (for example, $8$), the input entering $\tanh$ would reach around $2$, pushing the output to approximately $0.96$ out on the asymptotic tail where it plateaus toward $1$.
To keep the upcoming manual backpropagation math readable, Karpathy sets the bias to a carefully tuned value:
- $b = 6.8813735870195432$
With this bias, the intermediate values evaluate cleanly: - $n \approx 0.8813736$ - $o = \tanh(n) \approx 0.70710678$ ($1 / \sqrt{2}$)
He adds descriptive labels to every node (`x1`, `w1`, `x2`, `w2`, `x1*w1`, `x2*w2`, `x1*w1 + x2*w2`, `b`, `n`, and `o`). In a full neural network, thousands of such neurons are chained together toward an ultimate scalar loss measuring task performance. Here, backpropagation will evaluate $\frac{\partial o}{\partial v}$ for each variable $v$, showing how each weight must shift to alter the neuron's output.
[01:02:21](https://www.youtube.com/watch?v=VMj-3S1tku0&t=3741s) Backpropagation Through tanh and Addition
Karpathy begins manual backpropagation from the end of the graph:
1. **Base Case:** The derivative of the output with respect to itself is always $1$: $$\frac{\partial o}{\partial o} = 1.0 \implies \texttt{o.grad} = 1.0$$
2. **Differentiating $\tanh$:** By the standard calculus identity, $$\frac{d}{dx}(\tanh(x)) = 1 - \tanh^2(x)$$ Because $o = \tanh(n)$, the local derivative is simply $1 - o^2$: $$\frac{\partial o}{\partial n} = 1 - (\texttt{o.data})^2 = 1 - (0.70710678)^2 \approx 1 - 0.5 = 0.5$$ Applying the chain rule: $$\texttt{n.grad} = \frac{\partial o}{\partial o} \cdot \frac{\partial o}{\partial n} = 1.0 \cdot 0.5 = 0.5$$
3. **Propagating Through Addition:** > “Pluses are my favorite operations to back propagate through because it's very simple.”
Because addition has a local derivative of $1$ with respect to each input, a `+` node acts as a pure gradient distributor. The upstream gradient of $0.5$ flows unaltered across the sums: - $\texttt{b.grad} = 0.5$ - $\texttt{(x1w1 + x2w2).grad} = 0.5$ - $\texttt{x1w1.grad} = 0.5$ - $\texttt{x2w2.grad} = 0.5$
A positive gradient of $0.5$ indicates that nudging any of these intermediate nodes upward will increase the final output $o$.
[01:06:20](https://www.youtube.com/watch?v=VMj-3S1tku0&t=3980s) Initiating Backpropagation Through Multiplications
Moving backward from `x2w2` toward individual inputs and weights, the graph encounters a multiplication node: $x_2 w_2 = x_2 \cdot w_2$. Karpathy pauses to prompt the viewer to work out the local derivative of a product. By the product rule, the local derivative with respect to one operand is the data value of the other:
$$\frac{\partial (x_2 w_2)}{\partial x_2} = w_2$$
Applying the chain rule to obtain $\frac{\partial o}{\partial x_2}$:
$$\texttt{x2.grad} = \texttt{w2.data} \cdot \texttt{x2w2.grad}$$
[01:06:51](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4011s) Completing Manual Backpropagation Through the Neuron Inputs
Continuing the manual backpropagation on the two-input neuron, Andrej Karpathy traces the chain rule into the input products $x_2 w_2$ and $x_1 w_1$. Having already established that the upstream gradient flowing into $x_2 w_2$ is $0.5$, he applies the local derivative of multiplication to find the gradients for both weight $w_2$ and input $x_2$. By the product rule, the local derivative of $x_2 w_2$ with respect to $w_2$ is $x_2$, yielding $w_2.\text{grad} = x_2.\text{data} \times x_2 w_2.\text{grad}$. Because $x_2.\text{data}$ equals $0$, $w_2.\text{grad}$ evaluates to $0$. Conversely, $x_2.\text{grad} = w_2.\text{data} \times x_2 w_2.\text{grad} = 1.0 \times 0.5 = 0.5$.
Karpathy pauses to emphasize the physical intuition behind this zero gradient. A derivative quantifies the sensitivity of the final output with respect to a change in an input. “If I wiggle $w_2$, how is the output changing?” he asks. “It's not changing because we're multiplying by zero.” Because the signal is being multiplied by zero, it is completely squashed, meaning perturbations to $w_2$ have no bearing on the neuron's eventual output at this operating point.
Turning to the first branch, $x_1 w_1$, the upstream gradient is likewise $0.5$. The local derivative with respect to $x_1$ is $w_1$ (which is $-3.0$), making $x_1.\text{grad} = -3.0 \times 0.5 = -1.5$. The local derivative with respect to $w_1$ is $x_1$ (which is $2.0$), yielding $w_1.\text{grad} = 2.0 \times 0.5 = 1.0$. Looking at the full computational graph, Karpathy summarizes the actionable takeaway for optimization: if we wish to increase this neuron's output activation, $w_2$ does not matter at all right now, but increasing weight $w_1$ will directly increase the output with a positive proportionality factor of $1.0$.
- $w_2.\text{grad} = 0 \times 0.5 = 0.0$ - $x_2.\text{grad} = 1.0 \times 0.5 = 0.5$ - $x_1.\text{grad} = -3.0 \times 0.5 = -1.5$ - $w_1.\text{grad} = 2.0 \times 0.5 = 1.0$
[01:09:02](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4142s) Automating Local Derivatives: Storing `_backward` Closures in `Value`
Having worked through every addition and multiplication by hand, Karpathy declares that “doing the back propagation manually is obviously ridiculous so we are now going to put an end to this suffering and we're going to see how we can implement the backward pass a bit more automatically.” Rather than calculating and assigning gradients outside the class, each `Value` instance should internally know how to propagate gradients locally across the specific operation that produced it.
To achieve this, he augments the `Value` class with an internal attribute named `_backward`. On class initialization, `self._backward` defaults to an empty function that does nothing, representing the base case for leaf nodes such as input features or weights where there are no upstream dependencies to propagate into. For composite nodes created via operators, `_backward` is defined as a nested closure inside the operator method and bound to the resulting output `Value`.
Inside `__add__`, the addition of `self` and `other` produces `out`. The local derivative of an addition with respect to either input is always $1.0$. Karpathy defines the inner function `_backward()` to assign `self.grad = 1.0 * out.grad` and `other.grad = 1.0 * out.grad`, directly replicating the gradient-copying behavior seen during manual passes. For `__mul__`, the local derivative with respect to `self` is `other.data`, and with respect to `other` is `self.data`. Its closure sets `self.grad = other.data * out.grad` and `other.grad = self.data * out.grad`. For `tanh`, where the output scalar $t = \tanh(x)$ is already stored, the derivative $1 - t^2$ is scaled by `out.grad` and assigned to `self.grad`: `self.grad = (1.0 - t**2) * out.grad`.
```mermaid flowchart LR accTitle: Local backward closures in Value accDescr: Diagram illustrating how output gradients are chained into input gradients inside operator closures. subgraph Addition["out = self + other"] direction TB A1["out.grad"] -->|"1.0 * out.grad"| B1["self.grad"] A1 -->|"1.0 * out.grad"| C1["other.grad"] end subgraph Multiplication["out = self * other"] direction TB A2["out.grad"] -->|"other.data * out.grad"| B2["self.grad"] A2 -->|"self.data * out.grad"| C2["other.grad"] end subgraph Activation["out = self.tanh()"] direction TB A3["out.grad"] -->|"(1 - out.data^2) * out.grad"| B3["self.grad"] end ```
[01:14:05](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4445s) Calling `_backward` Along the Chain and Debugging a Callable Error
With the closures written, Karpathy re-evaluates the mathematical expression defining the neuron and checks the visualization to confirm all gradients start cleanly at $0.0$. To run backpropagation using the newly created machinery, the process must start at the output node `o`. Because every `_backward` implementation multiplies the local derivative by `out.grad`, leaving `o.grad` at its initial zero would zero out all preceding calculations. Thus, as the mandatory base case, he explicitly sets `o.grad = 1.0`.
When he first invokes `o._backward()`, Python throws a `TypeError: 'NoneType' object is not callable`. Karpathy laughs, remarking, “so I thought about redoing it but I figured I should just leave the error in here because it's pretty funny.” The bug happened in the operator definitions where he wrote `out._backward = _backward()` instead of `out._backward = _backward`. Calling the closure immediately at definition time evaluated it, returned `None`, and bound `None` to the attribute. After correcting the assignment to store the function reference itself, he reruns the cell and verifies that `o._backward()` correctly computes $n.\text{grad} = 0.5$.
From there, he calls the `_backward` methods sequentially up the computational graph: 1. `o._backward()` pushes the gradient through $\tanh$ to populate $n.\text{grad} = 0.5$. 2. `n._backward()` distributes the $0.5$ across the addition to bias $b$ and intermediate node $(x_1 w_1 + x_2 w_2)$. 3. While calling `b._backward()` is valid, it does nothing because $b$ is a leaf node initialized with an empty lambda. 4. Calling `_backward()` on $(x_1 w_1 + x_2 w_2)$ routes the $0.5$ gradient into the two separate multiplication nodes, $x_1 w_1$ and $x_2 w_2$. 5. Calling `_backward()` on $x_2 w_2$ produces $w_2.\text{grad} = 0.0$ and $x_2.\text{grad} = 0.5$. 6. Calling `_backward()` on $x_1 w_1$ produces $x_1.\text{grad} = -1.5$ and $w_1.\text{grad} = 1.0$.
The resulting graph matches the numbers derived during manual backpropagation down to the last decimal place, proving that the encapsulated closures work as intended.
[01:17:32](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4652s) Graph Ordering: Introducing Topological Sort for Full Backpropagation
Although automating the local chain rule logic removes the burden of writing calculus equations by hand, manually orchestrating the exact sequence of individual `._backward()` function calls remains tedious and error-prone. Karpathy explains that to completely automate the backward pass into a single `o.backward()` call, the engine needs an algorithm to determine the precise order in which nodes must be evaluated.
The fundamental rule of backpropagation through a computation graph is dependency resolution: a node can only process its incoming gradients and backpropagate them to its parents after every node that depends on it has already finished propagating its gradients. If a node is called before all its downstream consumers have contributed their gradients, the calculation will be incomplete and incorrect.
To establish this ordering systematically, Karpathy introduces the concept of a Directed Acyclic Graph (DAG) and topological sorting. A topological sort creates a linear layout of graph nodes such that every directed edge points strictly in one direction—conventionally from left to right. When traveling in reverse along this topological ordering (from right to left), each node is guaranteed to be visited only after all downstream nodes have already executed, providing the exact execution schedule required for automatic backpropagation.
[01:18:46](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4726s) Implementing the Full Backward Method with Topological Sort
To automate backpropagation across an entire computation graph rather than calling backward functions by hand, Karpathy explains how a topological sort is constructed. Starting at the output node `o` (which acts as the root for the traversal), the algorithm visits each child recursively. It maintains a set of visited nodes so that cycles or duplicate paths are handled correctly. Crucially, a node only appends itself to the ordering list after all of its children have finished processing. Karpathy emphasizes the guarantee this provides: “this node that we're going to call it on like say o is only going to add itself to the topo list after all of the children have been processed and that's how this function is guaranteeing that you're only going to be in the list once all your children are in the list and that's the invariant that is being maintained.” When evaluated on the output node `o`, the list ends with `o` (value $0.707$) preceded by `n`, with all upstream input nodes laid out to the left.
```mermaid flowchart LR accTitle: Reverse Topological Backpropagation Flow accDescr: Diagram showing graph traversal ordered from inputs to output, then traversed in reverse order during backward pass.
subgraph ForwardOrder["Topological Graph Layout"] Inputs["Inputs & Weights<br/>x1, w1, x2, w2, b"] --> Intermediates["Intermediates<br/>x1w1, x2w2, n"] Intermediates --> Root["Root / Output<br/>o = tanh(n)"] end
subgraph BackwardOrder["Reversed Traversal (.backward())"] direction RL Step1["1. Set o.grad = 1.0"] --> Step2["2. Call o._backward()"] Step2 --> Step3["3. Call n._backward()"] Step3 --> Step4["4. Call children._backward()"] end ```
To execute backpropagation cleanly, Karpathy packages this logic into an official `backward()` method (without a leading underscore) on the `Value` class, encapsulating the entire traversal: 1. `build_topo(self)` is invoked to assemble the ordered list of nodes starting from `self`. 2. The root gradient is seeded: `self.grad = 1.0` (the base case). 3. The list is iterated in reverse (`reversed(topo)`), calling `_backward()` on each node.
Once wrapped inside `Value`, resetting all gradients to zero and invoking `o.backward()` successfully computes all the exact derivatives across the entire single-neuron graph in one clean call: “and that's uh that's back propagation place for one neuron.”
[01:22:28](https://www.youtube.com/watch?v=VMj-3S1tku0&t=4948s) Uncovering and Fixing the Gradient Accumulation Bug
Despite the clean run, Karpathy cautions against premature celebration: “now we shouldn't be too happy with ourselves actually because we have a bad bug um and we have not surfaced the bug because of some specific conditions that we are we have to think about right now.” He demonstrates the failure mode with a minimal toy example:
```python a = Value(3.0) b = a + a b.backward() ```
The forward pass correctly yields $b = 6.0$, but `a.grad` ends up as $1.0$ instead of the mathematically correct $2.0$ (since $\frac{d(a+a)}{da} = 1 + 1 = 2$). In addition, `self` and `other` point to the exact same object `a`. During `b._backward()`, `self.grad` is set to $1.0$, and then `other.grad` sets that same attribute back to $1.0$, completely overwriting the contribution. Karpathy illustrates this with a second, slightly richer expression where $a$ and $b$ branch into both a product $d = a \cdot b$ and a sum $e = a + b$, which then combine into $f = d \cdot e$. When `e._backward()` runs, it writes gradients to $a$ and $b$; next, `d._backward()` runs and overwrites them, obliterating the gradients from the $e$ pathway.
- “basically we're going to see an issue anytime we use a variable more than once” - In previous examples, every variable was only consumed by a single subsequent operation, masking the bug entirely. - By the multivariate chain rule, when a variable splits into multiple pathways, its gradients along each branch must sum together: “the solution there is basically that we have to accumulate these gradients these gradients add and so instead of setting those gradients we can simply do plus equals”. - Changing the assignment `= out.grad *...` to `+= out.grad *...` across all `_backward` closures solves the issue, because all gradients are initialized to zero and simply accumulate every incoming backprop contribution. - Re-running $b = a + a$ after switching to `+=` yields the expected `a.grad = 2.0`.
[01:27:05](https://www.youtube.com/watch?v=VMj-3S1tku0&t=5225s) Deconstructing tanh and Supporting Mixed-Type Operations
After tidying up the notebook and deleting scratch examples, Karpathy turns his attention to the hyperbolic tangent function. While treating $\tanh$ as a monolithic primitive is computationally convenient because its derivative $1 - \tanh^2(x)$ is simple, Karpathy wants to break $\tanh$ down into its constituent atomic operations:
$$\tanh(x) = \frac{e^{2x} - 1}{e^{2x} + 1}$$
Breaking down $\tanh$ into smaller building blocks serves two purposes: it proves that composite decomposition produces the exact same numerical outputs and gradients, and it provides an excuse to implement additional arithmetic operators like exponentiation, division, and subtraction.
However, attempting arithmetic with raw Python numbers immediately highlights a usability limitation: writing `a + 1` crashes with `AttributeError: 'int' object has no attribute 'data'`. In `Value.__add__(self, other)`, Python tries to read `other.data`, but primitive integers and floats do not have that attribute: - To allow seamless expressions with constants, Karpathy updates `__add__` to check whether `other` is an instance of `Value`. If it is not, it is automatically promoted: `other = other if isinstance(other, Value) else Value(other)`. - He applies the exact same logic to `__mul__`, enabling syntax like `a * 2`. - But he immediately flags the next asymmetry: while `a * 2` works smoothly via `Value.__mul__`, the swapped order `2 * a` will fail because Python falls back to `int.__mul__`, which knows nothing about custom `Value` objects: “now here's a kind of unfortunate and not obvious part a times two works we saw that but two times a is that gonna work you'd expect it to right but actually it will not”.
[01:29:22](https://www.youtube.com/watch?v=VMj-3S1tku0&t=5362s) Handling Operand Ordering with `__rmul__` and Adding Exponentiation
When writing an expression like `a * 2`, Python internally routes the call to `a.__mul__(2)`. However, if the operands are reversed to `2 * a`, Python invokes `int.__mul__(2, a)`. Because Python's built-in integer class has no knowledge of how to multiply a `Value` instance, the runtime becomes confused and raises an error. To handle this gracefully, Python provides special fallback methods prefixed with `r`, such as `__rmul__`. When the left-hand operand fails to evaluate the operation, Python checks whether the right-hand operand provides `__rmul__`. Andrej Karpathy implements `__rmul__` on the `Value` class by swapping the operands and delegating back: “basically `2 * a` will redirect to `__rmul__` and `__rmul__` will basically call `a * 2` and that's how that will work.” Once defined, evaluating `2 * a` cleanly returns `4`.
```mermaid flowchart LR accTitle: Python fallback lookup via __rmul__ accDescr: Diagram showing how Python falls back to __rmul__ on the right operand when the left operand does not support multiplying a Value instance. subgraph DirectCall["Direct Call: 2 * a"] Op1["int(2)"] -->|"tries __mul__(a)"| Fail["Fails: int cannot multiply Value"] end subgraph FallbackCall["Fallback via __rmul__"] Fail -->|"checks right operand"| RMul["a.__rmul__(2)"] RMul -->|"redirects internally"| Forward["a * 2 (__mul__)"] end ```
With non-commutative scalar multiplication addressed, Karpathy shifts focus to the missing mathematical functions needed to deconstruct the hyperbolic tangent: exponentiation and division. Exponentiation mirrors the existing scalar `tanh` design by operating on a single scalar input and producing a single scalar output. The forward pass retrieves the underlying float via `self.data`, applies `math.exp(x)`, and packages the result into a fresh `Value` object. To propagate gradients through $e^x$, Karpathy asks viewers to pause and identify the local derivative. Because $\frac{d}{dx} e^x = e^x$, the local derivative is simply the forward evaluation already cached in `out.data`. By applying the chain rule, Karpathy defines the gradient backward step:
$$\frac{\partial L}{\partial x} = \text{out.data} \times \text{out.grad}$$
Karpathy observes: “it looks a little confusing but this is what it is and that's the exponentiation.” Running `a.exp()` verifies that both the forward evaluation and backward autograd hook operate as intended.
[01:31:49](https://www.youtube.com/watch?v=VMj-3S1tku0&t=5509s) Implementing General Power and Deriving Division and Subtraction
Rather than writing a bespoke division operator, Karpathy implements a generalized power method (`__pow__`), pointing out that division is merely a special case: “division is just a special case of something a bit more powerful.” Because $\frac{a}{b} = a \cdot b^{-1}$, raising a value to an arbitrary power $k$ and multiplying yields division directly. To keep the calculus tractable and avoid requiring multivariable derivatives for arbitrary base-exponent relationships, the exponent `other` is explicitly restricted via an `assert` statement to only accept an `int` or a `float`:
```python assert isinstance(other, (int, float)), "only supporting int/float powers for now" ```
The forward pass computes `self.data ** other`. To formulate the backward pass, Karpathy reviews standard calculus rules—specifically the power rule:
$$\frac{d}{dx} x^n = n x^{n-1}$$
Translating this rule into Python using the cached variables, the local derivative is `other * (self.data ** (other - 1))`. Chaining this with the incoming downstream gradient yields:
```python self.grad += (other * (self.data ** (other - 1))) * out.grad ```
With `__pow__` verified, division is concisely expressed as:
```python def __truediv__(self, other): return self * other**-1 ```
Evaluating `a / b` where `a = Value(2.0)` and `b = Value(4.0)` returns `0.5`.
Finally, Karpathy observes that expressions like `a - b` still fail. Rather than engineering subtraction as a separate primitive with its own backward pass, he leverages the existing building blocks: subtraction is defined as adding a negative value, implemented via unary negation (`__neg__`), which simply multiplies the object by `-1`:
- Unary negation: `def __neg__(self): return self * -1` - Subtraction: `def __sub__(self, other): return self + (-other)`
“The reason I'm doing that just because it's more general and yeah you might as well do it that way,” Karpathy explains, noting how composable operators streamline library design without duplicating backward-pass logic.
[01:36:21](https://www.youtube.com/watch?v=VMj-3S1tku0&t=5781s) Deconstructing `tanh` and Verifying Composite Backward Passes
Having implemented exponentiation, division, and subtraction, Karpathy takes the full two-dimensional neuron previously evaluated with a monolithic `tanh` node and breaks the activation down into its explicit mathematical definition:
$$\tanh(x) = \frac{e^{2x} - 1}{e^{2x} + 1}$$
In the notebook, the composite activation is coded directly: ```python # Instead of: o = n.tanh() e = (2 * n).exp() o = (e - 1) / (e + 1) ```
Karpathy poses a sanity check before executing the graph visualization: breaking `tanh` into individual elementary operations will produce a significantly longer DAG containing explicit nodes for multiplication, exponentiation, negation, subtraction, power (`**-1`), and division. However, because both formulations represent the exact same underlying mathematical function, two invariants must hold: the forward output value must match, and the leaf gradients computed during the backward pass must be identical.
| Node | Monolithic `tanh` Gradient | Composite $\frac{e^{2x}-1}{e^{2x}+1}$ Gradient | |:--- |:--- |:--- | | `w2` | `0.0` | `0.0` | | `x2` | `0.5` | `0.5` | | `w1` | `-1.5` | `-1.5` | | `x1` | `1.0` | `1.0` |
Upon executing `o.backward()` and rendering the graph via Graphviz, the terminal scalar value matches, and the leaf node gradients align exactly: `w2` and `x2` report `0.0` and `0.5`, while `w1` and `x1` report `-1.5` and `1.0`. Karpathy points out the overarching architectural takeaway regarding autograd engines: “the level at which you implement your operations is totally up to you you can implement backward passes for tiny expressions like a single individual plus or a single times or you can implement them for say tanh which is a kind of a potentially you can see it as a composite operation.” Engineers are free to choose the operational granularity that provides the best balance of simplicity, numerical stability, and performance.
[01:39:03](https://www.youtube.com/watch?v=VMj-3S1tku0&t=5943s) Abstraction Boundaries in Autograd and the Local Gradient Principle
Reflecting on the decomposition of composite mathematical functions like $\tanh$, Karpathy points out that viewing an operation as either a single monolithic block or a network of atomic primitives is somewhat arbitrary. At its core, the computation graph only cares about inputs and outputs, tied together by a well-defined mapping:
> “All that matters is we have some kind of inputs and some kind of an output and this output is a function of the inputs in some way and as long as you can do forward pass and the backward pass of that little operation it doesn't matter what that operation is and how composite it is if you can write the local gradients you can chain the gradient and you can continue back propagation so the design of what those functions are is completely up to you.”
Whether one breaks an operation down to basic additions and multiplications or bundles an entire sub-expression into an analytical closed-form derivative, backpropagation continues unaffected across the boundary via the chain rule.
```mermaid flowchart LR accTitle: Abstraction Boundary in Autograd accDescr: Diagram illustrating that local derivatives wrap internal complexity behind clean gradient interfaces. subgraph CompositeNode["Encapsulated Operation"] In["Inputs"] --> Fwd["Forward: y = f(x)"] Fwd --> Out["Outputs"] Out -.->|"Incoming dL/dy"| Bwd["Local: dy/dx"] Bwd -.->|"Chained dL/dx"| In end ```
[01:39:31](https://www.youtube.com/watch?v=VMj-3S1tku0&t=5971s) Validating Micrograd Against PyTorch
To prove that the homemade engine is faithful to modern deep learning systems, Karpathy translates the previous neuron example directly into PyTorch. Micrograd was intentionally modeled on PyTorch’s API, but there are two fundamental distinctions between them:
1. **Scalars vs. Tensors:** Micrograd is purely scalar-valued, whereas PyTorch builds everything around multi-dimensional arrays (tensors). To mimic the Micrograd neuron, PyTorch tensors must be declared as single-element scalars. 2. **Dtype Precision:** Python floating-point literals default to double precision (`float64`). PyTorch tensors default to single precision (`float32`), so each scalar tensor must be explicitly cast using `.double()` to ensure bit-level numerical equivalence. 3. **Tracking Gradients on Leaf Nodes:** For performance reasons, PyTorch defaults `requires_grad=False` on leaf tensors, such as model inputs. To replicate backpropagation through the inputs, Karpathy explicitly marks them with `requires_grad=True`.
```python import torch
# Construct leaf scalar tensors matching micrograd inputs x1 = torch.Tensor([2.0]).double(); x1.requires_grad = True x2 = torch.Tensor([0.0]).double(); x2.requires_grad = True w1 = torch.Tensor([-3.0]).double(); w1.requires_grad = True w2 = torch.Tensor([1.0]).double(); w2.requires_grad = True b = torch.Tensor([6.8813735870195432]).double(); b.requires_grad = True
# Forward pass n = x1*w1 + x2*w2 + b o = torch.tanh(n)
# Backward pass o.backward() ```
When evaluated, the forward output prints as `0.7071` (matching `o.data`), and accessing the individual scalar gradients via `.grad.item()` confirms exact parity with Micrograd: - $\frac{\partial o}{\partial x_1} = 0.5$ - $\frac{\partial o}{\partial w_1} = 1.0$ - $\frac{\partial o}{\partial x_2} = 0.0$ - $\frac{\partial o}{\partial w_2} = -1.5$
Karpathy stresses that while Micrograd exposes the scalar logic in clean Python, PyTorch’s tensor abstraction exists purely for parallel computing horsepower:
> “Torches torch can do what we did in micrograd as a special case when your tensors are all single element tensors but the big deal with PyTorch is that everything is significantly more efficient because we are working with these tensor objects and we can do lots of operations in parallel on all of these tensors.”
[01:43:55](https://www.youtube.com/watch?v=VMj-3S1tku0&t=6235s) Building Neural Network Primitives: The Neuron Class
Having confirmed the autograd engine's correctness, Karpathy turns toward assembling actual neural networks. A neural network is simply an organized class of mathematical expressions. To demonstrate this, he initiates the construction of a Multi-Layer Perceptron (MLP) library patterned after `torch.nn`.
The fundamental building block is the individual `Neuron`: - **State:** Initialized with a weight vector `w` (one random `Value` in the range $[-1, 1]$ per input feature) and a scalar bias `b` representing the neuron's base excitation threshold or "trigger happiness." - **Execution (`__call__`):** Calculates the dot product $\mathbf{w} \cdot \mathbf{x} + b$, then runs that raw activation through a $\tanh$ non-linearity.
```python import random
class Neuron: def __init__(self, nin): # nin: number of inputs self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)] self.b = Value(random.uniform(-1, 1))
def __call__(self, x): # w * x + b act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b) out = act.tanh() return out ```
Karpathy points out an idiomatic Python trick when using `sum`: the built-in function takes an optional second argument `start`, which defaults to integer `0`. Passing `self.b` as the starting accumulator avoids adding a plain integer zero to a `Value` instance and folds the bias directly into the summation over the paired generator `zip(self.w, x)`.
[01:47:55](https://www.youtube.com/watch?v=VMj-3S1tku0&t=6475s) Grouping Neurons into Layers
With individual neurons behaving properly, the next structural level is a neural network layer. In a standard feedforward architecture, a layer consists of multiple neurons arranged side by side. Crucially, these neurons have no horizontal interconnections among themselves, but all of them receive the exact same input vector:
> “What is a layer of neurons it's just it's just a set of neurons evaluated independently.”
Karpathy defines the `Layer` class as a collection of independent `Neuron` instances:
```python class Layer: def __init__(self, nin, nout): # nout: number of outputs (number of neurons in this layer) self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x): outs = [n(x) for n in self.neurons] return outs[0] if len(outs) == 1 else outs ```
Demonstrating this in practice, Karpathy instantiates a layer with 2 inputs and 3 output neurons: passing a 2-dimensional vector `x = [2.0, 3.0]` produces a list of 3 separate `Value` objects, each representing the activation of an independent neuron initialized with its own random weights and bias.
[01:48:53](https://www.youtube.com/watch?v=VMj-3S1tku0&t=6533s) Defining the Multi-Layer Perceptron (MLP)
To complete the architecture, Andrej Karpathy builds on top of single neurons and layers to define an entire Multi-Layer Perceptron (MLP). In an MLP, layers simply feed into each other sequentially. Rather than taking a single integer `nouts` representing the output size of one layer, the `MLP` class takes `nin` along with a list of output dimensions (`nouts`). This list defines the sizes of all consecutive layers across the full network. The constructor gathers the input and output dimensions into a unified list, iterates over adjacent pairs to instantiate `Layer` objects, and stores them in `self.layers`. Calling the MLP on an input sequentially runs the forward pass through each layer in order.
Karpathy implements an MLP matching an architectural diagram consisting of a 3-dimensional input feeding into two hidden layers of 4 neurons each, terminating in a single output unit: `MLP(3, [4, 4, 1])`. For ergonomics, Karpathy adjusts the layer forward logic: instead of always returning a one-element list when a layer has only a single neuron, it unpacks that single scalar (`outs[0] if len(outs) == 1 else outs`). Visualizing this network using `draw_dot(n(x))` reveals a massive, deeply nested computational graph tracing all operations across the layers down to that single final scalar output. Karpathy emphasizes how automated engines handle complexity: “And so obviously you would never differentiate on pen and paper these expressions, but with micrograd we will be able to backpropagate all the way through this and backpropagate into these weights of all these neurons.”
```mermaid flowchart LR accTitle: MLP Architecture Dataflow accDescr: Three-dimensional input vector passes sequentially through two hidden layers of four neurons and produces a single scalar output. subgraph Inputs["Input Layer"] X["Input Vector x (size 3)"] end subgraph Hidden["Hidden Layers"] L1["Layer 1 (4 neurons)"] L2["Layer 2 (4 neurons)"] end subgraph Out["Output Layer"] L3["Layer 3 (1 neuron)"] Y["Scalar Output"] end
X --> L1 L1 --> L2 L2 --> L3 L3 --> Y ```
[01:51:04](https://www.youtube.com/watch?v=VMj-3S1tku0&t=6664s) Creating a Tiny Dataset and Measuring Loss
Karpathy sets up a toy binary classification dataset containing four input vectors (`xs`) and four corresponding scalar target labels (`ys = [1.0, -1.0, -1.0, 1.0]`). Querying the freshly initialized neural network on all four inputs produces initial predictions (`ypred`) such as `0.91`, `0.88`, `0.80`, and `0.80`. Comparing these outputs to the desired targets shows that while the first example is relatively close to `1.0`, the second and third examples output roughly `0.88` and `0.80` when they ought to be `-1.0`. Karpathy points out that deep learning solves this mismatch by compressing global error into a single objective value: “And the trick used in deep learning to achieve this is to calculate a single number that somehow measures the total performance of your neural net, and we call this single number the loss.”
To quantify this error, Karpathy implements the mean squared error (MSE) loss. Zipping the target labels `ys` with predictions `ypred`, he computes `(yout - ygt)**2` for each pair. Squaring ensures that any discrepancy yields a positive penalty regardless of whether the prediction overshot or undershot the target, giving exactly `0.0` only when the network matches the ground truth perfectly. Summing these four individual squared residuals produces an aggregate loss of roughly `7.12`. The objective of training is therefore to tune the internal weights to make this loss as close to zero as possible.
- Input dataset `xs`: four distinct 3-dimensional data points. - Target outputs `ys`: `[1.0, -1.0, -1.0, 1.0]`. - Initial predictions: `[0.91, 0.88, 0.80, 0.80]`, showing heavy misclassification on the negative targets. - Individual loss terms: `(yout - ygt)**2` summed across all four training examples yields an aggregate loss of ~`7.12`.
[01:55:04](https://www.youtube.com/watch?v=VMj-3S1tku0&t=6904s) Backpropagating the Loss and Inspecting Gradients
Executing `loss.backward()` triggers the reverse-mode automatic differentiation pass from the top-level loss value of `7.12` all the way down the computational tree. Karpathy inspects the internal attributes of a specific weight—navigating via `n.layers[0].neurons[0].w[0]`—and verifies that its `.grad` attribute has now been populated with a floating-point value. Because this particular gradient evaluates to a negative number, calculus indicates that increasing this specific weight will cause the overall loss to decrease. Karpathy notes the sense of ease that autograd provides: “So now, of course, if we do `loss.backward()`, something magical happened when I hit enter.”
Calling `draw_dot(loss)` generates a sprawling graph that stitches together four complete forward passes of the entire MLP alongside the arithmetic operations defining the MSE loss function. Karpathy points out that although leaf nodes representing fixed input data (`xs`) also accumulate gradients in this graph, those derivatives are irrelevant because the data inputs cannot be modified. Instead, the only actionable gradients are those attached to the network's internal parameters: the weights (`w`) and biases (`b`). To prepare for optimization, Karpathy introduces a `.parameters()` method modeled directly on PyTorch's `nn.Module.parameters()`. At the `Neuron` level, this method simply concatenates `self.w` and `[self.b]` into a flat list of `Value` objects, laying the groundwork to collect and adjust every trainable parameter simultaneously.
[01:58:44](https://www.youtube.com/watch?v=VMj-3S1tku0&t=7124s) Collecting Parameters Across Layers and the MLP
To build an automatic optimization scheme, every learnable parameter across the entire neural network must be accessible. In `micrograd`, parameters are individual scalar `Value` objects rather than multi-dimensional tensors. Karpathy notes that `Layer` is a module, so it should have its own `.parameters()` method. Initially, one might write an imperative loop: initialize an empty list `params`, iterate over `neuron in self.neurons`, call `neuron.parameters()`, and extend the list with `params.extend(...)`. However, Karpathy points out that “this is way too much code” and replaces it with a clean, nested list comprehension:
```python return [p for neuron in self.neurons for p in neuron.parameters()] ```
Applying the exact same pattern to the `MLP` class collects all parameters across all layers:
```python return [p for layer in self.layers for p in layer.parameters()] ```
Because new methods were added to the classes, the existing network instance cannot access the updated API without re-executing the class definitions and instantiating a fresh network. Re-initializing the multi-layer perceptron resets its random weights and biases, which slightly alters the starting predictions and loss value. Calling `n.parameters()` on the newly initialized network returns a flat list containing all weights and biases: exactly 41 parameters in total across the architecture.
```mermaid flowchart TD accTitle: MLP Parameter Hierarchy accDescr: Parameter collection cascading up from individual Neurons through Layers to the entire MLP model.
subgraph MLP["MLP (41 parameters total)"] subgraph Layer["Layer"] subgraph Neuron["Neuron"] W["Weights: w_i (Value)"] B["Bias: b (Value)"] end end end
W --> Neuron B --> Neuron Neuron -->|"neuron.parameters()"| Layer Layer -->|"layer.parameters()"| MLP MLP -->|"n.parameters()"| Opt["Optimizer Loop: p.data -= lr * p.grad"] ```
[02:01:12](https://www.youtube.com/watch?v=VMj-3S1tku0&t=7272s) The Gradient Descent Step and Sign Direction
With access to all 41 parameters via `n.parameters()`, the network can be updated based on gradient information. Karpathy inspects an arbitrary neuron parameter whose data is currently around `0.85` and whose gradient with respect to the loss is slightly negative. In gradient descent, the gradient vector points in the direction of steepest increase in loss. To minimize loss rather than maximize it, updates must travel in the exact opposite direction of the gradient.
Karpathy steps through the update rule:
$$\text{p.data} = \text{p.data} - \eta \cdot \text{p.grad}$$
where $\eta$ is a small step size or learning rate (such as `0.01`). He walks through the sign logic carefully to ensure intuition holds: if a parameter has a negative gradient, increasing its value decreases the loss. Subtracting a negative quantity results in addition, nudging `p.data` upward (for example, from `0.854` to `0.857`), which directly drives the loss downward. “And so you can convince yourself that this sort of plug does the right thing here with the negative because we want to minimize the loss.”
Re-evaluating the loss function after a single step verifies the mechanics: - Initial loss on the new network: `4.84` - Loss after 1 step ($\eta = 0.01$): `4.36` - Loss after 2 steps: `3.90` - Loss after 3 steps: `3.66` - Loss after 4 steps: `3.47`
Repeating the forward pass, backward pass (`loss.backward()`), and parameter update iteratively steadily brings the predictions closer to the desired binary classification targets (`+1` and `-1`).
[02:06:26](https://www.youtube.com/watch?v=VMj-3S1tku0&t=7586s) Learning Rates, Overstepping, and Training Instability
To accelerate convergence, Karpathy tests increasing the learning rate step size from `0.01` to `0.1`. The loss quickly plunges to `0.31` and then to `0.04`, bringing predictions extremely close to targets like `1.0` and `-1.0`. However, pushing the step size too aggressively reveals a core challenge of first-order optimization: overstepping.
Karpathy explains the underlying hazard: “If you go too fast by the way if you try to make it too big of a step you may actually overstep it's overconfidence because again remember we don't actually know exactly about the loss function the loss function has all kinds of structure and we only know about the very local dependence of all these parameters on the loss but if we step too far we may step into you know a part of the loss that is completely different and that can destabilize training and make your loss actually blow up even.”
Continuing to push the updates aggressively causes the loss to briefly spike or overshoot before serendipitously settling into a deep valley with a loss of $7 \times 10^{-9}$ (`7e-9`), yielding virtually perfect predictions. Karpathy emphasizes that this outcome was lucky and characterizes step size selection in vanilla gradient descent as a delicate balancing act: “So usually this learning rate and the tuning of it is a subtle art you want to set your learning rate if it's too low you're going to take way too long to converge but if it's too high the whole thing gets unstable and you might actually even explode the loss depending on your loss function.”
[02:08:38](https://www.youtube.com/watch?v=VMj-3S1tku0&t=7718s) Assembling the Complete Training Loop
To replace manual notebook cell execution with a clean, repeatable workflow, Karpathy drafts a standardized training loop structure. He defines an iteration loop (`for k in range(20):`) encapsulating the three essential phases of deep learning:
1. **Forward Pass:** Compute predictions `ypred = [n(x) for x in xs]` and evaluate the scalar loss `loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred))`. 2. **Backward Pass:** Call `loss.backward()` to propagate derivatives back through the computational graph to every weight and bias. 3. **Update (Gradient Descent):** For every `p in n.parameters()`, modify `p.data += -learning_rate * p.grad`.
Selecting an intermediate learning rate between the slow `0.01` and the volatile `0.1` (such as `0.05` or similar), Karpathy cleans up experimental scratch cells to run the unified 20-step loop from a fresh re-initialization of the network.
[02:09:59](https://www.youtube.com/watch?v=VMj-3S1tku0&t=7799s) The Classic Mistake: Forgetting to Zero the Gradients
After running the manual optimization loop, Andrej Karpathy notes that the loss converges cleanly and in a controlled manner, yielding accurate predictions in `ypred`. However, he immediately stops himself to reveal a major flaw in the training script: “Okay, so this is kind of embarrassing, but we actually have a really terrible bug in here, and it's a subtle bug, and it's a very common bug, and I can't believe I've done it for the 20th time in my life, especially on camera.” Rather than reshooting the segment, he decides to keep it in so viewers can appreciate what debugging neural networks actually feels like.
He brings up an old tweet of his listing the most common neural network mistakes, pointing out that the current implementation fell victim to item number three: forgetting to zero the gradients before calling backward. In the `Value` class, the gradient attribute starts at `grad = 0.0`. During backpropagation, every local backward pass accumulates incoming gradients using `+=` rather than simple assignment `=`. Because the training loop was updating parameters with `p.data += -learning_rate * p.grad` without resetting `p.grad`, the gradients from preceding iterations remained intact. Each subsequent backward pass continued stacking new gradient values on top of the old ones.
To fix this, Karpathy introduces a loop across all network parameters directly before the backward pass, setting each `p.grad = 0.0`. In PyTorch, this operation is encapsulated by the familiar `.zero_grad()` method. After resetting the network and rerunning the optimization loop with the fix, the loss descent is slower and requires more iterations to hit rock-bottom values, but it behaves properly. The only reason the buggy version worked at all was that fitting four scalar data points is an extraordinarily trivial problem. The accumulating gradients effectively behaved like an accidental, massive learning rate schedule that happened to slingshot the parameters toward the solution. On any realistic or complex dataset, that unchecked accumulation would have caused the optimization to fail entirely.
```mermaid flowchart TD accTitle: The Training Loop with Zero Grad accDescr: Sequential flow showing a forward pass, zeroing gradients, backpropagation, and parameter updating.
subgraph OptimizationStep["Single Optimization Step"] FP["1. Forward Pass<br/>ypred = [n(x) for x in xs]<br/>loss = sum((yout - ygt)**2)"] ZG["2. Zero Gradients<br/>for p in n.parameters():<br/>p.grad = 0.0"] BP["3. Backward Pass<br/>loss.backward()"] UP["4. Parameter Update<br/>for p in n.parameters():<br/>p.data += -step_size * p.grad"] end
FP --> ZG ZG --> BP BP --> UP ```
- When `p.grad` is not zeroed out, the `+=` accumulation in the backward functions adds new derivatives to historical ones, inflating the effective gradient magnitude. - In neural networks, code bugs frequently fail silently; the network can often partially learn despite severe implementation errors, especially on toy datasets. - Resetting the weights and training with explicit zero-grad results in a smooth, monotonic reduction of the mean squared error toward zero.
[02:14:03](https://www.youtube.com/watch?v=VMj-3S1tku0&t=8043s) Summarizing Neural Nets: From Micrograd to Frontier Models
Stepping back from the code, Karpathy synthesizes the core principles of deep learning demonstrated throughout the walkthrough. At their heart, neural networks are simple mathematical expressions that take two sets of inputs: the external data and internal tunable parameters (weights and biases). A forward pass evaluates this expression to produce predictions, followed by a loss function that evaluates the quality of those predictions against target labels. Backpropagation then traverses that computational graph in reverse to compute local derivatives, producing gradients that point in the direction of greatest increase for each parameter. Gradient descent simply nudges the weights in the opposite direction, stepping iteratively downhill to minimize the loss.
He emphasizes that even frontier systems operate on these exact same mathematical mechanics: “It's a massive blob of neural tissue—simulated neural tissue, roughly speaking—and you can make it do extremely complex problems, and these neurons then have all kinds of very fascinating emergent properties.” Scaling from an MLP of 41 parameters up to models like GPT, with hundreds of billions of parameters trained on vast portions of the internet to predict the next token in a sequence, does not alter the underlying paradigm. The graphs become vastly wider and deeper, simple mean squared error is swapped for cross-entropy loss, and vanilla stochastic gradient descent is replaced with more advanced optimizers, but the core mechanics—evaluating values, applying chain-rule backpropagation to accumulate gradients, and updating parameters—remain identical.
- **Neural Network Essence**: A forward pass expression mapping data and parameters to outputs, evaluated via a loss function. - **Optimization Mechanism**: Loss minimization driven by backpropagation-derived gradients followed by iterative descent updates. - **Micrograd Scale**: A tiny Multi-Layer Perceptron containing 41 parameters solving a 4-sample toy problem. - **Modern Large Language Models**: Identical backward accumulation and descent dynamics scaled up to hundreds of billions of parameters predicting token sequences using cross-entropy loss.
[02:16:46](https://www.youtube.com/watch?v=VMj-3S1tku0&t=8206s) Code Tour of the Micrograd Repository
To validate that viewers now understand the entirety of Micrograd, Karpathy opens the repository on GitHub and performs a file-by-file walkthrough. An empty `__init__.py` anchors the package, while `engine.py` houses the core `Value` engine. This class contains everything written in the lesson: `.data`, `.grad`, the `.backward` closures, the child sets, and operator overrides like `__add__`, `__mul__`, and `__pow__`. The primary difference in the repo is that `engine.py` implements the `relu` (Rectified Linear Unit) activation instead of $\tanh$. Karpathy explains that $\tanh$ was chosen for the video because its smooth curve and non-trivial derivative better exercised calculus and local gradient concepts, though both functions serve as interchangeable non-linearities in Multi-Layer Perceptrons.
Next, he turns to `nn.py`, which implements `Neuron`, `Layer`, and `MLP`. The repository wraps these objects in a base `Module` class to mirror PyTorch's `torch.nn.Module`, providing inherited convenience utilities such as `zero_grad()`. To confirm correctness, the repository includes a test suite (`test_engine.py`) that executes identical expressions in both Micrograd and PyTorch, validating that forward values and backward gradients match down to floating-point precision across both simple and complex expressions. Finally, he glances at `demo.ipynb`, which shows Micrograd training a slightly larger MLP on a 2D binary classification dataset with interleaved red and blue clusters, demonstrating that the tiny engine can separate non-linear decision boundaries out of the box.
| File / Component | Primary Responsibility in Micrograd | |:--- |:--- | | `engine.py` | Contains the `Value` class, mathematical operator overrides, activation functions (e.g., `relu`), and topological sorting `.backward()`. | | `nn.py` | Defines the `Module` base class along with `Neuron`, `Layer`, and `MLP` architectures. | | `test_engine.py` | Verifies Micrograd's forward and backward computations directly against PyTorch's autograd engine. | | `demo.ipynb` | Demonstrates training an MLP binary classifier on a 2D non-linear toy dataset. |
- “In the beginning of this video I told you that by the end of it you would understand everything in Micrograd, and then we'd slowly build it up. Let me briefly prove that to you.” — Andrej Karpathy - The repository favors `relu` over `tanh` purely for simplicity, but Karpathy notes he plans to add $\tanh$ directly to the main repository later. - The `Module` base class consolidates parameter gathering (`parameters()`) and gradient clearing (`zero_grad()`) across arbitrary submodules.
[02:19:24](https://www.youtube.com/watch?v=VMj-3S1tku0&t=8364s) Real-World Training Techniques: Batches, Losses, Regularization, and Decay
Andrej Karpathy walks through the more complete notebook demo (`demo.ipynb` in the micrograd repository) to highlight how a realistic training setup differs from the toy four-example walkthrough. Because the earlier dataset was tiny, the network could comfortably process all four samples simultaneously on every single iteration. However, in real-world scenarios where datasets scale up to millions of examples, feeding the whole collection at once is completely impractical. Instead, practitioners sample a random subset called a "batch" and run forward, backward, and parameter updates solely on that batch.
```mermaid flowchart LR accTitle: Mini-batch optimization step accDescr: Diagram illustrating a single optimization loop processing a mini-batch of data. subgraph Data ["Data Sampling"] D["Full Dataset (Millions)"] -->|"sample random subset"| B["Batch"] end subgraph Step ["Optimization Step"] B --> FP["Forward Pass (Loss)"] FP --> BP["Backward Pass (zero_grad + backward)"] BP --> UP["Parameter Update (Decaying Learning Rate)"] end ```
The loss setup in the demo also incorporates techniques beyond the basic mean squared error (MSE) used earlier. Karpathy implements a max-margin loss, noting that binary cross-entropy is another standard alternative for binary classification. He remarks:
> “The one that we used was the mean squared error loss because it's the simplest one.”
In simple classification problems, switching between these loss formulations does not drastically alter the final outcome. The demo also includes L2 regularization, a penalty term used to control overfitting and improve generalization on unseen data, though Karpathy leaves a deep dive into regularization mechanics for future videos. Finally, the training loop incorporates learning rate decay: the learning rate begins high to traverse large parameter regions and gradually shrinks over iterations, allowing the optimizer to settle smoothly into fine details near the end. Evaluating the network on the two-dimensional dataset reveals a clean decision surface separating the red and blue clusters.
---
[02:21:10](https://www.youtube.com/watch?v=VMj-3S1tku0&t=8470s) Hunting for Tanh Backward in PyTorch: CPU and CUDA Kernels
To bridge the gap between micrograd and production deep learning systems, Karpathy ventures into the PyTorch open-source codebase to locate how PyTorch implements the exact backward derivative of hyperbolic tangent:
$$\frac{d}{dx}\tanh(x) = (1 - \tanh^2(x)) \cdot \text{out.grad}$$
Finding this single line of math in a massive codebase proved surprisingly frustrating. A simple repository search for `tanh` surfaced 2,800 results spanning 406 files. As Karpathy wryly remarks:
> “And honestly I spent about 15 minutes and I couldn't find tanh, and that's because these libraries unfortunately they grow in size and entropy.”
He points out that production frameworks are built to be executed at peak hardware speed rather than read for casual inspection:
> “Unfortunately these libraries are quite complex; they're meant to be used, not really inspected.”
```mermaid flowchart TD accTitle: Micrograd vs Production PyTorch Tanh Implementation accDescr: Comparison showing micrograd's concise single-line backward implementation versus PyTorch's specialized hardware kernels. subgraph Micrograd ["micrograd (Pure Python)"] M1["def _backward(): self.grad += (1 - out.data**2) * out.grad"] end subgraph PyTorch ["PyTorch C++/CUDA Backend"] Search["Search: 2,800 matches across 406 files"] Search --> CPU["CPU Kernel (BinaryOpsKernel.cpp)"] Search --> CUDA["CUDA GPU Kernel"] CPU --> C1["Handles complex types, BFloat16, etc."] CPU --> C2["Core expression: a * (1 - b * b)"] CUDA --> G1["GPU device kernel: single-line evaluate"] end ```
By following a GitHub pull request where a developer modified the backward pass, Karpathy traces the logic down into PyTorch's device-specific backends:
- **CPU Kernel**: Found unexpectedly inside `BinaryOpsKernel.cpp` (even though $\tanh$ is an elementwise unary operation). The surrounding code is packed with guards for exotic numeric formats like complex numbers and `bfloat16`. Deep in the kernel logic, the familiar calculation appears: `a * (1 - b * b)`, where `b` is the cached forward output ($\tanh(x)$) and `a` represents incoming output gradient (`self.grad`). - **GPU (CUDA) Kernel**: Implemented separately for Nvidia hardware devices, executing the identical algebraic derivative directly inside a CUDA kernel block.
---
[02:23:43](https://www.youtube.com/watch?v=VMj-3S1tku0&t=8623s) Custom Autograd Functions in PyTorch and Lecture Wrap-Up
Karpathy explains that PyTorch exposes an explicit extension API for developers who need to define custom operations. By subclassing `torch.autograd.Function`, one must implement two core static methods: `forward` (evaluating the mathematical function) and `backward` (computing the local gradient via the chain rule). As an example, he points to a Legendre polynomial definition:
> “As long as you can do the forward pass of this little function piece that you want to add, and as long as you know the local derivative, the local gradients which are implemented in the backward, PyTorch will be able to backpropagate through your function and then you can use this as a Lego block in a larger Lego castle of all the different Lego blocks that PyTorch already has.”
With the full tour complete, Karpathy concludes the lecture. He directs viewers to the GitHub repository containing the finished micrograd code, the video description links, and discussion channels where students can post questions. He mentions the possibility of a future follow-up video addressing frequent questions, reminding viewers to like and subscribe to help the video reach a broader audience before closing the lecture.
---
[02:25:20](https://www.youtube.com/watch?v=VMj-3S1tku0&t=8720s) Bloopers and Outtakes
The video concludes with a lighthearted reel of recording bloopers showing the challenges of filming technical lectures in single takes:
- Karpathy stumbles mid-derivation while staring at his scratch pad: *"Now here's the problem. We know $\frac{\partial L}{\partial \dots}$ wait, what is the problem?"* - He trips over the library name while delivering the outro, calling it *"microcraft"* and laughing: *"micro crab."* - While coding operator overloading for multiplication (`a * 2`), an unexpected Python error halts the recording: *"Okay, now let's do the exact same thing for multiply because we can't do something like a times two... oops, I know what happened there."*
## Notable quotes > [04:33](https://www.youtube.com/watch?v=VMj-3S1tku0&t=273s) my claim is that micrograd is what you need to train your networks and everything else is just efficiency — Andrej Karpathy
## Mentioned Andrej Karpathy, micrograd, PyTorch, JAX, Python, NumPy, Matplotlib, Graphviz, CUDA, Nvidia, GPT, GitHub
## Video description This is the most step-by-step spelled-out explanation of backpropagation and training of neural networks. It only assumes basic knowledge of Python and a vague recollection of calculus from high school.
Links: - micrograd on github: https://github.com/karpathy/micrograd - jupyter notebooks I built in this video: https://github.com/karpathy/nn-zero-to-hero/tree/master/lectures/micrograd - my website: https://karpathy.ai - my twitter: https://twitter.com/karpathy - "discussion forum": nvm, use youtube comments below for now :) - (new) Neural Networks: Zero to Hero series Discord channel: https://discord.gg/3zy8kqD9Cp , for people who'd like to chat more and go beyond youtube comments
Exercises: you should now be able to complete the following google collab, good luck!: https://colab.research.google.com/drive/1FPTx1RXtBfc4MaTkf7viZZD4U2F9gtKN?usp=sharing
Chapters: 00:00:00 intro 00:00:25 micrograd overview 00:08:08 derivative of a simple function with one input 00:14:12 derivative of a function with multiple inputs 00:19:09 starting the core Value object of micrograd and its visualization 00:32:10 manual backpropagation example #1: simple expression 00:51:10 preview of a single optimization step 00:52:52 manual backpropagation example #2: a neuron 01:09:02 implementing the backward function for each operation 01:17:32 implementing the backward function for a whole expression graph 01:22:28 fixing a backprop bug when one node is used multiple times 01:27:05 breaking up a tanh, exercising with more operations 01:39:31 doing the same thing but in PyTorch: comparison 01:43:55 building out a neural net library (multi-layer perceptron) in micrograd 01:51:04 creating a tiny dataset, writing the loss function 01:57:56 collecting all of the parameters of the neural net 02:01:12 doing gradient descent optimization manually, training the network 02:14:03 summary of what we learned, how to go towards modern neural nets 02:16:46 walkthrough of the full code of micrograd on github 02:21:10 real stuff: diving into PyTorch, finding their backward pass for tanh 02:24:39 conclusion 02:25:20 outtakes :)
[Source](https://www.youtube.com/watch?v=VMj-3S1tku0)