Seq2Seq 把任务拆成两半:编码器读完整个输入只交出最后一个隐状态; 解码器拿这个向量当起点一步一步往外吐。中间那个向量是唯一的通道,也是唯一的瓶颈。 实测 6 个词的词表、两个 16 维隐层、838 个参数,2000 轮后损失从 1.7918 降到 0.0020。 训练集整串全对 1.0000、测试 0.9900;逐位正确率测试 0.9980。两个指标要分开报。 倒序任务上它几乎全对,但输入一长就压不住了——这正是 6.6 要修的。
6.4 的语言模型是「读前文、猜下一个」,输入输出在同一串上。 Seq2Seq 是两串,长度还可以不一样。 6.6 的注意力在解码的每一步把编码器的所有中间状态都拿出来看一遍,不再只靠最后一个向量。 第 5 章的卷积网络也是「把输入压成一个向量」,只是那里压的是图片,这里压的是序列。
一、两个网络,一个向量
编码器是个普通 RNN,读 T 步,只保留最后那个隐状态,中间的全扔掉。 解码器是另一个 RNN,起点是这个向量,每一步的输出再喂回自己当下一步的输入。 这叫自回归:自己吐出来的东西当下一步的输入。训练时喂正确答案(teacher forcing), 推理时喂自己的预测。 两种喂法的差别,是训练表现和推理表现不一致的主要来源。这一节末尾会讲它带来的具体麻烦。
二、一个具体的任务
输入 5 个字母(a 到 e 里随机取),要求倒着输出。词表 6,多出来那个是起始符。 为什么要有起始符:解码的第一步还没有「上一个输出」,得给它一个占位。编码器不需要, 因为它第一步就有真实输入。 参数 838:编码器 6 乘 16 加 16 乘 16,解码器 16 乘 16 加 16 乘 6,再加偏置。加起来就是这个数。 随机猜的损失是 ln6 等于 1.7918。 5 个位置独立猜,整串全对的概率是 6 的 5 次方分之一,约 0.0001。
三、整串全对与逐位正确
| 集合 | 样本数 | 整串全对 | 逐位正确 |
|---|---|---|---|
| 训练 | 400 | 1.0000 | 1.0000 |
| 测试 | 100 | 0.9900 | 0.9980 |
整串全对要求 5 个位置一个不错。逐位正确是平均命中率,错一位只掉 0.2。 所以 0.9900 和 0.9980 讲的是同一件事的两个侧面: 100 条测试里有 1 条整串错,而那一条只错了一位。 报数字时一定要看清用的是哪个。同一个模型能同时是「99% 正确」和「只能完整答对 93%」, 差别全在口径上。 顺带一个经验:序列越长,这两个指标的差距越大。10 位任务上整串全对会掉得比逐位快得多。
四、瓶颈
编码器把 5 个字母压进 16 维,信息量够。所以这个任务上它几乎满分。 但序列一长就不行:10 个字母还是 16 维,30 个也是 16 维。容量是固定的。 而且这个向量必须在读完最后一步时还完整保留第一步的信息,这正是 6.1 的梯度消失问题。 两个毛病叠在一起:容量固定,加上早期信息衰减。注意力的思路是「别压了,都留着,用的时候再挑」。 这也解释了一个现象: 朴素 Seq2Seq 在短句翻译上过得去,句子一长就崩。2014 年那篇论文报告的就是 5 到 10 个词的句子。
五、训练时的两个陷阱
teacher forcing 让训练收敛快很多,但推理时它看到的是自己吐出来的东西,分布和训练时不一样。 这叫曝光偏差:训练时每一步的输入都是「正确答案」,错不到哪里去; 推理时第一步错了,后面就全在错的上下文里走。 缓解办法是训练后期混合着喂自己的预测。代价是收敛变慢,收益是推理更稳。 另一个陷阱是停止: 推断时必须设一个停止符,否则它不知道什么时候停。这一节的 Demo 里长度是固定的 5, 所以回避了这个问题。
六、动手 Demo:读完整句,再一句一句吐出来
任务是倒序:输入 5 个字母,要求原样倒着输出。 编码器和解码器各 16 维隐层,解码器以编码器最后那个隐状态为起点。 训练 2000 轮,然后分别算整串全对和逐位正确。
import numpy as np
T, V = 5, 6 # 5 个字母,再加 1 个起始符
Xtr, Ytr = reverse_task(400, T, V - 1, rg)
Xte, Yte = reverse_task(100, T, V - 1, rg)
# 解码器的输入要右移一位:第 1 步喂起始符 V-1
ys = np.concatenate([np.full((len(Ytr), 1), V - 1),
Ytr[:, :-1]], 1)
m = Seq2Seq(V, 16, np.random.default_rng(4), attention=False)
for k in range(2000): # 838 个参数
loss, g = m.loss_grads(Xtr, ys, onehot(Ytr))
m.apply(g, 0.5)
pred, _ = m.greedy(Xte, V - 1) # 每步取 argmax
print("整串全对 %.4f" % float((pred == Yte).all(1).mean()))
print("逐位正确 %.4f" % float((pred == Yte).mean()))
实跑输出:
--------------------------------------------------------------------
Sequence-To-Sequence:读完整句,再一句一句吐出来
--------------------------------------------------------------------
任务:输入 5 个字母(a 到 e 里随机取),要求原样倒着输出。
结构是最朴素的编码器-解码器:编码器读完 5 步,把最后那个隐状态交给解码器当起点,
解码器再吐 5 步。
量 值
------------------------------------------
词表 6(5 个字母加 1 个起始符)
编码器隐层 16
解码器隐层 16
参数个数 838
随机猜的损失 1.7918
轮数 损失
--------------------
400 0.1896
800 0.0103
1200 0.0045
1600 0.0028
2000 0.0020
集合 样本数 整串全对 逐位正确
------------------------------------------
训练 400 1.0000 1.0000
测试 100 0.9900 0.9980
**整串全对和逐位正确是两个不同的指标。**前者要求 5 个位置一个不错,后者是平均命中率。
**报论文数时一定要看清用的是哪个。**
抽三个样本看一眼(0 到 4 对应 a 到 e):
输入 deeae 答案 eaeed 预测 eaeed
输入 ceaea 答案 aeaec 预测 aeaec
输入 caaae 答案 eaaac 预测 eaaac
**编码器把整个输入压进了一个 16 维向量,解码器只靠这个向量重建倒序。**
这就是 6-5 节那个结构的全部内容。
也正因为「全部压进一个向量」,序列一长就会出问题——下一节看这个瓶颈怎么破。
先看那张量值表:词表 6、两个 16 维隐层、838 个参数、随机猜的损失 1.7918。
838 个参数的任务,跑 2000 轮,一秒钟的事。
损失那一列:0.1896、0.0103、0.0045、0.0028、0.0020。前 400 轮掉了 9 倍,后面掉得很慢。
这个形状和 6.4 的困惑度曲线一样:先学粗规律(每个位置大概该输出什么),
再学细规律(这个位置具体是哪个字母)。
准确率那张表两行:训练 1.0000 与 1.0000,测试 0.9900 与 0.9980。
训练集上它做到了完美,测试上差一点点。
那一点点差在哪,看三个样本就明白了:deeae 变 eaeed 对、ceaea 变 aeaec 对、
caaae 变 eaaac 对。抽的三个全对,说明错的那一条是偶然。
注意输出的第一个字母就是输入的最后一位。解码器没有任何「看到输入中间那些字母」的通道,
它全靠在起点那个向量里。这就是瓶颈的具体形态。
5 个字母、16 维向量,装得下。
换成 10 个字母就明显不够了——下一节把序列拉长,会看到准确率掉下来,
同时看到注意力权重开始有结构。
30 秒实验
- 把编码器和解码器的隐层都改成 8,参数量掉到多少?准确率会掉吗?
- 为什么解码器需要一个起始符,编码器不需要?
- 把任务从「倒序」改成「原样复制」,收敛会更快还是更慢?
常见误区
- 以为编码器把所有中间状态都传给了解码器。朴素 Seq2Seq 只传最后一个。 传中间状态是 6.6 干的事。
- 以为整串全对 0.99 就等于逐位 0.99。5 位的任务里,整串全对 0.99 意味着逐位至少 0.998。
- 以为训练时用 teacher forcing、推理时也一样。推理时没有答案可喂, 只能喂自己的预测,这个落差是真实存在的。
自测三问(点开对答案)
1. Seq2Seq 的编码器和解码器各自做什么? —— 编码器读完整个输入序列,只交出最后一个隐状态; 解码器以这个向量为起点,一步一步生成输出,每一步的输出作为下一步的输入。 2. 为什么说那个中间向量是瓶颈? —— 不管输入多长它都是固定维度, 而且必须在读完最后一步时还保留开头的信息——容量固定加上早期信息衰减,长序列就压不住。 3. 整串全对和逐位正确有什么区别? —— 整串全对要求每个位置都对,逐位正确是平均命中率。 5 位的任务里,一条样本错一位,整串全对掉 1,逐位只掉 0.2。瓶颈的根源是「只留一个向量」。 6.6 的注意力说:编码器每一步都留着,解码时自己回头挑——这一改,长序列就活了。
- ☐ (本节完,下一节 6.6 Attention)