USAAIO Lesson 33, from Week 11, fully worked. It derives the forward difference and its O(ε) error from Taylor's theorem, then the central difference and its O(ε²) error term by term, with the odd terms cancelling, and works the truncation-against-round-off trade-off that sets ε at about 1e-5. It builds the relative-error gradient test from scratch on one running logistic-regression model, deriving the analytic gradient Xᵀ(p−y)/N, gradient-checking it to a relative error of 1.9e-10, matching it against torch.autograd to 1e-16, and then breaking it two ways - a sign flip giving 0.46, and a missing 1/N giving exactly 0.60 - both of which the check catches. The second half is real-model debugging: broadcasting that turns (n,) − (n,1) into (n,n), reducing along the wrong axis, a forgotten zero_grad accumulating 2, 4, 6, the sigmoid derivative peaking at 0.25, per-layer gradient norms diagnosing vanishing and exploding gradients, clipping, the unstable-sigmoid overflow, and NaN hunting with clamping and detect_anomaly. It ends with a build-your-own gradient_check project. Every snippet runs standalone, and every number came from real numpy and torch execution. The lesson runs to 60 slides.
Subject: Machine Learning · 115 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
USAAIO · Lesson 33 · Week 11
Trust no gradient you haven't checked. We derive the finite-difference formulas from Taylor, build the relative-error test from scratch, break a real model's gradient two ways and catch both — then run the debugging playbook for shapes, zero_grad, vanishing/exploding norms, and NaNs.
Objectives
O(ε) error and the central difference's O(ε²) error from Taylor expansions, term by termε ≈ 1e-5 is the sweet spot (not 1e-12)< 1e-5 passes, ~0.5 is a real bugXᵀ(p−y)/N, gradient-check it, and match torch.autograd1/N, broadcasting, wrong axis, forgotten zero_grad — and diagnose vanishing/exploding gradients and NaNsWarm-up
Discussion prompt
Before we open Lesson 33: Gradient Checking & Debugging: without looking back, what was the main idea of Bias-Variance & Cross-Validation, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
the bias-variance decomposition MSE = Bias² + Variance + Noise, model complexity and the U-shaped test error, cross-validation strategies (k-fold, stratified, LOO, time-series), hyperparameter tuning, and nested CV. Measure bias and variance empirically and build k-fold CV from scratch vs sklearn.
Section
Part 1 of 6 — motivation
Intuition
Backprop is just the chain rule applied by hand (or by a framework) across many layers. One dropped factor, one flipped sign, one wrong axis — and the gradient is wrong but not crashing.
Training still runs. The loss still changes. It just heads the wrong way, or the wrong distance, and you waste hours blaming your learning rate or your data.
So we need a second, independent way to compute the gradient — one we trust because it uses only the loss function itself. That is the finite difference.
Counterexample
Discussion prompt
Backprop is just the chain rule applied by hand (or by a framework) across many layers. One dropped factor, one flipped sign, one wrong axis — and the gradient is wrong but not crashing.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
Training still runs. The loss still changes. It just heads the wrong way, or the wrong distance, and you waste hours blaming your learning rate or your data.
Concept
A derivative is a slope: how much f moves when x moves a hair. If we nudge one input by a tiny ε and measure how f responds, we recover that slope numerically — no calculus, no chain rule.
\[ \frac{\partial f}{\partial x} \;=\; \lim_{\epsilon \to 0}\; \frac{f(x+\epsilon) - f(x)}{\epsilon} \]
We can't take ε → 0 on a computer (round-off), so we pick a small-but-finite ε and accept a controlled error. How we form the quotient decides how big that error is — the next slides derive it exactly.
Section
Part 2 of 6 — Taylor to O(ε²)
Intuition
There are two natural ways to estimate a slope from function values. One-sided (forward): stand at x, take one step to x+ε, and measure the rise. Two-sided (central): step ε to each side and measure across the gap 2ε.
The central version is symmetric around x, and symmetry is powerful: any error that bends the same way on both sides cancels. That is the intuition; the Taylor algebra on the next slides makes it exact.
Concept
Everything about finite-difference accuracy falls out of one fact: a smooth f near x is its Taylor series. Expanding at x + ε and at x − ε:
\[ f(x+\epsilon) = f(x) + f'(x)\,\epsilon + \tfrac{1}{2}f''(x)\,\epsilon^2 + \tfrac{1}{6}f'''(x)\,\epsilon^3 + \cdots \]
\[ f(x-\epsilon) = f(x) - f'(x)\,\epsilon + \tfrac{1}{2}f''(x)\,\epsilon^2 - \tfrac{1}{6}f'''(x)\,\epsilon^3 + \cdots \]
Analogy
Discussion prompt
Explain The tool: Taylor expansion by analogy to something with no Machine Learning in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Everything about finite-difference accuracy falls out of one fact: a smooth f near x is its Taylor series. Expanding at x + ε and at x − ε:
Ranking
Put in order
Put the moves of Forward difference is only O(ε) into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. Everything except the constant term survives; this is f(x+ε) − f(x).
Worked example
The forward (one-sided) difference uses f(x+ε) and f(x). Subtract f(x) from the first Taylor line and divide by ε:
Subtract f(x) from the (x+ε) expansion
Why: Everything except the constant term survives; this is f(x+ε) − f(x).
\[ f(x+\epsilon) - f(x) = f'(x)\,\epsilon + \tfrac{1}{2}f''(x)\,\epsilon^2 + \cdots \]
Divide both sides by ε
Why: Isolate the slope estimate on the left; every remaining term keeps one factor of ε or more.
\[ \frac{f(x+\epsilon) - f(x)}{\epsilon} = f'(x) + \tfrac{1}{2}f''(x)\,\epsilon + \cdots \]
The leftover error is proportional to ε → O(ε)
Why: The first uncancelled term is ½f''(x)·ε. Halving ε only halves the error. That linear decay is why forward differences are the weaker tool.
| method | estimate | leading error term | order |
|---|---|---|---|
| forward | (f(x+ε) − f(x))/ε | ½ f''(x) · ε | O(ε) |
Notation
Annotate
From Forward difference is only O(ε) — read this one piece at a time. What is each part doing?
On: \( f(x+\epsilon) - f(x) = f'(x)\,\epsilon + \tfrac{1}{2}f''(x)\,\epsilon^2 + \cdots \)
Step zero
Discussion prompt
Central difference: subtract the two expansions — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: Subtract: f(x+ε) − f(x−ε)
Answer:
Worked example
The central difference uses both f(x+ε) and f(x−ε). Subtract the second Taylor line from the first and watch what cancels:
Subtract: f(x+ε) − f(x−ε)
Why: Line up the two series term by term. The even-power terms (f(x), ½f''ε²) are IDENTICAL in both, so they cancel; the odd-power terms DOUBLE.
\[ f(x+\epsilon) - f(x-\epsilon) = 2 f'(x)\,\epsilon + \tfrac{1}{3}f'''(x)\,\epsilon^3 + \cdots \]
The f'' term cancelled — that is the whole trick
Why: Because +½f''ε² appears in BOTH expansions with the same sign, subtracting kills it. The first surviving error term is now the ε³ one, not ε².
Divide by 2ε
Why: Solve for the slope. Dividing the ε³ error term by ε leaves ε².
\[ \frac{f(x+\epsilon) - f(x-\epsilon)}{2\epsilon} = f'(x) + \tfrac{1}{6}f'''(x)\,\epsilon^2 + \cdots \]
Reverse engineer
Discussion prompt
Work backwards. The example finished here:
The f'' term cancelled — that is the whole trick
What was it asked to do, and what must it have been given? Reconstruct the problem from its answer.
Hint: Every quantity in the result had to enter somewhere. Account for each one.
Answer:
The central difference uses both f(x+ε) and f(x−ε). Subtract the second Taylor line from the first and watch what cancels:
Concept
The leading error of the central difference is ⅙ f'''(x)·ε². Halving ε now quarters the error — quadratic, not linear. Same two function calls, dramatically better accuracy.
\[ \boxed{\;\frac{\partial f}{\partial x} \approx \frac{f(x+\epsilon) - f(x-\epsilon)}{2\epsilon}, \qquad \text{error } = O(\epsilon^2)\;} \]
| method | leading error | order |
|---|---|---|
| forward | ½ f''(x) · ε | O(ε) |
| central | ⅙ f'''(x) · ε² | O(ε²) |
Comparison
Comparison matrix
From Central difference is O(ε²): refill the order column from what you know. The rest of the table is as it appeared.
| method | leading error | order |
|---|---|---|
| forward | ½ f''(x) · ε | O(ε) |
| central | ⅙ f'''(x) · ε² | O(ε²) |
Estimation
Predict first
Take f(x) = x³ at x = 2 (true derivative 12). Halve ε each row and watch the central error fall by exactly 4× while the forward error only halves. Runnable standalone:
Commit before you compute: what does See O(ε²) in real numbers come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.
Correct: Central error ratio is 4.0 every time ε halves
Why: A prediction you can defend turns the computation into a check rather than a leap of faith — and an answer that contradicts it is caught on the spot. 0.1→0.05 takes 1.0e-2 → 2.5e-3, a factor of 4 = 2².
Worked example
Take f(x) = x³ at x = 2 (true derivative 12). Halve ε each row and watch the central error fall by exactly 4× while the forward error only halves. Runnable standalone:
import numpy as np
f = lambda x: x**3 # f'(x) = 3x^2, so f'(2) = 12
for eps in [0.1, 0.05, 0.025, 0.0125]:
central = (f(2+eps) - f(2-eps)) / (2*eps)
forward = (f(2+eps) - f(2)) / eps
print(f'{eps:.4f} central_err={abs(central-12):.3e} forward_err={abs(forward-12):.3e}')Central error ratio is 4.0 every time ε halves
Why: 0.1→0.05 takes 1.0e-2 → 2.5e-3, a factor of 4 = 2². That factor-4 is the fingerprint of O(ε²). Forward's error only halves (factor 2 = 2¹).
| ε | central error | central ratio | forward error |
|---|---|---|---|
| 0.1000 | 1.000e-02 | — | 6.100e-01 |
| 0.0500 | 2.500e-03 | 4.0× | 3.025e-01 |
| 0.0250 | 6.250e-04 | 4.0× | 1.506e-01 |
| 0.0125 | 1.563e-04 | 4.0× | 7.516e-02 |
Discrimination
Sort into buckets
Sort these by central ratio, from memory, without looking back at See O(ε²) in real numbers. Telling them apart on the spot is the skill; the table is only where the answer happens to be written down.
Intuition
The Taylor error shrinks as ε → 0, so it is tempting to make ε microscopic. But a computer stores f(x) in ~16 digits. When x+ε and x−ε are almost equal, f(x+ε) − f(x−ε) subtracts two nearly-equal numbers and the leading digits cancel.
That is catastrophic cancellation: the result is dominated by round-off. Dividing by the tiny 2ε then amplifies that noise. So error goes DOWN as ε shrinks (truncation) but then UP again (round-off).
Missing information
Discussion prompt
Sweep ε across many orders of magnitude on f(x)=x² at x=3 (true derivative 6). The error is a U: big ε = truncation, tiny ε = round-off. Standalone:
What do you need to know — or decide — before the first line can be written? List everything the problem has to hand you.
Hint: Anything you would have to invent to get started is a thing the problem must supply.
Answer:
From 1e-1 down to 1e-5 truncation shrinks the error. Below 1e-5 round-off takes over: at 1e-9 and 1e-11 the error is STUCK at ~5e-7, worse than 1e-5. This is why ε=1e-12 is a trap.
Worked example
Sweep ε across many orders of magnitude on f(x)=x² at x=3 (true derivative 6). The error is a U: big ε = truncation, tiny ε = round-off. Standalone:
import numpy as np
f = lambda x: x**2 # f'(3) = 6
for eps in [1e-1, 1e-3, 1e-5, 1e-7, 1e-9, 1e-11]:
d = (f(3+eps) - f(3-eps)) / (2*eps)
print(f'eps={eps:.0e} central_err={abs(d-6):.3e}')Error bottoms out around ε ≈ 1e-5, then climbs again
Why: From 1e-1 down to 1e-5 truncation shrinks the error. Below 1e-5 round-off takes over: at 1e-9 and 1e-11 the error is STUCK at ~5e-7, worse than 1e-5. This is why ε=1e-12 is a trap.
| ε | central error | regime |
|---|---|---|
| 1e-1 | 5.33e-15 | (tiny — x² has no f''') |
| 1e-3 | 6.61e-13 | truncation |
| 1e-5 | 3.93e-11 | sweet spot |
| 1e-7 | 9.82e-09 | round-off creeping in |
| 1e-9 | 4.96e-07 | round-off |
| 1e-11 | 4.96e-07 | round-off floor |
Trade off
Comparison matrix
From The truncation vs round-off U-curve: every row here is a choice with a cost. Fill the central error column, then say which row you would actually pick and what you give up for it.
| ε | central error | regime |
|---|---|---|
| 1e-1 | 5.33e-15 | (tiny — x² has no f''') |
| 1e-3 | 6.61e-13 | truncation |
| 1e-5 | 3.93e-11 | sweet spot |
| 1e-7 | 9.82e-09 | round-off creeping in |
| 1e-9 | 4.96e-07 | round-off |
| 1e-11 | 4.96e-07 | round-off floor |
Anomaly
Predict first
A student writes this, and it looks reasonable:
Use the forward difference (f(x+ε) − f(x))/ε and make ε as tiny as possible — smaller ε must mean less error, right?
It is wrong. Say what breaks — and say it before you turn the page.
Correct: Two mistakes stacked. Forward is only O(ε), so it is inaccurate to begin with; and ε=1e-12 sits deep in the round-off regime where cancellation destroys the digits.
Use the central difference with ε ≈ 1e-5 — O(ε²) accurate and sitting at the bottom of the U-curve.
Why: Two mistakes stacked. Forward is only O(ε), so it is inaccurate to begin with; and ε=1e-12 sits deep in the round-off regime where cancellation destroys the digits. A correct gradient then 'fails' the check on noise.
Trap
Use the forward difference (f(x+ε) − f(x))/ε and make ε as tiny as possible — smaller ε must mean less error, right?
Forward diff, ε = 1e-12
Why: Two mistakes stacked. Forward is only O(ε), so it is inaccurate to begin with; and ε=1e-12 sits deep in the round-off regime where cancellation destroys the digits. A correct gradient then 'fails' the check on noise.
Use the central difference with ε ≈ 1e-5 — O(ε²) accurate and sitting at the bottom of the U-curve.
(f(x+ε) − f(x−ε)) / 2ε, ε = 1e-5
Why: Central kills the O(ε) term, and 1e-5 balances truncation against round-off. That combination is what lets a correct gradient land at ~1e-11 instead of drowning in noise.
Section
Part 3 of 6 — the verdict rule
Concept
We now have two gradients: g_analytic (from calculus) and g_numeric (finite difference). To compare, we must not use raw |g_a − g_b| — a difference of 0.01 is tiny if the gradient is 1000 but huge if the gradient is 0.001.
Normalize by the size of the gradients so the verdict is scale-free:
\[ \text{rel err} = \frac{\lvert g_{\text{analytic}} - g_{\text{numeric}} \rvert}{\lvert g_{\text{analytic}} \rvert + \lvert g_{\text{numeric}} \rvert} \]
Explain it
Discussion prompt
Explain Why relative, not absolute, error to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Normalize by the size of the gradients so the verdict is scale-free:
Concept
The denominator caps the ratio in [0, 1]. A correct gradient makes numerator ≈ 0, so rel err ≈ 0. A totally wrong one pushes it toward 1. The thresholds:
| max rel error | verdict |
|---|---|
| < 1e-7 | definitely correct |
| 1e-7 to 1e-5 | correct (fine for float64) |
| 1e-3 to 1e-2 | suspicious — likely a bug |
| > 1e-2 | definitely a bug |
Take the max over all coordinates: one wrong entry is enough to fail. And add a small 1e-12 to the denominator so a genuinely-zero gradient doesn't divide by zero.
Sorting
Sort into buckets
These are the pieces of Lesson 33: Gradient Checking & Debugging, out of order. Put each one back under the part of the lesson it belongs to.
Section
Part 4 of 6 — the running model
Concept
We carry one model through the rest of the lesson: logistic regression on a tiny 4-sample dataset. Column 0 is the bias, y is the 0/1 label:
| sample | bias | x₁ | x₂ | y |
|---|---|---|---|---|
| 0 | 1 | 0.5 | −0.3 | 1 |
| 1 | 1 | −1.2 | 0.8 | 0 |
| 2 | 1 | 2.0 | 0.1 | 1 |
| 3 | 1 | 0.3 | −1.5 | 0 |
Weights start at w = [0.1, −0.2, 0.4]. We will derive its gradient by hand, then gradient-check it — the exact workflow you use on any backward pass you write.
Invariant
Step through it
Step through Our running model: logistic regression one row at a time. One of these columns never changes — find it, and say why it cannot.
Worked example
The model predicts p = σ(Xw) with σ(z) = 1/(1+e⁻ᶻ), and the loss is mean binary cross-entropy:
\[ z = Xw, \quad p = \sigma(z), \quad L(w) = -\frac{1}{N}\sum_{i=1}^{N} \big[\,y_i \log p_i + (1-y_i)\log(1-p_i)\,\big] \]
import numpy as np
X = np.array([[1.,0.5,-0.3],[1.,-1.2,0.8],[1.,2.0,0.1],[1.,0.3,-1.5]])
y = np.array([1.,0.,1.,0.])
w = np.array([0.1,-0.2,0.4])
sigmoid = lambda z: 1/(1+np.exp(-z))
z = X @ w
p = sigmoid(z)
L = -np.mean(y*np.log(p) + (1-y)*np.log(1-p))
print('z =', z.round(4))
print('p =', p.round(4))
print('L =', round(L, 6))| sample | z = x·w | p = σ(z) | y | p − y |
|---|---|---|---|---|
| 0 | −0.1200 | 0.4700 | 1 | −0.5300 |
| 1 | +0.6600 | 0.6593 | 0 | +0.6593 |
| 2 | −0.2600 | 0.4354 | 1 | −0.5646 |
| 3 | −0.5600 | 0.3635 | 0 | +0.3635 |
Scale up
Step through it
Step through The loss and the forward pass and watch the numbers move. Now imagine the input ten times bigger: which column is the one that stops this being practical?
Ranking
Put in order
Put the moves of Derive the analytic gradient into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. L depends on w only through z = Xw.
Worked example
Chain rule: ∂L/∂w = (∂L/∂z)·(∂z/∂w)
Why: L depends on w only through z = Xw. Differentiate the outer loss w.r.t. z, then multiply by ∂z/∂w = Xᵀ.
The cross-entropy + sigmoid combine to ∂L/∂zᵢ = (pᵢ − yᵢ)/N
Why: A standard, beautiful cancellation: the σ' in the chain rule exactly cancels the σ in the log-loss denominator, leaving just (p − y). The 1/N comes from the mean.
\[ \frac{\partial L}{\partial z} = \frac{1}{N}(p - y) \]
Multiply by ∂z/∂w = Xᵀ
Why: Since z = Xw, ∂zᵢ/∂w is row i of X; stacking gives Xᵀ on the left.
\[ \boxed{\;\nabla_w L = \frac{1}{N}\,X^\top (p - y)\;} \]
Translation
\( \boxed{\;\nabla_w L = \frac{1}{N}\,X^\top (p - y)\;} \)
Draw it
Translate both ways. First write the expression above as a sentence with no symbols in it at all. Then cover it, and write your sentence back as notation. If the two versions disagree, the disagreement is the thing to fix.
Ranking
Put in order
Put the moves of Compute the gradient by hand, entry by entry into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. Column 0 is all ones, so its dot with (p−y) is just the sum; over N it is the mean.
Worked example
Use the p − y column from the forward pass: [−0.5300, 0.6593, −0.5646, 0.3635], and N = 4. Each gradient entry is a column of X dotted with (p−y), over 4:
Bias entry = mean(p − y) = (−0.5300 + 0.6593 − 0.5646 + 0.3635)/4
Why: Column 0 is all ones, so its dot with (p−y) is just the sum; over N it is the mean. = −0.017948.
x₁ entry = Σ x₁ᵢ(pᵢ−yᵢ)/4 = −0.519076
Why: Column 1 = [0.5,−1.2,2.0,0.3] dotted with (p−y), divided by 4.
x₂ entry = Σ x₂ᵢ(pᵢ−yᵢ)/4 = 0.021153
Why: Column 2 = [−0.3,0.8,0.1,−1.5] dotted with (p−y), divided by 4.
\[ \nabla_w L = \begin{bmatrix} -0.017948 \\ -0.519076 \\ 0.021153 \end{bmatrix} \]
Notation
Annotate
From Compute the gradient by hand, entry by entry — read this one piece at a time. What is each part doing?
On: \( \nabla_w L = \begin{bmatrix} -0.017948 \\ -0.519076 \\ 0.021153 \end{bmatrix} \)
Pattern
Predict first
The table runs: bias | −0.01794812 | −0.01794812 | ≈2e-11 · x₁ | −0.51907571 | −0.51907571 | ≈1e-11
In Gradient-check it: analytic vs numeric, given the rows so far: what is the next one — the row where coord is x₂?
Correct: x₂ | 0.02115318 | 0.02115318 | 1.88e-10
| coord | analytic | numeric | rel err |
|---|---|---|---|
| bias | −0.01794812 | −0.01794812 | ≈2e-11 |
| x₁ | −0.51907571 | −0.51907571 | ≈1e-11 |
| x₂ | 0.02115318 | 0.02115318 | 1.88e-10 |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. Analytic and numeric agree to ~10 digits, far under the 1e-5 threshold.
Worked example
Now the payoff. Compute the numeric gradient with central differences and compare by max relative error. Fully standalone — re-defines X, y, w, everything:
import numpy as np
X = np.array([[1.,0.5,-0.3],[1.,-1.2,0.8],[1.,2.0,0.1],[1.,0.3,-1.5]])
y = np.array([1.,0.,1.,0.]); w = np.array([0.1,-0.2,0.4])
sigmoid = lambda z: 1/(1+np.exp(-z))
def loss(w):
p = sigmoid(X @ w)
return -np.mean(y*np.log(p) + (1-y)*np.log(1-p))
def grad_analytic(w):
p = sigmoid(X @ w)
return X.T @ (p - y) / len(y)
def numeric(f, w, eps=1e-5):
g = np.zeros_like(w)
for i in range(len(w)):
wp, wm = w.copy(), w.copy(); wp[i]+=eps; wm[i]-=eps
g[i] = (f(wp) - f(wm)) / (2*eps)
return g
ga, gn = grad_analytic(w), numeric(loss, w)
rel = np.max(np.abs(ga-gn)/(np.abs(ga)+np.abs(gn)+1e-12))
print('analytic:', ga.round(8))
print('numeric :', gn.round(8))
print(f'max rel error = {rel:.2e}')max rel error = 1.88e-10 → PASS
Why: Analytic and numeric agree to ~10 digits, far under the 1e-5 threshold. The hand-derived Xᵀ(p−y)/N is correct. This is your unit test for any backward pass.
| coord | analytic | numeric | rel err |
|---|---|---|---|
| bias | −0.01794812 | −0.01794812 | ≈2e-11 |
| x₁ | −0.51907571 | −0.51907571 | ≈1e-11 |
| x₂ | 0.02115318 | 0.02115318 | 1.88e-10 |
Fill the middle
Fill in the blanks
From Cross-check against torch.autograd — one line has had its right-hand side removed. Put it back.
import numpy as np, torch
torch.manual_seed(0)
X = torch.tensor([[1.,0.5,-0.3],[1.,-1.2,0.8],[1.,2.0,0.1],[1.,0.3,-1.5]])
y = torch.tensor([1.,0.,1.,0.])
w = torch.tensor([0.1,-0.2,0.4], requires_grad=True)
p = torch.sigmoid(X @ w)
L = -torch.mean(ytorch.log(p) + (1-y)torch.log(1-p))
L.backward()
print('torch grad :', w.grad.numpy().round(8))
print('our formula:', (X.T @ (p.detach()-y) / len(y)).numpy().round(8))
Why: p is what everything below it consumes, so the wrong expression here fails later and somewhere else. autograd walks the same chain rule we did by hand and lands on identical numbers (max abs diff 0.0 here — bit-for-bit the same).
Worked example
A finite difference is one independent oracle; automatic differentiation is another. If your analytic gradient matches BOTH, you can stop worrying. Standalone torch:
import numpy as np, torch
torch.manual_seed(0)
X = torch.tensor([[1.,0.5,-0.3],[1.,-1.2,0.8],[1.,2.0,0.1],[1.,0.3,-1.5]])
y = torch.tensor([1.,0.,1.,0.])
w = torch.tensor([0.1,-0.2,0.4], requires_grad=True)
p = torch.sigmoid(X @ w)
L = -torch.mean(y*torch.log(p) + (1-y)*torch.log(1-p))
L.backward()
print('torch grad :', w.grad.numpy().round(8))
print('our formula:', (X.T @ (p.detach()-y) / len(y)).numpy().round(8))torch grad == Xᵀ(p−y)/N to machine precision
Why: autograd walks the same chain rule we did by hand and lands on identical numbers (max abs diff 0.0 here — bit-for-bit the same). Two independent oracles agreeing = the gradient is right.
| coord | torch autograd | our Xᵀ(p−y)/N |
|---|---|---|
| bias | −0.01794811 | −0.01794811 |
| x₁ | −0.51907570 | −0.51907570 |
| x₂ | 0.02115317 | 0.02115317 |
Pattern
Step through it
Step through Cross-check against torch.autograd one row at a time. What is driving the change, and what would the row after the last one be?
Intuition
Fair question: torch.autograd already gives an exact gradient — why bother with finite differences at all? Because the two answer different questions.
Autograd checks "did I compute the derivative of the code I wrote correctly?" Finite differences check "is the code I wrote the derivative of the loss I meant?" When you hand-derive a backward pass (an olympiad staple, or a custom CUDA kernel), autograd isn't watching it — the finite-difference check is your only independent witness.
So the finite difference is the ground truth that needs nothing but the forward function. Use it to validate a hand-written gradient; use autograd as a fast second opinion when it's available.
Section
Part 4 continued — catching bugs
Faded example
Fill in the blanks
Bug #1 — a sign flip, with the scaffolding fading: two lines are gone now — fill both.
import numpy as np
f = lambda x: np.sum(x*2np.sin(x))
def numeric(f, x, eps=1e-5):
g = np.zeros_like(x)
for i in range(len(x)):
xp, xm = x.copy(), x.copy(); xp[i]+=eps; xm[i]-=eps
g[i] = (f(xp) - f(xm)) / (2*eps)
return g
x = np.array([0.5,1.0,1.5,2.0])
good = lambda x: 2xnp.sin(x) + x*2np.cos(x)
bad = lambda x: 2xnp.sin(x) - x*2np.cos(x) # sign bug
gn = numeric(f, x)
for name, g in [('good', good(x)), ('bad', bad(x))]:
rel = np.max(np.abs(g-gn)/(np.abs(g)+np.abs(gn)+1e-12))
print(f'___: ___')
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. The correct gradient sits at 1e-11; the sign bug jumps to ~0.46.
Worked example
Suppose backprop mis-signs the gradient — a classic slip. We test it on the simpler scalar-sum function f(x)=Σ xᵢ² sin xᵢ (analytic 2x sin x + x² cos x) so the sign error is isolated. The bug flips the second term to −:
import numpy as np
f = lambda x: np.sum(x**2*np.sin(x))
def numeric(f, x, eps=1e-5):
g = np.zeros_like(x)
for i in range(len(x)):
xp, xm = x.copy(), x.copy(); xp[i]+=eps; xm[i]-=eps
g[i] = (f(xp) - f(xm)) / (2*eps)
return g
x = np.array([0.5,1.0,1.5,2.0])
good = lambda x: 2*x*np.sin(x) + x**2*np.cos(x)
bad = lambda x: 2*x*np.sin(x) - x**2*np.cos(x) # sign bug
gn = numeric(f, x)
for name, g in [('good', good(x)), ('bad', bad(x))]:
rel = np.max(np.abs(g-gn)/(np.abs(g)+np.abs(gn)+1e-12))
print(f'{name}: {rel:.2e}')good: 4.86e-11 (PASS) — bad: 4.58e-01 (FAIL)
Why: The correct gradient sits at 1e-11; the sign bug jumps to ~0.46. A rel error near 0.5 is a screaming red flag — gradient checking turns a silent bug loud.
| gradient | max rel error | verdict |
|---|---|---|
| correct | 4.86e-11 | PASS |
| sign bug | 4.58e-01 | FAIL (caught) |
Fill the middle
Fill in the blanks
From Bug #2 — forgetting the 1/N — one line has had its right-hand side removed. Put it back.
import numpy as np
X = np.array([[1.,0.5,-0.3],[1.,-1.2,0.8],[1.,2.0,0.1],[1.,0.3,-1.5]])
y = np.array([1.,0.,1.,0.]); w = np.array([0.1,-0.2,0.4])
sigmoid = lambda z: 1/(1+np.exp(-z))
def loss(w):
p = sigmoid(X @ w)
return -np.mean(ynp.log(p)+(1-y)np.log(1-p))
def numeric(f, w, eps=1e-5):
g = np.zeros_like(w)
for i in range(len(w)):
wp, wm = w.copy(), w.copy(); wp[i]+=eps; wm[i]-=eps
g[i]=*(f(wp)-f(wm))/(2eps)**
return g
p = sigmoid(X @ w)
gn = numeric(loss, w)
g_bug = X.T @ (p - y) # BUG: no / len(y)
rel = np.max(np.abs(g_bug-gn)/(np.abs(g_bug)+np.abs(gn)+1e-12))
print(f'missing 1/N rel err = ___')
Why: g[i] is what everything below it consumes, so the wrong expression here fails later and somewhere else. Every coordinate is 4× too big.
Worked example
Back to the logistic model. A very common bug: writing Xᵀ(p−y) (a sum) instead of Xᵀ(p−y)/N (a mean). The gradient is off by a constant factor N = 4:
import numpy as np
X = np.array([[1.,0.5,-0.3],[1.,-1.2,0.8],[1.,2.0,0.1],[1.,0.3,-1.5]])
y = np.array([1.,0.,1.,0.]); w = np.array([0.1,-0.2,0.4])
sigmoid = lambda z: 1/(1+np.exp(-z))
def loss(w):
p = sigmoid(X @ w)
return -np.mean(y*np.log(p)+(1-y)*np.log(1-p))
def numeric(f, w, eps=1e-5):
g = np.zeros_like(w)
for i in range(len(w)):
wp, wm = w.copy(), w.copy(); wp[i]+=eps; wm[i]-=eps
g[i]=(f(wp)-f(wm))/(2*eps)
return g
p = sigmoid(X @ w)
gn = numeric(loss, w)
g_bug = X.T @ (p - y) # BUG: no / len(y)
rel = np.max(np.abs(g_bug-gn)/(np.abs(g_bug)+np.abs(gn)+1e-12))
print(f'missing 1/N rel err = {rel:.2e}')rel err = 6.00e-01 → FAIL, and it is EXACTLY 0.60
Why: Every coordinate is 4× too big. With g_bug = 4·gn, rel err = |4g−g|/(|4g|+|g|) = 3/5 = 0.60 for every entry. A clean constant-factor bug shows up as a suspiciously round rel error.
| coord | buggy (sum) | numeric (correct) | ratio | rel err |
|---|---|---|---|---|
| bias | −0.071792 | −0.017948 | 4.0× | 0.60 |
| x₁ | −2.076303 | −0.519076 | 4.0× | 0.60 |
| x₂ | 0.084613 | 0.021153 | 4.0× | 0.60 |
Intuition
Notice how the size of the failure told us the kind of bug. A sign flip gave a messy ~0.46 that varied by coordinate; a clean factor-of-N gave exactly 0.60 on every coordinate.
A constant rel error across all coordinates screams scaling bug (missing /N, /2, a learning-rate factor folded in). A rel error that is large on some coordinates but fine on others points to a specific term — you can literally see which weight's derivation is wrong.
Concept
Gradient checking is expensive: a d-dimensional gradient needs 2d extra loss evaluations (two per coordinate). For a model with a million parameters that is two million forward passes — hopelessly slow to run every step.
So it is a unit test, not part of training. Run it once on a tiny input with a handful of parameters, confirm the backward pass is right, then turn it off and train with the fast analytic gradient.
Two more habits: check on a small random input (not all zeros — many bugs hide when x = 0), and re-run the check whenever you touch the backward pass.
Ranking
Put in order
These are the steps of The gradient-check recipe, scrambled. Put them back in order before the next slide shows you.
i, central-difference with ε = 1e-5: (f(x+εeᵢ) − f(x−εeᵢ)) / 2εmax |gₐ − gₙ| / (|gₐ| + |gₙ| + 1e-12) over all coordinates< 1e-5 correct; ~1e-3 and up is a bug — go fix the formula, not εtorch.autogradWhy: This is the order the recipe itself gives. Recalling the sequence without the slide in front of you is the difference between recognising the method and being able to run it — most of what goes wrong in practice is a step done out of turn.
Pattern
i, central-difference with ε = 1e-5: (f(x+εeᵢ) − f(x−εeᵢ)) / 2εmax |gₐ − gₙ| / (|gₐ| + |gₙ| + 1e-12) over all coordinates< 1e-5 correct; ~1e-3 and up is a bug — go fix the formula, not εtorch.autogradElimination
Eliminate the wrong options
The central difference (f(x+ε)−f(x−ε))/2ε has truncation error of order:
3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.
Survives elimination: A
Why: Subtracting the two Taylor expansions cancels the constant and ½f''ε² (even-power) terms; the first surviving error term is ⅙f'''ε², so after dividing by 2ε the error is O(ε²). Halving ε quarters the error.
Check
Think back to which Taylor terms cancelled.
Check your understanding
The central difference (f(x+ε)−f(x−ε))/2ε has truncation error of order:
Answer: A
Why: Subtracting the two Taylor expansions cancels the constant and ½f''ε² (even-power) terms; the first surviving error term is ⅙f'''ε², so after dividing by 2ε the error is O(ε²). Halving ε quarters the error.
Prediction
Predict first
Your gradient check returns a max relative error of 0.46. The right move is:
Answer it in your own words, now, with nothing to choose from. The options are on the next slide — and picking the right one off a list is an easier skill than producing it.
Correct: Treat the analytic gradient as buggy and fix the formula
Why: A correct gradient gives rel error around 1e-7 to 1e-11. 0.46 is enormous — the analytic gradient itself is wrong (here, a sign flip). Find and fix the derivation.
Check
Your check just returned a max relative error of 0.46.
Check your understanding
Your gradient check returns a max relative error of 0.46. The right move is:
Answer: A
Why: A correct gradient gives rel error around 1e-7 to 1e-11. 0.46 is enormous — the analytic gradient itself is wrong (here, a sign flip). Find and fix the derivation.
Section
Part 5 of 6 — the silent bugs
Concept
A gradient check catches a wrong formula. But real models break in ways that never raise an exception: a shape quietly broadcasts, a reduction picks the wrong axis, gradients pile up across steps. The loss just... misbehaves.
| bug | symptom | how you catch it |
|---|---|---|
| broadcasting | silent wrong values, (n,n) blowup | print every shape |
| wrong reduction axis | loss off by a factor | print shape after sum/mean |
| forgot zero_grad() | gradients accumulate | watch grad grow each step |
| sign error / high lr | loss goes UP | plot the loss curve |
| log(0), 0/0, overflow | NaN loss | detect_anomaly, clip inputs |
Estimation
Predict first
The single most common silent bug: subtracting a (n,) from a (n,1). NumPy broadcasts them into an (n,n) matrix instead of erroring. Standalone:
Commit before you compute: what does Bug: broadcasting turns a vector into a matrix come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.
Correct: (3,) − (3,1) → (3,3), not (3,)
Why: A prediction you can defend turns the computation into a check rather than a leap of faith — and an answer that contradicts it is caught on the spot. Broadcasting aligns from the RIGHT: (3,) becomes (1,3), and (1,3) vs (3,1) expands to (3,3).
Worked example
The single most common silent bug: subtracting a (n,) from a (n,1). NumPy broadcasts them into an (n,n) matrix instead of erroring. Standalone:
import numpy as np
a = np.array([1., 2., 3.]) # shape (3,)
b = np.array([[1.], [2.], [3.]]) # shape (3, 1)
wrong = a - b # broadcasts to (3, 3)!
print('a.shape', a.shape, 'b.shape', b.shape)
print('a - b shape:', wrong.shape)
print(wrong)
right = a - b.ravel() # fix: match shapes first
print('a - b.ravel() shape:', right.shape, '=', right)(3,) − (3,1) → (3,3), not (3,)
Why: Broadcasting aligns from the RIGHT: (3,) becomes (1,3), and (1,3) vs (3,1) expands to (3,3). No error is raised — a downstream .mean() then averages 9 numbers instead of 3. Always print shapes.
| expression | shape | result |
|---|---|---|
| a (vector) | (3,) | [1, 2, 3] |
| b (column) | (3, 1) | [[1],[2],[3]] |
| a − b (BUG) | (3, 3) | 9-element matrix |
| a − b.ravel() | (3,) | [0, 0, 0] |
Discrimination
Sort into buckets
Sort these by shape, from memory, without looking back at Bug: broadcasting turns a vector into a matrix. Telling them apart on the spot is the skill; the table is only where the answer happens to be written down.
Worked example
axis=0 reduces down columns; axis=1 reduces across rows. Pick the wrong one and your per-sample loss becomes a per-feature loss — a silent factor error. Standalone:
import numpy as np
M = np.array([[1., 2., 3.],
[4., 5., 6.]]) # shape (2, 3): 2 samples, 3 features
print('axis=0 (down cols):', M.mean(axis=0), M.mean(axis=0).shape)
print('axis=1 (across rows):', M.mean(axis=1), M.mean(axis=1).shape)
print('no axis (all):', M.mean())axis=0 → (3,) per-feature; axis=1 → (2,) per-sample
Why: For a (samples, features) matrix, a per-sample loss needs axis=1. Using axis=0 returns 3 numbers instead of 2 — the WRONG shape, which then broadcasts and hides. The shape of the result is your tell.
| reduction | result | shape | meaning |
|---|---|---|---|
| mean(axis=0) | [2.5, 3.5, 4.5] | (3,) | per-feature |
| mean(axis=1) | [2.0, 5.0] | (2,) | per-sample |
| mean() | 3.5 | () | scalar (whole) |
Worked example
PyTorch accumulates gradients into .grad by design (for RNNs, grad accumulation). If you forget zero_grad(), each step adds to the last — the gradient balloons. Standalone:
import torch
# f(w) = w^2, so df/dw = 2w = 2 at w=1 EVERY step
w = torch.tensor([1.0], requires_grad=True)
no_zero = []
for step in range(3):
(w*w).sum().backward() # forgot to zero .grad!
no_zero.append(w.grad.item())
w2 = torch.tensor([1.0], requires_grad=True)
with_zero = []
for step in range(3):
if w2.grad is not None: w2.grad.zero_()
(w2*w2).sum().backward()
with_zero.append(w2.grad.item())
print('without zero_grad:', no_zero)
print('with zero_grad:', with_zero)without: [2, 4, 6] — with: [2, 2, 2]
Why: The true gradient is 2 every step. Without zero_grad it accumulates 2→4→6, so your effective learning rate silently grows. Call optimizer.zero_grad() (or .grad.zero_()) BEFORE every backward().
| step | grad (no zero_grad) | grad (with zero_grad) |
|---|---|---|
| 1 | 2.0 | 2.0 |
| 2 | 4.0 (accumulated) | 2.0 |
| 3 | 6.0 (accumulated) | 2.0 |
Comparison
Comparison matrix
From Bug: forgetting zero_grad(): refill the grad (no zero_grad) column from what you know. The rest of the table is as it appeared.
| step | grad (no zero_grad) | grad (with zero_grad) |
|---|---|---|
| 1 | 2.0 | 2.0 |
| 2 | 4.0 (accumulated) | 2.0 |
| 3 | 6.0 (accumulated) | 2.0 |
Anomaly
Predict first
A student writes this, and it looks reasonable:
Loss is going UP during training. Obviously the model needs to train longer, or the dataset is too small — crank the epochs and add data.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: A rising TRAINING loss means you are stepping uphill — the update itself is wrong.
A rising training loss = you're doing gradient ascent. Suspect a sign error in the gradient or a learning rate too high.
Why: A rising TRAINING loss means you are stepping uphill — the update itself is wrong. More epochs make it worse, not better; more data doesn't fix a mis-signed gradient or a divergent learning rate.
Trap
Loss is going UP during training. Obviously the model needs to train longer, or the dataset is too small — crank the epochs and add data.
Add epochs / more data
Why: A rising TRAINING loss means you are stepping uphill — the update itself is wrong. More epochs make it worse, not better; more data doesn't fix a mis-signed gradient or a divergent learning rate.
A rising training loss = you're doing gradient ascent. Suspect a sign error in the gradient or a learning rate too high.
Gradient-check, then lower the lr
Why: First gradient-check the backward pass (a flipped sign sends you uphill deterministically). If the gradient is correct, the step size is overshooting — reduce the learning rate until the loss descends.
Break the constraint
Discussion prompt
The rule this trap just fixed:
A rising training loss = you're doing gradient ascent. Suspect a sign error in the gradient or a learning rate too high.
Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?
Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.
Answer:
A rising TRAINING loss means you are stepping uphill — the update itself is wrong. More epochs make it worse, not better; more data doesn't fix a mis-signed gradient or a divergent learning rate.
Section
Part 5 continued — pathologies
Intuition
Backprop multiplies a Jacobian per layer. Multiply many numbers all < 1 and the product races to 0 (vanishing); multiply many numbers all > 1 and it races to ∞ (exploding). Depth turns a small bias into an exponential.
The sigmoid's derivative peaks at 0.25. Stack L sigmoids and the best-case gradient factor is 0.25ᴸ — it collapses fast.
\[ \max_z \sigma'(z) = \tfrac{1}{4}, \qquad \text{so a chain of } L \text{ shrinks by up to } \left(\tfrac{1}{4}\right)^{L} \]
Explain it
Discussion prompt
Explain Why deep gradients vanish or explode to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
The sigmoid's derivative peaks at 0.25. Stack L sigmoids and the best-case gradient factor is 0.25ᴸ — it collapses fast.
Fill the middle
Fill in the blanks
From Where the 0.25 comes from — one line has had its right-hand side removed. Put it back.
import numpy as np
sig = lambda z: 1/(1+np.exp(-z))
sigp = *lambda z: sig(z)(1-sig(z))** # σ'(z) = σ(1-σ)
for z in [-4, -2, 0, 2, 4]:
print(f'z=___ sigma=___ sigma_prime=___')
print('peak at z=0:', sigp(0))
Why: sigp is what everything below it consumes, so the wrong expression here fails later and somewhere else. At z=0, σ=0.5 and σ'=0.5·0.5=0.25.
Worked example
The sigmoid derivative has a clean form: σ'(z) = σ(z)(1 − σ(z)). Let s = σ(z) ∈ (0,1); then σ' = s(1−s), a downward parabola in s maximized at s = ½, giving ¼. Confirm across z — standalone:
import numpy as np
sig = lambda z: 1/(1+np.exp(-z))
sigp = lambda z: sig(z)*(1-sig(z)) # σ'(z) = σ(1-σ)
for z in [-4, -2, 0, 2, 4]:
print(f'z={z:+d} sigma={sig(z):.4f} sigma_prime={sigp(z):.4f}')
print('peak at z=0:', sigp(0))σ'(z) is largest at z = 0, equal to 0.25, and decays toward 0 on both sides
Why: At z=0, σ=0.5 and σ'=0.5·0.5=0.25. As |z| grows the sigmoid saturates (σ→0 or 1) and σ'→0. So EVERY layer multiplies the backprop signal by at most 0.25 — usually far less once units saturate.
| z | σ(z) | σ'(z) = σ(1−σ) |
|---|---|---|
| −4 | 0.0180 | 0.0177 |
| −2 | 0.1192 | 0.1050 |
| 0 | 0.5000 | 0.2500 (peak) |
| 2 | 0.8808 | 0.1050 |
| 4 | 0.9820 | 0.0177 |
Pattern
Step through it
Step through Where the 0.25 comes from one row at a time. What is driving the change, and what would the row after the last one be?
Pattern
Predict first
The table runs: 0 | nearest input | 3.74e-02 · 1 | — | 3.37e-01 · 2 | — | 2.67e+00
In Diagnose vanishing with per-layer grad norms, given the rows so far: what is the next one — the row where layer is 3?
Correct: 3 | nearest output | 1.46e+01
| layer | position | grad norm |
|---|---|---|
| 0 | nearest input | 3.74e-02 |
| 1 | — | 3.37e-01 |
| 2 | — | 2.67e+00 |
| 3 | nearest output | 1.46e+01 |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. The earliest layer (0) gets a gradient ~400× smaller than the last (3).
Worked example
The diagnostic tool: print the gradient norm of each layer after backward(). If norms shrink steadily toward the input layers, you have vanishing gradients. A 4-layer sigmoid net, standalone:
import torch
torch.manual_seed(0)
layers = torch.nn.ModuleList([torch.nn.Linear(8, 8) for _ in range(4)])
x = torch.randn(16, 8)
for lin in layers:
x = torch.sigmoid(lin(x)) # 4 stacked sigmoids
x.sum().backward()
for i, lin in enumerate(layers):
print(f'layer {i} grad norm = {lin.weight.grad.norm().item():.4e}')Norms grow 0.037 → 0.34 → 2.7 → 14.6 from input to output
Why: The earliest layer (0) gets a gradient ~400× smaller than the last (3). Signal barely reaches the input layers — the vanishing signature. Fix with ReLU/better init/residual connections/normalization.
| layer | position | grad norm |
|---|---|---|
| 0 | nearest input | 3.74e-02 |
| 1 | — | 3.37e-01 |
| 2 | — | 2.67e+00 |
| 3 | nearest output | 1.46e+01 |
Missing information
Discussion prompt
The cure for exploding gradients is gradient clipping: if the gradient's norm exceeds a threshold, rescale it down to that threshold — same direction, capped length. Standalone:
What do you need to know — or decide — before the first line can be written? List everything the problem has to hand you.
Hint: Anything you would have to invent to get started is a thing the problem must supply.
Answer:
The 3–4–5 triangle: [30,40] has norm 50, scaling by 5/50 gives [3,4] with norm exactly 5. Direction preserved, magnitude capped. In torch: torch.nn.utils.clip_grad_norm_(params, 5.0).
Worked example
The cure for exploding gradients is gradient clipping: if the gradient's norm exceeds a threshold, rescale it down to that threshold — same direction, capped length. Standalone:
import numpy as np
g = np.array([30.0, 40.0]) # a big gradient
norm = np.linalg.norm(g) # sqrt(30^2+40^2) = 50
print('grad norm =', norm)
clip = 5.0
if norm > clip:
g = g * clip / norm # rescale to length 5
print('clipped grad =', g, 'new norm =', np.linalg.norm(g))norm 50 → clipped to [3, 4], new norm 5
Why: The 3–4–5 triangle: [30,40] has norm 50, scaling by 5/50 gives [3,4] with norm exactly 5. Direction preserved, magnitude capped. In torch: torch.nn.utils.clip_grad_norm_(params, 5.0).
| quantity | before clip | after clip |
|---|---|---|
| gradient | [30, 40] | [3, 4] |
| norm | 50.0 | 5.0 |
| direction | 0.6, 0.8 | 0.6, 0.8 (same) |
Estimation
Predict first
A NaN (or inf) loss almost always comes from log(0), 0/0, sqrt of a negative, or overflow. Cross-entropy hits log(0) when a prediction saturates to exactly 0 or 1. Standalone:
Commit before you compute: what does Hunting NaNs: log(0) and the clip fix come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.
Correct: Raw loss = inf; clipping p to [1e-12, 1−1e-12] gives 9.4414
Why: A prediction you can defend turns the computation into a check rather than a leap of faith — and an answer that contradicts it is caught on the spot. np.log(0) is −inf, so the mean is inf and every downstream gradient becomes NaN.
Worked example
A NaN (or inf) loss almost always comes from log(0), 0/0, sqrt of a negative, or overflow. Cross-entropy hits log(0) when a prediction saturates to exactly 0 or 1. Standalone:
import numpy as np
p = np.array([1.0, 0.0, 0.5]) # a prediction saturated to 0
y = np.array([1.0, 1.0, 1.0])
with np.errstate(divide='ignore', invalid='ignore'):
bad = -np.mean(y*np.log(p)) # log(0) = -inf → inf loss
print('log(0) loss:', bad)
eps = 1e-12
good = -np.mean(y*np.log(np.clip(p, eps, 1-eps)))
print('clipped loss:', round(good, 4))Raw loss = inf; clipping p to [1e-12, 1−1e-12] gives 9.4414
Why: np.log(0) is −inf, so the mean is inf and every downstream gradient becomes NaN. Clamping the prediction away from the boundary keeps log finite. In torch, torch.autograd.set_detect_anomaly(True) names the exact op that first produced the NaN.
| prediction handling | log term | loss |
|---|---|---|
| raw p (contains 0) | log(0) = −inf | inf (broken) |
| np.clip(p, 1e-12, 1−1e-12) | log(1e-12) ≈ −27.6 | 9.4414 (finite) |
Reverse engineer
Discussion prompt
Work backwards. The example finished here:
Raw loss = inf; clipping p to [1e-12, 1−1e-12] gives 9.4414
What was it asked to do, and what must it have been given? Reconstruct the problem from its answer.
Hint: Every quantity in the result had to enter somewhere. Account for each one.
Answer:
A NaN (or inf) loss almost always comes from log(0), 0/0, sqrt of a negative, or overflow. Cross-entropy hits log(0) when a prediction saturates to exactly 0 or 1. Standalone:
Anomaly
Predict first
A student writes this, and it looks reasonable:
Write the sigmoid straight from the formula: 1 / (1 + np.exp(-z)). It's the definition — what could go wrong?
It is wrong. Say what breaks — and say it before you turn the page.
Correct: exp(−(−1000)) = exp(1000) = inf → a RuntimeWarning: overflow.
Use the branch-stable form: for z ≥ 0 use 1/(1+e⁻ᶻ); for z < 0 use eᶻ/(1+eᶻ). Each branch only ever exponentiates a non-positive number, so exp stays in [0, 1].
Why: exp(−(−1000)) = exp(1000) = inf → a RuntimeWarning: overflow. The final value happens to round to 0 here, but the inf can propagate (inf−inf = NaN) and the warning is telling you the intermediate blew up. Large-magnitude logits are common once training diverges.
Trap
Write the sigmoid straight from the formula: 1 / (1 + np.exp(-z)). It's the definition — what could go wrong?
1/(1+np.exp(-z)) at z = −1000
Why: exp(−(−1000)) = exp(1000) = inf → a RuntimeWarning: overflow. The final value happens to round to 0 here, but the inf can propagate (inf−inf = NaN) and the warning is telling you the intermediate blew up. Large-magnitude logits are common once training diverges.
Use the branch-stable form: for z ≥ 0 use 1/(1+e⁻ᶻ); for z < 0 use eᶻ/(1+eᶻ). Each branch only ever exponentiates a non-positive number, so exp stays in [0, 1].
Pick the branch by the sign of z
Why: exp of a non-positive argument never overflows. Same mathematical function, no inf, no warning. In practice call a library's stable sigmoid / use logits-based losses (torch.nn.BCEWithLogitsLoss) that fold the sigmoid in stably.
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
f near x is its Taylor series. Expanding at x + ε and at x − ε:; Normalize by the size of the gradients so the verdict is scale-free:(f(x+ε) − f(x))/ε and make ε as tiny as possible — smaller ε must mean less error, right?; Loss is going UP during training. Obviously the model needs to train longer, or the dataset is too small — crank the epochs and add data.Constraint
Discussion prompt
Run The debugging checklist with this step confiscated:
zero_grad: reset gradients each step or they accumulate (2, 4, 6, …)
Is it still possible? If it is, say what takes its place and what it costs you. If it is not, say exactly what that step was providing that nothing else does.
Hint: A step you can drop for free was never load-bearing. If you cannot drop it, name the thing that goes wrong the moment it is gone.
Answer:
.shape; broadcasting (n,) vs (n,1) hides silent bugssum/mean, confirm the result shape matches per-sample vs per-feature intent< 1e-5, and cross-check torch.autograd2, 4, 6, …)set_detect_anomaly(True); clamp inputs to log, sqrt, and division away from bad valuesPattern
.shape; broadcasting (n,) vs (n,1) hides silent bugssum/mean, confirm the result shape matches per-sample vs per-feature intent< 1e-5, and cross-check torch.autograd2, 4, 6, …)set_detect_anomaly(True); clamp inputs to log, sqrt, and division away from bad valuesEdge cases
Discussion prompt
The debugging checklist works on the cases you have just seen. Push it to the edge: what is the most degenerate input it still handles — empty, zero, one item, everything equal — and what is the first case where it stops being true? Name the case, not just "it breaks".
Hint: Try the smallest legal input, then the largest, then the one where two things collide. Methods are specified at their edges; the middle takes care of itself.
Answer:
.shape; broadcasting (n,) vs (n,1) hides silent bugssum/mean, confirm the result shape matches per-sample vs per-feature intent< 1e-5, and cross-check torch.autograd2, 4, 6, …)set_detect_anomaly(True); clamp inputs to log, sqrt, and division away from bad valuesCommit first
Predict first
You compute a - b where a.shape is (4,) and b.shape is (4, 1). The result shape is:
Commit to an answer, then rate it — certain, fairly sure, or guessing — and write the rating down before you turn the page.
Correct: (4, 4) — a silent broadcast, almost certainly a bug
Why: Broadcasting aligns from the right: (4,) is treated as (1,4), and (1,4) against (4,1) expands both to (4,4). No error is raised, so a downstream mean() averages 16 values instead of 4 — a classic silent bug. Fix with b.ravel() or matching shapes.
The rating matters as much as the answer: confident-and-wrong is the combination that survives revision, because nothing about it feels like it needs revisiting.
Check
Remember broadcasting aligns from the right.
Check your understanding
You compute a - b where a.shape is (4,) and b.shape is (4, 1). The result shape is:
Answer: A
Why: Broadcasting aligns from the right: (4,) is treated as (1,4), and (1,4) against (4,1) expands both to (4,4). No error is raised, so a downstream mean() averages 16 values instead of 4 — a classic silent bug. Fix with b.ravel() or matching shapes.
Prediction
Predict first
In PyTorch you call backward() three times on the same constant-gradient step and never call zero_grad(). The .grad values you read are:
Answer it in your own words, now, with nothing to choose from. The options are on the next slide — and picking the right one off a list is an easier skill than producing it.
Correct: 2, 4, 6 — gradients accumulate across backward() calls
Why: PyTorch ADDS each new gradient into .grad rather than overwriting, so a constant gradient of 2 accumulates to 2, 4, 6. You must call optimizer.zero_grad() (or .grad.zero_()) before each backward() to reset it.
Check
The true gradient of the step is constant at 2.0.
Check your understanding
In PyTorch you call backward() three times on the same constant-gradient step and never call zero_grad(). The .grad values you read are:
Answer: A
Why: PyTorch ADDS each new gradient into .grad rather than overwriting, so a constant gradient of 2 accumulates to 2, 4, 6. You must call optimizer.zero_grad() (or .grad.zero_()) before each backward() to reset it.
Elimination
Eliminate the wrong options
A binary-cross-entropy loss turns NaN after a few steps. The most likely immediate cause is:
3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.
Survives elimination: A
Why: BCE contains log(p) and log(1−p). When a prediction saturates to 0 or 1, one of those logs is log(0) = −inf, which propagates to a NaN loss and NaN gradients. Clamp p to [1e-12, 1−1e-12] (or use a log-sum-exp-stable loss).
Check
Your cross-entropy loss suddenly reads NaN.
Check your understanding
A binary-cross-entropy loss turns NaN after a few steps. The most likely immediate cause is:
Answer: A
Why: BCE contains log(p) and log(1−p). When a prediction saturates to 0 or 1, one of those logs is log(0) = −inf, which propagates to a NaN loss and NaN gradients. Clamp p to [1e-12, 1−1e-12] (or use a log-sum-exp-stable loss).
Section
Part 6 of 6 — the project
Concept
Build gradient_check(f, grad, x) once, then use it forever as a unit test on any backward pass you write. You'll confirm a correct gradient passes, then plant a bug and watch it fail.
| # | requirement | tool |
|---|---|---|
| 1 | central numeric gradient | (f(x+ε)−f(x−ε))/2ε |
| 2 | max relative error; pass if < 1e-5 | abs diff / abs sum |
| 3 | flip a sign → check FAILS | compare rel errors |
Build rules: type every line yourself, use the central difference with ε = 1e-5, and add 1e-12 in the denominator so a zero gradient never divides by zero. Run after each milestone.
Analogy
Discussion prompt
Explain Project: your reusable gradient checker by analogy to something with no Machine Learning in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Build gradient_check(f, grad, x) once, then use it forever as a unit test on any backward pass you write. You'll confirm a correct gradient passes, then plant a bug and watch it fail.
Worked example
Your turn: write the central-difference gradient of f(x) = Σ xᵢ² sin xᵢ at x = [0.5, 1, 1.5, 2]. Predict its shape (4,) before you print.
Hint: loop over coordinates, perturb each by ±ε, apply (f(x+ε) − f(x−ε)) / 2ε. Copy x before mutating so you don't perturb two coordinates at once.
import numpy as np
f = lambda x: np.sum(x**2*np.sin(x))
def numeric(f, x, eps=1e-5):
g = np.zeros_like(x)
for i in range(len(x)):
xp, xm = x.copy(), x.copy(); xp[i]+=eps; xm[i]-=eps
g[i] = (f(xp) - f(xm)) / (2*eps)
return g
x = np.array([0.5, 1.0, 1.5, 2.0])
print(numeric(f, x).round(4))| x | numeric ∂f/∂xᵢ |
|---|---|
| 0.5 | 0.6988 |
| 1.0 | 2.2232 |
| 1.5 | 3.1516 |
| 2.0 | 1.9726 |
Scale up
Step through it
Step through Milestone 1 — the numeric gradient and watch the numbers move. Now imagine the input ten times bigger: which column is the one that stops this being practical?
Worked example
Your turn: compare the analytic gradient 2x sin x + x² cos x to your numeric one by max relative error. Predict: will it pass?
Hint: rel = max(|gₐ − gₙ| / (|gₐ| + |gₙ| + 1e-12)); pass if < 1e-5. It should land around 1e-11.
import numpy as np
f = lambda x: np.sum(x**2*np.sin(x))
def numeric(f, x, eps=1e-5):
g = np.zeros_like(x)
for i in range(len(x)):
xp, xm = x.copy(), x.copy(); xp[i]+=eps; xm[i]-=eps
g[i] = (f(xp) - f(xm)) / (2*eps)
return g
grad = lambda x: 2*x*np.sin(x) + x**2*np.cos(x)
x = np.array([0.5, 1.0, 1.5, 2.0])
ga, gn = grad(x), numeric(f, x)
rel = np.max(np.abs(ga-gn)/(np.abs(ga)+np.abs(gn)+1e-12))
print(f'{rel:.2e}', rel < 1e-5)| quantity | value |
|---|---|
| max rel error | 4.86e-11 |
| threshold | 1e-5 |
| pass? | True |
Pattern
Step through it
Step through Milestone 2 — check the correct gradient one row at a time. What is driving the change, and what would the row after the last one be?
Worked example
Your turn: flip the sign of the second term (− x² cos x) and re-check. Predict the rel-error magnitude before running.
Hint: grad_bug = 2*x*np.sin(x) - x**2*np.cos(x). The rel error should jump to ~0.46 — a clear FAIL.
import numpy as np
f = lambda x: np.sum(x**2*np.sin(x))
def numeric(f, x, eps=1e-5):
g = np.zeros_like(x)
for i in range(len(x)):
xp, xm = x.copy(), x.copy(); xp[i]+=eps; xm[i]-=eps
g[i] = (f(xp) - f(xm)) / (2*eps)
return g
grad_bug = lambda x: 2*x*np.sin(x) - x**2*np.cos(x)
x = np.array([0.5, 1.0, 1.5, 2.0])
gb, gn = grad_bug(x), numeric(f, x)
rel = np.max(np.abs(gb-gn)/(np.abs(gb)+np.abs(gn)+1e-12))
print(f'{rel:.2e}', rel < 1e-5)| gradient | rel error | pass? |
|---|---|---|
| correct | 4.86e-11 | True |
| sign bug | 4.58e-01 | False (caught) |
Worked example
Your turn: point gradient_check at the logistic-regression loss from Part 4 and its analytic gradient Xᵀ(p−y)/N. Predict it passes; then swap in the /N-less version and predict 0.60.
Hint: reuse the SAME gradient_check — it doesn't care whether f is a toy sum or a real loss. That reusability is the whole point.
import numpy as np
def gradient_check(f, grad, x, eps=1e-5):
gn = np.zeros_like(x)
for i in range(len(x)):
xp, xm = x.copy(), x.copy(); xp[i]+=eps; xm[i]-=eps
gn[i] = (f(xp) - f(xm)) / (2*eps)
ga = grad(x)
return np.max(np.abs(ga-gn)/(np.abs(ga)+np.abs(gn)+1e-12))
X = np.array([[1.,0.5,-0.3],[1.,-1.2,0.8],[1.,2.0,0.1],[1.,0.3,-1.5]])
y = np.array([1.,0.,1.,0.]); w = np.array([0.1,-0.2,0.4])
sig = lambda z: 1/(1+np.exp(-z))
loss = lambda w: -np.mean(y*np.log(sig(X@w)) + (1-y)*np.log(1-sig(X@w)))
good = lambda w: X.T @ (sig(X@w) - y) / len(y)
bad = lambda w: X.T @ (sig(X@w) - y) # missing /N
print('good:', f'{gradient_check(loss, good, w):.2e}')
print('bad :', f'{gradient_check(loss, bad, w):.2e}')| gradient | rel error | verdict |
|---|---|---|
| good Xᵀ(p−y)/N | 1.88e-10 | PASS |
| bad Xᵀ(p−y) | 6.00e-01 | FAIL (caught) |
Trade off
Comparison matrix
From Milestone 4 — check a real model gradient: every row here is a choice with a cost. Fill the rel error column, then say which row you would actually pick and what you give up for it.
| gradient | rel error | verdict |
|---|---|---|
| good Xᵀ(p−y)/N | 1.88e-10 | PASS |
| bad Xᵀ(p−y) | 6.00e-01 | FAIL (caught) |
Concept
import numpy as np
def gradient_check(f, grad, x, eps=1e-5):
gn = np.zeros_like(x)
for i in range(len(x)):
xp, xm = x.copy(), x.copy(); xp[i]+=eps; xm[i]-=eps
gn[i] = (f(xp) - f(xm)) / (2*eps) # central diff
ga = grad(x)
return np.max(np.abs(ga-gn)/(np.abs(ga)+np.abs(gn)+1e-12))
f = lambda x: np.sum(x**2*np.sin(x))
good = lambda x: 2*x*np.sin(x) + x**2*np.cos(x)
bad = lambda x: 2*x*np.sin(x) - x**2*np.cos(x)
x = np.array([0.5, 1.0, 1.5, 2.0])
print('good:', f'{gradient_check(f, good, x):.2e}')
print('bad :', f'{gradient_check(f, bad, x):.2e}')| gradient | rel error | verdict |
|---|---|---|
| good | 4.86e-11 | PASS |
| bad | 4.58e-01 | FAIL |
If gradient_check passes the correct gradient and flags the buggy one, you now have a unit test for every backward pass you will ever write.
Comparison
Comparison matrix
From The full program: refill the rel error column from what you know. The rest of the table is as it appeared.
| gradient | rel error | verdict |
|---|---|---|
| good | 4.86e-11 | PASS |
| bad | 4.58e-01 | FAIL |
Concept
Slides closed, out loud: explain (1) why the central difference is O(ε²) — which Taylor terms cancel — (2) why ε = 1e-12 is worse than 1e-5, and (3) what a rising training loss tells you.
Stretch (homework): plant a deliberate bug in your Lesson-18 MLP backprop and locate it with gradient_check, then plot per-layer gradient norms during training to spot vanishing/exploding. This debugging toolkit recurs in the training loop (Week 14) and transformers (Week 30).
Counterexample
Discussion prompt
Slides closed, out loud: explain (1) why the central difference is O(ε²) — which Taylor terms cancel — (2) why ε = 1e-12 is worse than 1e-5, and (3) what a rising training loss tells you.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — Why check a gradient at all? · Finite differences, derived · The relative-error test · Check a real gradient · Now break it · Debugging real models. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
O(ε²) error from Taylor — the even terms cancel — and know ε ≈ 1e-5 beats 1e-12< 1e-5 passes, ~0.5 is a bug, and cross-check torch.autogradXᵀ(p−y)/N, and read a bug's rel error as a clue (sign → 0.46, missing /N → exactly 0.60)(n,)−(n,1)→(n,n), wrong axis, forgotten zero_grad (2,4,6)NaNs with clamping and detect_anomaly| idea | the one thing to remember |
|---|---|
| central diff | (f(x+ε)−f(x−ε))/2ε, ε≈1e-5, O(ε²) |
| gradient check | max rel err < 1e-5 = correct |
| rel-error clue | constant → scaling bug; per-coord → that term |
| rising loss | sign bug or learning rate too high |
| broadcasting | print shapes; (n,)−(n,1) is (n,n) |
| zero_grad | reset each step or grads accumulate |
| NaN | log/sqrt/div; clamp inputs, detect_anomaly() |
Want this taught 1-on-1? Alexander tutors Machine Learning — $55/session, free consultation.