Lesson 33: Gradient Checking & Debugging

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

What this lesson covers

The lesson, slide by slide

1. Gradient Checking & Debugging

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.

2. By the end of this lesson you can

Objectives

  1. Derive the forward difference's O(ε) error and the central difference's O(ε²) error from Taylor expansions, term by term
  2. Explain the truncation vs round-off tradeoff and why ε ≈ 1e-5 is the sweet spot (not 1e-12)
  3. Build a relative-error gradient check and read its verdict: < 1e-5 passes, ~0.5 is a real bug
  4. Derive the logistic-regression gradient Xᵀ(p−y)/N, gradient-check it, and match torch.autograd
  5. Catch the classic backprop bugs — sign flip, missing 1/N, broadcasting, wrong axis, forgotten zero_grad — and diagnose vanishing/exploding gradients and NaNs

3. What survived from Bias-Variance & Cross-Validation?

Warm-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.

4. Why check a gradient at all?

Section

Part 1 of 6 — motivation

5. A wrong gradient fails silently

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.

6. Break it if you can: A wrong gradient fails silently

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.

7. The idea: perturb the input, watch the loss

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.

8. Finite differences, derived

Section

Part 2 of 6 — Taylor to O(ε²)

9. Two ways to straddle the point

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.

10. The tool: Taylor expansion

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 \]

11. By analogy: The tool: Taylor expansion

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 − ε:

12. What has to happen first: Forward difference is only O(ε)

Ranking

Put in order

Put the moves of Forward difference is only O(ε) into the order they have to happen.

  1. Subtract f(x) from the (x+ε) expansion
  2. Divide both sides by ε
  3. The leftover error is proportional to ε → O(ε)

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).

13. Forward difference is only O(ε)

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.

methodestimateleading error termorder
forward(f(x+ε) − f(x))/ε½ f''(x) · εO(ε)

14. Decode the notation: Forward difference is only 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 \)

  • Everything except the constant term survives; this is f(x+ε) − f(x).
  • Isolate the slope estimate on the left; every remaining term keeps one factor of ε or more.
  • The first uncancelled term is ½f''(x)·ε. Halving ε only halves the error. That linear decay is why forward differences are the weaker tool.

15. Plan first: Central difference: subtract the two expansions

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:

  1. Subtract: f(x+ε) − f(x−ε)
  2. The f'' term cancelled — that is the whole trick
  3. Divide by 2ε

16. Central difference: subtract the two expansions

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 \]

17. Work backwards from the answer: Central difference: subtract the two…

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:

18. Central difference is O(ε²)

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)\;} \]

methodleading errororder
forward½ f''(x) · εO(ε)
central⅙ f'''(x) · ε²O(ε²)

19. Fill in: order for Central difference is 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.

methodleading errororder
forward½ f''(x) · εO(ε)
central⅙ f'''(x) · ε²O(ε²)

20. Guess the shape of the answer: See O(ε²) in real numbers

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².

21. See O(ε²) in real numbers

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 errorcentral ratioforward error
0.10001.000e-02—6.100e-01
0.05002.500e-034.0×3.025e-01
0.02506.250e-044.0×1.506e-01
0.01251.563e-044.0×7.516e-02

22. Which is which, by central ratio

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.

—
0.1000
4.0×
0.0500; 0.0250; 0.0125
g1
central ratio is "—" for 0.1000 — that is what the table on "See O(ε²) in real numbers" records, and it is the single property separating this group from the rest.
g2
central ratio is "4.0×" for 0.0500, 0.0250, 0.0125 — that is what the table on "See O(ε²) in real numbers" records, and it is the single property separating this group from the rest.

23. Why not take ε → 0?

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).

24. What has to be given first: The truncation vs round-off U-curve

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.

25. The truncation vs round-off U-curve

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 errorregime
1e-15.33e-15(tiny — x² has no f''')
1e-36.61e-13truncation
1e-53.93e-11sweet spot
1e-79.82e-09round-off creeping in
1e-94.96e-07round-off
1e-114.96e-07round-off floor

26. What each one costs: The truncation vs round-off U-curve

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 errorregime
1e-15.33e-15(tiny — x² has no f''')
1e-36.61e-13truncation
1e-53.93e-11sweet spot
1e-79.82e-09round-off creeping in
1e-94.96e-07round-off
1e-114.96e-07round-off floor

