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

序列建模与表示学习

“先打印,再关机”和“先关机,再打印”,包含的词几乎一样,结果却完全不同。如果把它们都变成词语计数,顺序就丢了。

图像模型利用空间位置,序列模型则要处理信息到来的先后。我们先给网络加上一点状态,再把这一篇学到的训练方法用在一个完整分类项目上。

1. 一边接收输入,一边更新状态

循环神经网络,简称 RNN,每一步接收当前输入 xt\boldsymbol x_t,并更新隐藏状态:

ht=tanh⁡(Wxxt+Whht−1+b) \boldsymbol h_t =\tanh(W_x\boldsymbol x_t+ W_h\boldsymbol h_{t-1}+\boldsymbol b)

下标 tt 表示时间或序列位置。WxW_x 处理新输入,WhW_h 处理旧状态,h0\boldsymbol h_0 可以从零开始。tanh 按分量作用,将结果压到 −1~1。

同一组参数会在各位置重复使用。循环不是训练程序不停重复同一个数据批次,而是一次样本内部,当前计算依赖前一步状态。

若输入每步 dd 维、状态 mm 维,则 WxW_x 为 m×dm\times d,WhW_h 为 m×mm\times m。每个位置既可以输出一个预测,也可以只在读完整条序列后,根据最终状态分类。

看看一维版本:

ht=tanh⁡(xt+0.5ht−1) h_t=\tanh(x_t+0.5h_{t-1})

两条序列 [1,0,−1][1,0,-1] 与 [−1,0,1][-1,0,1] 的平均值都为零,但最后状态一负一正。它们包含相同的数,出现顺序不同,状态也就不同。

图 1:这些状态由手工指定的递推式计算,没有经过训练。它展示顺序可以被保留,不代表最终状态能够无损保存整条序列。

2. 很久以前的信息,怎样传到现在

把 RNN 沿时间展开,就像一串共享参数的小网络。训练时,梯度也沿这串计算往回传,这通常叫通过时间的反向传播。

在刚才的一维例子中:

∂ht∂ht−1=0.5(1−ht2) \frac{\partial h_t}{\partial h_{t-1}} =0.5(1-h_t^2)

跨越许多步,链式法则会把这些局部导数相乘。因为 1−ht2≤11-h_t^2\le1,跨 20 步的这条状态梯度通路,绝对值最多为 0.5200.5^{20},约百万分之一。

这说明:即使前面某个输入很重要,它对最后损失的影响也可能在回传时变得很小。反过来,反复相乘的因子大于 1,也可能导致梯度爆炸。

梯度裁剪能限制过大的更新,截断反向传播能减少计算与内存,但截断也意味着更早的路径不再完整接受当前损失的反馈。它们没有自动解决长期依赖。

3. LSTM 给状态增加可调的门

长短期记忆网络(LSTM)保留隐藏状态 ht\boldsymbol h_t,另外维护记忆状态 ct\boldsymbol c_t。这里介绍常用的带遗忘门版本,用几个取值在 0~1 的门,控制保留旧信息、写入新信息和输出多少信息。

为使公式短一些,先把旧隐藏状态与新输入拼起来,记作 zt\boldsymbol z_t。各组权重、偏置分别学习不同控制规则:

ft=σ(Wfzt+bf)it=σ(Wizt+bi)ot=σ(Wozt+bo) \begin{aligned} \boldsymbol f_t&=\sigma(W_f\boldsymbol z_t+\boldsymbol b_f)\\ \boldsymbol i_t&=\sigma(W_i\boldsymbol z_t+\boldsymbol b_i)\\ \boldsymbol o_t&=\sigma(W_o\boldsymbol z_t+\boldsymbol b_o) \end{aligned}

ff 是遗忘门,决定保留多少旧记忆;ii 是输入门,决定写入多少候选内容;oo 是输出门。候选内容为:

c~t=tanh⁡(Wczt+bc) \tilde{\boldsymbol c}_t =\tanh(W_c\boldsymbol z_t+\boldsymbol b_c)

然后更新:

ct=ft⊙ct−1+it⊙c~tht=ot⊙tanh⁡(ct) \begin{aligned} \boldsymbol c_t &=\boldsymbol f_t\odot\boldsymbol c_{t-1} +\boldsymbol i_t\odot\tilde{\boldsymbol c}_t\\ \boldsymbol h_t &=\boldsymbol o_t\odot\tanh(\boldsymbol c_t) \end{aligned}

⊙\odot 表示对应位置相乘。每个门本身也是由数据学习的,不需要人逐步决定“现在该忘记哪句话”。

沿记忆状态的直接通路,梯度会乘上遗忘门。若它持续接近 1,旧信息就有机会保留更久。假设每步因子为 0.99,20 步后仍约为 0.818。

图 2:这是固定因子的数学示意,不是训练后的性能比较。真实 LSTM 的门会随输入与状态变化,还存在经过门计算的其他梯度路径。

LSTM 改善了传递信息的条件,却不保证无限记忆、理解任意长文本或始终优化成功。

4. 跟着两种状态算几步

脚本只使用标准库。为了看清更新,它把 LSTM 的三个门固定为 0.8、0.2、0.9;这只演示记忆更新,不是完整的可学习 LSTM:

python3 src/examples/sequence_models.py
"""第 16 章:顺序、循环状态与 LSTM 记忆通路;只用标准库。"""

from math import tanh


def recurrent(sequence, recurrent_weight=0.5):
    state, history = 0.0, []
    for value in sequence:
        state = tanh(value+recurrent_weight*state)
        history.append(state)
    return history


def lstm_fixed_gates(sequence):
    # 为便于观察,三个门固定;完整 LSTM 的门由输入与旧状态学习得到。
    cell, history = 0.0, []
    forget, write, read = 0.8, 0.2, 0.9
    for value in sequence:
        candidate = tanh(value)
        cell = forget*cell + write*candidate
        hidden = read*tanh(cell)
        history.append((cell, hidden))
    return history


def main():
    first, second = [1, 0, -1], [-1, 0, 1]
    print(f"两条序列的均值:{sum(first)/3:.1f},{sum(second)/3:.1f}")
    for sequence in [first, second]:
        states = recurrent(sequence)
        print(f"序列 {sequence} 的循环状态:" + ", ".join(f"{x:.4f}" for x in states))
    for step, (cell, hidden) in enumerate(lstm_fixed_gates(first), start=1):
        print(f"固定门 LSTM 第 {step} 步:c={cell:.4f},h={hidden:.4f}")
    print(f"20 次相乘:0.5^20={0.5**20:.8f},0.99^20={0.99**20:.4f}")


if __name__ == "__main__":
    main()

输出为:

两条序列的均值:0.0,0.0
序列 [1, 0, -1] 的循环状态:0.7616, 0.3634, -0.6741
序列 [-1, 0, 1] 的循环状态:-0.7616, -0.3634, 0.6741
固定门 LSTM 第 1 步:c=0.1523,h=0.1360
固定门 LSTM 第 2 步:c=0.1219,h=0.1091
固定门 LSTM 第 3 步:c=-0.0548,h=-0.0493
20 次相乘:0.5^20=0.00000095,0.99^20=0.8179

批量处理序列时,通常使用“批量 × 长度 × 特征”的输入。PyTorch 的 nn.LSTM(batch_first=True) 采用这个输入布局;隐藏状态与记忆状态仍按“层数乘方向数 × 批量 × 隐藏维度”排列,不能因为设置了 batch_first 就把所有张量都解释成同一布局。

长短不同的序列常要补齐长度。补齐位置不应被当成真实词语参与损失;若取最后状态做分类,还要取得真实序列结尾的状态,或使用支持变长序列的处理。只屏蔽损失,并不会自动阻止补齐输入继续改变状态。

5. 监督、自监督与表示复用

训练数字分类器时,我们提供“这张图是 3”这样的标签,属于监督学习。若把一段文本的后一个词当作前文的预测目标,目标来自数据本身,通常称为自监督学习。

