Lesson 27: Numerical Stability

USAAIO Lesson 27, from Week 9, fully worked to olympiad depth. It covers the finite precision of IEEE-754 - overflow, underflow, and cancellation - with the exact float64 thresholds, then proves the shift-invariance of softmax algebraically. It derives a stable softmax and the log-sum-exp trick one move per beat and traces them by hand on z = [1000, 1001, 1002], then makes log-softmax and cross-entropy overflow-proof. It goes on to the condition number, an ill-conditioned Hilbert solve, and ridge or Tikhonov regularization lifting the smallest eigenvalue, then float32 against float64 and mixed precision. It ends with a build-it project verified line by line against numpy, scipy, and torch. Every snippet runs standalone in a fresh interpreter, and every number was produced by real execution. The lesson runs to 64 slides.

Subject: Machine Learning · 113 slides · code lesson

Open the interactive version of this deck · Homework for this lesson

What this lesson covers

The lesson, slide by slide

1. Numerical Stability

Title

USAAIO · Lesson 27 · Week 9

Math that is exact on paper can print nan in floating point. We take one running example, z = [1000, 1001, 1002], and make its softmax, its log-sum-exp, and its cross-entropy survive finite precision - deriving every fix one move at a time.

2. By the end of this lesson you can

Objectives

  1. State the three IEEE-754 hazards - overflow, underflow, cancellation - with the exact float64 thresholds (exp overflows past ~709.78)
  2. Prove softmax is shift-invariant, softmax(z) = softmax(z - c), and use c = max(z) to kill overflow with zero change to the answer
  3. Derive the log-sum-exp identity log Σ eᶻⁱ = z* + log Σ e^(zᵢ - z*) and use it for a stable cross-entropy
  4. Read the condition number κ = σ_max / σ_min, predict the digits a solve loses (~log₁₀κ), and demote inv for solve / lstsq
  5. Explain why ridge A + λI lifts the smallest eigenvalue and shrinks κ, and why float32 overflows so much sooner than float64

3. What survived from Evaluation Metrics?

Warm-up

Discussion prompt

Before we open Lesson 27: Numerical Stability: without looking back, what was the main idea of Evaluation Metrics, 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:

classification metrics (precision, recall, F1, AUC-ROC, AUC-PR, log-loss), why accuracy misleads on imbalanced data, regression metrics (MSE, RMSE, MAE, R², MAPE), and generation metrics (BLEU, ROUGE, perplexity, FID). Implement precision/recall/F1 and AUC from scratch and verify against sklearn.

4. How floats break

Section

Part 1 of 6 - IEEE-754

5. A float has finite range

Concept

A 64-bit float (IEEE-754 binary64, aka double) stores about 15-16 significant decimal digits and tops out at a largest finite value. Ask for anything bigger and you get inf, not a bigger number.

\[ \text{max float64} = 1.7977\times 10^{308}, \qquad \varepsilon_{\text{machine}} \approx 2.22\times 10^{-16} \]

overflow — A result whose magnitude exceeds the largest representable float, so it is rounded to +inf (or -inf). Once inf enters a computation it usually spreads: inf - inf and inf / inf both give nan.

6. Where exp overflows

Concept

Exponentials are the usual culprit. exp(x) stays finite only while x is below log(max float64). One step past that threshold and it is inf.

\[ e^{x} < \text{max float64} \iff x < \log(\text{max}) \approx 709.78 \]

So exp(709) is a giant but finite number, while exp(710) overflows to inf. A vector of logits near 1000 is far, far past that line.

7. Guess the shape of the answer: The overflow / underflow thresholds, measured

Estimation

Predict first

Let numpy report the exact edges. This block re-imports numpy and prints the four numbers that define the whole lesson - runnable on its own:

Commit before you compute: what does The overflow / underflow thresholds, measured come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.

Correct: exp(709) is finite, exp(710) is inf

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. The cliff sits at log(max) = 709.78.

8. The overflow / underflow thresholds, measured

Worked example

Let numpy report the exact edges. This block re-imports numpy and prints the four numbers that define the whole lesson - runnable on its own:

import numpy as np
print(np.finfo(np.float64).max)          # 1.7976931348623157e+308
print(np.log(np.finfo(np.float64).max))  # 709.782712893384
print(np.exp(709.0))                      # 8.22e+307
print(np.exp(710.0))                      # inf
print(np.finfo(np.float32).max)          # 3.4028235e+38
print(np.log(np.finfo(np.float32).max))  # 88.72284

exp(709) is finite, exp(710) is inf

Why: The cliff sits at log(max) = 709.78. Below it exp returns a real (astronomically large) number; one unit above it there is no representable value, so IEEE-754 rounds to +inf. float32's cliff is at only 88.7 - remember that for mixed precision.

expressionprinted value
np.finfo(float64).max1.7976931348623157e+308
np.log(max float64)709.782712893384
np.exp(709.0)8.218407461554972e+307
np.exp(710.0)inf
np.log(max float32)88.72284

9. Fill in: printed value for The overflow / underflow thresholds, measured

Comparison

Comparison matrix

From The overflow / underflow thresholds, measured: refill the printed value column from what you know. The rest of the table is as it appeared.

expressionprinted value
np.finfo(float64).max1.7976931348623157e+308
np.log(max float64)709.782712893384
np.exp(709.0)8.218407461554972e+307
np.exp(710.0)inf
np.log(max float32)88.72284

10. Underflow: the other edge

Concept

Symmetrically, a positive number too small to represent rounds to 0.0. exp of a very negative argument underflows: below about -745 the result is exactly 0, not a tiny positive.

underflow — A nonzero result whose magnitude is below the smallest representable positive float, rounded to 0.0. Dangerous when you later divide by it or take its log - 0/0 = nan and log(0) = -inf.

So a naive softmax of very negative logits fails too: every exp underflows to 0, the denominator becomes 0, and 0 / 0 = nan. Overflow and underflow are two faces of the same fix.

11. Take the definitions apart: overflow vs underflow

Definition probe

Sort into buckets

Every line below is part of the definition of overflow or of underflow — one or the other, never both. Put each where it belongs.

overflow
A result whose magnitude exceeds the largest representable float, so it is rounded to +inf (or -inf).; Once inf enters a computation it usually spreads; inf - inf and inf / inf both give nan.
underflow
A nonzero result whose magnitude is below the smallest representable positive float, rounded to 0.0.; Dangerous when you later divide by it or take its log - 0/0 = nan and log(0) = -inf.
b1
A result whose magnitude exceeds the largest representable float, so it is rounded to +inf (or -inf). Once inf enters a computation it usually spreads: inf - inf and inf / inf both give nan.
b2
A nonzero result whose magnitude is below the smallest representable positive float, rounded to 0.0. Dangerous when you later divide by it or take its log - 0/0 = nan and log(0) = -inf.

12. Relative error is what matters

Concept

Floats carry a fixed number of significant digits, not a fixed number of decimals. So the error that matters is relative - error divided by the true size - and it is bounded by machine epsilon per operation.

\[ \text{rel err} = \frac{|\hat v - v|}{|v|} \;\gtrsim\; \varepsilon_{\text{machine}} \approx 2.22\times 10^{-16} \]

One rounding is harmless. The danger is an operation that amplifies relative error - overflow (to inf), or cancellation, which inflates a tiny absolute error into a huge relative one by shrinking the denominator.

13. The hazard nobody sees: cancellation

Intuition

Overflow is loud - you see inf. Cancellation is silent. Subtract two nearly equal numbers and the leading digits agree and cancel, leaving only the noisy trailing digits.

Add 1 to 10¹⁶ and the 1 falls off the end of the 16 available digits. Subtract 10¹⁶ back and you get 0 - the 1 is gone forever. The relative error just exploded.

The cure is to never form the tiny difference explicitly. Libraries ship log1p(x) for log(1 + x) and expm1(x) for exp(x) - 1 precisely to dodge cancellation near zero.

14. Break it if you can: The hazard nobody sees: cancellation

Counterexample

Discussion prompt

Overflow is loud - you see inf. Cancellation is silent. Subtract two nearly equal numbers and the leading digits agree and cancel, leaving only the noisy trailing digits.

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:

Add 1 to 10¹⁶ and the 1 falls off the end of the 16 available digits. Subtract 10¹⁶ back and you get 0 - the 1 is gone forever. The relative error just exploded.

15. What has to be given first: Cancellation, caught in the act

Missing information

Discussion prompt

Watch a 1 vanish, then watch log1p rescue log(1 + x) for tiny x. 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:

1e16 already uses all ~16 significant digits, so the +1 lands beyond the last representable bit and is dropped. At 1e8 there is still room, so (1e8 + 1) - 1e8 = 1.0 as expected.

16. Cancellation, caught in the act

Worked example

Watch a 1 vanish, then watch log1p rescue log(1 + x) for tiny x. Standalone:

