AI Hero Club

Transformer 实战 · 第 5 章

已自行标记完成0 / 12

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

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

CHAPTER 05 / 12建议学习 120 分钟

从残差到完整解码块

归一化、残差与前馈怎样接成一个完整解码块,并保留可检查的梯度通路?

本章要解决的 4 个问题
  • 逐步计算人口方差 LayerNorm 与精确 GELU 前馈
  • 写出 pre-LN 两分支前向及残差梯度通路
  • 按形状逐项推导整机参数账本
  • 用代数不变量、数值与结构检查验证块实现
课程项目 ZIP ↓

本章要拼出什么,哪些知识已作为前提

前四章已经给出词元、嵌入、位置、因果多头注意力。本章把注意力接入真正可堆叠的解码块,结束时你应能逐项解释一次前向的形状、归一化的具体数字、残差的梯度通路和整台模型的参数数量。前提是会计算均值、平方和、矩阵乘法,并会对一元函数求导;多元链式法则在需要时用局部变化说明。这里不证明任何深层网络一定可训练。

约定批量为 BB,当前序列长度为 TT,隐藏宽度为 dd,头数为 hh,每头宽度为 dh=d/hd_h=d/h,前馈隐藏宽度为 ff。这些都是正整数,且 hh 整除 dd。块输入 XX 是形状 [B,T,d][B,T,d] 的实数张量;下文的小写 xx 表示其中一个位置的长度 dd 向量。统计沿最后的特征轴计算,既不跨时间,也不跨批量。所有激活是无量纲数。

本项目冻结为 pre-LN:每个子层先归一化再计算,再加回原输入。每块有两套独立 LayerNorm,注意力的 Q/K/V/O 线性映射无偏置,前馈两层均有偏置。激活采用精确 GELU,归一化稳定常数固定为 ε=10−5\varepsilon=10^{-5}。本章先令 dropout 为零,因此前向是确定函数;开启 dropout 后,恒等性与数值对照应固定随机状态或在评估模式下进行。

逐位置 LayerNorm:均值、人口方差与稳定常数

定义 μ\mu 为该位置全部 dd 个特征的算术平均;定义中心化坐标 ci=xi−μc_i=x_i-\mu;定义人口形式方差 vv 为中心化平方和除以 dd。这不是从样本估计未知总体方差的统计任务,因此不做除以 d−1d-1 的无偏修正。定义 r=v+εr=\sqrt{v+\varepsilon},再令标准化值 zi=ci/rz_i=c_i/r。

μ=1d∑i=1dxi,ci=xi−μ,v=1d∑i=1dci2,zi=civ+ε,LN⁡(x)i=γizi+βi.\mu=\frac1d\sum_{i=1}^{d}x_i,\quad c_i=x_i-\mu,\quad v=\frac1d\sum_{i=1}^{d}c_i^2,\quad z_i=\frac{c_i}{\sqrt{v+\varepsilon}},\quad \operatorname{LN}(x)_i=\gamma_i z_i+\beta_i.
定义:每套 LayerNorm 有独立的长度 d 缩放参数 γ 与偏置参数 β。

参数 γi\gamma_i 与 βi\beta_i 是训练得到的逐特征缩放和偏移,默认初值分别为一和零;同一套参数在所有批次与位置共享,但统计值每个位置重新算。它们不是把每个位置都压成同一个向量,而是调整标准化后的坐标。如果默认 γ=1,β=0\gamma=1,\beta=0,归一化结果的均值为零,因为中心化坐标和为零;其人口方差为 v/(v+ε)v/(v+\varepsilon),并非严格的一。

最后一个结论可以直接代入核查:标准化平方和除以 dd,等于原中心化平方和除以 d(v+ε)d(v+\varepsilon),分子正是 vv。当 vv 远大于稳定常数时,它接近一;当 v=0v=0 时,它等于零。这里接近一是有条件的数值近似,不能把含稳定常数的公式简写成永远单位方差。仿射参数不为默认值后,均值为零也不再必然成立。

平移检查是一条独立代数检验:对全部特征加同一常数 aa,新均值也加 aa,所以中心化坐标不变,方差不变,输出不变。正比例缩放却不能照搬这个结论:把 xx 乘 s>0s>0,分母变为 s2v+ε\sqrt{s^2v+\varepsilon},固定的稳定常数没有随之缩放,因此一般只有近似尺度不变。若把整个张量跨时间归一化,未来词元会进入统计,破坏本项目的因果契约。

两条残差分支怎样构成一个块