自监督仍然有明确的预测目标、损失与评估,只是目标不必逐条由人工另行标注。也不是把答案直接作为当前输入:预测后一个词时,不能提前看到它,否则任务会失去意义。

不同训练目标会塑造不同表示。识别图中数字的网络可能学到笔画特征;预测序列下一项的网络可能保留有助于后续预测的上下文。但“有助于源任务”不自动等于“对所有任务都有用”。

迁移学习尝试复用这些表示。常见的两种方式是:

方式哪些参数改变适合先回答的问题
冻结特征提取部分,只训练新输出层只有新任务的输出层已有表示是否已经提供有用信息?
微调整个网络或部分层输出层和选定的表示层能否进一步适应新数据?

冻结参数意味着不更新它们,评估模式则影响 Dropout、BatchNorm 等行为,两者仍需分别设置。小数据下微调太猛,可能破坏已有表示;冻结全部表示,又可能无法适应新任务。

公平比较时,应保留从头训练或简单模型的基线,并说明源数据与目标评估数据是否重叠。若预训练时已经见过评估样本,这个条件也应如实说明。

下一篇会把自监督学习具体落实为语言建模。现在先完成一个规模小、能看见错误样本的监督学习项目。

6. 第三篇实践:识别手写数字

我们使用 scikit-learn 随包提供的 load_digits 数据:1797 张 8×88\times8 灰度图,像素取值 0~16,标签为 0~9。它来自 UCI 手写数字数据的原始测试部分;本书为教学重新划分,因此结果不对应原论文的基准划分。数据接口说明

原始数据由 E. Alpaydin 与 C. Kaynak 提供,UCI 当前以 CC BY 4.0 发布。数据来源、引用方式见 UCI 数据页面。下面的图片从这些样本绘制,加入了真实标签与模型预测。

先规定比较条件

分层随机划分后,训练、验证、测试分别为 1077、360、360 张。模型只使用训练标签,按验证交叉熵选择配置和轮次。

我们比较三种配置,每种最多训练 25 轮:

名称结构输入
MLP / 16展平 → 32 单元 → 10 分数像素除以 16
CNN / 16卷积 → 池化 → 32 单元 → 10 分数像素除以 16
CNN 原像素与前一个 CNN 相同保留 0~16

除以 16 来自数据已知取值范围,不需要从测试样本学习。两个 CNN 使用相同初始参数、批次顺序、优化器和轮数,比较输入尺度的影响。

MLP 与 CNN 的参数数量不同,所以这组比较考察的是两个具体方案,不能只把分数差异归因于“卷积更好”。所有实验固定种子并使用 CPU 双精度,方便核对本次运行。

完整训练与评估

在第十四章的环境中运行项目脚本:

python src/examples/digits_project.py

数据随 scikit-learn 安装,不需要额外下载。代码中的卷积输入是 (B,1,8,8)(B,1,8,8);卷积保留空间大小,池化后为 (B,8,4,4)(B,8,4,4),展平得到 128 个特征。

"""第三篇实践:本地手写数字分类、学习曲线与受控比较;CPU 实验。"""

from copy import deepcopy
import numpy as np
import torch
from torch import nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split


def make_data():
    digits = load_digits()
    indices = np.arange(len(digits.target))
    development, test = train_test_split(
        indices, test_size=0.2, stratify=digits.target, random_state=2026)
    train, validation = train_test_split(
        development, test_size=0.25, stratify=digits.target[development],
        random_state=2027)
    datasets = []
    for ids in [train, validation, test]:
        datasets.append((torch.tensor(digits.images[ids, None], dtype=torch.float64),
                         torch.tensor(digits.target[ids], dtype=torch.long)))
    return datasets, [train, validation, test]


def make_model(kind):
    if kind == "mlp":
        return nn.Sequential(nn.Flatten(), nn.Linear(64, 32), nn.ReLU(),
                             nn.Linear(32, 10)).double()
    return nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(),
                         nn.MaxPool2d(2), nn.Flatten(), nn.Linear(128, 32),
                         nn.ReLU(), nn.Linear(32, 10)).double()


