AI Hero Club

Transformer 实战 · 第 7 章

已自行标记完成0 / 12

仅保存在当前浏览器;完成标记不代表通过项目验收。

本章提纲 9 个小节
全课目录 12 章
完整课程计划 ↗
Transformer 实战 / 课程计划 ↗

CHAPTER 07 / 12建议学习 120 分钟

实现可训练的 Decoder

怎样把嵌入、多层块和输出头接成模型,并发现未注册参数或互相矛盾的目标?

本章要解决的 4 个问题
  • 把词嵌入、位置、多层块、finalLN 与 head 接成公开接口
  • 用手算极限核验完整 logits 与对齐损失
  • 区分可微计算、模块注册与优化器持有边界
  • 运行全部参数连接和固定批次拟合验收并识别矛盾目标
课程项目 ZIP ↓

本章目标:从词元到可更新的整台模型

本章把前六章接成一台 Decoder 语言模型。输入是词元编号,输出是每个位置对下一个词元的 logits;给出对齐目标时,同时返回一个能反向传播的标量损失。前提是理解嵌入、因果注意力、pre-LN 块、交叉熵与自动微分。本章不止检查一个层的输出,而要完成整机前向、全部参数的梯度连接、一次真实优化和固定批次的拟合诊断;大规模文本泛化留给后续数据实验。

定义词表大小 VV、最大上下文长度 CC、层数 NN、隐藏宽度 dd、头数 hh 与前馈宽度 ff。本章采用学习式绝对位置;词嵌入与输出权重不绑定;两层 FFN 有偏置,Q/K/V/O 与 head 无偏置,所有 LayerNorm 保留第五章的人口方差与稳定常数。训练演示先令 dropout 为零,模型、数据和优化器均在 CPU 上创建,避免把设备差异混入正确性检查。

公开接口先冻结,内部拆分服务于它

Config 接收 vocab_size、block_size、d_model、n_heads、n_layers、d_ff、dropout;分别对应 V,C,d,h,N,fV,C,d,h,N,f 与丢弃概率。构造时检查所有宽度和层数为正、头数整除隐藏宽度、丢弃概率范围合法。非法配置在构造阶段报错,优于等到某次 reshape 才产生难追踪异常。本章不依赖项目内部给块和投影取什么变量名。

公开对象或返回项契约本章检查
TransformerLM.forwardtokens, targets=None, past_key_values=None, use_cache=False训练用 past_key_values=None、use_cache=False
tokens整数张量 [B,T];0≤编号<V;1≤T≤C越界和空序列显式拒绝
targets可选整数张量 [B,T];位置 t 已是下一个词元模型内部不再额外错移一次
logits浮点张量 [B,T,V]保留所有位置,不只最后一行
losstargets 给出时为标量;未给时为 None可 backward;不先转 Python 数字
past_key_values字典中的缓存返回项本章不使用缓存;后续单独验收
公开 forward 返回 dict(logits, loss, past_key_values);具体内部模块名可以不同。

整数 tokens 不能直接送进矩阵乘法。词表嵌入矩阵 E∈RV×dE\in\mathbb R^{V\times d} 以编号取行,位置表 P∈RC×dP\in\mathbb R^{C\times d} 以位置零到 T−1T-1 取行。同一位置表跨批次共享,所以位置形状 [T,d][T,d] 可在批量轴合法广播;它与第五章警告的意外残差广播不同,因为这里的共享是明确设计。初始化状态记为 H(0)H^{(0)}。

Hb,t,:(0)=Etokensb,t,:+Pt,:,H(k+1)=Block⁡k(H(k))(0≤k<N).H^{(0)}_{b,t,:}=E_{\mathrm{tokens}_{b,t},:}+P_{t,:},\quad H^{(k+1)}=\operatorname{Block}_k(H^{(k)})\quad(0\le k<N).
精确前向递推:块按编号顺序执行,各块参数独立。

