TAI BUI
Back to curriculum
BuildPython

Chain Rule & Automatic Differentiation

The chain rule is the entire trick behind training a neural network. Automatic differentiation is just that trick, running as code.

TIME: ~90 minutesPREREQUISITES: Phase 1, Lesson 04 (Calculus for Machine Learning)

Learning Objectives

  • Build a minimal autograd engine (Value class) that records operations and computes gradients via reverse-mode autodiff
  • Implement forward and backward passes through a computation graph using topological sort
  • Construct and train a multi-layer perceptron on XOR using only the from-scratch autograd engine
  • Verify autodiff correctness using gradient checking against numerical finite differences

The Problem

You already know how to take the derivative of a simple function. A neural network is not a simple function. It's hundreds of them, stacked and composed: matrix multiply, add bias, apply an activation, matrix multiply again, softmax, cross-entropy loss. The output is a function of a function of a function, dozens of layers deep.

Training the network means finding the gradient of the loss with respect to every single weight, all at once. By hand, that's impossible for millions of parameters. Numerically, with finite differences, it works, but it's too slow to run at the scale training requires.

The chain rule supplies the math. Automatic differentiation supplies the algorithm. Together, they compute exact gradients through an arbitrary composition of functions, in time proportional to a single forward pass.

This is the actual mechanism behind PyTorch, TensorFlow, and JAX. You're about to build a working miniature of it yourself.

Warm-up0/3
1. What does the derivative of a function at a point tell you?Warm-up
A smooth rising curve on a plain grid with a single point marked on it, and a note asking what the derivative tells you right at that point.
2. Let f(x) = x + 1 and g(x) = 2x. What is f(g(3))?Warm-up
A pipeline of three boxes: x equals 3, then g(x) equals 2x, then f(x) equals x plus 1, ending in a question mark, with a note to work from the inside out.
3. A Python loop runs `total += x` on every pass over a list of numbers. After the loop, what does `total` hold?Warm-up
A small loop adding total += x over the list 4, 1, 7, 2, next to a table of passes with the x value shown but the running total left as a question mark on every row.

The Concept

The Chain Rule

If y = f(g(x)), the derivative of y with respect to x is:

dy/dx = dy/dg * dg/dx = f'(g(x)) * g'(x)

Multiply the derivatives along the chain, one link at a time. Each link contributes its own local derivative.

Take y = sin(x^2) as a concrete example:

g(x) = x^2       g'(x) = 2x
f(g) = sin(g)     f'(g) = cos(g)

dy/dx = cos(x^2) * 2x

Deeper compositions just extend the chain:

y = f(g(h(x)))

dy/dx = f'(g(h(x))) * g'(h(x)) * h'(x)

Every layer in a neural network is one more link.

Computational Graphs

A computational graph makes the chain rule visual. Every operation becomes a node. Data flows forward through the graph. Gradients flow backward through it.

Forward pass (compute values):

Mermaid diagram

Backward pass (compute gradients):

Mermaid diagram

The backward pass applies the chain rule at every node, propagating gradients from the output back to the inputs.

Forward Mode vs Reverse Mode

There are two ways to push the chain rule through a graph.

Forward mode starts at the inputs and pushes derivatives forward. It seeds dx/dx = 1 and propagates that through each operation. Good when you have few inputs and many outputs.

Forward mode: seed dx/dx = 1, propagate forward

  x = 2       (dx/dx = 1)
  a = x^2     (da/dx = 2x = 4)
  y = sin(a)  (dy/dx = cos(a) * da/dx = cos(4) * 4 = -2.615)

Reverse mode starts at the output and pulls gradients backward. It seeds dy/dy = 1 and propagates that through each operation in reverse. Good when you have many inputs and few outputs.

Reverse mode: seed dy/dy = 1, propagate backward

  y = sin(a)  (dy/dy = 1)
  a = x^2     (dy/da = cos(a) = cos(4) = -0.654)
  x = 2       (dy/dx = dy/da * da/dx = -0.654 * 4 = -2.615)

Neural networks have millions of inputs (weights) and exactly one output (loss). Reverse mode computes every gradient in a single backward pass, which is exactly why backpropagation uses reverse mode.

ModeSeedDirectionBest when
Forwarddx_i/dx_i = 1Input to outputFew inputs, many outputs
Reversedy/dy = 1Output to inputMany inputs, few outputs (neural nets)

