AI Hero Club

Transformer 实战 · 第 8 章

已自行标记完成0 / 12

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

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

CHAPTER 08 / 12建议学习 120 分钟

训练、数据与可复现恢复

训练被中断后,怎样恢复模型、优化器、抽样与随机状态,让下一步更新仍然相同?

本章要解决的 4 个问题
  • 以固定文档 ID 划分并构造每字符及 EOS 一次计分的窗口
  • 推导右 padding、忽略目标和有效 token 加权评估
  • 沿真实 API 运行训练与 eval/no_grad 验证
  • 保存并检验模型、优化器、采样器及 torch RNG 的完整恢复
课程项目 ZIP ↓

目标:让下一次更新可以被完整重做

第七章已经证明模型能接通并拟合一个固定批次。本章把真实文档、不同长度窗口、随机抽样、验证和存档接进同一个训练过程。结束时你应能说明一个 loss 的分母、每个目标从哪篇文档来,以及中断后下一步为什么与不停机训练相同。前提是理解交叉熵、AdamW 状态、因果掩码和模型的公开接口;本章的精确恢复范围限定为相同 CPU、线程与项目锁定软件环境,不承诺跨设备逐位一致。

先切文档,再建立词表与窗口

项目 data/documents.jsonl 含二十四篇原创短文,每篇有稳定 id 和 text;splits.json 明确列出十八篇训练、三篇验证、三篇测试。split_documents 先检查编号唯一、三集合互斥、并且恰好覆盖原件,随后才编码与建窗口。若先把一篇文章切成许多重叠窗口再随机分组,同一上下文很容易同时进入训练与验证,分数会掺入记忆效应。

文档 ID 隔离是必要检查,但不能检测两个不同 ID 下的重复正文或语义近似文章。若更换真实语料,还需审查重复来源;本项目没有实现通用近重复检测,因此不能把编号互斥夸大为已经排除一切信息泄漏。相同常用汉字出现在多个集合是正常现象,不等于泄漏;需要防的是同一任务答案或原文片段通过不当划分被提前看见。

CharTokenizer.fit 只读取训练文档,把出现过的 Unicode 字符排序后建立编号;保留 UNK=0、BOS=1、EOS=2。UNK 表示词表外字符,BOS 表示文档开始,EOS 表示结束。验证和测试复用这张词表,未知字映射为零,不在评估时偷偷扩词表。词表顺序也是模型语义的一部分:相同权重若配上另一张编号表,即使尺寸不变,输出文字含义也会变。

一个文本长 nn 的文档编码为 s=(BOS,c1,…,cn,EOS)s=(BOS,c_1,\ldots,c_n,EOS),长度为 n+2n+2,其中 cic_i 是字符编号。定义预测对为 (si,si+1)(s_i,s_{i+1}),输入索引 ii 从零到 nn,所以有 n+1n+1 个目标:每个真实字符一次,另加 EOS 一次;BOS 只作输入。标题不进入训练正文,标点属于字符,未知字仍然计分为 UNK。这个分母决定损失能与什么比较。

窗口边界是建模约定,不只是内存技巧

令 block_size 为正整数 CC。build_windows 取 start 为零、CC、2C2C,每次 stop 为 start 加 CC 与最后可用输入位置的较小者;inputs 取 s[start:stop],targets 取 s[start+1:stop+1]。窗口不跨文档,目标区间连续但不重叠,因此拼回同文档各窗口 targets 应恰等于完整编码去掉首个 BOS。这个序列相等比只比较 token 数更强。

新窗口位置从零重新编号,注意力上下文也重新开始。边界处的第一个输入可以是上一窗口已经预测过的字符,但它在此只是新上下文起点;目标仍不重复。这种计分不是无限上下文似然,也不是给每个目标都提供完整前缀的滑动评估。改变窗口长度会改变可见上下文和绝对位置,不能把两种窗口分数当作完全相同任务下的纯性能差异。

算例一:从两个文档走到右 padding 与正确分母

