Contents
, where
Deep Breakdown
What it tells me (Situation Awareness):
The log-sum-exp (LSE) trick is a numerically stable identity for computing when the values are large (causing overflow: in float64) or very negative (causing underflow: ). By subtracting before exponentiating, the largest term becomes and all other terms are , preventing overflow entirely. The mathematical identity is exact: no approximation is made. In DA/DS: LSE appears in softmax cross-entropy loss (numerically stable implementation), log-likelihood computation for mixture models (log-sum over component likelihoods), Hidden Markov Model forward algorithm, attention mechanisms in transformers, and any log-probability computation involving sums of exponentials. NumPy's scipy.special.logsumexp and PyTorch's torch.logsumexp both implement this trick.
Flowchart
Real-world use
Where it is used
Softmax cross-entropy in every neural network (stable implementation of where ); Gaussian Mixture Model log-likelihood (sum over components in log space); HMM forward/backward algorithm; attention score computation in transformers; Bayesian model comparison (log marginal likelihood); any computation of .
Problem it solves
Direct computation of fails numerically for extreme inputs. In float64: overflows to inf for ; underflows to for . Log-probabilities in language models, log-likelihoods summed over long sequences, and logit vectors from large-vocabulary classifiers routinely exceed these ranges.
How it solves the problem
Factor out the maximum: . Since for all (because ), every , so . No overflow. If all are very negative (underflow risk), subtracting still gives for the largest term — at least one term survives, preventing .
Example in a business
A DS computes softmax cross-entropy for a 10k-class product classifier (e-commerce). Raw logits from the final layer can be large (e.g., ). Naive softmax: — numerator . Stable softmax: subtract first; — numerator , others , , etc. This is implemented directly in PyTorch's nn.CrossEntropyLoss.
The formula, piece by piece
- : natural logarithm (base ); appears because we compute log-probabilities
- : sum of exponentials; the quantity we need in log-space
- : the maximum value in the vector; used as the shift constant
- : shifted values; all since is the maximum; prevents overflow
- : shifted exponentials; all in ; no overflow possible
- : sum of shifted exponentials; always in for terms
- : safe log (argument is positive and ); result
- : add the shift back to recover the true answer
- Identity proof:
- Stable softmax: ; numerically equivalent to original definition
The formula in words
"To safely compute the log of a sum of exponentials: subtract the maximum value from all terms before exponentiating (so the largest term becomes ), sum those safe exponentials, take the log, then add the maximum back. The result is algebraically identical to the naive formula but stays well within floating-point range." Tiny example: . Naive: (overflow in float64). LSE: . Shifted: . . . Result: . Exact (arbitrary precision): . Matches.
Critical Concepts
- Choice of : any constant makes the identity mathematically correct. The convention is optimal: it guarantees no overflow (all terms ) and no underflow of the largest term. Using (no shift) provides no protection.
- Stable softmax: for softmax, the cancels in numerator and denominator: . Any shift gives the same result, so is used purely for stability.
- Stable log-softmax: . This avoids computing explicitly (which would lose precision for small probabilities) and is used in
torch.nn.LogSoftmax. - Two-pass algorithm: requires one pass to compute , one pass to compute the sum. For streaming/online data, approximate algorithms exist.
- Applications in log-space probability: when working with log-probabilities (instead of directly), computing requires LSE: .
This is standard in speech recognition, NLP decoding, and probabilistic graphical models.
Step-by-step calculation (tiny example)
Compute using the LSE trick.
Step 1: Find .
Step 2: Shift .
Step 3: Exponentiate , , . Sum .
Step 4: Log and add back . Result . Verification: .
Code examples
Beginner example (Python)
import numpy as np
def log_sum_exp(x: np.ndarray) -> float:
"""Numerically stable log-sum-exp: log(sum(exp(x_i))) = a + log(sum(exp(x_i - a)))."""
a = np.max(x)
return float(a + np.log(np.sum(np.exp(x - a))))
def softmax_stable(x: np.ndarray) -> np.ndarray:
"""Numerically stable softmax: exp(x_i - max) / sum(exp(x_j - max))."""
shifted = x - np.max(x)
exp_shifted = np.exp(shifted)
return exp_shifted / exp_shifted.sum()
# Demonstrate overflow prevention
x_normal = np.array([1.0, 2.0, 3.0])
x_large = np.array([1000.0, 999.0, 998.0])
x_small = np.array([-1000.0, -999.0, -998.0])
print("=== Log-Sum-Exp Trick ===")
for label, x in [("normal", x_normal), ("large (+1000)", x_large), ("small (-1000)", x_small)]:
naive_sum = np.sum(np.exp(x)) # may overflow or lose precision
lse_result = log_sum_exp(x)
naive_result = np.log(naive_sum) if np.isfinite(naive_sum) else float("nan")
print(f"\n{label}: x = {x}")
print(f" Naive log(sum(exp(x))): {naive_result}")
print(f" LSE trick: {lse_result}")
print("\n=== Stable Softmax ===")
x_overflow = np.array([1000.0, 999.0, 997.0])
naive_sm = np.exp(x_overflow) / np.sum(np.exp(x_overflow))
stable_sm = softmax_stable(x_overflow)
print(f"Naive softmax: {naive_sm}") # [nan, nan, nan] due to overflow
print(f"Stable softmax: {stable_sm}") # [0.731, 0.269, 0.036] correct
print(f"Sum check: {stable_sm.sum():.6f}")Beginner example (R)
# Log-sum-exp trick: log(sum(exp(x))) = a + log(sum(exp(x - a))), a = max(x)
log_sum_exp <- function(x) {
a <- max(x)
a + log(sum(exp(x - a)))
}
softmax_stable <- function(x) {
shifted <- x - max(x)
exp_shifted <- exp(shifted)
exp_shifted / sum(exp_shifted)
}
# Test cases
x_normal <- c(1, 2, 3)
x_large <- c(1000, 999, 998)
x_small <- c(-1000, -999, -998)
for (label in c("normal", "large", "small")) {
x <- switch(label, normal = x_normal, large = x_large, small = x_small)
naive_result <- tryCatch(log(sum(exp(x))), warning = function(w) NaN)
cat(sprintf("\n%s: LSE = %.4f Naive = %s\n", label, log_sum_exp(x),
ifelse(is.finite(naive_result), sprintf("%.4f", naive_result), "Inf/NaN")))
}
# Stable softmax
cat(sprintf("\nStable softmax([1000, 999, 997]): %.4f %.4f %.4f\n", softmax_stable(c(1000, 999, 997))))Professional example (Python)
import numpy as np
from scipy.special import logsumexp
np.random.seed(42)
# -- Stable cross-entropy loss and log-likelihood for mixture model -----------
# 1. Stable softmax cross-entropy (multi-class, K=5 classes)
def stable_cross_entropy(logits: np.ndarray, labels: np.ndarray) -> float:
"""Cross-entropy using log-sum-exp trick. logits: (n, K), labels: (n,) int."""
n, k = logits.shape
# log_softmax = logits - LSE(logits)
lse = logsumexp(logits, axis=1, keepdims=True) # (n, 1)
log_probs = logits - lse # log-softmax (n, K)
correct_log_probs = log_probs[np.arange(n), labels] # log p(y_i) for each sample
return float(-np.mean(correct_log_probs))
# Simulate logits with large values (realistic in deep classifiers)
N, K = 200, 10
logits = np.random.randn(N, K) * 100 # large logits -- naive exp would overflow
true_labels = np.random.randint(0, K, N)
ce_stable = stable_cross_entropy(logits, true_labels)
print(f"Stable cross-entropy (large logits, K={K}): {ce_stable:.4f}")
print(f" (Naive exp(logits * 100) would overflow to inf)")
# 2. GMM log-likelihood via LSE
def gmm_log_likelihood(
x: np.ndarray, means: np.ndarray, stds: np.ndarray, weights: np.ndarray
) -> float:
"""Gaussian mixture model log-likelihood using LSE for numerical stability.
log p(x) = log sum_k pi_k * N(x; mu_k, sigma_k)
= LSE over k of [log pi_k + log N(x; mu_k, sigma_k)]
"""
n_components = len(means)
log_component_scores = np.zeros((len(x), n_components))
for k in range(n_components):
log_nk = -0.5 * ((x - means[k]) / stds[k])**2 - np.log(stds[k]) - 0.5 * np.log(2 * np.pi)
log_component_scores[:, k] = np.log(weights[k]) + log_nk
# LSE over components for each sample
log_p_x = logsumexp(log_component_scores, axis=1)
return float(np.sum(log_p_x))
# 2-component GMM
x_data = np.concatenate([
np.random.randn(150) * 0.5 + 2.0,
np.random.randn(100) * 0.8 - 1.0,
])
means = np.array([2.0, -1.0])
stds = np.array([0.5, 0.8])
weights = np.array([0.6, 0.4])
log_lik = gmm_log_likelihood(x_data, means, stds, weights)
print(f"\nGMM log-likelihood (n={len(x_data)}, K=2 components): {log_lik:.4f}")
print(f"Average log-likelihood per sample: {log_lik/len(x_data):.4f}")
print(f"\nKey: logsumexp() from scipy.special implements the LSE trick.")
print(f"It is used to avoid overflow when summing component probabilities.")Source: domain knowledge