Dual Numbers for Forward Mode

Forward mode has a clean implementation: dual numbers. A dual number has the form a + b*epsilon, where epsilon^2 = 0.

Dual number: (value, derivative)

(2, 1) means: value is 2, derivative w.r.t. x is 1

Arithmetic rules:
  (a, a') + (b, b') = (a+b, a'+b')
  (a, a') * (b, b') = (a*b, a'*b + a*b')
  sin(a, a')         = (sin(a), cos(a)*a')

Seed the input variable with derivative 1, and the derivative propagates automatically through every operation after that.

Building an Autograd Engine

An autograd engine needs three things:

  1. Value wrapping. Wrap every number in an object that stores its value and its gradient.
  2. Graph recording. Every operation records its inputs and the local gradient function used to compute its output.
  3. Backward pass. Topologically sort the graph (order every node so it comes after everything it depends on), then walk it in reverse, applying the chain rule at each node.

This is exactly what PyTorch's autograd does. The torch.Tensor class wraps values, records operations whenever requires_grad=True, and computes gradients the moment you call .backward().

How PyTorch Autograd Works Under the Hood

When you write PyTorch code:

python
x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 + 3 * x + 1
y.backward()
print(x.grad)  # 7.0 = 2*x + 3 = 2*2 + 3

PyTorch, internally, does five things:

  1. Creates a Tensor node for x with requires_grad=True.
  2. Every operation (**, *, +) creates a new node and records the backward function.
  3. y.backward() triggers reverse-mode autodiff through the recorded graph.
  4. Each node's grad_fn computes local gradients and passes them to parent nodes.
  5. Gradients accumulate in .grad attributes via addition, never replacement.

The graph itself is dynamic, built define-by-run: a fresh graph gets constructed on every forward pass. That's exactly why PyTorch supports ordinary Python control flow (if/else, loops) inside a model.

Check: The Concept0/13
1. What does the chain rule in calculus state?Define
A two-stage chain x into g(x) into f(g), with g prime of x and f prime of g shown as two separate boxed local derivatives feeding into a question mark for dy/dx.
2. What is a computational graph?Define
A flowchart: x1 = 2 and x2 = 3 feed a multiply node (a = 6); a and the constant b = 1 both feed an add node (c = 7); c feeds a relu node producing output y = 7. Only forward values are shown, no gradients.
3. What is forward-mode autodiff?Define
x = 2 seeded with dx/dx = 1, an arrow to a = x squared = 4 with da/dx = 2x = 4 computed, then an arrow to a question mark; the seed sits at the leftmost, input node.
4. What is reverse-mode autodiff?Define
y = sin(a) seeded with dy/dy = 1, an arrow pointing left to a = x squared with dy/da = cos(4) about -0.654 computed, then an arrow further left to a question mark; the seed sits at the rightmost, output node.
5. In forward-mode autodiff with dual numbers, what does the pair (2, 1) represent?Define
The three dual-number arithmetic rules for addition, multiplication and sine written generically with a and a prime, b and b prime, next to the specific pair (2, 1) marked with a question mark.
6. What is autograd?Define
A Value node c = a * b shown holding pointers back to its inputs a and b, an unrun backward closure, and a grad still at zero, with a note asking what kind of system keeps records like this.
7. What is a dynamic (define-by-run) graph?Define
The same forward function with an if/else branch, run with x = 3 (taking the if branch, graph x then times 2) and run with x = -1 (taking the else branch, graph x then plus 5), producing two different recorded graphs.
8. What is a topological sort of a computation graph?Define
A small dependency graph with nodes x1, x2, a, b and y connected by arrows (x1 and x2 into a, a into b, b into y), with an empty ordered list of five question marks beside it.
9. Why does backpropagation use reverse-mode rather than forward-mode autodiff for neural networks?Explain
A fan of many weight nodes on the left, all connecting into a single loss node on the right, labelled millions of weights feeding one scalar loss.
10. Why does PyTorch's dynamic (define-by-run) graph let a model use Python if/else and loops?Explain
A single forward call with an if/else branch, with a note that x = 3 makes x.data > 0 true, so the if branch executed and the else branch did not.
11. Reverse mode on y = sin(x^2) at x = 2 gives dy/da = cos(4) ≈ -0.654 for a = x^2. What is dy/dx?Apply
Two known pieces for y = sin(x squared) at x = 2: da/dx = 4.0 computed forward, and dy/da about -0.654 given, with dy/dx left as dy/da times da/dx equals a question mark.
12. Using the dual-number rule (a, a') * (b, b') = (a*b, a'*b + a*b'), what is (2, 1) * (3, 0)?Apply
The dual-number product rule written out, applied to the pair (2, 1) times (3, 0), with both slots of the result left as question marks.
13. You need the derivatives of 1,000 different outputs with respect to a single input variable. Which mode fits best?Design
A single input x feeding into a large function box, which produces a labelled list of 1,000 different outputs, y1 through y1000.