def metrics(model, dataset, scale):
    model.eval()
    with torch.no_grad():
        x, y = dataset
        logits = model(x/scale)
        loss = nn.functional.cross_entropy(logits, y).item()
        prediction = logits.argmax(1)
        accuracy = (prediction == y).double().mean().item()
    return loss, accuracy, prediction


def train(kind, scale, train_data, validation, seed=2026, epochs=25):
    torch.manual_seed(seed)
    model = make_model(kind)
    optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.001)
    generator = torch.Generator().manual_seed(2030)
    best_loss, best_epoch, best_state = float("inf"), 0, None
    history = []
    x, y = train_data
    for epoch in range(1, epochs+1):
        model.train()
        for batch in torch.randperm(len(x), generator=generator).split(64):
            optimizer.zero_grad(set_to_none=True)
            loss = nn.functional.cross_entropy(model(x[batch]/scale), y[batch])
            if not torch.isfinite(loss):
                raise RuntimeError("损失出现非有限值")
            loss.backward()
            optimizer.step()
        train_loss, _, _ = metrics(model, train_data, scale)
        val_loss, val_accuracy, _ = metrics(model, validation, scale)
        history.append((train_loss, val_loss, val_accuracy))
        if val_loss < best_loss:
            best_loss, best_epoch = val_loss, epoch
            best_state = deepcopy(model.state_dict())
    model.load_state_dict(best_state)
    return model, best_epoch, history


def run():
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)
    datasets, ids = make_data()
    configurations = [("MLP / 16", "mlp", 16.0),
                      ("CNN / 16", "cnn", 16.0),
                      ("CNN 原像素", "cnn", 1.0)]
    results = []
    for name, kind, scale in configurations:
        model, epoch, history = train(kind, scale, datasets[0], datasets[1])
        val_loss, accuracy, _ = metrics(model, datasets[1], scale)
        results.append((name, scale, model, epoch, history, val_loss, accuracy))
    selected = min(results, key=lambda row: row[5])
    return datasets, ids, results, selected


def main():
    datasets, ids, results, selected = run()
    print("训练/验证/测试:" + " / ".join(str(len(x)) for x, _ in datasets))
    majority = torch.bincount(datasets[0][1]).argmax()
    baseline = (datasets[1][1] == majority).double().mean().item()
    print(f"多数类基线验证准确率={baseline:.3f}")
    for name, _, model, epoch, _, loss, accuracy in results:
        count = sum(p.numel() for p in model.parameters())
        print(f"{name}:参数={count},最佳轮次={epoch},"
              f"验证交叉熵={loss:.3f},准确率={accuracy:.3f}")
    name, scale, model, _, _, _, _ = selected
    loss, accuracy, prediction = metrics(model, datasets[2], scale)
    print(f"固定方案:{name};测试交叉熵={loss:.3f},准确率={accuracy:.3f}")
    target = datasets[2][1]
    wrong = torch.where(prediction != target)[0]
    print(f"测试错误数:{len(wrong)} / {len(target)}")
    for index in wrong[:3]:
        print(f"样本 ID={ids[2][index]}:真实={target[index].item()},"
              f"预测={prediction[index].item()}")


if __name__ == "__main__":
    main()

运行结果:

训练/验证/测试:1077 / 360 / 360
多数类基线验证准确率=0.100
MLP / 16:参数=2410,最佳轮次=25,验证交叉熵=0.313,准确率=0.928
CNN / 16:参数=4538,最佳轮次=25,验证交叉熵=0.209,准确率=0.928
CNN 原像素:参数=4538,最佳轮次=24,验证交叉熵=0.107,准确率=0.969
固定方案:CNN 原像素;测试交叉熵=0.066,准确率=0.978
测试错误数:8 / 360
样本 ID=1118:真实=3,预测=7
样本 ID=1468:真实=8,预测=9
样本 ID=1580:真实=9,预测=8

这里出现了一个值得认真看的结果:原像素 CNN 反而更好。不能为了符合“缩放输入通常有帮助”的经验,就把这次结果隐藏起来。输入尺度会与初始化、激活、优化器及训练预算一起作用,并没有“缩放后每次都更好”的保证。

