Objective
Causal LM Loss Predicts The Next Token, Not The Current Token
A decoder-only language model consumes tokens from the left context and
predicts the next token at each position. The common implementation
pattern is to produce logits with shape [batch, time, vocab],
then compare logits[:, :-1] with
labels[:, 1:]. Hugging Face's causal language modeling
guide notes that its data collator can copy input_ids into
labels because the alignment shift is handled by the model
path. In hand-written PyTorch, make the shift explicit so tests can
catch off-by-one errors.
def shift_for_causal_lm(logits, labels):
# logits: [batch, time, vocab], labels: [batch, time]
return logits[:, :-1, :].contiguous(), labels[:, 1:].contiguous()
- One-token shift: position
tpredicts the label att + 1; the final logit has no next token target in the same sequence. - Do not double shift: if a framework model shifts internally, pass unshifted labels; if you own the loss function, shift exactly once.
- Teacher forcing: training sees the true previous tokens, while generation feeds back sampled tokens one step at a time.
Question: What is the symptom of shifting labels twice?
The model is trained to predict a token two positions ahead, so loss can stall even though shapes, dtype, and optimizer code all look valid.
Masking
Padding And Prompt Tokens Need An Explicit Loss Contract
PyTorch CrossEntropyLoss supports
ignore_index: ignored targets do not contribute to the loss
or input gradients. For language modeling, that is the clean way to
exclude padding tokens and any prompt positions that should condition
the model without being scored. Keep the attention mask and loss mask
separate: attention controls what the model can read, while
ignore_index controls which predictions count toward the
objective.
IGNORE_INDEX = -100
labels = input_ids.clone()
labels[attention_mask == 0] = IGNORE_INDEX
labels[:, :prompt_len] = IGNORE_INDEX # Optional for completion-only SFT.
- Mask padding: padded positions should not teach the model to emit pad tokens unless that is a deliberate objective.
- Mask prompts deliberately: for instruction tuning, score only the assistant/completion span when the prompt is context rather than target text.
- Average honestly: report loss over non-ignored targets, not over padded sequence length.
Question: Why is an attention mask not enough to ignore padding in the loss?
The attention mask can stop the model from reading padding, but the loss function will still score padded target positions unless their labels are set to the ignore index.
Sequence Packing
Pack Short Examples Without Letting Them Read Each Other
Packing improves token utilization by concatenating multiple short examples into one fixed-length training sequence. The contract is stricter than simple concatenation: labels must still predict the next token within the intended example, and the attention mask must prevent a later packed sample from using tokens from an unrelated earlier sample unless that boundary was deliberately bridged with an EOS objective. Hugging Face's CLM guide shows concatenating text into blocks for next-token training, while TRL and NVIDIA NeMo document packing as an efficiency feature that needs explicit packing metadata and masks.
import torch
def packed_causal_keep_mask(segment_ids):
# segment_ids: [time], same id means tokens belong to one packed example.
time = segment_ids.numel()
causal = torch.ones(time, time, dtype=torch.bool).tril()
same_segment = segment_ids[:, None] == segment_ids[None, :]
return causal & same_segment
segment_ids = torch.tensor([0, 0, 0, 1, 1, 2])
keep = packed_causal_keep_mask(segment_ids)
assert keep[4, 3] # sample 1 can read its own previous token
assert not keep[4, 2] # sample 1 cannot read sample 0
- Track segment ids: keep enough metadata to know which packed tokens came from the same source example or conversation.
- Mask attention boundaries: a plain lower-triangular mask allows cross-example leakage inside the same packed row.
- Mask loss boundaries: ignore boundary targets when the next token would come from a different example and should not be learned as a continuation.
- Test one tiny pack: assert allowed and blocked positions before trusting a fused attention backend or trainer packing flag.
Question: Why is EOS not a complete substitute for a packed attention mask?
EOS can teach the model that one document ended, but a later sample can still attend to earlier unrelated tokens unless the attention mask or packed-sequence backend blocks that path.
Sources: Hugging Face causal language modeling guide, Hugging Face TRL SFTTrainer packing docs, NVIDIA NeMo Megatron Bridge packed sequences, Hugging Face sequence packing note.
Implementation Drill
Write A Causal LM Loss That Fails Loudly
The interview-safe version is small but strict: assert shapes, shift
once, flatten only after alignment, and pass ignore_index
through to cross entropy. This mirrors the math while keeping the batch
and time dimensions inspectable until the final loss call.
import torch
import torch.nn.functional as F
IGNORE_INDEX = -100
def causal_lm_loss(logits, labels, ignore_index=IGNORE_INDEX):
if logits.ndim != 3 or labels.ndim != 2:
raise ValueError("expected logits [batch, time, vocab] and labels [batch, time]")
if logits.shape[:2] != labels.shape:
raise ValueError("logits and labels must share batch/time dimensions")
shift_logits = logits[:, :-1, :].contiguous()
shift_labels = labels[:, 1:].contiguous()
vocab = shift_logits.size(-1)
return F.cross_entropy(
shift_logits.view(-1, vocab),
shift_labels.view(-1),
ignore_index=ignore_index,
)
def test_causal_lm_loss_ignores_padding():
torch.manual_seed(0)
logits = torch.randn(2, 5, 11, requires_grad=True)
labels = torch.randint(0, 11, (2, 5))
labels[0, -2:] = IGNORE_INDEX
loss = causal_lm_loss(logits, labels)
assert torch.isfinite(loss)
loss.backward()
assert torch.isfinite(logits.grad).all()
- Test the alignment: create a tiny batch where the correct next token is obvious and prove the loss uses
labels[:, 1:]. - Test ignored rows: include padding or prompt-masked labels and confirm gradients remain finite.
- Test framework parity: if you switch to
AutoModelForCausalLM, verify whether the model shifts labels internally before reusing this helper.
Question: Why flatten after shifting instead of before?
Flattening first hides the time alignment. Shifting while tensors still have batch and time axes makes the next-token contract visible and easier to test.
Sources: Hugging Face causal language modeling guide, Hugging Face LLM course causal LM note, PyTorch CrossEntropyLoss reference.