Build It

Step 1: The Value class

python
class Value:
    def __init__(self, data, children=(), op=''):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(children)
        self._op = op
 
    def __repr__(self):
        return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

Every Value stores its numeric data, its gradient (initially zero), a backward function, and pointers to child nodes that produced it.

Step 2: Arithmetic operations with gradient tracking

python
    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
            other.grad += out.grad
        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
            other.grad += self.data * out.grad
        out._backward = _backward
        return out
 
    def relu(self):
        out = Value(max(0, self.data), (self,), 'relu')
        def _backward():
            self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
        out._backward = _backward
        return out

Each operation creates a closure that knows how to compute its own local gradient and multiply that value by the upstream gradient (out.grad). The += matters because a single value can feed into more than one operation, and each one contributes its own share of the gradient.

Step 3: The backward pass

python
    def backward(self):
        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)
 
        self.grad = 1.0
        for v in reversed(topo):
            v._backward()

Topologically sorting the graph first ensures every node's gradient is fully computed before it propagates to its children. The seed gradient is 1.0 (dy/dy = 1).

Step 4: More operations for a complete engine

The basic Value class only handles addition, multiplication, and relu. A real autograd engine needs more than that. Here's what you need to actually build neural networks:

python
    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 __rsub__(self, other):
        return other + (-self)
 
    def __pow__(self, n):
        out = Value(self.data ** n, (self,), f'**{n}')
        def _backward():
            self.grad += n * (self.data ** (n - 1)) * out.grad
        out._backward = _backward
        return out
 
    def __truediv__(self, other):
        return self * (other ** -1) if isinstance(other, Value) else self * (Value(other) ** -1)
 
    def exp(self):
        import math
        e = math.exp(self.data)
        out = Value(e, (self,), 'exp')
        def _backward():
            self.grad += e * 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 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

Why each operation matters:

OperationBackward ruleUsed in
__sub__Reuses add + negLoss computation (pred - target)
__pow__n * x^(n-1)Polynomial activations, MSE (error^2)
__truediv__Reuses mul + pow(-1)Normalization, learning rate scaling
expexp(x) * upstreamSoftmax, log-likelihood
log(1/x) * upstreamCross-entropy loss, log probabilities
tanh(1 - tanh^2) * upstreamClassic activation function

The clever part: __sub__ and __truediv__ are defined in terms of existing operations. They get correct gradients for free because the chain rule composes through the underlying add/mul/pow operations.

Step 5: Mini MLP from scratch

With a complete Value class, you can build a neural network. No PyTorch. No NumPy. Just Values and the chain rule.

python
import random
 
class Neuron:
    def __init__(self, n_inputs):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(n_inputs)]
        self.b = Value(0.0)
 
    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()
 
    def parameters(self):
        return self.w + [self.b]
 
class Layer:
    def __init__(self, n_inputs, n_outputs):
        self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]
 
    def __call__(self, x):
        return [n(x) for n in self.neurons]
 
    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]
 
class MLP:
    def __init__(self, sizes):
        self.layers = [Layer(sizes[i], sizes[i+1]) for i in range(len(sizes)-1)]
 
    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x[0] if len(x) == 1 else x
 
    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

A Neuron computes tanh(w1*x1 + w2*x2 + ... + b). A Layer is a list of neurons. An MLP stacks layers. Every weight is a Value, so calling loss.backward() propagates gradients to every parameter.

Training on XOR:

python
random.seed(42)
model = MLP([2, 4, 1])  # 2 inputs, 4 hidden neurons, 1 output
 