import numpy as np
b = 1e16
print((b + 1.0) - b)     # 0.0   the +1 vanished
a = 1e8
print((a + 1.0) - a)     # 1.0   fine at 1e8
print(np.log(1.0 + 1e-10))   # 1.000000082690371e-10  (noisy)
print(np.log1p(1e-10))       # 9.999999999500001e-11  (accurate)

(1e16 + 1) - 1e16 = 0.0

Why: 1e16 already uses all ~16 significant digits, so the +1 lands beyond the last representable bit and is dropped. At 1e8 there is still room, so (1e8 + 1) - 1e8 = 1.0 as expected.

log(1 + 1e-10) loses digits; log1p keeps them

Why: Forming 1 + 1e-10 rounds away most of the tiny addend before log runs, so the naive answer 1.0000000827e-10 is wrong in the 8th digit. log1p computes it without ever forming the cancelling sum: 9.9999999995e-11.

expressionprinted valuenote
(1e16 + 1) - 1e160.0the 1 is lost
(1e8 + 1) - 1e81.0still fits
np.log(1.0 + 1e-10)1.000000082690371e-10wrong at digit 8
np.log1p(1e-10)9.999999999500001e-11accurate

17. What each one costs: Cancellation, caught in the act

Trade off

Comparison matrix

From Cancellation, caught in the act: every row here is a choice with a cost. Fill the printed value column, then say which row you would actually pick and what you give up for it.

expressionprinted valuenote
(1e16 + 1) - 1e160.0the 1 is lost
(1e8 + 1) - 1e81.0still fits
np.log(1.0 + 1e-10)1.000000082690371e-10wrong at digit 8
np.log1p(1e-10)9.999999999500001e-11accurate

18. Softmax that survives

Section

Part 2 of 6 - shift invariance

19. The running example

Concept

One dataset for the whole lesson: a classifier emits three logits (pre-softmax scores) for three classes. They happen to be large.

\[ z = \begin{bmatrix} 1000 \\ 1001 \\ 1002 \end{bmatrix}, \qquad z^{*} = \max_i z_i = 1002 \]

The gaps between them are only 1 and 2, so the softmax should be perfectly ordinary. But the raw magnitudes are ~1000 - and exp(1000) is inf. That tension is the entire problem.

20. By analogy: The running example

Analogy

Discussion prompt

Explain The running example 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:

One dataset for the whole lesson: a classifier emits three logits (pre-softmax scores) for three classes. They happen to be large.

21. Softmax, and why exp is unavoidable

Concept

Softmax turns logits into a probability vector - all entries positive and summing to 1 - by exponentiating and normalizing:

\[ \operatorname{softmax}(z)_i = \frac{e^{z_i}}{\sum_{j} e^{z_j}} \]

The exp is what makes the outputs positive and the differences multiplicative. We cannot drop it - so we must make it safe.

22. Teach it back: Softmax, and why exp is unavoidable

Explain it

Discussion prompt

Explain Softmax, and why exp is unavoidable 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:

Softmax turns logits into a probability vector - all entries positive and summing to 1 - by exponentiating and normalizing:

23. Predict the next row: The naive formula overflows to nan

Pattern

Predict first

The table runs: np.exp(z) | [inf, inf, inf] · e.sum() | inf

In The naive formula overflows to nan, given the rows so far: what is the next one — the row where step is e / e.sum()?

Correct: e / e.sum() | [nan, nan, nan]

stepvalue
np.exp(z)[inf, inf, inf]
e.sum()inf
e / e.sum()[nan, nan, nan]

Why: The relationship between the columns, not the individual numbers, is what generates the next row. Each logit exceeds 709.78, so each exp overflows.

24. The naive formula overflows to nan

Worked example

Translate the formula literally. Every exp is inf, and inf / inf = nan. Standalone:

import numpy as np
z = np.array([1000., 1001., 1002.])
e = np.exp(z)
print(e)                 # [inf inf inf]
print(e / e.sum())       # [nan nan nan]

np.exp(z) = [inf, inf, inf]

Why: Each logit exceeds 709.78, so each exp overflows. numpy raises a RuntimeWarning ('overflow encountered in exp') and fills the array with inf - it does not crash, which is exactly why this bug slips into production silently.

inf / inf = nan across the board

Why: The sum is inf, so every entry becomes inf/inf, which IEEE-754 defines as nan. The probabilities are destroyed even though the true answer is a tame [0.090, 0.245, 0.665].

stepvalue
np.exp(z)[inf, inf, inf]
e.sum()inf
e / e.sum()[nan, nan, nan]

25. Where the cost goes: The naive formula overflows to nan

Cost model

Annotate

In The naive formula overflows to nan, before reading the notes: mark where the time actually goes. Which line dominates?

  • Each logit exceeds 709.78, so each exp overflows. numpy raises a RuntimeWarning ('overflow encountered in exp') and fills the array with inf - it does not crash, which is exactly why this bug slips into production silently.
  • The sum is inf, so every entry becomes inf/inf, which IEEE-754 defines as nan. The probabilities are destroyed even though the true answer is a tame [0.090, 0.245, 0.665].

26. The key property: shift invariance

Concept

Softmax does not change if you add the same constant c to every logit. This is the fact that saves us - and it is exact, not an approximation.

\[ \operatorname{softmax}(z - c)_i = \operatorname{softmax}(z)_i \quad\text{for any constant } c \]

27. What has to happen first: Prove shift invariance in one line

Ranking

Put in order

Put the moves of Prove shift invariance in one line into the order they have to happen.

  1. Start from softmax(z - c)_i
  2. Factor e^{-c} out of each exponential
  3. Cancel the common e^{-c}

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. Subtract c from every logit and write out the definition.

28. Prove shift invariance in one line

Worked example

Start from softmax(z - c)_i

Why: Subtract c from every logit and write out the definition.

\[ \operatorname{softmax}(z - c)_i = \frac{e^{z_i - c}}{\sum_j e^{z_j - c}} \]

Factor e^{-c} out of each exponential

Why: e^{z_i - c} = e^{z_i} e^{-c}, and likewise in every term of the sum - the same constant factor appears on top and in every summand below.

\[ = \frac{e^{-c}\,e^{z_i}}{e^{-c}\sum_j e^{z_j}} \]

Cancel the common e^{-c}

Why: The identical nonzero factor e^{-c} in numerator and denominator cancels exactly - no rounding, no approximation. We are left with the original softmax.

\[ = \frac{e^{z_i}}{\sum_j e^{z_j}} = \operatorname{softmax}(z)_i \qquad\blacksquare \]

29. Decode the notation: Prove shift invariance in one line

Notation

Annotate

From Prove shift invariance in one line — read this one piece at a time. What is each part doing?

On: \( \operatorname{softmax}(z - c)_i = \frac{e^{z_i - c}}{\sum_j e^{z_j - c}} \)

  • Subtract c from every logit and write out the definition.
  • e^{z_i - c} = e^{z_i} e^{-c}, and likewise in every term of the sum - the same constant factor appears on top and in every summand below.
  • The identical nonzero factor e^{-c} in numerator and denominator cancels exactly - no rounding, no approximation. We are left with the original softmax.

30. Choose c = max, and nothing overflows

Concept

Shift invariance lets us pick any c. Pick the smartest one: c = z* = max(z). Then every shifted logit zᵢ - z* is ≤ 0, so every exp is in (0, 1].

\[ \operatorname{softmax}(z)_i = \frac{e^{\,z_i - z^{*}}}{\sum_j e^{\,z_j - z^{*}}}, \qquad e^{\,z_i - z^{*}} \le e^{0} = 1 \]

The largest exponential is exactly 1 (for the max logit), so overflow is impossible. Underflow of the small terms is harmless - they were near-zero contributions anyway. The answer is unchanged; only the arithmetic path is safe.

31. Restore the missing line: Shift invariance, confirmed numerically

Fill the middle

Fill in the blanks

From Shift invariance, confirmed numerically — one line has had its right-hand side removed. Put it back.

import numpy as np
z = np.array([1000., 1001., 1002.])
def softmax(x):
e = np.exp(x - x.max()); return e / e.sum()
print(softmax(z).round(6)) # [0.090031 0.244728 0.665241]
print(softmax(np.array([1.,2.,3.])).round(6)) # [0.090031 0.244728 0.665241]
print(np.allclose(softmax(z), softmax(z - 500.))) # True

Why: z is what everything below it consumes, so the wrong expression here fails later and somewhere else. [1000,1001,1002] is [1,2,3] shifted up by 999.

32. Shift invariance, confirmed numerically

Worked example

Before trusting the proof, check it: softmax([1000,1001,1002]) should equal softmax([1,2,3]) (a shift by 999) and softmax(z - 500) (any shift at all). Standalone:

import numpy as np
z = np.array([1000., 1001., 1002.])
def softmax(x):
    e = np.exp(x - x.max()); return e / e.sum()
print(softmax(z).round(6))                 # [0.090031 0.244728 0.665241]
print(softmax(np.array([1.,2.,3.])).round(6))  # [0.090031 0.244728 0.665241]
print(np.allclose(softmax(z), softmax(z - 500.)))  # True

softmax(z) == softmax([1,2,3]) to 6 dp

