Lesson 08 · Part II · Autograd on single numbers
Who owes the gradient?
Each op knows only its local slope. Is that enough?
This lesson builds on lessons 01–07. New here? Start at lesson 01, or carry on and load the finished pieces with one click.
You write a = Value(2.0), b = Value(3.0) and c = a + b, then set a.data = 10.0. What data do the two parents in c._prev hold now?
_prev kept copies of the numbers when c was made
_prev keeps the parent Values, not their numbers: backward needs the objects to write gradients into._prev holds a itself, so it sees the change
10.0 and 3.0. _prev holds the parent Values themselves, the objects a backward pass writes gradients into. c.data is still 5.0: nothing recomputes it until you add again. Today you walk those links backward.
Five straight-line layers in a row, with no kink between them. How many straight-line layers could replace them exactly?
One. A line of a line is a line, however many you stack: the slopes multiply and the offsets carry through.
Three suspects
A Value records the operation that made it and the Values it came from, so a loss built from Values carries its whole history.
Here is one. w = 0.5, x = 2 and b = 1 make the prediction = 2. The target is y = 3, so the loss is 1.
Nudge z itself from 2 to 2.01 and the loss becomes = 0.9801, down by 0.0199: a measured slope of −1.99, about −2 per unit of z.
Three numbers, w, x and b, made that miss. Which should change, and by how much? Nudging each would cost three more passes. Today every answer comes from one pass backward.
Nudge w from 0.50 to 0.51. Roughly how much does the loss change?
About −0.04. Each step along the path multiplies the nudge by that step's local slope. The cell below measures it: the loss goes from 1.0000 to 0.9604, a change of −0.0396, almost exactly 0.01 · 2 · (−2) = −0.04.
Nudge w by 0.01 and measure: one extra forward pass for one knob.
def loss(w, x=2.0, b=1.0, y=3.0):
z = w * x + b
return (z - y) ** 2
before, after = loss(0.50), loss(0.51)
print(f"loss at w = 0.50: {before:.4f}")
print(f"loss at w = 0.51: {after:.4f}")
print(f"change: {after - before:+.4f}")
loss at w = 0.50: 1.0000 loss at w = 0.51: 0.9604 change: -0.0396
Rates multiply
Each operation knows only its local slope: how fast its output moves per unit of one input, with the other input held still. For a sum , each input's local slope is 1. For a product , the local slope for a is b, and for b it is a. The square at the top has no rule yet; lesson 11 derives one. Its local slope here is the −2 you measured by nudging z.
Going backward, each operation multiplies the gradient arriving from above by its local slope, and hands the result to its inputs. That is the chain rule; applied backward through a whole graph, it is called backpropagation.
Along the path from w to L the local slopes multiply: which here is (−2) · 2 = −4, at w = 0.5, x = 2, b = 1 and y = 3.
Same graph. What is ?
(−2) · 1 = −2: a sum passes the gradient through unchanged.
And ?
(−2) · w = (−2) · 0.5 = −1: a product sends each input the gradient times the other input.
The graph of , walked backward from z's gradient of −2. Step through it: each node shows its value and the gradient it receives.
| node | op | forward value | gradient |
|---|---|---|---|
| b | input | 1 | -2 |
| x | input | 2 | -1 |
| w | input | 0.5 | -4 |
| w*x | * | 1 | -2 |
| z | + | 2 | -2 |
What: fill in the _backward of + and *: + sets each input's grad to out.grad; * sets each input's grad to out.grad times the other input's data.
Why: without a backward, every slope still costs one forward pass per knob. Every sum and product in every model you build later runs these lines. Lesson 09 fixes what happens when an input is used twice, and lesson 10 runs them in the right order for you.
Your ember can send a gradient backward through + and *.
- In out = self + other, if self grows by a tiny amount, how much does out grow? And in out = self * other?
- For +, each input's local slope is 1, so each gets out.grad. For *, self's local slope is other.data and other's is self.data; multiply each by out.grad, which is the chain rule.
- Nearly the code: in *'s _backward, self.grad = other.data * out.grad and other.grad = self.data * out.grad; in +'s, self.grad = out.grad and other.grad = out.grad.
- + has local slope 1 for both inputs, so each receives out.grad.
- For a * b, a's gradient is b times out.grad and b's is a times out.grad.
- Running _backward from L down to the leaves gives every gradient of L = (w*x) * (u + v).
- Calling _backward on a leaf is allowed and changes nothing.
- In a * 4 the gradient that reaches a is 4.
The graph above, backward by hand with your code: set z's gradient, then call each _backward from the top down. Running it redraws that graph from your Values, with their gradients; step through it to see them.
import hx
from ember.scalar import Value
w, x, b = Value(0.5), Value(2.0), Value(1.0)
for v, name in [(w, "w"), (x, "x"), (b, "b")]:
v.label = name
wx = w * x
wx.label = "w*x"
z = wx + b
z.label = "z"
def squared_miss(value):
return (value - 3.0) ** 2 # the loss as a function of z, with the target y = 3
z.grad = round((squared_miss(z.data + 0.000001) - squared_miss(z.data)) / 0.000001, 3) # measured by nudging z
z._backward() # + passes z's gradient to w*x and to b
wx._backward() # * passes w*x's gradient to w and to x
print(f"z.grad = {z.grad}, (w*x).grad = {wx.grad}")
print(f"w.grad = {w.grad}, x.grad = {x.grad}, b.grad = {b.grad}")
hx.show_graph(z)
z.grad = -2.0, (w*x).grad = -2.0 w.grad = -4.0, x.grad = -1.0, b.grad = -2.0
From one pass backward, the cell prints w.grad = −4.0, x.grad = −1.0 and b.grad = −2.0: the three answers to the questions above.
with a = 1, b = 2 and c = −3. What is ?
The product sends a + b the gradient c = −3, and the sum passes it through unchanged: −3. For c it is a + b = 3.
with a = 2, b = 3 and c = 4. What is ?
12. Check it by nudging: a = 2.01 gives 2.01 · 3 · 4 = 24.12, up 0.12 for a nudge of 0.01.
Where this goes
Your backward assumes each Value is used once. Use one twice, as in a * a, and it gives the wrong gradient without any error. Lesson 09 finds out why.
When you finish this lesson
Your ember can now: send a gradient backward through + and *
It already could:
- record how every number was made, and draw it as a graph
- measure every slope of a loss at once, and count the cost
- compute XOR with two layers and a kink
Next Many paths, one sum · A value used twice: why does = instead of += break training silently? · ~35 min