首页 / Seq2Seq学习笔记:从基础到前沿


Copyright ©2025 luckyxi的学习日志 | All Rights Reserved

粤ICP备2025495461号-1

粤公网安备44060402003071号


# 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*.