循环不变量是:第 kk 次循环前,H(k)H^{(k)} 形状仍为 [B,T,d][B,T,d],位置 tt 只依赖输入零到 tt 的合法前缀;执行一个正确因果块后,两条性质继续成立。初始化的查表与逐位置相加显然不读取未来位置,因此归纳得到整机隐藏状态的因果性。仅调用最后一个块、把所有块都读同一份初始输入,或在循环内意外覆盖原始 tokens,都不是这个递推。

Z=LN⁡final(H(N)),S=ZWhead,Whead∈Rd×V.Z=\operatorname{LN}_{final}(H^{(N)}),\qquad S=ZW_{head},\qquad W_{head}\in\mathbb R^{d\times V}.
最终 LayerNorm 后接无偏置 head;S 即返回的完整 logits 张量。

若给 targets,按第六章对各有效位置计算负对数似然并聚合;本章的固定小批次没有 padding,所以直接平均。返回字典里的 loss 必须仍在计算图中;用于日志的 loss.item() 只生成一个无图的数字,不能把它重新包成张量冒充原 loss。没有 targets 时保留 loss=None,使推理调用不必构造假标签。训练时不返回跨步缓存,以免把历史计算图、过期权重或不同样本前缀串在一起。

算例一:两层整机的可手算极限

为手算暂取二类数学模型,V=d=f=2,h=1,N=2,C≥2V=d=f=2,h=1,N=2,C\ge2。项目另外保留 UNK/BOS/EOS,要求 vocab_size 至少三,因此此二类配置仅用于标准库理论核查,不直接传给项目 Config。输入两个词元 A、B,编号为零、一;嵌入分别为 (1,0)(1,0)、(0,1)(0,1),位置表这两个位置均为零。两块的注意力输出和前馈输出都设为零,所以第一块、第二块的隐藏状态都等于初始嵌入。finalLN 的 gamma 为一、beta 为零,head 数学矩阵为二阶单位矩阵;全部设置均为明确的特设参数。

第一位置均值为零点五,中心化为 (0.5,−0.5)(0.5,-0.5),人口方差零点二五。令 a=0.5/0.25+10−5≈0.9999800a=0.5/\sqrt{0.25+10^{-5}}\approx0.9999800,则两位置的最终 logits 分别为 (a,−a)(a,-a)、(−a,a)(-a,a)。目标刻意设为 B、A,也就是每一行更低的那个 logit,因此每行损失都是 log⁡(1+e2a)\log(1+e^{2a}),约二点一二六九。平均仍是这个值,不会再除以词表大小。

现在把 head 两列交换,目标就变成每行较高的 logit,损失为 log⁡(1+e−2a)\log(1+e^{-2a}),约零点一二六九。两者相减恰为 2a2a,因为 1+e2a=e2a(1+e−2a)1+e^{2a}=e^{2a}(1+e^{-2a})。这给整机数据流一个独立代数检查:若代码忘了 finalLN、绑错输出方向、只输出最后位置,或内部又移了一次目标,都无法同时满足这组数值、形状与差值。

独立运行标准库整机特设极限检查:两块恒等、finalLN、head 与逐位置损失贯通;不是通用 Decoder 实现。
import math
embeddings=[[1.0,0.0],[0.0,1.0]]
hidden=[row[:] for row in embeddings]
for _ in range(2):
    hidden=[[v+0.0 for v in row] for row in hidden]
def ln(row):
    mean=sum(row)/len(row)
    var=sum((v-mean)**2 for v in row)/len(row)
    return [(v-mean)/math.sqrt(var+1e-5) for v in row]
logits=[ln(row) for row in hidden]
def ce(row,target):
    top=max(row)
    return -row[target]+top+math.log(sum(math.exp(v-top) for v in row))
high=sum(ce(row,y) for row,y in zip(logits,[1,0]))/2
low=sum(ce(list(reversed(row)),y) for row,y in zip(logits,[1,0]))/2
a=0.5/math.sqrt(0.25+1e-5)
assert abs(high-low-2*a)<1e-12
assert len(logits)==2 and len(logits[0])==2
print('logits',logits,'wrong-direction loss',high,'swapped head loss',low)