27. Something is wrong here: one-sided difference with a microscopic ε

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.

28. Trap: one-sided difference with a microscopic ε

Trap

The 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.

The fix

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.

29. The relative-error test

Section

Part 3 of 6 — the verdict rule

30. Why relative, not absolute, error

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} \]

31. Teach it back: Why relative, not absolute, error

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:

32. Reading the number

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 errorverdict
< 1e-7definitely correct
1e-7 to 1e-5correct (fine for float64)
1e-3 to 1e-2suspicious — likely a bug
> 1e-2definitely 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.

33. Where does each piece belong: Lesson 33: Gradient Checking & Debugging

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.

Why check a gradient at all?
A wrong gradient fails silently; The idea: perturb the input, watch the loss
Finite differences, derived
Two ways to straddle the point; The tool: Taylor expansion; Forward difference is only O(ε)
The relative-error test
Why relative, not absolute, error; Reading the number
s1
Why check a gradient at all? is where Lesson 33: Gradient Checking & Debugging puts A wrong gradient fails silently, The idea: perturb the input, watch the loss. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
Finite differences, derived is where Lesson 33: Gradient Checking & Debugging puts Two ways to straddle the point, The tool: Taylor expansion, Forward difference is only O(ε). Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
The relative-error test is where Lesson 33: Gradient Checking & Debugging puts Why relative, not absolute, error, Reading the number. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

34. Check a real gradient

Section

Part 4 of 6 — the running model

35. Our running model: logistic regression

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:

samplebiasx₁x₂y
010.5−0.31
11−1.20.80
212.00.11
310.3−1.50

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.

36. What stays fixed: Our running model: logistic regression

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.

  1. Step 1: sample is 0
  2. Step 2: sample is 1
  3. Step 3: sample is 2
  4. Step 4: sample is 3

37. The loss and the forward pass

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))
samplez = x·wp = σ(z)yp − y
0−0.12000.47001−0.5300
1+0.66000.65930+0.6593
2−0.26000.43541−0.5646
3−0.56000.36350+0.3635

38. What happens as it grows: The loss and the forward pass

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?

  1. Step 1: sample is 0
  2. Step 2: sample is 1
  3. Step 3: sample is 2
  4. Step 4: sample is 3

39. What has to happen first: Derive the analytic gradient

Ranking

Put in order

Put the moves of Derive the analytic gradient into the order they have to happen.

  1. Chain rule: ∂L/∂w = (∂L/∂z)·(∂z/∂w)
  2. The cross-entropy + sigmoid combine to ∂L/∂zᵢ = (pᵢ − yᵢ)/N
  3. Multiply by ∂z/∂w = Xᵀ

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.

40. Derive the analytic gradient

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)\;} \]

41. Say it in words: Derive the analytic gradient

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.

42. What has to happen first: Compute the gradient by hand, entry by entry

Ranking

Put in order

Put the moves of Compute the gradient by hand, entry by entry into the order they have to happen.

  1. Bias entry = mean(p − y) = (−0.5300 + 0.6593 − 0.5646 + 0.3635)/4
  2. x₁ entry = Σ x₁ᵢ(pᵢ−yᵢ)/4 = −0.519076
  3. x₂ entry = Σ x₂ᵢ(pᵢ−yᵢ)/4 = 0.021153

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.

43. Compute the gradient by hand, entry by entry

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} \]

44. Decode the notation: Compute the gradient by hand, entry by entry

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} \)

  • Column 0 is all ones, so its dot with (p−y) is just the sum; over N it is the mean. = −0.017948.
  • Column 1 = [0.5,−1.2,2.0,0.3] dotted with (p−y), divided by 4.
  • Column 2 = [−0.3,0.8,0.1,−1.5] dotted with (p−y), divided by 4.

45. Predict the next row: Gradient-check it: analytic vs numeric

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

coordanalyticnumericrel err
bias−0.01794812−0.01794812≈2e-11
x₁−0.51907571−0.51907571≈1e-11
x₂0.021153180.021153181.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.

46. Gradient-check it: analytic vs numeric

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.

