1. Background Knowledge
In reinforcement learning, a policy π(a∣s) maps states to a probability distribution over actions. When actions are discrete, this is typically parameterized by a softmax over a vector of unnormalized scores called logits. If hb is the logit for action b, the probability of action a is:
π(a)=∑behbeha
Policy-gradient algorithms (e.g., REINFORCE, A2C) require the log-probability logπ(a) rather than the probability itself. This is because gradients of the log-likelihood are numerically more stable and algebraically simpler. Taking the log of the softmax gives:
logπ(a)=ha−logb∑ehb
The term log∑behb is the log-sum-exp (LSE) of the logits. Directly computing ∑behb can overflow for large logits or underflow for very negative ones. The log-sum-exp trick stabilizes this by shifting all logits by a constant m (typically m=maxbhb):
LSE(h)=m+logb∑ehb−m
Since m is the maximum, every hb−m≤0, so each exponential is in (0,1] and the sum cannot overflow. The shift m is added back to preserve the correct value.
2. Algorithm Approach
The core pattern is the log-sum-exp trick:
- Find the maximum logit m=maxbhb.
- Subtract m from every logit to get shifted values hb−m.
- Exponentiate the shifted values and sum them.
- Take the natural log of that sum.
- Add m back to get the stable LSE.
- Subtract the LSE from the chosen action's logit ha to get logπ(a).
This is a standard numerical-stability technique used throughout machine learning (e.g., in cross-entropy loss, attention mechanisms, and softmax layers).
3. Step-by-Step Strategy
- Extract the max: Compute m=max(logits). This is a single pass over the array.
- Shift and exponentiate: For each logit hb, compute ehb−m. Because hb−m≤0, these values are bounded above by 1.
- Sum the exponentials: Accumulate ∑behb−m.
- Log and unshift: Compute m+log(sum) to obtain the stable LSE.
- Subtract for log-probability: Return logits[a]−LSE.
In code, this looks roughly like:
import numpy as np
def log_prob(logits, a):
m = np.max(logits)
shifted = logits - m
lse = m + np.log(np.sum(np.exp(shifted)))
return logits[a] - lse
The key insight is that you never compute ∑ehb directly; you always work with the shifted version.
4. Common Pitfalls
- Skipping the shift: Computing log(∑ehb) directly will overflow if any hb>∼709 (the log of the max float64) or underflow if all hb are very negative.
- Using the wrong max: The shift must be the maximum of the logits, not the mean or zero. Using a smaller shift can still cause overflow.
- Index confusion: Make sure you subtract the LSE from logits[a] (the chosen action's logit), not from the max or some other element.
- Integer logits: If logits is an integer array, logits - m stays integer and np.exp may behave unexpectedly. Cast to float first.
- Empty or single-element edge cases: With one action, the LSE equals that logit and logπ(a)=0, which is correct (π=1). Verify your implementation handles this gracefully.
5. Time & Space Complexity
- Time: O(n) where n is the number of actions (length of logits). You scan once for the max, once for the shifted exponentials, and once for the sum. These are all linear passes.
- Space: O(n) if you materialize the shifted array, or O(1) extra space if you compute the sum in a single loop without storing intermediate values. In practice, vectorized NumPy implementations use O(n) temporary storage.