本章目标:从词元到可更新的整台模型
本章把前六章接成一台 Decoder 语言模型。输入是词元编号,输出是每个位置对下一个词元的 logits;给出对齐目标时,同时返回一个能反向传播的标量损失。前提是理解嵌入、因果注意力、pre-LN 块、交叉熵与自动微分。本章不止检查一个层的输出,而要完成整机前向、全部参数的梯度连接、一次真实优化和固定批次的拟合诊断;大规模文本泛化留给后续数据实验。
定义词表大小 、最大上下文长度 、层数 、隐藏宽度 、头数 与前馈宽度 。本章采用学习式绝对位置;词嵌入与输出权重不绑定;两层 FFN 有偏置,Q/K/V/O 与 head 无偏置,所有 LayerNorm 保留第五章的人口方差与稳定常数。训练演示先令 dropout 为零,模型、数据和优化器均在 CPU 上创建,避免把设备差异混入正确性检查。
公开接口先冻结,内部拆分服务于它
Config 接收 vocab_size、block_size、d_model、n_heads、n_layers、d_ff、dropout;分别对应 与丢弃概率。构造时检查所有宽度和层数为正、头数整除隐藏宽度、丢弃概率范围合法。非法配置在构造阶段报错,优于等到某次 reshape 才产生难追踪异常。本章不依赖项目内部给块和投影取什么变量名。
| 公开对象或返回项 | 契约 | 本章检查 |
|---|---|---|
| TransformerLM.forward | tokens, 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] | 保留所有位置,不只最后一行 |
| loss | targets 给出时为标量;未给时为 None | 可 backward;不先转 Python 数字 |
| past_key_values | 字典中的缓存返回项 | 本章不使用缓存;后续单独验收 |
整数 tokens 不能直接送进矩阵乘法。词表嵌入矩阵 以编号取行,位置表 以位置零到 取行。同一位置表跨批次共享,所以位置形状 可在批量轴合法广播;它与第五章警告的意外残差广播不同,因为这里的共享是明确设计。初始化状态记为 。
循环不变量是:第 次循环前, 形状仍为 ,位置 只依赖输入零到 的合法前缀;执行一个正确因果块后,两条性质继续成立。初始化的查表与逐位置相加显然不读取未来位置,因此归纳得到整机隐藏状态的因果性。仅调用最后一个块、把所有块都读同一份初始输入,或在循环内意外覆盖原始 tokens,都不是这个递推。
若给 targets,按第六章对各有效位置计算负对数似然并聚合;本章的固定小批次没有 padding,所以直接平均。返回字典里的 loss 必须仍在计算图中;用于日志的 loss.item() 只生成一个无图的数字,不能把它重新包成张量冒充原 loss。没有 targets 时保留 loss=None,使推理调用不必构造假标签。训练时不返回跨步缓存,以免把历史计算图、过期权重或不同样本前缀串在一起。
算例一:两层整机的可手算极限
为手算暂取二类数学模型,。项目另外保留 UNK/BOS/EOS,要求 vocab_size 至少三,因此此二类配置仅用于标准库理论核查,不直接传给项目 Config。输入两个词元 A、B,编号为零、一;嵌入分别为 、,位置表这两个位置均为零。两块的注意力输出和前馈输出都设为零,所以第一块、第二块的隐藏状态都等于初始嵌入。finalLN 的 gamma 为一、beta 为零,head 数学矩阵为二阶单位矩阵;全部设置均为明确的特设参数。
第一位置均值为零点五,中心化为 ,人口方差零点二五。令 ,则两位置的最终 logits 分别为 、。目标刻意设为 B、A,也就是每一行更低的那个 logit,因此每行损失都是 ,约二点一二六九。平均仍是这个值,不会再除以词表大小。
现在把 head 两列交换,目标就变成每行较高的 logit,损失为 ,约零点一二六九。两者相减恰为 ,因为 。这给整机数据流一个独立代数检查:若代码忘了 finalLN、绑错输出方向、只输出最后位置,或内部又移了一次目标,都无法同时满足这组数值、形状与差值。
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 与数据增强,不做验证集结论。若一个足够表达该小映射的模型仍不能降低训练损失,优先检查接线、目标对齐、梯度与优化器,而不是先换更多训练资料。为避免随机结果被误写成定理,下方代码的门槛是待运行的验收断言;只有实际运行成功后,才可把最终损失写入实验记录。
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 或扰动一层来做诊断,但不能据此擅自改变本章冻结架构。
算例二:同一上下文的矛盾标签无法被“过拟合到零”
构造两条完全相同的输入序列、相同位置、相同可见前缀;在某个位置,第一条目标为零号,第二条为一号。模型确定性执行,且没有样本编号等额外输入,因此这个位置给两条样本的概率必须相同。设零号概率为 ,另一类为 ,两条等权交叉熵的平均为下面函数。
最优概率为一半、一半,损失 。若模型偏向零号到零点九,两条损失分别约零点一零五三与二点三零二六,平均一点二零四,反而更差。因此训练到零点六九附近可能是正确收敛,不是层数不够。把阈值任意设成零点一会使这道题永远失败;修复需要去除矛盾标注、补充能区分样本的信息,或承认任务本身是条件随机分布。
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
提示 2
对照参考解答
TRY IT YOURSELF
练习二|实现诊断:梯度存在但参数没有更新
某块放在普通 Python list 中。forward 确实调用它,参数的 requires_grad=True,loss.backward 后块内参数有 grad,但 optimizer.step 后它不变。为什么?如何验收修复?
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习三|推导:三比一冲突标签的最佳概率
同一上下文出现四次,三次目标为零号,一次为一号。模型没有额外区分信息,推导最优零号概率与最小平均损失。
提示 1
提示 2
对照参考解答
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}