记 AA 为前章已定义的因果多头注意力映射,它保持输入的 [B,T,d][B,T,d] 形状;记 FF 为接下来定义的前馈映射。下标一、二表示独立归一化参数,不是同一输出重复使用。第一步用原输入生成注意力增量并相加,得到中间状态 UU;第二步根据 UU 生成前馈增量,再与 UU 相加得到块输出 YY。

U=X+A(LN⁡1(X)),Y=U+F(LN⁡2(U)).U=X+A(\operatorname{LN}_1(X)),\qquad Y=U+F(\operatorname{LN}_2(U)).
精确的 pre-LN 前向约定;本章 dropout=0。

这个顺序必须逐项执行。第二套归一化读的是已经加入注意力增量的 UU,不是最初的 XX;否则你实现的是另一张计算图。相加两端都必须是同一形状,不能依赖广播把错误的 [B,1,d][B,1,d] 增量复制到全部位置。注意力负责在合法的过去位置之间交换信息,前馈则分别处理每个位置的特征,权重沿时间共享。

为定义前馈,采用数学上的行向量约定:W1W_1 形状为 [d,f][d,f],b1b_1 为 [f][f],W2W_2 为 [f,d][f,d],b2b_2 为 [d][d]。先算 a=xW1+b1a=xW_1+b_1,逐坐标作用非线性,再乘 W2W_2 回到宽度 dd。实际 PyTorch Linear 的存储矩阵是输出宽度在前,所以调用时乘其转置;数学矩阵与存储形状不可混写。

F(x)=g(xW1+b1)W2+b2,g(a)=aΦ(a),Φ(a)=12[1+erf⁡ ⁣(a2)].F(x)=g(xW_1+b_1)W_2+b_2,\qquad g(a)=a\Phi(a),\qquad \Phi(a)=\frac12\left[1+\operatorname{erf}\!\left(\frac a{\sqrt2}\right)\right].
定义:GELU 逐元素执行;Φ 是标准正态累积分布函数,erf 是误差函数。

这里把误差函数作为标准库提供的特殊函数导入,等价定义为 erf⁡(u)=2∫0ue−s2 ds/π\operatorname{erf}(u)=2\int_0^u e^{-s^2}\,ds/\sqrt\pi。因此 Φ\Phi 是积分到当前位置的概率,不是随机掷一次硬币;精确 GELU 前向本身没有抽样。负输入通常得到一个小的负输出,而不是像 ReLU 那样直接变零。若两层之间完全没有非线性,矩阵乘法可合并为一层仿射映射,扩宽再压回就没有增加这部分函数的非线性表达能力。

算例一:一条真正有增量的前馈残差

取一个位置,d=f=2d=f=2,输入 x=(1,3)x=(1,3),两套归一化参数均为默认值。为了单独算清前馈,令注意力输出投影为零,所以注意力增量为零,u=xu=x。前馈设 W1W_1 为单位矩阵,W2W_2 为单位矩阵的一半,两偏置为零。这个特设权重是机制算例,不是建议训练时把全部投影置零。

先算第二套归一化:均值为二,中心化为负一和一,人口方差为一。令 a=1/1.00001a=1/\sqrt{1.00001},得到 z=(−a,a)z=(-a,a),其中 a≈0.999995a\approx0.999995。利用标准正态对称性,Φ(−a)=1−Φ(a)\Phi(-a)=1-\Phi(a);数值上 g(−a)≈−0.1586557g(-a)\approx-0.1586557,g(a)≈0.8413393g(a)\approx0.8413393。右乘半个单位矩阵,增量为约 (−0.0793278,0.4206696)(-0.0793278,0.4206696);加回原输入,块输出约 (0.9206722,3.4206696)(0.9206722,3.4206696)。

这次输出没有要求整体均值为零,因为归一化仅发生在分支入口,残差主路保留原尺度。独立核验可以看两坐标差:由正态对称性,g(a)−g(−a)=ag(a)-g(-a)=a,所以增量第二坐标减第一坐标应恰为 a/2a/2;数值约零点四九九九九七五。这个恒等式与直接用误差函数求两个值是不同的检查路线,可发现正值仍按 GELU、负值却被截零的混合错误;但整函数若被替换为 ReLU,仍能通过此项,必须另查具体数值。

算例二:方差、位置与稳定性的失败对照

仍用向量一、三,若误把方差分母写成 d−1d-1,得到方差二,标准化变为约负零点七零七一与正零点七零七一。形状完全正确,数值却偏了约三成;后续激活和残差都会改变。正确方差应由“平方和二除特征数二”得到一,不是凭输出看起来对称就认定成功。对称性只能检查一部分错误,不能识别分母选错。