零分支算例专门检查接线,不能检查所有注意力权重都能学到东西。当输出投影为零时,查询与键的数值梯度可为零;这由第六章链式法则直接决定。下一项必须改用一般随机初始化,在多位置输入上检查所有参数张量是否连到损失。把两种机制交给同一组特殊零权重,会使“没有梯度”和“本例合法零梯度”无法区分。

参数注册:可微、被发现、被更新是三件事

在 PyTorch 中,nn.Module 负责管理子模块与参数;把一个模块赋为另一个模块的属性,会使其参与递归参数遍历。多块应放入 nn.ModuleList 等注册容器,并在循环中真正调用。普通 Python list 虽能保存对象、forward 也可能运行,但其中子模块不会因此自动出现在外层 parameters()、设备迁移和 state_dict() 中。

错误示例是先创建一个块,再用列表重复同一个对象若干次。这不是多个独立块,而是同一参数被重复使用;自动微分会汇总多次调用的梯度,却仍只有一份权重。正确方式是每次构造新的块对象,再注册整个集合。第五章的参数账本能发现数量减少,逐层对象身份检查能直接定位共享;二者检查的是数量与结构,不必借助训练曲线猜测。

普通 tensor 即使 requires_grad=True,也不等于已经成为模块参数;自建权重应使用 nn.Parameter 或注册模块中已有 Parameter。相反,固定因果 mask、位置索引等是计算所需常量,不应为了出现在参数表里被当作可训练权重,可以按需计算或注册为 buffer。注册影响保存、迁移与优化器发现,不改变某条数学公式的意义。

创建优化器前先把模型移到目标设备和 dtype,再用 model.parameters() 创建优化器。随后检查优化器持有参数的对象身份集合等于模型待训练参数集合。如果创建后又替换了某个 Linear,旧优化器仍可能指向旧对象;新层 forward 和 backward 都正常,却永远不被旧优化器更新。此类故障不应靠增加训练步数补救。

梯度连接检查逐个 named_parameters() 查看:对需要训练的张量,grad is None 通常表示这次未参与可微路径或被截断;grad 为全零则可能是当前输入的合法结果。词表里本批次未出现的嵌入行也应为零,而非要求全部词元行都非零。再检查每个梯度元素有限,记录各模块梯度范数;只看一个 head 的梯度不能证明前面的块被使用。

与实际项目实现逐项对照

共享项目的 model.py 已将这些逻辑落成具体对象:token_embedding 与 position_embedding 建立初始状态,blocks 是逐次构造 TransformerBlock 的 ModuleList,final_norm 后接 head。每块调用 ln_attention、attention、ln_feedforward 与 feedforward;注意力先分头再合头,并显式做因果 softmax。公开接口不依赖这些内部名称,但阅读实现时可以沿它们跟踪本章方程,而不必猜测隐藏的黑箱层。

初始化也属于执行契约:项目把 Linear 和 Embedding 权重设为均值零、标准差零点零二的正态随机数,线性偏置为零;LayerNorm 保持框架默认缩放一、偏置零。随机初始化是人为选择而非从交叉熵推导的结论。若把所有层完全置零,多个特征可能保持相同,前层也可能因后层零权重收不到非零信号;所以手算用的零分支极限不得拿来替代正常学习测试。

本章训练片段须在 content/projects/transformer-practice/README.md 所在项目目录执行,并使用该项目固定的 Python 与 PyTorch 环境。它直接调用已读的 Config 和 TransformerLM,不依赖不存在的 build_decoder 或 fit 接口。这里四符号交替夹具只测试张量学习,不作为保留符号的自然文本使用方法;第八章才通过 CharTokenizer 与文档窗口构造真实目标。

固定批次过拟合:先检验学习管线,再讨论泛化