coordanalyticnumericrel err
bias−0.01794812−0.01794812≈2e-11
x₁−0.51907571−0.51907571≈1e-11
x₂0.021153180.021153181.88e-10

47. Restore the missing line: Cross-check against torch.autograd

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).

48. Cross-check against torch.autograd

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.

coordtorch autogradour Xᵀ(p−y)/N
bias−0.01794811−0.01794811
x₁−0.51907570−0.51907570
x₂0.021153170.02115317

49. Watch it run: Cross-check against torch.autograd

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?

  1. Step 1: coord is bias
  2. Step 2: coord is x₁
  3. Step 3: coord is x₂

50. If autograd exists, why finite differences?

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.

51. Now break it

Section

Part 4 continued — catching bugs

52. Finish it with less help: Bug #1 — a sign flip

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.

53. Bug #1 — a sign flip

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.

gradientmax rel errorverdict
correct4.86e-11PASS
sign bug4.58e-01FAIL (caught)

54. Restore the missing line: Bug #2 — forgetting the 1/N

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.

55. Bug #2 — forgetting the 1/N

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.

coordbuggy (sum)numeric (correct)ratiorel err
bias−0.071792−0.0179484.0×0.60
x₁−2.076303−0.5190764.0×0.60
x₂0.0846130.0211534.0×0.60

56. The rel-error magnitude is a clue

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.

57. A test, not a training step

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.

58. Rebuild the recipe: The gradient-check recipe

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.

  1. Numeric gradient: for each coordinate i, central-difference with ε = 1e-5: (f(x+εeᵢ) − f(x−εeᵢ)) / 2ε
  2. Analytic gradient: the one your backward pass computes
  3. Relative error: max |gₐ − gₙ| / (|gₐ| + |gₙ| + 1e-12) over all coordinates
  4. Verdict: < 1e-5 correct; ~1e-3 and up is a bug — go fix the formula, not ε
  5. Locate: a constant rel error = a scaling bug; a coordinate-specific one = that term's derivative
  6. Confirm-correct: for extra assurance, cross-check against torch.autograd

Why: 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.

59. The gradient-check recipe

Pattern

  1. Numeric gradient: for each coordinate i, central-difference with ε = 1e-5: (f(x+εeᵢ) − f(x−εeᵢ)) / 2ε
  2. Analytic gradient: the one your backward pass computes
  3. Relative error: max |gₐ − gₙ| / (|gₐ| + |gₙ| + 1e-12) over all coordinates
  4. Verdict: < 1e-5 correct; ~1e-3 and up is a bug — go fix the formula, not ε
  5. Locate: a constant rel error = a scaling bug; a coordinate-specific one = that term's derivative
  6. Confirm-correct: for extra assurance, cross-check against torch.autograd

60. Rule out three: Check yourself — the order of the central…

Elimination

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.

  • A. O(ε²) — the even-power terms cancel, leaving the ε² term
  • B. O(ε) — same as the forward difference
  • C. O(1/ε) — it grows as ε shrinks
  • D. exactly zero — it is the true derivative

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.

61. Check yourself — the order of the central difference

Check

Think back to which Taylor terms cancelled.

Check your understanding

The central difference (f(x+ε)−f(x−ε))/2ε has truncation error of order:

  • A. O(ε²) — the even-power terms cancel, leaving the ε² term (correct)
  • B. O(ε) — same as the forward difference
  • C. O(1/ε) — it grows as ε shrinks
  • D. exactly zero — it is the true derivative

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.

Why B tempts people
The forward difference is O(ε) because it keeps the ½f''ε term. The central difference is strictly better — that term cancels.
Why C tempts people
O(1/ε) describes the ROUND-OFF error growth as ε → 0, not the truncation error of the formula. The truncation error is O(ε²) and shrinks with ε.
Why D tempts people
It is an approximation: O(ε²) truncation plus round-off. It equals the true derivative only in the ε → 0 limit, which a computer cannot take.

62. Answer it before you see the options: Check yourself — reading the verdict

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.

63. Check yourself — reading the verdict

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:

  • A. Treat the analytic gradient as buggy and fix the formula (correct)
  • B. Accept it — 0.46 is a small relative error
  • C. Shrink ε until the error drops below 1e-5
  • D. Ignore it; gradient checks are unreliable noise

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.