再取常量向量 (2,2)(2,2)。均值二,方差零;保留稳定常数时两坐标标准化都为零,输出等于 β\beta。若删掉稳定常数,分母为零,公式未定义;这不是一个可以忽略的小舍入误差。给常量向量加任意共同平移仍应得到相同输出。代码同时计算正确实现、错误分母和零方差边界,使一次成功不能掩盖另外两类故障。

独立运行独立标准库验证:前馈数值、GELU 对称恒等式、平移不变性和零方差边界。
import math
EPS = 1e-5
def layer_norm(x, eps=EPS, sample_variance=False):
    mean = sum(x) / len(x)
    divisor = len(x)-1 if sample_variance else len(x)
    variance = sum((v-mean)**2 for v in x) / divisor
    return [(v-mean)/math.sqrt(variance+eps) for v in x]
def gelu(x):
    return 0.5*x*(1+math.erf(x/math.sqrt(2)))
z = layer_norm([1.0, 3.0])
y = [x+0.5*gelu(v) for x,v in zip([1.0,3.0],z)]
assert abs((gelu(z[1])-gelu(z[0]))-z[1]) < 1e-12
assert layer_norm([2.0,2.0]) == [0.0,0.0]
shifted = layer_norm([11.0,13.0])
assert max(abs(a-b) for a,b in zip(z,shifted)) < 1e-12
expected_var = 1.0/(1.0+EPS)
assert abs(sum(v*v for v in z)/2-expected_var) < 1e-12
print('preLN residual', y)
print('wrong denominator', layer_norm([1.0,3.0],sample_variance=True))
try:
    layer_norm([2.0,2.0],eps=0.0)
except ZeroDivisionError:
    print('expected failure: zero variance without eps')

残差给梯度一条直路,但不保证训练成功

把一个位置或整个张量展平成列向量,只为讨论导数。对任意残差子层写 y=x+R(x)y=x+R(x),其中 RR 是可微增量函数;令 JRJ_R 的第 i,ji,j 项为输出 RiR_i 对输入 xjx_j 的偏导。因为 xix_i 对 xjx_j 的导数为单位矩阵的对应项,对和求导便得 Jy=I+JRJ_y=I+J_R。这条恒等项不需要穿过分支里的归一化、注意力和激活。

∇xℓ=(I+JR)T∇yℓ=∇yℓ+JRT∇yℓ.\nabla_x\ell=(I+J_R)^{\mathsf T}\nabla_y\ell=\nabla_y\ell+J_R^{\mathsf T}\nabla_y\ell.
精确链式法则:ℓ 为标量损失,梯度按列向量记录,T 上标表示转置。

第一项就是从残差主路直接传回的梯度,第二项来自分支。它们相加而非择一保留,因此可以互相抵消。取一维反例 R(x)=−xR(x)=-x,其导数为负一,残差输出恒为零,总导数也为零。故“存在恒等通路”是精确结构性质,“梯度永不消失”却不由它推出。堆叠多个子层时还要连乘各自的雅可比矩阵,不能把单层加法误当成整网梯度恒为一。

若采用 post-LN,形式变为 y=LN⁡(x+R(x))y=\operatorname{LN}(x+R(x)),其导数为归一化雅可比乘 (I+JR)(I+J_R),恒等主路也经过归一化。这个代数区别说明两种结构不同,不构成任意任务下 pre-LN 必定更优的定理。把本项目分支全部置零时,pre-LN 块应严格保持输入;若代码仍输出标准化向量,就很可能把归一化移到了加法后面。模型末尾的 final LayerNorm 是另一层,不属于该块恒等测试。

形状与参数:每一个可学习标量都记账

再做一次形状演练:若批量二、长度三、宽度四、两头、前馈宽度八,则归一化仍为二乘三乘四;每头查询键值为二乘二乘三乘二;注意力权重为二乘二乘三乘三;拼头并投影后回到二乘三乘四。前馈中间量为二乘三乘八,第二线性层恢复二乘三乘四。任何一步意外交换长度轴与特征轴,可能只在二者不相等时暴露,故测试故意使用三与四。

参数账本与激活账本也必须分开。序列变长时,当前激活与注意力矩阵会变大,但线性层矩阵与归一化参数并未随之增长;相反,扩大可用位置表会增加已注册参数,即使本次只输入一个词元也一样。先看参数是否有梯度资格,再看该批次梯度是否非零,最后看优化器是否真正持有它们,是三个不同问题。后两章会分别检验,不能因为此处总数相符就跳过。