设训练词表已有甲、乙、丙、丁、戊,编号三到七;第一篇为“甲乙丙丁戊”,编码是 (1,3,4,5,6,7,2)(1,3,4,5,6,7,2),取窗口三。第一个窗口输入 (1,3,4)(1,3,4),目标 (3,4,5)(3,4,5);第二个输入 (5,6,7)(5,6,7),目标 (6,7,2)(6,7,2)。六个目标正好是五字符加一 EOS。第二篇“甲”编码 (1,3,2)(1,3,2),输入 (1,3)(1,3)、目标 (3,2)(3,2),两篇合计八个计分目标。

把第一窗口和第二篇短窗口组成一批,需要统一长度三。collate 在输入右侧填 EOS,短行变为 (1,3,2)(1,3,2);对应目标右侧填忽略标记 −100-100,变为 (3,2,−100)(3,2,-100)。短行的第三位置是人工填充,不参与损失;第二位置预测的 EOS 却是真实目标,必须保留。不能仅按目标值等于 EOS 就忽略,否则模型永远不学习文档结束。

为何输入填 EOS 不会污染前面的真实位置?因果注意力让位置一只能读零与一,无法读取位置二的右侧填充;LayerNorm 和 FFN 又只在同位置处理特征。由第七章的因果归纳,前两位置结果不受后面填充影响。这项结论依赖右填充与因果结构;若改左填充,位置编号和可见前缀都要重新设计,不能自动沿用。

M=∑b,t1ybt≠−100,L=∑b,t:ybt≠−100−log⁡p(ybt∣xb,≤t)M.M=\sum_{b,t}\mathbf1_{y_{bt}\ne-100},\qquad L=\frac{\sum_{b,t:y_{bt}\ne-100}-\log p(y_{bt}\mid x_{b,\le t})}{M}.
定义:M 为有效目标数且必须大于零;忽略标记不在词表中,不参与分子和分母。

再假设两个评估批次分别含六个有效目标、均值损失零点五,以及两个有效目标、均值损失二。总负对数似然为六乘零点五加二乘二,等于七;除八得到零点八七五。把两批均值直接平均会得到一点二五,使小批次被过度加权;把填充位置也计入分母则会虚假压低损失。项目模型先返回有效 token 均值,evaluate_model 再乘回有效数累计。

独立运行独立标准库:目标拼接不变量、一次计分、右 padding 和加权聚合。
# Standard-library window and aggregation reference.
BOS,EOS=1,2
def windows(ids,C):
    return [(ids[a:min(a+C,len(ids)-1)],
             ids[a+1:min(a+C,len(ids)-1)+1])
            for a in range(0,len(ids)-1,C)]
first=windows([BOS,3,4,5,6,7,EOS],3)
second=windows([BOS,3,EOS],3)
assert [y for _,ys in first for y in ys]==[3,4,5,6,7,EOS]
assert sum(len(y) for _,y in first+second)==8
batch=[first[0],second[0]]; T=max(len(x) for x,_ in batch)
x=[a+[EOS]*(T-len(a)) for a,_ in batch]
y=[b+[-100]*(T-len(b)) for _,b in batch]
assert y==[[3,4,5],[3,2,-100]]
weighted=(6*0.5+2*2.0)/(6+2)
assert abs(weighted-0.875)<1e-12
print('inputs',x,'targets',y,'weighted loss',weighted,'wrong batch mean',(0.5+2)/2)

抽样状态与训练循环的共同边界

StatefulBatchSampler 不从全部可能窗口中无限独立抽样,而是用自己的 CPU torch.Generator 产生一轮窗口编号排列,再用 cursor 顺序取下一批;最后一批可以比 batch_size 短。排列耗尽才 epoch 加一并洗牌。这里一个 epoch 是所有已建窗口被访问一次,不代表每个文档权重相同:长文窗口多,自然贡献更多训练位置。

采样器状态包括窗口数、批量大小、当前 permutation、cursor、epoch 和本地 generator_state。只保存 seed 会从随机序列起点重放,无法知道已消费多少排列;只存 cursor 又无法重建当轮顺序。项目没有 Python random、NumPy 随机或多进程数据 worker,故不声称已处理它们的恢复;若以后引入,就必须扩展状态契约。