Why B tempts people
The denominator caps rel error at 1, so 0.46 is nearly the worst possible. Correct gradients are ~1e-9; 0.46 is a definite bug.
Why C tempts people
Shrinking ε changes only the NUMERIC estimate, which is already accurate. It cannot fix a wrong analytic FORMULA — you would just chase round-off.
Why D tempts people
Done right (central diff, ε≈1e-5, relative error), gradient checks are extremely reliable. A 0.46 is real signal, not noise.

64. Debugging real models

Section

Part 5 of 6 — the silent bugs

65. Most bugs don't crash — they corrupt

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.

bugsymptomhow you catch it
broadcastingsilent wrong values, (n,n) blowupprint every shape
wrong reduction axisloss off by a factorprint shape after sum/mean
forgot zero_grad()gradients accumulatewatch grad grow each step
sign error / high lrloss goes UPplot the loss curve
log(0), 0/0, overflowNaN lossdetect_anomaly, clip inputs

66. Guess the shape of the answer: Bug: broadcasting turns a vector into a…

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).

67. Bug: broadcasting turns a vector into a matrix

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.

expressionshaperesult
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]

68. Which is which, by shape

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.

(3,)
a (vector); a − b.ravel()
(3, 1)
b (column)
(3, 3)
a − b (BUG)
g1
shape is "(3,)" for a (vector), a − b.ravel() — that is what the table on "Bug: broadcasting turns a vector into a…" records, and it is the single property separating this group from the rest.
g2
shape is "(3, 1)" for b (column) — that is what the table on "Bug: broadcasting turns a vector into a…" records, and it is the single property separating this group from the rest.
g3
shape is "(3, 3)" for a − b (BUG) — that is what the table on "Bug: broadcasting turns a vector into a…" records, and it is the single property separating this group from the rest.

69. Bug: reducing over the wrong axis

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.

reductionresultshapemeaning
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)

70. Bug: forgetting zero_grad()

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().

stepgrad (no zero_grad)grad (with zero_grad)
12.02.0
24.0 (accumulated)2.0
36.0 (accumulated)2.0

71. Fill in: grad (no zero_grad) for Bug: forgetting zero_grad()

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.

stepgrad (no zero_grad)grad (with zero_grad)
12.02.0
24.0 (accumulated)2.0
36.0 (accumulated)2.0

72. Something is wrong here: the rising-loss reflex

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.

73. Trap: the rising-loss reflex

Trap

The 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.

The fix

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.

74. Break it on purpose: the rising-loss reflex

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.

75. Vanishing, exploding, NaN

Section

Part 5 continued — pathologies

76. Why deep gradients vanish or explode

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} \]

77. Teach it back: Why deep gradients vanish or explode

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.

78. Restore the missing line: Where the 0.25 comes from

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.

79. Where the 0.25 comes from

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−σ)
−40.01800.0177
−20.11920.1050
00.50000.2500 (peak)
20.88080.1050
40.98200.0177

80. Watch it run: Where the 0.25 comes from

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?

  1. Step 1: z is −4
  2. Step 2: z is −2
  3. Step 3: z is 0
  4. Step 4: z is 2
  5. Step 5: z is 4

81. Predict the next row: Diagnose vanishing with per-layer grad norms

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

layerpositiongrad norm
0nearest input3.74e-02
1—3.37e-01
2—2.67e+00
3nearest output1.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).

82. Diagnose vanishing with per-layer grad norms

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.

layerpositiongrad norm
0nearest input3.74e-02
1—3.37e-01
2—2.67e+00
3nearest output1.46e+01

83. What has to be given first: Fix exploding gradients with clipping

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).

84. Fix exploding gradients with clipping

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).

quantitybefore clipafter clip
gradient[30, 40][3, 4]
norm50.05.0
direction0.6, 0.80.6, 0.8 (same)

85. Guess the shape of the answer: Hunting NaNs: log(0) and the clip fix

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.

86. Hunting NaNs: log(0) and the clip fix

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 handlinglog termloss
raw p (contains 0)log(0) = −infinf (broken)
np.clip(p, 1e-12, 1−1e-12)log(1e-12) ≈ −27.69.4414 (finite)

