Float32 spans roughly 10^{-38} to 10^{38}: \expoverflows to \infty once its argument passes \approx +88, and past \approx -88 it gradually underflows through the subnormals, hitting exactly 0 near -104.
Feed the from-scratch softmax the logits \mathbf{o}=(1000, 0, 0): \exp(1000)=\infty, the ratio is \infty/\infty=NaN, and one NaN poisons the entire backward pass. We watched this happen in the softmax-from-scratch section; the fused loss below never forms that ratio.
Fix, step 1: shift by the max
Numerical stability
Softmax is unchanged if we subtract the same constant from every logit (the \exp\bar{o} factors cancel). Choose \bar{o}=\max_k o_k:
Now every exponent o_j - \bar{o} \le 0, so each \exp lands in (0, 1]: no overflow. The denominator sits in [1, q].
Fix, step 2: never form the softmax
Numerical stability
Underflow could still bite if we then took \log of a near-zero probability. But we only ever want \log \hat y_j for the loss, so fold the \log in and the division disappears:
The gap peaks at the tiex = 0, where it equals \log 2 \approx 0.69, the bound \log q you proved in the softmax-regression section (exercise 6), here at q = 2. Away from the tie, soft and hard max are indistinguishable.
03
In code
one fused call, four frameworks
Hand the loss the logits
The fused loss
Optax names it for exactly what it does: softmax_cross_entropy_with_integer_labels takes logits plus integer labels and fuses the stable softmax with the cross-entropy:
@d2l.add_to_class(d2l.Classifier)def loss(self, Y_hat, Y, averaged=True): Y_hat = d2l.reshape(Y_hat, (-1, Y_hat.shape[-1])) Y = d2l.reshape(Y, (-1,)) fn = optax.softmax_cross_entropy_with_integer_labelsreturn fn(Y_hat, Y).mean() if averaged else fn(Y_hat, Y)
One rule for the fused loss
The fused loss
The name differs by library; the contract does not. The built-in fused loss takes logits, not probabilities: passing softmax outputs would softmax twice.
Defined once on Classifier (note the #@save): the whole book inherits the stable loss.
04
Train
same data, same curve, less code
Train
Results
Same Fashion-MNIST, same 10 epochs, same Trainer:
data = d2l.FashionMNIST(batch_size=256)model = SoftmaxRegression(num_outputs=10, lr=0.1)trainer = d2l.Trainer(max_epochs=10)trainer.fit(model, data)
Converges to the same ~83–84% validation accuracy as the from-scratch model of the softmax-from-scratch section, now in a handful of lines, and with the correct loss instead of a clamped one.
Recap
Wrap-up
From scratch taught what softmax and cross-entropy are; concise is what we reach for.
The forward pass outputs logits; the built-in loss owns the softmax.
That built-in is the log-sum-exp rewrite \ell = \bar{o} + \log\sum_k e^{o_k-\bar{o}} - o_y, not a naive softmax → log → NLL.
lse is a smooth max: within \log q of \max_k o_k, gap largest (\log 2 for q{=}2) exactly at the tie.
Fewer lines and numerically correct: float32’s \pm 88 (and -104) cliffs never come into play.