Five Tasks, Five Pairings: The Table That Decides Your Model’s Last Two Layers
The final layer and the loss are one decision, not two. The output layer defines what numbers the network emits; the loss defines what those numbers mean. When they disagree, training still runs — gradients are computed, the loss goes down, and the model quietly optimizes the wrong objective. So start from the target tensor and work backwards, never from a favorite layer.
| Task | Final layer | Loss (PyTorch) | Target tensor | Example |
|---|---|---|---|---|
| Regression, unbounded | Linear(d, 1), no activation | MSELoss / HuberLoss | float (N, 1) | house price |
| Regression, bounded in [0, 1] | Linear(d, 1) + Sigmoid | BCELoss or MSE | float in [0, 1] | normalized score |
| Binary, one label per example | Linear(d, 1), raw logit | BCEWithLogitsLoss | float 0/1, shape (N, 1) | churn yes/no |
| Multi-label, several tags per example | Linear(d, K), raw logits | BCEWithLogitsLoss | multi-hot float (N, K) | article tags |
| Multiclass, one label per example | Linear(d, K), raw logits | CrossEntropyLoss | LongTensor indices (N,) | topic of article |
| Count data | Linear(d, 1) | PoissonNLLLoss | non-negative float (N, 1) | deliveries per day |
Two columns in that table surprise people. First, logits — the raw pre-activation values. Modern PyTorch classification losses take logits and fold the sigmoid or softmax inside a numerically stable fused kernel, so your model’s forward pass ends at the Linear layer with no activation before the loss. When you need probabilities for reporting, thresholds, or calibration, apply torch.sigmoid(logits) or torch.softmax(logits, dim=1) after the fact. And since softmax is monotone, argmax over logits equals argmax over its output — accuracy needs no activation either.
Three questions walk the table and you can answer them about any new request in under a minute: is the target a number or a class; are the classes mutually exclusive; is there one label per example or many. A bounded number takes a squashing output, an unbounded number takes a bare linear one, classes take a K-way head with a log-likelihood from the cross-entropy family, and counts take PoissonNLL, derived from the Poisson distribution’s likelihood rather than a distance. Decide the pairing on paper before the first epoch — in production a wrong pairing trains happily and only shows up as a metric that sits slightly, inexplicably off.
Second, the target formats. CrossEntropyLoss expects a LongTensor of class indices, shape (N,), not one-hot rows — hand it float targets and you get an error about the expected scalar type Long, or in newer versions a silent reinterpretation as soft labels you did not intend. BCEWithLogitsLoss expects float targets in {0, 1} with the same shape as the logits; a (N,) versus (N, 1) mismatch triggers broadcasting that quietly changes the loss rather than failing. The multiclass versus multi-label split is decided by the loss, not the architecture: softmax forces the K outputs to compete for a total probability of 1, which encodes mutually exclusive classes, while sigmoid scores each output independently, which is what lets an article be both sports and finance.
For regression, two pairing notes earn their keep in practice. An unbounded target needs a linear output — put a sigmoid on a price model and you have capped every prediction at 1, a bug that looks like terrible training rather than a wrong layer. Standardize targets to zero mean and unit variance before MSE, or the initial loss starts in the millions and the first updates are dominated by scale instead of signal. And when the data has outliers, MSE squares them into dominating gradients; Activation Functions covered why squashing units saturate, and here the analogous fix is HuberLoss — quadratic near zero, linear in the tails, so a handful of wild points cannot hijack a batch.
Cross-Entropy From Raw Logits: The Three Terms PyTorch Computes For You
For K classes with logits z and the true class index y, categorical cross-entropy is the negative log probability of the true class:
L = −log p_y where p = softmax(z)
expanded: L = −z_y + log Σ_j exp(z_j)
Work it on real numbers: logits (2.0, 1.0, 0.1) with true class 0. exp(2) = 7.389, exp(1) = 2.718, exp(0.1) = 1.105, summing to 11.212, so p = (0.659, 0.242, 0.099) and L = −log(0.659) = 0.417. Sanity checks worth memorizing: perfectly separated logits give p_y ≈ 1 and L ≈ 0, while identical logits give a uniform distribution and L = log K (log 3 ≈ 1.10 for three classes). The loss punishes confidence that is wrong, and only confidence.
Now the reason every serious implementation looks different from the formula. The naive version overflows:
def naive_ce(logits, y): # do not ship this
p = np.exp(logits) / np.exp(logits).sum()
return -np.log(p[y])
naive_ce(np.array([800.0, 799.0]), 0) # nan — exp(800) overflows float64
The correct loss is finite here: class 0 is only e^1 ≈ 2.7 times more likely than class 1, so L = log(1 + e^(−1)) = 0.313. Two facts rescue it. Softmax is invariant to adding a shared constant to every logit, and log-sum-exp can be computed stably by subtracting the maximum first:
def stable_ce(logits, y):
z = logits - logits.max() # largest entry becomes 0
lse = np.log(np.exp(z).sum()) # log-sum-exp of shifted logits
return -z[y] + lse
stable_ce(np.array([800.0, 799.0]), 0) # 0.3133 — finite, correct
That invariance is worth stating plainly: adding the same constant to every logit changes nothing in the loss — only differences between logits matter. A model can let its logits drift to enormous magnitudes during training with zero effect on the objective, which is exactly the regime where the naive formula dies and the shifted one does not.
PyTorch runs this fused computation, and the surrounding habits follow from it:
logits = model(xb) # final layer is Linear(d, K) — no softmax
loss = F.cross_entropy(logits, yb) # yb: LongTensor of class indices, shape (N,)
pred = logits.argmax(dim=1) # softmax would not change argmax
The beginner detour to know by name: putting a Softmax layer inside the model and calling CrossEntropyLoss anyway. Recent PyTorch accepts it — it log-softmaxes whatever you hand it — but you have already softmaxed, and the double squish compresses the logit differences that carry the learning signal. Gradients shrink, training crawls, and the loss curve still looks plausible, which is what makes the bug expensive. The rule to quote: the model emits logits; the loss owns the non-linearity. Softmax happens once — inside the loss during training, or after inference when you need probabilities — never both before and inside.
The same formula explains why this loss trains so well. Its gradient with respect to each logit is p_j − 1{j = y}: the true class’s logit is pushed up by (1 − p_y), every other logit is pulled down by its own probability. Confident and correct means near-zero gradient; wrong and confident means gradient of magnitude near 1. Training ends when the softmax saturates on the true class, not when the logits stop growing — logits can drift arbitrarily large at no cost to the objective, a property the next chapter’s failure tour exploits.
One more detail you will meet in real code: reduction. F.cross_entropy averages over the batch by default, so the logged loss is a per-example mean; reduction='none' returns the per-example vector when you must weight or mask examples yourself, and reduction='sum' fits gradient-accumulation schemes that defer the optimizer step. Mind the scale when you switch — gradients are proportional to the loss, so a sum-reduced loss on a large batch silently raises the effective learning rate.
Pairings That Train Anyway: Double Softmax, MSE Classification, and Imbalance Defaults
Mismatched pairings almost never crash. They converge — to the wrong place, at the wrong speed, with a loss curve that looks busy. Three mismatches you should be able to name and repair on the spot.
MSE with a sigmoid output for classification is the classic. Its gradient with respect to the logit is (p − y)·σ′(z), and σ′ = p(1 − p) vanishes whenever the sigmoid saturates — including when it is confidently wrong, which is precisely when the example carries the most information. Cross-entropy cancels that derivative: its gradient with respect to the logit is simply p − y. Same representable predictions, completely different training dynamics, which is why the cross-entropy family is the default and MSE on 0/1 labels shows up mostly in legacy code. The double-softmax mistake from the previous chapter belongs in the same family: a mismatch that still trains, just slower, while the loss says nothing is wrong.
The second mismatch is direction, not speed: sigmoid-plus-BCE on mutually exclusive classes, or softmax-plus-CE on multi-label data. Softmax outputs must sum to 1, so feeding them to BCEWithLogitsLoss asks each one to be independently 0 or 1 — the objectives contradict each other, per-class losses stop being comparable, and the model receives conflicting gradients for every multi-tag example. Choose the pairing from the label structure: can two labels be true at once? Then sigmoid and BCEWithLogits. No? Then softmax and cross-entropy.
The third is imbalance, where the default loss is quietly wrong. With a 1% positive rate, an all-negative predictor scores 99% accuracy and its gradients for the positive class barely register. Two knobs rebalance the objective without touching the data:
# multiclass where class 1 is ~20x rarer than the others
crit = nn.CrossEntropyLoss(weight=torch.tensor([1.0, 20.0, 1.0]))
# binary with the same imbalance
crit = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([20.0]))
pos_weight multiplies the positive term of each example’s loss, so false negatives on the rare class dominate the gradient and the decision boundary shifts toward catching them. Be precise about the side effect: a weighted loss optimizes a reweighted distribution, so the probabilities that come out are no longer calibrated to the real class prior — recalibrate before presenting them as risks to anyone downstream. Focal loss, which down-weights easy well-classified examples, is the research-grade extension for extreme imbalance, but weighted cross-entropy is the answer that gets you the follow-up question. Whatever you weight, verify the calibration afterwards: bucket validation predictions by stated confidence and compare each bucket’s mean predicted probability against its actual frequency — systematic overshoot on the rare class is the signature of a reweighted loss living in a world where that class is commoner than it is. Isotonic or Platt calibration on a held-out set repairs the numbers without retraining, turning a weighted model’s outputs back into risks you can quote to a product manager.
Overconfidence rounds out the failure tour. With enough capacity, logits grow without bound, predicted confidence approaches 1.0, and a single mislabeled example near the boundary generates an enormous loss that distorts its whole batch. Two standard mitigations: label smoothing, nn.CrossEntropyLoss(label_smoothing=0.1), which spreads a little probability mass to wrong classes and measurably improves calibration for a fraction of a percent of accuracy; and regularization — weight decay plus early stopping — which keeps logit magnitudes in check for the reasons the previous chapter’s weight-growth caveat predicted.
Close with the drill that turns this topic into reflex. A product interviewer says: predict house price, churn probability, article topic, article tags. Your answers, in order: Linear(1) with Huber or MSE; Linear(1) with BCEWithLogitsLoss; Linear(K) with CrossEntropyLoss; Linear(K) with BCEWithLogitsLoss. Say them without pausing — then watch the same question reappear as the loss.backward() call in Backpropagation and the Training Loop.
Quiz
1. You build a multi-label tagger where an article can carry several tags at once. Which final-layer and loss pairing is correct?
2. CrossEntropyLoss receives logits (2.0, 1.0, 0.1) with true class 0. What loss value does it compute?
3. Why does nn.CrossEntropyLoss take raw logits instead of softmax probabilities?
4. Class 1 is 20x rarer than the rest and the model predicts class 0 for everything. What is the smallest correct intervention?
Flashcards
Question
What tensor format does nn.CrossEntropyLoss expect for its targets, and what does the model emit?
Click to reveal answer
Answer
Targets are a LongTensor of integer class indices with shape (N,) — not one-hot rows. The model emits (N, K) raw logits; the loss applies log-softmax internally. Passing float targets raises an expected-scalar-type-Long error (or is silently read as soft labels).
Question
Give the four canonical output-layer and loss pairings.
Click to reveal answer
Answer
Unbounded regression: Linear(d, 1) + MSE or Huber. Binary single-label: Linear(d, 1) + BCEWithLogitsLoss. Multiclass single-label: Linear(d, K) + CrossEntropyLoss. Multi-label: Linear(d, K) + BCEWithLogitsLoss. The label structure — bounded? exclusive? — picks the row.
Question
Compute the cross-entropy for logits (2, 1, 0.1) with true class 0, and name the stable formula.
Click to reveal answer
Answer
CE = −z_y + log Σ_j exp(z_j) = −2 + log(11.212) = 0.417. Stable implementations subtract the max logit before exponentiating (log-sum-exp trick); the loss is invariant to adding a constant to all logits, so only logit differences matter.
Question
Why must the model end in raw logits rather than a softmax before CrossEntropyLoss?
Click to reveal answer
Answer
The loss already computes log-softmax internally with a max-shift for stability. Softmaxing first double-squashes the probabilities, compressing logit differences so gradients shrink and training slows — while the loss curve still looks plausible. Emit logits; softmax once, inside the loss or after inference.
Question
How do you handle a 20x class imbalance without resampling, and what is the side effect?
Click to reveal answer
Answer
Weight the loss: CrossEntropyLoss(weight=...) scales per-class terms; BCEWithLogitsLoss(pos_weight=...) multiplies the positive term for binary tasks. Side effect: the model optimizes the reweighted distribution, so its probabilities are no longer calibrated to the real prior and may need recalibration.
Revision Notes
Key Takeaways
- 1. The output layer and the loss are one decision: derive both from the target tensor’s structure — boundedness, exclusivity, arity — not from habit
- 2. Modern PyTorch losses consume logits and fuse the activation inside for stability; the model’s forward pass ends at Linear with no softmax before the loss
- 3. Categorical cross-entropy is −z_y + log-sum-exp(z); stable implementations subtract the max logit, and the loss ignores any constant added to all logits
- 4. Cross-entropy’s gradient wrt logits is p − y — no saturation factor — which is why sigmoid-plus-MSE trains so much worse on classification
- 5. Class weighting (weight=, pos_weight=) fixes imbalance without resampling, but the model then optimizes a reweighted distribution and its probabilities need recalibration
Interview Tips
- • "Predict house price / churn / topic / tags — pick the layers and losses" — answer all four from the label structure without pausing: Linear+Huber, Linear+BCEWithLogits, Linear(K)+CE, Linear(K)+BCEWithLogits; speed here reads as shipped experience
- • "Why does CrossEntropyLoss take logits?" — give the fused log-softmax and the max-shift stability argument, then name the double-softmax symptom: plausible loss curve, crawling training
- • "Your binary model outputs confident wrong predictions that never correct — diagnose it" — sigmoid-plus-MSE saturating: σ′(p(1−p)) kills the gradient exactly when the error is largest; switch to BCEWithLogits
- • "How do you handle 1% positive class rate?" — weighted loss first, mention focal loss as the escalation, and add the calibration caveat; the caveat is what separates candidates who have used the trick from those who have read about it
Cheat Sheet
Output Layers and Loss Pairings
The pairing table
| Task | Final layer | Loss | Target |
|---|---|---|---|
| Unbounded regression | Linear(d,1) | MSE / Huber | float (N,1) |
| Binary | Linear(d,1) logit | BCEWithLogitsLoss | float 0/1 (N,1) |
| Multiclass (exclusive) | Linear(d,K) logits | CrossEntropyLoss | Long indices (N,) |
| Multi-label | Linear(d,K) logits | BCEWithLogitsLoss | multi-hot float (N,K) |
| Counts | Linear(d,1) | PoissonNLLLoss | non-negative float |
Cross-entropy math
L = −log softmax(z)_y = −z_y + log Σ exp(z_j)
logits (2, 1, 0.1), class 0: −2 + log(11.212) = 0.417
stable form: subtract max logit first; loss invariant to z + c·1
gradients wrt logits: dL/dz_j = softmax(z)_j − 1{j = y} (p − y)
Rules that prevent the classic bugs
- Model emits logits; the loss owns the log-softmax/sigmoid — softmax twice shrinks gradients
- CrossEntropyLoss targets: LongTensor indices (N,), never one-hot floats
- BCEWithLogits targets: floats 0/1, same shape as logits — watch (N,) vs (N,1) broadcasting
- MSE + sigmoid for classification: gradient (p−y)·σ′(z) dies on saturation; CE cancels σ′
When defaults fail
- Imbalance: weight=torch.tensor([...]) (multiclass) / pos_weight= (binary); probabilities need recalibration after
- Overconfidence: label_smoothing=0.1, weight decay, early stopping
- Regression outliers: HuberLoss instead of MSE; standardize targets first