预训练任务与训练流程
数据已经整理好,网络也写好了。接下来似乎只要加一个 for 循环:读数据,算损失,反向传播,更新参数。
真正跑起来,却很快会碰到一些小问号。短句末尾补了空位,这些空位算不算训练目标?几条短句拼进一个块,后一句能不能看到前一句?训练跑到一半停电,重新读入模型文件,是否真的回到了刚才的状态?
这些细节不像网络结构图那样显眼,却会改变模型接受的训练。这一章把它们放到一张桌上,最后用一个可以中断、恢复并逐项比较的实验收尾。
1. 同一段文字,可以出两种练习题
自回归:接下来是什么
第十八到二十一章已经用过自回归语言建模。给定真实前缀,让模型预测下一个词元:
输入:BOS 我 爱 喝 茶
目标: 我 爱 喝 茶 EOS
每一列都有自己的预测任务。训练时,模型直接拿到真实前缀;生成时,它才需要把自己刚写出的词元接回输入。
对长度为 的目标序列,一种平均损失写法是:
是模型参数, 是当前位置之前的真实词元。边界符怎样计入长度,需要在实现中固定;本书的脚本把 EOS 当作需要预测的目标,不把 PAD 当作目标。
因果掩码让第 个预测只能利用它的前缀。虽然 GPU 或 CPU 可以一次处理整个矩阵,未来信息仍然不能穿过掩码。
掩码建模:这里缺了什么
另一种练习把句子中的一部分藏起来:
原句:我 爱 喝 茶
输入:我 爱 MASK 茶
目标:只在第三个位置预测“喝”
这时,“茶”也能帮助判断空缺。模型需要双向上下文,损失只在选中的位置计算。设 是选中位置的集合, 是经过遮挡或扰动的输入:
这里介绍的是简化的掩码语言建模任务。原始 BERT 采用双向 Transformer,并有具体的输入扰动与预训练任务设计;不能把简化例子直接当作原论文的完整复现。
两个任务都能从文本本身构造目标,所以常被称为自监督学习。不过,它们学到信息的路径不同。给因果解码器插入 MASK,却不改注意力可见范围,并不会自动得到双向编码器;训练了填空模型,也不意味着能原样套用自回归生成循环。
图 1:左图的三角形表示因果可见范围。右图突出填空位置可以利用两侧文本;实际双向网络会同时计算所有输入位置。
2. 组批次:形状整齐,损失才好算
回忆第二十一章的一个训练块:输入 x 与目标 y 都是形状 的整数张量。 是批次中的块数, 是每块处理的位置数。
实际文档长短不一。示例选出 32 条文本,一半保留完整短句,一半只留下第一句;加上边界目标后,每条分别有 27 或 15 个真实目标。为了排成矩阵,我们把每条补到 32 个位置。
补齐让形状统一,却不应改变题目。因此,PAD 对应的目标被排除在损失之外:
在有效目标位置取 1,在 PAD 位置取 0; 是该位置的交叉熵。分母是实际预测的词元数,不是固定的 。
这个写法相当于让每个有效词元等权。若先对每篇文档求平均,再对文档求平均,短文中的每个词元会获得更大的相对权重。两者都可以被定义为目标,但不能在不知情的情况下混用。
我们的文档靠左、PAD 补在右边,又采用因果注意力。因此,有效输入位置不会看到右边的 PAD;它们的预测不受那些未来空位影响。双向模型、左侧填充或其他布局不能直接沿用这个理由,需要额外处理注意力的填充掩码。
3. 梯度累积:分几次计算,同一个总目标
假如内存只放得下 8 条样本,却希望每次更新使用 16 条,可以先算前 8 条的梯度,再加上后 8 条的梯度,最后只更新一次参数。这叫梯度累积。
这里有个容易漏掉的细节:两个微批次的有效词元可能不一样多。如果各自求平均,再机械地除以 2,不一定得到整批词元平均。
设第 个微批次的损失和为 ,有效目标数为 。我们要的是:
实现时先数出整批的有效目标总数 ,每个微批次都用同一个分母:
loss = microbatch_loss_sum / total_valid_targets
loss.backward()
这样累积后的梯度就是这些微批次计算所得总目标的梯度。等所有微批次结束,再做一次梯度裁剪、一次优化器更新,随后清空梯度。
看一个只有两个微批次的例子:第一个有 4 个目标,平均损失 2;第二个有 2 个目标,平均损失 5。平均两个均值得到 3.5,但词元平均应当是:
如果模型带有 Dropout,拆成不同微批次时可能使用不同随机掩码,不能要求它与一次大批次前向逐位相同。本章的精确恢复比较会保持相同的微批次划分;检查梯度累积公式时,则可以关闭 Dropout,并允许浮点求和顺序带来的微小差异。
4. 序列拼接:省掉空位以后,边界还在吗
把两条短文拼进一个长块,可以少算一些 PAD。这常被称为 packing。但“拼进去”至少有两种不同含义。
一种把文档接成连续流,只用 EOS 标出边界,后面的文本仍可注意前面的文本。另一种希望每篇文档保持独立,即使存放在同一块里,也不能跨文档读取信息。
EOS 是一个输入符号,不会自动重置注意力。如果需要独立文档语义,就要显式设置边界。用最小例子看看:
整段: BOS a EOS | BOS b EOS
输入: BOS a EOS | BOS b
目标: a EOS BOS | b EOS
独立文档方案让输入中的前三个位置属于 A,后两个属于 B。允许注意的位置需同时满足“在当前文档里”和“不在未来”:
是第 个输入位置的文档编号。与此同时,A 的 EOS 不再承担“预测下一篇 BOS”的任务;这个跨边界目标也从损失中去掉。
图 2:行是发出查询的位置,列是可读取的位置。独立方案还需要配合目标掩码;本例把每篇文档的位置编号也重置。
位置编号是否重置,与具体位置方案和训练约定有关。本例的输入位置是 [0, 1, 2, 0, 1],每篇从 0 开始。
本章代码会生成这份掩码和目标选择,方便逐格检查。实际训练仍采用一篇一块、右侧填充;我们没有把展示掩码悄悄当成已经实现的打包加速。要真正接入 packing,还需要让网络接收分组掩码与位置编号,并检验跨文档隔离。
5. 调度:学习率要跟着哪个时钟走
第十四章介绍过学习率。较长训练还常用一段预热:先从较小的步长开始,再逐渐达到设定值;之后按计划衰减。
本例只运行 12 次优化器更新,前三步的学习率依次是 0.001、0.002、0.003,随后用余弦曲线降到 0.0003:
是预热结束后已经走过的比例,范围为 。这只是一个容易观察的训练计划,不是在说余弦调度对所有任务都最好。
图 3:横轴是优化器更新次数。一次更新里的两个微批次,共用同一个学习率。
如果按更新次数调度,改变有效批次大小就会改变每个学习率阶段看过的数据量。做跨配置比较时,也可以按累计有效词元或计算量定义进度。关键是选定一个时钟并记下来:恢复训练时,不能让时钟突然回到零。
6. 断点恢复:模型只是训练状态的一部分
第二十一章保存的推理快照,包含网络配置、词表和模型参数,足以让模型继续生成。但继续训练还需要更多东西。
| 状态 | 不恢复可能发生什么 |
|---|---|
| 模型参数与网络配置 | 从错误的网络或权重开始 |
| 优化器状态 | Adam 的历史一阶、二阶估计丢失 |
| 已完成更新数与调度计划 | 学习率走回起点,或衰减节奏改变 |
| 数据位置或采样器状态 | 读到另一批样本 |
| 随机数状态 | Dropout 等随机操作走上另一条路径 |
| 词表、数据指纹与环境信息 | 同一个 ID 可能指向不同字符或数据版本 |
示例用有放回抽样,每一步由一个独立的 torch.Generator 选择 16 条样本;Dropout 使用 PyTorch 的 CPU 随机数状态。两者都保存。
恢复时,先建立网络并读入权重、建立并恢复优化器,最后再恢复全局随机数状态。因为创建网络本身会抽取随机参数,如果先恢复随机数、再初始化网络,就已经多消耗了一段随机序列。
这里把检查点保存在一次优化器更新完成、梯度清空以后。若在梯度累积中途保存,还得记下尚未应用的梯度、已完成微批次等状态。先选一个清楚的边界,恢复逻辑会简单很多。
本例只使用 CPU,且固定线程、数据顺序、软件版本和确定性算法。若程序还使用 Python 或 NumPy 的全局随机数、GPU 随机数、多进程数据加载、混合精度缩放器或分布式采样器,也需要处理相关状态。相同种子本身不能保证跨设备、跨版本逐位复现;具体限制可参考 PyTorch 的复现说明。
7. 动手:把连续训练切成两半
本实验沿用 tiny_transformer.py 与 small_language_model.py。如果单独下载,请把它们和 pretraining_loop.py 放在同一目录。
环境与上一章训练实验相同:Python 3.12、PyTorch 2.14.1,CPU 单线程。可以在已有虚拟环境运行,也可以在仓库根目录执行:
uv run --python 3.12 --with-requirements requirements.txt python src/examples/pretraining_loop.py
程序做三件事:连续训练 12 步;另起一次相同初始化的训练,在第 6 步保存并恢复;再做一个故意丢掉优化器历史的对照。
连续训练与恢复训练:
12 步损失逐项完全相同:True
最终参数逐项完全相同:True
不恢复优化器时仍完全相同:False
首步损失=3.540136;末步损失=3.177099
每条样本的真实目标数:[15, 27];微批次 8,有效批次 16
分组因果掩码:
1 0 0 0 0
1 1 0 0 0
1 1 1 0 0
0 0 0 1 0
0 0 0 1 1
参与损失的位置:[1, 1, 0, 1, 1]
重置后的输入位置:[0, 1, 2, 0, 1]
12 步当然没有训练出一个好语言模型。这里检验的是训练状态能否接上,而不是生成能力。
还可以分两个独立进程运行:
uv run --python 3.12 --with-requirements requirements.txt python src/examples/pretraining_loop.py --save runs/resume.pt
uv run --python 3.12 --with-requirements requirements.txt python src/examples/pretraining_loop.py --resume runs/resume.pt
第二条命令应报告从第 6 步继续到第 12 步,末步损失仍为 3.177099。它读取原检查点继续计算,不覆盖该文件。若词表、数据指纹或训练计划与检查点不同,示例会直接报告不匹配,避免把另一场实验当成无缝续训。
展开完整代码:损失加权、学习率、检查点与恢复
"""第 23 章:词元加权的梯度累积,以及 CPU 上可精确恢复的训练。"""
import argparse
from hashlib import sha256
from math import cos, pi
from pathlib import Path
from tempfile import TemporaryDirectory
import torch
from torch import nn
from tiny_transformer import TinyTransformer
from small_language_model import PAD, corpus, vocabulary, make_windows
PLAN = dict(total=12, warmup=3, peak=0.003, floor=0.0003, batch=16, micro=8)
class DropoutLanguageModel(TinyTransformer):
def __init__(self, dropout=0.1, **kwargs):
super().__init__(**kwargs)
self.dropout = nn.Dropout(dropout)
self.config["dropout"] = dropout
def forward(self, ids):
length = ids.shape[1]
if not 1 <= length <= self.config["max_context"]:
raise ValueError("输入长度超出位置表范围")
positions = torch.arange(length, device=ids.device)
x = self.dropout(self.tokens(ids)+self.positions(positions))
return self.output(self.final_norm(self.blocks(x)))
def learning_rate(step, plan):
if step < plan["warmup"]:
return plan["peak"]*(step+1)/plan["warmup"]
fraction = (step-plan["warmup"]+1)/(plan["total"]-plan["warmup"])
return plan["floor"] + (plan["peak"]-plan["floor"])*(1+cos(pi*fraction))/2
def dataset():
train, _, _ = corpus()
vocab = vocabulary(train)
# 16 条全文,16 条只留第一句,使每个样本的有效目标数不同。
texts = [text if i % 2 == 0 else text[:14] for i, text in enumerate(train[:32])]
fingerprint = sha256("\n".join(texts).encode()).hexdigest()
return make_windows(texts, vocab, 32), vocab, fingerprint
def fresh(vocab):
torch.manual_seed(2026)
model = DropoutLanguageModel(vocab_size=len(vocab), width=16).double()
optimizer = torch.optim.AdamW(model.parameters(), lr=PLAN["peak"], weight_decay=0.01)
sampler = torch.Generator().manual_seed(2027)
return model, optimizer, sampler
def accumulate(model, x, y, micro):
# 每个 microbatch 都除以同一个分母,最后相加才是整批词元平均。
total_targets = (y != PAD).sum()
if not total_targets:
raise ValueError("整个批次没有有效目标")
loss_sum = 0.0
for start in range(0, len(x), micro):
logits = model(x[start:start+micro])
numerator = nn.functional.cross_entropy(
logits.flatten(0, 1), y[start:start+micro].flatten(),
ignore_index=PAD, reduction="sum")
loss = numerator/total_targets
if not torch.isfinite(loss):
raise RuntimeError("损失出现非有限值")
loss.backward()
loss_sum += loss.detach().item()
return loss_sum
def advance(model, optimizer, sampler, data, start, stop, plan):
x, y, _ = data
history = []
model.train()
for step in range(start, stop):
batch = torch.randint(len(x), (plan["batch"],), generator=sampler)
optimizer.zero_grad(set_to_none=True)
for group in optimizer.param_groups:
group["lr"] = learning_rate(step, plan)
loss = accumulate(model, x[batch], y[batch], plan["micro"])
nn.utils.clip_grad_norm_(model.parameters(), 1.0, error_if_nonfinite=True)
optimizer.step()
optimizer.zero_grad(set_to_none=True)
history.append(loss)
return history
def save(path, model, optimizer, sampler, completed, vocab, fingerprint, plan):
path.parent.mkdir(parents=True, exist_ok=True)
torch.save(dict(model=model.state_dict(), config=model.config,
optimizer=optimizer.state_dict(), completed=completed, plan=plan,
sampler=sampler.get_state(), rng=torch.get_rng_state(),
vocab=vocab, data_sha256=fingerprint, torch_version=str(torch.__version__)), path)
def restore(path, vocab, fingerprint, plan, restore_optimizer=True):
saved = torch.load(path, map_location="cpu", weights_only=True)
if saved["vocab"] != vocab or saved["data_sha256"] != fingerprint or saved["plan"] != plan:
raise ValueError("词表、数据或训练计划与检查点不同")
if saved["torch_version"] != str(torch.__version__):
raise ValueError("精确续训示例要求相同 PyTorch 版本")
model = DropoutLanguageModel(**saved["config"]).double()
model.load_state_dict(saved["model"])
optimizer = torch.optim.AdamW(model.parameters(), lr=plan["peak"], weight_decay=0.01)
if restore_optimizer:
optimizer.load_state_dict(saved["optimizer"])
sampler = torch.Generator()
sampler.set_state(saved["sampler"])
# 初始化模型会消耗随机数,因此必须在它之后恢复全局 RNG。
torch.set_rng_state(saved["rng"])
return model, optimizer, sampler, saved["completed"]
def packing():
# 输入是 [BOS, a, EOS, BOS, b];目标向右错开一位。
segments = torch.tensor([0, 0, 0, 1, 1, 1])
input_segments = segments[:-1]
allowed = torch.ones(5, 5, dtype=torch.bool).tril()
allowed &= input_segments[:, None] == input_segments[None, :]
targets_used = input_segments == segments[1:]
return allowed, targets_used, torch.tensor([0, 1, 2, 0, 1])
def demonstration():
data, vocab, fingerprint = dataset()
full, optimizer, sampler = fresh(vocab)
continuous = advance(full, optimizer, sampler, data, 0, PLAN["total"], PLAN)
partial, optimizer, sampler = fresh(vocab)
first = advance(partial, optimizer, sampler, data, 0, 6, PLAN)
with TemporaryDirectory() as directory:
path = Path(directory)/"resume.pt"
save(path, partial, optimizer, sampler, 6, vocab, fingerprint, PLAN)
resumed, optimizer, sampler, step = restore(path, vocab, fingerprint, PLAN)
second = advance(resumed, optimizer, sampler, data, step, PLAN["total"], PLAN)
reset, optimizer, sampler, step = restore(path, vocab, fingerprint, PLAN, False)
advance(reset, optimizer, sampler, data, step, PLAN["total"], PLAN)
same = lambda a, b: all(torch.equal(x, y) for x, y in zip(a.parameters(), b.parameters()))
print("连续训练与恢复训练:")
print(f" 12 步损失逐项完全相同:{continuous == first+second}")
print(f" 最终参数逐项完全相同:{same(full, resumed)}")
print(f" 不恢复优化器时仍完全相同:{same(full, reset)}")
print(f"首步损失={continuous[0]:.6f};末步损失={continuous[-1]:.6f}")
counts = sorted(set((data[1] != PAD).sum(1).tolist()))
print(f"每条样本的真实目标数:{counts};微批次 8,有效批次 16")
allowed, targets_used, positions = packing()
print("分组因果掩码:")
for row in allowed.int().tolist():
print(" ".join(map(str, row)))
print(f"参与损失的位置:{targets_used.int().tolist()}")
print(f"重置后的输入位置:{positions.tolist()}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group()
group.add_argument("--save", type=Path, help="训练到第 6 步并保存")
group.add_argument("--resume", type=Path, help="从检查点继续到第 12 步")
args = parser.parse_args()
torch.set_num_threads(1)
torch.use_deterministic_algorithms(True)
if args.save or args.resume:
data, vocab, fingerprint = dataset()
if args.save:
model, optimizer, sampler = fresh(vocab)
advance(model, optimizer, sampler, data, 0, 6, PLAN)
save(args.save, model, optimizer, sampler, 6, vocab, fingerprint, PLAN)
print("已保存第 6 步的完整训练状态。")
else:
model, optimizer, sampler, step = restore(args.resume, vocab, fingerprint, PLAN)
history = advance(model, optimizer, sampler, data, step, PLAN["total"], PLAN)
print(f"从第 {step} 步继续至第 {PLAN['total']} 步;末步损失={history[-1]:.6f}")
else:
demonstration()
if __name__ == "__main__":
main()
8. 停下来试一试
- 为什么自回归训练可以一次计算多个位置,却不能读取未来词元?
- 两个微批次分别有 3、9 个有效目标,平均损失分别为 4、2。整批词元平均是多少?
- 把两篇文档之间放一个 EOS,就能保证第二篇读不到第一篇吗?
- 图 2 的独立方案为什么还要把
EOS → BOS的目标去掉? - 为什么保存了参数和随机种子,仍可能无法精确续训?
- 让恢复程序先恢复随机数状态,再创建网络,观察后半程是否还完全一致。
- 把梯度累积从两个微批次改成四个。应当在哪些时刻裁剪、更新和清空梯度?
- 如果在第 6 步把总训练计划从 12 步改为 24 步,还应该把它叫作原计划的精确恢复吗?
参考答案与提示
- 并行计算描述运算的组织方式;因果掩码描述信息能流到哪里。矩阵同时算,不代表每个元素能访问全部输入。
- ,不是两个均值的简单平均 3。
- 不能。EOS 是符号,跨文档可见性由注意力掩码决定。
- 我们希望每篇独立建模,不训练从一篇结尾跳到下一篇开头的连接任务。
- 需要当前随机数状态而非最初种子;还缺优化器历史、样本顺序、调度进度等。
- 网络初始化消耗随机数,会使后续 Dropout 序列改变。恢复顺序是实验的一部分。
- 先清空梯度,四个微批次累积完再裁剪和更新,随后清空。所有微批次仍除以同一个有效目标总数。
- 这是修改训练计划后的继续训练。它可以是合理实验,但已经改变了学习率轨迹,不能期待与原 12 步连续运行完全相同。
9. 继续阅读
- Devlin 等,BERT:理解双向上下文与掩码预训练。
- PyTorch:保存与加载模型:比较推理参数与训练检查点。
- PyTorch:可复现性:了解随机数与确定性计算的适用边界。
训练可以接着跑了,下一个问题是:还要跑多久,模型该做多大?下一章把参数、词元和计算量放进同一份预算里。