Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The previous lesson predicted one continuous scalar: extra decode latency in milliseconds. Half squared error could say "raise the prediction by four units." A review triage bot faces a different challenge: pick exactly one category among bug, docs, and security.
Suppose the incident note reads, "This docs page is stale," while the model emits raw scores bug=3.0, docs=1.0, security=0.0. It picks bug, missing the correct label. What training signal should backpropagation send back through the network?
A binary right-or-wrong check can't distinguish a narrow near-miss from an arrogant blunder. Assigning docs an 11% probability should cost significantly more than assigning it 49%, even if bug wins the top spot in both runs. We need a mathematical bridge that turns unbounded classification scores into honest probability shares, converts missed confidence into an exact penalty, and sends back a gradient that pulls the correct score upward while pushing wrong favorites down.
A decision begins as three raw scores
The final linear layer of a neural network outputs one real number per possible class. These numbers are logits: unbounded scores produced by taking dot products between internal representations and class weight vectors. They don't represent probabilities yet, and they aren't constrained between zero and one.
| Label | Current logit | What the incident note says |
|---|---|---|
bug | 3.0 | Model's current favorite, but incorrect |
docs | 1.0 | Correct supervised target |
security | 0.0 | Plausible alternative, incorrect here |
A larger logit means the network prefers that label over competitors. A score of 3.0 doesn't mean 300% confidence. Read the table as an uncalibrated ranking: bug leads docs by two score points, but we haven't assigned any percentage share to any option.
Logits can be negative, zero, or wildly positive without breaking mathematics. Probabilities, by contrast, must satisfy two strict Kolmogorov axioms: every probability must lie in , and the entire set of mutually exclusive choices must sum to exactly .
Predict the three checks in inspect-raw-logits.py before running it. bug takes the highest score, the raw sum is , and the list fails the probability test. We've got a ranking, but not a normalized distribution that an information-theoretic loss can evaluate.
1labels = ["bug", "docs", "security"]
2logits = [3.0, 1.0, 0.0]
3
4best_index = max(range(len(logits)), key=logits.__getitem__)
5print("largest logit:", labels[best_index])
6print("raw score sum:", sum(logits))
7print("valid probability distribution:", all(0 <= z <= 1 for z in logits) and sum(logits) == 1)1largest logit: bug
2raw score sum: 4.0
3valid probability distribution: FalseThe raw scores identify a winner, but they don't yet say how much probability the correct label received.
Softmax turns score gaps into probability mass
Softmax maps an unconstrained vector of real numbers into a valid categorical probability distribution. It operates in two deliberate stages: exponentiation and normalization.
First, it raises Euler's constant to each logit. Because for every real number , exponentiation eliminates negative values and guarantees positivity. At the same time, exponentiation magnifies differences: a constant score lead of expands into an exponential multiplier of .
Second, it sums those positive weights into a single shared denominator, called the partition function:
Dividing each exponential weight by this sum forces the output shares to sum to .
For our three triage scores, walk through the arithmetic: , , and . The shared sum is .
| Label | Logit | Unnormalized weight | Probability share |
|---|---|---|---|
bug | 3.0 | 20.09 | 0.844 |
docs | 1.0 | 2.72 | 0.114 |
security | 0.0 | 1.00 | 0.042 |
| Total | 23.81 | 1.000 |
The general equation applies this two-step transformation to any vector :
Here is the probability assigned to class , is its logit, and is the number of competing classes. Notice the distribution: the model assigns 0.844 to the incorrect bug label, leaving only 0.114 for the correct docs label. That value is the handoff to the loss function.
![Visual progression of softmax and log-sum-exp stabilization: raw logits [3.0, 1.0, 0.0] are shifted by max logit 3.0 to [0.0, -2.0, -3.0], exponentiated to bounded weights [1.000, 0.135, 0.050] summing to 1.185, and normalized into probability mass 84.4 percent bug, 11.4 percent docs, and 4.2 percent security.](/cdn/content-image/preparation/softmax-cross-entropy-optimization/illustrations/_generated/softmax_logits_dark.png?v=6de5a78ce3d4)
Before running stable-softmax.py, confirm the invariants: the class ranking stays preserved, every entry is strictly positive, and the values sum to 1.0.
1import math
2
3def stable_softmax(logits: list[float]) -> list[float]:
4 peak = max(logits)
5 weights = [math.exp(z - peak) for z in logits]
6 total = sum(weights)
7 return [weight / total for weight in weights]
8
9labels = ["bug", "docs", "security"]
10logits = [3.0, 1.0, 0.0]
11probabilities = stable_softmax(logits)
12
13for label, probability in zip(labels, probabilities):
14 print(f"{label:8s} {probability:.3f}")
15print("sum ", round(sum(probabilities), 3))1bug 0.844
2docs 0.114
3security 0.042
4sum 1.0The correct label is docs, but its probability is only 0.114. Why can't the model fix this by simply tweaking the post-softmax numbers directly?
Answer
Probabilities are consequences of logits and compete for a fixed budget of 1.0. Learning updates parameters, which changes the logits. Pushing the docs logit upward relative to bug is what increases its probability on the next forward pass.
Stable arithmetic prevents floating-point overflow
Our gentle logits [3.0, 1.0, 0.0] fit easily inside standard floating-point variables. What happens when a deep network produces raw scores like [1000.0, 998.0, 997.0]?
In IEEE 754 single-precision floating-point (float32), numbers overflow once they exceed approximately . Because , any logit greater than overflows to +inf. In half-precision formats (float16 and bfloat16), the dynamic range is even tighter: float16 overflows at , which corresponds to . Logits as modest as trigger +inf.
Once an exponential overflows to +inf, the denominator evaluates to +inf. Dividing inf / inf yields NaN (Not a Number), poisoning every downstream weight tensor in your model.
Softmax possesses a key algebraic property: shift invariance.[1] Adding or subtracting any arbitrary scalar constant from every logit leaves the resulting probabilities completely unchanged:
The common factor factors out of the sum and cancels between numerator and denominator. This identity unlocks the classic numerical stabilization trick: choose , the maximum logit across the vector.
Subtracting shifts the entire logit vector so that the largest value becomes exactly zero (). Every other shifted logit is strictly negative (). Exponentiating non-positive numbers guarantees:
- The largest exponential is . Overflow is mathematically impossible.
- Every other exponential lies in .
- The denominator sum is at least , preventing division by zero.
Very negative shifted logits (such as ) will underflow to floating-point 0.0. That underflow is benign during the forward pass: a weight of 0.0 simply means that candidate receives zero probability share.
1import math
2
3def stable_softmax(logits: list[float]) -> list[float]:
4 peak = max(logits)
5 weights = [math.exp(z - peak) for z in logits]
6 total = sum(weights)
7 return [weight / total for weight in weights]
8
9large_logits = [1000.0, 998.0, 997.0]
10try:
11 _ = [math.exp(z) for z in large_logits]
12 naive_ok = True
13except OverflowError:
14 naive_ok = False
15
16stable = stable_softmax(large_logits)
17print("naive overflowed:", not naive_ok)
18print("stable:", [round(probability, 3) for probability in stable])
19print("stable sum:", round(sum(stable), 3))1naive overflowed: True
2stable: [0.844, 0.114, 0.042]
3stable sum: 1.0NumPy's naive exponential returns inf, followed by NaN when dividing by the infinite sum. Shifting by the maximum value first eliminates overflow while returning the exact same probability distribution.
Cross-entropy prices missed predictions through surprise
Softmax gave us a probability distribution where docs received . How do we score that prediction?
Cross-entropy measures the quality of a predicted distribution against ground truth. It's grounded in Maximum Likelihood Estimation (MLE) and information theory.
In information theory, observing an event with probability yields an information content, or surprise, of:
We use natural logarithms throughout, measuring surprise in nats (using base-two logarithms would measure surprise in bits). If an event is certain (), observing it brings zero surprise: . If an event is extremely rare (), observing it brings near-infinite surprise: nats.
When training a supervised model, the ground truth for an input is a target distribution . For single-label classification, is a one-hot vector where the correct class index gets weight and all competing classes get . For our triage note:
Cross-entropy calculates the expected surprise across all classes under the true distribution:
Because for all non-target classes and for the target, every term except the correct class multiplies by zero. Cross-entropy collapses to the Negative Log-Likelihood (NLL) of the true target class:
For our incident note where :
If the network had assigned 0.844 to docs, the loss would be only nats. If it had placed 0.999 on docs, the loss would plummet to nats.
The log-sum-exp formulation prevents underflow crashes
Computing p = softmax(z) and then calling math.log(p[c]) introduces a subtle failure mode. If a logit is very negative (for example, logits = [1000.0, 0.0, -1.0] with target docs), . In floating-point arithmetic, underflows to 0.0. Calling log(0.0) produces -inf, crashing training.
The loss can be expressed directly in terms of raw logits, bypassing intermediate probabilities entirely.[1] Expanding :
The first term is log-sum-exp. We stabilize it using the same maximum shift :
Substituting this back into the loss expression:
Grouping first prevents catastrophic cancellation. When and , the first term evaluates cleanly to , while the log-sum evaluates to . The loss returns without ever taking the logarithm of zero.
1import math
2
3def cross_entropy_from_logits(logits: list[float], target_index: int) -> float:
4 peak = max(logits)
5 log_total = math.log(sum(math.exp(z - peak) for z in logits))
6 return (peak - logits[target_index]) + log_total
7
8ordinary = [3.0, 1.0, 0.0]
9shifted_high = [z + 1000.0 for z in ordinary]
10
11print("ordinary docs loss:", round(cross_entropy_from_logits(ordinary, 1), 3))
12print("large-offset loss: ", round(cross_entropy_from_logits(shifted_high, 1), 3))
13print("underflowed target weight:", math.exp(-1000.0))
14print("finite target loss:", cross_entropy_from_logits([1000.0, 0.0, -1.0], 1))
15print("equal huge scores:", round(cross_entropy_from_logits([1e20, 1e20, 1e20], 1), 3))
16assert math.isclose(cross_entropy_from_logits([1e20] * 3, 1), math.log(3))1ordinary docs loss: 2.17
2large-offset loss: 2.17
3underflowed target weight: 0.0
4finite target loss: 1000.0
5equal huge scores: 1.099Another sample gets 0.80 probability on its correct class. Which sample contributes more loss: that sample or our docs sample at 0.114?
Answer
Our docs sample contributes far more loss. With its unrounded probability, is roughly nats, while is only nats. Lower target probabilities create exponentially harsher penalties.
Entropy is reference uncertainty and KL divergence is prediction error
Deepen the distinction between entropy, cross-entropy, and Kullback-Leibler (KL) divergence.
Entropy measures the inherent uncertainty already present inside a reference probability distribution :
Cross-entropy measures the total cost of representing distribution using model distribution . Kullback-Leibler divergence measures the extra penalty caused specifically by the mismatch between and :
For our one-hot docs target, has zero entropy (): the supervised answer is completely deterministic. Every fraction of a nat in our loss comes from model error ().
When targets are soft (such as in distillation or human annotations with disagreement, like ), the reference distribution has inherent entropy nats. Even an ideal model () will report a loss of nats, with . Don't mistake inherent target uncertainty for model failure.
1from math import exp, log
2
3def entropy(probabilities: list[float]) -> float:
4 return sum(-p * log(p) for p in probabilities if p > 0)
5
6def cross_entropy(target: list[float], predicted: list[float]) -> float:
7 return -sum(p * log(q) for p, q in zip(target, predicted) if p > 0)
8
9weights = [exp(value) for value in [3.0, 1.0, 0.0]]
10predicted = [weight / sum(weights) for weight in weights]
11one_hot_target = [0.0, 1.0, 0.0]
12uncertain_target = [0.5, 0.5, 0.0]
13
14one_hot_loss = cross_entropy(one_hot_target, predicted)
15matched_loss = cross_entropy(uncertain_target, uncertain_target)
16
17print(f"one-hot target entropy: {entropy(one_hot_target):.3f}")
18print(f"one-hot cross-entropy: {one_hot_loss:.3f}")
19print(f"one-hot KL mismatch: {one_hot_loss - entropy(one_hot_target):.3f}")
20print(f"soft target entropy: {entropy(uncertain_target):.3f}")
21print(f"matching cross-entropy: {matched_loss:.3f}")
22print(f"matching KL mismatch: {matched_loss - entropy(uncertain_target):.3f}")1one-hot target entropy: 0.000
2one-hot cross-entropy: 2.170
3one-hot KL mismatch: 2.170
4soft target entropy: 0.693
5matching cross-entropy: 0.693
6matching KL mismatch: 0.000A non-zero loss doesn't mean the model is broken if the labels themselves carry irreducible noise. The decomposition separates target entropy from model prediction error.
The combined gradient reveals a clean error signal
Loss tells us how poorly the model performed. To update weights, backpropagation needs the gradient: the partial derivative of the loss with respect to each logit, .
Softmax and cross-entropy create one of the cleanest gradient simplifications in machine learning.[1]
Recall the loss in terms of raw logits:
Differentiate with respect to any logit :
By the chain rule, the derivative of is . With :
The derivative of the log-sum-exp term is softmax itself.
Meanwhile, the second term equals if (the target class) and otherwise. That is exactly the target weight :
Vectorized across all classes:
Predicted probability minus true target. Look at the numbers for our triage example:
| Label | Predicted probability | Target weight | Logit gradient | Gradient descent update |
|---|---|---|---|---|
bug | 0.844 | 0 | +0.844 | Lowers wrong favorite |
docs | 0.114 | 1 | -0.886 | Raises correct target |
security | 0.042 | 0 | +0.042 | Lowers minor competitor |
The zero-sum conservation property
Sum all elements of the gradient vector:
The gradients sum to zero. The upward pull on the correct class () is exactly balanced by the downward push on competing classes (). Softmax distributes a fixed unit of probability mass; learning to favor one class inevitably pulls mass away from others.

