/**
* Segmented step control for visualization stepping.
* @param {Object} options
* @param {number} options.min - Minimum step value (default 0)
* @param {number} options.max - Maximum step value
* @param {number} options.value - Initial value (default min)
* @param {string} options.label - Optional label text
* @returns {number} Current step value (reactive)
*/
stepControl = function({min = 0, max, value, label = null} = {}) {
const initialValue = value ?? min;
const steps = Array.from({length: max - min + 1}, (_, i) => min + i);
const container = htl.html`<div class="step-control">
${label ? htl.html`<span class="step-control-label">${label}</span>` : ''}
<div class="step-control-segments" role="group" aria-label="${label || 'Step control'}">
${steps.map(step => htl.html`<button
class="step-control-segment ${step === initialValue ? 'active' : ''}"
data-step="${step}"
aria-pressed="${step === initialValue}"
tabindex="${step === initialValue ? 0 : -1}"
>${step}</button>`)}
</div>
</div>`;
const segments = container.querySelectorAll('.step-control-segment');
let currentValue = initialValue;
function updateActive(newValue) {
currentValue = newValue;
segments.forEach(seg => {
const isActive = parseInt(seg.dataset.step) === newValue;
seg.classList.toggle('active', isActive);
seg.setAttribute('aria-pressed', isActive);
seg.tabIndex = isActive ? 0 : -1;
});
container.value = newValue;
container.dispatchEvent(new Event('input', {bubbles: true}));
}
// Click handler
segments.forEach(seg => {
seg.addEventListener('click', () => {
updateActive(parseInt(seg.dataset.step));
});
});
// Keyboard navigation
container.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.min(currentValue + 1, max);
updateActive(next);
segments[next - min].focus();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const prev = Math.max(currentValue - 1, min);
updateActive(prev);
segments[prev - min].focus();
} else if (e.key === 'Home') {
e.preventDefault();
updateActive(min);
segments[0].focus();
} else if (e.key === 'End') {
e.preventDefault();
updateActive(max);
segments[max - min].focus();
}
});
container.value = initialValue;
return container;
}Module 02: Autograd
Introduction
Automatic differentiation enables neural network training. It computes gradients through any computation - the foundation of backpropagation.
Autograd (automatic differentiation) computes gradients for you. When you call loss.backward() in PyTorch, autograd figures out how to adjust every parameter to reduce the loss.
Why is this essential for LLMs?
- Millions of parameters: An LLM has millions (or billions) of numbers to adjust. Only automatic computation can handle this scale.
- Complex computations: Attention, embeddings, layer norms - the gradient must flow through all of them.
- Training loop: Every training step computes gradients to update weights.
Autograd makes deep learning trainable.
What You’ll Learn
After this module, you can:
- Explain how computational graphs enable gradient computation
- Build a working scalar autograd engine from scratch
- Extend these principles to tensor operations
- Use PyTorch’s autograd for gradient computation
- Identify and avoid common autograd pitfalls
Intuition: The Computational Graph
Every computation builds a directed acyclic graph (DAG) dynamically as operations execute. Frameworks call this approach “define-by-run” because the code path determines graph structure—enabling conditionals and loops to create different graphs each pass.
Think of a computation as a graph of operations:
Forward pass: Compute values flowing down (x -> a -> b -> c)
Backward pass: Compute gradients flowing up (c -> b -> a -> x)
- dc/dc = 1 (output gradient is always 1)
- dc/db = 2b = 14 (derivative of b^2)
- dc/da = dc/db x db/da = 14 x 1 = 14
- dc/dx = dc/da x da/dx = 14 x 3 = 42
The chain rule connects everything: multiply local gradients as you go back.
Computational Graph: Forward and Backward
An example shows both passes:
The Math
Chain Rule
For composed functions f(g(x)):
d/dx[f(g(x))] = f'(g(x)) x g'(x)
This extends to any depth - just multiply local derivatives along the path.
Common Gradients
| Operation | Forward | Local Gradient |
|---|---|---|
c = a + b |
sum | dc/da = 1, dc/db = 1 |
c = a * b |
product | dc/da = b, dc/db = a |
c = a ** n |
power | dc/da = n x a^(n-1) |
c = exp(a) |
exp | dc/da = exp(a) |
c = log(a) |
log | dc/da = 1/a |
c = tanh(a) |
tanh | dc/da = 1 - tanh^2(a) |
c = relu(a) |
ReLU | dc/da = 1 if a > 0 else 0 |
Matrix Multiplication Gradients
For C = A @ B:
- dL/dA = dL/dC @ B.T
- dL/dB = A.T @ dL/dC
This is why matrix shapes matter so much!
Gradient Accumulation
When a value is used multiple times, gradients ADD:
Code Walkthrough
Explore autograd interactively:
import torch
print(f"PyTorch version: {torch.__version__}")PyTorch version: 2.10.0+cu128
Building a Simple Autograd Engine
This simple autograd engine (inspired by Andrej Karpathy’s micrograd) handles scalar values only - PyTorch’s autograd extends these same principles to tensors of any shape, which is what makes it powerful for real neural networks.
class Value:
"""A scalar value that tracks its gradient."""
def __init__(self, data, children=(), op='', label=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(children)
self._op = op
self.label = label
def __repr__(self):
return f"Value(data={self.data}, grad={self.grad})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += out.grad # d(a+b)/da = 1
other.grad += out.grad # d(a+b)/db = 1
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad # d(a*b)/da = b
other.grad += self.data * out.grad # d(a*b)/db = a
out._backward = _backward
return out
def __pow__(self, other):
assert isinstance(other, (int, float)), "only supporting int/float powers"
out = Value(self.data ** other, (self,), f'**{other}')
def _backward():
self.grad += (other * self.data ** (other - 1)) * out.grad
out._backward = _backward
return out
def __neg__(self):
return self * -1
def __sub__(self, other):
return self + (-other)
def __radd__(self, other):
return self + other
def __rmul__(self, other):
return self * other
def tanh(self):
import math
t = math.tanh(self.data)
out = Value(t, (self,), 'tanh')
def _backward():
self.grad += (1 - t ** 2) * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(max(0, self.data), (self,), 'relu')
def _backward():
self.grad += (self.data > 0) * out.grad
out._backward = _backward
return out
def exp(self):
import math
out = Value(math.exp(self.data), (self,), 'exp')
def _backward():
self.grad += out.data * out.grad
out._backward = _backward
return out
def log(self):
import math
out = Value(math.log(self.data), (self,), 'log')
def _backward():
self.grad += (1.0 / self.data) * out.grad
out._backward = _backward
return out
def __truediv__(self, other):
return self * (other ** -1)
def backward(self):
# Topological sort to process in correct order
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
# Go backwards, applying chain rule
self.grad = 1.0
for v in reversed(topo):
v._backward()Testing Our Value Class
# Create some Values
a = Value(2.0, label='a')
b = Value(3.0, label='b')
print(f"a = {a}")
print(f"b = {b}")
print(f"\nInitially, gradients are 0.0")a = Value(data=2.0, grad=0.0)
b = Value(data=3.0, grad=0.0)
Initially, gradients are 0.0
# Perform a computation
c = a * b
c.label = 'c'
print(f"c = a * b = {c.data}")
print(f"\nNow let's compute gradients with backward():")
c.backward()
print(f"\ndc/da = {a.grad} (should be b = 3.0)")
print(f"dc/db = {b.grad} (should be a = 2.0)")c = a * b = 6.0
Now let's compute gradients with backward():
dc/da = 3.0 (should be b = 3.0)
dc/db = 2.0 (should be a = 2.0)
Verifying Gradients Numerically
# Let's verify: increase 'a' by a tiny amount
epsilon = 0.0001
a_original = 2.0
b_val = 3.0
c_original = a_original * b_val
c_perturbed = (a_original + epsilon) * b_val
numerical_gradient = (c_perturbed - c_original) / epsilon
print(f"Numerical gradient dc/da = {numerical_gradient:.4f}")
print(f"Our computed gradient: {a.grad}")
print(f"\nThey match! (The tiny difference is numerical precision)")Numerical gradient dc/da = 3.0000
Our computed gradient: 3.0
They match! (The tiny difference is numerical precision)
The Chain Rule in Action
# Let's trace through: c = (a + b) * b
a = Value(2.0, label='a')
b = Value(3.0, label='b')
sum_ab = a + b
sum_ab.label = 'sum'
c = sum_ab * b
c.label = 'c'
c.backward()
print(f"Expression: c = (a + b) * b")
print(f"a = {a.data}, b = {b.data}")
print(f"sum = a + b = {sum_ab.data}")
print(f"c = sum * b = {c.data}")
print(f"\nGradients:")
print(f"dc/da = {a.grad} (expected: b = 3)")
print(f"dc/db = {b.grad} (expected: sum + a = 5 + 3 = 8)")Expression: c = (a + b) * b
a = 2.0, b = 3.0
sum = a + b = 5.0
c = sum * b = 15.0
Gradients:
dc/da = 3.0 (expected: b = 3)
dc/db = 8.0 (expected: sum + a = 5 + 3 = 8)
Training a Neuron
Train a neuron to output a target value:
# Training loop
x_val = 2.0
target_val = 0.8
# Learnable parameters (start with small random values)
w = 0.3
b = 0.1
learning_rate = 0.5
losses = []
print("Training a neuron to output 0.8 when input is 2.0")
print("=" * 50)
for step in range(20):
# Create fresh Values (gradients reset)
x = Value(x_val, label='x')
w_v = Value(w, label='w')
b_v = Value(b, label='b')
target = Value(target_val, label='target')
# Forward pass
y = (w_v * x + b_v).tanh()
loss = (y - target) ** 2
# Backward pass
loss.backward()
# Gradient descent update
w = w - learning_rate * w_v.grad
b = b - learning_rate * b_v.grad
losses.append(loss.data)
if step % 4 == 0:
print(f"Step {step:2d}: y={y.data:.4f}, loss={loss.data:.6f}, w={w:.4f}, b={b:.4f}")
print(f"\nFinal output: {y.data:.4f}")
print(f"Target: {target_val}")
print("Pretty close!")Training a neuron to output 0.8 when input is 2.0
==================================================
Step 0: y=0.6044, loss=0.038272, w=0.5484, b=0.2242
Step 4: y=0.8121, loss=0.000146, w=0.4650, b=0.1825
Step 8: y=0.8002, loss=0.000000, w=0.4595, b=0.1798
Step 12: y=0.8000, loss=0.000000, w=0.4594, b=0.1797
Step 16: y=0.8000, loss=0.000000, w=0.4594, b=0.1797
Final output: 0.8000
Target: 0.8
Pretty close!
The loss decreases because gradients tell us which way to adjust w and b!
The Evolutionary Leap: Scalar to Tensor
We’ve now mastered scalar autograd with our Value class. The chain rule, computational graphs, and backward passes all work the same way at any scale. But real neural networks operate on tensors with thousands or millions of elements. Let’s build a second autograd engine — this time for tensors — to see how the same principles scale up.
Why Scalars Don’t Scale
Our Value class works beautifully for understanding autograd. But try to imagine training a real neural network with it:
- A small MLP with 10,000 parameters creates 10,000
Valueobjects - Each forward pass creates thousands more intermediate
Valuenodes - Python loops for every dot product (catastrophically slow)
- Memory explodes with millions of tiny objects
The fix isn’t “optimize Python.” The fix is stop pretending a neural network is a pile of scalars.
# The problem: dot product with scalar Values
def slow_dot_product(values_a, values_b):
"""This is what we're doing now - Python loop over scalars."""
result = values_a[0] * values_b[0]
for a, b in zip(values_a[1:], values_b[1:]):
result = result + a * b
return result
# With 1000-dimensional vectors, that's 1000 Python operations
# A GPU can do this in ONE operationThe solution: upgrade from scalar Value to tensor Tensor.
Building a Tensor Autograd
The evolutionary leap: a Tensor class backed by NumPy arrays instead of Python floats.
import numpy as np
from typing import Optional, Tuple, Callable, Set
class Tensor:
"""
A NumPy-backed tensor with reverse-mode autodiff.
This is what PyTorch's tensors do internally (but in C++/CUDA).
"""
def __init__(self, data, requires_grad: bool = False, _prev: Set["Tensor"] = None, _op: str = ""):
# Convert to numpy array if needed
if isinstance(data, np.ndarray):
self.data = data.astype(np.float32)
else:
self.data = np.array(data, dtype=np.float32)
self.requires_grad = requires_grad
self.grad: Optional[np.ndarray] = None
self._prev = _prev or set()
self._op = _op
self._backward: Callable[[], None] = lambda: None
def __repr__(self) -> str:
return f"Tensor(shape={self.data.shape}, requires_grad={self.requires_grad})"
@property
def shape(self) -> Tuple[int, ...]:
return self.data.shape
def zero_grad(self) -> None:
self.grad = NoneAlready we see the key difference: self.data is a NumPy array, not a float. This means operations work on entire arrays at once.
The Tricky Part: Broadcasting Gradients
When you add a (batch, dim) tensor to a (dim,) bias, NumPy broadcasts the bias across all batches. But during backprop, we need to undo that broadcasting - the gradient for the bias should be summed across the batch dimension.
def _unbroadcast(grad: np.ndarray, target_shape: Tuple[int, ...]) -> np.ndarray:
"""
Undo NumPy broadcasting for gradients.
Example:
Forward: y = x + b where x is (B, D) and b is (D,)
Backward: grad wrt b should be sum over batch axis -> (D,)
Rules:
1. While grad has extra leading dims, sum them away
2. For dims where target had size 1, sum over that axis
"""
g = grad
# Remove leading dims added by broadcasting
while len(g.shape) > len(target_shape):
g = g.sum(axis=0)
# Sum over axes where target had size 1
for axis, (gdim, tdim) in enumerate(zip(g.shape, target_shape)):
if tdim == 1 and gdim != 1:
g = g.sum(axis=axis, keepdims=True)
return g.reshape(target_shape)
# Test it
grad = np.ones((3, 4)) # Gradient has batch dimension
bias_shape = (4,) # Bias was (4,) before broadcasting
result = _unbroadcast(grad, bias_shape)
print(f"Input grad shape: {grad.shape}")
print(f"Target shape: {bias_shape}")
print(f"Result shape: {result.shape}") # (4,) - summed over batch
print(f"Result values: {result}") # [3, 3, 3, 3] - sum of 3 ones per positionInput grad shape: (3, 4)
Target shape: (4,)
Result shape: (4,)
Result values: [3. 3. 3. 3.]
This is the key insight PyTorch handles automatically. When you see RuntimeError: grad shape doesn't match, it’s usually a broadcasting gradient issue.
Tensor Arithmetic with Gradients
Now we add operations. Each operation stores a _backward function that knows how to push gradients to its inputs.
# Add these methods to our Tensor class (shown separately for clarity)
def tensor_add(self, other) -> "Tensor":
"""
Addition: c = a + b
Gradient: dc/da = 1, dc/db = 1 (with unbroadcasting)
"""
other = other if isinstance(other, Tensor) else Tensor(other)
out = Tensor(
self.data + other.data,
requires_grad=self.requires_grad or other.requires_grad,
_prev={self, other},
_op="+"
)
def _backward():
if out.grad is None:
return
if self.requires_grad:
g = _unbroadcast(out.grad, self.data.shape)
self.grad = g if self.grad is None else (self.grad + g)
if other.requires_grad:
g = _unbroadcast(out.grad, other.data.shape)
other.grad = g if other.grad is None else (other.grad + g)
out._backward = _backward
return out
def tensor_mul(self, other) -> "Tensor":
"""
Multiplication: c = a * b
Gradient: dc/da = b, dc/db = a (with unbroadcasting)
"""
other = other if isinstance(other, Tensor) else Tensor(other)
out = Tensor(
self.data * other.data,
requires_grad=self.requires_grad or other.requires_grad,
_prev={self, other},
_op="*"
)
def _backward():
if out.grad is None:
return
if self.requires_grad:
g = _unbroadcast(out.grad * other.data, self.data.shape)
self.grad = g if self.grad is None else (self.grad + g)
if other.requires_grad:
g = _unbroadcast(out.grad * self.data, other.data.shape)
other.grad = g if other.grad is None else (other.grad + g)
out._backward = _backward
return out
# Attach to Tensor class
Tensor.__add__ = tensor_add
Tensor.__radd__ = lambda self, other: tensor_add(self, other)
Tensor.__mul__ = tensor_mul
Tensor.__rmul__ = lambda self, other: tensor_mul(self, other)
Tensor.__neg__ = lambda self: tensor_mul(self, -1.0)
Tensor.__sub__ = lambda self, other: tensor_add(self, -other)This pattern matches our scalar Value - with arrays and _unbroadcast.
Matrix Multiplication: The Core of Neural Networks
Matrix multiplication is where tensors really shine. This single operation replaces thousands of scalar multiplications and additions.
def tensor_matmul(self, other) -> "Tensor":
"""
Matrix multiplication: C = A @ B
Forward: C[i,j] = sum_k A[i,k] * B[k,j]
Backward:
dA = dC @ B.T (gradient flows back through B transposed)
dB = A.T @ dC (gradient flows back through A transposed)
"""
other = other if isinstance(other, Tensor) else Tensor(other)
out = Tensor(
np.matmul(self.data, other.data),
requires_grad=self.requires_grad or other.requires_grad,
_prev={self, other},
_op="matmul"
)
def _backward():
if out.grad is None:
return
if self.requires_grad:
# dA = dC @ B.T
dA = np.matmul(out.grad, np.swapaxes(other.data, -1, -2))
dA = _unbroadcast(dA, self.data.shape)
self.grad = dA if self.grad is None else (self.grad + dA)
if other.requires_grad:
# dB = A.T @ dC
dB = np.matmul(np.swapaxes(self.data, -1, -2), out.grad)
dB = _unbroadcast(dB, other.data.shape)
other.grad = dB if other.grad is None else (other.grad + dB)
out._backward = _backward
return out
Tensor.matmul = tensor_matmul
# Test: simple 2x2 matmul
A = Tensor([[1, 2], [3, 4]], requires_grad=True)
B = Tensor([[5, 6], [7, 8]], requires_grad=True)
C = A.matmul(B)
print(f"A @ B =\n{C.data}")
# Backward pass
C.grad = np.ones_like(C.data) # Seed gradient
C._backward()
print(f"\ndA =\n{A.grad}")
print(f"\ndB =\n{B.grad}")A @ B =
[[19. 22.]
[43. 50.]]
dA =
[[11. 15.]
[11. 15.]]
dB =
[[4. 4.]
[6. 6.]]
Matrix multiplication is the fundamental operation of neural networks. Every linear layer computes x @ W + b.
Backward Pass: Topological Sort
Just like our scalar Value, we need to traverse the graph in reverse order.
def tensor_backward(self) -> None:
"""
Reverse-mode autodiff: topologically sort and backprop.
"""
# Build topological order
topo = []
visited = set()
def build(v: Tensor):
if v not in visited:
visited.add(v)
for p in v._prev:
build(p)
topo.append(v)
build(self)
# Seed gradient and propagate
self.grad = np.ones_like(self.data, dtype=np.float32)
for v in reversed(topo):
v._backward()
Tensor.backward = tensor_backwardActivation Functions
Neural networks need nonlinearities. Here are the common ones:
def tensor_relu(self) -> "Tensor":
"""ReLU: max(0, x). Gradient: 1 if x > 0, else 0."""
out = Tensor(
np.maximum(self.data, 0.0),
requires_grad=self.requires_grad,
_prev={self},
_op="relu"
)
def _backward():
if out.grad is None or not self.requires_grad:
return
g = out.grad * (self.data > 0.0)
self.grad = g if self.grad is None else (self.grad + g)
out._backward = _backward
return out
def tensor_tanh(self) -> "Tensor":
"""Tanh: gradient is (1 - tanh^2)."""
t = np.tanh(self.data)
out = Tensor(t, requires_grad=self.requires_grad, _prev={self}, _op="tanh")
def _backward():
if out.grad is None or not self.requires_grad:
return
g = out.grad * (1.0 - t * t)
self.grad = g if self.grad is None else (self.grad + g)
out._backward = _backward
return out
Tensor.relu = tensor_relu
Tensor.tanh = tensor_tanhReduction Operations
We need sum and mean for computing losses.
def tensor_sum(self, axis=None, keepdims=False) -> "Tensor":
"""Sum over axis. Gradient broadcasts back to input shape."""
out = Tensor(
self.data.sum(axis=axis, keepdims=keepdims),
requires_grad=self.requires_grad,
_prev={self},
_op="sum"
)
def _backward():
if out.grad is None or not self.requires_grad:
return
g = out.grad
# Expand reduced axes back to input shape
if axis is None:
g = np.ones_like(self.data) * g
else:
axes = axis if isinstance(axis, tuple) else (axis,)
if not keepdims:
for ax in sorted(axes):
g = np.expand_dims(g, axis=ax)
g = np.ones_like(self.data) * g
self.grad = g if self.grad is None else (self.grad + g)
out._backward = _backward
return out
def tensor_mean(self, axis=None, keepdims=False) -> "Tensor":
"""Mean = sum / count."""
if axis is None:
denom = self.data.size
else:
axes = axis if isinstance(axis, tuple) else (axis,)
denom = np.prod([self.data.shape[ax] for ax in axes])
return self.sum(axis=axis, keepdims=keepdims) * (1.0 / float(denom))
Tensor.sum = tensor_sum
Tensor.mean = tensor_mean
# Test
x = Tensor([[1, 2, 3], [4, 5, 6]], requires_grad=True)
loss = x.mean()
print(f"Mean: {loss.data}")
loss.backward()
print(f"Gradient (each element contributes 1/6): \n{x.grad}")Mean: 3.5
Gradient (each element contributes 1/6):
[[0.16666667 0.16666667 0.16666667]
[0.16666667 0.16666667 0.16666667]]
Putting It Together: A Tiny Neural Network
Use our tensor autograd to train a small network:
# Complete example: train a 2-layer network on XOR
np.random.seed(42)
# XOR dataset
X = Tensor([[0, 0], [0, 1], [1, 0], [1, 1]], requires_grad=False)
y = Tensor([[0], [1], [1], [0]], requires_grad=False)
# Weights (small random init)
W1 = Tensor(np.random.randn(2, 8) * 0.5, requires_grad=True)
b1 = Tensor(np.zeros((1, 8)), requires_grad=True)
W2 = Tensor(np.random.randn(8, 1) * 0.5, requires_grad=True)
b2 = Tensor(np.zeros((1, 1)), requires_grad=True)
params = [W1, b1, W2, b2]
lr = 0.5
for step in range(200):
# Forward
h = X.matmul(W1) + b1 # (4, 8)
h = h.tanh() # activation
out = h.matmul(W2) + b2 # (4, 1)
# MSE loss
diff = out + (y * -1.0) # out - y
loss = (diff * diff).mean()
# Backward
for p in params:
p.grad = None
loss.backward()
# SGD update
for p in params:
p.data -= lr * p.grad
if step % 50 == 0:
print(f"Step {step}: loss = {loss.data:.4f}")
print(f"\nFinal predictions:")
print(f" [0,0] -> {out.data[0,0]:.3f} (target: 0)")
print(f" [0,1] -> {out.data[1,0]:.3f} (target: 1)")
print(f" [1,0] -> {out.data[2,0]:.3f} (target: 1)")
print(f" [1,1] -> {out.data[3,0]:.3f} (target: 0)")Step 0: loss = 1.0835
Step 50: loss = 0.1430
Step 100: loss = 655805535748096.0000
Step 150: loss = nan
Final predictions:
[0,0] -> nan (target: 0)
[0,1] -> nan (target: 1)
[1,0] -> nan (target: 1)
[1,1] -> nan (target: 0)
/opt/hostedtoolcache/Python/3.11.14/x64/lib/python3.11/site-packages/numpy/_core/_methods.py:49: RuntimeWarning: overflow encountered in reduce
return umr_sum(a, axis, dtype, out, keepdims, initial, where)
/tmp/ipykernel_3006/919648715.py:36: RuntimeWarning: overflow encountered in multiply
self.data * other.data,
/tmp/ipykernel_3006/744146672.py:24: RuntimeWarning: overflow encountered in matmul
dA = np.matmul(out.grad, np.swapaxes(other.data, -1, -2))
/tmp/ipykernel_3006/616841562.py:27: RuntimeWarning: invalid value encountered in multiply
g = out.grad * (1.0 - t * t)
This trained neural network uses only NumPy and 100 lines of autograd code.
PyTorch’s Tensor Autograd
The same XOR problem in PyTorch:
import torch
# Same XOR problem
X_pt = torch.tensor([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=torch.float32)
y_pt = torch.tensor([[0], [1], [1], [0]], dtype=torch.float32)
torch.manual_seed(42)
# Note: create tensor, multiply, THEN set requires_grad to keep as leaf tensors
W1_pt = (torch.randn(2, 8) * 0.5).requires_grad_(True)
b1_pt = torch.zeros(1, 8, requires_grad=True)
W2_pt = (torch.randn(8, 1) * 0.5).requires_grad_(True)
b2_pt = torch.zeros(1, 1, requires_grad=True)
params_pt = [W1_pt, b1_pt, W2_pt, b2_pt]
for step in range(200):
# Forward - identical logic!
h = (X_pt @ W1_pt + b1_pt).tanh()
out = h @ W2_pt + b2_pt
loss = ((out - y_pt) ** 2).mean()
# Backward - one line
loss.backward()
# Update - use .data to modify in-place, keeping tensors as leaves
with torch.no_grad():
for p in params_pt:
p.data -= 0.5 * p.grad
p.grad = None
if step % 50 == 0:
print(f"Step {step}: loss = {loss.item():.4f}")Step 0: loss = 0.3617
Step 50: loss = nan
Step 100: loss = nan
Step 150: loss = nan
Key Insight
The logic is identical. PyTorch:
- Handles
_unbroadcastautomatically - Uses optimized C++/CUDA kernels
- Provides convenient
loss.backward()without manual graph traversal - Has
torch.no_grad()context for updates
You now understand what happens inside requires_grad=True and backward().
PyTorch Autograd (Scalar Examples)
See how PyTorch does the same thing with scalars:
# Create tensors that track gradients
x = torch.tensor(2.0, requires_grad=True)
w = torch.tensor(3.0, requires_grad=True)
b = torch.tensor(1.0, requires_grad=True)
# Forward pass
y = w * x + b # Linear function: y = 3*2 + 1 = 7
loss = y ** 2 # Square loss: loss = 49
# Backward pass - PyTorch computes all gradients automatically
loss.backward()
# Derivation using chain rule:
# dloss/dy = 2*y = 2*7 = 14
# dloss/dx = dloss/dy * dy/dx = 14 * w = 14 * 3 = 42
# dloss/dw = dloss/dy * dy/dw = 14 * x = 14 * 2 = 28
# dloss/db = dloss/dy * dy/db = 14 * 1 = 14
print(f"y = {y.item():.1f}, loss = {loss.item():.1f}")
print(f"dloss/dx = {x.grad.item():.1f} (= 2*y*w = 2*7*3)")
print(f"dloss/dw = {w.grad.item():.1f} (= 2*y*x = 2*7*2)")
print(f"dloss/db = {b.grad.item():.1f} (= 2*y*1 = 2*7)")y = 7.0, loss = 49.0
dloss/dx = 42.0 (= 2*y*w = 2*7*3)
dloss/dw = 28.0 (= 2*y*x = 2*7*2)
dloss/db = 14.0 (= 2*y*1 = 2*7)
# With vectors
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
w = torch.tensor([0.1, 0.2, 0.3], requires_grad=True)
# Forward pass
y = (x * w).sum() # Dot product
print(f"y = x . w = {y.item():.4f}")
# Backward pass
y.backward()
print(f"\ndy/dx = {x.grad}") # Should be w
print(f"dy/dw = {w.grad}") # Should be xy = x . w = 1.4000
dy/dx = tensor([0.1000, 0.2000, 0.3000])
dy/dw = tensor([1., 2., 3.])
# With matrices (like in attention)
Q = torch.randn(2, 3, requires_grad=True) # Query
K = torch.randn(2, 3, requires_grad=True) # Key
# Attention scores: Q @ K^T
scores = Q @ K.T
loss = scores.sum()
loss.backward()
print(f"Q shape: {Q.shape}")
print(f"K shape: {K.shape}")
print(f"scores shape: {scores.shape}")
print(f"\ndloss/dQ shape: {Q.grad.shape}")
print(f"dloss/dK shape: {K.grad.shape}")
print("\nGradients have the same shape as the original tensors!")Q shape: torch.Size([2, 3])
K shape: torch.Size([2, 3])
scores shape: torch.Size([2, 2])
dloss/dQ shape: torch.Size([2, 3])
dloss/dK shape: torch.Size([2, 3])
Gradients have the same shape as the original tensors!
Interactive Exploration
Now that you’ve seen gradient computation in code, explore it interactively. The widget below lets you modify input values and see how gradients change in real-time — demonstrating the chain rule in action.
```{ojs}
//| echo: false
// Theme colors - uses CSS variables from _diagram-variables.scss
// Falls back to diagramTheme values when specific variables aren't available
theme = {
// Primary colors from CSS variables
bgPrimary: getCSSVar('--diagram-bg', diagramTheme.bg),
textPrimary: getCSSVar('--diagram-node-text', diagramTheme.nodeText),
textMuted: getCSSVar('--diagram-edge-stroke', diagramTheme.edgeStroke),
// Input nodes - use accent color system
inputBg: getCSSVar('--diagram-node-fill', diagramTheme.nodeFill),
inputBorder: getCSSVar('--diagram-accent', diagramTheme.accent),
inputText: getCSSVar('--diagram-accent', diagramTheme.accent),
// Operation nodes - use highlight color system
opBg: getCSSVar('--diagram-node-fill-alt', diagramTheme.nodeFillHover),
opBorder: getCSSVar('--diagram-highlight', diagramTheme.highlight),
opText: getCSSVar('--diagram-highlight', diagramTheme.highlight),
// Output nodes - blend of node colors
outputBg: getCSSVar('--diagram-node-fill', diagramTheme.nodeFill),
outputBorder: diagramTheme.success,
outputText: diagramTheme.success,
// Gradient text - use error color for visibility
gradientText: diagramTheme.error,
// Arrow color
arrowColor: getCSSVar('--diagram-edge-stroke', diagramTheme.edgeStroke)
}
```
ImportantOJS Syntax Error (line 1683, column 14)Unexpected token
TipTry This
Gradient depends on values: Change a from 2 to 4. Watch ∂c/∂b change (it equals a + 2b).
b has two paths: Notice ∂c/∂b is the sum of two contributions - one from the multiplication (= sum) and one via the addition (= b).
Zero gradients: Set b = 0. Now ∂c/∂a = 0 because the multiplication by b kills the gradient.
Negative gradients: Try negative values. Gradients can be negative, indicating the output decreases when the input increases.
Verify the formula: For any a, b values: ∂c/∂a should equal b, and ∂c/∂b should equal a + 2b.
Exercises
Exercise 1: Verify the Gradient of x^3
# Verify the gradient of x^3 at x=2
# Expected: d/dx[x^3] = 3x^2 = 12
x = Value(2.0, label='x')
y = x ** 3
y.backward()
print(f"x = {x.data}")
print(f"y = x^3 = {y.data}")
print(f"dy/dx = {x.grad} (expected: 12.0)")x = 2.0
y = x^3 = 8.0
dy/dx = 12.0 (expected: 12.0)
Exercise 2: Softmax Gradient
# Compute gradient of softmax numerator
# If y = exp(x) / (exp(x) + exp(z)), what is dy/dx?
x = Value(1.0, label='x')
z = Value(2.0, label='z')
exp_x = x.exp()
exp_z = z.exp()
y = exp_x / (exp_x + exp_z)
y.backward()
print(f"x = {x.data}, z = {z.data}")
print(f"y = softmax(x)[0] = {y.data:.4f}")
print(f"dy/dx = {x.grad:.4f}")
print(f"\nThis is the gradient that flows back through softmax!")x = 1.0, z = 2.0
y = softmax(x)[0] = 0.2689
dy/dx = 0.1966
This is the gradient that flows back through softmax!
Exercise 3: ReLU vs Tanh Gradients
# Compare ReLU vs tanh gradients
for x_val in [-2.0, -0.5, 0.5, 2.0]:
x_relu = Value(x_val)
x_tanh = Value(x_val)
y_relu = x_relu.relu()
y_tanh = x_tanh.tanh()
y_relu.backward()
y_tanh.backward()
print(f"x={x_val:5.1f}: ReLU grad={x_relu.grad:.3f}, tanh grad={x_tanh.grad:.3f}")
print("\nNotice: ReLU has 0 or 1, tanh is smooth but saturates for large |x|")x= -2.0: ReLU grad=0.000, tanh grad=0.071
x= -0.5: ReLU grad=0.000, tanh grad=0.786
x= 0.5: ReLU grad=1.000, tanh grad=0.786
x= 2.0: ReLU grad=1.000, tanh grad=0.071
Notice: ReLU has 0 or 1, tanh is smooth but saturates for large |x|
Backpropagation in Neural Networks
Gradients flow backward through a neural network layer:
Key insights:
- Gradients flow backward: Starting from the loss, we trace back through every operation
- Chain rule connects layers: Multiply local gradients along the path
- Accumulation: If a value is used multiple times, gradients add up
- Shape matters: dL/dW must have the same shape as W for the update
Detaching Tensors and Stopping Gradients
Sometimes you want to stop gradient flow. PyTorch provides several mechanisms:
Using detach()
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x * 2
z = y.detach() # z has no gradient history
print(f"y.requires_grad: {y.requires_grad}")
print(f"z.requires_grad: {z.requires_grad}")
# z is now a "constant" - no gradients flow through it
loss = z.sum()
# loss.backward() would NOT compute gradients for xy.requires_grad: True
z.requires_grad: False
Using no_grad()
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x * 2
# Compute without building graph (saves memory)
with torch.no_grad():
z = y * 3
print(f"Inside no_grad, z.requires_grad: {z.requires_grad}")
# Common use: inference
model_output = y # pretend this is model output
with torch.no_grad():
prediction = model_output.argmax() # no gradients neededInside no_grad, z.requires_grad: False
When to Stop Gradients
- Inference: No training, no gradients needed
- Frozen layers: Transfer learning with some layers fixed
- Metrics: Computing accuracy, loss for logging (not backprop)
- Target values: In losses like MSE, the target is a constant
Memory Implications
Why Autograd Uses Memory
During the forward pass, autograd stores intermediate values needed for gradient computation:
# Each operation stores data for backward pass
x = torch.randn(1000, 1000, requires_grad=True)
y = x @ x.T # Stores x for gradient computation
z = y.relu() # Stores y (to know which elements > 0)
out = z.mean() # Stores z
# All of x, y, z stay in memory until backward() completes
out.backward()
# Now intermediate tensors can be freedMemory Grows with Depth
For a model with L layers: - Forward pass: O(L) memory for activations - Each activation tensor can be large (batch_size x hidden_dim) - LLMs with 100+ layers and large hidden dims = huge memory
Gradient Checkpointing
Trade compute for memory by recomputing activations during backward:
from torch.utils.checkpoint import checkpoint
def expensive_layer(x):
"""A layer we want to checkpoint."""
return x.relu().pow(2)
x = torch.randn(100, 100, requires_grad=True)
# Without checkpointing: stores intermediate activations
y_normal = expensive_layer(x)
# With checkpointing: discards intermediates, recomputes during backward
y_checkpoint = checkpoint(expensive_layer, x, use_reentrant=False)
print(f"Both produce same result: {torch.allclose(y_normal, y_checkpoint)}")Both produce same result: True
In practice, checkpoint every few layers to reduce memory by ~sqrt(L).
Common Pitfalls
1. Forgetting to Zero Gradients
x = torch.tensor(2.0, requires_grad=True)
# First backward
y = x ** 2
y.backward()
print(f"After first backward: x.grad = {x.grad}")
# Second backward without zeroing - gradients ACCUMULATE!
y = x ** 2
y.backward()
print(f"After second backward: x.grad = {x.grad}") # 8.0, not 4.0!
# Always zero gradients before backward in training loops
x.grad.zero_()
y = x ** 2
y.backward()
print(f"After zeroing: x.grad = {x.grad}") # Back to 4.0After first backward: x.grad = 4.0
After second backward: x.grad = 8.0
After zeroing: x.grad = 4.0
2. In-place Operations
x = torch.tensor([1.0, 2.0], requires_grad=True)
y = x * 2
# This would break the graph (commented to avoid error):
# y.add_(1) # In-place operation on a tensor needed for gradient
# Instead, use out-of-place operations:
y = y + 1 # Creates new tensor, graph preserved
y.sum().backward()
print(f"x.grad: {x.grad}")x.grad: tensor([2., 2.])
3. Losing requires_grad
x = torch.tensor([1.0, 2.0], requires_grad=True)
# Can't call .numpy() directly on a tensor requiring grad:
# y = x.numpy() # RuntimeError: Can't call numpy() on tensor that requires grad
# Must detach first - this explicitly breaks the graph
y = x.detach().numpy()
print(f"y is now numpy array: {type(y)}")
# Converting back loses gradient tracking:
z = torch.from_numpy(y)
print(f"z.requires_grad: {z.requires_grad}") # False
# Be careful when mixing numpy and autogrady is now numpy array: <class 'numpy.ndarray'>
z.requires_grad: False
4. Backward Through Non-Scalar
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2 # Vector output
# This fails (commented to avoid error):
# y.backward() # RuntimeError: need gradient argument
# For non-scalar outputs, provide a gradient tensor:
y.backward(torch.ones_like(y)) # Equivalent to y.sum().backward()
print(f"x.grad: {x.grad}")x.grad: tensor([2., 4., 6.])
Summary
Key takeaways:
- Gradients flow backward through the computational graph
- Chain rule connects everything: multiply local gradients
- Gradients accumulate when a value is used multiple times
- Training = gradient descent: use gradients to adjust parameters
- PyTorch autograd does this automatically for any computation
- Memory matters: intermediate activations consume memory; use
detach(),no_grad(), or gradient checkpointing - Zero gradients: always zero gradients before each backward pass in training loops
What’s Next
Module 03: Tokenization covers converting text into numbers that our model can process. Autograd works behind the scenes during training, but we need input data first!