# Seq2Seq学习笔记:从基础到前沿
## 目录
1. [引言:序列到序列学习的意义](#1-引言序列到序列学习的意义)
2. [预备知识:循环神经网络(RNN)基础](#2-预备知识循环神经网络rnn基础)
- 2.1 RNN的核心思想与结构
- 2.2 RNN的前向传播与BPTT
- 2.3 梯度消失与梯度爆炸问题
- 2.4 LSTM与GRU:门控机制的改进
3. [Seq2Seq的经典架构:编码器-解码器](#3-seq2seq的经典架构编码器-解码器)
- 3.1 核心思想:两阶段处理
- 3.2 编码器(Encoder):将输入序列编码为上下文向量
- 3.3 解码器(Decoder):从上下文向量生成输出序列
- 3.4 训练策略:Teacher Forcing
- 3.5 推理策略:自回归生成与贪心搜索
4. [问题与局限:信息瓶颈与长序列挑战](#4-问题与局限信息瓶颈与长序列挑战)
- 4.1 定长上下文向量的瓶颈
- 4.2 长序列依赖的衰减
5. [注意力机制(Attention):突破瓶颈的关键](#5-注意力机制attention突破瓶颈的关键)
- 5.1 直观理解:对齐与检索
- 5.2 Bahdanau注意力(加性注意力)
- 5.3 Luong注意力(乘性注意力)
- 5.4 注意力机制的本质:查询、键、值
6. [Seq2Seq的高级优化技巧](#6-seq2seq的高级优化技巧)
- 6.1 束搜索(Beam Search)
- 6.2 长度归一化与覆盖率惩罚
- 6.3 双向编码器
- 6.4 多层堆叠与残差连接
- 6.5 嵌入共享与权重绑定
7. [实践指南:从零实现一个Seq2Seq(PyTorch)](#7-实践指南从零实现一个seq2seqpytorch)
- 7.1 数据预处理与词表构建
- 7.2 编码器实现(包括嵌入层、RNN层)
- 7.3 解码器实现(包括注意力机制)
- 7.4 训练循环与损失掩码
- 7.5 推理与BLEU评估
8. [评估方法:如何衡量序列生成质量](#8-评估方法如何衡量序列生成质量)
- 8.1 BLEU(双语评估替补)
- 8.2 ROUGE、METEOR、CIDEr
- 8.3 人工评估与任务特定指标
9. [变体与扩展:超越基本Seq2Seq](#9-变体与扩展超越基本seq2seq)
- 9.1 指针网络(Pointer Networks)
- 9.2 拷贝机制(Copy Mechanism)
- 9.3 覆盖机制(Coverage Mechanism)
- 9.4 计划采样(Scheduled Sampling)
10. [现代替代:Transformer与自注意力](#10-现代替代transformer与自注意力)
- 10.1 Transformer的宏观架构
- 10.2 缩放点积注意力与多头注意力
- 10.3 位置编码
- 10.4 为什么Transformer在很大程度上取代了RNN Seq2Seq?
11. [应用案例](#11-应用案例)
- 11.1 机器翻译(Neural Machine Translation)
- 11.2 文本摘要(Abstractive Summarization)
- 11.3 对话系统(Chatbots)
- 11.4 图像描述(Image Captioning)
12. [总结与展望](#12-总结与展望)
---
## 1. 引言:序列到序列学习的意义
序列到序列(Seq2Seq)学习,是深度学习中处理**输入序列到输出序列转换**任务的经典框架。许多现实世界的问题本质上都是序列转换问题:
- **机器翻译**:输入一个英语句子(词序列),输出一个中文句子(词序列)。
- **文本摘要**:输入一篇长文章(字符或词序列),输出一个简短的摘要。
- **语音识别**:输入一段音频信号(特征向量序列),输出对应的文本句子。
- **对话系统**:输入用户的历史话语(词序列),输出系统回复。
- **代码生成**:输入自然语言描述,输出代码片段(token序列)。
在这些任务中,输入与输出的长度通常**不相等**,且输入和输出元素的对应关系(对齐)复杂、非线性。传统的机器学习方法(如隐马尔可夫模型、条件随机场)虽然能处理序列,但受限于条件独立性假设或特征工程,难以捕捉长距离依赖和复杂的语义映射。
Seq2Seq架构,最初由Sutskever等人(2014)和Cho等人(2014)提出,利用两个循环神经网络(RNN)——一个编码器、一个解码器——优雅地解决了变长序列到变长序列的映射问题。编码器将整个输入序列压缩成**一个固定维度的上下文向量**,解码器则基于这个向量,一步步生成输出序列。
这一看似简单的“编码-解码”范式,极大地推动了自然语言处理和序列生成领域的进步。然而,固定长度的上下文向量很快成为性能瓶颈,限制了模型对长序列的处理能力。**注意力机制**的引入(Bahdanau et al., 2015)彻底改变了局面,它允许解码器在每一步动态“关注”输入序列的不同部分,使得Seq2Seq模型能够处理更长的序列,并取得显著更好的性能。
本笔记将系统性地讲解Seq2Seq模型从基本原理到现代优化的完整知识体系,并包含代码实践,力求理论与实践兼备。
## 2. 预备知识:循环神经网络(RNN)基础
Seq2Seq的核心构建模块是循环神经网络(RNN)及其变体LSTM和GRU。理解RNN是掌握Seq2Seq的前提。
### 2.1 RNN的核心思想与结构
传统的前馈神经网络(如多层感知机)假定所有输入之间相互独立。然而,对于序列数据(如句子、时间序列),当前元素与先前元素之间存在强烈的依赖关系。RNN通过引入**隐状态(hidden state)**来记忆序列的历史信息。
**基本结构:**
对于一个输入序列 \( x = (x_1, x_2, ..., x_T) \),RNN维护一个隐状态 \( h_t \),它在每个时间步 \( t \) 按照相同的权重矩阵进行更新:
\[
h_t = \tanh(W_{xh} x_t + W_{hh} h_{t-1} + b_h)
\]
其中:
- \( x_t \) 是时间步 \( t \) 的输入向量。
- \( h_{t-1} \) 是上一个时间步的隐状态,初始 \( h_0 \) 通常为零向量。
- \( W_{xh} \) 是输入到隐状态的权重矩阵。
- \( W_{hh} \) 是隐状态到隐状态的权重矩阵(循环权重)。
- \( b_h \) 是偏置项。
- \( \tanh \) 是双曲正切激活函数,将输出压缩到 \([-1, 1]\) 之间,以保持数值稳定。
每个时间步,RNN可以产生一个输出 \( y_t \)(例如,在字符级语言模型中,\( y_t \) 是预测的下一个字符的概率分布):
\[
y_t = \text{softmax}(W_{hy} h_t + b_y)
\]
**关键特性:参数共享。** 同一组权重 \( (W_{xh}, W_{hh}, W_{hy}) \) 在所有时间步上重用,这意味着RNN可以处理任意长度的序列,且模型容量与序列长度解耦。
**展开表示:**
将循环连接按时间步展开,RNN就变成了一个非常深的“前馈”网络,层数等于序列长度。这从概念上很重要,因为它揭示了反向传播是如何工作的。
### 2.2 RNN的前向传播与BPTT
**前向传播**:按时间顺序从 \( t=1 \) 到 \( T \) 迭代计算 \( h_t \) 和 \( y_t \),并存储所有中间值(用于反向传播)。
**反向传播:随时间反向传播(BPTT)**
由于RNN在时间上共享参数,梯度需要沿着时间维度进行累积。BPTT的总体步骤如下:
1. 计算损失函数 \( L \)(例如,序列中每个时间步的交叉熵损失之和)。
2. 对于每个时间步 \( t \),计算输出梯度 \( \partial L / \partial y_t \)。
3. 反向遍历时间步:从 \( t=T \) 到 \( t=1 \),利用链式法则计算梯度 \( \partial L / \partial h_t \)。这个步骤中,\( h_t \) 的梯度不仅来自该时间步的输出 \( y_t \),还来自下一个时间步的隐状态 \( h_{t+1} \)(通过循环权重 \( W_{hh} \))。
4. 最后,累积所有时间步的梯度来更新权重矩阵 \( W_{xh}, W_{hh}, W_{hy} \)。
### 2.3 梯度消失与梯度爆炸问题
BPTT在长序列上表现不佳,主要是因为梯度在通过多个时间步传播时,会反复乘以循环权重矩阵 \( W_{hh} \)。
**梯度爆炸**:如果 \( W_{hh} \) 的最大特征值大于1,梯度在反向传播时会指数级增长,导致参数更新过大、训练不稳定(出现NaN)。**解决方案**:梯度裁剪(Gradient Clipping)——如果梯度的L2范数超过某个阈值,则将其缩放到该阈值内。
**梯度消失**:如果 \( W_{hh} \) 的最大特征值小于1,梯度会指数级衰减到零。较早时间步的隐状态对最终损失的贡献变得微乎其微,模型无法学习长距离的依赖关系(例如,句子开头的名词与结尾的动词一致性)。**解决方案**:使用LSTM或GRU等门控机制。
### 2.4 LSTM与GRU:门控机制的改进
长短期记忆网络(LSTM)和门控循环单元(GRU)通过引入**门**(gate)来控制信息流动,从而有效缓解梯度消失问题。
#### LSTM
LSTM引入了**单元状态(cell state)** \( C_t \),作为信息传递的主干道,并通过三个门来精细控制信息的遗忘、写入和输出:
- **遗忘门(forget gate)**:决定从上一单元状态 \( C_{t-1} \) 中丢弃哪些信息。
- **输入门(input gate)**:决定将当前输入 \( x_t \) 和上一步隐状态 \( h_{t-1} \) 的哪些信息写入单元状态。
- **输出门(output gate)**:决定从单元状态中读取哪些信息并输出到隐状态 \( h_t \)。
数学表达式:
\[
\begin{aligned}
f_t &= \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) \quad & \text{(遗忘门)} \\
i_t &= \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) \quad & \text{(输入门)} \\
\tilde{C}_t &= \tanh(W_C \cdot [h_{t-1}, x_t] + b_C) \quad & \text{(候选单元状态)} \\
C_t &= f_t \odot C_{t-1} + i_t \odot \tilde{C}_t \quad & \text{(更新单元状态)} \\
o_t &= \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) \quad & \text{(输出门)} \\
h_t &= o_t \odot \tanh(C_t)
\end{aligned}
\]
其中 \( \sigma \) 是sigmoid函数(输出0-1),\( \odot \) 是逐元素乘法。
**关键优势**:通过遗忘门和输入门的相加结构,梯度在沿时间反向传播时,可以通过 \( C_t \) 路径直接流动,避免了反复乘以 \( W_{hh} \),从而很好地保留了长距离梯度。
#### GRU
GRU是LSTM的简化版本,将遗忘门和输入门合并为**更新门(update gate)**,并将单元状态和隐状态合并。结构更简单,计算量更小,但性能通常与LSTM相当。
\[
\begin{aligned}
z_t &= \sigma(W_z \cdot [h_{t-1}, x_t]) \quad & \text{(更新门)} \\
r_t &= \sigma(W_r \cdot [h_{t-1}, x_t]) \quad & \text{(重置门)} \\
\tilde{h}_t &= \tanh(W \cdot [r_t \odot h_{t-1}, x_t]) \\
h_t &= (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t
\end{aligned}
\]
**实践经验**:通常,LSTM和GRU在Seq2Seq任务中性能相近,GRU训练速度稍快。对于大多数应用,两者都是可靠的选择。
## 3. Seq2Seq的经典架构:编码器-解码器
### 3.1 核心思想:两阶段处理
Seq2Seq模型将序列转换任务分解为两个阶段:
1. **编码阶段**:编码器RNN逐个读取输入序列中的每个元素(例如,源语言的词)。读取完整个序列后,其最终隐状态 \( h_T \)(或单元状态 \( C_T \))被当作**上下文向量(context vector)** \( c \),它是输入序列的“语义摘要”。
2. **解码阶段**:解码器RNN以上下文向量 \( c \) 作为其初始隐状态,然后自回归地生成输出序列。每个时间步,它基于当前隐状态和上一个已生成的输出元素,预测下一个输出元素的概率分布。
### 3.2 编码器(Encoder):将输入序列编码为上下文向量
编码器通常是一个单层或多层的RNN(LSTM或GRU)。给定输入序列 \( X = (x_1, x_2, ..., x_T) \),编码器按顺序计算隐状态序列:
\[
h_t = \text{EncoderRNN}(x_t, h_{t-1})
\]
完成最后一步处理后,编码器的最终隐状态 \( h_T \) 包含了整个输入序列的信息。在最基本的Seq2Seq架构中,这个 \( h_T \) 就直接作为上下文向量 \( c \)。
如果编码器是LSTM,有时会同时使用最终隐状态 \( h_T \) 和最终单元状态 \( C_T \) 来初始化解码器(因为解码器也需要单元状态)。实践中,常将 \( h_T \) 和 \( C_T \) 拼接或变换后作为解码器的初始状态。
**注意**:原始Seq2Seq论文(Sutskever et al., 2014)对输入序列做了**逆序输入**。例如,输入“ABC”实际上被输入为“CBA”。这个技巧在当时极大地缓解了长距离依赖问题,因为源语言的开头单词(更可能需要提前输出)会离解码器的初始状态更近。注意力机制发明后,逆序输入不再是必须的。
### 3.3 解码器(Decoder):从上下文向量生成输出序列
解码器也是一个RNN(通常是LSTM或GRU)。它被初始化为上下文向量 \( c \)(或由 \( c \) 计算出的状态)。在每个时间步 \( t \),解码器:
1. 接收上一个生成的输出符号 \( y_{t-1} \)(在训练时通常是ground truth,在推理时是模型自己生成的)。
2. 更新自己的隐状态 \( s_t = \text{DecoderRNN}(y_{t-1}, s_{t-1}, c) \)。
3. 通过一个输出层(全连接 + softmax)计算在整个词表上的概率分布 \( P(y_t | y_{`)作为第一个输入。
2. 在每个时间步,根据当前状态预测下一个词的概率分布 \( p_t \)。
3. 选择概率最高的词(贪心搜索)或按某种策略采样得到 \( \hat{y}_t \)。
4. 将 \( \hat{y}_t \) 作为下一步的输入。
5. 重复直到生成结束符号 `` 或达到最大长度。
贪心搜索简单快速,但容易陷入局部最优。更高级的策略如束搜索(Beam Search)会在后续章节介绍。
## 4. 问题与局限:信息瓶颈与长序列挑战
虽然经典Seq2Seq在机器翻译等任务上取得了突破,但它存在根本性的缺陷。
### 4.1 定长上下文向量的瓶颈
编码器必须将整个输入序列(不论其长度如何)压缩成一个固定维度的向量 \( c \)。这类似于要求一个人用一句话总结一整本小说。当输入序列较长或信息密度很高时,这个向量的表达能力是严重不足的。信息压缩过程中的损失,导致解码器无法准确生成。
**直觉**:翻译一个20个词的句子时,编码器最终的隐状态必须记住每个词的含义、词序、语法结构、长距离修饰关系等。维度是固定的(例如512维),对于20个词或许可行,但对于50个词或100个词,信息就会被过度压缩。
### 4.2 长序列依赖的衰减
即使RNN(LSTM/GRU)缓解了梯度消失,在实践中,当输入序列长度超过一定阈值(例如30-50个词)时,基本Seq2Seq模型的性能会急剧下降。这是因为:
- 编码器在读到句子末尾时,其对句子开头的“记忆”已经变得模糊。
- 解码器在生成开头几个词时,需要依赖输入序列开头的信息,这些信息已经被多次变换和“遗忘”。
注意力机制正是为解决这两个问题而设计的。
## 5. 注意力机制(Attention):突破瓶颈的关键
注意力机制是Seq2Seq发展史上最重要的一步。它允许解码器在生成每个词时,**动态地“回看”编码器处理过的所有隐状态**,并**聚焦于与当前解码最相关的部分输入**。
### 5.1 直观理解:对齐与检索
想象一位人类译员在翻译一个长句。他不会只看一遍句子就闭上眼睛默写译文;相反,他会在写译文时不断回看原文,尤其关注当前正在翻译的片段对应的原文位置。
注意力机制模拟了这种行为。对于解码器的第 \( t \) 步,模型计算一个**注意力分布**——一个关于输入序列中各位置重要性的概率向量。然后,根据这个分布,计算**上下文向量** \( c_t \)(注意,不再是固定 \( c \))作为编码器隐状态的**加权和**。最后,解码器基于 \( c_t \)、当前隐状态 \( s_t \) 和上一个输出 \( y_{t-1} \) 来预测下一个词。
### 5.2 Bahdanau注意力(加性注意力)
Bahdanau等人(2015)在神经机器翻译中首次引入注意力机制,也称为**加性注意力**。
设编码器所有时间步的隐状态为 \( h_1, h_2, ..., h_T \)(\( h_j \in \mathbb{R}^{d_h} \)),解码器当前隐状态为 \( s_{t-1} \)(注意:这里使用 \( s_{t-1} \) 而非 \( s_t \),因为需要先计算注意力才能得到 \( s_t \),具体实现有差异)。对于每个编码器位置 \( j \),我们计算一个**对齐分数(alignment score)** \( e_{t,j} \):
\[
e_{t,j} = v_a^T \tanh(W_a s_{t-1} + U_a h_j)
\]
其中 \( v_a \in \mathbb{R}^{d_a} \),\( W_a \in \mathbb{R}^{d_a \times d_{\text{dec}}} \),\( U_a \in \mathbb{R}^{d_a \times d_{\text{enc}}} \) 是可学习的参数矩阵。这个分数衡量了 \( s_{t-1} \) 和 \( h_j \) 的匹配程度。
然后,使用softmax函数将所有分数归一化为注意力权重:
\[
\alpha_{t,j} = \frac{\exp(e_{t,j})}{\sum_{k=1}^{T} \exp(e_{t,k})}
\]
权重向量 \( \alpha_t \) 是一个概率分布,指示了解码当前位置应该关注输入的哪些部分。
最后,计算加权的上下文向量 \( c_t \):
\[
c_t = \sum_{j=1}^{T} \alpha_{t,j} h_j
\]
这个 \( c_t \) 随后会与解码器的输入 \( y_{t-1} \) 拼接,共同传递给解码器RNN,或者用于后续输出概率的计算。
### 5.3 Luong注意力(乘性注意力)
Luong等人(2015)提出了一种更简单的变体,称为**乘性注意力**(或点积注意力),其计算效率更高。主要区别在于:
1. 对齐分数计算方式不同(利用点积或矩阵乘法):
- **点积**:\( e_{t,j} = s_t^T h_j \) (此处 \( s_t \) 是解码器当前步的隐状态,而不是 \( t-1 \) 步)
- **缩放点积**:\( e_{t,j} = \frac{s_t^T h_j}{\sqrt{d_{\text{enc}}}} \) (Transformer引入的缩放版本)
- **一般性点积**:\( e_{t,j} = s_t^T W_a h_j \)
2. 计算时机不同:Bahdanau注意力在输入解码器RNN之前计算 \( c_t \)(使用 \( s_{t-1} \)),因此解码器的输入包含了 \( c_t \);Luong注意力在计算解码器状态 \( s_t \) 之后计算 \( c_t \),然后 \( c_t \) 单独用于预测输出词,不反馈给RNN内部。
Luong注意力的步骤:
- 计算解码器隐状态 \( s_t = \text{RNN}(y_{t-1}, s_{t-1}) \)
- 基于 \( s_t \) 计算对齐权重 \( \alpha_t \)
- 计算 \( c_t = \sum \alpha_{t,j} h_j \)
- 得到注意力向量 \( \tilde{s}_t = \tanh(W_c [c_t; s_t]) \)
- 从 \( \tilde{s}_t \) 预测输出词
### 5.4 注意力机制的本质:查询、键、值
在现代深度学习视角下,注意力机制可以被泛化为一个**查询(Query)**、一组**键(Key)**和一组**值(Value)**的检索框架。
- **查询(Q)**:解码器当前的隐状态(\( s_t \) 或 \( s_{t-1} \)),代表“我想找什么样的信息”。
- **键(K)**:编码器每个时间步的隐状态(\( h_j \)),每个键代表“我这里有什么类型的信息”。
- **值(V)**:也是编码器隐状态(或它们的线性变换),是实际要提取的信息内容。
注意力的计算过程:
1. 计算查询与每个键的相似度(对齐分数)。
2. 用softmax将相似度转换为权重分布。
3. 用权重对值进行加权求和,得到上下文向量。
这个“QKV”视角极大地统一了各种注意力变体,并为后续的Transformer自注意力奠定了基础(其中Q、K、V都来自同一个序列的不同投影)。
## 6. Seq2Seq的高级优化技巧
### 6.1 束搜索(Beam Search)
贪心搜索在每个时间步只保留概率最高的一个词,可能导致整体概率很高的序列由于某一步的局部次优选择而被错过。束搜索维护一个大小为 \( B \)(束宽)的候选集,每个候选是一个部分生成的序列(以及其对数概率)。
**算法步骤:**
1. 初始化候选列表为 `[([], 0.0)]`,其中0.0是累积对数概率(log-likelihood,实际用log避免下溢)。
2. 对于每个时间步 \( t=1 \) 到 \( T_{\max} \):
- 对于候选列表中的每个部分序列,计算其下一个词的logits,并取前 \( B \) 个最可能的词(或考虑全部词,但后续会裁剪)。
- 将每个词添加到序列,并更新累积对数概率(加上新词的log概率)。
- 将所有扩展后的新序列合并,按概率排序,只保留前 \( B \) 个得分最高的序列。
3. 一旦某个序列生成了 ``,将其从候选列表中移到完成列表中(或者继续保留但不再扩展)。
4. 最终选择完成列表中得分最高的序列(通常做长度归一化后比较)。
**束宽选择**:\( B=1 \) 等价于贪心搜索。\( B \) 越大,搜索越精确,但计算成本线性增加。常见选择是 4、8、12。
### 6.2 长度归一化与覆盖率惩罚
束搜索倾向于选择较短的序列,因为每增加一个词(其概率小于1)就会增加对数概率的负值(累积概率自然倾向于更短的乘积)。为解决此问题,使用**长度归一化**:
```
```
其中 \( \alpha \) 是超参数(通常0.6-1.0)。当 \( \alpha=1 \) 时,相当于取平均对数概率。
**覆盖率惩罚**(Coverage Penalty)用于防止重复生成相同内容(尤其在文本摘要中)。它惩罚那些注意力权重分布过于集中在某些输入位置的行为,鼓励模型在生成过程中均匀覆盖输入的不同部分。
### 6.3 双向编码器
标准编码器是单向的:隐状态 \( h_t \) 只依赖于过去的信息(\( x_1,...,x_t \))。但理解一个词的位置常常需要上下文信息,即它左边的词和右边的词。双向RNN(BiRNN)通过一个前向RNN和一个后向RNN来解决这个问题:
- 前向RNN:从左到右计算 \( \overrightarrow{h_t} = \text{RNN}_{\text{fwd}}(x_t, \overrightarrow{h_{t-1}}) \)
- 后向RNN:从右到左计算 \( \overleftarrow{h_t} = \text{RNN}_{\text{bwd}}(x_t, \overleftarrow{h_{t+1}}) \)
- 最终的编码器隐状态 \( h_t = [\overrightarrow{h_t}; \overleftarrow{h_t}] \) (向量拼接)
这样,每个位置的 \( h_t \) 都包含了该位置完整的左右上下文,极大地提升了编码器的表示能力。双向编码器已成为现代Seq2Seq的标准配置。
### 6.4 多层堆叠与残差连接
单个RNN层的表达能力有限。通过堆叠多个RNN层(例如,2-4层),可以构建更深、更具表达能力的模型。高层RNN可以学习到更抽象的时序特征。
然而,深层RNN面临梯度流动困难和性能退化问题。**残差连接(Residual Connection)** 可以将输入跨层直接传递到输出,帮助梯度直达较低层。在RNN中,残差连接通常这样实现:
```
\[
h_t^{(l)} = \text{RNN}^{(l)}(h_t^{(l-1)}, h_{t-1}^{(l)}) + h_t^{(l-1)}
\]
```
即该层输出与输入相加,再经过激活函数(或直接相加后作为下一层输入)。需要保证维度一致,否则需线性投影。
### 6.5 嵌入共享与权重绑定
在Seq2Seq模型中,编码器的嵌入层(将词索引转换为向量)和解码器的输出层(将隐状态转换回词概率分布)通常都对应同一个词表。**权重绑定(Weight Tying)** 技术指出,可以将解码器输出层的权重矩阵 \( W_{\text{out}} \) 与编码器的嵌入矩阵 \( E \) 共享(一般是转置关系):
```
\[
W_{\text{out}} = E^{\top}
\]
```
这样做的好处:
- 大幅减少参数量(词表大时尤其显著)。
- 充当一种正则化,限制模型自由度,提升泛化。
- 经验上通常带来更快的收敛和稍好的性能。
如果编码器和解码器词表相同(如单语任务),甚至可以共享编码器和解码器的整个嵌入层。对于不同语言(如英-法翻译),通常各自有独立的词表和嵌入层,但仍可以在解码器输出层绑定到解码器自己的嵌入层。
## 7. 实践指南:从零实现一个Seq2Seq(PyTorch)
本部分将用PyTorch实现一个带Luong注意力机制的Seq2Seq模型,用于英语到法语的简单翻译(字符级别或词级别示例)。完整代码可以在Jupyter Notebook中运行。
### 7.1 数据预处理与词表构建
我们使用一个小的平行语料库,例如:
英语:`["hello world", "how are you", "i love coding"]`
法语:`["bonjour le monde", "comment allez vous", "j'adore coder"]`
为了简单,可以按字符处理,或按词处理。这里按词处理。
```python
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from collections import Counter
import random
# 原始数据
eng_sentences = ["hello world", "how are you", "i love coding", "good morning"]
fra_sentences = ["bonjour le monde", "comment allez vous", "j'adore coder", "bonjour"]
# 添加, , ,
BOS_token = ''
EOS_token = ''
PAD_token = ''
UNK_token = ''
def build_vocab(sentences, min_freq=1):
word_counts = Counter()
for sent in sentences:
words = sent.split()
word_counts.update(words)
# 保留出现频率 >= min_freq 的词
vocab = {word: idx+4 for idx, (word, cnt) in enumerate(word_counts.items()) if cnt >= min_freq}
vocab[BOS_token] = 0
vocab[EOS_token] = 1
vocab[PAD_token] = 2
vocab[UNK_token] = 3
idx_to_word = {idx: word for word, idx in vocab.items()}
return vocab, idx_to_word
eng_vocab, eng_idx2word = build_vocab(eng_sentences)
fra_vocab, fra_idx2word = build_vocab(fra_sentences)
def encode(sentence, vocab, max_len=None):
words = sentence.split()
indices = [vocab.get(word, vocab[UNK_token]) for word in words]
# 添加EOS
indices.append(vocab[EOS_token])
if max_len:
indices = indices[:max_len] # 截断
return indices
def pad_sequences(sequences, pad_token, max_len=None):
if max_len is None:
max_len = max(len(seq) for seq in sequences)
padded = []
for seq in sequences:
padded_seq = seq + [pad_token] * (max_len - len(seq))
padded.append(padded_seq)
return padded, max_len
# 编码所有句子
eng_encoded = [encode(s, eng_vocab) for s in eng_sentences]
fra_encoded = [encode(s, fra_vocab) for s in fra_sentences]
# 填充
pad_idx = eng_vocab[PAD_token]
eng_padded, eng_max_len = pad_sequences(eng_encoded, pad_idx)
fra_padded, fra_max_len = pad_sequences(fra_encoded, pad_idx) # 使用相同的pad_idx但注意pad_token在两个词表中都对应2即可
# 转换为LongTensor
eng_tensor = torch.LongTensor(eng_padded)
fra_tensor = torch.LongTensor(fra_padded)
# 创建DataLoader
dataset = torch.utils.data.TensorDataset(eng_tensor, fra_tensor)
dataloader = torch.utils.data.DataLoader(dataset, batch_size=2, shuffle=True)
```
### 7.2 编码器实现(包括嵌入层、RNN层)
```python
class Encoder(nn.Module):
def __init__(self, input_dim, emb_dim, hidden_dim, n_layers=2, dropout=0.5):
super().__init__()
self.embedding = nn.Embedding(input_dim, emb_dim, padding_idx=pad_idx)
self.rnn = nn.LSTM(emb_dim, hidden_dim, n_layers, dropout=dropout, batch_first=True)
self.dropout = nn.Dropout(dropout)
def forward(self, src):
# src shape: (batch_size, src_len)
embedded = self.dropout(self.embedding(src)) # (batch, src_len, emb_dim)
outputs, (hidden, cell) = self.rnn(embedded) # outputs: (batch, src_len, hidden_dim * num_directions)
# hidden, cell: (n_layers * num_directions, batch, hidden_dim)
return outputs, hidden, cell
```
### 7.3 解码器实现(包括注意力机制)
使用Luong注意力(点积形式),需要将编码器所有输出(`outputs`)和当前解码器隐状态进行点积。
```python
class Decoder(nn.Module):
def __init__(self, output_dim, emb_dim, hidden_dim, n_layers=2, dropout=0.5):
super().__init__()
self.output_dim = output_dim
self.hidden_dim = hidden_dim
self.embedding = nn.Embedding(output_dim, emb_dim, padding_idx=pad_idx)
self.rnn = nn.LSTM(emb_dim + hidden_dim, hidden_dim, n_layers, dropout=dropout, batch_first=True)
self.fc_out = nn.Linear(hidden_dim * 2, output_dim) # 将注意力向量和隐状态拼接
self.dropout = nn.Dropout(dropout)
def forward(self, input, hidden, cell, encoder_outputs):
# input: (batch_size, 1) 当前时间步的输入词索引
# hidden, cell: (n_layers, batch, hidden_dim)
# encoder_outputs: (batch, src_len, hidden_dim)
# 扩展输入维度用于RNN
input = input.unsqueeze(1) # (batch, 1)
embedded = self.dropout(self.embedding(input)) # (batch, 1, emb_dim)
# 计算注意力权重
# 获取解码器当前隐状态(使用最后一层)
hidden_last = hidden[-1] # (batch, hidden_dim)
# 点积注意力: (batch, src_len) = (batch, hidden_dim) @ (batch, hidden_dim, src_len)
# 需要调整形状: encoder_outputs (batch, src_len, hidden_dim) -> (batch, hidden_dim, src_len)
energy = torch.bmm(hidden_last.unsqueeze(1), encoder_outputs.permute(0,2,1)) # (batch, 1, src_len)
attention_weights = torch.softmax(energy, dim=-1) # (batch, 1, src_len)
# 加权求和得到上下文向量
context = torch.bmm(attention_weights, encoder_outputs) # (batch, 1, hidden_dim)
context = context.squeeze(1) # (batch, hidden_dim)
# 将嵌入向量和上下文向量拼接作为RNN输入
rnn_input = torch.cat((embedded.squeeze(1), context), dim=1) # (batch, emb_dim+hidden_dim)
rnn_input = rnn_input.unsqueeze(1) # (batch, 1, emb_dim+hidden_dim)
# RNN前向
output, (hidden, cell) = self.rnn(rnn_input, (hidden, cell)) # output: (batch, 1, hidden_dim)
output = output.squeeze(1) # (batch, hidden_dim)
# 将输出与上下文向量拼接,通过线性层预测下一个词
prediction = self.fc_out(torch.cat((output, context), dim=1)) # (batch, output_dim)
return prediction, hidden, cell
```
### 7.4 训练循环与损失掩码
损失函数需要忽略填充位置(PAD)的贡献。
```python
def train(model, dataloader, optimizer, criterion, clip, device):
model.train()
epoch_loss = 0
for batch in dataloader:
src, trg = batch[0].to(device), batch[1].to(device)
optimizer.zero_grad()
# 编码器
encoder_outputs, hidden, cell = model.encoder(src)
# 解码器初始输入是 token
bos_idx = model.decoder.embedding.padding_idx? No, we have to get from vocab.
# 我们可以在模型外部传参,但简单起见,假设 trg 第一列就是BOS?通常我们不会把BOS放在目标里,而是动态构建。
# 正确做法: trg = [BOS, word1, word2, ..., EOS],输入解码器时使用 trg[:, :-1],计算损失时与 trg[:, 1:]比较。
# 重写循环:
trg_input = trg[:, :-1] # (batch, trg_len-1)
trg_output = trg[:, 1:] # (batch, trg_len-1)
outputs = torch.zeros(trg_input.size(0), trg_input.size(1), model.decoder.output_dim).to(device)
# 初始解码器状态使用编码器的最终状态
hidden, cell = hidden, cell
for t in range(trg_input.size(1)):
# 逐词输入
pred, hidden, cell = model.decoder(trg_input[:, t], hidden, cell, encoder_outputs)
outputs[:, t, :] = pred
# 计算损失,忽略PAD
loss = criterion(outputs.reshape(-1, outputs.shape[-1]), trg_output.reshape(-1))
# 但criterion默认不忽略PAD,我们需要设置ignore_index
# 简单重新定义criterion = nn.CrossEntropyLoss(ignore_index=pad_idx)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
optimizer.step()
epoch_loss += loss.item()
return epoch_loss / len(dataloader)
# 超参数
INPUT_DIM = len(eng_vocab)
OUTPUT_DIM = len(fra_vocab)
EMB_DIM = 256
HIDDEN_DIM = 512
N_LAYERS = 2
DROPOUT = 0.5
LR = 0.001
CLIP = 1.0
N_EPOCHS = 20
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
enc = Encoder(INPUT_DIM, EMB_DIM, HIDDEN_DIM, N_LAYERS, DROPOUT)
dec = Decoder(OUTPUT_DIM, EMB_DIM, HIDDEN_DIM, N_LAYERS, DROPOUT)
model = Seq2Seq(enc, dec, device).to(device)
optimizer = optim.Adam(model.parameters(), lr=LR)
criterion = nn.CrossEntropyLoss(ignore_index=pad_idx) # pad_idx 需要确保与词汇表一致
for epoch in range(N_EPOCHS):
loss = train(model, dataloader, optimizer, criterion, CLIP, device)
print(f'Epoch {epoch+1}, Loss: {loss:.4f}')
```
*(注:上述代码是示意性的,实际运行需要补充`Seq2Seq`包装类、处理设备一致性、以及正确的`` token索引等细节。完整可运行代码请参考附件的课外实践。)*
### 7.5 推理与BLEU评估
```python
def translate_sentence(model, sentence, eng_vocab, fra_vocab, max_len=50, device='cpu'):
model.eval()
tokens = sentence.lower().split()
src_indices = [eng_vocab.get(t, eng_vocab[UNK_token]) for t in tokens]
src_tensor = torch.LongTensor(src_indices).unsqueeze(0).to(device)
with torch.no_grad():
encoder_outputs, hidden, cell = model.encoder(src_tensor)
trg_indices = [fra_vocab[BOS_token]]
for _ in range(max_len):
trg_tensor = torch.LongTensor([trg_indices[-1]]).to(device)
with torch.no_grad():
pred, hidden, cell = model.decoder(trg_tensor, hidden, cell, encoder_outputs)
pred_token = pred.argmax(1).item()
trg_indices.append(pred_token)
if pred_token == fra_vocab[EOS_token]:
break
translated = [fra_idx2word[idx] for idx in trg_indices[1:-1]] # 去掉BOS和EOS
return ' '.join(translated)
# 示例
print(translate_sentence(model, "hello world", eng_vocab, fra_vocab, device=device))
```
BLEU评分可以使用`nltk.translate.bleu_score`实现。
```python
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction
def compute_bleu(model, references, hypotheses):
smooth = SmoothingFunction().method1
bleu_scores = []
for ref, hyp in zip(references, hypotheses):
bleu = sentence_bleu([ref.split()], hyp.split(), smoothing_function=smooth)
bleu_scores.append(bleu)
return sum(bleu_scores)/len(bleu_scores)
```
## 8. 评估方法:如何衡量序列生成质量
### 8.1 BLEU(双语评估替补)
BLEU最初用于机器翻译,衡量候选译文与一个或多个参考译文的n-gram重叠度,并加入简短惩罚(brevity penalty)防止生成过短译文。
**计算步骤**:
1. 计算修正后的n-gram精度 \( p_n \):对每个n-gram,计数其在候选译文中出现且匹配参考译文的最大次数(clipped count)。
2. 计算几何平均数 \( \exp(\sum_{n=1}^{N} w_n \log p_n) \),通常 \( N=4 \),\( w_n=1/N \)。
3. 乘以简短惩罚 \( \text{BP} = \min(1, e^{1 - L_{\text{ref}} / L_{\text{cand}}}) \)。
**优点**:计算快速,与人类判断有一定相关性。**缺点**:不擅长评估语义和语法,对同义词惩罚,无法捕捉长距离结构。
### 8.2 ROUGE、METEOR、CIDEr
- **ROUGE**(面向召回):主要衡量生成文本与参考文本的n-gram重叠中的召回率(及F1),常用于文本摘要。ROUGE-N(n-gram)、ROUGE-L(最长公共子序列)。
- **METEOR**:显式考虑了同义词匹配、词干还原和词序,与人类相关性更好,但计算复杂。
- **CIDEr**(Consensus-based Image Description Evaluation):专用于图像描述,通过对每个n-gram赋予TF-IDF权重来强调罕见但有意义的词。
### 8.3 人工评估与任务特定指标
尽管自动指标方便,但任务的关键细节往往需要人工打分(如流畅度、充分性)。在某些任务中,还可使用任务特定指标,如对话系统中的回复成功率、代码生成中的编译通过率等。
## 9. 变体与扩展:超越基本Seq2Seq
### 9.1 指针网络(Pointer Networks)
标准Seq2Seq的输出空间是固定的词表。而指针网络**从输入序列中直接拷贝元素**作为输出。它通过注意力机制为输入序列的每个位置生成一个“指针”概率,最大概率位置对应的输入元素即被输出。这解决了输出词汇依赖于输入实例的问题(如组合优化问题中的节点排序)。
### 9.2 拷贝机制(Copy Mechanism)
在处理需要将某些罕见词(如专有名词、数字)原样输出的任务(如文本摘要、对话系统)时,拷贝机制结合了生成模式(从词表选词)和拷贝模式(从输入序列中选择一个token)。模型动态决定每个输出位置是生成新词还是拷贝输入中的某个词。代表性的工作:See et al. (2017) 的 Pointer-Generator Network。
### 9.3 覆盖机制(Coverage Mechanism)
用于缓解解码过程中重复生成相同内容的问题(尤其在长文本摘要中)。它维护一个**覆盖向量**(coverage vector),记录输入序列中每个位置到目前为止已经被注意力“覆盖”的程度。在计算新的注意力权重时,对之前覆盖度高的位置加以惩罚,促使模型探索未覆盖的部分。
### 9.4 计划采样(Scheduled Sampling)
针对Teacher Forcing造成的暴露偏差,计划采样在训练过程中,以概率 \( \epsilon_i \) 使用模型自身在上一步的预测作为下一步输入,否则使用ground truth。\( \epsilon_i \) 通常随训练步数从1衰减到0(开始时多用真实值稳定,后期多用预测值模拟推理环境)。这能让模型学会在推理时修正错误,但可能降低训练稳定性。
## 10. 现代替代:Transformer与自注意力
Vaswani等人2017年的论文《Attention Is All You Need》提出Transformer架构,彻底抛弃了RNN结构,完全基于自注意力(self-attention)和前馈网络。Transformer已成为现代Seq2Seq任务的默认选择,并在大多数基准上大幅超越RNN-based模型。
### 10.1 Transformer的宏观架构
Transformer仍然采用编码器-解码器架构:
- **编码器**:由N个相同的层堆叠而成,每层包含一个多头自注意力模块(Multi-Head Self-Attention)和一个逐位置前馈网络(Position-wise FFN),每个子层后都有残差连接和层归一化。
- **解码器**:同样有N个层,每层包含一个带掩码的多头自注意力(防止看到未来位置)、一个编码器-解码器注意力模块(将编码器的输出作为K和V,解码器的当前状态作为Q),以及一个前馈网络。
### 10.2 缩放点积注意力与多头注意力
**缩放点积注意力**:
\[
\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right) V
\]
其中 \( d_k \) 是键的维度。缩放因子 \( \frac{1}{\sqrt{d_k}} \) 防止点积过大导致softmax梯度消失。
**多头注意力**:将Q、K、V分别通过不同的线性投影映射到多个低维空间(h个头),在每个头上独立执行注意力,然后将所有头的输出拼接并再次线性投影。多头机制允许模型从不同表征子空间联合关注信息。
### 10.3 位置编码
由于Transformer没有任何循环或卷积,模型不知道序列的先后顺序。因此需要注入**位置编码(Positional Encoding)**,通常是正弦函数生成的位置嵌入(每个维度用不同频率的正弦/余弦函数),直接加到输入嵌入上。
### 10.4 为什么Transformer在很大程度上取代了RNN Seq2Seq?
1. **并行计算**:RNN必须逐步计算,难以利用GPU的并行能力;Transformer的自注意力计算全序列同时进行,训练速度快很多。
2. **长距离依赖**:RNN经过很多步后信息容易衰减;Transformer任意两个位置直接交互,路径长度恒为1,轻易捕获长依赖。
3. **可扩展性**:增加层数和头数往往能稳定提升性能,且容易在大规模数据上训练(如BERT、GPT)。
4. **性能**:在各种Seq2Seq任务上,Transformer BLEU分数比最优的RNN Seq2Seq模型高出数个点。
当然,Transformer也有计算量随序列长度平方增加的缺点,但针对长序列的改进(如稀疏注意力)已有很多研究。
尽管如此,经典的RNN-based Seq2Seq仍然在教学、嵌入式设备、以及对实时性要求高但序列不长(如语音片段)的场景中有用武之地。
## 11. 应用案例
### 11.1 机器翻译(Neural Machine Translation)
这是Seq2Seq的经典应用。Google翻译于2016年将生产系统切换为基于LSTM的注意力Seq2Seq模型,随后很快又转向Transformer。关键挑战包括:处理罕见词、歧义词、不同语序、大词表等。
### 11.2 文本摘要(Abstractive Summarization)
不同于抽取式摘要(直接复制原文句子),生成式摘要要求模型重新组织语言。Seq2Seq + 注意力 + 拷贝机制是目前的主流方法(如PEGASUS、BART等预训练模型)。
### 11.3 对话系统(Chatbots)
端到端的对话生成使用Seq2Seq将用户消息映射到回复。但开放域对话容易产生安全但无聊的回复(“我很好”)。改进方法包括条件生成、引入个性嵌入、强化学习优化等。
### 11.4 图像描述(Image Captioning)
使用卷积神经网络(CNN,如ResNet)作为图像编码器提取视觉特征(作为“隐状态”序列),然后采用Seq2Seq解码器生成描述语句。CNN特征图的空间维度可以视为编码器的“时间步”,注意力机制可以定位到图像的特定区域。
## 12. 总结与展望
本笔记系统回顾了Seq2Seq模型的演进历程:从基础RNN,到编码器-解码器架构,再到注意力机制的突破,以及Transformer对传统架构的革命。
**核心收获:**
- Seq2Seq是处理变长序列映射问题的通用框架,特别适用于NLP任务。
- 注意力机制通过动态对齐,解决了信息瓶颈和长依赖难题,是模型能力的里程碑。
- 训练与推理时存在差异(Teacher Forcing vs. 自回归),需要针对性优化。
- Transformer凭借并行计算和自注意力成为目前的最优解,但RNN版本仍有理解和实现的教育价值。
**当前前沿方向:**
- 大规模预训练语言模型(如BERT、GPT-3、T5):通过自监督学习在巨量数据上预训练,然后微调至下游Seq2Seq任务,达到了前所未有的性能。
- 高效Transformer:针对长序列,提出Linformer、BigBird、Longformer等线性或稀疏注意力变体。
- 多模态Seq2Seq:同时处理文本、图像、音频等多种模态输入和输出。
最后,Seq2Seq不仅是一套技术,更是一种思维方式:将复杂问题分解为“理解输入”和“生成输出”两个可分离的阶段,并在两者之间设计信息通路。掌握Seq2Seq将为学习和研究更高级的生成模型奠定坚实基础。
---
**参考文献与推荐阅读:**
1. Sutskever, I., Vinyals, O., & Le, Q. V. (2014). Sequence to sequence learning with neural networks. *NeurIPS*.
2. Cho, K., et al. (2014). Learning phrase representations using RNN encoder-decoder for statistical machine translation. *EMNLP*.
3. Bahdanau, D., Cho, K., & Bengio, Y. (2015). Neural machine translation by jointly learning to align and translate. *ICLR*.
4. Luong, M. T., Pham, H., & Manning, C. D. (2015). Effective approaches to attention-based neural machine translation. *EMNLP*.
5. Vaswani, A., et al. (2017). Attention is all you need. *NeurIPS*.
6. See, A., Liu, P. J., & Manning, C. D. (2017). Get to the point: Summarization with pointer-generator networks. *ACL*.
7. Klein, G., et al. (2017). OpenNMT: Open-source toolkit for neural machine translation. *ACL system demonstrations*.