87. Work backwards from the answer: Hunting NaNs: log(0) and the clip fix

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:

88. Something is wrong here: the naive sigmoid overflows

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.

89. Trap: the naive sigmoid overflows

Trap

The 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.

The fix

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.

90. Which of these survive contact with Lesson 33: Gradient Checking & Debugging?

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.

Holds up
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.; 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 − ε:; Normalize by the size of the gradients so the verdict is scale-free:
Breaks
Use the forward difference (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.
sound
These are stated as this lesson states them — each one survives the edge cases Lesson 33: Gradient Checking & Debugging puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

91. Without one step: The debugging checklist

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:

  1. Shapes first: print every tensor's .shape; broadcasting (n,) vs (n,1) hides silent bugs
  2. Axis: after every sum/mean, confirm the result shape matches per-sample vs per-feature intent
  3. Gradient-check the backward pass — rel err < 1e-5, and cross-check torch.autograd
  4. zero_grad: reset gradients each step or they accumulate (2, 4, 6, …)
  5. Loss sanity: it must go DOWN; a rising loss = sign bug or learning rate too high
  6. Per-layer grad norms: shrinking toward input = vanishing; blowing up = exploding → clip
  7. NaN: set_detect_anomaly(True); clamp inputs to log, sqrt, and division away from bad values

92. The debugging checklist

Pattern

  1. Shapes first: print every tensor's .shape; broadcasting (n,) vs (n,1) hides silent bugs
  2. Axis: after every sum/mean, confirm the result shape matches per-sample vs per-feature intent
  3. Gradient-check the backward pass — rel err < 1e-5, and cross-check torch.autograd
  4. zero_grad: reset gradients each step or they accumulate (2, 4, 6, …)
  5. Loss sanity: it must go DOWN; a rising loss = sign bug or learning rate too high
  6. Per-layer grad norms: shrinking toward input = vanishing; blowing up = exploding → clip
  7. NaN: set_detect_anomaly(True); clamp inputs to log, sqrt, and division away from bad values

93. Where does it stop working: The debugging checklist

Edge 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:

  1. Shapes first: print every tensor's .shape; broadcasting (n,) vs (n,1) hides silent bugs
  2. Axis: after every sum/mean, confirm the result shape matches per-sample vs per-feature intent
  3. Gradient-check the backward pass — rel err < 1e-5, and cross-check torch.autograd
  4. zero_grad: reset gradients each step or they accumulate (2, 4, 6, …)
  5. Loss sanity: it must go DOWN; a rising loss = sign bug or learning rate too high
  6. Per-layer grad norms: shrinking toward input = vanishing; blowing up = exploding → clip
  7. NaN: set_detect_anomaly(True); clamp inputs to log, sqrt, and division away from bad values

94. How sure are you: Check yourself — the broadcasting shape

Commit 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.

95. Check yourself — the broadcasting shape

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:

  • A. (4, 4) — a silent broadcast, almost certainly a bug (correct)
  • B. (4,) — element-wise subtraction
  • C. (4, 1) — b's shape wins
  • D. a ValueError — the shapes are incompatible

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.

Why B tempts people
You'd get (4,) only if both operands were (4,) (or one were a scalar). Here b is (4,1), which forces a 2-D broadcast.
Why C tempts people
Broadcasting doesn't pick one operand's shape; it expands each dimension to the max, giving (4,4), not (4,1).
Why D tempts people
These shapes are broadcast-COMPATIBLE — 1 expands to 4 in each direction — so NumPy proceeds silently rather than erroring. That silence is exactly the danger.

96. Answer it before you see the options: Check yourself — accumulating gradients

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.

97. Check yourself — accumulating gradients

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:

  • A. 2, 4, 6 — gradients accumulate across backward() calls (correct)
  • B. 2, 2, 2 — each backward() overwrites the last
  • C. 2, 1, 0.67 — they are averaged over calls
  • D. a RuntimeError on the second backward()

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.

Why B tempts people
Overwriting is what you WANT, but it's not the default — you get it only by calling zero_grad() first. Without it, .grad accumulates.
Why C tempts people
PyTorch never averages gradients across backward() calls; it sums them. Averaging over a batch happens inside a single loss (e.g. mean), not across steps.
Why D tempts people
A second backward() errors only if you didn't retain the graph AND are reusing the same graph. Here each iteration builds a fresh graph, so it accumulates without error.

98. Rule out three: Check yourself — the NaN source

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.

  • A. A prediction saturated to exactly 0 or 1, so log(0) = −inf
  • B. The learning rate is slightly too low
  • C. The batch size is not a power of two
  • D. You used float64 instead of float32

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).

