Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Softmax
The function at the end of every classifier and language model: turn a vector of raw scores (logits) into a probability distribution. The catch every AI engineer learns the hard way — do it the numerically stable way so big logits do not overflow.
The problem
Given a list of logits (raw real-valued scores), return the softmax: a list of the same length where each value is exp(logit) / Σ exp(logits). The result is a probability distribution — every value in [0, 1] and the whole thing sums to 1. Subtract the max logit before exponentiating so large values do not overflow (this does not change the result).
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement softmax(logits) → a probability distribution over the logits. Subtract the max first for numerical stability, exponentiate, then divide by the sum.
Examples and constraints 4 examples
logits = [0, 0][0.5, 0.5]logits = [2, 1, 0][0.6652409557748218, 0.24472847105479764, 0.09003057317038046]logits = [1000, 1000][0.5, 0.5]logits = [-1000, -1000][0.5, 0.5]- 1 ≤ len(logits)
- The output must sum to 1 (within floating-point tolerance).
- Subtract
max(logits)beforeexp— the numerically stable softmax.
Hints 4 ideas
- Probabilities must be positive and sum to 1 — exponentiate, then normalize by the total.
- Find
m = max(logits)and exponentiatelogit - m. Shifting by a constant cancels out in the ratio but stopsexpfrom overflowing. - Sum the exponentials once, then divide each by that sum.
- Sanity check: all-equal logits give a uniform distribution.
Approach, complexity & discussion — open after you solve
Start from brute force
The formula as written: exps = [math.exp(x) for x in logits], then divide each by sum(exps). That is O(n) time and O(n) space, and exact for modest logits, but it fails at the extremes that real logits reach. math.exp(1000) raises OverflowError in Python and is Infinity in JavaScript, where infinity over infinity gives NaN. At the other end, every exp(-1000) underflows to 0.0, and dividing by a zero total raises ZeroDivisionError. A slower variant recomputes the sum inside the loop, once per element, which is O(n²) for the same wrong answers.
The approach
Shift first, then exponentiate. The reference softmax computes m = max(logits), then exps = [math.exp(x - m) for x in logits] and total = sum(exps), and returns [e / total for e in exps]. Subtracting a constant changes nothing mathematically, because exp(x − m) = exp(x) · exp(−m) and the common factor exp(−m) cancels between every numerator and the denominator. Numerically it changes everything: every exponent is now at most 0, so the largest term is exactly exp(0) = 1 and nothing can overflow, and total is at least 1, so the division is always defined.
Very negative shifted logits can still underflow to 0.0, and that is harmless: their true probability is below the smallest positive double, about 5e-324. The output keeps the order of the logits, is unchanged when a constant is added to all of them, and sums to 1 up to rounding. The TypeScript version does the same three steps with Math.max(...logits) and reduce.
Walk through an example
For the second example, [2, 1, 0], the shift makes the largest exponent exactly 0, and the result sums to 1 up to the last bit.
| step | quantity | value |
|---|---|---|
| 1 | m = max(logits) | 2 |
| 2 | shifted logits x - m | [0, -1, -2] |
| 3 | exps | [1.0, 0.36787944117144233, 0.1353352832366127] |
| 4 | total | 1.5032147244080551 |
| 5 | e / total | [0.6652409557748218, 0.24472847105479764, 0.09003057317038046] |
| 6 | sum of the output | 0.9999999999999999, which is 1 within floating-point tolerance |
Complexity
Time O(n): one pass for the max, one to exponentiate, one to sum and one to divide. Space O(n) for exps and the output; overwriting the input in place would make the extra space O(1). The unshifted version has the same O(n) time and space but overflows or underflows on large logits, and recomputing the sum per element makes it O(n²).
Edge cases to test
[1000, 1000]→[0.5, 0.5]. Unshifted,math.exp(1000)raisesOverflowError, and the JavaScript version returns[NaN, NaN].[-1000, -1000]→[0.5, 0.5]. Unshifted, both exponentials underflow to 0.0 and the division by a zero total fails.[1000, 0]→[1.0, 0.0].exp(-1000)underflows to exactly 0.0, so a probability that is positive in theory prints as 0. Use log-softmax rather thanlog(softmax(x)), which would give negative infinity.[2, 1, 0]and[3, 2, 1]return identical lists. Adding a constant to every logit never changes the softmax; multiplying them, as temperature does, does change it.[]: Python’smaxraisesValueError, while the TypeScript version returns[]. The constraints exclude it, but the two languages disagree.- A very large array in TypeScript:
Math.max(...logits)passes every element as a separate argument and can throwRangeErrorpast the engine’s argument limit. A plain loop for the max avoids it.
Common mistakes
- Skipping the max-subtraction —
exp()of a large logit overflows toinfand the result becomesnan. - Dividing by
n(the count) instead of the sum of the exponentials. - Treating it element-wise — softmax is defined over the whole vector, so the denominator couples every entry.
Follow-ups an interviewer asks next
- Add a temperature parameter.
- Divide every logit by
Tfirst:softmax([x / T for x in logits]). A temperature below 1 sharpens the distribution toward the largest logit, above 1 flattens it toward uniform, and asTapproaches 0 it approaches greedy argmax. Keep the max shift on the scaled values, because a smallTmakes them large enough to overflow. - You need log-probabilities for a loss.
- Compute
x - m - log(sum(exp(x_j - m)))directly, the log-sum-exp trick, instead oflog(softmax(x)). The direct form never takes the log of an underflowed 0.0, so[1000, 0]gives log-probabilities of 0 and -1000 instead of 0 and negative infinity. Deep learning libraries fuse log-softmax with the negative log-likelihood loss for this reason. - The logits arrive in chunks and you cannot hold them all.
- Keep a running maximum
mand a running sumsofexp(x - m). When a new value exceedsm, rescale the sum first,s = s * exp(m - x_new) + 1, then make it the new maximum; otherwise addexp(x - m). One pass yields the normalizer, and this online softmax is the core of FlashAttention. - You only need to sample one token, not the whole distribution.
- The Gumbel-max trick samples from
softmax(x)without normalizing: add independent Gumbel noise,-log(-log(u))withuuniform in (0, 1), to every logit and take the argmax. It is exact, needs no normalizing sum, and parallelizes well over a large vocabulary.
Where this shows up
Softmax is the output layer of essentially every classifier and language model (a distribution over the next token) and sits inside attention (a distribution over the keys). The max-subtraction trick is a classic “implement it from scratch” interview check precisely because forgetting it is the bug that silently produces nan in real training code.
Reference solution
Reference solution — Python
import math
def softmax(logits):
m = max(logits) # shift for numerical stability
exps = [math.exp(x - m) for x in logits]
total = sum(exps)
return [e / total for e in exps]
print(softmax([2, 1, 0])) # -> [0.665..., 0.245..., 0.090...]
print(sum(softmax([2, 1, 0]))) # -> 1.0Reference solution — TypeScript
function softmax(logits: number[]): number[] {
const m = Math.max(...logits); // shift for numerical stability
const exps = logits.map((x) => Math.exp(x - m));
const total = exps.reduce((a, b) => a + b, 0);
return exps.map((e) => e / total);
}
console.log(softmax([2, 1, 0])); // -> [0.665..., 0.245..., 0.090...]Questions about Softmax
Why do you subtract the max in softmax?
To keep the exponentials in floating-point range without changing the result. Softmax divides exp of each logit by the sum of all of them, and exp grows so fast that a logit of 1000 overflows double precision: Python raises OverflowError, and JavaScript returns Infinity, which turns the output into NaN. Subtracting the largest logit from every logit multiplies every term, top and bottom, by the same factor, so it cancels. Afterward the largest exponent is exactly 0, its exponential is 1, and the sum is at least 1, so neither overflow nor division by zero can happen.
What is the difference between softmax and sigmoid?
Sigmoid squashes one score into a probability between 0 and 1, while softmax turns a whole vector of scores into probabilities that sum to 1. They agree in the two-class case: the softmax of [x, 0] gives the first class 1 divided by 1 plus exp of negative x, which is exactly the sigmoid of x. Use softmax when exactly one of several classes is correct, such as the next token. Use an independent sigmoid per label when several labels can be true at once, as in tagging, because their probabilities should not compete.
Does adding a constant to the logits change the softmax?
No. Adding the same constant c to every logit multiplies every exponential by exp(c), which cancels between each numerator and the shared denominator, so [2, 1, 0] and [3, 2, 1] produce identical probabilities. This shift invariance is exactly what makes the max-subtraction trick safe. Multiplying the logits is different: scaling them by a factor greater than 1 sharpens the distribution toward the largest logit, and scaling by less than 1 flattens it, which is what a sampling temperature does when it divides the logits by T.
Why does softmax sometimes return exactly 0 or exactly 1?
Because of floating-point limits, not because the math says so. The smallest positive double is about 5e-324, close to exp of -745, so any shifted logit below about -745 underflows to 0.0. For [1000, 0] the second exponential is exp of -1000, which becomes 0.0, and the result prints as [1.0, 0.0] even though the true second value is tiny but positive. That is harmless for sampling, but taking the log afterward gives negative infinity, so losses should use log-softmax, which never takes the log of an underflowed zero.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- Cosine SimilarityThe measure behind every embedding search and RAG system: how aligned are two vectors, ignoring their length? Dot product over the product of magnitudes — 1 identical, 0 orthogonal, -1 opposite. Solve it in Python or TypeScript.Read →
- Top-K RetrievalThe core of the "R" in RAG: given a query embedding and a set of document embeddings, return the indices of the k most similar docs by cosine similarity, with a stable tie-break. Solve it in Python or TypeScript.Read →
- Token-Level F1The metric behind QA evaluation (SQuAD and friends): how well does a predicted answer overlap a reference as a bag of words? Compute token precision and recall, then their harmonic-mean F1. Solve it in Python or TypeScript.Read →