xs = [[0, 0], [0, 1], [1, 0], [1, 1]]
ys = [-1, 1, 1, -1]  # XOR pattern (using -1/1 for tanh)
 
for step in range(100):
    preds = [model(x) for x in xs]
    loss = sum((p - y) ** 2 for p, y in zip(preds, ys))
 
    for p in model.parameters():
        p.grad = 0.0
    loss.backward()
 
    lr = 0.05
    for p in model.parameters():
        p.data -= lr * p.grad
 
    if step % 20 == 0:
        print(f"step {step:3d}  loss = {loss.data:.4f}")
 
print("\nPredictions after training:")
for x, y in zip(xs, ys):
    print(f"  input={x}  target={y:2d}  pred={model(x).data:6.3f}")

This is micrograd. A complete neural network training loop in pure Python with automatic differentiation. Every commercial deep learning framework does the same thing at massive scale.

Step 6: Gradient checking

How do you know your autodiff is correct? Compare it against numerical derivatives. This is gradient checking.

python
def gradient_check(build_expr, x_val, h=1e-7):
    x = Value(x_val)
    y = build_expr(x)
    y.backward()
    autodiff_grad = x.grad
 
    y_plus = build_expr(Value(x_val + h)).data
    y_minus = build_expr(Value(x_val - h)).data
    numerical_grad = (y_plus - y_minus) / (2 * h)
 
    diff = abs(autodiff_grad - numerical_grad)
    return autodiff_grad, numerical_grad, diff

Test it on a complex expression:

python
def expr(x):
    return (x ** 3 + x * 2 + 1).tanh()
 
ad, num, diff = gradient_check(expr, 0.5)
print(f"Autodiff:  {ad:.8f}")
print(f"Numerical: {num:.8f}")
print(f"Difference: {diff:.2e}")
# Difference should be < 1e-5

Gradient checking is essential when implementing new operations. If your backward pass has a bug, the numerical check catches it. Every serious deep learning implementation runs gradient checks during development.

When to use gradient checking:

SituationDo gradient check?
Adding a new operation to your autogradYes, always
Debugging a training loop that won't convergeYes, check gradients first
Production trainingNo, too slow (2x forward passes per parameter)
Unit tests for autograd codeYes, automate it

Step 7: Verify against manual calculation

python
x1 = Value(2.0)
x2 = Value(3.0)
a = x1 * x2          # a = 6.0
b = a + Value(1.0)    # b = 7.0
y = b.relu()          # y = 7.0
 
y.backward()
 
print(f"y = {y.data}")          # 7.0
print(f"dy/dx1 = {x1.grad}")   # 3.0 (= x2)
print(f"dy/dx2 = {x2.grad}")   # 2.0 (= x1)

Manual check: y = relu(x1*x2 + 1). Since x1*x2 + 1 = 7 > 0, relu is identity. dy/dx1 = x2 = 3. dy/dx2 = x1 = 2. The engine matches.

