Deep Learning · Module 5 of 26 · 8 concepts · about 17 min

Loss Functions

The precise error signal the optimiser is allowed to minimise, and how to make sure it measures what you meant.

At work: Telling a model what “wrong” means for the business: a missed defect, a bad price estimate, a wrong category.

Cross-entropy, binary cross-entropy and regression loss

What it is

A loss function converts a prediction and its target into a differentiable scalar that training can minimise. Cross-entropy penalises low probability assigned to the correct class and is the usual multiclass objective; binary cross-entropy handles one or more independent yes/no labels. MSE and MAE compare numeric targets, but differ in how strongly they punish a large residual: MSE squares it, while MAE treats every additional unit of error equally. Batch loss is usually the mean over examples, so its scale remains comparable as batch size changes.

Why it matters

A model does exactly what its loss rewards, not what its name suggests. The loss must match both the target encoding and the decision you care about; an excellent loss can still pair with an unsuitable business threshold or evaluation metric. Before training, state what one target represents, what one model output represents, and why reducing this scalar should improve the real outcome.

In the real world: Cross-entropy for classifying support tickets, binary cross-entropy for fraud yes/no, regression loss for delivery time.

Visual example

Loss

Cross-entropy strongly penalises being confidently wrong.

00.20.40.60.81012345probability assigned to the correct classcross-entropy lossconfidently wrongcorrect
import torch, torch.nn as nn

logits = torch.randn(32, 10)
labels = torch.randint(0, 10, (32,))
print(nn.CrossEntropyLoss()(logits, labels).item())   # multiclass, one label each

scores = torch.randn(32, 1)
tags = torch.randint(0, 2, (32, 1)).float()
print(nn.BCEWithLogitsLoss()(scores, tags).item())    # independent yes/no

print(nn.MSELoss()(torch.randn(32, 1), torch.randn(32, 1)).item())   # regression

Using MSE on class IDs such as 0, 1, 2. It implies class 2 is twice as far from class 0 as class 1 is, an arbitrary ordering that ordinary multiclass classification does not have.

What a loss function has to be

What it is

A loss is one scalar per batch, computed from predictions and targets, that is differentiable with respect to the model's parameters almost everywhere. 'One scalar' is a hard requirement of backward(): gradients are defined with respect to a single number, so a per-example loss vector must be reduced before you can call backward on it. 'Differentiable' rules out the things you actually care about — accuracy, F1, ranking position — because they are step functions whose gradient is zero wherever it exists.

Why it matters

This is why the loss and the metric are two different objects. You minimise cross-entropy and report accuracy; you minimise a smooth surrogate and evaluate the discrete decision. Forgetting that leads to the recurring question 'why is my loss falling while accuracy is flat?' — the answer is that the loss is being paid to make correct predictions more confident, which moves it without crossing any decision threshold.

In the real world: The loss must reflect what the business cares about, and be smooth enough for the model to learn from.

import torch, torch.nn as nn

logits, labels = torch.randn(8, 4, requires_grad=True), torch.randint(0, 4, (8,))

per_example = nn.CrossEntropyLoss(reduction="none")(logits, labels)
print(per_example.shape)          # torch.Size([8]) — not a scalar, cannot backward

loss = per_example.mean()         # now it is one number
loss.backward()

accuracy = (logits.argmax(dim=1) == labels).float().mean()
print(accuracy.requires_grad)     # True, but argmax's gradient is zero everywhere

Trying to optimise accuracy directly. argmax has a zero gradient, so (preds == labels).float().mean().backward() runs without complaint and updates nothing — the classic 'loss is stuck at exactly its initial value' report.

MSE for regression, worked

What it is

Mean squared error averages the squared residuals: mean((y_hat − y)²). Squaring makes every error positive and makes large errors dominate — a residual of 2 contributes sixteen times as much as a residual of 0.5. MAE (mean(|y_hat − y|)) treats each unit of error equally, and Huber/SmoothL1 behaves like MSE for small residuals and like MAE beyond a threshold, which is the usual compromise when outliers are real but shouldn't own the gradient.

