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
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.
Objectives
exp overflows past ~709.78)softmax(z) = softmax(z - c), and use c = max(z) to kill overflow with zero change to the answerlog Σ eᶻⁱ = z* + log Σ e^(zᵢ - z*) and use it for a stable cross-entropyκ = σ_max / σ_min, predict the digits a solve loses (~log₁₀κ), and demote inv for solve / lstsqA + λI lifts the smallest eigenvalue and shrinks κ, and why float32 overflows so much sooner than float64Warm-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.
Section
Part 1 of 6 - IEEE-754
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.
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.
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.
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.72284exp(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.
| expression | printed value |
|---|---|
| np.finfo(float64).max | 1.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 |
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.
| expression | printed value |
|---|---|
| np.finfo(float64).max | 1.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 |
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.
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.
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.
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.
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.
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.
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.
| expression | printed value | note |
|---|---|---|
| (1e16 + 1) - 1e16 | 0.0 | the 1 is lost |
| (1e8 + 1) - 1e8 | 1.0 | still fits |
| np.log(1.0 + 1e-10) | 1.000000082690371e-10 | wrong at digit 8 |
| np.log1p(1e-10) | 9.999999999500001e-11 | accurate |
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.
| expression | printed value | note |
|---|---|---|
| (1e16 + 1) - 1e16 | 0.0 | the 1 is lost |
| (1e8 + 1) - 1e8 | 1.0 | still fits |
| np.log(1.0 + 1e-10) | 1.000000082690371e-10 | wrong at digit 8 |
| np.log1p(1e-10) | 9.999999999500001e-11 | accurate |
Section
Part 2 of 6 - shift invariance
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.
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.
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.
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:
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]
| step | value |
|---|---|
| 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.
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].
| step | value |
|---|---|
| np.exp(z) | [inf, inf, inf] |
| e.sum() | inf |
| e / e.sum() | [nan, nan, nan] |
Cost model
Annotate
In The naive formula overflows to nan, before reading the notes: mark where the time actually goes. Which line dominates?
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 \]
Ranking
Put in order
Put the moves of Prove shift invariance in one line into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. Subtract c from every logit and write out the definition.
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 \]
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}} \)
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.
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.
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.))) # Truesoftmax(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.
| input | softmax (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 |
Error analysis
Annotate
Walk the callouts on Shift invariance, confirmed numerically. Each one is a place this is easy to get subtly wrong.
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.9999999999999999z - 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.
| quantity | value (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 |
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:
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 \]
| i | zᵢ | zᵢ - z* | e^(zᵢ - z*) | pᵢ = e/S |
|---|---|---|---|---|
| 0 | 1000 | -2 | 0.135335 | 0.090031 |
| 1 | 1001 | -1 | 0.367879 | 0.244728 |
| 2 | 1002 | 0 | 1.000000 | 0.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.
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?
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.
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.
| stage | value |
|---|---|
| np.exp(z) | [inf, inf, inf] |
| p | [nan, nan, nan] |
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.
| stage | value |
|---|---|
| exp(z - max) | [0.135335, 0.367879, 1.0] |
| p | [0.090031, 0.244728, 0.665241] |
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.
Section
Part 3 of 6 - loss without overflow
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.
Ranking
Put in order
Put the moves of Derive the log-sum-exp identity into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. 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.
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^{*}}\;} \]
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.
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 \]
| piece | value (verified) |
|---|---|
| z* = max z | 1002 |
| inner sum e^-2 + e^-1 + e^0 | 1.503215 |
| log(inner sum) | 0.407606 |
| LSE(z) = z* + log(inner) | 1002.407606 |
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.
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.
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.407606naive = 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.
| method | log Σ exp z |
|---|---|
| naive log(sum(exp(z))) | inf |
| stable (subtract max) | 1002.407606 |
| scipy.special.logsumexp | 1002.407606 |
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.
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.
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-entropylog 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.
| quantity | value (verified) |
|---|---|
| log p | [-2.407606, -1.407606, -0.407606] |
| exp(log p) | [0.090031, 0.244728, 0.665241] |
| CE (true = class 2) | 0.407606 |
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.
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.
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).
| approach | result |
|---|---|
| naive exp(z) | [0.0, 0.0, 0.0] |
| naive softmax | [nan, nan, nan] |
| stable softmax | [0.665241, 0.244728, 0.090031] |
Section
Part 4 of 6 - 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.
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.
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.
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)^2cond(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.
| quantity | value (verified) |
|---|---|
| cond(X) | 2.4497e+04 |
| cond(XᵀX) | 6.0012e+08 |
| cond(X)² | 6.0012e+08 |
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.
| quantity | value (verified) |
|---|---|
| cond(X) | 2.4497e+04 |
| cond(XᵀX) | 6.0012e+08 |
| cond(X)² | 6.0012e+08 |
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+04sigma_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.
| quantity | value (verified) |
|---|---|
| singular values | [2.000050, 4.999875e-05] |
| sigma_max / sigma_min | 40002.000074915224 |
| np.linalg.cond(A) | 4.0002e+04 |
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.
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.
| quantity | value (verified) |
|---|---|
| cond(hilbert(8)) | 1.5258e+10 |
| log10(cond) (digits lost) | ~10.18 |
| max error, np.linalg.solve | 1.3089e-07 |
| max error, inv @ b | 9.5367e-07 |
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.
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.
| n | cond(H) | max solve error |
|---|---|---|
| 4 | 1.5514e+04 | 5.37e-14 |
| 6 | 1.4951e+07 | 6.65e-11 |
| 8 | 1.5258e+10 | 1.31e-07 |
| 10 | 1.6025e+13 | 2.43e-05 |
Every three rows adds ~three orders of magnitude to κ and loses ~three more digits - the log₁₀ κ rule made visible.
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?
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.
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-07inv 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.
| approach | max error |
|---|---|
| np.linalg.inv(H) @ b | 9.5367e-07 |
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-07solve 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.
| approach | max error |
|---|---|
| np.linalg.solve(H, b) | 1.3089e-07 |
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.
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: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.Section
Part 5 of 6 - Tikhonov regularization
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.
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.
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.
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.
Ranking
Put in order
Put the moves of lambda I shifts every eigenvalue up by lambda into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. Let v be an eigenvector of symmetric A with eigenvalue mu: Av = mu v.
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) \]
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) \)
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
| quantity | plain H | H + 1e-3 I |
|---|---|---|
| cond | 1.5258e+10 | 1.6969e+03 |
| min eigenvalue | 1.1115e-10 | 1.0000e-03 |
| max eigenvalue | 1.6959 | 1.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.
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+lamIkappa 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.
| quantity | plain H | H + 1e-3 I |
|---|---|---|
| cond | 1.5258e+10 | 1.6969e+03 |
| min eigenvalue | 1.1115e-10 | 1.0000e-03 |
| max eigenvalue | 1.6959 | 1.6969 |
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?
Section
Part 6 of 6 - float32 and mixed precision
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}) \]
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.
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.
| expression | dtype | result |
|---|---|---|
| np.exp(100) | float32 | inf |
| np.exp(100) | float64 | 2.688e+43 |
| stable softmax | float32 | [0.090031, 0.244728, 0.665241] |
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:
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.
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.
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.407606torch.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 call | value (verified) |
|---|---|
| torch.softmax(z) | [0.0900, 0.2447, 0.6652] |
| torch.logsumexp(z) | 1002.4076 |
| cross_entropy(logits, [2]) | 0.407606 |
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.
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.
max(z) before exp - shift-invariant, so the answer is unchanged and nothing overflowsLSE(z) = z* + log Σ e^(zᵢ - z*); feed raw logits to cross-entropy, never softmax outputslog1p / expm1; do not form the tiny differencenp.linalg.solve / lstsq, never inv; check κ and expect to lose ~log₁₀ κ digitsκ huge): regularize with A + λI - it lifts the smallest eigenvalue and shrinks κ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.
Pattern
max(z) before exp - shift-invariant, so the answer is unchanged and nothing overflowsLSE(z) = z* + log Σ e^(zᵢ - z*); feed raw logits to cross-entropy, never softmax outputslog1p / expm1; do not form the tiny differencenp.linalg.solve / lstsq, never inv; check κ and expect to lose ~log₁₀ κ digitsκ huge): regularize with A + λI - it lifts the smallest eigenvalue and shrinks κexp at ~88.7; use mixed precision (FP16 compute + FP32 master state, loss scaling)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:
max(z) before exp - shift-invariant, so the answer is unchanged and nothing overflowsLSE(z) = z* + log Σ e^(zᵢ - z*); feed raw logits to cross-entropy, never softmax outputslog1p / expm1; do not form the tiny differencenp.linalg.solve / lstsq, never inv; check κ and expect to lose ~log₁₀ κ digitsκ huge): regularize with A + λI - it lifts the smallest eigenvalue and shrinks κexp at ~88.7; use mixed precision (FP16 compute + FP32 master state, loss scaling)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.
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.
Check
The single most important fact in the lesson.
Check your understanding
Subtracting max(z) before exponentiating in softmax is valid because:
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.
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.
Check
Trace the identity, do not memorize it.
Check your understanding
The log-sum-exp trick rewrites log Σ e^{zᵢ} as:
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.
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).
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:
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).
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.
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.
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?
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.
Section
The project
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.
| # | requirement | tool |
|---|---|---|
| 1 | show naive exp overflows to nan | np.exp |
| 2 | stable softmax (subtract max) | x - x.max() |
| 3 | stable log-sum-exp vs scipy | scipy.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.
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.
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]| expression | result |
|---|---|
| np.exp(z) | [inf, inf, inf] |
| e / e.sum() | [nan, nan, nan] |
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]| input | stable softmax |
|---|---|
| [1000, 1001, 1002] | [0.090031, 0.244728, 0.665241] |
| (= softmax([1, 2, 3])) | shift-invariant |
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| source | log Σ exp z |
|---|---|
| your lse(z) | 1002.407606 |
| scipy logsumexp | 1002.407606 |
| np.isclose | True |
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.
| source | log Σ exp z |
|---|---|
| your lse(z) | 1002.407606 |
| scipy logsumexp | 1002.407606 |
| np.isclose | True |
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 line | value |
|---|---|
| naive exp | inf (overflow) |
| softmax | [0.090031, 0.244728, 0.665241] |
| lse | 1002.4076 |
| == scipy | True |
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.
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 line | value |
|---|---|
| naive exp | inf (overflow) |
| softmax | [0.090031, 0.244728, 0.665241] |
| lse | 1002.4076 |
| == scipy | True |
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).
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.
Recap
exp past ~709.78), underflow (to 0), cancellation - and use log1p / expm1 to dodge the lastmax(z) for a softmax and log-softmax that never overflowLSE = z* + log Σ e^(zᵢ - z*) and compute cross-entropy as LSE - z_true from raw logits~log₁₀ κ lost digits, and use solve / lstsq over invA + λI (lifts the smallest eigenvalue), and know float32 overflows exp at ~88.7| idea | the one thing to remember |
|---|---|
| softmax | subtract max before exp (shift-invariant, exact) |
| log-sum-exp | z* + log Σ e^(zᵢ - z*); CE = LSE - z_true |
| cancellation | log1p / expm1, never form the tiny difference |
| condition number | large κ loses ~log₁₀ κ digits; solve, never inv |
| ridge | A + λI lifts min eigenvalue, shrinks κ |
| precision | float32 exp overflows at ~88.7; mixed precision + FP32 state |
Want this taught 1-on-1? Alexander tutors Machine Learning — $55/session, free consultation.