Check: Build It0/22
1. In the Value engine, what is gradient accumulation?Define
A value x with arrows to two separate operations, op A and op B, both of which use x, and a note asking what x's gradient holds once both operations have run.
2. What is an MLP?Define
A layered network diagram: 2 input nodes, fully connected to 4 hidden nodes, fully connected to 1 output node, labelled input, hidden (each applying a weighted sum plus activation) and output.
3. In a from-scratch MLP built on Values, what does a single neuron compute?Define
A single neuron: inputs x1, x2, x3 arriving along weighted arrows w1, w2, w3 into a sum-plus-bias node, then into an activation box, then out as y.
4. What is gradient checking and when should you use it?Define
Two separate boxes, one computing a derivative by autodiff (backward) and one by a numerical finite-difference formula, both feeding into a comparison marked with a question mark.
5. Why does backward() topologically sort the graph before it computes any gradients?Explain
Node a feeding two later operations, op1 and op2, each marked with an unanswered contributed question mark, both pointing down into an a.grad box left as a question mark.
6. In the Value class, what does each operation's `_backward` closure compute?Explain
The Value class's own __add__ and __mul__ backward closures side by side, each adding onto self.grad and other.grad using out.grad.
7. Why does backward() set `self.grad = 1.0` before walking the graph?Explain
The start of backward(), with the line self.grad = 1.0 marked, set right before walking the topological order in reverse.
8. Why does `__sub__`, defined as `self + (-other)`, give correct gradients without any backward code of its own?Explain
The Value class's __neg__ (self times negative one) and __sub__ (self plus negative other) with a note that neither one writes its own backward closure.
9. Why does each Value keep references to the nodes that produced it (`_prev`)?Explain
Two panels: with _prev links, a solid arrow connects a and b into c = a*b; without _prev, those same links are dashed and broken, marked with a question mark.
10. A from-scratch MLP with a tanh output is trained on XOR targets [-1, 1, 1, -1] instead of [0, 1, 1, 0]. Why?Explain
A tanh curve plotted from -3 to 3, beside the two XOR-target encodings named in the question: the one used in training (-1, 1, 1, -1) and the one not used (0, 1, 1, 0), with no indication of which encoding fits the curve.
11. Why is gradient checking used in unit tests but not in production training?Explain
A table of gradient-check cost in forward passes for 1, 1,000 and 1,000,000 parameters, each row double the parameter count.
12. A Value x = Value(2.0) calls y = x.exp(), then y.backward(). Using the engine's exp rule, what is x.grad?Apply
The Value class's own exp() method, with e marked as unresolved, and a note that out.grad is seeded at 1.0, asking for x.grad.
13. With x = Value(3.0), y = x * x and y.backward(), what is x.grad?Apply
x = 3.0 feeding both the self side and the other side of the same multiply, x times x, with a note that y.backward() seeds out.grad at 1.0 and asks for x.grad.
14. With x1 = Value(2.0), x2 = Value(-3.0), a = x1 * x2, b = a + Value(1.0), y = b.relu(), what are x1.grad and x2.grad after y.backward()?Apply
The forward chain for x1 = 2.0 and x2 = -3.0: a = -6, b = -5, y = relu(b) = 0, with a note asking for x1.grad and x2.grad after y.backward().
15. How many learnable parameters (weights and biases) does MLP([2, 4, 1]) have, with Neuron, Layer and MLP built as in the Value engine?Apply
The MLP([2, 4, 1]) network with 2 inputs, 4 hidden neurons and 1 output, noting each hidden neuron has 2 weights plus 1 bias and the output neuron has 4 weights plus 1 bias, with no total given.
16. In the training loop a parameter has p.data = 1.0 and p.grad = 2.0, with lr = 0.05. What is p.data after `p.data -= lr * p.grad`?Apply
The update line p.data -= lr times p.grad, with given values p.data = 1.0, p.grad = 2.0, lr = 0.05, and the result after the line left as a question mark.
17. Your autograd engine gives correct gradients for simple expressions but wrong numbers once one value feeds two operations. The backward closures use '=' instead of '+='. Why does '+=' fix it?Analyze
A shared value x feeding op1 and op2, where op1's closure sets x.grad with '=' and op2's closure uses '+=' on x.grad, with no indication of which one is the bug.
18. Someone replaces the reversed topological order in backward() with an arbitrary node order. Gradients for nodes that feed several operations come out wrong. What is the likely cause?Analyze
A shared node feeding op1 (already run) and op2 (not yet run), with a note asking what the shared node would pass to its own children if its backward ran right now, using only op1's share.
19. A training loop's loss turns erratic after a few steps. The `for p in model.parameters(): p.grad = 0.0` lines have been deleted. Why does that break training?Analyze
The training loop with the p.grad = 0.0 reset line commented out as deleted, and a note asking what happens to p.grad across steps given that every closure uses plus-equals.
20. In a network built with relu, one neuron's weighted sum is negative for every training input. What happens to the weights feeding it?Analyze
A table of three training inputs, each giving a negative weighted sum into one neuron, with the relu gate drawn as closed and producing 0 every time, next to the weights that feed it.
21. You add a sqrt operation to the Value class. Which plan gives gradients you can trust?Design
The sqrt operation's forward formula and its local derivative, 1 over 2 times the square root of x, with a note that the new backward closure has to use that local derivative somehow, asking which plan gives trustworthy gradients.
22. Your from-scratch network will not converge. Changing the learning rate twice did nothing, and you recently added a new operation. What do you do first?Design
A timeline of three attempts: lowering the learning rate (no change), raising it (no change), and a new operation added recently and not yet checked, ending in a question mark for what to try next.