Why: [1000,1001,1002] is [1,2,3] shifted up by 999. Shift invariance says the softmax is identical - and it is, [0.090031, 0.244728, 0.665241] both ways. Only the gaps (1 and 2) matter, never the absolute scale.

np.allclose(softmax(z), softmax(z - 500)) is True

Why: Any shift works, not just by the max - subtracting 500 gives the same distribution. We choose the max only because it is the shift that guarantees no overflow.

inputsoftmax (verified)
[1000, 1001, 1002][0.090031, 0.244728, 0.665241]
[1, 2, 3][0.090031, 0.244728, 0.665241]
z - 500 (any shift)allclose: True

33. Inspect it line by line: Shift invariance, confirmed numerically

Error analysis

Annotate

Walk the callouts on Shift invariance, confirmed numerically. Each one is a place this is easy to get subtly wrong.

  • [1000,1001,1002] is [1,2,3] shifted up by 999. Shift invariance says the softmax is identical - and it is, [0.090031, 0.244728, 0.665241] both ways. Only the gaps (1 and 2) matter, never the absolute scale.
  • Any shift works, not just by the max - subtracting 500 gives the same distribution. We choose the max only because it is the shift that guarantees no overflow.

34. Stable softmax on z = [1000, 1001, 1002]

Worked example

Subtract the max before exponentiating. Same numbers as the naive version would have wanted - but real, not nan. Standalone:

import numpy as np
z = np.array([1000., 1001., 1002.])
def softmax(x):
    e = np.exp(x - x.max())
    return e / e.sum()
print(np.exp(z - z.max()))      # [0.13533528 0.36787944 1.        ]
print(softmax(z).round(6))      # [0.090031 0.244728 0.665241]
print(softmax(z).sum())         # 0.9999999999999999

z - z.max() = [-2, -1, 0]

Why: Subtracting 1002 maps the logits to [-2, -1, 0]. The exponentials become [e^-2, e^-1, e^0] = [0.135, 0.368, 1.0] - all at most 1, nothing overflows.

softmax = [0.090031, 0.244728, 0.665241], sums to 1

Why: A valid probability vector, and identical to softmax([1,2,3]) by shift invariance. The tiny 0.9999999999999999 instead of exactly 1.0 is ordinary rounding, off by one part in 10^16.

quantityvalue (verified)
z - z.max()[-2, -1, 0]
exp(z - z.max())[0.13533528, 0.36787944, 1.0]
softmax(z)[0.090031, 0.244728, 0.665241]
softmax(z).sum()0.9999999999999999

35. Work backwards from the answer: Stable softmax on z = [1000, 1001, 1002]

Reverse engineer

Discussion prompt

Work backwards. The example finished here:

softmax = [0.090031, 0.244728, 0.665241], sums to 1

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:

Subtract the max before exponentiating. Same numbers as the naive version would have wanted - but real, not nan. Standalone:

36. Full hand trace of the stable softmax

Worked example

By hand, one entry at a time, so no number is a mystery. Shift by 1002, exponentiate, divide by the sum S = e^-2 + e^-1 + e^0.

\[ S = e^{-2} + e^{-1} + e^{0} = 0.135335 + 0.367879 + 1 = 1.503215 \]

izᵢzᵢ - z*e^(zᵢ - z*)pᵢ = e/S
01000-20.1353350.090031
11001-10.3678790.244728
2100201.0000000.665241

Check: 0.090031 + 0.244728 + 0.665241 = 1.000000

Why: The three probabilities sum to 1, confirming a valid distribution. Each pᵢ = e^(zᵢ - z*) / S; the largest logit (class 2) gets the largest probability, as it must.

37. Watch it run: Full hand trace of the stable softmax

Pattern

Step through it

Step through Full hand trace of the stable softmax one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: i is 0
  2. Step 2: i is 1
  3. Step 3: i is 2

38. Something is wrong here: exponentiate the raw logits

Anomaly

Predict first

A student writes this, and it looks reasonable:

Softmax is exp(z) / Σ exp(z) - so translate the formula directly, no tricks.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: The formula is mathematically correct but numerically dead: each exp overflows past 709.78, the sum is inf, and inf/inf = nan.

Subtract the max first - shift invariance makes it mathematically identical and numerically safe.

Why: The formula is mathematically correct but numerically dead: each exp overflows past 709.78, the sum is inf, and inf/inf = nan. The model's predictions become nan and every downstream loss and gradient turns to nan too.

39. Trap: exponentiate the raw logits

Trap

The trap

Softmax is exp(z) / Σ exp(z) - so translate the formula directly, no tricks.

import numpy as np
z = np.array([1000., 1001., 1002.])
p = np.exp(z) / np.exp(z).sum()
print(p)   # [nan nan nan]

exp(1000) = inf, so p = inf/inf = nan

Why: The formula is mathematically correct but numerically dead: each exp overflows past 709.78, the sum is inf, and inf/inf = nan. The model's predictions become nan and every downstream loss and gradient turns to nan too.

stagevalue
np.exp(z)[inf, inf, inf]
p[nan, nan, nan]

The fix

Subtract the max first - shift invariance makes it mathematically identical and numerically safe.

import numpy as np
z = np.array([1000., 1001., 1002.])
e = np.exp(z - z.max())
p = e / e.sum()
print(p.round(6))   # [0.090031 0.244728 0.665241]

Largest exp is exp(0) = 1, no overflow

Why: softmax(z) = softmax(z - max) exactly (proved on the previous slide), so the answer is unchanged, but every exponent is now <= 0. The single word max between exp and the raw logits is the whole fix.

stagevalue
exp(z - max)[0.135335, 0.367879, 1.0]
p[0.090031, 0.244728, 0.665241]

40. Break it on purpose: exponentiate the raw logits

Break the constraint

Discussion prompt

The rule this trap just fixed:

Subtract the max first - shift invariance makes it mathematically identical and numerically safe.

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:

The formula is mathematically correct but numerically dead: each exp overflows past 709.78, the sum is inf, and inf/inf = nan. The model's predictions become nan and every downstream loss and gradient turns to nan too.

41. The log-sum-exp trick

Section

Part 3 of 6 - loss without overflow

42. Why we need log Σ exp

Concept

Training does not use softmax directly - it uses its log, because cross-entropy is -log p_true. And the log of the normalizer is the log-sum-exp:

\[ \log p_i = z_i - \log\sum_j e^{z_j}, \qquad \operatorname{LSE}(z) = \log\sum_j e^{z_j} \]

Computing LSE naively re-introduces the same overflow: Σ exp(z) is inf, so log(inf) is inf. We need the same max-subtraction move, but derived cleanly through the log.

43. What has to happen first: Derive the log-sum-exp identity

Ranking

Put in order

Put the moves of Derive the log-sum-exp identity into the order they have to happen.

  1. Factor e^{z*} out of the sum
  2. Take the log; log of a product is a sum of logs
  3. Every shifted exponent is <= 0, so the inner sum is safe

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. Let z* = max z. Every term e^{z_j} = e^{z} e^{z_j - z}, so the common factor e^{z*} pulls out of the whole sum.

44. Derive the log-sum-exp identity

Worked example

Factor e^{z*} out of the sum

Why: Let z* = max z. Every term e^{z_j} = e^{z} e^{z_j - z}, so the common factor e^{z*} pulls out of the whole sum.

\[ \sum_j e^{z_j} = e^{z^{*}} \sum_j e^{\,z_j - z^{*}} \]

Take the log; log of a product is a sum of logs

