Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°04

Softmax

Easy AI EngineeringLLMDecoding

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).

SOLVE IT YOURSELF

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.

YOUR TASK

Implement softmax(logits) → a probability distribution over the logits. Subtract the max first for numerical stability, exponentiate, then divide by the sum.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input logits = [0, 0]
Output [0.5, 0.5]
equal scores → uniform
EXAMPLE 2
Input logits = [2, 1, 0]
Output [0.6652409557748218, 0.24472847105479764, 0.09003057317038046]
higher logit → higher probability (about 0.665, 0.245, 0.090)
EXAMPLE 3
Input logits = [1000, 1000]
Output [0.5, 0.5]
the stable version must not overflow
EXAMPLE 4
Input logits = [-1000, -1000]
Output [0.5, 0.5]
edge case: without the shift every exp underflows to 0 and the division fails
CONSTRAINTS
  • 1 ≤ len(logits)
  • The output must sum to 1 (within floating-point tolerance).
  • Subtract max(logits) before exp — the numerically stable softmax.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Probabilities must be positive and sum to 1 — exponentiate, then normalize by the total.
  2. Find m = max(logits) and exponentiate logit - m. Shifting by a constant cancels out in the ratio but stops exp from overflowing.
  3. Sum the exponentials once, then divide each by that sum.
  4. 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.

Why exp and not simply dividing each score by the sum of the scores? Logits can be negative or sum to zero, so plain normalization can produce negative “probabilities” or divide by zero; exp maps every real number to a positive one. It is also strictly increasing, so a larger logit always gets a larger probability, and it turns differences into ratios: p_i / p_j = exp(x_i − x_j), so a logit gap of 1 always means a factor of e, wherever the pair sits on the number line. That ratio property is the shift invariance seen from the other side, and it is what makes the max subtraction free: only the gaps between logits carry information, never their absolute level.

logitsx − mexp(x − m)÷ total201.0000.6651-10.3680.2450-20.1350.090m = max = 2total = 1.503sum = 1top exponent is exp(0) = 1, so total ≥ 1 and nothing overflows
The reference on [2, 1, 0]. The max row (highlighted) becomes exactly exp(0) = 1, so total lies between 1 and n and the division is always defined. Each output is that row’s exponential over the one shared total, which is why softmax couples every entry.

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.

stepquantityvalue
1m = max(logits)2
2shifted logits x - m[0, -1, -2]
3exps[1.0, 0.36787944117144233, 0.1353352832366127]
4total1.5032147244080551
5e / total[0.6652409557748218, 0.24472847105479764, 0.09003057317038046]
6sum of the output0.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) raises OverflowError, 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 than log(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’s max raises ValueError, 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 throw RangeError past 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 to inf and the result becomes nan.
  • 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 T first: 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 as T approaches 0 it approaches greedy argmax. Keep the max shift on the scaled values, because a small T makes 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 of log(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 m and a running sum s of exp(x - m). When a new value exceeds m, rescale the sum first, s = s * exp(m - x_new) + 1, then make it the new maximum; otherwise add exp(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)) with u uniform 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.

Training. A classifier is trained with cross-entropy, -log p[y] for the true class y, and paired with softmax its gradient with respect to the logits is simply p − onehot(y): push up the true class by how much probability it is missing, push every other class down by the probability it took. That clean gradient is a large part of why softmax plus cross-entropy is the default pairing, and why frameworks compute the two together from the logits rather than from the probabilities.

Attention. Transformer attention takes softmax(QKᵀ / √d_k) over the keys for each query. The √d_k divisor exists because dot products of longer vectors have larger magnitude, and large logits drive softmax toward a one-hot output where the gradient almost vanishes; dividing keeps the logits in a range where the distribution is still soft. A causal mask sets future positions to negative infinity before the softmax, which the max shift handles fine as long as at least one position in the row is unmasked, since exp(-inf - m) is exactly 0.

Decoding. Temperature, top-k and top-p all act around this function: temperature rescales the logits before it, top-k keeps the k largest logits and renormalizes, and top-p keeps the smallest set of tokens whose probabilities reach p. Each of those filters needs the same stable normalization you just wrote.

Check yourself

Predict softmax([5, 5, 3]) to three decimals before running it.

[0.468, 0.468, 0.063]. After the shift the exponentials are 1, 1 and exp(-2) ≈ 0.135, so the total is about 2.135; the two tied logits split almost all the mass equally and the third keeps about 6%.

Sampling at temperature 0.5 divides [2, 1, 0] by 0.5 first. What happens to the top probability, 0.665?

It rises to about 0.867: the reference on [4, 2, 0] returns [0.867, 0.117, 0.016]. Halving the temperature doubles every gap, and since p_i / p_j = exp(x_i − x_j), the ratio of the top two goes from e to e², so the distribution sharpens toward the argmax.

A colleague shifts by min(logits) instead of max(logits), reasoning that any constant cancels. Which input breaks it?

Any input whose spread exceeds about 709, such as [1000, 0]. The constant does cancel mathematically, but shifting by the minimum makes every exponent at least 0, so the largest becomes exp(1000) and Python raises OverflowError: math range error. Only the max guarantees every exponent is at most 0.

Greedy decoding only needs the index of the most likely token. Do you need softmax at all?

No. Softmax is strictly increasing in each logit, so the argmax of the probabilities is the argmax of the logits: one O(n) pass with no exp and no division. You need the full distribution only to sample, apply top-p, or compute a loss.

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.0
Reference 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.

▶  Watch it explained
Finished this one? 0 / 76 Challenges done
NextCross-Entropy Loss Next on the “Model internals” ladder · rung 2 of 6

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.