真实 train_step(model, optimizer, sampler, windows) 先设 train 模式,从 sampler.next_indices() 取编号,再 collate,清空梯度,取得 loss,backward,裁剪全局范数到一,optimizer.step。返回当前 loss、有效目标数、裁剪前梯度范数和窗口编号。外层完成一次更新才 step 加一;存档时 step 表示已经完成多少次更新,而非下一批的标签。

训练日志的区间均值按有效目标数加权,验证则在固定验证窗口上完整遍历,不抽训练随机批次冒充验证。eval 模式关闭 dropout 等训练行为,no_grad 关闭梯度记录,二者作用不同,必须同时使用。evaluate_model 记住进入前的 training 状态,退出时恢复;否则一次验证之后可能让后续训练长期停在评估模式而没有报错。

定义平均自然对数损失为 LL,困惑度为 exp⁡(L)\exp(L)。把定义展开得 exp⁡[−∑ilog⁡pi/M]=(∏ipi)−1/M\exp[-\sum_i\log p_i/M]=(\prod_i p_i)^{-1/M},即真实目标概率几何平均的倒数;它不是词表大小或逐题正确率。报告应带有效数、窗口、词表和未知率。UNK 合并多个未见字,会改变预测事件,不能与另一种词元化的大模型困惑度直接对比。

算例二:只恢复权重为何走向另一条更新轨迹

用标量 AdamW 的一个可手算特例隔离优化器状态:初始参数一,学习率零点一,两个 beta 为零点九、零点九九,衰减零;为精确算数暂令优化稳定项零,下面平方矩始终为正,因此没有除零。依次给梯度一、负一。第一步一阶矩零点一、二阶矩零点零一,偏置校正后均为一,自适应更新零点一,参数变为零点九。

第二步保留状态时,一阶矩为零点九乘零点一减零点一,等于负零点零一;二阶矩为零点九九乘零点零一加零点零一,等于零点零一九九。校正后,一阶矩除一减零点九平方,得到负一除十九;二阶矩除一减零点九九平方,正好为一。因此参数增加零点一除十九,变为约零点九零五二六三。

若只加载参数零点九,却重建全新优化器,负一被当作第一步梯度,校正后自适应比值为负一,参数直接增加零点一到一。二者即使下一批与梯度完全相同,也相差约零点零九四七三七。实际训练梯度还依赖前面参数、采样与 dropout,偏差会继续传递。这里是严格状态反例,不是对某次真实训练恢复结果的拟合解释。

独立运行独立标准库:同一权重与下一梯度,是否恢复矩和步数会产生可计算差异。
import math, copy
def step(state,g):
    state=copy.deepcopy(state)
    state['t']+=1
    state['m']=0.9*state['m']+0.1*g
    state['v']=0.99*state['v']+0.01*g*g
    mh=state['m']/(1-0.9**state['t'])
    vh=state['v']/(1-0.99**state['t'])
    state['theta']-=0.1*mh/math.sqrt(vh)
    return state
start={'theta':1.0,'m':0.0,'v':0.0,'t':0}
saved=step(start,1.0)
continuous=step(saved,-1.0)
restored=step(copy.deepcopy(saved),-1.0)
weights_only=step({'theta':saved['theta'],'m':0.0,'v':0.0,'t':0},-1.0)
assert continuous==restored
assert abs(continuous['theta']-(0.9+0.1/19))<1e-12
assert abs(weights_only['theta']-1.0)<1e-12
print('complete restore',continuous,'weights only',weights_only)

真实 checkpoint 保存哪些状态,按什么次序恢复

状态为什么必须保存项目实现字段
模型结构与权重重建同一计算图与数值config、model
优化器与已完成步数矩、每参数计步、超参数决定下一更新optimizer、step
分词与数据来源编号含义、文档划分和窗口必须相同tokenizer、run_manifest.data
采样进度与局部随机流下一批与下一轮排列一致sampler 的 permutation/cursor/epoch/generator_state
全局 torch 随机流dropout 等下一次随机操作一致torch_rng_state
执行环境约束浮点顺序与算法选择environment 中 CPU、torch、threads
项目固定 CPU 训练;有学习率调度器或混合精度缩放器的扩展还需保存对应状态,本项目未使用。