注意力的四个投影各有 d2d^2 个权重,合计 4d24d^2;把 Q/K/V 打包成一个 [3d,d][3d,d] 存储矩阵也不改变数量。前馈权重为 df+fd=2dfdf+fd=2df,两偏置为 f+df+d。每套归一化有两个长度 dd 的参数,两套合计 4d4d。相加得到每块 4d2+2df+f+5d4d^2+2df+f+5d。头数只改变这些宽度的划分,在固定 dd 且本架构不变时,不改变此参数总数。

对象数学输入→输出PyTorch 存储/参数数
Q/K/V/O 各投影[...,d]→[...,d]各 [d,d],无 bias;共 4d²
FF 第一层[...,d]→[...,f]weight [f,d],bias [f]
GELU[...,f]→[...,f]零个可学习参数
FF 第二层[...,f]→[...,d]weight [d,f],bias [d]
两套 LayerNorm[...,d]→[...,d]各 γ[d]、β[d];共 4d
残差加法两份 [B,T,d]→[B,T,d]零个可学习参数
数学右乘矩阵与框架存储转置要对应;激活内存不计入参数数。

为了把账本延伸到第七章整机,另定义词表大小 VV、最大位置数 CC、块数 NN。词嵌入和未绑权重的输出 head 各有 VdVd,学习式绝对位置嵌入有 CdCd,最终归一化有 2d2d。当前实际输入长度 TT 不改变已注册位置表的参数量,只须 T≤CT\le C。各块参数互不共享;若误复用同一个块对象,执行虽有多层,参数数却会少。

P=2Vd+Cd+N(4d2+2df+f+5d)+2d.P=2Vd+Cd+N(4d^2+2df+f+5d)+2d.
精确参数账本:无权重绑定、输出 head 无 bias、每块结构如上。

取 V=32,C=64,N=2,d=64,h=4,f=256V=32,C=64,N=2,d=64,h=4,f=256。每块四投影一万六千三百八十四,前馈权重三万二千七百六十八,前馈偏置三百二十,两归一化二百五十六,共四万九千七百二十八。两块九万九千四百五十六;词嵌入加 head 为四千零九十六,位置表四千零九十六,finalLN 一百二十八,总计十万七千七百七十六。这个数字是配置内的精确计数,不是显存字节数,也不包括优化器状态。

独立运行独立参数总数复算;与形状表逐项计数构成第二类检查。
def parameter_count(V,C,N,d,f):
    per_block = 4*d*d + 2*d*f + (f+d) + 4*d
    return 2*V*d + C*d + N*per_block + 2*d
assert parameter_count(32,64,2,64,256) == 107776
assert parameter_count(10,6,2,4,8) == 424
print(parameter_count(32,64,2,64,256))

项目增量与后继依赖

本章在 hero_transformer/model.py 增加两套归一化、逐位置 FFN 与 pre-LN 残差块,把前章因果注意力当作已验证子模块接入。提交时应能展示块输入输出同形、两分支为零时恒等、改变未来词元不影响过去输出、以及参数账本与注册参数一致四项结果。因果检查从注意力层上升到整块,防止归一化轴选错或跨时间的前馈操作重新引入泄漏。

在课程项目目录运行项目检查:需要已经实现的 hero_transformer 与 PyTorch;不能作为标准库独立片段运行。
from hero_transformer.model import Config, TransformerLM
cfg = Config(vocab_size=32,block_size=64,d_model=64,n_heads=4,
             n_layers=2,d_ff=256,dropout=0.0)
model = TransformerLM(cfg)
actual = sum(p.numel() for p in model.parameters())
assert actual == 107776, actual
print('registered parameters', actual)

第六章将沿本章同一张计算图推回 LayerNorm、GELU、线性层和残差的梯度,因此此处的轴、稳定常数与激活约定不能临时更换。第七章再重复调用互不共享的多个块、加入 finalLN 和 head,得到完整 logits 与损失。通过前向和参数检查并不说明每个参数已接入梯度;那是后两章需要继续验证的不同性质。

TRY IT YOURSELF

练习一|基础:给一个小配置算完整账本

设 V=10、C=6、N=2、d=4、h=2、f=8,其他结构不变。计算每块和整机参数数;把 h 改为一,数量会不会改变?