固定批次测试每一步使用完全相同的 tokens 与 targets,关闭 dropout 与数据增强,不做验证集结论。若一个足够表达该小映射的模型仍不能降低训练损失,优先检查接线、目标对齐、梯度与优化器,而不是先换更多训练资料。为避免随机结果被误写成定理,下方代码的门槛是待运行的验收断言;只有实际运行成功后,才可把最终损失写入实验记录。

在课程项目目录运行完整项目训练验收:依赖 hero_transformer 与 PyTorch,覆盖所有层和参数张量的梯度连接;不宣称标准库可独立运行。
import torch
from hero_transformer.model import Config, TransformerLM
torch.set_num_threads(1)
torch.manual_seed(7)
model=TransformerLM(Config(vocab_size=4,block_size=8,d_model=16,
    n_heads=2,n_layers=2,d_ff=32,dropout=0.0)).double()
optimizer=torch.optim.AdamW(model.parameters(),lr=0.02,weight_decay=0.0)
x=torch.tensor([[0,1,0,1]],dtype=torch.long)
y=torch.tensor([[1,0,1,0]],dtype=torch.long)
model_ids={id(p) for p in model.parameters() if p.requires_grad}
opt_ids={id(p) for g in optimizer.param_groups for p in g['params']}
assert model_ids==opt_ids
model.train()
initial=float(model(x,targets=y)['loss'].detach())
for _ in range(120):
    optimizer.zero_grad(set_to_none=True)
    out=model(x,targets=y,past_key_values=None,use_cache=False)
    assert out['logits'].shape==(1,4,4)
    assert out['loss'].ndim==0
    out['loss'].backward()
    missing=[name for name,p in model.named_parameters() if p.requires_grad and p.grad is None]
    assert not missing, missing
    assert all(torch.isfinite(p.grad).all() for p in model.parameters() if p.grad is not None)
    torch.nn.utils.clip_grad_norm_(model.parameters(),1.0)
    optimizer.step()
model.eval()
with torch.no_grad():
    final=float(model(x,targets=y)['loss'])
    pred=model(x)['logits'].argmax(dim=-1)
assert final<0.1 and final<initial
assert torch.equal(pred,y)
print({'initial':initial,'final':final,'prediction':pred.tolist()})

这个小数据只有交替规则,但模型仍必须经过词嵌入、位置表、两块、finalLN 与 head 才能产出训练损失。运行结果应保存种子、配置、初始/最终损失和预测,而不只截图最后一行。测试通过说明该输入上的学习链可工作,不说明它学会长距离依赖,也不说明所有参数承担了相同信息。可以进一步冻结 head 或扰动一层来做诊断,但不能据此擅自改变本章冻结架构。

算例二:同一上下文的矛盾标签无法被“过拟合到零”

构造两条完全相同的输入序列、相同位置、相同可见前缀;在某个位置,第一条目标为零号,第二条为一号。模型确定性执行,且没有样本编号等额外输入,因此这个位置给两条样本的概率必须相同。设零号概率为 p∈(0,1)p\in(0,1),另一类为 1−p1-p,两条等权交叉熵的平均为下面函数。

L(p)=−12log⁡p−12log⁡(1−p),L′(p)=2p−12p(1−p),L′′(p)=12p2+12(1−p)2>0.L(p)=-\tfrac12\log p-\tfrac12\log(1-p),\quad L^{\prime}(p)=\frac{2p-1}{2p(1-p)},\quad L^{\prime\prime}(p)=\frac1{2p^2}+\frac1{2(1-p)^2}>0.
两个冲突标签的精确目标;严格凸且边界损失发散,唯一最优为 p=1/2。

最优概率为一半、一半,损失 log⁡2≈0.693147\log2\approx0.693147。若模型偏向零号到零点九,两条损失分别约零点一零五三与二点三零二六,平均一点二零四,反而更差。因此训练到零点六九附近可能是正确收敛,不是层数不够。把阈值任意设成零点一会使这道题永远失败;修复需要去除矛盾标注、补充能区分样本的信息,或承认任务本身是条件随机分布。