save_checkpoint(path, model, optimizer, sampler, step, tokenizer, run_manifest) 写入临时文件后原子替换目标,避免读到只写一半的最新文件。这不等于多文件事务或断电后持久性的完整保证。checkpoint.py 的 checkpoint_summary 只读摘要,不能替代训练恢复;evaluate.py 的 load_model_checkpoint 只恢复推理模型,也不提供优化器与采样进度。

真实训练恢复函数名是 restore_training(checkpoint, windows, expected_corpus_sha256=None),返回 model、optimizer、sampler、payload。先读取配置、重建模型并加载权重,再重建优化器和采样器并加载各自状态;最后才恢复全局 torch RNG。因为模型构造会消费随机数,若先恢复 RNG 再构造,新模型初始化会把随机流往前推,后续 dropout 就不再与连续训练对齐。

本项目 CLI 在恢复前用保存的 tokenizer 和 block_size 重新准备数据,再比较内容规范化哈希;同一文档 ID 但改过正文或 split 也会被拒绝。restore_training 还要求 PyTorch 版本和 CPU 线程数相同。恢复命令的 --steps 是目标总步数,存到一百六十再写二百意味着续跑四十步,不是再加二百;训练 batch_size 从 checkpoint 恢复,新命令的 batch-size 只影响评估分批。

独立于“能加载文件”的恢复验收

从同一中间状态分两条分支:甲不中断继续三步,乙先存档、重建并恢复后继续三步。逐步比较窗口编号与 loss,再比较全部模型张量、优化器状态、采样器状态与全局 RNG。只有加载无异常远远不够;即使最终模型很接近,也可能下一批已不同。检查时使用非零 dropout,可以让漏存 RNG 的错误真正被激活。

在课程项目目录运行项目恢复验收:真实 API、非零 dropout、四步存档后三步对照;需要固定项目环境,不能作为标准库片段运行。
import copy, tempfile
from pathlib import Path
import torch
from hero_transformer.data import prepare_data, StatefulBatchSampler
from hero_transformer.model import Config, TransformerLM
from hero_transformer.train import configure_cpu, train_step, save_checkpoint, restore_training
configure_cpu(7,threads=1)
tokenizer,all_windows,manifest=prepare_data(block_size=8)
windows=all_windows['train']
model=TransformerLM(Config(tokenizer.vocab_size,8,16,2,2,32,0.2))
optimizer=torch.optim.AdamW(model.parameters(),lr=0.003)
sampler=StatefulBatchSampler(len(windows),3,seed=19)
for _ in range(4): train_step(model,optimizer,sampler,windows)
def equal(a,b):
    if isinstance(a,torch.Tensor): return isinstance(b,torch.Tensor) and torch.equal(a,b)
    if isinstance(a,dict): return a.keys()==b.keys() and all(equal(a[k],b[k]) for k in a)
    if isinstance(a,(list,tuple)): return len(a)==len(b) and all(equal(x,y) for x,y in zip(a,b))
    return a==b
with tempfile.TemporaryDirectory() as directory:
    path=Path(directory)/'resume.pt'
    save_checkpoint(path,model,optimizer,sampler,4,tokenizer,{'seed':7,'data':manifest})
    before=[train_step(model,optimizer,sampler,windows) for _ in range(3)]
    expected=(copy.deepcopy(model.state_dict()),copy.deepcopy(optimizer.state_dict()),
              copy.deepcopy(sampler.state_dict()),torch.get_rng_state().clone())
    resumed,opt,samp,payload=restore_training(path,windows,manifest['corpus_sha256'])
    after=[train_step(resumed,opt,samp,windows) for _ in range(3)]
    assert payload['step']==4
    assert [x['indices'] for x in before]==[x['indices'] for x in after]
    assert [x['loss'] for x in before]==[x['loss'] for x in after]
    actual=(resumed.state_dict(),opt.state_dict(),samp.state_dict(),torch.get_rng_state())
    assert equal(expected,actual)
print('same-environment continuation matches model/optimizer/sampler/RNG')

当前项目保存的 evidence/smoke-run/metrics.json 记录 d=32、窗口十六、两层、batch 八训练一百六十步:全训练集损失约 5.962669 到 2.026304,验证约 5.964481 到 5.435705。验证未知字符率约百分之十三点一四,测试约百分之十七点五四。这些是已有证据文件中的一次结果,不是本章代码新跑出的成绩,更不能由训练损失下降推出通用中文能力。

