序列建模与表示学习
“先打印,再关机”和“先关机,再打印”,包含的词几乎一样,结果却完全不同。如果把它们都变成词语计数,顺序就丢了。
图像模型利用空间位置,序列模型则要处理信息到来的先后。我们先给网络加上一点状态,再把这一篇学到的训练方法用在一个完整分类项目上。
1. 一边接收输入,一边更新状态
循环神经网络,简称 RNN,每一步接收当前输入 ,并更新隐藏状态:
下标 表示时间或序列位置。 处理新输入, 处理旧状态, 可以从零开始。tanh 按分量作用,将结果压到 −1~1。
同一组参数会在各位置重复使用。循环不是训练程序不停重复同一个数据批次,而是一次样本内部,当前计算依赖前一步状态。
若输入每步 维、状态 维,则 为 , 为 。每个位置既可以输出一个预测,也可以只在读完整条序列后,根据最终状态分类。
看看一维版本:
两条序列 与 的平均值都为零,但最后状态一负一正。它们包含相同的数,出现顺序不同,状态也就不同。
图 1:这些状态由手工指定的递推式计算,没有经过训练。它展示顺序可以被保留,不代表最终状态能够无损保存整条序列。
2. 很久以前的信息,怎样传到现在
把 RNN 沿时间展开,就像一串共享参数的小网络。训练时,梯度也沿这串计算往回传,这通常叫通过时间的反向传播。
在刚才的一维例子中:
跨越许多步,链式法则会把这些局部导数相乘。因为 ,跨 20 步的这条状态梯度通路,绝对值最多为 ,约百万分之一。
这说明:即使前面某个输入很重要,它对最后损失的影响也可能在回传时变得很小。反过来,反复相乘的因子大于 1,也可能导致梯度爆炸。
梯度裁剪能限制过大的更新,截断反向传播能减少计算与内存,但截断也意味着更早的路径不再完整接受当前损失的反馈。它们没有自动解决长期依赖。
3. LSTM 给状态增加可调的门
长短期记忆网络(LSTM)保留隐藏状态 ,另外维护记忆状态 。这里介绍常用的带遗忘门版本,用几个取值在 0~1 的门,控制保留旧信息、写入新信息和输出多少信息。
为使公式短一些,先把旧隐藏状态与新输入拼起来,记作 。各组权重、偏置分别学习不同控制规则:
是遗忘门,决定保留多少旧记忆; 是输入门,决定写入多少候选内容; 是输出门。候选内容为:
然后更新:
表示对应位置相乘。每个门本身也是由数据学习的,不需要人逐步决定“现在该忘记哪句话”。
沿记忆状态的直接通路,梯度会乘上遗忘门。若它持续接近 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 张 灰度图,像素取值 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 安装,不需要额外下载。代码中的卷积输入是 ;卷积保留空间大小,池化后为 ,展平得到 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 手写数字数据,图中增加了预测标注。
这次测试错误率为 。三个错误里有相近笔画的混淆,但仅凭几张图,不能断言模型“理解了哪些笔画”。还可以统计混淆矩阵、按类别分析错误,并查看相近训练样本。
这是一份小型数据集上的随机记录划分,没有按书写者分组,也没有检验手机拍照、旋转背景或新的书写群体。测试结果支持当前划分条件下的表现,不能直接当作真实拍照识别系统的成绩。
一份简短报告可以写成:
在既定划分、种子和 25 轮预算下,原像素 CNN 取得最低验证交叉熵,恢复第 24 轮参数后,在 360 张保留测试图上的准确率为 0.978。另两种配置在预算结束时仍有改善空间。下一步在开发数据上比较更长训练与多个初始化种子,再用新的独立评估检查结论。
这个项目将第三篇连接起来:隐藏层学习表示,自动微分计算梯度,优化器更新权重,卷积利用局部结构,而验证与错误分析约束我们能说出的结论。
7. 自测与小实验
- 为什么序列的均值不能区分 与 ?
- RNN 在不同位置使用不同的一套权重吗?
- 固定因子 0.5 连乘 20 次说明了什么,不说明什么?
- LSTM 的遗忘门接近 1 时,记忆的直接通路有什么特点?
- 自监督学习是不是完全没有预测目标?
- 冻结网络参数与切换到评估模式,是同一件事吗?
- 本次原像素 CNN 更好,是否证明输入缩放没有价值?
- 怎样比较 MLP 与 CNN,才能更有把握讨论结构的作用?
展开参考答案
- 均值不保留顺序,两条序列拥有相同元素。
- 通常共享同一组参数,但每一步输入与状态不同。
- 说明一条反复乘小因子的通路会迅速减弱;不代表所有 RNN 在任何数据上都具有完全相同的梯度或性能。
- 旧记忆沿这条通路衰减较慢,但完整梯度还受到其他路径和门变化的影响。
- 不是。它从数据中构造目标,并根据预测损失训练。
- 不是。冻结控制参数更新,评估模式控制某些层的运行行为。
- 不证明。它是当前配置与预算下的结果,需要结合曲线和后续受控实验理解。
- 尽量控制参数量、训练预算、预处理与调参机会,重复实验,并明确仍未控制的差异。
选做一:把一维 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。