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
MXNet’s SoftmaxCrossEntropyLoss (default from_logits=False) applies the stable softmax internally, then the cross-entropy, so we still pass raw logits, never probabilities:
@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 = gluon.loss.SoftmaxCrossEntropyLoss() l = fn(Y_hat, Y)return l.mean() if averaged else l
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.