Seq2Seq(Sequence-to-Sequence)模型是一种用于处理输入和输出均为序列任务的深度学习模型。它最初被设计用于机器翻译,但后来广泛应用于其他任务,如文本摘要、对话系统、语音识别、问答系统等。
核心思想
Seq2Seq 模型的目标是将一个序列(如一句话)转换为另一个序列,例如:
-
输入:英文句子 "Hello, how are you?"
-
输出:法语句子 "Bonjour, comment ça va ?"
模型结构
Seq2Seq 模型通常由两部分组成:
-
编码器(Encoder):
-
将输入序列(如源语言句子)编码为一个固定长度的上下文向量(Context Vector),用于捕捉输入序列的语义信息。
-
常用循环神经网络(RNN、LSTM、GRU)或 Transformer 作为编码器。
-
-
解码器(Decoder):
-
基于编码器生成的上下文向量,逐步生成输出序列(如目标语言句子)。
-
解码器通常也是一个循环神经网络(或 Transformer),每一步生成一个输出词,直到生成结束符(如
<EOS>
)。
-
首先,来简单介绍下RNN(循环神经网络)结构:
1. RNN 简介
RNN结构
RNN中,每个单元接受两个输入,一个是当前时间步输入的信息 ,另一个是上一个单元的隐藏层状态 。为什么这种结构的RNN适合用于做文本等序列型数据的任务,主要是因为隐藏状态的存在使得模型具有记忆性。针对不同的任务,根据输入和输出的数量,通常对RNN结构进行调整。
RNN的常见几种结构
1.1 N to N
该模型处理的一般是输入和输出序列长度相等的任务,如
- 词性标注
- 语言模型(Language Modeling)
1.2 1 to N
此类结构的输入长度为1,输出长度为N,一般又可以分为两种:一种是将输入只输入到第一个神经元,另一种将输入输入到所有神经元。
一般用于以下任务:
图像生成文字,一般输入 X 为图片,输出为一段图片描述性的文字;
输入音乐类别,生成对应的音乐
根据小说(新闻情感)类别,生成对应的文字
1.3 N to 1
和1 to N相反,一般常见任务有:
- 序列分类任务,如给定一段文本或语音序列,归类(情感分类,主题分类等)
2. Seq2Seq 模型
经过上面对几种RNN结构的分析,不难发现RNN结构大多对序列的长度比较局限,对于类似于机器翻译的任务,输入和输出长度并不对等,为N to M的结构,简单的RNN束手无策,因此便有了新的模型,Encoder-Decoder模型,也就是Seq2Seq模型。
模型一般由两部分组成:第一部分是Encoder部分,用于对输入的N长度的序列进行表征;第二部分是Decoder部分,用于将Encoder提取出的表征建立起到输出的M长度序列的映射。
2.1 编码器 Encoder
Encoder部分一般使用了普通RNN的结构。其将一个序列表征为一个定长的上下文向量c,计算方式有多种,如下:
2.2 解码器 Decoder
相对于编码器而言,解码器的结构更多,下面介绍三种:
第一种
这种结构直接Encoder得到的上下文向量作为RNN的初始隐藏状态输入到RNN结构中,后续单元不接受 c 的输入,计算公式如下:
- 隐藏状态的更新:
第二种
第二种将Encoder得到的上下文向量作为每个神经单元的输入,不再是只作为第一个单元的初始隐藏状态。计算公式如下:
第三种
第三种在 c 的处理上和第二种类似,但是区别在于将前一个神经单元的输出作为当前神经单元的输出。计算公式如下:
3. Seq2Seq中的Trick
3.1 Teacher Forcing
主要针对第三种Decoder应用。当某一个单元输出出错时,如果将其输出传递给下一个单元,可能导致错误传递下去。这时候,需要在一定程度上减少这种传递,就采用按一定的比例决定是否对神经单元采用上一个上一个单元的输出作为输入。即:
3.2 Attention 机制(很重要)
提出Attention机制之前,我们先来看下之前的结构有什么问题:
核心问题是当序列过长时,上述的Encoder输出的上下文向量 c 无法记住所有信息,会出现长序列梯度消失的问题。比如句子有100个词,那么c里面可能丢失了前几个词的信息。
Attention 机制是怎样的?
Attention 机制其实是参考了人在翻译文章时候的注意力机制,它会将模型的注意力放在当前翻译的单词上,换句话说,它并不像前面提到的Encoder的结构一样对整个句子用一个表征,它对于每个单词都有一个以单词为中心的表征。Encoder结构如下:
3.3 束搜索(Beam Search)
注意:Beam Search只用于测试,不用于训练过程。
当模型训练好后,给其输入一段话,其输出的每个单元的 y 给的是各个词的概率,我们如何根据概率选词且如何判断是否句子终止呢?
采取的方法是在每个时间步,选取当前时间步条件概率最大的k个词,作为该时间步的候选输出序列。如下图,k选择2,第一步p(A|c)和p(C|c)最大;第二步 P(AB|c),P(CE|c)最大;第三步P(ABD|c),P(CED|c)最大。
这样,得到的最终候选序列就是各个时间步的得到的序列的集合,下图种即为6个 {A, C, AB, CE, ABD, CED}。那么最终预测结果就是要从这6个中选出分最高的。
这时候,可能有小伙伴会发现,那按概率算的话,序列越长的概率肯定越小呀,所以一般最后分数计算会有一个和序列长度有关的惩罚系数,如下:
其中L为候选序列的长度,α 一般选0.75. 这样一来,序列长的对应的系数更小,而由于取了对数,概率的对数是负数,如此变化后会使得长序列和短序列处于一个可比的情形。
4. 详细代码对比(RNN VS Seq2Seq)
4.1 基础 RNN 示例(以文本分类为例)
4.1.1 模型结构
-
输入:一个批次的句子(或序列),已被转化为数字索引表示(如词向量)。
-
RNN :使用
nn.RNN
来提取序列特征。 -
输出:可以是序列最后一个时间步(或平均池化后的序列向量)接一个线性层,最终输出分类结果。
import torch
import torch.nn as nnclass BasicOnlyRNNModel(nn.Module):
def init(self, vocab_size, embedding_dim, hidden_dim, output_dim,
num_layers=1, bidirectional=False):
super(BasicOnlyRNNModel, self).init()# 词向量层 self.embedding = nn.Embedding(vocab_size, embedding_dim) # 仅使用RNN self.rnn = nn.RNN( input_size=embedding_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=bidirectional ) # 双向时需要 *2 self.num_directions = 2 if bidirectional else 1 rnn_output_dim = hidden_dim * self.num_directions # 全连接层, 用于将 RNN 的输出映射到分类空间 self.fc = nn.Linear(rnn_output_dim, output_dim) def forward(self, text): """ text: [batch_size, seq_len] """ # 嵌入: [batch_size, seq_len] -> [batch_size, seq_len, embedding_dim] embedded = self.embedding(text) # 前向传播 RNN # rnn_out: [batch_size, seq_len, hidden_dim * num_directions] # hidden : [num_layers * num_directions, batch_size, hidden_dim] rnn_out, hidden = self.rnn(embedded) # 取最后一层的 hidden state # hidden 的形状: [num_layers * num_directions, batch_size, hidden_dim] # 若是单向 RNN,hidden[-1] 即可 # 若是双向 RNN,需拼接正向和反向最后时刻 if self.num_directions == 1: out = hidden[-1, :, :] # [batch_size, hidden_dim] else: # 对双向的情况,hidden[-2] 与 hidden[-1] 分别是正向/反向最后时刻 out = torch.cat((hidden[-2, :, :], hidden[-1, :, :]), dim=1) # [batch_size, hidden_dim * 2] # 全连接映射至分类空间 logits = self.fc(out) # [batch_size, output_dim] return logits
================ 以下是训练/推理时的简单示例 ================
if name == "main":
# 假设我们有一些超参数
vocab_size = 10000 # 词典大小
embedding_dim = 128 # 词向量维度
hidden_dim = 256 # RNN 隐藏层维度
output_dim = 2 # 假设做二分类
num_layers = 1 # RNN 层数
bidirectional = True # 是否使用双向model = BasicOnlyRNNModel( vocab_size=vocab_size, embedding_dim=embedding_dim, hidden_dim=hidden_dim, output_dim=output_dim, num_layers=num_layers, bidirectional=bidirectional ) # 假设当前批次的输入 batch_size = 32 seq_len = 50 batch_text = torch.randint(0, vocab_size, (batch_size, seq_len)) # [batch_size, seq_len] # 前向传播 logits = model(batch_text) # 形状: [32, 2] print(logits.shape) # 输出: torch.Size([32, 2])
基础 RNN 模型特点
- 数据流:输入序列 → 嵌入层 → RNN → 最后一层 hidden state → 全连接层 → 输出。
- 应用场景:常用于序列分类/回归(文本分类、情感分析、序列标注等)。
- 优点:结构简单,实现快速。
- 缺点:对长序列依赖可能不够,若需要输出一个序列(而不是一个标量或一个向量),就需要进一步改造或循环解码。
二、Seq2Seq 示例(以机器翻译为例)
对于Seq2Seq 模型,一般包含 Encoder(编码器)和 Decoder(解码器)两个主要部分(可选地加入Attention机制)。下文示例使用 LSTM 结构,演示最基础的编码器-解码器形式(不带注意力机制)。带注意力的情况会多一步 Attention 计算。
1. Encoder(编码器)
import torch
import torch.nn as nn
class Encoder(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers=1):
super(Encoder, self).__init__()
self.hidden_dim = hidden_dim
self.num_layers = num_layers
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers, batch_first=True)
def forward(self, src):
"""
src: [batch_size, src_len]
"""
embedded = self.embedding(src) # [batch_size, src_len, embedding_dim]
outputs, (hidden, cell) = self.lstm(embedded)
# outputs: [batch_size, src_len, hidden_dim]
# hidden: [num_layers, batch_size, hidden_dim]
# cell: [num_layers, batch_size, hidden_dim]
return hidden, cell
2. Decoder(解码器)
class Decoder(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers=1):
super(Decoder, self).__init__()
self.hidden_dim = hidden_dim
self.num_layers = num_layers
self.embedding = nn.Embedding(vocab_size, embedding_dim)
self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers, batch_first=True)
self.fc_out = nn.Linear(hidden_dim, vocab_size) # 输出到词表大小
def forward(self, tgt, hidden, cell):
"""
tgt: [batch_size] 当前时间步解码器输入(可以是一个词或一批词)
hidden, cell: 编码器传过来的(或者上一时间步传下来的)隐状态
"""
# 解码器一般一次输入一个token,也可以批量处理多个
# 这里假设 tgt 形状是 [batch_size],先unsqueeze到[batch_size, 1]
tgt = tgt.unsqueeze(1) # [batch_size, 1]
embedded = self.embedding(tgt) # [batch_size, 1, embedding_dim]
output, (hidden, cell) = self.lstm(embedded, (hidden, cell))
# output: [batch_size, 1, hidden_dim]
prediction = self.fc_out(output.squeeze(1)) # [batch_size, vocab_size]
return prediction, hidden, cell
3. Seq2Seq 整合
class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder, device):
super(Seq2Seq, self).__init__()
self.encoder = encoder
self.decoder = decoder
self.device = device
def forward(self, src, tgt, teacher_forcing_ratio=0.5):
"""
src: [batch_size, src_len]
tgt: [batch_size, tgt_len] 训练时通常会有"开始标志 <sos>"和"结束标志 <eos>"。
teacher_forcing_ratio: float,表示在训练时使用老师强制的概率
"""
batch_size = src.shape[0]
tgt_len = tgt.shape[1]
vocab_size = self.decoder.fc_out.out_features
# 用于存储解码器每个时间步的输出词分布
outputs = torch.zeros(batch_size, tgt_len, vocab_size).to(self.device)
# 1. 编码器对源序列进行编码,获取初始 hidden, cell
hidden, cell = self.encoder(src)
# 2. 解码器的第一个输入通常是 <sos>
input_t = tgt[:, 0] # 取第0个词(即<sos>)
for t in range(1, tgt_len):
# 2.1 将当前时间步的 token 喂给 Decoder
output, hidden, cell = self.decoder(input_t, hidden, cell)
# output 形状: [batch_size, vocab_size]
# 2.2 存储
outputs[:, t, :] = output
# 2.3 决定下一时间步输入:是用模型自己的预测还是用老师的标签(teacher forcing)
teacher_force = torch.rand(1).item() < teacher_forcing_ratio
top1 = output.argmax(1) # [batch_size], 最大值所对应的词索引
input_t = tgt[:, t] if teacher_force else top1
return outputs
Seq2Seq 模型特点
- 整体结构 :输入序列先经过
Encoder
编码,将序列信息"压缩"到hidden, cell
(或h_n, c_n
),再在解码阶段通过Decoder
逐步解码出目标序列。 - 输出 :是一个序列而非一个标量/向量,通过循环或一步步地喂入上一步的输出(或真实标签)来生成。
- 扩展 :在解码阶段可加入
Attention
(Luong/Bahdanau 等),或者进一步用Transformer
替代 RNN,形成更强大的序列到序列生成模型。 - 应用场景:机器翻译、文本摘要、对话系统、序列到序列的生成任务(如代码自动生成、语音到文本等)。
三、RNN VS Seq2Seq(回答问题:为什么要有编码器?直接把词向量拼在一起,然后用 RNN 1 to N 不行吗?)
在序列到序列(Seq2Seq)任务中(如机器翻译、文本摘要等),直接使用RNN后通过全连接层输出(1 to N)看似简单,但存在以下关键问题,而编码器-解码器(Encoder-Decoder)结构通过分步编码和解码的方式有效解决了这些挑战:
在序列到序列(Seq2Seq)任务中(如机器翻译、文本摘要等),直接使用词嵌入后通过全连接层("M个神经元")输出看似简单,但存在以下关键问题,而编码器-解码器(Encoder-Decoder)结构通过分步编码和解码的方式有效解决了这些挑战:
1. 序列的时序依赖性
自然语言中的单词顺序至关重要。例如:
-
句子1 :
猫追老鼠
-
句子2 :
老鼠追猫
两个句子包含相同的词,但含义完全相反。
-
简单词嵌入+全连接的缺陷 :
若直接将所有词嵌入拼接成一个向量(如
[猫, 追, 老鼠]
→ 一个长向量),模型会丢失词序信息,无法区分两个句子的差异。 -
编码器-解码器的优势 :
通过LSTM或Transformer按顺序处理输入词,编码器能够保留词序信息,并在隐藏状态中传递时序依赖关系。
2. 输入和输出的变长问题
在Seq2Seq任务中,输入和输出的长度通常是动态变化的。例如:
-
输入 :英文句子
"Hello world"
(2个词) -
输出 :中文翻译
"你好世界"
(3个词) -
简单词嵌入+全连接的缺陷 :
全连接层需要固定维度的输入和输出,无法处理变长序列。
-
编码器-解码器的优势:
-
编码器可处理任意长度的输入序列,将其压缩为固定长度的上下文向量(
hidden
和cell
)。 -
解码器基于上下文向量逐步生成变长的输出序列(逐词生成,直到预测到
<eos>
)。
-
3. 长距离依赖建模
语言中常存在跨越多个单词的依赖关系。例如:
-
句子 :
The cat, which was hungry and had been wandering the streets for days, finally found some food.
主句的主语
cat
与谓语found
相隔很远。 -
简单词嵌入+全连接的缺陷 :
全连接层难以捕捉长距离依赖(尤其是当句子较长时)。
-
编码器-解码器的优势:
-
LSTM通过门控机制(遗忘门、输入门)逐步更新
cell
状态,传递长期信息。 -
Transformer通过自注意力机制(Self-Attention)直接建模词与词之间的全局依赖。
-
4. 信息压缩与语义表示
编码器的核心作用是将输入序列编码为一个全局语义表示(上下文向量)。
-
简单词嵌入+全连接的缺陷 :
直接将所有词嵌入拼接为一个向量,缺乏对整体语义的抽象(相当于"词袋模型")。
-
编码器-解码器的优势:
-
编码器通过循环或注意力机制逐步融合上下文信息,生成紧凑的语义表示。
-
解码器基于此表示逐步展开生成目标序列,确保输出与输入语义一致。
-
5. 模型效率与参数共享
-
简单词嵌入+全连接的缺陷 :
若输入长度为
N
,输出长度为M
,全连接层的参数量为(N×embedding_dim) × M
,随序列长度增长迅速膨胀,导致计算成本高且易过拟合。 -
编码器-解码器的优势:
-
LSTM或Transformer通过参数共享(同一层处理所有时间步),参数量仅与隐藏层维度相关,与序列长度无关。
-
例如,LSTM的参数量为
4×(embedding_dim + hidden_dim)×hidden_dim
,与输入长度N
无关。
-
6. 实际案例对比
假设用两种模型处理机器翻译任务:
方案1:简单全连接
-
输入 :将源句子所有词嵌入拼接为一个向量(如
N=5
,embedding_dim=256
→ 输入维度1280
)。 -
输出 :直接映射到目标语言的词表(如
vocab_size=10000
),参数量为1280×10000 = 12.8M
。 -
问题:
-
无法处理长度变化的输入输出。
-
无法建模词序和长距离依赖。
-
参数量大且难以训练。
-
方案2:编码器-解码器(LSTM)
-
编码器 :LSTM逐步处理源序列,输出上下文向量(如
hidden_dim=256
)。 -
解码器:LSTM基于上下文向量逐词生成目标序列。
-
参数量 :编码器和解码器的LSTM参数量均为
4×(256+256)×256 ≈ 1M
,总计约2M
。 -
优势:
-
处理变长序列。
-
建模词序和长距离依赖。
-
参数量小且高效。
-
总结
编码器-解码器结构通过分步编码和解码,解决了以下核心问题:
-
时序依赖性:保留词序信息。
-
变长序列处理:动态生成输出。
-
长距离依赖建模:通过LSTM或注意力机制捕捉全局关系。
-
语义压缩与传递:生成紧凑的上下文表示。
-
模型效率:参数共享降低计算成本。
直接使用词嵌入+全连接的方案虽然在理论上可行,但无法满足实际任务的需求。编码器-解码器及其改进模型(如Transformer)已成为序列建模的主流方法,广泛应用于机器翻译、对话系统、文本生成等任务。