09

Lesson 09 · Part II · Autograd on single numbers

Many paths, one sum

A value used twice: why does = instead of += break training silently?

~35 min · 2 labs · builds on 08 Who owes the gradient?

This lesson builds on lessons 01–08. New here? Start at lesson 01, or carry on and load the finished pieces with one click.

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

The last product sends (a+b)⋅c(a + b) \cdot c the gradient d = −6. That product sends a+ba + b the gradient −6 · c = −12, and the sum passes −12 to a unchanged. Local slopes multiply along the path: 1 · 2 · (−6) = −12.

Measuring one whole gradient by nudging, with every slope sharing one measurement where you stand, cost 4,097 forward passes. How many knobs does the model have?

4,096: one pass is where you stand, and each of the others nudges one knob. Backpropagation's promise is one pass forward and one back.

A Value used twice

Your + and * now pass the gradient backward: each input receives out.grad times its local slope, which is enough for a graph where every Value is used once.

Real models reuse Values all the time. One weight multiplies the input of every example, and a squared error multiplies a miss by itself. What happens to a Value used twice?

b=a+ab = a + a, with a = 3. Nudge a by 0.01. By how much does b change per unit of nudge? That is, what is ∂b∂a\frac{\partial b}{\partial a}?

a+a=2aa + a = 2a, so nudging a by 0.01 moves b by 0.02: the slope is 2.

Your lesson 08 backward runs on b=a+ab = a + a at a = 3: set b.grad = 1 and call b._backward(). What does a.grad hold afterwards?

2.0: the engine sees a used twice and adds both shares
Nothing in the engine adds: both lines of +'s backward write the same a.grad, and the second replaces the first.
1.0: the second write replaces the first
+'s backward sets self.grad and then other.grad, and here both are a: the second line overwrites the first.
0.5: a used twice gets half from each use
Each use passes the full gradient of 1; nothing is split, and nothing is added either.

1.0, while the true slope is 2. Both lines of +'s backward target a.grad, and the second overwrites the first. The cell below runs your lesson 08 code on a + a and a * a: it gives 1.0 for a + a, where nudging gives 2.00, and 3.0 for a * a, where nudging gives 6.01.

Your lesson 08 code on a + a and a * a, next to the nudged slopes.

# ember: 08
from ember.scalar import Value

a = Value(3.0)
b = a + a
b.grad = 1.0
b._backward()
nudged = ((3.01 + 3.01) - (3.0 + 3.0)) / 0.01
print(f"b = a + a at a = 3: backward gives {a.grad}, nudging gives {nudged:.2f}")

a = Value(3.0)
c = a * a
c.grad = 1.0
c._backward()
nudged = (3.01 * 3.01 - 3.0 * 3.0) / 0.01
print(f"c = a * a at a = 3: backward gives {a.grad}, nudging gives {nudged:.2f}")
b = a + a at a = 3: backward gives 1.0, nudging gives 2.00
c = a * a at a = 3: backward gives 3.0, nudging gives 6.01

No error and no warning: a model trained on these gradients would learn worse, and nothing would tell you.

Every path pays in

When a Value feeds several places, a nudge to it travels down every path at once, and each path moves the output by its own share. So the Value's gradient is the sum of what every path sends back.

Take c=a⋅b+ac = a \cdot b + a with a = 2 and b = 5. Through a⋅ba \cdot b, a receives b = 5; through the + directly, it receives 1; its gradient is 5 + 1 = 6. Nudging agrees: a = 2.01 gives c = 2.01 · 5 + 2.01 = 12.06 instead of 12, a slope of 6.

So each _backward must add its share into an input's grad with +=, instead of setting it with =. This adding up is called gradient accumulation. Adding needs a starting point, and it has one: a new Value's grad starts at 0.

The graph of c=a⋅b+ac = a \cdot b + a at a = 2 and b = 5, walked backward from c. The Value a feeds both the product and the sum, so two shares arrive at a: 1 straight from the sum and 5 through the product, 6 in all.

nodeopforward valuegradient
ainput26
binput52
a*b*101
c+121

d=a⋅b+bd = a \cdot b + b with a = 4 and b = −2. What is ∂d∂b\frac{\partial d}{\partial b}?

Through a⋅ba \cdot b, b receives a = 4; through the + directly, 1. The sum is 5.

