一句文本怎样变成训练信号?因果语言模型的标签右移与 Next-Token Loss
从原始 token 序列出发,手算标签错位与交叉熵,追踪 logits、padding 和文档边界,并用 PyTorch 2.14 写出可检查的语言模型训练步。
上一篇用滑动窗口、块稀疏与全局 token 规定了长序列中“谁能读谁”。但一个注意力结构即使信息边完全正确,也不会自己知道该学什么。给定“机器 学习 很 有趣”,训练代码必须把每个前缀变成监督信号:看到“机器”预测“学习”,看到“机器 学习”预测“很”。
这就是因果语言建模(Causal Language Modeling, CLM)的下一个 token 预测(Next-Token Prediction)。本文只追踪一条主线:同一条 token 序列怎样错开一位成为输入与标签,模型输出的 [N,L,V] logits 又怎样变成一个可反传的交叉熵标量。
01 语言模型的训练样本到底是什么?#
设词表大小为 ,一段文本经分词器得到 。自回归分解(Autoregressive Factorization)把联合概率写成:
若序列前放置开头符 <BOS>,训练对齐就是:
模型输入 input_ids: [BOS, 机器, 学习, 很, 有趣]
监督标签 labels: [机器, 学习, 很, 有趣, EOS]
↑ 每一列预测右边那个 token
完整 tokens: [BOS, 机器, 学习, 很, 有趣, EOS] 长度 6
shift_logits: 位置 [0, 1, 2, 3, 4] 长度 5
shift_labels: id [机器, 学习, 很, 有趣, EOS] 长度 5text最后一个输入位置没有序列内的下一个 token,第一个 token 也没有更早位置产生的预测,所以有效配对数是 。
02 因果掩码与标签右移解决不同问题#
- 因果掩码(Causal Mask)限制隐藏状态 只能由 计算,防止读取未来;
- 标签右移(Label Shift)规定 的输出要与 比较。
input_ids [N,L] ─► embedding [N,L,D] ─► causal Transformer
│
▼
hidden [N,L,D] ─► linear W [V,D] ─► logits [N,L,V]
│ shift one step
▼
labels [N,L]text只有右移而没有因果 mask, 可偷看 ;只有因果 mask 而标签不右移,模型会学习复制当前位置。二者都正确,训练目标才和部署时“只给前缀、生成未来”一致。
03 logits 为什么不要先做 softmax?#
模型对位置 输出未归一化分数 。目标 token 为 时:
第二种写法可用 LogSumExp 稳定计算。PyTorch 2.14 当前官方 torch.nn.functional.cross_entropy ↗ 直接接收 logits;若先 softmax 再传入,函数会把概率误当 logits,数值稳定性和梯度都会变差。
对有效位置集合 ,。分母是有效 token 数,不是固定的 ;padding 或边界位置被忽略后,分母也要改变。
04 用三个词和三个 logits 手算一次#
假设词表为 [甲, 乙, 丙],某位置的 logits 为 ,目标 。归一化项为:
目标概率约为 ,损失 。梯度为:
梯度下降会提高“乙”的 logit,压低另外两项。若完全均匀猜测,loss 为 ; 时约为 10.82。这是有用的初始化基线:一开始远低于它,要检查标签泄漏;远高于它,要检查 logit 尺度和数值异常。
05 [N,L,V] 怎样交给 Cross-Entropy?#
import torch
import torch.nn.functional as F
def causal_lm_loss(
logits: torch.Tensor, # [N,L,V],未 softmax
token_ids: torch.Tensor, # [N,L],torch.long
*,
ignore_index: int = -100,
) -> torch.Tensor:
n, length, vocab = logits.shape
assert token_ids.shape == (n, length)
assert token_ids.dtype == torch.long
shift_logits = logits[:, :-1, :].contiguous() # [N,L-1,V]
shift_labels = token_ids[:, 1:].contiguous() # [N,L-1]
return F.cross_entropy(
shift_logits.view(-1, vocab), # [N(L-1),V]
shift_labels.view(-1), # [N(L-1)]
ignore_index=ignore_index,
reduction='mean',
)pythonview(-1, V) 前调用 contiguous(),因为切片后的张量不保证内存连续。也可以用 reshape,但仍应理解最终把每个有效位置当成一次 类分类。
06 padding 为什么不能当作普通词来学?#
A: [BOS, 甲, 乙, EOS, PAD, PAD]
B: [BOS, 丙, 丁, 戊, 己, EOS]text若直接使用所有 token id,A 会产生“EOS 后预测 PAD、PAD 后预测 PAD”的简单监督。模型被错误训练,长 padding 样本还会获得不成比例的权重。应把无需预测的位置替换成 ignore_index:
def labels_from_attention_mask(input_ids, attention_mask):
# input_ids/attention_mask: [N,L];1 表示真实 token
labels = input_ids.clone()
labels[attention_mask == 0] = -100
return labels
labels = labels_from_attention_mask(input_ids, attention_mask)
loss = causal_lm_loss(logits, labels)python输入侧 padding attention mask 仍要交给 Transformer,阻止真实 Query 读取 padding Key。注意力 mask 控制信息流,loss mask 控制计分位置,两者不能替代。
07 拼接文档时,边界是否应该产生监督?#
数据管道常把文档拼接再切成定长 block。若没有边界符:
文档 A 末尾 ... a_last | b_first ... 文档 B 开头
↑ 被迫学习 p(b_first | A)text至少应插入 <EOS>。是否训练 <EOS> → 下一文档首 token 取决于语义:连续章节可能允许跨块连续;无关网页通常应重置注意力,或忽略跨文档标签;packed sequence 中不同样本还必须使用块对角因果 mask,避免互相偷看。
def mask_cross_document_targets(labels, document_ids):
# 两者 [N,L];labels[:,t+1] 是 logits[:,t] 的目标
labels = labels.clone()
crosses = document_ids[:, 1:] != document_ids[:, :-1]
labels[:, 1:][crosses] = -100
return labelspython若保留 <EOS> 预测,要先决定忽略 a_last→EOS 还是 EOS→b_first,不要用一句“屏蔽边界”掩盖方向差异。
08 一个最小因果语言模型训练步#
from torch import nn
class TinyCausalLM(nn.Module):
def __init__(self, vocab_size: int, d_model: int, nhead: int):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=nhead,
dim_feedforward=4 * d_model,
batch_first=True, norm_first=True,
)
self.blocks = nn.TransformerEncoder(layer, num_layers=2)
self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
hidden = self.embedding(input_ids) # [N,L,D]
length = input_ids.size(1)
causal = nn.Transformer.generate_square_subsequent_mask(
length, device=input_ids.device
) # [L,L]
hidden = self.blocks(hidden, mask=causal, is_causal=True)
return self.lm_head(hidden) # [N,L,V]
model = TinyCausalLM(vocab_size=1000, d_model=128, nhead=4)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
logits = model(input_ids)
labels = labels_from_attention_mask(input_ids, attention_mask)
loss = causal_lm_loss(logits, labels) # scalar []
optimizer.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()python真实模型还要加入位置表示,并把 padding mask 传入 block;此处省略是为了突出 loss,不能直接当作 packed-batch 生产实现。
09 从语料到更新的完整伪代码#
for documents in corpus:
ids = tokenize(documents)
ids = insert_boundary_tokens(ids)
blocks, attention_mask, document_ids = pack(ids, max_length=L)
logits = causal_transformer(blocks) # [N,L,V]
labels = copy(blocks) # [N,L]
labels[padding_targets] = IGNORE
labels[cross_document_targets] = IGNORE
loss = CE(logits[:, :-1, :], labels[:, 1:])
backward(loss)
clip_gradients()
optimizer_step()text相同模型、相同 token 数,如果 block 长度、边界处理、重复数据和有效 token 比例不同,学到的条件分布也会不同。
10 困惑度该怎样从 loss 得到?#
若 loss 是按 token 平均的自然对数负似然,。loss 为 2 时困惑度约 7.39。它可理解为每一步的“有效候选规模”,但不能跨不同 tokenizer 直接比较。
跨 batch 也不能简单平均各 batch 的均值。应累加未约简 loss 与有效 token 数:
token_loss = F.cross_entropy(
shift_logits.reshape(-1, vocab),
shift_labels.reshape(-1),
ignore_index=-100,
reduction='sum',
)
valid_tokens = (shift_labels != -100).sum()
# 全部 batch 累加后
mean_nll = total_token_loss / total_valid_tokens
perplexity = torch.exp(mean_nll)python否则 padding 较多的小 batch 会和有效 token 很多的大 batch 获得相同权重。
11 怎样调试标签错位而不是猜?#
构造递增 id [10, 11, 12, 13],要求逐位置目标必须是 [11, 12, 13]:
- 打印
input_ids[:, :-1]与labels[:, 1:]; - 把正确目标 logit 设为 20、其余为 0,loss 应接近 0;
- 扰动未来 token,确认较早位置 logits 不变;
- 改变 padding token id,loss 必须不变;
- 全零 logits 的 loss 应接近 。
def perfect_logits(labels: torch.Tensor, vocab: int):
n, length = labels.shape
logits = torch.zeros(n, length - 1, vocab)
targets = labels[:, 1:].clamp_min(0)
logits.scatter_(2, targets[..., None], 20.0)
return logitspython对 -100 不能直接 scatter;这里 clamp_min(0) 只负责构造,最终 loss 仍用原始 labels 忽略对应位置。
12 常见的“loss 会降,但目标错了”#
| 错误 | 表面现象 | 真正后果 |
|---|---|---|
| logits 与同位置 token 比较 | loss 很快下降 | 学会复制输入 |
| 外部和模型内部各 shift 一次 | 也可能收敛 | 实际预测 |
| 先 softmax 再交叉熵 | 数值仍能运行 | 把概率当 logits |
| padding 未忽略 | loss 很漂亮 | 大量学习 PAD 转移 |
| batch loss 直接平均 | 曲线平滑 | 不同长度权重错误 |
| packed 文档共用普通 causal mask | 吞吐高 | 不相关样本泄漏 |
EOS 与 PAD 混用 | 生成异常 | 结束与补齐无法区分 |
13 训练与推理为何仍有差异?#
训练时使用教师强制(Teacher Forcing):位置 总能看到真实历史 ;推理时看到模型自己采样的历史。一次错误会改变后续条件,形成暴露偏差(Exposure Bias)。
Scheduled Sampling、序列级强化学习等方法试图缩小差异,却各自引入偏差或高方差。标准 next-token loss 的优势是目标明确、容易规模化、梯度稳定;它不保证事实正确、长程规划或人类偏好一致。
14 失败场景与相近目标#
- 语料包含错误、偏见或重复时,最大似然会拟合这些分布;
- 稀有但重要事实只贡献很少 token,平均 loss 对它不敏感;
- 多种合理表达只给一条参考时,目标只奖励其中一路;
- 长链推理按局部 token 计分,不直接优化最终任务成功;
- 低 loss 可能来自记忆训练文本,需去污染测试区分。
掩码语言模型从双向上下文恢复遮盖 token;序列到序列 Decoder 还条件于源序列;对比学习比较表示的相对相似性;偏好优化比较整段回答。它们都不能简单等同于从左到右 next-token loss。
15 今天真正需要记住什么?#
- 因果 mask 决定“能看什么”,标签右移决定“预测什么”。
[N,L,V]logits 与右移后的整数标签在有效位置做词表交叉熵。- padding、文档边界和 packed sequence 要分别处理信息 mask 与 loss mask。
- 验证指标必须按有效 token 加权;困惑度只在 tokenizer 和协议一致时可比。
16 思考题与小练习#
- 对
[BOS, A, B, EOS, PAD]写出shift_logits位置、shift_labels和有效 mask。若EOS也忽略,模型失去什么能力? - 构造 的两位置 logits,手算每项 loss、平均 loss 与 perplexity;再屏蔽一项,说明分母变化。
- 写单元测试识别“双重 shift”:让位置 的最高 logit 指向 ,比较正确标签和再右移标签的 loss。
相关工作#
- Bengio et al., A Neural Probabilistic Language Model ↗,早期神经自回归语言模型。
- Radford et al., Improving Language Understanding by Generative Pre-Training ↗,生成式预训练再微调。
- Radford et al., Language Models are Unsupervised Multitask Learners ↗,规模化 next-token 训练的任务行为。
- Kaplan et al., Scaling Laws for Neural Language Models ↗,模型、数据、计算与交叉熵的关系。
- Hoffmann et al., Training Compute-Optimal Large Language Models ↗,计算预算下参数与 token 配比。
17 下一篇预告#
next-token loss 已把每个位置变成一次 类预测,但词表可能有数万到数十万项,Embedding 与输出头随之成为巨大的参数和计算入口。下一篇将拆解子词分词:BPE 怎样从字符对合并出词表、未知词如何被组合,以及 tokenizer 为什么会改变长度、成本与 loss 的可比性。