独立运行标准库验证矛盾标签损失;解析二阶导证明最优,有限点比较只作数值核查。
import math
def objective(p):
    return -0.5*math.log(p)-0.5*math.log1p(-p)
for p in [0.1,0.5,0.9]: print(p,objective(p))
assert abs(objective(0.5)-math.log(2))<1e-12
assert objective(0.5)<objective(0.1)
assert objective(0.5)<objective(0.9)
# independent normalization check for the matched optimum
assert 0.5+0.5==1.0

整机验收需要相互独立的证据

第一类是语义与代数检查:手算特设模型、精确参数账本、因果前缀不变性、重复输入得到重复输出。第二类是学习与状态检查:完整 loss.backward、每类参数的梯度连接、优化器身份集合、固定批次拟合。前者发现结构错误,后者发现断图和更新遗漏;其中任何一项通过都不能替代另外一类。浮点比较使用写明的容差,不用字符串完全相同替代数值语义。

因果性可用同一模型、同一前缀,分别拼接两个不同未来后缀,比较前缀 logits。检查前设 eval 且 no_grad,避免 dropout 的随机差异被误认成未来泄漏。输入长度超过 block_size 时应拒绝,而不是静默循环位置编号;位置是学习表,重复使用错误编号会改变模型含义。返回所有位置也不能以性能为理由改成只返回最后一个,因为训练契约需要每个有效位置的监督。

本章项目增量是 model.py 中完整 TransformerLM,以及可直接运行的固定批次验收:包含配置、所有模块注册、前向返回字典、loss 的可微聚合和梯度路径检查。第八章接入文档数据、右 padding、有效词元加权评估、采样状态和可复现检查点。那时固定批次测试仍保留为正向控制;若真实训练失败,先重跑它,区分模型链损坏与数据/恢复问题。

TRY IT YOURSELF

练习一|基础:跟踪完整输出而非最后一层

批量二、长度五、词表十一、隐藏宽度八、层数三。写出嵌入后、各块后、finalLN 后与 head 后形状;若只返回最后位置会少掉多少监督位置?

提示 1
按嵌入、每个残差块、finalLN、词表head的顺序追踪三个轴。
提示 2
逐位置训练共有B×T个目标,只取末位时每条还剩几个?不要把词表轴当监督位置数。
对照参考解答
嵌入后为 [2,5,8];每一个块后仍为 [2,5,8];finalLN 后也是 [2,5,8];head 后为 [2,5,11]。不含 padding 时原有二乘五共十个监督位置,只保留每条最后一个会只剩两个,少八个。它不是同一损失的等价更快实现。若要只监督末位,必须明确另一个任务与目标构造,不能悄悄改变 forward 训练契约。

TRY IT YOURSELF

练习二|实现诊断:梯度存在但参数没有更新

某块放在普通 Python list 中。forward 确实调用它,参数的 requires_grad=True,loss.backward 后块内参数有 grad,但 optimizer.step 后它不变。为什么?如何验收修复?

提示 1
区分“参与了计算图”与“被外层模块发现并交给优化器”。
提示 2
比较模型与优化器持有的参数对象身份,再核对state_dict与一步更新;修复注册后需重建优化器。
对照参考解答
自动微分沿实际运算求导,因此未注册子模块也可能得到梯度;但外层 parameters() 没发现它,优化器便没有持有这组参数。改成 ModuleList 或显式注册,重新创建优化器,再检查模型参数与优化器参数对象身份集合相同。还应检查 state_dict 包含该块、设备/dtype 迁移覆盖它、计数满足账本,并在一个非退化批次上确认更新发生。只改 requires_grad 不会补上注册。

TRY IT YOURSELF

练习三|推导:三比一冲突标签的最佳概率

同一上下文出现四次,三次目标为零号,一次为一号。模型没有额外区分信息,推导最优零号概率与最小平均损失。