Subtraction needs no backward of its own: a−b=a+b⋅(−1)a - b = a + b \cdot (-1), so b receives −1 through a product and a sum you already have.

What: change the _backward of + and * so each adds its share into each input's grad (+=) instead of setting it.

Why: every weight in a real model is used many times. Without this, lesson 12's network trains on wrong gradients and no error ever says so.

Your ember sums the gradient of a Value used in many places.

  1. A Value used twice receives two shares. Which assignment operator keeps the first share when the second arrives?
  2. In both _backward functions, turn every = that writes a .grad into +=. Nothing else changes.
  3. Nearly the code: self.grad += other.data * out.grad and other.grad += self.data * out.grad.
  1. In a + a both uses send 1, so a's gradient is 2.
  2. In a * a each use sends the other's value, so a's gradient is 2a.
  3. In y = a*b + a, a receives b from the product and 1 from the sum.
  4. A gradient already in a.grad is kept and added to.

What: build -x, a - b and the reflected forms, such as 2 + a, 2 * a and 2 - a, out of + and *, so none of them needs a backward of its own.

Why: without it, a loss that subtracts a prediction from a target has no backward at all, because lesson 07 gave - a forward only. Built from + and *, subtraction is handled by lesson 10's backward with no new code.

Your ember can subtract, and backpropagate through it.

  1. Can you write -x using only *? And a - b using only + and a negation?
  2. -x is x * -1. a - b is a + (-b). 2 + a is a + 2, 2 * a is a * 2, and 2 - a is (-a) + 2.
  3. Nearly the code: __neg__ returns self * -1, __sub__ returns self + (-other), and __rsub__ returns other + (-self).
  1. -a has data -a and sends -1 back to a.
  2. In a - b, a receives 1 and b receives -1.
  3. 2 + a, 3 * a and 5 - a compute and pass gradients to a.
  4. a - 2 computes and passes 1 back to a.

The two graphs from before with your new code, and a - b.

from ember.scalar import Value

a = Value(3.0)
b = a + a
b.grad = 1.0
b._backward()
print(f"a + a at a = 3: a.grad = {a.grad}")

a = Value(3.0)
c = a * a
c.grad = 1.0
c._backward()
print(f"a * a at a = 3: a.grad = {a.grad}")

a, b = Value(5.0), Value(2.0)
d = a - b
d.grad = 1.0
d._backward()                 # the + inside a - b
for p in d._prev:
    p._backward()             # the * -1 inside -b (a leaf's _backward does nothing)
print(f"a - b: a.grad = {a.grad}, b.grad = {b.grad}")
a + a at a = 3: a.grad = 2.0
a * a at a = 3: a.grad = 6.0
a - b: a.grad = 1.0, b.grad = -1.0

The cell prints 2.0 for a + a, 6.0 for a * a, and 1.0 and −1.0 for a − b: the true slopes, now from backward.

f=a⋅a⋅af = a \cdot a \cdot a at a = 2, computed as (a⋅a)⋅a(a \cdot a) \cdot a. Once both products have passed their shares back, what gradient does a hold?

The outer product sends a the value of a⋅aa \cdot a, 4. It sends a⋅aa \cdot a the value a = 2, and a⋅aa \cdot a passes 2 · 2 = 4 to each of its two uses of a. Together 4 + 4 + 4 = 12.

A weight w multiplies the input of each of 64 examples in one loss. What gradient should w end up with?

The share from one of the uses: a Value gets its gradient from one place
Every use changes the loss when w changes, so every use contributes to the slope.
The average of the 64 shares, which the engine works out
The engine only adds. If the loss averages over the examples, as mse does, that division reaches every share through the chain rule.
The sum of the 64 shares, one from each use
w is one Value used 64 times: every use sends back a share, and backward adds them.

The sum. Averaging, when you want it, belongs in the loss, not in the engine.

Where this goes

With shared Values, order matters too: a Value must collect every share before it passes gradient on to its own parents. Lesson 10 finds that order.

When you finish this lesson

09

Your ember can now: get the right gradient for a value used in many places

It already could:

  • send a gradient backward through + and *
  • record how every number was made, and draw it as a graph
  • measure every slope of a loss at once, and count the cost

Next The order of blame · In what order must the graph be visited? · ~40 min

Continue

Report a problem with this lesson