qinghuan
← Transformer 与语言建模 / 全部笔记

单元 22 / Transformer 与语言建模

P0 · 必会主线L2 · 进阶

从 Decoder 到小型语言模型:标签移位与因果约束

把 token 序列接入因果模块,再让每个位置预测下一个 token,用移位交叉熵训练小语言模型,区分并行训练和逐步生成。

本篇目录 · 按需要跳转

读完这篇,你能做什么

训练固定循环序列,检查标签对应、未来输入改动不影响已有前缀输出和损失下降,并手算带忽略位置的下一 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.pypython · 44 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

experiment.py · python
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%;这只证明当前机制实验能拟合。

正在加载流程图…

位置t输出预测下一个token;数据移位和模型内部移位应二选一。
查看流程图源码(Mermaid)
flowchart TD
A["完整token序列"] --> B["输入去末位 标签去首位"]
B --> C["Embedding与位置表示"]
C --> D["因果Transformer块"]
D --> E["词表logits"]
E --> F["对有效目标计算交叉熵"]
F --> G["反向与参数更新"]

按顺序看,每一步做了什么

  1. 打印一个输入目标对,逐位置核对预测含义。
  2. 验证[B,T,V]输出及整数目标范围。
  3. 用未来扰动检查因果性,再训练小批数据。
  4. 按有效token汇总损失,并区分玩具拟合与泛化。

03对照实现代码

ForCausalLMLoss显式生成下一token标签 (新标签页)↗

Transformers v4.57.1 · ForCausalLMLoss;fixed_cross_entropy

原创等价目标示意,使用丢弃最后logit的写法;不包含模型并行、自定义num_items_in_batch或框架完整训练器。

核心机制代码 · python
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,目标等价但形状路径不同;调用者不得在已移位数据上再次套用该函数。

代码先调用谁,再调用谁

  1. 模型产生词表logits
  2. 准备移位标签
  3. 展平位置与词表维度
  4. 交叉熵求和或均值
  5. 反向更新参数

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目标的负对数似然之和。

时间 O(TV)额外空间 O(1)额外空间

对每个有效预测行扫描词表求最大值及指数和,无需保存softmax矩阵。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 34 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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损失分母

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    序列 [BOS,4,7,2,EOS] 的一次 teacher forcing 前向中,写出输入、目标和各位置条件信息;给出词表大小为 10 时 logits 形状。

    提交内容
    位置对齐表与概率分解
    怎样算完成
    • 移位次数明确
    • 每个目标条件范围正确

    练到的知识点条件概率分解 · 输入标签右移关系 · logits的B,T,V形状

  2. 作业 02原理问答

    比较训练一次前向与生成四个新 token 的调用过程,标注参数共享、可见前缀及是否需要外部编码器输出。

    提交内容
    两张时序图
    怎样算完成
    • 训练和生成信息条件一致
    • 交叉注意力不被混入

    练到的知识点teacher forcing · 因果训练与自回归生成 · Decoder-only与交叉注意力区别

  3. 作业 03原理问答

    同一句话分别以完整序列和两个重叠片段参与训练。自行规定重叠范围,审计每个下一 token 目标被计入几次及其权重。

    提交内容
    目标身份与计数表
    怎样算完成
    • 切片位置保留原身份
    • 重复监督可追踪

    练到的知识点输入标签右移关系 · 有效token损失分母

  4. 作业 04代码推演

    固定同一模型和长度五前缀,比较从完整前缀预测下一 token 与只给最后两个 token 的条件概率对象,指出何时可以认为二者可比。

    提交内容
    条件信息图与前提说明
    怎样算完成
    • 模型身份一致
    • 截断上下文不是无条件等价

    练到的知识点因果训练与自回归生成

综合应用

  1. 作业 05编程实践

    编写 CPU 小型语言模型损失函数,处理不同长度序列与有效目标计数;测试 EOS、PAD 共享数值但语义掩码不同的输入。

    提交内容
    代码、样例与统计记录
    怎样算完成
    • 标签语义由掩码明确
    • 空有效目标有契约

    练到的知识点输入标签右移关系 · EOS和padding · 有效token损失分母

  2. 作业 06排错实验

    在一个小因果模型中分别注入未移位标签和未来可见掩码,比较训练损失与自回归生成;用独立测试定位两类错误。

    提交内容
    故障实验与定位依据
    怎样算完成
    • 每次只改一个错误
    • 生成评价与训练损失分开

    练到的知识点输入标签右移关系 · 因果训练与自回归生成

  3. 作业 07面试场景

    两次评估使用相同句子,但一次包含更多 padding。设计能够检查平均损失可比性的报告,保留每句及全局有效 token 数。

    提交内容
    评估脚本与报告格式
    怎样算完成
    • 分母不由张量面积代替
    • 数据和模板版本固定

    练到的知识点EOS和padding · 有效token损失分母

  4. 作业 08编程实践

    编写 CPU 标签覆盖审计器,输入窗口起止位置、预测位置及 loss mask,找出遗漏和重复目标;测试跨窗口 EOS 与 padding。

    提交内容
    审计器及窗口样例
    怎样算完成
    • 目标位置映射完整
    • EOS 与填充规则明确

    练到的知识点输入标签右移关系 · 有效token损失分母

  5. 作业 09排错实验

    生成评估按请求报告平均 NLL,而训练按有效 token 统计。自建三条不同长度序列,核查两种排序可能不同的条件。

    提交内容
    逐句损失账本与指标审阅
    怎样算完成
    • 两类权重显式
    • 评价目标与使用场景对应

    练到的知识点因果训练与自回归生成 · 有效token损失分母

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    建立长度不超过 16 的合成序列任务,研究 teacher forcing 损失与自由生成错误的关系;预先定义停止规则和序列级指标。

    提交内容
    CPU 实验及失误分析
    怎样算完成
    • 训练评价协议明确
    • 不推断自然语言能力

    练到的知识点条件概率分解 · teacher forcing · 因果训练与自回归生成

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    某模型只记住固定位置上的常见 token,也能取得较低训练损失吗?构造可执行合成数据,并用位置或前缀扰动检验是否学到条件依赖。

    提交内容
    CPU 数据生成器与诊断实验
    怎样算完成
    • 替代机制可被检验
    • 评价不复用同一捷径

    练到的知识点输入标签右移关系 · 因果训练与自回归生成

  2. 作业 12思维拓展 · 跨域迁移

    把语言模型的 token 均值迁移到多步决策任务:短轨迹和长轨迹的错误成本不同,怎样定义评价才匹配任务成功目标?自拟两种轨迹分布。

    提交内容
    轨迹分布与指标设计
    怎样算完成
    • 统计单位明确
    • 代理损失与最终目标分开

    练到的知识点因果训练与自回归生成 · 有效token损失分母

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