本章项目增量是 data.py 的可追溯窗口、train.py 的完整循环与恢复、evaluate.py 的有效目标加权,以及 checkpoint.py 的只读检查入口。交付应包括 split、词表、配置、数据哈希、逐段日志、验证结果和连续/恢复对照。后继生成章节使用同一词表和 checkpoint,但推理缓存不属于训练恢复状态;性能章节也必须区分更新步、输入/输出 token 与真正计时范围。

TRY IT YOURSELF

练习一|基础:计数与平均的两个陷阱

三篇文档长度分别为二、零、五个字符,均编码 BOS 和 EOS,窗口不重叠计分。总有效目标多少?两个批次有效数二与六,均值损失一与三,总体损失多少?

提示 1
每篇文档的有效目标数来自BOS后的预测链,包含结束事件。
提示 2
后半题独立累加两批的NLL总和与有效数,别把前半文档例子的分母混进来。
对照参考解答
每篇目标数为字符数加一,所以分别三、一、六,共十。空文档仍有 BOS→EOS 的一个目标,不出现全忽略批次。两批累计负对数似然为二乘一加六乘三等于二十,总体损失为二十除八等于二点五,不是批均值平均二,也不应再除词表大小。计分总数的文档例子与后半批次例子是两套独立输入,不能把十误拿来当后半分母。

TRY IT YOURSELF

练习二|推导:为什么右填充不影响真实前缀

长度二的窗口右填到四,输入填 EOS、目标填 −100。证明前两位置 logits 在本项目结构下与未填充时相同,并指出改为左填充后哪个前提消失。

提示 1
证明填充不影响过去,可从最初嵌入相同开始逐块归纳。
提示 2
检查注意力可见键、归一化轴与FFN是否逐位置;左填充先问绝对位置编号是否还相同。
对照参考解答
词元嵌入与前两位置的绝对位置编号未变。每个因果注意力行只能访问不晚于当前的位置,因此前两行不会读取新增第三、四列;逐位置归一化与 FFN 也不混入后面位置。对每个残差块归纳,前两隐藏状态相同,finalLN/head 仍逐位置处理,所以 logits 相同,浮点实现用明确容差核验。左填充会改变真实词元的位置编号与可见键集合,这个归纳的初始条件不再成立,必须另行设计位置和 mask。

TRY IT YOURSELF

练习三|诊断:恢复了 seed 却没恢复下一批

存档时 sampler cursor=5、permutation 已知,dropout=0.2。恢复代码只重新 manual_seed(原seed),并从新构造的模型加载权重。列出两个不同偏差来源与一套验收。

提示 1
相同seed表示随机流起点相同,不表示已经消耗到同一位置。
提示 2
分别核对dropout的全局RNG与sampler的排列/游标/局部generator,再检查恢复后第一步输入和loss。
对照参考解答
一,重新给相同 seed 只回到随机流起点,不能恢复已经消费的 dropout 全局 torch RNG;构造模型又消耗随机数,次序也会改变后续掩码。二,新 sampler 的排列和 cursor 从起点开始,下一批可能重复已训练样本,局部 generator 的下一轮状态也不同。修复需恢复模型、优化器、采样完整状态,最后恢复全局 RNG。验收使用非零 dropout,连续与恢复分别跑同样若干步,比较每步 indices、loss、所有模型和优化器张量、sampler 与 RNG;只看最终 loss 接近无法定位或排除两种偏差。

CHECK YOUR UNDERSTANDING

检验理解

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

1. 某文档有五个字符,加入 BOS/EOS 后应有多少计分目标?

查看解析BOS 只作输入,五字符与一个 EOS 各计分一次。
2. 输入右填 EOS,对应人工目标应填什么?

查看解析真实 EOS 目标仍需计分;人工填充目标才忽略。
3. 恢复全局 torch RNG 应放在何时?

查看解析构造随机权重会消费随机数,最后恢复才能对齐下一次随机操作。
4. 项目一次完整评估的窗口边界怎样处理?

查看解析非重叠目标计分与跨窗口上下文延续是两个不同问题。

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