Why: log(e^{z} · S') = log(e^{z}) + log(S') = z* + log(S'), using log(ab) = log a + log b and log(e^{z}) = z.

\[ \log\sum_j e^{z_j} = z^{*} + \log\sum_j e^{\,z_j - z^{*}} \]

Every shifted exponent is <= 0, so the inner sum is safe

Why: e^{z_j - z} <= 1 for all j, so the inner sum lies in (0, n] - no overflow. The big number z is added back OUTSIDE the log, as a plain scalar. This is the log-sum-exp trick, and it is exact.

\[ \boxed{\;\operatorname{LSE}(z) = z^{*} + \log\sum_j e^{\,z_j - z^{*}}\;} \]

45. Say it in words: Derive the log-sum-exp identity

Translation

\( \log\sum_j e^{z_j} = z^{*} + \log\sum_j e^{\,z_j - z^{*}} \)

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.

46. LSE by hand on z = [1000, 1001, 1002]

Worked example

Pull out z* = 1002; the inner sum is the same S = 1.503215 from the softmax trace. Then add 1002 back.

\[ \operatorname{LSE}(z) = 1002 + \log(e^{-2} + e^{-1} + e^{0}) = 1002 + \log(1.503215) \]

\[ = 1002 + 0.407606 = 1002.407606 \]

piecevalue (verified)
z* = max z1002
inner sum e^-2 + e^-1 + e^01.503215
log(inner sum)0.407606
LSE(z) = z* + log(inner)1002.407606

47. Where does each piece belong: Lesson 27: Numerical Stability

Sorting

Sort into buckets

These are the pieces of Lesson 27: Numerical Stability, out of order. Put each one back under the part of the lesson it belongs to.

How floats break
A float has finite range; Where exp overflows; The overflow / underflow thresholds, measured
Softmax that survives
The running example; Softmax, and why exp is unavoidable; The naive formula overflows to nan
The log-sum-exp trick
Why we need log Σ exp; Derive the log-sum-exp identity; LSE by hand on z = [1000, 1001, 1002]
s1
How floats break is where Lesson 27: Numerical Stability puts A float has finite range, Where exp overflows, The overflow / underflow thresholds, measured. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
Softmax that survives is where Lesson 27: Numerical Stability puts The running example, Softmax, and why exp is unavoidable, The naive formula overflows to nan. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
The log-sum-exp trick is where Lesson 27: Numerical Stability puts Why we need log Σ exp, Derive the log-sum-exp identity, LSE by hand on z = [1000, 1001, 1002]. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

48. Finish it with less help: Naive LSE vs stable LSE vs scipy

Faded example

Fill in the blanks

Naive LSE vs stable LSE vs scipy, with the scaffolding fading: two lines are gone now — fill both.

import numpy as np
from scipy.special import logsumexp
z = np.array([1000., 1001., 1002.])
m = z.max()
naive = np.log(np.sum(np.exp(z))) # inf
stable = m + np.log(np.sum(np.exp(z - m)))
print(naive) # inf
print(round(stable, 6)) # 1002.407606
print(round(float(logsumexp(z)), 6)) # 1002.407606

Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. The naive log(sum(exp(z))) overflows inside the exp and returns inf.

49. Naive LSE vs stable LSE vs scipy

Worked example

Three ways to compute the same number; only two of them work. scipy.special.logsumexp does the max trick internally, so it agrees with the hand-derived stable version. Standalone:

import numpy as np
from scipy.special import logsumexp
z = np.array([1000., 1001., 1002.])
m = z.max()
naive  = np.log(np.sum(np.exp(z)))               # inf
stable = m + np.log(np.sum(np.exp(z - m)))
print(naive)                                     # inf
print(round(stable, 6))                          # 1002.407606
print(round(float(logsumexp(z)), 6))             # 1002.407606

naive = inf; stable = scipy = 1002.407606

Why: The naive log(sum(exp(z))) overflows inside the exp and returns inf. The stable formula keeps exponents in [e^-2, 1], sums to 1.503215, and lands on 1002.407606 - bit-for-bit what scipy's logsumexp returns.

methodlog Σ exp z
naive log(sum(exp(z)))inf
stable (subtract max)1002.407606
scipy.special.logsumexp1002.407606

50. Log-softmax and cross-entropy fall out

Concept

Once LSE is stable, everything downstream is stable. The log-probabilities are just the logits minus LSE:

\[ \log p_i = z_i - \operatorname{LSE}(z), \qquad \text{CE} = -\log p_{\text{true}} = \operatorname{LSE}(z) - z_{\text{true}} \]

Cross-entropy never forms a probability at all - it is LSE minus the true logit, a subtraction of two safe numbers. This is exactly what torch.nn.functional.cross_entropy computes from raw logits, and why you should feed it logits, never softmax outputs.

51. Guess the shape of the answer: Stable log-softmax and cross-entropy

Estimation

Predict first

Compute log p and the loss for true class 2, all through LSE - never a raw softmax. Standalone:

Commit before you compute: what does Stable log-softmax and cross-entropy come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.

Correct: CE = -log p_2 = 0.407606

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. The true class is 2, whose log-probability is -0.407606, so the loss is 0.407606.

52. Stable log-softmax and cross-entropy

Worked example

Compute log p and the loss for true class 2, all through LSE - never a raw softmax. Standalone:

import numpy as np
z = np.array([1000., 1001., 1002.])
m = z.max()
lse = m + np.log(np.sum(np.exp(z - m)))   # 1002.4076...
logp = z - lse                            # log-softmax, no overflow
print(logp.round(6))                      # [-2.407606 -1.407606 -0.407606]
true = 2                                  # correct class index
print(round(-logp[true], 6))              # 0.407606  = cross-entropy

log p = z - LSE = [-2.407606, -1.407606, -0.407606]

Why: Subtracting the scalar LSE from each logit gives the log-probabilities directly. exp of these recovers [0.090, 0.245, 0.665] - the softmax - so the identity log p = z - LSE holds.

CE = -log p_2 = 0.407606

Why: The true class is 2, whose log-probability is -0.407606, so the loss is 0.407606. Computed as LSE - z_true = 1002.407606 - 1002, a subtraction of two finite numbers - never an inf, never a nan.

quantityvalue (verified)
log p[-2.407606, -1.407606, -0.407606]
exp(log p)[0.090031, 0.244728, 0.665241]
CE (true = class 2)0.407606

53. Draw the shape of it: Stable log-softmax and cross-entropy

Blank canvas

Draw it

Draw what Stable log-softmax and cross-entropy just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.

54. Restore the missing line: Underflow: the same fix rescues tiny logits

Fill the middle

Fill in the blanks

From Underflow: the same fix rescues tiny logits — one line has had its right-hand side removed. Put it back.

import numpy as np
z = np.array([-1000., -1001., -1002.])
e = np.exp(z)
print(e) # [0. 0. 0.]
print(e / e.sum()) # [nan nan nan] 0/0
def softmax(x):
e = np.exp(x - x.max()); return e / e.sum()
print(softmax(z).round(6)) # [0.665241 0.244728 0.090031]

Why: e is what everything below it consumes, so the wrong expression here fails later and somewhere else. Every logit is below -745, so each exp rounds to exactly 0.0; the denominator is 0, and 0/0 is nan.

55. Underflow: the same fix rescues tiny logits

Worked example

Flip the sign: z = [-1000, -1001, -1002]. Naively every exp underflows to 0, the sum is 0, and 0 / 0 = nan. The max-subtraction fixes it too. Standalone:

import numpy as np
z = np.array([-1000., -1001., -1002.])
e = np.exp(z)
print(e)                 # [0. 0. 0.]
print(e / e.sum())       # [nan nan nan]  0/0
def softmax(x):
    e = np.exp(x - x.max()); return e / e.sum()
print(softmax(z).round(6))   # [0.665241 0.244728 0.090031]

Naive: exp underflows to 0, then 0/0 = nan

Why: Every logit is below -745, so each exp rounds to exactly 0.0; the denominator is 0, and 0/0 is nan. Underflow is overflow's mirror image - and the same c = max trick cures both.

Stable: subtract max = -1000, shifts to [0, -1, -2]

Why: After subtracting the max (-1000), the exponents become [0, -1, -2], the largest exp is 1, and the result is [0.665241, 0.244728, 0.090031] - the correct distribution, most mass on the largest logit -1000 (class 0).

approachresult
naive exp(z)[0.0, 0.0, 0.0]
naive softmax[nan, nan, nan]
stable softmax[0.665241, 0.244728, 0.090031]

56. Conditioning of a solve

Section

Part 4 of 6 - the condition number

57. The condition number

Concept

Overflow is about a single number. Conditioning is about a whole problem: how much a small change in the input can be amplified in the output. For solving Ax = b it is measured by the condition number.

\[ \kappa(A) = \frac{\sigma_{\max}}{\sigma_{\min}} \;=\; \frac{\lambda_{\max}}{\lambda_{\min}} \;\;(\text{symmetric PD}) \]

ill-conditioned — A problem with large kappa: tiny perturbations in A or b (including the unavoidable rounding of storing them) produce large changes in the solution x. The math is exact; the finite-precision answer is not.

58. Digits lost = log10 of kappa

Intuition

Rule of thumb: solving Ax = b loses about log₁₀ κ decimal digits of accuracy. Double precision starts with ~16 digits, so κ ≈ 10¹⁰ leaves only about 16 - 10 = 6 trustworthy digits.

This is the deep reason Lesson 7 warned against inverting XᵀX: forming XᵀX squares the condition number of X, doubling the digits you throw away before the solve even begins.

59. Finish it with less help: Forming XᵀX squares the condition number

Faded example

Fill in the blanks

Forming XᵀX squares the condition number, with the scaffolding fading: two lines are gone now — fill both.

import numpy as np
X = np.array([[1., 1.0000],
[1., 1.0001],
[1., 1.0002]])
kX = np.linalg.cond(X)
kG = np.linalg.cond(X.T @ X)
print(f'___') # 2.4497e+04 cond(X)
print(f'___') # 6.0012e+08 cond(XtX)
print(f'___') # 6.0012e+08 = cond(X)^2

Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. Singular values of XtX are the squares of X's singular values, so kappa(XtX) = kappa(X)^2.

60. Forming XᵀX squares the condition number

Worked example

The Lesson-7 warning, made concrete. Take three nearly collinear feature rows. The design matrix X is only mildly ill-conditioned, but the Gram matrix XᵀX used in the normal equations has κ squared. Standalone:

import numpy as np
X = np.array([[1., 1.0000],
              [1., 1.0001],
              [1., 1.0002]])
kX = np.linalg.cond(X)
kG = np.linalg.cond(X.T @ X)
print(f'{kX:.4e}')       # 2.4497e+04   cond(X)
print(f'{kG:.4e}')       # 6.0012e+08   cond(XtX)
print(f'{kX**2:.4e}')    # 6.0012e+08   = cond(X)^2

cond(XtX) = cond(X)^2

Why: Singular values of XtX are the squares of X's singular values, so kappa(XtX) = kappa(X)^2. Here 2.45e4 squared is 6.0e8 - exactly cond(XtX). Every solve through the normal equations therefore loses TWICE the digits X alone would.

So lstsq(X, y) beats solve(XtX, Xty)

Why: np.linalg.lstsq factors X directly via QR/SVD and never forms XtX, so it keeps kappa(X) instead of kappa(X)^2. That is why Lesson 7 preferred lstsq - and why conditioning, not just overflow, drives the choice of algorithm.

quantityvalue (verified)
cond(X)2.4497e+04
cond(XᵀX)6.0012e+08
cond(X)²6.0012e+08

61. Fill in: value (verified) for Forming XᵀX squares the condition number

Comparison

Comparison matrix

From Forming XᵀX squares the condition number: refill the value (verified) column from what you know. The rest of the table is as it appeared.

quantityvalue (verified)
cond(X)2.4497e+04
cond(XᵀX)6.0012e+08
cond(X)²6.0012e+08

62. A tiny ill-conditioned 2x2

Worked example

Two almost-parallel rows. The matrix is nearly singular, so its smallest singular value is tiny and κ is huge. Standalone:

import numpy as np
A = np.array([[1.0, 1.0],
              [1.0, 1.0001]])
sv = np.linalg.svd(A, compute_uv=False)
print(sv)                          # [2.000050e+00 4.999875e-05]
print(sv[0] / sv[-1])              # 40002.000074915224
print(f'{np.linalg.cond(A):.4e}') # 4.0002e+04

sigma_min ~ 5e-5, sigma_max ~ 2.0, so kappa ~ 4e4

Why: The second row differs from the first by only 0.0001, so the rows are nearly linearly dependent - the smallest singular value collapses toward 0. kappa = sigma_max / sigma_min = 40002, meaning ~log10(4e4) ~ 4.6 digits of accuracy are at risk.

quantityvalue (verified)
singular values[2.000050, 4.999875e-05]
sigma_max / sigma_min40002.000074915224
np.linalg.cond(A)4.0002e+04

63. Restore the missing line: The Hilbert matrix: conditioning explodes

Fill the middle

Fill in the blanks

From The Hilbert matrix: conditioning explodes — one line has had its right-hand side removed. Put it back.

import numpy as np
from scipy.linalg import hilbert
H = hilbert(8)
print(f'np.linalg.solve(H, b)') # 1.5258e+10
b = H @ np.ones(8) # exact rhs, true x = ones
xs = ___
xi = np.linalg.inv(H) @ b
print(f'___') # 1.3089e-07 solve
print(f'___') # 9.5367e-07 inv (worse)

Why: xs is what everything below it consumes, so the wrong expression here fails later and somewhere else. log10(1.53e10) = 10.18. Of double precision's ~16 digits, about 10 are eaten by conditioning, leaving ~6.

64. The Hilbert matrix: conditioning explodes

Worked example

The Hilbert matrix Hᵢⱼ = 1/(i+j+1) is the textbook ill-conditioned family - κ grows roughly ten-fold per added row. Solve Hx = b where the true x is all ones, and measure the error. Standalone:

import numpy as np
from scipy.linalg import hilbert
H = hilbert(8)
print(f'{np.linalg.cond(H):.4e}')       # 1.5258e+10
b = H @ np.ones(8)                       # exact rhs, true x = ones
xs = np.linalg.solve(H, b)
xi = np.linalg.inv(H) @ b
print(f'{np.abs(xs - 1).max():.4e}')    # 1.3089e-07  solve
print(f'{np.abs(xi - 1).max():.4e}')    # 9.5367e-07  inv (worse)

cond(H8) = 1.53e10, so ~10 digits lost

Why: log10(1.53e10) = 10.18. Of double precision's ~16 digits, about 10 are eaten by conditioning, leaving ~6. Even though b is exact and the true x is ones, the recovered x is off in the 7th digit.

solve err 1.3e-7 vs inv err 9.5e-7 - inv is worse

Why: Both degrade, but forming the explicit inverse and multiplying does more arithmetic on the ill-conditioned matrix, so its error 9.5e-7 is ~7x larger than solve's 1.3e-7. Same math, worse numerics - the case against inv.

quantityvalue (verified)
cond(hilbert(8))1.5258e+10
log10(cond) (digits lost)~10.18
max error, np.linalg.solve1.3089e-07
max error, inv @ b9.5367e-07

65. Draw the shape of it: The Hilbert matrix: conditioning explodes

Blank canvas

Draw it

Draw what The Hilbert matrix: conditioning explodes just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.

66. How bad it gets with size

Concept

Watch κ and the solve error climb together as the Hilbert matrix grows. By n = 10 the answer is wrong in the 5th digit; by ~n = 12 it is essentially meaningless in double precision.

ncond(H)max solve error
41.5514e+045.37e-14
61.4951e+076.65e-11
81.5258e+101.31e-07
101.6025e+132.43e-05

Every three rows adds ~three orders of magnitude to κ and loses ~three more digits - the log₁₀ κ rule made visible.

67. Watch it run: How bad it gets with size

Pattern

Step through it

Step through How bad it gets with size one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: n is 4
  2. Step 2: n is 6
  3. Step 3: n is 8
  4. Step 4: n is 10

68. Something is wrong here: inverting an ill-conditioned matrix

Anomaly

Predict first

A student writes this, and it looks reasonable:

To solve Ax = b, the math says x = A⁻¹ b - so compute the inverse and multiply.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: Explicitly forming A^{-1} runs a full extra pass of arithmetic on the already ill-conditioned matrix, amplifying round-off.

Never form the inverse. Factor and solve directly - and if κ is huge, regularize.

Why: Explicitly forming A^{-1} runs a full extra pass of arithmetic on the already ill-conditioned matrix, amplifying round-off. On Hilbert(8) the recovered x is off by 9.5e-7 - about 7x the error of a direct solve.

69. Trap: inverting an ill-conditioned matrix

Trap

The trap

To solve Ax = b, the math says x = A⁻¹ b - so compute the inverse and multiply.

import numpy as np
from scipy.linalg import hilbert
H = hilbert(8); b = H @ np.ones(8)
x = np.linalg.inv(H) @ b
print(f'{np.abs(x - 1).max():.4e}')  # 9.5367e-07

inv error = 9.5e-7

Why: Explicitly forming A^{-1} runs a full extra pass of arithmetic on the already ill-conditioned matrix, amplifying round-off. On Hilbert(8) the recovered x is off by 9.5e-7 - about 7x the error of a direct solve.

approachmax error
np.linalg.inv(H) @ b9.5367e-07

The fix

Never form the inverse. Factor and solve directly - and if κ is huge, regularize.

import numpy as np
from scipy.linalg import hilbert
H = hilbert(8); b = H @ np.ones(8)
x = np.linalg.solve(H, b)     # LU factorization, no inverse
print(f'{np.abs(x - 1).max():.4e}')  # 1.3089e-07

solve error = 1.3e-7 (7x better)

Why: np.linalg.solve does one stable LU factorization and back-substitution - fewer operations, less amplification. Same problem, error 1.3e-7 instead of 9.5e-7. Prefer solve; prefer lstsq (QR/SVD) when A is tall or rank-deficient.

approachmax error
np.linalg.solve(H, b)1.3089e-07

70. Which of these survive contact with Lesson 27: Numerical Stability?

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
Add 1 to 10¹⁶ and the 1 falls off the end of the 16 available digits. Subtract 10¹⁶ back and you get 0 - the 1 is gone forever. The relative error just exploded.; One dataset for the whole lesson: a classifier emits three logits (pre-softmax scores) for three classes. They happen to be large.; Softmax turns logits into a probability vector - all entries positive and summing to 1 - by exponentiating and normalizing:
Breaks
Softmax is exp(z) / Σ exp(z) - so translate the formula directly, no tricks.; To solve Ax = b, the math says x = A⁻¹ b - so compute the inverse and multiply.
sound
These are stated as this lesson states them — each one survives the edge cases Lesson 27: Numerical Stability 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.

71. Ridge shrinks kappa

Section

Part 5 of 6 - Tikhonov regularization

72. A floor under the smallest direction

Intuition

An ill-conditioned matrix has one direction it barely responds to - the near-zero smallest eigenvalue. That direction is where round-off gets amplified, because the solve divides by it.

Ridge puts a floor under that direction: adding λ to every eigenvalue can barely nudge the large ones but rescues the tiny one, so nothing is ever divided by an almost-zero.

The price is bias: the floor also slightly damps the real signal, pulling the solution toward 0. So keep λ just large enough to tame κ and no larger - a stability-vs-accuracy trade you tune, exactly as in ridge regression.

73. Teach it back: A floor under the smallest direction

Explain it

Discussion prompt

Explain A floor under the smallest direction 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:

An ill-conditioned matrix has one direction it barely responds to - the near-zero smallest eigenvalue. That direction is where round-off gets amplified, because the solve divides by it.

74. Add lambda I to the matrix

Concept

When κ is dangerous, ridge (Tikhonov) regularization adds a small λI to the matrix before solving - the same λI from Lesson 7's ridge regression, here as a pure conditioning fix.

\[ (A + \lambda I)\,x = b, \qquad \lambda > 0 \]

The claim: A + λI is far better conditioned than A. To see why, look at what λI does to the eigenvalues.

75. By analogy: Add lambda I to the matrix

Analogy

Discussion prompt

Explain Add lambda I to the matrix 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:

When κ is dangerous, ridge (Tikhonov) regularization adds a small λI to the matrix before solving - the same λI from Lesson 7's ridge regression, here as a pure conditioning fix.

76. What has to happen first: lambda I shifts every eigenvalue up by lambda

Ranking

Put in order

Put the moves of lambda I shifts every eigenvalue up by lambda into the order they have to happen.

  1. Start from an eigenpair of A
  2. Apply (A + lambda I) to the same v
  3. Same eigenvector, eigenvalue mu + lambda

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. Let v be an eigenvector of symmetric A with eigenvalue mu: Av = mu v.

77. lambda I shifts every eigenvalue up by lambda

Worked example

Start from an eigenpair of A

Why: Let v be an eigenvector of symmetric A with eigenvalue mu: Av = mu v.

\[ A v = \mu\, v \]

Apply (A + lambda I) to the same v

Why: (A + lambda I)v = Av + lambda v = mu v + lambda v, using Iv = v.

\[ (A + \lambda I)\,v = \mu v + \lambda v = (\mu + \lambda)\,v \]

Same eigenvector, eigenvalue mu + lambda

Why: So A and A + lambda I share every eigenvector, and each eigenvalue rises by exactly lambda. The smallest eigenvalue mu_min - the one that made kappa huge - becomes mu_min + lambda, lifted off the floor.

\[ \kappa(A + \lambda I) = \frac{\mu_{\max} + \lambda}{\mu_{\min} + \lambda} \;\ll\; \frac{\mu_{\max}}{\mu_{\min}} = \kappa(A) \]

78. Decode the notation: lambda I shifts every eigenvalue up by lambda

Notation

Annotate

From lambda I shifts every eigenvalue up by lambda — read this one piece at a time. What is each part doing?

On: \( \kappa(A + \lambda I) = \frac{\mu_{\max} + \lambda}{\mu_{\min} + \lambda} \;\ll\; \frac{\mu_{\max}}{\mu_{\min}} = \kappa(A) \)

  • Let v be an eigenvector of symmetric A with eigenvalue mu: Av = mu v.
  • (A + lambda I)v = Av + lambda v = mu v + lambda v, using Iv = v.
  • So A and A + lambda I share every eigenvector, and each eigenvalue rises by exactly lambda. The smallest eigenvalue mu_min - the one that made kappa huge - becomes mu_min + lambda, lifted off the floor.

79. Predict the next row: Ridge on the Hilbert matrix

Pattern

Predict first

The table runs: cond | 1.5258e+10 | 1.6969e+03 · min eigenvalue | 1.1115e-10 | 1.0000e-03

In Ridge on the Hilbert matrix, given the rows so far: what is the next one — the row where quantity is max eigenvalue?

Correct: max eigenvalue | 1.6959 | 1.6969

quantityplain HH + 1e-3 I
cond1.5258e+101.6969e+03
min eigenvalue1.1115e-101.0000e-03
max eigenvalue1.69591.6969

Why: The relationship between the columns, not the individual numbers, is what generates the next row. Adding lambda I lifts the smallest eigenvalue from 1.11e-10 to almost exactly 1e-3 (= lambda, since lambda swamps the tiny original), while the largest (~1.70) barely moves.

80. Ridge on the Hilbert matrix

Worked example

Add λ = 10⁻³ to Hilbert(8) and watch κ collapse from 10¹⁰ to 10³, driven entirely by the smallest eigenvalue jumping from ~10⁻¹⁰ to 10⁻³. Standalone:

import numpy as np
from scipy.linalg import hilbert
H = hilbert(8)
Hr = H + 1e-3 * np.eye(8)
print(f'{np.linalg.cond(H):.4e}')    # 1.5258e+10
print(f'{np.linalg.cond(Hr):.4e}')   # 1.6969e+03
evH = np.sort(np.linalg.eigvalsh(H))
evR = np.sort(np.linalg.eigvalsh(Hr))
print(f'{evH[0]:.4e}')               # 1.1115e-10  min eig H
print(f'{evR[0]:.4e}')               # 1.0000e-03  min eig H+lamI

kappa drops from 1.53e10 to 1.70e3

Why: Adding lambda I lifts the smallest eigenvalue from 1.11e-10 to almost exactly 1e-3 (= lambda, since lambda swamps the tiny original), while the largest (~1.70) barely moves. The ratio - kappa - collapses by seven orders of magnitude.

min eigenvalue: 1.11e-10 -> 1.00e-03

Why: This is the shift-by-lambda law in numbers: mu_min + lambda ~ lambda when mu_min << lambda. The near-zero eigenvalue that caused the ill-conditioning is gone. Trade-off: the solution is now biased toward 0, so keep lambda as small as the conditioning allows.

quantityplain HH + 1e-3 I
cond1.5258e+101.6969e+03
min eigenvalue1.1115e-101.0000e-03
max eigenvalue1.69591.6969

81. Watch it run: Ridge on the Hilbert matrix

Pattern

Step through it

Step through Ridge on the Hilbert matrix 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 cond
  2. Step 2: quantity is min eigenvalue
  3. Step 3: quantity is max eigenvalue

82. Precision & scale

Section

Part 6 of 6 - float32 and mixed precision

83. float32 overflows far sooner

Concept

Modern training runs in float32 (or float16) for speed and memory. But a 32-bit float tops out at ~3.4 × 10³⁸, so exp overflows at only x ≈ 88.7 - not 709.78. The stability tricks matter far more, not less, at low precision.

\[ \log(\text{max float32}) \approx 88.72 \;\ll\; 709.78 \approx \log(\text{max float64}) \]

84. What has to be given first: float32 dies where float64 lives

Missing information

Discussion prompt

Logits of just ~100 - trivial for float64 - already overflow in float32. The max-subtraction rescues both. 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:

100 > 88.7 overflows float32, but 100 < 709.78 is fine in float64 (2.688e43). The identical value overflows or not depending purely on precision - which is why FP16/FP32 training must subtract the max.

85. float32 dies where float64 lives

Worked example

Logits of just ~100 - trivial for float64 - already overflow in float32. The max-subtraction rescues both. Standalone:

import numpy as np
z = np.array([100., 101., 102.], dtype=np.float32)
print(np.exp(z))                 # [inf inf inf]  float32 dies at ~88.7
print(np.exp(np.float64(100.)))  # 2.688e+43      float64 fine
def softmax(x):
    e = np.exp(x - x.max()); return e / e.sum()
print(softmax(z).round(6))       # [0.090031 0.244728 0.665241]

float32 exp(100) = inf, float64 exp(100) = 2.7e43

Why: 100 > 88.7 overflows float32, but 100 < 709.78 is fine in float64 (2.688e43). The identical value overflows or not depending purely on precision - which is why FP16/FP32 training must subtract the max.

stable softmax works in float32 too

Why: After subtracting the max the exponents are [-2, -1, 0], well inside float32's range, so softmax returns [0.090031, 0.244728, 0.665241] - the same distribution as float64, to float32 precision.

expressiondtyperesult
np.exp(100)float32inf
np.exp(100)float642.688e+43
stable softmaxfloat32[0.090031, 0.244728, 0.665241]

86. Work backwards from the answer: float32 dies where float64 lives

Reverse engineer

Discussion prompt

Work backwards. The example finished here:

stable softmax works in float32 too

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:

Logits of just ~100 - trivial for float64 - already overflow in float32. The max-subtraction rescues both. Standalone:

87. Mixed precision & why it is safe

Concept

Mixed-precision training runs the forward and backward passes in fast FP16/BF16 but keeps a master copy of the weights, the gradients, and the optimizer state in FP32. Speed of low precision; stability of high precision.

Loss scaling multiplies the loss by a large constant before the backward pass so small gradients do not underflow to 0 in FP16, then divides it back out before the FP32 weight update. BatchNorm keeps activations near zero-mean / unit-variance, away from the ranges where exp and division lose precision - a stability win layered on its optimization benefit.

88. Guess the shape of the answer: torch computes the same stable numbers

Estimation

Predict first

PyTorch's softmax, logsumexp, and cross_entropy all do the max trick internally, so they match our hand-derived values on the same z. Standalone:

Commit before you compute: what does torch computes the same stable 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: torch cross_entropy(target=2) = 0.407606

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. cross_entropy takes RAW logits and internally computes LSE - z_true, matching our hand value 0.407606.

89. torch computes the same stable numbers

Worked example

PyTorch's softmax, logsumexp, and cross_entropy all do the max trick internally, so they match our hand-derived values on the same z. Standalone:

import numpy as np, torch
torch.manual_seed(0)
z = torch.tensor([1000., 1001., 1002.])
print(torch.softmax(z, dim=0))                  # [0.0900, 0.2447, 0.6652]
print(round(torch.logsumexp(z, dim=0).item(), 4))  # 1002.4076
ce = torch.nn.functional.cross_entropy(z.unsqueeze(0), torch.tensor([2]))
print(round(ce.item(), 6))                      # 0.407606

torch.softmax = [0.0900, 0.2447, 0.6652]

Why: Identical to our numpy stable softmax - torch subtracts the max under the hood, so the same z that overflows a naive formula gives a clean distribution.

torch cross_entropy(target=2) = 0.407606

Why: cross_entropy takes RAW logits and internally computes LSE - z_true, matching our hand value 0.407606. This is why you pass logits, never softmax outputs, to the loss - double-softmaxing is a classic bug.

torch callvalue (verified)
torch.softmax(z)[0.0900, 0.2447, 0.6652]
torch.logsumexp(z)1002.4076
cross_entropy(logits, [2])0.407606

90. Inspect it line by line: torch computes the same stable numbers

Error analysis

Annotate

Walk the callouts on torch computes the same stable numbers. Each one is a place this is easy to get subtly wrong.

  • Identical to our numpy stable softmax - torch subtracts the max under the hood, so the same z that overflows a naive formula gives a clean distribution.
  • cross_entropy takes RAW logits and internally computes LSE - z_true, matching our hand value 0.407606. This is why you pass logits, never softmax outputs, to the loss - double-softmaxing is a classic bug.

91. Rebuild the recipe: The numerical-stability toolkit

Ranking

Put in order

These are the steps of The numerical-stability toolkit, scrambled. Put them back in order before the next slide shows you.

  1. Softmax / log-softmax: subtract max(z) before exp - shift-invariant, so the answer is unchanged and nothing overflows
  2. Sums of exps (LSE): use LSE(z) = z* + log Σ e^(zᵢ - z*); feed raw logits to cross-entropy, never softmax outputs
  3. Near-equal subtractions: avoid cancellation with log1p / expm1; do not form the tiny difference
  4. Linear solves: np.linalg.solve / lstsq, never inv; check κ and expect to lose ~log₁₀ κ digits
  5. Ill-conditioned (κ huge): regularize with A + λI - it lifts the smallest eigenvalue and shrinks κ
  6. Low precision: float32 overflows exp at ~88.7; use mixed precision (FP16 compute + FP32 master state, loss scaling)

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.

92. The numerical-stability toolkit

Pattern

  1. Softmax / log-softmax: subtract max(z) before exp - shift-invariant, so the answer is unchanged and nothing overflows
  2. Sums of exps (LSE): use LSE(z) = z* + log Σ e^(zᵢ - z*); feed raw logits to cross-entropy, never softmax outputs
  3. Near-equal subtractions: avoid cancellation with log1p / expm1; do not form the tiny difference
  4. Linear solves: np.linalg.solve / lstsq, never inv; check κ and expect to lose ~log₁₀ κ digits
  5. Ill-conditioned (κ huge): regularize with A + λI - it lifts the smallest eigenvalue and shrinks κ
  6. Low precision: float32 overflows exp at ~88.7; use mixed precision (FP16 compute + FP32 master state, loss scaling)

93. Where does it stop working: The numerical-stability toolkit

Edge cases

Discussion prompt

The numerical-stability toolkit 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. Softmax / log-softmax: subtract max(z) before exp - shift-invariant, so the answer is unchanged and nothing overflows
  2. Sums of exps (LSE): use LSE(z) = z* + log Σ e^(zᵢ - z*); feed raw logits to cross-entropy, never softmax outputs
  3. Near-equal subtractions: avoid cancellation with log1p / expm1; do not form the tiny difference
  4. Linear solves: np.linalg.solve / lstsq, never inv; check κ and expect to lose ~log₁₀ κ digits
  5. Ill-conditioned (κ huge): regularize with A + λI - it lifts the smallest eigenvalue and shrinks κ
  6. Low precision: float32 overflows exp at ~88.7; use mixed precision (FP16 compute + FP32 master state, loss scaling)

94. Rule out three: Check yourself - why subtract the max

Elimination

Eliminate the wrong options

Subtracting max(z) before exponentiating in softmax is valid because:

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. softmax is shift-invariant: softmax(z) = softmax(z - c) for any constant c
  • B. it makes the output more accurate than the true softmax
  • C. the maximum logit is always negligible
  • D. it rescales the logits to unit variance

Survives elimination: A

Why: e^{-c} factors out of the numerator and every term of the denominator and cancels exactly, so softmax(z - c) = softmax(z). Choosing c = max makes every exponent <= 0 (each exp <= 1), so nothing overflows - with zero change to the result.

95. Check yourself - why subtract the max

Check

The single most important fact in the lesson.

Check your understanding

Subtracting max(z) before exponentiating in softmax is valid because:

  • A. softmax is shift-invariant: softmax(z) = softmax(z - c) for any constant c (correct)
  • B. it makes the output more accurate than the true softmax
  • C. the maximum logit is always negligible
  • D. it rescales the logits to unit variance

Answer: A

Why: e^{-c} factors out of the numerator and every term of the denominator and cancels exactly, so softmax(z - c) = softmax(z). Choosing c = max makes every exponent <= 0 (each exp <= 1), so nothing overflows - with zero change to the result.

Why B tempts people
The result is mathematically identical, not more accurate - the point is to avoid overflow (inf/inf = nan), not to change the answer. The exact-cancellation proof shows the value is unchanged.
Why C tempts people
The max can be enormous (1002 here); it is the opposite of negligible. Subtracting it is exactly what pulls the exponents down to a safe range.
Why D tempts people
Subtracting a constant is a shift, not a rescale; it does not touch the variance. Rescaling to unit variance would divide the logits and change the softmax output.

96. Answer it before you see the options: Check yourself - log-sum-exp

Prediction

Predict first

The log-sum-exp trick rewrites log Σ e^{zᵢ} as:

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: z* + log Σ e^{zᵢ - z}, where z = max zᵢ

Why: Factor e^{z} out of the sum, then take the log: log(e^{z} Σ e^{zᵢ - z}) = z + log Σ e^{zᵢ - z*}. The shifted exponents are all <= 0, so the inner sum stays in (0, n] - no overflow - and the identity is exact.

97. Check yourself - log-sum-exp

Check

Trace the identity, do not memorize it.

Check your understanding

The log-sum-exp trick rewrites log Σ e^{zᵢ} as:

  • A. z* + log Σ e^{zᵢ - z}, where z = max zᵢ (correct)
  • B. Σ log(zᵢ)
  • C. log(z*) + Σ e^{zᵢ}
  • D. max over log(zᵢ)

Answer: A

Why: Factor e^{z} out of the sum, then take the log: log(e^{z} Σ e^{zᵢ - z}) = z + log Σ e^{zᵢ - z*}. The shifted exponents are all <= 0, so the inner sum stays in (0, n] - no overflow - and the identity is exact.

Why B tempts people
Σ log(zᵢ) is log of a product of the logits - a completely different quantity, and it takes log of the raw logits rather than of a sum of exponentials.
Why C tempts people
This keeps a raw Σ e^{zᵢ} (which still overflows) and wrongly takes log of z. The max is added OUTSIDE the log as z, not as log(z*), and the surviving sum must be the shifted one.
Why D tempts people
The trick preserves the full sum of exponentials; it does not collapse to a max over logs. That would discard all but one term.

98. Answer it before you see the options: Check yourself - conditioning cost

Prediction

Predict first

A linear system has condition number kappa ~ 10^10. Solving it in double precision (~16 digits) leaves roughly:

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: ~6 reliable digits - you lose about log10(kappa) ~ 10

Why: Each factor of 10 in kappa costs about one digit, so kappa ~ 10^10 loses ~10 of the ~16 available, leaving ~6 reliable digits. That is exactly what the Hilbert(8) solve showed: cond 1.5e10, error ~1.3e-7 (wrong near the 7th digit).

99. Check yourself - conditioning cost

Check

What does a big kappa actually cost?

Check your understanding

A linear system has condition number kappa ~ 10^10. Solving it in double precision (~16 digits) leaves roughly:

  • A. ~6 reliable digits - you lose about log10(kappa) ~ 10 (correct)
  • B. all 16 digits - kappa does not affect accuracy
  • C. 0 digits - the answer is completely meaningless
  • D. exactly 10 extra digits are gained

Answer: A

Why: Each factor of 10 in kappa costs about one digit, so kappa ~ 10^10 loses ~10 of the ~16 available, leaving ~6 reliable digits. That is exactly what the Hilbert(8) solve showed: cond 1.5e10, error ~1.3e-7 (wrong near the 7th digit).

Why B tempts people
Conditioning directly bounds the achievable accuracy; no algorithm can dodge it. A large kappa erodes digits regardless of whether you use solve, inv, or lstsq.
Why C tempts people
You lose ~10 digits, not all 16 - about 6 remain, so the answer is degraded but still meaningful. It becomes meaningless only when log10(kappa) approaches the ~16 available (around n=12 for Hilbert).
Why D tempts people
A large kappa LOSES accuracy, it does not gain it. The sign of the effect is backwards - conditioning is a cost, never a bonus.

100. Rule out three: Check yourself - solve vs inv

Elimination

Eliminate the wrong options

Why prefer np.linalg.solve(A, b) over np.linalg.inv(A) @ b for an ill-conditioned A?

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. Forming the explicit inverse does more arithmetic on the ill-conditioned matrix, amplifying round-off - solve factors once and is more accurate
  • B. inv returns a mathematically different, wrong answer
  • C. solve automatically adds ridge regularization
  • D. inv only works on symmetric matrices

Survives elimination: A

Why: solve does one stable LU factorization plus back-substitution; inv computes the full inverse (extra passes over the bad matrix) and then multiplies. On Hilbert(8) solve's error was 1.3e-7 versus inv's 9.5e-7 - same math, worse numerics. Better still is lstsq (QR/SVD), which never forms A explicitly.

101. Check yourself - solve vs inv

Check

The coding section rewards numerically sound habits.

Check your understanding

Why prefer np.linalg.solve(A, b) over np.linalg.inv(A) @ b for an ill-conditioned A?

  • A. Forming the explicit inverse does more arithmetic on the ill-conditioned matrix, amplifying round-off - solve factors once and is more accurate (correct)
  • B. inv returns a mathematically different, wrong answer
  • C. solve automatically adds ridge regularization
  • D. inv only works on symmetric matrices

Answer: A

Why: solve does one stable LU factorization plus back-substitution; inv computes the full inverse (extra passes over the bad matrix) and then multiplies. On Hilbert(8) solve's error was 1.3e-7 versus inv's 9.5e-7 - same math, worse numerics. Better still is lstsq (QR/SVD), which never forms A explicitly.

Why B tempts people
In exact arithmetic both give the same x; the difference is floating-point round-off and speed, not the underlying algebra.
Why C tempts people
solve does no regularization - it factors whatever matrix you hand it. Ridge is the separate A + lambda I move.
Why D tempts people
inv works on any square non-singular matrix, symmetric or not. Symmetry is irrelevant to the reason to avoid it.

102. Your turn: make it survive

Section

The project

103. Project: stable softmax & log-sum-exp

Concept

Reproduce the whole story yourself on z = [1000, 1001, 1002]: first make the naive versions visibly fail, then build the stable ones and prove they match scipy. You derived every piece - now assemble it.

#requirementtool
1show naive exp overflows to nannp.exp
2stable softmax (subtract max)x - x.max()
3stable log-sum-exp vs scipyscipy.special.logsumexp

Build rules: type every line yourself, run after each line, and test on [1000, 1001, 1002] so the naive version visibly dies. When something prints nan, read why - do not paper over it.

104. Break it if you can: Project: stable softmax & log-sum-exp

Counterexample

Discussion prompt

Build rules: type every line yourself, run after each line, and test on [1000, 1001, 1002] so the naive version visibly dies. When something prints nan, read why - do not paper over it.

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.

105. Milestone 1 - watch it overflow

Worked example

Your turn: exponentiate [1000, 1001, 1002] directly and observe. Predict inf / inf before you run it.

Hint: np.exp(z) returns inf; dividing by its sum gives nan. Expect a RuntimeWarning - that warning is the lesson, not a bug to suppress.

import numpy as np
z = np.array([1000., 1001., 1002.])
e = np.exp(z)
print(e)             # [inf inf inf]
print(e / e.sum())   # [nan nan nan]
expressionresult
np.exp(z)[inf, inf, inf]
e / e.sum()[nan, nan, nan]

106. Milestone 2 - stable softmax

Worked example

Your turn: subtract the max before exp. Predict the output out loud - by shift invariance it equals softmax([1, 2, 3]).

Hint: e = np.exp(x - x.max()); return e / e.sum(). The largest exponent becomes exp(0) = 1.

import numpy as np
z = np.array([1000., 1001., 1002.])
def softmax(x):
    e = np.exp(x - x.max())
    return e / e.sum()
print(softmax(z).round(6))   # [0.090031 0.244728 0.665241]
inputstable softmax
[1000, 1001, 1002][0.090031, 0.244728, 0.665241]
(= softmax([1, 2, 3]))shift-invariant

107. Milestone 3 - log-sum-exp vs scipy

Worked example

Your turn: implement stable LSE and confirm it matches scipy.special.logsumexp. Predict: does the naive version finish, or overflow?

Hint: m = x.max(); return m + np.log(np.sum(np.exp(x - m))), then compare with np.isclose.

import numpy as np
from scipy.special import logsumexp
z = np.array([1000., 1001., 1002.])
def lse(x):
    m = x.max()
    return m + np.log(np.sum(np.exp(x - m)))
print(round(lse(z), 6))                  # 1002.407606
print(round(float(logsumexp(z)), 6))     # 1002.407606
print(np.isclose(lse(z), logsumexp(z)))  # True
sourcelog Σ exp z
your lse(z)1002.407606
scipy logsumexp1002.407606
np.iscloseTrue

108. What each one costs: Milestone 3 - log-sum-exp vs scipy

Trade off

Comparison matrix

From Milestone 3 - log-sum-exp vs scipy: every row here is a choice with a cost. Fill the log Σ exp z column, then say which row you would actually pick and what you give up for it.

sourcelog Σ exp z
your lse(z)1002.407606
scipy logsumexp1002.407606
np.iscloseTrue

109. The full program

Concept

import numpy as np
from scipy.special import logsumexp

def softmax(x):
    e = np.exp(x - x.max()); return e / e.sum()

def lse(x):
    m = x.max(); return m + np.log(np.sum(np.exp(x - m)))

z = np.array([1000., 1001., 1002.])
print('naive exp   :', np.exp(z)[0], '(overflow)')  # inf
print('softmax     :', softmax(z).round(6))         # [0.090031 0.244728 0.665241]
print('lse         :', round(lse(z), 4))            # 1002.4076
print('== scipy    :', np.isclose(lse(z), logsumexp(z)))  # True
printed linevalue
naive expinf (overflow)
softmax[0.090031, 0.244728, 0.665241]
lse1002.4076
== scipyTrue

If the naive exp prints inf while your softmax and lse return real numbers that match scipy - you have made the math survive floating point.

110. Fill in: value for The full program

Comparison

Comparison matrix

From The full program: refill the value column from what you know. The rest of the table is as it appeared.

printed linevalue
naive expinf (overflow)
softmax[0.090031, 0.244728, 0.665241]
lse1002.4076
== scipyTrue

111. Show it off

Concept

Slides closed, out loud: explain (1) why subtracting the max cannot change softmax (the e^{-c} cancellation), (2) why log-sum-exp never overflows, and (3) what κ = 10¹⁰ costs a double-precision solve.

Stretch (homework): reproduce the ill-conditioned Hilbert(8) solve and confirm inv is worse than solve; then add λI and watch κ collapse. These exact tricks power Flash Attention (Week 30) and stable diffusion sampling (Week 48).

112. Connect it up: Lesson 27: Numerical Stability

Connect it up

Draw it

One page, no notation unless you need it: draw how these connect — How floats break · Softmax that survives · The log-sum-exp trick · Conditioning of a solve · Ridge shrinks kappa · Precision & scale. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.

113. What you can do now

Recap

ideathe one thing to remember
softmaxsubtract max before exp (shift-invariant, exact)
log-sum-expz* + log Σ e^(zᵢ - z*); CE = LSE - z_true
cancellationlog1p / expm1, never form the tiny difference
condition numberlarge κ loses ~log₁₀ κ digits; solve, never inv
ridgeA + λI lifts min eigenvalue, shrinks κ
precisionfloat32 exp overflows at ~88.7; mixed precision + FP32 state

Sources

  1. USAAIO Year-Long Master Lesson Plan, Lesson 27 (Week 9 - Numerical Stability) — Barron / USAAIO Round 2 Preparation, 2026
  2. IEEE 754-2019, binary64 range and rounding — IEEE Standard for Floating-Point Arithmetic
  3. numpy.finfo, numpy.linalg.cond / solve / lstsq
  4. scipy.special.logsumexp; scipy.linalg.hilbert
  5. Trefethen and Bau, Numerical Linear Algebra, Lectures 12-14 (conditioning, stability) — SIAM, 1997
  6. Every exp, softmax, log-sum-exp, condition number, and torch value produced by real execution — numpy 2.2.6 + scipy 1.16 + 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