In a complete neural network, backpropagation channels this residual back to shared weights via the chain rule. Testing on logits directly makes the local mechanic transparent.
1import math
2
3def probabilities_and_loss(logits: list[float], target_index: int) -> tuple[list[float], float]:
4 peak = max(logits)
5 shifted = [z - peak for z in logits]
6 log_total = math.log(sum(math.exp(value) for value in shifted))
7 log_probs = [value - log_total for value in shifted]
8 probabilities = [math.exp(value) for value in log_probs]
9 return probabilities, -log_probs[target_index]
10
11labels = ["bug", "docs", "security"]
12logits = [3.0, 1.0, 0.0]
13target_index = labels.index("docs")
14target = [1.0 if index == target_index else 0.0 for index in range(3)]
15
16before, before_loss = probabilities_and_loss(logits, target_index)
17gradient = [probability - label for probability, label in zip(before, target)]
18after_logits = [z - 0.5 * slope for z, slope in zip(logits, gradient)]
19after, after_loss = probabilities_and_loss(after_logits, target_index)
20
21print("gradient:", [round(value, 3) for value in gradient])
22print("docs probability:", round(before[1], 3), "->", round(after[1], 3))
23print("loss:", round(before_loss, 3), "->", round(after_loss, 3))1gradient: [0.844, -0.886, 0.042]
2docs probability: 0.114 -> 0.23
3loss: 2.17 -> 1.469Confirm the mathematical slope against numerical finite differences: nudge each logit by and check the symmetric difference quotient.
1import math
2
3def loss(logits: list[float], target_index: int) -> float:
4 peak = max(logits)
5 shifted = [z - peak for z in logits]
6 return math.log(sum(math.exp(value) for value in shifted)) - shifted[target_index]
7
8logits = [3.0, 1.0, 0.0]
9target_index = 1
10peak = max(logits)
11shifted = [z - peak for z in logits]
12total = sum(math.exp(value) for value in shifted)
13probabilities = [math.exp(value) / total for value in shifted]
14analytic = [
15 probability - (1.0 if index == target_index else 0.0)
16 for index, probability in enumerate(probabilities)
17]
18
19epsilon = 1e-5
20numeric = []
21for index in range(3):
22 plus = logits.copy()
23 minus = logits.copy()
24 plus[index] += epsilon
25 minus[index] -= epsilon
26 slope = (loss(plus, target_index) - loss(minus, target_index)) / (2 * epsilon)
27 numeric.append(slope)
28
29print("analytic:", [round(value, 6) for value in analytic])
30print("numeric: ", [round(value, 6) for value in numeric])
31print("match:", all(math.isclose(left, right, abs_tol=1e-6) for left, right in zip(analytic, numeric)))1analytic: [0.843795, -0.885805, 0.04201]
2numeric: [0.843795, -0.885805, 0.04201]
3match: TrueWhy squared probability error saturates and stalls
Why not use Mean Squared Error (MSE) on predicted probabilities: ?
To see why MSE fails for classification, examine how gradients flow through the softmax Jacobian matrix:
When differentiating cross-entropy via the multivariate chain rule:
The factor has in the denominator, which cancels the in the softmax Jacobian numerator.
Under MSE, the derivative with respect to probability is . There is no denominator to cancel the softmax Jacobian:
When the model is confidently wrong (for instance, and ), the softmax derivative term vanishes. The gradient flattens out, shrinking the correction by three orders of magnitude. The network enters a saturated plateau and stops learning.
With cross-entropy, the gradient on the target logit is , delivering a massive, full-strength corrective kick.
1import math
2
3def softmax(logits: list[float]) -> list[float]:
4 peak = max(logits)
5 weights = [math.exp(z - peak) for z in logits]
6 total = sum(weights)
7 return [weight / total for weight in weights]
8
9def squared_probability_loss(logits: list[float], target: list[float]) -> float:
10 error = [probability - label for probability, label in zip(softmax(logits), target)]
11 return 0.5 * sum(value * value for value in error)
12
13logits = [8.0, 0.0, 0.0]
14target = [0.0, 1.0, 0.0]
15probabilities = softmax(logits)
16ce_gradient = [probability - label for probability, label in zip(probabilities, target)]
17
18epsilon = 1e-5
19mse_gradient = []
20for index in range(3):
21 plus = logits.copy()
22 minus = logits.copy()
23 plus[index] += epsilon
24 minus[index] -= epsilon
25 slope = (
26 squared_probability_loss(plus, target)
27 - squared_probability_loss(minus, target)
28 ) / (2 * epsilon)
29 mse_gradient.append(slope)
30
31print("probabilities:", [round(value, 4) for value in probabilities])
32print("cross-entropy gradient:", [round(value, 4) for value in ce_gradient])
33print("squared-probability gradient:", [round(value, 4) for value in mse_gradient])1probabilities: [0.9993, 0.0003, 0.0003]
2cross-entropy gradient: [0.9993, -0.9997, 0.0003]
3squared-probability gradient: [0.001, -0.0007, -0.0003]The numerical gap is striking: cross-entropy provides an aggressive gradient around , while squared probability error stalls with a feeble signal around .
PyTorch expects unnormalized logits directly
In PyTorch, nn.CrossEntropyLoss expects unnormalized logits, not probabilities.[2]
Internally, PyTorch fuses log_softmax with negative log-likelihood (NLLLoss). Fusing them performs the stable log-sum-exp trick in a single optimized GPU kernel and avoids materializing intermediate probabilities in memory.
If you mistakenly insert an nn.Softmax() activation before nn.CrossEntropyLoss, PyTorch treats those already-normalized probabilities as logits. It applies log-softmax a second time: . The code won't crash because tensor shapes match, but the objective function is corrupted and gradients shrink drastically.
1import torch
2from torch import nn
3
4logits = torch.tensor([[3.0, 1.0, 0.0]], requires_grad=True)
5target = torch.tensor([1]) # docs
6loss_fn = nn.CrossEntropyLoss()
7
8loss = loss_fn(logits, target)
9loss.backward()
10probabilities = torch.softmax(logits.detach(), dim=1)
11
12print("probabilities:", [round(value, 3) for value in probabilities[0].tolist()])
13print("loss:", round(loss.item(), 3))
14print("gradient:", [round(value, 3) for value in logits.grad[0].tolist()])1probabilities: [0.844, 0.114, 0.042]
2loss: 2.17
3gradient: [0.844, -0.886, 0.042]Now reproduce the common bug: softmax first, then CrossEntropyLoss. The shapes match, so Python raises no warning. Compare the target gradients.
1import torch
2from torch import nn
3
4target = torch.tensor([1]) # docs
5loss_fn = nn.CrossEntropyLoss()
6
7correct_input = torch.tensor([[3.0, 1.0, 0.0]], requires_grad=True)
8correct_loss = loss_fn(correct_input, target)
9correct_loss.backward()
10
11wrong_input = torch.tensor([[3.0, 1.0, 0.0]], requires_grad=True)
12wrong_loss = loss_fn(torch.softmax(wrong_input, dim=1), target)
13wrong_loss.backward()
14
15print("raw logits loss:", round(correct_loss.item(), 3))
16print("probabilities passed as logits:", round(wrong_loss.item(), 3))
17print("correct docs gradient:", round(correct_input.grad[0, 1].item(), 3))
18print("distorted docs gradient:", round(wrong_input.grad[0, 1].item(), 3))1raw logits loss: 2.17
2probabilities passed as logits: 1.387
3correct docs gradient: -0.886
4distorted docs gradient: -0.127The distorted gradient reveals the defect: the corrective gradient for docs shrank from to . Leave your final classification layer linear during training, and feed raw logits directly into nn.CrossEntropyLoss. Apply softmax only during inference when a client or caller explicitly requests probabilities.
Temperature scaling shapes inference entropy
During training, we optimize the network at temperature . During inference or knowledge distillation, scaling logits by a positive temperature parameter alters distribution entropy:
Dividing logits by changes how sharply probabilities concentrate:
- When , logit differences expand. The leading class absorbs more probability mass. As , softmax approaches an argmax one-hot distribution (greedy selection).
- When , logit differences shrink. Mass spreads more evenly across competitors. As , the distribution approaches uniform uncertainty ().
Temperature doesn't alter class ranking: the highest logit before scaling remains the highest logit after scaling. Temperature is a decoding and calibration control, not a parameter update.
In knowledge distillation, Hinton et al. use high temperature ( to ) to reveal soft relationships between non-target classes (so-called "dark knowledge").[3] In large language model serving, temperature modulates sampling diversity.
| Temperature | bug () | docs () | security () | Interpretation |
|---|---|---|---|---|
0.5 | 0.980 | 0.018 | 0.002 | Sharpened distribution; wrong winner reinforced |
1.0 | 0.844 | 0.114 | 0.042 | Original learned model distribution |
2.0 | 0.629 | 0.231 | 0.140 | Flattened distribution; alternatives gain mass |
1import math
2
3def softmax_at_temperature(logits: list[float], temperature: float) -> list[float]:
4 if not math.isfinite(temperature) or temperature <= 0:
5 raise ValueError("temperature must be finite and positive")
6 scaled = [z / temperature for z in logits]
7 peak = max(scaled)
8 weights = [math.exp(z - peak) for z in scaled]
9 total = sum(weights)
10 return [weight / total for weight in weights]
11
12logits = [3.0, 1.0, 0.0]
13for temperature in (0.5, 1.0, 2.0):
14 probabilities = softmax_at_temperature(logits, temperature)
15 print(f"T={temperature:.1f}", [round(probability, 3) for probability in probabilities])1T=0.5 [0.98, 0.018, 0.002]
2T=1.0 [0.844, 0.114, 0.042]
3T=2.0 [0.629, 0.231, 0.14]If docs is correct but bug is currently the highest logit, does lowering temperature repair the model?
Answer
No. Lowering temperature sharpens the current ranking, strengthening the mistaken bug preference to 98%. Training changes logits by following loss gradients; temperature only rescales probabilities generated from current logits.
Label smoothing curbs overconfident logits
Training models on hard one-hot targets introduces a structural hazard: overconfidence.
With a hard target , minimizing cross-entropy loss requires driving . In softmax, only occurs when:
Because logits are linear combinations of weights and activations, the optimizer continuously drives weight norms to grow larger and larger. The network becomes dogmatically overconfident, degrades its calibration, and memorizes noise in the training labels.
Label smoothing resolves this by softening the hard target distribution.[4] Given smoothing parameter (typically ) and classes:
For our 3-class triage problem with target docs (), , and :
- Target class:
- Competitor classes:
- Competitor classes:
What logit gap does the model need now to achieve zero gradient? Instead of an infinite score difference, the target probability reaches equilibrium when:
The model only needs a modest, finite gap of logits. If weights push higher so that , the gradient becomes positive, pushing the target logit back down. Label smoothing acts as an automatic brake on runaway weights.
PyTorch provides built-in label smoothing via nn.CrossEntropyLoss(label_smoothing=0.1).
1import torch
2from torch import nn
3
4logits = torch.tensor([[3.0, 1.0, 0.0]], requires_grad=True)
5target = torch.tensor([1]) # docs
6alpha = 0.1
7num_classes = 3
8
9loss_fn = nn.CrossEntropyLoss(label_smoothing=alpha)
10loss = loss_fn(logits, target)
11loss.backward()
12
13probs = torch.softmax(logits.detach(), dim=1)[0]
14smoothed_target = torch.tensor([
15 alpha / num_classes,
16 (1.0 - alpha) + (alpha / num_classes),
17 alpha / num_classes,
18])
19manual_loss = -torch.sum(smoothed_target * torch.log(probs))
20grad_manual = probs - smoothed_target
21
22print("probabilities: ", [round(x, 3) for x in probs.tolist()])
23print("smoothed target: ", [round(x, 3) for x in smoothed_target.tolist()])
24print("pytorch loss: ", round(loss.item(), 3))
25print("manual loss: ", round(manual_loss.item(), 3))
26print("logit gradient: ", [round(x, 3) for x in logits.grad[0].tolist()])
27print("manual gradient: ", [round(x, 3) for x in grad_manual.tolist()])1probabilities: [0.844, 0.114, 0.042]
2smoothed target: [0.033, 0.933, 0.033]
3pytorch loss: 2.137
4manual loss: 2.137
5logit gradient: [0.81, -0.819, 0.009]
6manual gradient: [0.81, -0.819, 0.009]The output verifies the mathematical correspondence: PyTorch's label-smoothed loss matches manual NLL against exactly, and the gradient equals .
Next-token prediction extends the loss across sequences
Our triage classifier evaluated one decision. Autoregressive language models evaluate the exact same categorical decision at every sequence position.
Given input context , a language model emits hidden states . The language model head projects against an embedding matrix to produce vocabulary logits , where is vocabulary size. The observed next token provides the supervised target.
For a sequence of length , the sequence loss is the mean negative log-likelihood across all predicted positions:
Reuse our running numbers with a tiny vocabulary: ["bug", "docs", "today"].
- Position 1 predicts
docs() from logits[3.0, 1.0, 0.0]. Target probability is , producing nats. - Position 2 predicts
today() from logits[0.5, 0.0, 2.0]. Target probability is , producing nats. - Sequence mean loss: nats.

