目标:让下一次更新可以被完整重做
第七章已经证明模型能接通并拟合一个固定批次。本章把真实文档、不同长度窗口、随机抽样、验证和存档接进同一个训练过程。结束时你应能说明一个 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 表示结束。验证和测试复用这张词表,未知字映射为零,不在评估时偷偷扩词表。词表顺序也是模型语义的一部分:相同权重若配上另一张编号表,即使尺寸不变,输出文字含义也会变。
一个文本长 的文档编码为 ,长度为 ,其中 是字符编号。定义预测对为 ,输入索引 从零到 ,所以有 个目标:每个真实字符一次,另加 EOS 一次;BOS 只作输入。标题不进入训练正文,标点属于字符,未知字仍然计分为 UNK。这个分母决定损失能与什么比较。
窗口边界是建模约定,不只是内存技巧
令 block_size 为正整数 。build_windows 取 start 为零、、,每次 stop 为 start 加 与最后可用输入位置的较小者;inputs 取 s[start:stop],targets 取 s[start+1:stop+1]。窗口不跨文档,目标区间连续但不重叠,因此拼回同文档各窗口 targets 应恰等于完整编码去掉首个 BOS。这个序列相等比只比较 token 数更强。
新窗口位置从零重新编号,注意力上下文也重新开始。边界处的第一个输入可以是上一窗口已经预测过的字符,但它在此只是新上下文起点;目标仍不重复。这种计分不是无限上下文似然,也不是给每个目标都提供完整前缀的滑动评估。改变窗口长度会改变可见上下文和绝对位置,不能把两种窗口分数当作完全相同任务下的纯性能差异。
算例一:从两个文档走到右 padding 与正确分母
设训练词表已有甲、乙、丙、丁、戊,编号三到七;第一篇为“甲乙丙丁戊”,编码是 ,取窗口三。第一个窗口输入 ,目标 ;第二个输入 ,目标 。六个目标正好是五字符加一 EOS。第二篇“甲”编码 ,输入 、目标 ,两篇合计八个计分目标。
把第一窗口和第二篇短窗口组成一批,需要统一长度三。collate 在输入右侧填 EOS,短行变为 ;对应目标右侧填忽略标记 ,变为 。短行的第三位置是人工填充,不参与损失;第二位置预测的 EOS 却是真实目标,必须保留。不能仅按目标值等于 EOS 就忽略,否则模型永远不学习文档结束。
为何输入填 EOS 不会污染前面的真实位置?因果注意力让位置一只能读零与一,无法读取位置二的右侧填充;LayerNorm 和 FFN 又只在同位置处理特征。由第七章的因果归纳,前两位置结果不受后面填充影响。这项结论依赖右填充与因果结构;若改左填充,位置编号和可见前缀都要重新设计,不能自动沿用。
再假设两个评估批次分别含六个有效目标、均值损失零点五,以及两个有效目标、均值损失二。总负对数似然为六乘零点五加二乘二,等于七;除八得到零点八七五。把两批均值直接平均会得到一点二五,使小批次被过度加权;把填充位置也计入分母则会虚假压低损失。项目模型先返回有效 token 均值,evaluate_model 再乘回有效数累计。
# 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 状态,退出时恢复;否则一次验证之后可能让后续训练长期停在评估模式而没有报错。
定义平均自然对数损失为 ,困惑度为 。把定义展开得 ,即真实目标概率几何平均的倒数;它不是词表大小或逐题正确率。报告应带有效数、窗口、词表和未知率。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 |
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 的错误真正被激活。
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
提示 2
对照参考解答
TRY IT YOURSELF
练习二|推导:为什么右填充不影响真实前缀
长度二的窗口右填到四,输入填 EOS、目标填 −100。证明前两位置 logits 在本项目结构下与未填充时相同,并指出改为左填充后哪个前提消失。
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习三|诊断:恢复了 seed 却没恢复下一批
存档时 sampler cursor=5、permutation 已知,dropout=0.2。恢复代码只重新 manual_seed(原seed),并从新构造的模型加载权重。列出两个不同偏差来源与一套验收。