本章要拼出什么,哪些知识已作为前提
前四章已经给出词元、嵌入、位置、因果多头注意力。本章把注意力接入真正可堆叠的解码块,结束时你应能逐项解释一次前向的形状、归一化的具体数字、残差的梯度通路和整台模型的参数数量。前提是会计算均值、平方和、矩阵乘法,并会对一元函数求导;多元链式法则在需要时用局部变化说明。这里不证明任何深层网络一定可训练。
约定批量为 ,当前序列长度为 ,隐藏宽度为 ,头数为 ,每头宽度为 ,前馈隐藏宽度为 。这些都是正整数,且 整除 。块输入 是形状 的实数张量;下文的小写 表示其中一个位置的长度 向量。统计沿最后的特征轴计算,既不跨时间,也不跨批量。所有激活是无量纲数。
本项目冻结为 pre-LN:每个子层先归一化再计算,再加回原输入。每块有两套独立 LayerNorm,注意力的 Q/K/V/O 线性映射无偏置,前馈两层均有偏置。激活采用精确 GELU,归一化稳定常数固定为 。本章先令 dropout 为零,因此前向是确定函数;开启 dropout 后,恒等性与数值对照应固定随机状态或在评估模式下进行。
逐位置 LayerNorm:均值、人口方差与稳定常数
定义 为该位置全部 个特征的算术平均;定义中心化坐标 ;定义人口形式方差 为中心化平方和除以 。这不是从样本估计未知总体方差的统计任务,因此不做除以 的无偏修正。定义 ,再令标准化值 。
参数 与 是训练得到的逐特征缩放和偏移,默认初值分别为一和零;同一套参数在所有批次与位置共享,但统计值每个位置重新算。它们不是把每个位置都压成同一个向量,而是调整标准化后的坐标。如果默认 ,归一化结果的均值为零,因为中心化坐标和为零;其人口方差为 ,并非严格的一。
最后一个结论可以直接代入核查:标准化平方和除以 ,等于原中心化平方和除以 ,分子正是 。当 远大于稳定常数时,它接近一;当 时,它等于零。这里接近一是有条件的数值近似,不能把含稳定常数的公式简写成永远单位方差。仿射参数不为默认值后,均值为零也不再必然成立。
平移检查是一条独立代数检验:对全部特征加同一常数 ,新均值也加 ,所以中心化坐标不变,方差不变,输出不变。正比例缩放却不能照搬这个结论:把 乘 ,分母变为 ,固定的稳定常数没有随之缩放,因此一般只有近似尺度不变。若把整个张量跨时间归一化,未来词元会进入统计,破坏本项目的因果契约。
两条残差分支怎样构成一个块
记 为前章已定义的因果多头注意力映射,它保持输入的 形状;记 为接下来定义的前馈映射。下标一、二表示独立归一化参数,不是同一输出重复使用。第一步用原输入生成注意力增量并相加,得到中间状态 ;第二步根据 生成前馈增量,再与 相加得到块输出 。
这个顺序必须逐项执行。第二套归一化读的是已经加入注意力增量的 ,不是最初的 ;否则你实现的是另一张计算图。相加两端都必须是同一形状,不能依赖广播把错误的 增量复制到全部位置。注意力负责在合法的过去位置之间交换信息,前馈则分别处理每个位置的特征,权重沿时间共享。
为定义前馈,采用数学上的行向量约定: 形状为 , 为 , 为 , 为 。先算 ,逐坐标作用非线性,再乘 回到宽度 。实际 PyTorch Linear 的存储矩阵是输出宽度在前,所以调用时乘其转置;数学矩阵与存储形状不可混写。
这里把误差函数作为标准库提供的特殊函数导入,等价定义为 。因此 是积分到当前位置的概率,不是随机掷一次硬币;精确 GELU 前向本身没有抽样。负输入通常得到一个小的负输出,而不是像 ReLU 那样直接变零。若两层之间完全没有非线性,矩阵乘法可合并为一层仿射映射,扩宽再压回就没有增加这部分函数的非线性表达能力。
算例一:一条真正有增量的前馈残差
取一个位置,,输入 ,两套归一化参数均为默认值。为了单独算清前馈,令注意力输出投影为零,所以注意力增量为零,。前馈设 为单位矩阵, 为单位矩阵的一半,两偏置为零。这个特设权重是机制算例,不是建议训练时把全部投影置零。
先算第二套归一化:均值为二,中心化为负一和一,人口方差为一。令 ,得到 ,其中 。利用标准正态对称性,;数值上 ,。右乘半个单位矩阵,增量为约 ;加回原输入,块输出约 。
这次输出没有要求整体均值为零,因为归一化仅发生在分支入口,残差主路保留原尺度。独立核验可以看两坐标差:由正态对称性,,所以增量第二坐标减第一坐标应恰为 ;数值约零点四九九九九七五。这个恒等式与直接用误差函数求两个值是不同的检查路线,可发现正值仍按 GELU、负值却被截零的混合错误;但整函数若被替换为 ReLU,仍能通过此项,必须另查具体数值。
算例二:方差、位置与稳定性的失败对照
仍用向量一、三,若误把方差分母写成 ,得到方差二,标准化变为约负零点七零七一与正零点七零七一。形状完全正确,数值却偏了约三成;后续激活和残差都会改变。正确方差应由“平方和二除特征数二”得到一,不是凭输出看起来对称就认定成功。对称性只能检查一部分错误,不能识别分母选错。
再取常量向量 。均值二,方差零;保留稳定常数时两坐标标准化都为零,输出等于 。若删掉稳定常数,分母为零,公式未定义;这不是一个可以忽略的小舍入误差。给常量向量加任意共同平移仍应得到相同输出。代码同时计算正确实现、错误分母和零方差边界,使一次成功不能掩盖另外两类故障。
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')残差给梯度一条直路,但不保证训练成功
把一个位置或整个张量展平成列向量,只为讨论导数。对任意残差子层写 ,其中 是可微增量函数;令 的第 项为输出 对输入 的偏导。因为 对 的导数为单位矩阵的对应项,对和求导便得 。这条恒等项不需要穿过分支里的归一化、注意力和激活。
第一项就是从残差主路直接传回的梯度,第二项来自分支。它们相加而非择一保留,因此可以互相抵消。取一维反例 ,其导数为负一,残差输出恒为零,总导数也为零。故“存在恒等通路”是精确结构性质,“梯度永不消失”却不由它推出。堆叠多个子层时还要连乘各自的雅可比矩阵,不能把单层加法误当成整网梯度恒为一。
若采用 post-LN,形式变为 ,其导数为归一化雅可比乘 ,恒等主路也经过归一化。这个代数区别说明两种结构不同,不构成任意任务下 pre-LN 必定更优的定理。把本项目分支全部置零时,pre-LN 块应严格保持输入;若代码仍输出标准化向量,就很可能把归一化移到了加法后面。模型末尾的 final LayerNorm 是另一层,不属于该块恒等测试。
形状与参数:每一个可学习标量都记账
再做一次形状演练:若批量二、长度三、宽度四、两头、前馈宽度八,则归一化仍为二乘三乘四;每头查询键值为二乘二乘三乘二;注意力权重为二乘二乘三乘三;拼头并投影后回到二乘三乘四。前馈中间量为二乘三乘八,第二线性层恢复二乘三乘四。任何一步意外交换长度轴与特征轴,可能只在二者不相等时暴露,故测试故意使用三与四。
参数账本与激活账本也必须分开。序列变长时,当前激活与注意力矩阵会变大,但线性层矩阵与归一化参数并未随之增长;相反,扩大可用位置表会增加已注册参数,即使本次只输入一个词元也一样。先看参数是否有梯度资格,再看该批次梯度是否非零,最后看优化器是否真正持有它们,是三个不同问题。后两章会分别检验,不能因为此处总数相符就跳过。
注意力的四个投影各有 个权重,合计 ;把 Q/K/V 打包成一个 存储矩阵也不改变数量。前馈权重为 ,两偏置为 。每套归一化有两个长度 的参数,两套合计 。相加得到每块 。头数只改变这些宽度的划分,在固定 且本架构不变时,不改变此参数总数。
| 对象 | 数学输入→输出 | 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] | 零个可学习参数 |
为了把账本延伸到第七章整机,另定义词表大小 、最大位置数 、块数 。词嵌入和未绑权重的输出 head 各有 ,学习式绝对位置嵌入有 ,最终归一化有 。当前实际输入长度 不改变已注册位置表的参数量,只须 。各块参数互不共享;若误复用同一个块对象,执行虽有多层,参数数却会少。
取 。每块四投影一万六千三百八十四,前馈权重三万二千七百六十八,前馈偏置三百二十,两归一化二百五十六,共四万九千七百二十八。两块九万九千四百五十六;词嵌入加 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 残差块,把前章因果注意力当作已验证子模块接入。提交时应能展示块输入输出同形、两分支为零时恒等、改变未来词元不影响过去输出、以及参数账本与注册参数一致四项结果。因果检查从注意力层上升到整块,防止归一化轴选错或跨时间的前馈操作重新引入泄漏。
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
提示 2
对照参考解答
TRY IT YOURSELF
练习二|推导:精确 GELU 的一条检查恒等式
不查数值表,利用标准正态密度关于零对称,推出 g(a)-g(-a)=a。再解释为何这个检查不能发现所有 GELU 错误。
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习三|诊断:恒等块不等于恒等模型
把每块注意力输出和 FFN 输出强制为零,块测试通过。但完整模型输出仍不同于输入嵌入,是否必然有 bug?列出必须另外检查的两类故障。
提示 1
提示 2
对照参考解答
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