Notice the proportion: position 1 accounts for of the sequence loss.
Tensor contracts and vocabulary memory scaling
In PyTorch, a language model batch emits logits shaped [B, S, V] (batch size, sequence length, vocabulary size) and targets shaped [B, S]. PyTorch's multi-dimensional cross-entropy expects classes on dimension 1: [B, V, S]. Alternatively, reshape both into 2D tensors: [B * S, V] with targets [B * S].
In modern language models with vocabularies between and tokens (such as Llama 3 or GPT-4o), materializing full 3D logit tensors [B, S, V] creates severe VRAM bottlenecks. At batch size , sequence length , and , a float32 logit tensor requires over 33 GB of GPU memory just for the scores. Production pipelines use chunked cross-entropy or fused GPU kernels that stream hidden states in small tiles, computing log-sum-exp and gradients in SRAM without ever writing the full vocabulary matrix to global GPU memory.
1import torch
2from torch import nn
3
4logits = torch.tensor([[[3.0, 1.0, 0.0], [0.5, 0.0, 2.0]]])
5targets = torch.tensor([[1, 2]]) # docs, today
6loss_fn = nn.CrossEntropyLoss()
7
8class_axis_loss = loss_fn(logits.transpose(1, 2), targets)
9flattened_loss = loss_fn(
10 logits.reshape(-1, logits.size(-1)),
11 targets.reshape(-1),
12)
13
14print("logits shape:", tuple(logits.shape))
15print("targets shape:", tuple(targets.shape))
16print("class-axis loss:", round(class_axis_loss.item(), 3))
17print("flattened loss:", round(flattened_loss.item(), 3))1logits shape: (1, 2, 3)
2targets shape: (1, 2)
3class-axis loss: 1.238
4flattened loss: 1.238The same reduction can be implemented without PyTorch using standard library loops:
1import math
2
3def per_position_cross_entropy(logits: list[list[float]], targets: list[int]) -> list[float]:
4 losses = []
5 for row, target_index in zip(logits, targets):
6 peak = max(row)
7 log_total = math.log(sum(math.exp(z - peak) for z in row))
8 losses.append((peak - row[target_index]) + log_total)
9 return losses
10
11logits = [[3.0, 1.0, 0.0], [0.5, 0.0, 2.0]]
12targets = [1, 2]
13losses = per_position_cross_entropy(logits, targets)
14
15print("position losses:", [round(value, 3) for value in losses])
16print("mean loss:", round(sum(losses) / len(losses), 3))1position losses: [2.17, 0.306]
2mean loss: 1.238Diagnostic failure modes in production pipelines
When classification or language modeling runs misbehave, trace errors systematically from arithmetic through interface contracts:
| Symptom | Probable cause | Diagnostic check and remediation |
|---|---|---|
Loss evaluates to NaN or OverflowError occurs | Direct logit exponentiation without max-subtraction | Subtract before exponentiating; verify inputs don't already contain NaN |
Loss outputs +inf on initial steps | Calling log(softmax(z)) when a target probability underflows to zero | Use log-sum-exp directly on logits: (m - z_c) + log(sum(exp(z - m))) |
| Plausible loss but slow or stalled convergence | Manual softmax layer placed before nn.CrossEntropyLoss | Pass unnormalized logits directly into nn.CrossEntropyLoss |
| Confidently wrong predictions don't correct | Loss function switched to MSE on probabilities | Use cross-entropy; verify logit gradients scale as rather than saturated values |
| Runaway weights and extreme overconfidence | Training with hard one-hot labels on noisy data | Add label smoothing (label_smoothing=0.1) to cap logit divergence |
| Sampling generates garbled or uniform text | Inference temperature set too high () | Lower temperature () or use top-p nucleus sampling |
| Sequence loss hides severe per-token failures | Mean reduction washes out individual token spikes | Log unreduced per-token losses alongside sequence means |
Diagnose issues in strict dependency order: numerical stability first, interface contract second, gradient sign third, decoding controls fourth, and sequence reduction last.
Try a second target
Experiment with the running example: switch the correct label to security () while keeping logits [3.0, 1.0, 0.0].
Before running the code, calculate the expected loss and gradient by hand:
- Target probability: .
- Loss: nats.
- Gradient :
bug: (pushed down)docs: (pushed down)security: (pulled up strongly) Notice the zero-sum balance: . Subtracting raises thesecuritylogit while suppressing both competitors.
What values should the security-target experiment produce before the update?
Answer
The probabilities remain [0.844, 0.114, 0.042], because changing labels doesn't alter the forward pass. The loss jumps to approximately 3.170 nats. The gradient becomes [0.844, 0.114, -0.958]. Subtracting that gradient raises security rather than docs.