The log-sum-exp trick and stable softmax
Derive the log-sum-exp shift and use it to compute softmax and log-likelihoods without overflow.
The softmax and the log-likelihood of a classifier both involve , which overflows for any moderately large and underflows for moderately negative ones[1]. The log-sum-exp trick is a one-line algebraic identity that makes the expression stable for any inputs. Every deep-learning framework uses it internally.
The naive failure
Computing with float32: at , , still finite, but gives . Even before overflow, summing one huge with many tiny loses the small terms entirely. Softmax, , returns or in those regimes.
The shift identity
For any constant ,
Derivation. Factor out of the sum: . Taking the log, , where the right side uses .
The identity holds for any ; the smart choice is . Then for all , so each , no term overflows, and at least one term equals 1 (avoiding underflow of the whole sum to zero). The shifted formula is numerically stable for any input[2].
Stable softmax
The same shift carries through to softmax. Subtracting from every multiplies numerator and denominator by the same , leaving the ratio unchanged:
With , both numerator and denominator are well-scaled.
Log-softmax and the cross-entropy loss
Training computes the log-softmax for the true class, not the softmax itself. Computing directly applies the shift:
This avoids the lossy two-step "softmax then log", which can produce
when a class has probability that underflowed. The fused log_softmax is the
preferred building block, and the cross-entropy loss with logits in deep learning
calls it internally.
Check it on arrays
import numpy as np
def logsumexp_naive(x):
return np.log(np.sum(np.exp(x)))
def logsumexp_stable(x):
c = np.max(x)
return c + np.log(np.sum(np.exp(x - c)))
def softmax_naive(x):
e = np.exp(x)
return e / e.sum()
def softmax_stable(x):
e = np.exp(x - x.max())
return e / e.sum()
# Overflow regime
x_big = np.array([1000.0, 1001.0, 1002.0])
print(f"naive logsumexp: {logsumexp_naive(x_big):.6f}") # inf
print(f"stable logsumexp: {logsumexp_stable(x_big):.6f}") # ~1002.407...
print(f"naive softmax: {softmax_naive(x_big)}") # nan
print(f"stable softmax: {softmax_stable(x_big)}") # finite
# Underflow regime
x_small = np.array([-1000.0, -1001.0, -1002.0])
print(f"naive softmax: {softmax_naive(x_small)}") # 0/0 -> nan
print(f"stable softmax: {softmax_stable(x_small)}") # correct
# Verify the stable softmax matches the analytic answer
x = np.array([1.0, 2.0, 3.0])
analytic = np.exp(x) / np.exp(x).sum()
print("agreement at moderate x:", np.allclose(softmax_stable(x), analytic))
# Compare against numpy's library implementation
print("matches np.exp normalization:", np.allclose(softmax_stable(x_big), softmax_stable(x_big - 500)))The naive computations break in both extremes while the shifted versions return finite, correct answers. Shifting by the max is the entire fix, and the cost is one extra pass over the array.
Exercises
Exercise 1. Use the shift identity with to compute by hand, expressing the answer as and giving its numerical value.
Solution
Step 1. The maximum is . The identity gives
Step 2. Evaluate the shifted sum: , , , so the sum is and .
Step 3. The result is . Every shifted exponent is , so no term overflows, and the term for at the maximum equals , keeping the sum away from underflow.
Exercise 2. Prove the bound for terms. This is why log-sum-exp is sometimes called a smooth maximum.
Solution
Step 1. Let . Every term , and one term equals , so . Taking logs, , the lower bound.
Step 2. Each term satisfies , so . Taking logs, , the upper bound.
Step 3. Together, : log-sum-exp returns the maximum plus a slack that never exceeds , and the slack vanishes when one term dominates the rest.
Exercise 3 (code). Implement stable log_softmax directly as
with . Verify it agrees with the
two-step on moderate inputs and stays finite on inputs that
make the two-step route return .
Solution
import numpy as np
def log_softmax_stable(x):
c = x.max()
shifted = x - c
return shifted - np.log(np.sum(np.exp(shifted)))
def log_softmax_twostep(x):
e = np.exp(x)
return np.log(e / e.sum()) # underflows then logs zero
# Moderate inputs: both routes agree
x = np.array([1.0, 2.0, 3.0])
assert np.allclose(log_softmax_stable(x), log_softmax_twostep(x))
# Large inputs: two-step route produces -inf, stable route stays finite
x_big = np.array([1000.0, 1001.0, 1002.0])
ls = log_softmax_stable(x_big)
assert np.all(np.isfinite(ls))
assert not np.all(np.isfinite(log_softmax_twostep(x_big)))
print("stable log_softmax:", ls)The direct form subtracts the max before exponentiating, so it never overflows; fusing the log into the subtraction also avoids the the two-step route hits once a class probability underflows.
The next lesson moves from elementwise numerics to solving systems by repeated approximation.
References
- [1]Goldberg, D. (1991). "What every computer scientist should know about floating-point arithmetic". ACM Computing Surveys, 23(1), 5-48. Overflow, underflow, and the exponential range (doi:10.1145/103162.103163).↩
- [2]Higham, N. J. (2002). Accuracy and Stability of Numerical Algorithms (2nd ed.). SIAM. Reformulation to avoid overflow in exponential sums (doi:10.1137/1.9780898718027).↩