Lesson 09 · Part II · Autograd on single numbers
Many paths, one sum
A value used twice: why does = instead of += break training silently?
This lesson builds on lessons 01–08. New here? Start at lesson 01, or carry on and load the finished pieces with one click.
with a = 4, b = −1, c = 2 and d = −6. What is ?
The last product sends the gradient d = −6. That product sends 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?
, with a = 3. Nudge a by 0.01. By how much does b change per unit of nudge? That is, what is ?
, so nudging a by 0.01 moves b by 0.02: the slope is 2.
Your lesson 08 backward runs on at a = 3: set b.grad = 1 and call b._backward(). What does a.grad hold afterwards?
a.grad, and the second replaces the first.self.grad and then other.grad, and here both are a: the second line overwrites the first.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 with a = 2 and b = 5. Through , 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 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.
| node | op | forward value | gradient |
|---|---|---|---|
| a | input | 2 | 6 |
| b | input | 5 | 2 |
| a*b | * | 10 | 1 |
| c | + | 12 | 1 |
with a = 4 and b = −2. What is ?
Through , b receives a = 4; through the + directly, 1. The sum is 5.
Subtraction needs no backward of its own: , 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.
- A Value used twice receives two shares. Which assignment operator keeps the first share when the second arrives?
- In both _backward functions, turn every = that writes a .grad into +=. Nothing else changes.
- Nearly the code: self.grad += other.data * out.grad and other.grad += self.data * out.grad.
- In a + a both uses send 1, so a's gradient is 2.
- In a * a each use sends the other's value, so a's gradient is 2a.
- In y = a*b + a, a receives b from the product and 1 from the sum.
- 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.
- Can you write -x using only *? And a - b using only + and a negation?
- -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.
- Nearly the code: __neg__ returns self * -1, __sub__ returns self + (-other), and __rsub__ returns other + (-self).
- -a has data -a and sends -1 back to a.
- In a - b, a receives 1 and b receives -1.
- 2 + a, 3 * a and 5 - a compute and pass gradients to a.
- 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.
at a = 2, computed as . Once both products have passed their shares back, what gradient does a hold?
The outer product sends a the value of , 4. It sends the value a = 2, and 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 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
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