📕 第6章 深度学习的应用
📄 一句话概括

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 秒实验

  1. 把编码器和解码器的隐层都改成 8,参数量掉到多少?准确率会掉吗?
  2. 为什么解码器需要一个起始符,编码器不需要?
  3. 把任务从「倒序」改成「原样复制」,收敛会更快还是更慢?

常见误区

  1. 以为编码器把所有中间状态都传给了解码器。朴素 Seq2Seq 只传最后一个。 传中间状态是 6.6 干的事。
  2. 以为整串全对 0.99 就等于逐位 0.99。5 位的任务里,整串全对 0.99 意味着逐位至少 0.998。
  3. 以为训练时用 teacher forcing、推理时也一样。推理时没有答案可喂, 只能喂自己的预测,这个落差是真实存在的。
自测三问(点开对答案) 1. Seq2Seq 的编码器和解码器各自做什么? —— 编码器读完整个输入序列,只交出最后一个隐状态; 解码器以这个向量为起点,一步一步生成输出,每一步的输出作为下一步的输入。 2. 为什么说那个中间向量是瓶颈? —— 不管输入多长它都是固定维度, 而且必须在读完最后一步时还保留开头的信息——容量固定加上早期信息衰减,长序列就压不住。 3. 整串全对和逐位正确有什么区别? —— 整串全对要求每个位置都对,逐位正确是平均命中率。 5 位的任务里,一条样本错一位,整串全对掉 1,逐位只掉 0.2。

瓶颈的根源是「只留一个向量」。 6.6 的注意力说:编码器每一步都留着,解码时自己回头挑——这一改,长序列就活了。

相关笔记