Use It

Verify against PyTorch

python
import torch
 
x1 = torch.tensor(2.0, requires_grad=True)
x2 = torch.tensor(3.0, requires_grad=True)
a = x1 * x2
b = a + 1.0
y = torch.relu(b)
y.backward()
 
print(f"PyTorch dy/dx1 = {x1.grad.item()}")  # 3.0
print(f"PyTorch dy/dx2 = {x2.grad.item()}")  # 2.0

Same gradients. Your engine computes the same result as PyTorch because the math is the same: reverse-mode autodiff via the chain rule.

A more complex expression

python
a = Value(2.0)
b = Value(-3.0)
c = Value(10.0)
f = (a * b + c).relu()  # relu(2*(-3) + 10) = relu(4) = 4
 
f.backward()
print(f"df/da = {a.grad}")  # -3.0 (= b)
print(f"df/db = {b.grad}")  #  2.0 (= a)
print(f"df/dc = {c.grad}")  #  1.0
Check: Use It0/1
1. For f = (a * b + c).relu() with a = 2, b = -3, c = 10, what is a.grad after f.backward()?Apply
The forward computation 2 times -3 plus 10 equals 4, then relu of 4 equals 4 (positive, so the gradient passes through), with a.grad after f.backward() left as the question.

Ship It

This lesson produces:

  • outputs/skill-autodiff.md: a skill for building and debugging autograd systems.
  • code/autodiff.py: a minimal autograd engine you can keep extending.

The Value class you build here becomes the foundation for the neural network training loop in Phase 3.

Author's Note

The bug that will actually get you here isn't the chain rule, it's the +=. The first time I wrote a Value class, I used = instead of += in a couple of the _backward closures out of habit, the way you would for a variable that's only ever used once. It ran. It printed numbers. Every one of those numbers was wrong, quietly, in a way that only showed up two layers downstream where a shared weight fed two different neurons.

Gradient checking is the only reason I found it in under an hour instead of staring at a loss curve that wouldn't converge and blaming the learning rate. Run the numerical check on every new operation you add to this engine, not just once at the end. It's cheap insurance against a bug that autodiff will never raise an exception for.

Exercises

  1. Add __pow__ to the Value class so you can compute x ** n. Verify that d/dx(x^3) at x=2 equals 12.0.

  2. Add tanh as an activation function. Verify that tanh'(0) = 1 and tanh'(2) = 0.0707 (approx).

  3. Build a computation graph for a single neuron: y = relu(w1*x1 + w2*x2 + b). Compute all five gradients and verify against PyTorch.

  4. Implement forward-mode autodiff using dual numbers. Create a Dual class and verify it gives the same derivatives as your reverse-mode engine.

Key Terms

TermWhat people sayWhat it actually means
Chain rule"Multiply the derivatives"The derivative of composed functions equals the product of each function's local derivative, evaluated at the right point
Computational graph"The network diagram"A directed acyclic graph where nodes are operations and edges carry values (forward) or gradients (backward)
Forward mode"Push derivatives forward"Autodiff that propagates derivatives from inputs to outputs. One pass per input variable.
Reverse mode"Backpropagation"Autodiff that propagates gradients from outputs to inputs. One pass per output variable.
Autograd"Automatic gradients"A system that records operations on values, builds a graph, and computes exact gradients via the chain rule
Dual numbers"Value plus derivative"Numbers of the form a + b*epsilon (epsilon^2 = 0) that carry derivative information through arithmetic
Topological sort"Dependency order"Ordering graph nodes so every node comes after all its dependencies. Required for correct gradient propagation.
Gradient accumulation"Add, don't replace"When a value feeds into multiple operations, its gradient is the sum of all incoming gradient contributions
Dynamic graph"Define by run"A computation graph rebuilt on every forward pass, allowing Python control flow inside models (PyTorch style)
Gradient checking"Numerical verification"Comparing autodiff gradients against numerical finite-difference gradients to verify correctness. Essential for debugging.
MLP"Multi-layer perceptron"A neural network with one or more hidden layers of neurons. Each neuron computes a weighted sum plus bias, then applies an activation function.
Neuron"Weighted sum + activation"The basic unit: output = activation(w1x1 + w2x2 + ... + b). The weights and bias are learnable parameters.

Further Reading

Code

Ships With