前两种设置的最佳轮次恰好在第 25 轮,这还提示它们可能没有训练充分。若想判断更长训练后差异是否保留,应另做一个明确改变预算的开发实验,不能仅凭当前结果宣布某种输入形式永远最佳。

图 3:曲线来自相同数据划分。训练轮数相同不等于计算量完全相同,不同模型每轮所需计算也不同。

再看具体错误

图 4:显示代码输出的前三个错误样本,不是挑选最醒目的失败。像素来自 Alpaydin 与 Kaynak 的 UCI 手写数字数据,图中增加了预测标注。

这次测试错误率为 8/360≈2.22%8/360\approx2.22\%。三个错误里有相近笔画的混淆,但仅凭几张图,不能断言模型“理解了哪些笔画”。还可以统计混淆矩阵、按类别分析错误,并查看相近训练样本。

这是一份小型数据集上的随机记录划分,没有按书写者分组,也没有检验手机拍照、旋转背景或新的书写群体。测试结果支持当前划分条件下的表现,不能直接当作真实拍照识别系统的成绩。

一份简短报告可以写成:

在既定划分、种子和 25 轮预算下,原像素 CNN 取得最低验证交叉熵,恢复第 24 轮参数后,在 360 张保留测试图上的准确率为 0.978。另两种配置在预算结束时仍有改善空间。下一步在开发数据上比较更长训练与多个初始化种子,再用新的独立评估检查结论。

这个项目将第三篇连接起来:隐藏层学习表示,自动微分计算梯度,优化器更新权重,卷积利用局部结构,而验证与错误分析约束我们能说出的结论。

7. 自测与小实验

  1. 为什么序列的均值不能区分 [1,0,−1][1,0,-1] 与 [−1,0,1][-1,0,1]?
  2. RNN 在不同位置使用不同的一套权重吗?
  3. 固定因子 0.5 连乘 20 次说明了什么,不说明什么?
  4. LSTM 的遗忘门接近 1 时,记忆的直接通路有什么特点?
  5. 自监督学习是不是完全没有预测目标?
  6. 冻结网络参数与切换到评估模式,是同一件事吗?
  7. 本次原像素 CNN 更好,是否证明输入缩放没有价值?
  8. 怎样比较 MLP 与 CNN,才能更有把握讨论结构的作用?
展开参考答案
  1. 均值不保留顺序,两条序列拥有相同元素。
  2. 通常共享同一组参数,但每一步输入与状态不同。
  3. 说明一条反复乘小因子的通路会迅速减弱;不代表所有 RNN 在任何数据上都具有完全相同的梯度或性能。
  4. 旧记忆沿这条通路衰减较慢,但完整梯度还受到其他路径和门变化的影响。
  5. 不是。它从数据中构造目标,并根据预测损失训练。
  6. 不是。冻结控制参数更新,评估模式控制某些层的运行行为。
  7. 不证明。它是当前配置与预算下的结果,需要结合曲线和后续受控实验理解。
  8. 尽量控制参数量、训练预算、预处理与调参机会,重复实验,并明确仍未控制的差异。

选做一:把一维 RNN 的循环权重从 0.5 改为 1.2,观察状态变化。不要只凭权重大于 1 就断言梯度必然爆炸,因为 tanh 的导数也参与相乘。

选做二:在开发集上把两个缩放输入的分类器训练到 75 轮,记录最佳验证损失与曲线。再固定方案,用三个初始化种子重复。把“优化得更充分”和“更换结构”的效果分开报告。

接下来进入第四篇。我们将从文本如何变成数字出发,逐步实现一个能预测下一个 Token 的小型语言模型。循环网络与 LSTM 的张量接口可参阅 PyTorch LSTM 文档。LSTM 的历史起点是 Hochreiter 与 Schmidhuber 于 1997 年发表的 Long Short-Term Memory;本章使用的遗忘门来自后续发展,可查阅 Gers、Schmidhuber 与 Cummins 的 Learning to Forget。