读完这篇,你能做什么
训练固定循环序列,检查标签对应、未来输入改动不影响已有前缀输出和损失下降,并手算带忽略位置的下一 token 损失。
学习理由、准备要求与知识点
为什么学它
标签放错位置,或训练时直接读到了未来 token,损失可能很好看,模型却没有学会真正的下一 token 预测。
先读这些内容
知识点清单
- 条件概率分解
- 输入标签右移关系
- logits的B,T,V形状
- teacher forcing
- 因果训练与自回归生成
- EOS和padding
- 有效token损失分母
- Decoder-only与交叉注意力区别
运行前先确认PyTorch 2.9.1+cpu、Java 17,损失源码对照Transformers v4.57.1。实验从零训练四符号玩具因果模型,不使用预训练权重,不代表自然语言能力。
01先弄懂它是什么
语言模型训练时,一个基本问题是:看到前面的 token,下一个 token 有多可能出现?它把整段序列的概率拆开计算:p(x1,...,xT)=各位置p(xt|更早token)的乘积,训练通常最小化这些条件概率的负对数之和。
例如序列 [0,1,2,3] 可以拆成输入 [0,1,2]、目标 [1,2,3];第一个位置看到 0 后预测 1。如果让每个位置看见当前 token 又预测自己,模型可能只学会复制,没学到下一 token 的分布。
Decoder-only 模型会先表示 token 和位置,再经过只能读取允许历史的自注意力、前馈残差块,最后转换成词表上的分数。它不一定需要原始编码器解码器 Transformer 中的跨注意力,也就是读取另一组 memory 的那一层。
因此选框架组件时不能只看名称:PyTorch 的 TransformerDecoderLayer 包含对 memory 的交叉注意力,不能直接认为它完整对应所有现代 decoder-only 架构。
模型输出logits形状[B,T,V],V为词表大小。交叉熵将每个有效位置视为一个V类预测,目标是整数ID。训练时真实前缀已知,可以通过因果掩码并行计算所有位置;生成时未来token未知,通常根据当前末位置分布选择一个token,再追加到上下文继续计算。训练并行不意味着生成也能任意并行得到未知后续。
文中这几个词,先认一认
- Teacher forcing
- 训练时把真实的历史 token 交给模型,让它预测下一步;历史并不全部来自模型自己的前面输出。
- 标签移位
- 把位置 t 的预测对上位置 t+1 的真实 token。移位应在一个明确环节完成,避免重复错位。
- 有效token
- 真正参与损失计算的目标位置。被
ignore_index忽略的位置不计入。
02看看它怎么工作
标签移位可以由数据准备完成,也可以由模型损失函数内部完成,但不能重复做两次。源码示例接受与输入等长的labels,在末尾补ignore_index再取后移部分,从而让每个logit预测下一个标签。实验选择外部准备x=sequence[:-1]和y=sequence[1:],再直接计算交叉熵,因此没有调用带内部移位的模型损失接口。
每个位置负对数似然为logsumexp(logits)-正确类别logit;对有效位置求平均。padding与跨文档边界是否训练必须明确。若一个批次所有标签都被忽略,平均分母为零,不能把NaN当成可接受损失;应跳过该更新或在数据阶段拒绝。评测时还要按token总数聚合,不能让短序列与长序列的批均值随意等权。
CPU实验使用词表0到3的循环序列,两个注意力头、可学习位置向量和一个无dropout小块。它能够检验前向、反向和优化器连接正确;任务过于简单,低损失不能说明模型理解语言。修改最后一个输入token后,前面的logits必须不变;这个独立断言用于检查因果结构,而不是从损失下降反推掩码正确。
先跑通一个最小例子
完整CPU小语言模型,固定四个符号的循环模式,训练60次更新;不下载数据。
展开收起代码 · experiment.py
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import torch
from torch import nn
from torch.nn import functional as F
torch.manual_seed(22)
torch.set_num_threads(1)
sequence = (torch.arange(9)[None, :] + torch.arange(4)[:, None]) % 4
x, y = sequence[:, :-1], sequence[:, 1:] # 外部只移位一次
class TinyLM(nn.Module):
def __init__(self):
super().__init__()
self.embedding = nn.Embedding(4, 16)
self.position = nn.Embedding(8, 16)
self.block = nn.TransformerEncoderLayer(16, 2, 32, dropout=0.,
batch_first=True, norm_first=True)
self.head = nn.Linear(16, 4)
def forward(self, ids):
t = ids.shape[1]
hidden = self.embedding(ids) + self.position(torch.arange(t))
blocked = torch.ones(t, t, dtype=torch.bool).triu(1) # 此接口True表示禁止
return self.head(self.block(hidden, src_mask=blocked))
model = TinyLM()
optimizer = torch.optim.AdamW(model.parameters(), lr=.02)
assert x[0].tolist() == [0, 1, 2, 3, 0, 1, 2, 3]
assert y[0].tolist() == [1, 2, 3, 0, 1, 2, 3, 0]
with torch.no_grad():
initial_logits = model(x)
initial = F.cross_entropy(initial_logits.reshape(-1, 4), y.reshape(-1)).item()
changed = x.clone()
changed[:, -1] = (changed[:, -1] + 1) % 4
assert torch.allclose(initial_logits[:, :-1], model(changed)[:, :-1], atol=1e-6)
for _ in range(60):
optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = F.cross_entropy(logits.reshape(-1, 4), y.reshape(-1))
assert torch.isfinite(loss)
loss.backward()
optimizer.step()
with torch.no_grad():
logits = model(x)
final = F.cross_entropy(logits.reshape(-1, 4), y.reshape(-1)).item()
assert logits.shape == (4, 8, 4)
assert final < initial * .2
assert (logits.argmax(-1) == y).float().mean() > .95
print(f"CPU toy LM checks passed; loss {initial:.4f} -> {final:.4f}")
核对运行结果输出玩具LM检查通过,循环任务损失下降且准确率超过95%;这只证明当前机制实验能拟合。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["完整token序列"] --> B["输入去末位 标签去首位"]
B --> C["Embedding与位置表示"]
C --> D["因果Transformer块"]
D --> E["词表logits"]
E --> F["对有效目标计算交叉熵"]
F --> G["反向与参数更新"]按顺序看,每一步做了什么
- 打印一个输入目标对,逐位置核对预测含义。
- 验证[B,T,V]输出及整数目标范围。
- 用未来扰动检查因果性,再训练小批数据。
- 按有效token汇总损失,并区分玩具拟合与泛化。
03对照实现代码
Transformers v4.57.1 · ForCausalLMLoss;fixed_cross_entropy
原创等价目标示意,使用丢弃最后logit的写法;不包含模型并行、自定义num_items_in_batch或框架完整训练器。
import torch
from torch.nn import functional as F
def next_token_loss(logits, labels):
target = labels[..., 1:] # 当前输出对应下一个真实token
prediction = logits[..., :-1, :].float()
valid = target.ne(-100).sum()
if valid == 0:
raise ValueError("no supervised token") # 不允许零分母悄悄变成NaN
total = F.cross_entropy(prediction.reshape(-1, prediction.shape[-1]),
target.reshape(-1), ignore_index=-100, reduction="sum")
return total / valid # 分母是有效预测位置数
真实函数先将logits上转float,以减少损失计算中的部分低精度问题。
未提供shift_labels时,它在labels末尾补忽略值再切片,保留logits长度并忽略最后无目标位置。
展平后通过fixed_cross_entropy计算损失;若提供num_items_in_batch,使用求和再除指定计数。
教学写法移除最后logit,目标等价但形状路径不同;调用者不得在已移位数据上再次套用该函数。
代码先调用谁,再调用谁
- 模型产生词表logits
- 准备移位标签
- 展平位置与词表维度
- 交叉熵求和或均值
- 反向更新参数
04动手写一个例子
带忽略标签的下一token负对数似然
输入T行V类logits和T个同长度标签,位置t预测labels[t+1]。忽略值-100不计入分母,最后logit无目标;有效目标为空时拒绝。
- 输入 1
三行二类logits全0,labels=[0,1,0]- 输出 1
平均损失ln(2),有效目标2
- 输入 2
labels=[0,1,-100]- 输出 2
有效目标1,损失仍ln(2)
遍历到T-2,明确取下一位置标签,避免用当前标签训练复制。
每行使用max加log指数和构造logsumexp,不先softmax再取log。
只对未忽略标签累加并计数,最后除以有效目标数。
为什么这样写是对的处理到位置t后,累计量恰好是已处理有效下一token目标的负对数似然之和。
对每个有效预测行扫描词表求最大值及指数和,无需保存softmax矩阵。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
public class Main {
record Loss(double mean,int tokens) {}
static Loss loss(double[][] logits,int[] labels){
if(logits.length!=labels.length||labels.length<2||logits[0].length==0)throw new IllegalArgumentException();
int vocab=logits[0].length,count=0;double sum=0;
for(double[] row:logits){
if(row.length!=vocab)throw new IllegalArgumentException();
for(double x:row)if(!Double.isFinite(x))throw new IllegalArgumentException();
}
for(int t=0;t+1<labels.length;t++){
int target=labels[t+1]; // 一行logit只预测下一位置
if(target==-100)continue;
if(target<0||target>=vocab)throw new IllegalArgumentException();
double max=Double.NEGATIVE_INFINITY;
for(double x:logits[t])max=Math.max(max,x);
double expSum=0;for(double x:logits[t])expSum+=Math.exp(x-max);
sum+=(max-logits[t][target])+Math.log(expSum);count++; // 稳定负对数似然
}
if(count==0)throw new IllegalArgumentException("no targets");
return new Loss(sum/count,count); // 只按有效目标归一化
}
static void check(boolean v){if(!v)throw new AssertionError();}
static void close(double x,double y){check(Math.abs(x-y)<1e-10);}
public static void main(String[] args){
double[][] z={{0,0},{0,0},{0,0}};
close(loss(z,new int[]{0,1,0}).mean(),Math.log(2));
check(loss(z,new int[]{0,1,-100}).tokens()==1);
close(loss(new double[][]{{1000,1000},{0,0}},new int[]{0,1}).mean(),Math.log(2));
boolean bad=false;try{loss(z,new int[]{0,-100,-100});}catch(IllegalArgumentException e){bad=true;}check(bad);
bad=false;try{loss(z,new int[]{0,2,0});}catch(IllegalArgumentException e){bad=true;}check(bad);
check(loss(new double[][]{{0,10},{10,0},{0,0}},new int[]{0,1,0}).mean()<.001);
System.out.println("6 causal token loss checks passed");
}
}这些特殊情况也要试一试
- 所有目标被忽略时不返回伪造的零损失。
- 最后一行logit不对应序列外未知标签。
- 标签移位不能同时在数据与损失层重复执行。
进阶选读 · 生产拓展:长度与边界一致的语言模型评测
场景不同文档长度和上下文窗口导致有效目标计数不同。
做法保存每个评测样本的有效token数和总负对数似然,汇总后再计算均值或困惑度;明确窗口重叠时哪些目标仅计算一次。
复杂度损失统计O(NV),N为实际计入的目标数;模型前向成本另计。
好处与代价更完整上下文能改变损失,报告必须固定分词器、窗口和边界策略。
05项目里怎么用
从零小实验应先过拟合一个极小数据集,确认模型、标签和梯度连接正确,再扩大数据。这是一项调试手段,不是泛化验证;训练集准确率不能替代独立测试集。
生成策略如greedy、温度和采样会改变输出,但不改变训练时目标分布定义。生成时还要处理EOS和最大长度,避免将固定长度截断误认为模型学会停止。
实际训练记录需要包括词表、上下文长度、有效token数、损失掩码和数据切分。比较两个实验时这些设置不同,损失下降可能只是任务更容易或统计口径改变,不能自动归因于架构改进。
容易踩的坑
- 输入与标签同位置造成复制任务。
- 外部和内部各移位一次。
- 去掉因果掩码却仍叫自回归训练。
- 把玩具周期数据拟合当作自然语言能力。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 手工小模型 | 机制和张量透明 | 缺少成熟训练优化 | 学习与正确性验证 |
| 框架因果语言模型 | 整合损失和生成能力 | 需理解内部移位与配置 | 实际微调或复现 |
| 整段teacher forcing | 训练位置可并行 | 与生成时错误前缀分布不同 | 自回归最大似然训练 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01输入[0,1,2]应预测哪些目标?
展开参考答案与推导
若完整序列为[0,1,2,3],对应目标[1,2,3]。
02训练可以并行而生成为何通常逐步?
展开参考答案与推导
训练真实前缀已知;生成下一token未知,需要先决定再作为后续上下文。
03有效token均值分母是什么?
展开参考答案与推导
实际未被忽略的目标位置数,不是固定B乘T。
换个情况,还会处理吗
01损失很低但生成只复制输入,应检查什么?
展开参考答案与推导
标签是否正确移位、模型是否看到了要预测的目标。
02批次全为忽略标签如何处理?
展开参考答案与推导
数据阶段拒绝或显式跳过更新,不能任由零分母NaN传播。
03用TransformerEncoderLayer构建因果模型是否矛盾?
展开参考答案与推导
名称不决定因果性,显式因果掩码可限制自注意力;仍需自己添加Embedding、输出头和损失。
写代码试一试
01二类logits相等时正确类NLL是多少?
- 输入 / 条件
[0,0]- 输出 / 预期
ln(2)
提示 概率均为0.5。
展开参考答案与推导
负log(0.5)=ln(2)。
02三标签[0,1,-100]有几个目标?
- 输入 / 条件
下一token预测- 输出 / 预期
1
提示 起始标签不作为该片段的预测目标。
展开参考答案与推导
只有labels[1]有效,labels[2]忽略。
03如何验证未来泄漏?
- 输入 / 条件
仅修改末位输入- 输出 / 预期
更早logits保持
提示 关闭dropout做对照。
展开参考答案与推导
相同模型前向比较前缀输出,并可进一步检查未来梯度为零。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
训练固定循环序列,检查标签对应、未来输入改动不影响已有前缀输出和损失下降,并手算带忽略位置的下一 token 损失。
本篇重点加练
标签对齐、生成条件与有效目标归约共同定义语言模型目标,重点检查不同数据表示的一致性。
- 输入标签右移关系
- 因果训练与自回归生成
- 有效token损失分母
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
序列 [BOS,4,7,2,EOS] 的一次 teacher forcing 前向中,写出输入、目标和各位置条件信息;给出词表大小为 10 时 logits 形状。
- 提交内容
- 位置对齐表与概率分解
- 怎样算完成
- 移位次数明确
- 每个目标条件范围正确
练到的知识点条件概率分解 · 输入标签右移关系 · logits的B,T,V形状
- 作业 02原理问答
比较训练一次前向与生成四个新 token 的调用过程,标注参数共享、可见前缀及是否需要外部编码器输出。
- 提交内容
- 两张时序图
- 怎样算完成
- 训练和生成信息条件一致
- 交叉注意力不被混入
练到的知识点teacher forcing · 因果训练与自回归生成 · Decoder-only与交叉注意力区别
- 作业 03原理问答
同一句话分别以完整序列和两个重叠片段参与训练。自行规定重叠范围,审计每个下一 token 目标被计入几次及其权重。
- 提交内容
- 目标身份与计数表
- 怎样算完成
- 切片位置保留原身份
- 重复监督可追踪
练到的知识点输入标签右移关系 · 有效token损失分母
- 作业 04代码推演
固定同一模型和长度五前缀,比较从完整前缀预测下一 token 与只给最后两个 token 的条件概率对象,指出何时可以认为二者可比。
- 提交内容
- 条件信息图与前提说明
- 怎样算完成
- 模型身份一致
- 截断上下文不是无条件等价
练到的知识点因果训练与自回归生成
综合应用
- 作业 05编程实践
编写 CPU 小型语言模型损失函数,处理不同长度序列与有效目标计数;测试 EOS、PAD 共享数值但语义掩码不同的输入。
- 提交内容
- 代码、样例与统计记录
- 怎样算完成
- 标签语义由掩码明确
- 空有效目标有契约
练到的知识点输入标签右移关系 · EOS和padding · 有效token损失分母
- 作业 06排错实验
在一个小因果模型中分别注入未移位标签和未来可见掩码,比较训练损失与自回归生成;用独立测试定位两类错误。
- 提交内容
- 故障实验与定位依据
- 怎样算完成
- 每次只改一个错误
- 生成评价与训练损失分开
练到的知识点输入标签右移关系 · 因果训练与自回归生成
- 作业 07面试场景
两次评估使用相同句子,但一次包含更多 padding。设计能够检查平均损失可比性的报告,保留每句及全局有效 token 数。
- 提交内容
- 评估脚本与报告格式
- 怎样算完成
- 分母不由张量面积代替
- 数据和模板版本固定
练到的知识点EOS和padding · 有效token损失分母
- 作业 08编程实践
编写 CPU 标签覆盖审计器,输入窗口起止位置、预测位置及 loss mask,找出遗漏和重复目标;测试跨窗口 EOS 与 padding。
- 提交内容
- 审计器及窗口样例
- 怎样算完成
- 目标位置映射完整
- EOS 与填充规则明确
练到的知识点输入标签右移关系 · 有效token损失分母
- 作业 09排错实验
生成评估按请求报告平均 NLL,而训练按有效 token 统计。自建三条不同长度序列,核查两种排序可能不同的条件。
- 提交内容
- 逐句损失账本与指标审阅
- 怎样算完成
- 两类权重显式
- 评价目标与使用场景对应
练到的知识点因果训练与自回归生成 · 有效token损失分母
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
建立长度不超过 16 的合成序列任务,研究 teacher forcing 损失与自由生成错误的关系;预先定义停止规则和序列级指标。
- 提交内容
- CPU 实验及失误分析
- 怎样算完成
- 训练评价协议明确
- 不推断自然语言能力
练到的知识点条件概率分解 · teacher forcing · 因果训练与自回归生成
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
某模型只记住固定位置上的常见 token,也能取得较低训练损失吗?构造可执行合成数据,并用位置或前缀扰动检验是否学到条件依赖。
- 提交内容
- CPU 数据生成器与诊断实验
- 怎样算完成
- 替代机制可被检验
- 评价不复用同一捷径
练到的知识点输入标签右移关系 · 因果训练与自回归生成
- 作业 12思维拓展 · 跨域迁移
把语言模型的 token 均值迁移到多步决策任务:短轨迹和长轨迹的错误成本不同,怎样定义评价才匹配任务成功目标?自拟两种轨迹分布。
- 提交内容
- 轨迹分布与指标设计
- 怎样算完成
- 统计单位明确
- 代理损失与最终目标分开
练到的知识点因果训练与自回归生成 · 有效token损失分母
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Transformers4.57.1损失源码 (新标签页)↗
核对内部标签移位与有效位置计数。
- 深入参考文档英文
PyTorch2.9.1 Transformer模块 (新标签页)↗
区分因果自注意力与含memory的Decoder层。
- 深入参考文档英文
Attention Is All You Need (新标签页)↗
理解移位标签与因果掩码共同定义自回归目标。