Why it matters

The choice decides what the model optimises toward. MSE's optimum is the conditional mean, so it chases outliers; MAE's optimum is the conditional median, so it ignores them. On a dataset with a handful of mislabelled extreme targets, MSE will visibly distort the fit to reduce those few residuals, and the fix is the loss, not the model. MSE's gradient is also proportional to the residual, which is convenient: big errors push harder automatically.

In the real world: Training a model to estimate a used car's price, where big errors are punished much more than small ones.

Visual example

Regression loss

The same four predictions, two losses. One residual moves MSE and barely moves MAE.

Squared residuals

(what MSE averages)

mean((ŷ − y)²) vs mean(|ŷ − y|)
yŷresidualsquared
032.50.50.25
155.5-0.50.25
224-24
37611

If that row is an outlier rather than a signal, MSE will bend the model toward it.

Both reductions, worked

MSE = (0.25 + 0.25 + 4.0 + 1.0) / 4 = 1.375

MAE = (0.5 + 0.5 + 2.0 + 1.0) / 4 = 1.0

row 2 is 73% of MSE and 50% of MAE

import torch, torch.nn as nn

y = torch.tensor([3.0, 5.0, 2.0, 7.0])
y_hat = torch.tensor([2.5, 5.5, 4.0, 6.0])

print(nn.MSELoss()(y_hat, y).item())         # 1.375  — the 2.0 residual dominates
print(nn.L1Loss()(y_hat, y).item())          # 1.0
print(nn.SmoothL1Loss()(y_hat, y).item())    # 0.5625 — quadratic only near zero

Comparing MSE values across differently scaled targets. A loss of 1.4 is excellent for targets in the thousands and terrible for targets in [0, 1]; report a scale-free metric such as R² or MAE-over-target-std alongside it, or normalise the target.

Logits, not probabilities: why CrossEntropyLoss takes raw scores

What it is

nn.CrossEntropyLoss is log_softmax followed by negative log-likelihood, fused into one operation. It takes the model's raw logits of shape (batch, classes) and integer class indices of shape (batch,). The fusion exists for numerics: computing softmax and then taking its log means exponentiating potentially large numbers and then taking the log of potentially tiny ones, while log_softmax does it in one stable step by subtracting the max first.

Why it matters

Applying softmax yourself first is the most common silent bug in PyTorch classification code. Nothing raises: the probabilities are a valid (batch, classes) tensor, so the loss dutifully treats them as logits, exponentiates values already squashed into (0, 1), and produces a flattened objective whose gradients are much smaller than they should be. The model trains, slowly and worse, and the loss curve looks plausible. The corresponding pair on the binary side is BCEWithLogitsLoss (raw scores) versus BCELoss (probabilities) — use the first.

In the real world: A common bug that makes a classifier train badly without any error message.

Visual example

The logits trap

CrossEntropyLoss already contains a softmax. Feeding it probabilities applies two.

loss on logits [2.0, 1.0, 0.1], true class = 0
what you passwhat the loss seesloss
correct[2.0, 1.0, 0.1]log_softmax of logits0.417
double softmax[0.659, 0.242, 0.099]log_softmax of probabilities0.802

Common misconception

Wrong idea: passing probabilities is harmless because they are still ordered correctly.

Correct idea: the ordering survives; the scale does not. Squashed inputs give a flatter objective and much smaller gradients, so training slows down for a reason nothing reports.

import torch, torch.nn as nn

logits = torch.tensor([[2.0, 1.0, 0.1]])
label = torch.tensor([0])

print(nn.CrossEntropyLoss()(logits, label).item())          # 0.4170 — correct
print(nn.CrossEntropyLoss()(logits.softmax(dim=1), label).item())   # 0.8021 — wrong

# The equivalent, spelled out:
print(-logits.log_softmax(dim=1)[0, 0].item())             # 0.4170

