Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

预训练任务与训练流程

数据已经整理好,网络也写好了。接下来似乎只要加一个 for 循环:读数据,算损失,反向传播,更新参数。

真正跑起来,却很快会碰到一些小问号。短句末尾补了空位,这些空位算不算训练目标?几条短句拼进一个块,后一句能不能看到前一句?训练跑到一半停电,重新读入模型文件,是否真的回到了刚才的状态?

这些细节不像网络结构图那样显眼,却会改变模型接受的训练。这一章把它们放到一张桌上,最后用一个可以中断、恢复并逐项比较的实验收尾。

1. 同一段文字,可以出两种练习题

自回归:接下来是什么

第十八到二十一章已经用过自回归语言建模。给定真实前缀,让模型预测下一个词元:

输入:BOS  我  爱  喝  茶
目标: 我  爱  喝  茶  EOS

每一列都有自己的预测任务。训练时,模型直接拿到真实前缀;生成时,它才需要把自己刚写出的词元接回输入。

对长度为 TT 的目标序列,一种平均损失写法是:

LAR=−1T∑t=1Tlog⁡pθ(xt∣x<t) \mathcal L_{\mathrm{AR}} =-\frac1T\sum_{t=1}^{T} \log p_\theta(x_t\mid x_{<t})

θ\theta 是模型参数,x<tx_{<t} 是当前位置之前的真实词元。边界符怎样计入长度,需要在实现中固定;本书的脚本把 EOS 当作需要预测的目标,不把 PAD 当作目标。

因果掩码让第 tt 个预测只能利用它的前缀。虽然 GPU 或 CPU 可以一次处理整个矩阵,未来信息仍然不能穿过掩码。

掩码建模:这里缺了什么

另一种练习把句子中的一部分藏起来:

原句:我  爱  喝  茶
输入:我  爱 MASK 茶
目标:只在第三个位置预测“喝”

这时,“茶”也能帮助判断空缺。模型需要双向上下文,损失只在选中的位置计算。设 MM 是选中位置的集合,x~\tilde{\mathbf x} 是经过遮挡或扰动的输入:

LMLM=−1∣M∣∑t∈Mlog⁡pθ(xt∣x~) \mathcal L_{\mathrm{MLM}} =-\frac1{|M|}\sum_{t\in M} \log p_\theta(x_t\mid\tilde{\mathbf x})

这里介绍的是简化的掩码语言建模任务。原始 BERT 采用双向 Transformer,并有具体的输入扰动与预训练任务设计;不能把简化例子直接当作原论文的完整复现。

两个任务都能从文本本身构造目标,所以常被称为自监督学习。不过,它们学到信息的路径不同。给因果解码器插入 MASK,却不改注意力可见范围,并不会自动得到双向编码器;训练了填空模型,也不意味着能原样套用自回归生成循环。

图 1:左图的三角形表示因果可见范围。右图突出填空位置可以利用两侧文本;实际双向网络会同时计算所有输入位置。

2. 组批次:形状整齐,损失才好算

回忆第二十一章的一个训练块:输入 x 与目标 y 都是形状 (B,T)(B,T) 的整数张量。BB 是批次中的块数,TT 是每块处理的位置数。

实际文档长短不一。示例选出 32 条文本,一半保留完整短句,一半只留下第一句;加上边界目标后,每条分别有 27 或 15 个真实目标。为了排成矩阵,我们把每条补到 32 个位置。

补齐让形状统一,却不应改变题目。因此,PAD 对应的目标被排除在损失之外:

L=∑b,tmbtℓbt∑b,tmbt \mathcal L =\frac{\sum_{b,t}m_{bt}\ell_{bt}} {\sum_{b,t}m_{bt}}

mbtm_{bt} 在有效目标位置取 1,在 PAD 位置取 0;ℓbt\ell_{bt} 是该位置的交叉熵。分母是实际预测的词元数,不是固定的 BTBT。

这个写法相当于让每个有效词元等权。若先对每篇文档求平均,再对文档求平均,短文中的每个词元会获得更大的相对权重。两者都可以被定义为目标,但不能在不知情的情况下混用。

我们的文档靠左、PAD 补在右边,又采用因果注意力。因此,有效输入位置不会看到右边的 PAD;它们的预测不受那些未来空位影响。双向模型、左侧填充或其他布局不能直接沿用这个理由,需要额外处理注意力的填充掩码。

3. 梯度累积:分几次计算,同一个总目标

假如内存只放得下 8 条样本,却希望每次更新使用 16 条,可以先算前 8 条的梯度,再加上后 8 条的梯度,最后只更新一次参数。这叫梯度累积。