99. Check yourself — the NaN source

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:

  • A. A prediction saturated to exactly 0 or 1, so log(0) = −inf (correct)
  • B. The learning rate is slightly too low
  • C. The batch size is not a power of two
  • D. You used float64 instead of float32

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).

Why B tempts people
A too-low learning rate makes training SLOW, not NaN. NaNs come from invalid math (log(0), 0/0, overflow), not from small steps.
Why C tempts people
Batch size being a power of two is a minor performance preference; it has no effect on whether the loss is finite.
Why D tempts people
float64 is MORE precise than float32 and less prone to overflow, so it would reduce NaNs, not cause them. The culprit is log(0), independent of dtype.

100. Your turn: build gradient_check

Section

Part 6 of 6 — the project

101. Project: your reusable gradient checker

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.

#requirementtool
1central numeric gradient(f(x+ε)−f(x−ε))/2ε
2max relative error; pass if < 1e-5abs diff / abs sum
3flip a sign → check FAILScompare 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.

102. By analogy: Project: your reusable gradient checker

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.

103. Milestone 1 — the numeric gradient

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))
xnumeric ∂f/∂xᵢ
0.50.6988
1.02.2232
1.53.1516
2.01.9726

104. What happens as it grows: Milestone 1 — the numeric gradient

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?

  1. Step 1: x is 0.5
  2. Step 2: x is 1.0
  3. Step 3: x is 1.5
  4. Step 4: x is 2.0

105. Milestone 2 — check the correct gradient

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)
quantityvalue
max rel error4.86e-11
threshold1e-5
pass?True

106. Watch it run: Milestone 2 — check the correct gradient

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?

  1. Step 1: quantity is max rel error
  2. Step 2: quantity is threshold
  3. Step 3: quantity is pass?

107. Milestone 3 — plant a bug and catch it

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)
gradientrel errorpass?
correct4.86e-11True
sign bug4.58e-01False (caught)

108. Milestone 4 — check a real model gradient

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}')
gradientrel errorverdict
good Xᵀ(p−y)/N1.88e-10PASS
bad Xᵀ(p−y)6.00e-01FAIL (caught)

109. What each one costs: Milestone 4 — check a real model gradient

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.

gradientrel errorverdict
good Xᵀ(p−y)/N1.88e-10PASS
bad Xᵀ(p−y)6.00e-01FAIL (caught)

110. The full program

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}')
gradientrel errorverdict
good4.86e-11PASS
bad4.58e-01FAIL

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.

111. Fill in: rel error for The full program

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.

gradientrel errorverdict
good4.86e-11PASS
bad4.58e-01FAIL

112. Show it off

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).

113. Break it if you can: Show it off

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.

114. Connect it up: Lesson 33: Gradient Checking & Debugging

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.

115. What you can do now

Recap

ideathe one thing to remember
central diff(f(x+ε)−f(x−ε))/2ε, ε≈1e-5, O(ε²)
gradient checkmax rel err < 1e-5 = correct
rel-error clueconstant → scaling bug; per-coord → that term
rising losssign bug or learning rate too high
broadcastingprint shapes; (n,)−(n,1) is (n,n)
zero_gradreset each step or grads accumulate
NaNlog/sqrt/div; clamp inputs, detect_anomaly()

Sources

  1. USAAIO Year-Long Master Lesson Plan, Lesson 33 (Week 11 — Gradient Checking & Debugging) — Barron · USAAIO Round 2 Preparation, 2026
  2. CS231n Notes — Gradient checks (relative error, central differences, kinks)
  3. PyTorch torch.autograd.gradcheck & set_detect_anomaly
  4. Every relative error, gradient norm, and trace value produced by real execution — numpy 2.2.6 + torch 2.7.1, verification run July 2026

Want this taught 1-on-1? Alexander tutors Machine Learning — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108