Reading the second number above as 'a slightly worse loss'. It is a different objective. The gradient through a double-softmax is compressed toward zero, so the model learns at a fraction of the rate — and the bug is invisible unless you compare against a run without it.

Binary vs categorical cross-entropy

What it is

Binary cross-entropy scores each output independently: for one output and target t ∈ {0, 1}, it is −[t·log p + (1−t)·log(1−p)], and for K independent tags it is the mean of K such terms. Categorical cross-entropy assumes exactly one class is correct and scores only the true class's probability: −log p_true. In PyTorch the shapes differ accordingly — BCEWithLogitsLoss wants float targets shaped like the logits, CrossEntropyLoss wants int64 indices with no class dimension at all.

Why it matters

The choice encodes a factual claim about the labels, and the wrong claim caps performance. A multi-label problem run through softmax forces the tags to compete, so a document that genuinely belongs to three topics can never score high on all three. A single-label problem run through independent sigmoids discards the 'exactly one' constraint that would have sharpened the model, and permits outputs that say 'definitely a cat and definitely a dog'. Two classes are a special case that works either way: one sigmoid output, or a softmax over two logits.

In the real world: “Is this a defect?” uses one; “which of 12 defect types?” uses the other.

import torch, torch.nn as nn

# Multiclass: one label per example, integer indices.
logits = torch.randn(4, 3)
labels = torch.tensor([0, 2, 1, 1])
print(nn.CrossEntropyLoss()(logits, labels).item())

# Multi-label: a float 0/1 per tag, same shape as the logits.
tag_logits = torch.randn(4, 3)
tags = torch.tensor([[1.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 1.0]])
print(nn.BCEWithLogitsLoss()(tag_logits, tags).item())

Passing one-hot targets to CrossEntropyLoss out of habit. Recent PyTorch accepts them as a soft target distribution, so it will not raise — but if you also kept the class-index code path elsewhere, two parts of your codebase are now computing different losses on the same data.

Reduction and class weights

What it is

Every PyTorch loss takes reduction: "mean" (the default) averages over the batch, "sum" adds, and "none" returns one value per example. The default is what keeps the loss — and therefore the gradient scale, and therefore the usable learning rate — comparable when batch size changes. reduction="none" is the hook for anything per-example: masking padded positions, weighting hard examples, or inspecting which rows a model finds hardest. CrossEntropyLoss(weight=...) scales each class's contribution, and ignore_index drops a label entirely.

Why it matters

reduction="sum" couples the learning rate to the batch size: double the batch and every gradient doubles, so a rate that was stable becomes divergent. Class weights are the standard deep-learning answer to imbalance — cheaper than resampling and it keeps every example in the epoch — but they change what the loss means, so the reported number is no longer comparable with an unweighted run.

In the real world: Making rare but important cases, like a cracked part, count more during training.

Visual example

Reduction

The reduction is not cosmetic: it decides whether your learning rate still works at a new batch size.

the same 6-example batch, three reductions
returnsgradient scaleuse for
"mean"scalarindependent of batch sizethe default; always start here
"sum"scalargrows with batch sizegradient accumulation, carefully
"none"(6,) tensorn/a until reducedmasking, per-example weighting, analysis

Switching to "sum" without dividing the learning rate is a common cause of a run that diverged 'for no reason' after a batch-size change.

import torch, torch.nn as nn

logits, labels = torch.randn(6, 3), torch.tensor([0, 0, 0, 1, 1, 2])

per_example = nn.CrossEntropyLoss(reduction="none")(logits, labels)
print(per_example.shape, per_example.mean().item())   # (6,) and the default loss

# Upweight the rare classes: 6 / (3 * class_count)
weights = torch.tensor([0.67, 1.0, 2.0])
print(nn.CrossEntropyLoss(weight=weights)(logits, labels).item())

# Skip padding positions entirely, e.g. in a sequence task:
print(nn.CrossEntropyLoss(ignore_index=-100)(logits, labels).item())

Masking by multiplying the loss by 0/1 and then calling .mean(). The zeros are still counted in the denominator, so the loss shrinks as padding grows; divide by the mask's sum instead, or use ignore_index.

