08

Lesson 08 · Part II · Autograd on single numbers

Who owes the gradient?

Each op knows only its local slope. Is that enough?

~40 min · 1 lab · builds on 07 A number that remembers

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?

2.0 and 3.0: _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.
10.0 and 3.0: _prev holds a itself, so it sees the change
c records a and b as objects, so reading a through c's record reads the a you changed.

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 z=w⋅x+bz = w \cdot x + b = 2. The target is y = 3, so the loss (z−y)2(z - y)^{2} is 1.

Nudge z itself from 2 to 2.01 and the loss becomes (2.01−3)2(2.01 - 3)^{2} = 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?

+0.01: the loss moves one-for-one with w
Try it: w = 0.51 gives z = 2.02 and a loss of 0.9604. The loss went down, and by more than 0.01.
−0.02: w's nudge becomes z's nudge, and z's slope is −2
That is what nudging z directly would do. But w reaches z through a multiplication by x = 2, so its effect on z is twice as large.
−0.04: x = 2 doubles w's nudge on its way into z, and z's slope is −2
The nudge is multiplied by x = 2 on its way into z, and each unit of z moves the loss by −2: 0.01 · 2 · (−2) = −0.04.
0.00: the slopes along the way, 2 and −2, cancel
That adds the slopes. Along a path they multiply: 2 · (−2) = −4.

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 a+ba + b, each input's local slope is 1. For a product a⋅ba \cdot b, 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: ∂L∂w=∂L∂z⋅∂z∂w\frac{\partial L}{\partial w} = \frac{\partial L}{\partial z} \cdot \frac{\partial z}{\partial w} which here is (−2) · 2 = −4, at w = 0.5, x = 2, b = 1 and y = 3.

Same graph. What is ∂L∂b\frac{\partial L}{\partial b}?

(−2) · 1 = −2: a sum passes the gradient through unchanged.

And ∂L∂x\frac{\partial L}{\partial x}?

(−2) · w = (−2) · 0.5 = −1: a product sends each input the gradient times the other input.

The graph of z=w⋅x+bz = w \cdot x + b, walked backward from z's gradient of −2. Step through it: each node shows its value and the gradient it receives.

nodeopforward valuegradient
binput1-2
xinput2-1
winput0.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 *.

  1. In out = self + other, if self grows by a tiny amount, how much does out grow? And in out = self * other?
  2. 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.
  3. 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.
  1. + has local slope 1 for both inputs, so each receives out.grad.
  2. For a * b, a's gradient is b times out.grad and b's is a times out.grad.
  3. Running _backward from L down to the leaves gives every gradient of L = (w*x) * (u + v).
  4. Calling _backward on a leaf is allowed and changes nothing.
  5. 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.

f=(a+b)⋅cf = (a + b) \cdot c with a = 1, b = 2 and c = −3. What is ∂f∂a\frac{\partial f}{\partial a}?

The product sends a + b the gradient c = −3, and the sum passes it through unchanged: −3. For c it is a + b = 3.

g=(a⋅b)⋅cg = (a \cdot b) \cdot c with a = 2, b = 3 and c = 4. What is ∂g∂a\frac{\partial g}{\partial a}?

7: b plus c
That adds the local slopes. Along a path they multiply.
12: c times b
The gradient reaching a · b is c = 4, and a · b passes it to a times b = 3: 4 · 3 = 12.
4: the slope of the last multiplication, c
4 is the slope of g with respect to a · b; a reaches g through one more multiplication, by b = 3.

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

08

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

Continue

Report a problem with this lesson