这里有个容易漏掉的细节:两个微批次的有效词元可能不一样多。如果各自求平均,再机械地除以 2,不一定得到整批词元平均。

设第 kk 个微批次的损失和为 SkS_k,有效目标数为 nkn_k。我们要的是:

L=∑kSk∑knk \mathcal L=\frac{\sum_k S_k}{\sum_k n_k}

实现时先数出整批的有效目标总数 NvalidN_{\mathrm{valid}},每个微批次都用同一个分母:

loss = microbatch_loss_sum / total_valid_targets
loss.backward()

这样累积后的梯度就是这些微批次计算所得总目标的梯度。等所有微批次结束,再做一次梯度裁剪、一次优化器更新,随后清空梯度。

看一个只有两个微批次的例子:第一个有 4 个目标,平均损失 2;第二个有 2 个目标,平均损失 5。平均两个均值得到 3.5,但词元平均应当是:

4×2+2×54+2=3 \frac{4\times2+2\times5}{4+2}=3

如果模型带有 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。允许注意的位置需同时满足“在当前文档里”和“不在未来”:

allowij=(j≤i) ∧ (si=sj) \mathrm{allow}_{ij} =(j\le i)\ \land\ (s_i=s_j)

sis_i 是第 ii 个输入位置的文档编号。与此同时,A 的 EOS 不再承担“预测下一篇 BOS”的任务;这个跨边界目标也从损失中去掉。

图 2:行是发出查询的位置,列是可读取的位置。独立方案还需要配合目标掩码;本例把每篇文档的位置编号也重置。

位置编号是否重置,与具体位置方案和训练约定有关。本例的输入位置是 [0, 1, 2, 0, 1],每篇从 0 开始。

本章代码会生成这份掩码和目标选择,方便逐格检查。实际训练仍采用一篇一块、右侧填充;我们没有把展示掩码悄悄当成已经实现的打包加速。要真正接入 packing,还需要让网络接收分组掩码与位置编号,并检验跨文档隔离。

5. 调度:学习率要跟着哪个时钟走

第十四章介绍过学习率。较长训练还常用一段预热:先从较小的步长开始,再逐渐达到设定值;之后按计划衰减。

本例只运行 12 次优化器更新,前三步的学习率依次是 0.001、0.002、0.003,随后用余弦曲线降到 0.0003:

η(u)=ηmin⁡+ηmax⁡−ηmin⁡2(1+cos⁡(πu)) \eta(u)=\eta_{\min} +\frac{\eta_{\max}-\eta_{\min}}2 \bigl(1+\cos(\pi u)\bigr)

uu 是预热结束后已经走过的比例,范围为 [0,1][0,1]。这只是一个容易观察的训练计划,不是在说余弦调度对所有任务都最好。

图 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. 停下来试一试

  1. 为什么自回归训练可以一次计算多个位置,却不能读取未来词元?
  2. 两个微批次分别有 3、9 个有效目标,平均损失分别为 4、2。整批词元平均是多少?
  3. 把两篇文档之间放一个 EOS,就能保证第二篇读不到第一篇吗?
  4. 图 2 的独立方案为什么还要把 EOS → BOS 的目标去掉?
  5. 为什么保存了参数和随机种子,仍可能无法精确续训?
  6. 让恢复程序先恢复随机数状态,再创建网络,观察后半程是否还完全一致。
  7. 把梯度累积从两个微批次改成四个。应当在哪些时刻裁剪、更新和清空梯度?
  8. 如果在第 6 步把总训练计划从 12 步改为 24 步,还应该把它叫作原计划的精确恢复吗?
参考答案与提示
  1. 并行计算描述运算的组织方式;因果掩码描述信息能流到哪里。矩阵同时算,不代表每个元素能访问全部输入。
  2. (3×4+9×2)/12=2.5(3\times4+9\times2)/12=2.5,不是两个均值的简单平均 3。
  3. 不能。EOS 是符号,跨文档可见性由注意力掩码决定。
  4. 我们希望每篇独立建模,不训练从一篇结尾跳到下一篇开头的连接任务。
  5. 需要当前随机数状态而非最初种子;还缺优化器历史、样本顺序、调度进度等。
  6. 网络初始化消耗随机数,会使后续 Dropout 序列改变。恢复顺序是实验的一部分。
  7. 先清空梯度,四个微批次累积完再裁剪和更新,随后清空。所有微批次仍除以同一个有效目标总数。
  8. 这是修改训练计划后的继续训练。它可以是合理实验,但已经改变了学习率轨迹,不能期待与原 12 步连续运行完全相同。

9. 继续阅读

训练可以接着跑了,下一个问题是:还要跑多久,模型该做多大?下一章把参数、词元和计算量放进同一份预算里。