This lesson builds on lessons 01–10. New here? Start at lesson 01, or carry on and load the finished pieces with one click.
m = a * b and q = m + a. You call each node's _backward by hand. Which order gives every Value its full gradient?
m first, then q: m is closer to the inputs, so it goes first
m would pass its gradient on before q had sent m anything, so a would get nothing along that path.
q first, then m: q uses m, so q must hand m its share before m passes anything on
Users before the used: m's gradient is complete only after q has run.
q, then m. Backward runs from the output toward the inputs, users before the used.
You could check a backward by nudging, the way lesson 6 measured slopes. Why do checks nudge small functions with a few inputs, not a whole network?
They would work as well on a whole network; small functions are only easier to read
At 1 ms a pass, a million passes take about 17 minutes, and that checks one gradient.
Nudging costs a forward pass per input, so checking a big network would cost as much as training it the slow way
Each knob needs its own nudged pass, so one check of a million-knob network costs at least a million passes.
Cost. Check each operation once, on a few inputs; backpropagation then composes operations you trust.
A wrong slope doesn't crash
Your Value.backward() walks any graph of + and * in topological order and delivers every gradient in one pass.
A network also needs powers, exp, log, tanh and relu, each with its own local slope. A wrong formula raises nothing: the model trains badly, and nobody knows why. How do you know a derivative is right?
Your backward says 2000.000 and nudging says 2000.001. Elsewhere, backward says 0.002 and nudging says 0.001. Both disagree by 0.001. Which disagreement points to a bug?
Both, equally: the gap is the same
The same gap means very different things next to 2000 and next to 0.002.
The second: it is off by half of its size
0.001 out of 0.002 is a factor of two; 0.001 out of 2000 is what nudging and rounding leave behind.
The first: bigger numbers deserve more suspicion
Big gradients carry big rounding; what matters is the gap compared with the size.
The second. Judge a gap by its size relative to the numbers, a rule the derivation below makes exact.
Before any formula, measure. numerical_derivative, which nudges one input and divides, still works on any function of one number. One of the five functions below, tanh, is a smooth step from −1 to 1, like sigmoid's from 0 to 1.
Measure five local slopes by nudging: x3, exp, the natural log ln, tanh and relu.
import math
from ember.basics import numerical_derivative
def relu(x):
return max(0.0, x)
for name, f, x in [("x**3", lambda t: t ** 3, 2.0), ("exp", math.exp, 0.5), ("ln", math.log, 2.0),
("tanh", math.tanh, 0.0), ("relu", relu, -1.0), ("relu", relu, 2.0)]:
print(f"slope of {name:4} at x = {x:4}: {numerical_derivative(f, x):.4f}")
slope of x**3 at x = 2.0: 12.0000
slope of exp at x = 0.5: 1.6487
slope of ln at x = 2.0: 0.5000
slope of tanh at x = 0.0: 1.0000
slope of relu at x = -1.0: 0.0000
slope of relu at x = 2.0: 1.0000
Measured: 12.0000 for x3 at 2, 1.6487 for exp at 0.5, 0.5000 for ln at 2, 1.0000 for tanh at 0, and 0.0000 and 1.0000 for relu at −1 and at 2.
Here is a cheat sheet of formulas for those slopes, of the kind you might find anywhere:
function
local slope
xn
nxn−1
ex
ex
lnx
x1
tanhx
1−tanhx
relu(x)
1 when x > 0, 0 when x < 0
At x = 0 the cheat sheet's tanh line gives 1 − tanh(0) = 1, and the measurement gives 1.0000. What does that tell you about the formula?
It is right: formula and measurement agree
They agree at one point. Many different formulas pass through the same point.
Only that it gives the right value at x = 0: one point can't confirm a formula
A formula is a claim about every x; one measurement tests it at one x.
Nothing: nudging can't check a formula
It can, at every point you try, which is why a check tries more than one.
One point only. A check has to try several points, and it needs a fair way to judge a disagreement. Keep an eye on that tanh line.
The table, measured
Go down the table one row at a time, each next to its measurement. One function is new: the natural log, written ln, which undoes ex. ember names its method log. Leave the tanh row for the checker you are about to build.
Powers: the slope of xn is nxn−1, and at x = 2 with n = 3 that is 3⋅22 = 12, as measured. The exponential is its own slope: e0.5≈1.6487, as measured.
The natural log undoes the exponential, lnex=x, and its slope is x1: at x = 2 that is 0.5, as measured. relu's slope is 0 on the left of 0 and 1 on the right, as measured.
The slope of lnx is x1. What is the slope of ln at x = 4?
1 / 4 = 0.25: the log flattens as x grows.
The measurement itself can be better. A one-sided nudge measures the slope a little to the right of x. Nudge both ways instead and divide by 2h, and the errors on the two sides cancel. That is the central difference.
The central difference nudges both ways: ∂x∂f≈2hf(x+h)−f(x−h) For x3 at x = 1 with h = 0.01 that is (1.030301 − 0.970299) / 0.02 = 3.0001, off by 0.0001, where the one-sided nudge gives 3.0301, off by 0.0301.
How far off each estimate of the slope of x3 at 1 is, for nudges from 0.01 down to 0.000000000001.
print("The slope of x**3 at x = 1 is exactly 3. How far off is each estimate?")
for h in [1e-2, 1e-4, 1e-6, 1e-8, 1e-10, 1e-12]:
forward = ((1 + h) ** 3 - 1) / h
central = ((1 + h) ** 3 - (1 - h) ** 3) / (2 * h)
print(f"h = {h:.0e}: forward off by {abs(forward - 3):.1e}, central off by {abs(central - 3):.1e}")
The slope of x**3 at x = 1 is exactly 3. How far off is each estimate?
h = 1e-02: forward off by 3.0e-02, central off by 1.0e-04
h = 1e-04: forward off by 3.0e-04, central off by 1.0e-08
h = 1e-06: forward off by 3.0e-06, central off by 8.0e-11
h = 1e-08: forward off by 4.0e-09, central off by 7.1e-09
h = 1e-10: forward off by 2.5e-07, central off by 2.5e-07
h = 1e-12: forward off by 2.7e-04, central off by 1.0e-04
Smaller nudges help until rounding takes over. The central difference is best near h = 1e-06 (Python's way of writing 0.000001), off by 8.0e-11. At h = 1e-12 rounding has undone the gain: it is off by 1.0e-04 again, as at h = 0.01.
To judge a disagreement, divide the gap by the size: max(∣a∣,∣b∣)∣a−b∣ For 2000.000 against 2000.001 that is 0.001 / 2000.001 ≈ 0.0000005; for 0.002 against 0.001 it is 0.001 / 0.002 = 0.5.
Backward says 0.30 and nudging says 0.25. By the formula above, what is the relative error?
|0.30 − 0.25| / 0.30 = 0.05 / 0.30 ≈ 0.1667: a bug, whatever the absolute gap looks like.
What: write numerical_grad(func, inputs, eps), the central-difference slope of func, a function of a list of plain numbers, for each input. Then write gradcheck_scalar(func, inputs, eps, atol, rtol): run func on a list of Values, call backward(), and return True only if every input's grad is within atol + rtol times the measured size of numerical_grad's slope. That is the relative error above, multiplied out so nothing divides by zero, with the measured size in place of the larger one (the two are nearly equal when the check passes) and a small absolute floor.
Why: without it, a wrong local slope trains a model badly and nothing says so. It proves each new backward right before anything trusts it: the cell after it uses it to catch the cheat sheet's tanh, lesson 14 turns it into gradcheck for tensors, and every operation after that is checked by it.
Your ember can check its own derivatives.
numerical_grad nudges one input at a time, both up and down. For gradcheck_scalar: which two numbers do you compare for each input, and how do you make the comparison fair for big and small gradients?
numerical_grad: for each input, copy the list twice, add eps to one copy and subtract it from the other, and divide the difference of func by 2 * eps. gradcheck_scalar: make Values from inputs, call backward on func(values), measure with numerical_grad on a float version of func, and accept each pair when the gap is at most atol + rtol * abs(numeric).
Nearly the code: numeric = numerical_grad(lambda xs: func([Value(x) for x in xs]).data, inputs, eps); return all(abs(v.grad - n) <= atol + rtol * abs(n) for v, n in zip(values, numeric)).
The gradient of x0^2 * x1 at [3, 2] is [12, 9].
With eps = 0.001, x^3 at 1 gives (1.001^3 - 0.999^3) / 0.002 = 3.000001.
numerical_grad does not change the list it is given.
x0 * x1 + e^x0 has a correct backward, so gradcheck_scalar returns True.
An op whose backward sends v instead of 2v fails the check.
A wrong gradient on the second input is caught too.
x^3 at 200 has gradient 120,000: a correct backward passes although the estimate is off by about 0.0004.
Your checker on the cheat sheet's tanh, at x = 0, 0.5 and 1.
import math
from ember.scalar import Value
from ember.gradcheck import gradcheck_scalar
def cheat_sheet_tanh(values):
"""tanh of the one input, with the cheat sheet's backward: local slope 1 - tanh(x)."""
v = values[0]
t = math.tanh(v.data)
out = Value(t, (v,), "tanh")
def _backward():
v.grad += (1 - t) * out.grad
out._backward = _backward
return out
for x in [0.0, 0.5, 1.0]:
print(f"x = {x}: gradcheck_scalar says {gradcheck_scalar(cheat_sheet_tanh, [x])}")
x = 0.0: gradcheck_scalar says True
x = 0.5: gradcheck_scalar says False
x = 1.0: gradcheck_scalar says False
The cheat sheet's tanh passes at x = 0 and fails at 0.5 and at 1.0. At x = 1, tanh(1) ≈ 0.7616, so the sheet says 1 − 0.7616 = 0.2384 while the true slope is 1−0.76162 ≈ 0.42. The right formula is 1−tanh2x, and the sheet's line was planted for your checker to find.
What: give ** (with a plain-number exponent) its backward by the power rule, and write Value.tanh with its backward, whose local slope is 1−tanh2x. exp, log and relu arrive ready-made, backward included: read them before you start.
Why: without them, a squared error or a tanh neuron has no backward, and lesson 12's neurons squash with tanh. With ** in place, division is a times b to the power −1 and needs no backward of its own.
Your ember differentiates everything a small network needs, and has checked it.
For out = x ** k, how much does out change per unit change of x? For tanh, remember that the cheat sheet was wrong: check your formula with gradcheck_scalar before you trust it.
Power rule: the local slope of x ** k is k * x ** (k - 1). tanh's local slope is 1 - tanh(x) ** 2; compute t = math.tanh(self.data) once and reuse it. Both add their share into self.grad, times out.grad.
Nearly the code: in __pow__, a _backward that does self.grad += exponent * self.data ** (exponent - 1) * out.grad; in tanh, self.grad += (1 - t * t) * out.grad.
The gradient of a**3 at 2 is 3 * 2**2 = 12.
a**-1 at 4 has gradient -1/16; b**0.5 at 9 has gradient 1/6.
a**2 at -3 has gradient -6.
At 0.5, tanh gives tanh(0.5) and its gradient is 1 - tanh(0.5)**2.
At 0 the gradient of tanh is 1 (a hand check here cannot tell 1 - t from 1 - t^2).
a / b is a * b**-1, so it has gradients 1/b and -a/b^2; 6 / b has -6/b^2.
(a*b + tanh(c))**2 has the gradients the chain rule predicts.
a ** Value(2) raises TypeError: exponents are plain numbers.
A check compares x2 at x = 1000: backward says 2000, nudging says 2000.0001. Is the backward wrong?
Yes: 0.0001 is a sizeable gap for a derivative
Next to 2000, a gap of 0.0001 is rounding, not a bug.
No: the relative error is about 0.00000005
0.0001 / 2000.0001 is about 0.00000005, far inside any tolerance.
No bug. The same gap next to 0.0002 would be one.
relu has no single slope at x = 0: 0 on the left, 1 on the right. What does a central difference with h = 0.001 measure there?
(relu(0.001) − relu(−0.001)) / 0.002 = 0.001 / 0.002 = 0.5, the average of the two sides. A check exactly at a kink disagrees with either choice, so checks avoid kinks.
Where this goes
Every operation a small network needs now has a backward you have checked. Lesson 12 builds neurons, layers and a network from Values, and trains it.
When you finish this lesson
Your ember can now: differentiate pow, exp, log, tanh and relu, and prove it numerically
It already could:
backpropagate through any graph with one call to backward()
get the right gradient for a value used in many places