提示 1
先分别数Q/K/V/O、FFN权重与偏置、两套归一化,再汇总整机。
提示 2
总宽度d不变时,改变头数只重新分割维度;词嵌入、位置表与最终归一化别漏记。
对照参考解答
四投影为 4×16=64;前馈两权重为 2×4×8=64;前馈偏置为 8+4=12;两套归一化为 4×4=16。因此每块 156,两块 312。词嵌入与 head 共 2×10×4=80,位置表 6×4=24,最终归一化 8,整机 80+24+312+8=424。h 从二改为一仍整除 d,宽度总量未变,参数数仍为 424;每头宽度从二变四,注意力计算行为可以改变,不能因为参数数相同就认为模型相同。

TRY IT YOURSELF

练习二|推导:精确 GELU 的一条检查恒等式

不查数值表,利用标准正态密度关于零对称,推出 g(a)-g(-a)=a。再解释为何这个检查不能发现所有 GELU 错误。

提示 1
先由正态密度对称写出Φ(−a)与Φ(a)的关系。
提示 2
代入g(−a)并相减;再找一个不同函数也满足同一恒等式,判断该检查是否充分。
对照参考解答
密度对称且积分总量为一,故 Φ(-a)=1-Φ(a)。依定义 g(-a)=(-a)Φ(-a)=-a+aΦ(a);所以 g(a)-g(-a)=aΦ(a)-[-a+aΦ(a)]=a。所有有限实数 a 都成立。可是错误地把 g 写成 ReLU 也满足 ReLU(a)-ReLU(-a)=a,因此该恒等式只是必要而非充分检查。还要在 a=1 或 a=-1 对照误差函数定义得到的约 0.84134 或 -0.15866,才可区分这两种函数。

TRY IT YOURSELF

练习三|诊断:恒等块不等于恒等模型

把每块注意力输出和 FFN 输出强制为零,块测试通过。但完整模型输出仍不同于输入嵌入,是否必然有 bug?列出必须另外检查的两类故障。

提示 1
零分支测试的比较边界是单个block,而完整模型还有哪些逐位置变换?
提示 2
分别检查参数注册账本和未来扰动不变量;它们排查的问题与单块恒等性不同。
对照参考解答
不必然。每块此时保持其输入,但整机仍包含位置嵌入、最终 LayerNorm 和把隐藏宽度映射到词表大小的 head;最终 logits 本来就不等于输入嵌入。测试应在单块边界比较同形张量。另外检查:一,实际注册参数数量是否符合账本,以发现共享块、漏注册或意外 bias;二,扰动未来词元后过去位置输出是否保持,以发现因果 mask、归一化轴或序列混合错误。这些性质与零分支恒等性逻辑独立,不能用一个 PASS 替代全部。

BUILD THE MECHANISM

接通pre-LN残差块

在同一个starter.py实现TransformerBlock.forward,调用上一步学生注意力。本章只接普通整段输入;脚手架中已有cache参数仅保留接口,第10章才要求解释和使用。 下载本课完整项目,在项目根目录运行;只编辑 learner/starter.py。

输入与输出 本章past_key_value=None、use_cache=False;先LN→attention→残差,再LN→FFN→残差。
已提供模块构造、参数、dropout与元组返回接口;复用上一步学生attention,只接无缓存前向。

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

先把每条支路的输入命名,区分原hidden和归一化值。

提示 2

第一次残差后的hidden才是第二个LN的输入。

提示 3

全零分支应恒等;非零固定权重算例才能区分pre/post LN。

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

迁移检验 将FF宽度从7改为11,保持输入输出宽度4;解释两条残差为何均可相加。

参考实现与解释
在课程项目目录运行参考实现
# TransformerBlock.forward(替换已提供类的此方法)
def forward(self, hidden, past_key_value=None, use_cache=False):
        attended, present = self.attention(
            self.ln_attention(hidden), past_key_value, use_cache
        )
        hidden = hidden + attended
        hidden = hidden + self.feedforward(self.ln_feedforward(hidden))
        return hidden, present

CHECK YOUR UNDERSTANDING

检验理解

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

1. 固定 eps>0 且默认仿射参数时,标准化特征的人口方差是?

查看解析平方和除 d 后,分子为原方差 v,分母为 v+eps;常量输入才为零。
2. pre-LN 第二个残差应计算为?

查看解析第二分支必须读取第一残差完成后的 U;本项目归一化位于分支入口。
3. 残差雅可比 I+J_R 可以保证什么?

查看解析R(x)=−x 是总导数为零的反例;结构通路不是普遍训练保证。
4. 固定 d 与 FFN 宽度,只改变合法头数 h,四投影参数数如何变化?

查看解析头数重分配总宽度;在本冻结架构中四个映射仍各有 d² 个权重。

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