Practise it: Layer Norm, Backwards

Label smoothing

What it is

Label smoothing replaces the hard target — probability 1 on the true class, 0 elsewhere — with a slightly softened one: 1 − ε on the true class and ε/(K−1) spread over the rest, for a small ε such as 0.1. In PyTorch it is one argument: nn.CrossEntropyLoss(label_smoothing=0.1). The model is then rewarded for being mostly right rather than infinitely confident.

Why it matters

Hard targets ask for a logit gap that grows without bound, because −log p keeps rewarding more confidence forever. That drives overconfident, badly calibrated models whose 0.99 predictions are right far less than 99% of the time, and it makes the model brittle on noisy labels. Smoothing caps the reward for confidence, which usually improves calibration and slightly improves accuracy on large classification tasks. It is a regulariser applied to the *loss*, not to the weights or the activations — which makes it a useful contrast with weight decay and dropout in module 11.

In the real world: Stops an image model from being overconfident, which helps when some training labels are slightly wrong.

import torch, torch.nn as nn

logits = torch.tensor([[6.0, 0.0, 0.0]])   # very confident, and correct
label = torch.tensor([0])

print(nn.CrossEntropyLoss()(logits, label).item())                       # 0.0050
print(nn.CrossEntropyLoss(label_smoothing=0.1)(logits, label).item())    # 0.4049
# The smoothed loss is still paying attention: extra confidence no longer helps.

Leaving label smoothing on and then reading the loss as comparable to an unsmoothed run. Smoothing has a non-zero minimum — the loss cannot reach zero even with perfect predictions — so a 'worse' smoothed loss may be a better model.

Choosing a loss to match the problem

What it is

Work backwards from the decision. Name the decision the output feeds, the encoding of one target, and the cost of each kind of mistake; the loss follows almost mechanically. Scalar target → MSE, or SmoothL1 if outliers are real. Exactly one of K classes → cross-entropy on logits. Independent tags → BCE-with-logits. Skewed costs → class weights, or a threshold tuned after training rather than a different loss. Counts → Poisson; ordered categories → an ordinal formulation rather than either regression or plain classification.

Why it matters

The ML syllabus's problem-framing module makes the general argument: the loss is where your definition of a good answer gets written down, and a model will exploit any mismatch. What deep learning adds is that the mismatch is harder to notice, because a big network will happily achieve a low loss on the wrong objective and produce numbers that look like success. The deep-learning-specific discipline is to check the loss against the target dtype and shape before the first run, and to keep the reported metric separate from the optimised loss.

In the real world: A demand model that under-forecasts is costlier than one that over-forecasts; the loss should reflect that.

Visual example

Decision guide

The target's shape and dtype decide the loss. Get these two columns to agree and most loss bugs disappear.

problem → head → target → loss
output shapetargetloss
regression(B, 1)(B, 1) floatMSELoss / SmoothL1Loss
multi-class(B, K)(B,) int64CrossEntropyLoss
multi-label(B, K)(B, K) float 0/1BCEWithLogitsLoss
binary(B, 1)(B, 1) float 0/1BCEWithLogitsLoss

Why this matters

Two of the four rows share an output shape and differ only in the target. The target column is therefore the thing to check first when a loss raises or refuses to fall.

import torch.nn as nn

# One head per problem shape, and the loss it forces:
price = (nn.Linear(64, 1), nn.SmoothL1Loss())          # scalar, outliers expected
digit = (nn.Linear(64, 10), nn.CrossEntropyLoss())     # one of ten classes
topics = (nn.Linear(64, 20), nn.BCEWithLogitsLoss())   # up to 20 independent tags
fraud = (nn.Linear(64, 1), nn.BCEWithLogitsLoss(pos_weight=None))  # threshold later

Choosing the loss to match the metric you will report. Accuracy, F1 and AUC are not differentiable, so the loss is always a surrogate — optimise the surrogate, then tune the decision threshold against the metric on validation data.