提示 1
同上下文输出只能有一套概率,先用p表示零号概率并写加权平均NLL。
提示 2
求一阶导的驻点,再用二阶导与边界检查;非零最优损失不自动等于训练故障。
对照参考解答
平均损失 L(p)=−3log(p)/4−log(1−p)/4,导数为 −3/(4p)+1/[4(1−p)]。令其为零,交叉相乘得到 p=3(1−p),所以 p=3/4。二阶导为 3/(4p²)+1/[4(1−p)²]>0,且边界发散,因此是唯一最小值。代回得到 −0.75log0.75−0.25log0.25≈0.562335 nat。非零最优来自条件标签不确定性,而不是优化器一定失灵。

BUILD THE MECHANISM

连接自己的完整decoder

实现TransformerLM.forward的无缓存路径;必须经过本文件学生block、学生attention,不导入完整成品。第10章再沿同一实现增加历史KV与位置偏移,本阶段无需提前实现它们。 下载本课完整项目,在项目根目录运行;只编辑 learner/starter.py。

输入与输出 本章输入tokens为[B,T]int64,past_key_values=None、use_cache=False,位置编号从0到T−1。
经过自己的attention和block;loss只在非−100目标累计后除有效目标数;整段长度不得超窗口。

在课程项目目录运行待完成的学习任务
# TransformerLM.forward(替换已提供类的此方法)
def forward(self, tokens, targets=None, past_key_values=None, use_cache=False):
        raise NotImplementedError('decoder')
提示 1

先画embedding→每层block→final norm→head形状。

提示 2

本章positions从0开始,各层只处理当前整段;cache参数保留但不参与检查。

提示 3

两个不等长样本先算总NLL与有效token总数,再做一次除法。

独立检查 .venv/bin/python -m learner.check --stage decoder --output runs/learner-decoder.json

迁移检验 用自己新增的两篇短文构造输入;输出自己的logits/loss/梯度收据,不将它冒充作者冻结模型。

参考实现与解释
在课程项目目录运行参考实现
# TransformerLM.forward(第7章:先完成无缓存整机)
def forward(self, tokens, targets=None, past_key_values=None, use_cache=False):
    if past_key_values is not None or use_cache:
        raise NotImplementedError('第10章再增加缓存路径')
    if tokens.ndim != 2 or tokens.dtype != torch.long or tokens.shape[1] == 0:
        raise ValueError('tokens must be nonempty [B,T] int64')
    batch, length = tokens.shape
    if length > self.config.block_size:
        raise ValueError('input exceeds block_size')
    positions = torch.arange(length, device=tokens.device)
    hidden = self.embedding_dropout(self.token_embedding(tokens) + self.position_embedding(positions))
    for block in self.blocks:
        hidden, _ = block(hidden, past_key_value=None, use_cache=False)
    logits = self.head(self.final_norm(hidden))
    loss = None
    if targets is not None:
        if targets.shape != tokens.shape or targets.dtype != torch.long:
            raise ValueError('targets must match tokens shape and int64 dtype')
        valid = (targets != -100).sum()
        if valid == 0:
            raise ValueError('targets require at least one scored token')
        loss = F.cross_entropy(logits.reshape(-1,self.config.vocab_size), targets.reshape(-1), ignore_index=-100, reduction='sum') / valid
    return {'logits':logits,'loss':loss,'past_key_values':None}

CHECK YOUR UNDERSTANDING

检验理解

先作答,再决定是否展开解析。选择题用于发现理解缺口;独立实现与迁移任务仍需另行检验。

1. forward 给 targets 时应返回怎样的 loss?

查看解析优化需要保留计算图;日志数字不能替代原始损失张量。
2. 普通 list 里的块可能有梯度但不更新,原因可能是?

查看解析计算图与参数发现是不同机制。
3. 两条相同确定性输入给相反等权标签,二类最小平均损失为?

查看解析两条输出必须一致,最佳为各一半;边界会令另一条损失发散。
4. 某批次未使用的词元嵌入行梯度为零,说明什么?

查看解析应区分张量未连接与某些行在当前输入下合法零梯度。

自行标记用于记录学习进度,不代表通过项目验收。