自然语言处理(下) · 深度学习 10
关键字: 深度学习 自然语言处理 NLP seq2seq textCNN seq2seq 束搜索 注意力机制 Attention BLEU PyTorch
本文将重点介绍著名的 seq2seq 架构,包含编码器、解码器以及注意力机制。最后,本文给出了一个简单的 seq2seq 的 PyTorch 实现。
文本情感分类
本节展示如何基于预训练词向量做情感分类。给定的数据集中,一个样本是 “特征:评论(一条文本)” 和 “类别:情感判断(positive 或 negative)” 的组合。 在数据预处理阶段,需要将评论拆分为独立的词,筛除词典中出现频率过低的词,并通过截断或者补 0 来将每条评论长度固定。
情感分类归根到底是一个分类问题。接下来分别给出基于双向循环神经网络和卷积神经网络的模型。
双向循环神经网络
具体地,每个词先通过嵌入层(nn.Embedding
)得到对应的词向量。然后,我们使用双向循环神经网络对特征序列进一步编码得到序列信息。
最后,我们将编码的序列信息通过全连接层变换为输出。具体地,我们可以将双向长短期记忆在最初时间步和最终时间步的隐藏状态连结,作为特征序列的表征传递给输出层分类。
# 双向循环神经网络
class BiRNN(nn.Module):
def __init__(self, vocab, embed_size, num_hiddens, num_layers):
super(BiRNN, self).__init__()
self.embedding = nn.Embedding(len(vocab), embed_size)
self.encoder = nn.LSTM(input_size=embed_size,
hidden_size=num_hiddens,
num_layers=num_layers,
bidirectional=True)
# 初始时间步和最终时间步的隐藏状态作为全连接层输入
# 双向循环神经网络的隐藏层变量是 num_hiddens*2,首尾都算上所以 * 4
# 输出的是 pos 或者 neg,因此 num_outputs=2
self.decoder = nn.Linear(4*num_hiddens, 2)
def forward(self, inputs):
# inputs 的形状是 (批量大小, 序列长度)
# 因为 LSTM 需要将序列长度 (seq_len) 作为第一维,所以将输入转置后
# 再提取词特征,输出形状为 (seq_len, 批量大小, 词向量维度)
# 然后用 embedding 的方式取代了 one-hot encoding
embeddings = self.embedding(inputs.permute(1, 0))
# outputs 形状是 (seq_len, 批量大小, 2*num_hiddens)
outputs, _ = self.encoder(embeddings) # output, (h, c)
# 连结初始时间步和最终时间步的隐藏状态作为全连接层输入
# 形状为 (批量大小, 4*num_hiddens)
encoding = torch.cat((outputs[0], outputs[-1]), -1)
outs = self.decoder(encoding)
return outs
卷积神经网络(textCNN)
假设输入的文本序列由 $n$ 个词组成,每个词用 $d$ 维的词向量表示。那么输入样本的宽为 $n$(一维向量的特征就是输入的文本序列),高为 1,输入通道数为 $d$(词向量的大小则是深度)。
然后按照如下步骤计算输出:
- 定义多个一维卷积核,并使用这些卷积核对输入分别做卷积计算。宽度不同的卷积核可能会捕捉到不同个数的相邻词的相关性;
- 对输出的所有通道分别做时序最大池化,再将这些通道的池化输出值连结为向;
- 通过全连接层将连结后的向量变换为有关各类别的输出。这一步可以使用丢弃层应对过拟合。
图 1 给出了 textCNN 的模型结构。这里的输入是一个有 11 个词的句子,每个词用 6 维词向量表示。因此输入序列的宽为 11,输入通道数为 6。 给定 2 个一维卷积核,核宽分别为 2 和 4,输出通道数分别设为 4 和 5。因此,一维卷积计算后,4 个输出通道的宽为 $11−2+1=10$, 而其他 5 个通道的宽为 $11−4+1=8$。尽管每个通道的宽不同,我们依然可以对各个通道做时序最大池化,并将 9 个通道的池化输出连结成一个 9 维向量。 最终,使用全连接将 9 维向量变换为 2 维输出,即正面情感和负面情感的预测。

# textCNN
class TextCNN(nn.Module):
def __init__(self, vocab, embed_size, kernel_sizes, num_channels):
super(TextCNN, self).__init__()
self.embedding = nn.Embedding(len(vocab), embed_size)
# 不参与训练的嵌入层
self.constant_embedding = nn.Embedding(len(vocab), embed_size)
self.dropout = nn.Dropout(0.5)
self.decoder = nn.Linear(sum(num_channels), 2)
# 时序最大池化层没有权重,所以可以共用一个实例
self.pool = GlobalMaxPool1d()
self.convs = nn.ModuleList() # 创建多个一维卷积层
for c, k in zip(num_channels, kernel_sizes):
self.convs.append(nn.Conv1d(in_channels=2 * embed_size,
out_channels=c,
kernel_size=k))
def forward(self, inputs):
# inputs: (batch_size, seq_len)
# embeddings: (batch_size, seq_len, 2 * embed_size)
embeddings = torch.cat((self.embedding(inputs), self.constant_embedding(inputs)), dim=2)
# 根据 Conv1D 要求的输入格式,将词向量维,即一维卷积层的通道维,变换到前一维
# embeddings: (batch_size, 2 * embed_size, seq_len)
embeddings = embeddings.permute(0, 2, 1)
# (batch_size, out_channels, out_seq_len) --->
# (batch_size, out_channels, 1) --->
# (batch_size, out_channels) --->
# (batch_size, sum_out_channels)
encoding = torch.cat(
[self.pool(F.relu(conv(embeddings))).squeeze(-1)
for conv in self.convs], dim=1
)
outputs = self.decoder(self.dropout(encoding))
return outputs
编码器—解码器(seq2seq)
在自然语言处理的很多应用中,输入和输出都可以是不定长序列。以机器翻译为例,输入可以是一段不定长的英语文本序列,输出也是一段不定长的法语文本序列,例如
- 英语输入:“They”、“are”、“watching”、“.”
- 法语输出:“Ils”、“regardent”、“.”
当输入和输出都是不定长序列时,我们可以使用编码器—解码器(encoder-decoder)或者 seq2seq 模型。这两个模型本质上都用到了两个循环神经网络,分别叫做编码器和解码器。 编码器用来分析输入序列,解码器用来生成输出序列。
下图描述了使用编码器—解码器将上述英语句子翻译成法语句子的一种方法。在训练数据集中,我们可以在每个句子后附上特殊符号 “$\langle eos \rangle$”(end of sequence)以表示序列的终止。
编码器每个时间步的输入依次为英语句子中的单词、标点和特殊符号 “$\langle eos \rangle$”。图中使用了编码器在 最终时间步的隐藏状态 作为输入句子的编码信息。
解码器在各个时间步中使用输入句子的编码信息、上个时间步的输出以及隐藏状态作为输入。我们希望解码器在各个时间步能正确依次输出翻译后的法语单词、标点和特殊符号 “$\langle eos \rangle$”。
此外,解码器在最初时间步输入用到了一个表示序列开始的特殊符号 “

接下来分别介绍编码器和解码器的定义。
编码器
编码器的作用是把一个不定长的输入序列的信息编码为一个定长的背景变量 $\vec{c}$,常用的编码器是循环神经网络。
以批量大小为 1 的时序数据样本为例,假设输入序列是 $x_1,\ldots,x_T$,其中 $x_i$ 是输入句子中的第 $i$ 个词。在时间步 $t$, 循环神经网络将 $x_t$ 的特征向量 $\vec{x}_t$ 和上个时间步的隐藏状态 $\vec{h}_{t-1}$ 作为输入, 并得到当前时间步的隐藏状态 $\vec{h}_t$。我们可以用函数 $f$ 表达循环神经网络隐藏层的变换: $$ \vec{h}_t = f(\vec{x}_t, \vec{h}_{t-1}). $$ 接下来,编码器通过自定义函数 $q$ 将各个时间步的隐藏状态变换为背景变量 $$ \vec{c} = q(\vec{h}_1, \ldots, \vec{h}_T). $$
例如,当选择 $q(\vec{h}_1, \ldots, \vec{h}_T) = \vec{h}_T$ 时,背景变量是输入序列最终时间步的隐藏状态 $\vec{h}_T$。
以上描述的编码器是一个单向的循环神经网络,每个时间步的隐藏状态只取决于该时间步及之前的输入子序列。也可以使用双向循环神经网络构造编码器。在这种情况下, 编码器每个时间步的隐藏状态同时取决于该时间步之前和之后的子序列(包括当前时间步的输入),并编码了整个序列的信息。
# 编码器
class Encoder(nn.Module):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, drop_prob=0, **kwargs):
super(Encoder, self).__init__(**kwargs)
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, dropout=drop_prob)
def forward(self, inputs, state):
# inputs: (batch_size, num_steps) --> (num_steps, batch_size, embed_size)
embeddings = self.embedding(inputs.long()).permute(1, 0, 2)
# outputs: (num_steps, batch_size, hidden_size)
# state: (num_hiddens, batch_size, hidden_size)
return self.rnn(embeddings, state)
def begin_state(self):
# initialized as zero
return None
解码器
编码器输出的背景变量 $\vec{c}$ 编码了整个输入序列 $x_1, \ldots, x_T$ 的信息。 给定训练样本中的输出序列 $y_1, y_2, \ldots, y_{T'}$,对每个时间步 $t'$(符号与输入序列或编码器的时间步 $t$ 有区别), 解码器输出 $y_{t'}$ 的条件概率将基于之前的输出序列 $y_1,\ldots,y_{t'-1}$ 和背景变量 $\vec{c}$,即 $P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \vec{c})$。
为此,我们可以使用另一个循环神经网络作为解码器。在输出序列的时间步 $t^\prime$,解码器将上一时间步的输出 $y_{t^\prime-1}$ 以及背景变量 $\vec{c}$ 作为输入, 并将它们与上一时间步的隐藏状态 $\vec{s}_{t^\prime-1}$ 变换为当前时间步的隐藏状态 $\vec{s}_{t^\prime}$。 因此,我们可以用函数 $g$ 表达解码器隐藏层的变换: $$ \vec{s}_{t^\prime} = g(y_{t^\prime-1}, \vec{c}, \vec{s}_{t^\prime-1}). $$
接下来我们可以使用自定义的输出层和 softmax 运算来计算 $P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \vec{c})$。 例如,基于当前时间步的解码器隐藏状态 $\vec{s}_{t^\prime}$、上一时间步的输出 $y_{t^\prime-1}$ 以及背景变量 $\vec{c}$ 来计算当前时间步输出 $y_{t^\prime}$ 的概率分布。
束搜索
接下来说明如何在得到 $P(y_{t^\prime} \mid y_1, \ldots, y_{t^\prime-1}, \vec{c})$ 之后输出不定长的序列。
设输出文本词典 $\mathcal{Y}$(包含特殊符号 “$\langle eos \rangle$”)的大小为 $\left|\mathcal{Y}\right|$,输出序列的最大长度为 $T'$。 所有可能的输出序列一共有 $\mathcal{O}(\left|\mathcal{Y}\right|^{T'})$ 种。这些输出序列中所有特殊符号 “$\langle eos \rangle$” 后面的子序列将被舍弃。
贪婪搜索:
一种得到输出序列的方法是贪婪搜索(greedy search)。对于输出序列任一时间步 $t'$,我们从 $|\mathcal{Y}|$ 个词中搜索出条件概率最大的词 $$ y_{t^{\prime} } = \underset{ y \in \mathcal{Y} } { \operatorname { argmax } } P \left( y | y_{ 1 } , \ldots , y_{ t^{ \prime } - 1 } , c \right) $$ 作为输出。一旦搜索出 “$\langle eos \rangle$” 符号,或者输出序列长度已经达到了最大长度 $T'$,便完成输出。
基于输入序列生成输出序列的条件概率是 $\prod_{t'=1}^{T'} P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \vec{c})$。 我们将该条件概率最大的输出序列称为最优输出序列。贪婪搜索的问题是 不能保证得到最优输出序列 。接下来用一个例子说明这一问题。
假设输出词典里面有 “A”“B”“C” 和“$\langle eos \rangle$”这 4 个词。图 3 中每个时间步下的 4 个数字分别代表了该时间步生成 “A”“B”“C” 和“$\langle eos \rangle$”这 4 个词的条件概率。 在每个时间步,贪婪搜索选取条件概率最大的词。因此,图 3 将生成输出序列 “A”“B”“C”“$\langle eos \rangle$”。该输出序列的条件概率是 $0.5\times0.4\times0.4\times0.6 = 0.048$。

接下来,观察图 4 演示的例子。与图 3 中不同,图 3 在时间步 2 中选取了条件概率第二大的词 “C”。 由于时间步 3 所基于的时间步 1 和 2 的输出子序列由图 3 中的 “A”“B” 变为了图 4 中的“A”“C”, 导致图 4 中时间步 3 生成各个词的条件概率发生了变化。在时间步 3 和 4 分别选取 “B” 和“$\langle eos \rangle$”, 此时的输出序列 “A”“C”“B”“$\langle eos \rangle$” 的条件概率是 $0.5\times0.3\times0.6\times0.6=0.054$,大于贪婪搜索得到的输出序列的条件概率。 因此,贪婪搜索得到的输出序列 “A”“B”“C”“$\langle eos \rangle$” 并非最优输出序列。

贪婪搜索的计算开销是 $\mathcal{O}(\left|\mathcal{Y}\right| \times T')$。
穷举搜索:
如果目标是得到最优输出序列,可以考虑穷举搜索(exhaustive search):穷举所有可能的输出序列,输出条件概率最大的序列。 此时的计算开销为 $\mathcal{O}(\left|\mathcal{Y}\right|^{T'})$。
束搜索:
束搜索(beam search)是对贪婪搜索的一个改进算法。它有一个束宽(beam size)超参数。我们将它设为 $k$。在时间步 1 时, 选取当前时间步条件概率最大的 $k$ 个词,分别组成 $k$ 个候选输出序列的首词。在之后的每个时间步,基于上个时间步的 $k$ 个候选输出序列, 从 $k \times \left|\mathcal{Y}\right|$ 个可能的输出序列中选取条件概率最大的 $k$ 个,作为该时间步的候选输出序列。最终, 我们从各个时间步的候选输出序列中筛选出包含特殊符号 “$\langle eos \rangle$” 的序列,并将它们中所有特殊符号 “$\langle eos \rangle$” 后面的子序列舍弃,得到最终候选输出序列的集合。

图 5 演示了束搜索的过程。假设输出序列的词典中只包含 5 个元素,即 $\mathcal{Y} = \{A, B, C, D, E\}$,且其中一个为特殊符号 “$\langle eos \rangle$”。 设束搜索的束宽等于 2,输出序列最大长度为 3。在输出序列的时间步 1 时,假设条件概率 $P(y_1 \mid \vec{c})$ 最大的 2 个词为 $A$ 和 $C$。 我们在时间步 2 时将对所有的 $y_2 \in \mathcal{Y}$ 都分别计算 $P(y_2 \mid A, \vec{c})$ 和 $P(y_2 \mid C, \vec{c})$, 并从计算出的 10 个条件概率中取最大的 2 个,假设为 $P(B \mid A, \vec{c})$ 和 $P(E \mid C, \vec{c})$。那么, 我们在时间步 3 时将对所有的 $y_3 \in \mathcal{Y}$ 都分别计算 $P(y_3 \mid A, B, \vec{c})$ 和 $P(y_3 \mid C, E, \vec{c})$, 并从计算出的 10 个条件概率中取最大的 2 个,假设为 $P(D \mid A, B, \vec{c})$ 和 $P(D \mid C, E, \vec{c})$。如此一来, 我们得到 6 个候选输出序列:
- $A$;
- $C$;
- $A$、$B$;
- $C$、$E$;
- $A$、$B$、$D$ 和
- $C$、$E$、$D$。
接下来,我们将根据这 6 个序列得出最终候选输出序列的集合(注意每一个最终候选输出的所有特殊符号 “$\langle eos \rangle$” 后面的子序列需要被舍弃)。
在最终候选输出序列的集合中,我们取以下分数最高的序列作为输出序列: $$ \frac{1}{L^\alpha} \log P(y_1, \ldots, y_{L}) = \frac{1}{L^\alpha} \sum_{t'=1}^L \log P(y_{t'} \mid y_1, \ldots, y_{t'-1}, \vec{c}),$$ 其中 $L$ 为最终候选序列长度,$\alpha$ 一般可选为 0.75。分母上的 $L^\alpha$ 是为了惩罚较长序列在以上分数中较多的对数相加项。
束搜索的计算开销为 $\mathcal{O}(k \times \left|\mathcal{Y}\right| \times T')$,介于贪婪搜索和穷举搜索的计算开销之间。 贪婪搜索可看作是束宽为 1 的束搜索 。束搜索通过灵活的束宽 $k$ 来权衡计算开销和搜索质量。
注意力机制
请思考一个翻译的例子:输入为英语序列 “They” “are” “watching” “.”,输出为法语序列 “Ils” “regardent” “.”。 不难想到,解码器在生成输出序列中的每一个词时可能只需利用输入序列某一部分的信息。例如,在输出序列的时间步 1, 解码器可以主要依赖 “They” “are” 的信息来生成 “Ils”,在时间步 2 则主要使用来自 “watching” 的编码信息生成 “regardent”, 最后在时间步 3 则直接映射句号 “.”。这看上去就像是在解码器的每一时间步对输入序列中不同时间步的表征或编码信息分配不同的注意力一样,这就是注意力机制。
仍然以循环神经网络为例,注意力机制通过对编码器所有时间步的隐藏状态做加权平均来得到背景变量。解码器在每一时间步调整这些权重, 即注意力权重,从而能够在不同时间步分别关注输入序列中的不同部分并编码进相应时间步的背景变量。接下来给出注意力机制的工作原理。
回顾 seq2seq 模型,解码器在时间步 $t'$ 的隐藏状态 $\vec{s}_{t'} = g(\vec{y}_{t'-1}, \vec{c}, \vec{s}_{t'-1})$, 其中 $\vec{y}_{t'-1}$ 是上一时间步 $t'-1$ 的输出 $y_{t'-1}$ 的表征,且任一时间步 $t'$ 使用相同的背景变量 $\vec{c}$。 但在注意力机制中,解码器的每一时间步将使用可变的背景变量。记 $\vec{c}_{t'}$ 是解码器在时间步 $t'$ 的背景变量,那么解码器在该时间步的隐藏状态可以改写为 $$\vec{s}_{t'} = g(\vec{y}_{t'-1}, \vec{c}_{t'}, \vec{s}_{t'-1}).$$ 这里的关键是如何计算背景变量 $\vec{c}_{t'}$ 和如何利用它来更新隐藏状态 $\vec{s}_{t'}$。下面将分别描述这两个关键点。
计算背景变量:
令编码器在时间步 $t$ 的隐藏状态为 $\vec{h}_t$,且总时间步数为 $T$。那么解码器在时间步 $t'$ 的背景变量为所有编码器隐藏状态的加权平均: $$ \vec{c}_{t'} = \sum_{t=1}^T \alpha_{t' t} \vec{h}_t, $$ 其中给定 $t'$ 时,权重 $\alpha_{t' t}$ 在 $t=1,\ldots,T$ 的值是一个概率分布。为了得到概率分布,我们可以使用 softmax 运算: $$ \alpha_{t't} = \frac{\exp(e_{t' t})}{ \sum_{k=1}^T \exp(e_{t' k}) },\quad t=1,\ldots,T. $$ 现在,我们需要定义如何计算上式中 softmax 运算的输入 $e_{t't}$。由于 $e_{t' t}$ 同时取决于解码器的时间步 $t'$ 和编码器的时间步 $t$, 我们不妨以解码器在时间步 $t'-1$ 的隐藏状态 $\vec{s}_{t' - 1}$ 与编码器在时间步 $t$ 的隐藏状态 $\vec{h}_t$ 为输入,并通过函数 $a$ 计算 $e_{t' t}$: $$ e_{t't} = a(\vec{s}_{t' - 1}, \vec{h}_t). $$ 这里函数 $a$ 有多种选择,如果两个输入向量长度相同,一个简单的选择是计算它们的内积 $a(\vec{s}, \vec{h})=\vec{s}^\top \vec{h}$。 最早提出注意力机制的论文是将输入连结后通过含单隐藏层的多层感知机变换: $$ a(\vec{s}, \vec{h}) = \vec{v}^\top \tanh(\vec{W}_s \vec{s} + \vec{W}_h \vec{h}), $$ 其中 $\vec{v}$、$\vec{W}_s$、$\vec{W}_h$ 都是可以学习的模型参数。
图 6 描绘了注意力机制如何为解码器在时间步 2 计算背景变量。首先,函数 $a$ 根据解码器在时间步 1 的隐藏状态和编码器在各个时间步的隐藏状态计算 softmax 运算的输入。 softmax 运算输出概率分布并对编码器各个时间步的隐藏状态做加权平均,从而得到背景变量。

我们还可以对注意力机制采用更高效的矢量化计算。广义上,注意力机制的输入包括查询项以及一一对应的键项和值项, 其中值项是需要加权平均的一组项。在加权平均中,值项的权重来自查询项以及与该值项对应的键项的计算。
这里,查询项为解码器的隐藏状态,键项和值项均为编码器的隐藏状态。 让我们考虑一个常见的简单情形,即编码器和解码器的隐藏单元个数均为 $h$,且函数 $a(\vec{s}, \vec{h})=\vec{s}^\top \vec{h}$。 假设我们希望根据解码器单个隐藏状态 $\vec{s}_{t'- 1} \in \mathbb{R}^{h}$ 和编码器所有隐藏状态 $\vec{h}_t \in \mathbb{R}^{h}, t = 1,\ldots,T$ 来计算背景向量 $\vec{c}_{t'}\in \mathbb{R}^{h}$。 我们可以将查询项矩阵 $\vec{Q} \in \mathbb{R}^{1 \times h}$ 设为 $\vec{s}_{t' - 1}^\top$,并令键项矩阵 $\vec{K} \in \mathbb{R}^{T \times h}$ 和值项矩阵 $\vec{V} \in \mathbb{R}^{T \times h}$ 相同且第 $t$ 行均为 $\vec{h}_t^\top$。此时,我们只需要通过矢量化计算 $$\text{softmax}(\vec{Q}\vec{K}^\top)\vec{V}$$ 即可算出转置后的背景向量 $\vec{c}_{t'}^\top$。当查询项矩阵 $\vec{Q}$ 的行数为 $n$ 时,上式将得到 $n$ 行的输出矩阵。 输出矩阵与查询项矩阵在相同行上一一对应。
更新隐藏状态:
现在我们描述第二个关键点,即更新隐藏状态。以门控循环单元为例,在解码器中我们可以对 GRU 中的门控循环单元的设计稍作修改, 从而变换上一时间步 $t'-1$ 的输出 $\vec{y}_{t'-1}$、隐藏状态 $\vec{s}_{t'- 1}$ 和当前时间步 $t'$ 的含注意力机制的背景变量 $\vec{c}_{t'}$。 解码器在时间步 $t'$ 的隐藏状态为 $$\vec{s}_{t'} = \vec{z}_{t'} \odot \vec{s}_{t'-1} + (1 - \vec{z}_{t'}) \odot \tilde{\vec{s}}_{t'},$$ 其中的重置门、更新门和候选隐藏状态分别为 $$ \begin{aligned} \vec{r}_{t'} &= \sigma(\vec{W}_{yr} \vec{y}_{t'-1} + \vec{W}_{sr} \vec{s}_{t'- 1} + \vec{W}_{cr} \vec{c}_{t'} + \vec{b}_r),\\ \vec{z}_{t'} &= \sigma(\vec{W}_{yz} \vec{y}_{t'-1} + \vec{W}_{sz} \vec{s}_{t'- 1} + \vec{W}_{cz} \vec{c}_{t'} + \vec{b}_z),\\ \tilde{\vec{s}}_{t'} &= \text{tanh}(\vec{W}_{ys} \vec{y}_{t'-1} + \vec{W}_{ss} (\vec{s}_{t'- 1} \odot \vec{r}_{t'}) + \vec{W}_{cs} \vec{c}_{t'} + \vec{b}_s), \end{aligned} $$ 其中含下标的 $\vec{W}$ 和 $\vec{b}$ 分别为门控循环单元的权重参数和偏差参数。
# 实现注意力机制
def attention_model(input_size, attention_size):
return nn.Sequential(
nn.Linear(input_size, attention_size, bias=False),
nn.Tanh(),
nn.Linear(attention_size, 1, bias=False)
)
def attention_forward(model , enc_states, dec_state):
"""
enc_states: (num_steps, batch_size, hidden_size)
dec_state: (batch_size, hidden_size)
"""
# 将解码器隐藏状态广播到和编码器隐藏状态形状相同后进行连结
dec_states = dec_state.unsqueeze(dim=0).expand_as(enc_states)
enc_and_dec_states = torch.cat((enc_states, dec_states), dim=2)
# e: (num_steps, batch_size, 1)
e = model(enc_and_dec_states)
# alpha: (num_steps, batch_size, 1)
alpha = F.softmax(e, dim=0)
# alpha 在每个时间步上的 softmax 分布作为权重和 enc_states 的各个时间步加权平均
# 自动对所有隐藏单元执行加权平均
# (num_steps, batch_size, 1) * (num_steps, batch_size, hidden_size) --->
# (num_steps, batch_size, hidden_size)
return (alpha * enc_states).sum(dim=0) # (batch_size, hidden_size)
# 含注意力机制的解码器
class Decoder(nn.Module):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
attention_size, drop_prob=0):
super(Decoder, self).__init__()
self.embedding = nn.Embedding(vocab_size, embed_size)
# 解码器隐藏状态广播到和编码器隐藏状态形状相同后进行连结
# 因此 attention 的输入大小为 2*num_hiddens
self.attention = attention_model(2*num_hiddens, attention_size)
# GRU 的输入包含 attention 输出的 c 和实际输入, 所以为 num_hiddens + embed_size
self.rnn = nn.GRU(num_hiddens + embed_size, num_hiddens,
num_layers, dropout=drop_prob)
self.out = nn.Linear(num_hiddens, vocab_size)
def forward(self, cur_input, state, enc_states):
"""
cur_input shape: (batch_size)
state shape: (num_hiddens, batch_size, hidden_size)
"""
# 使用注意力机制计算背景向量
c = attention_forward(self.attention, enc_states, state[-1])
# (batch_size, embed_size) + (batch_size, hidden_size) on dim 1
input_and_c = torch.cat((self.embedding(cur_input), c), dim=1)
# 为输入和背景向量的连结增加时间步维,时间步个数为 1
output, state = self.rnn(input_and_c.unsqueeze(0), state)
# 移除时间步维,输出形状为 (batch_size, vocab_size)
output = self.out(output).squeeze(dim=0)
return output, state
def begin_state(self, enc_state):
# 直接将编码器最终时间步的隐藏状态作为解码器的初始隐藏状态
return enc_state
本质上,注意力机制能够为编码信息中较有价值的部分分配较多的计算资源。这个有趣的想法自提出后得到了快速发展,特别是启发了依靠注意力机制来编码输入序列并解码出输出序列的 Transformer 模型的设计。 Transformer 抛弃了卷积神经网络和循环神经网络的架构,在计算效率上比基于循环神经网络的编码器—解码器模型通常更具明显优势。
BLEU
seq2seq 是一个通用的文本生成的框架,典型应用场景是机器翻译。判断机器翻译优劣的一个标准是 BLEU(Bilingual Evaluation Understudy)。 对于模型预测序列中任意的子序列,BLEU 考察这个子序列是否出现在标签序列中。
具体地,设词数为 $n$ 的子序列的精度为 $p_n$。它是预测序列与标签序列匹配词数为 $n$ 的子序列的数量与预测序列中词数为 $n$ 的子序列的数量之比。例如, 标签序列为 $A$、$B$、$C$、$D$、$E$、$F$,预测序列为 $A$、$B$、$B$、$C$、$D$,那么 $p_1 = 4/5, p_2 = 3/4, p_3 = 1/3, p_4 = 0$。 设 $len(\text{label})$ 和 $len(\text{pred})$ 分别为标签序列和预测序列的词数,那么,BLEU 的定义为 $$ \exp\left(\min\left(0, 1 - \frac{len(\text{label})}{len(\text{pred})}\right)\right) \prod_{n=1}^k \big(p_n\big)^{1 / 2^n},$$ 其中 $k$ 是我们希望匹配的子序列的最大词数。可以看到当预测序列和标签序列完全一致时,BLEU 为 1。
因为匹配较长子序列比匹配较短子序列更难,BLEU 对匹配较长子序列的精度赋予了更大权重。 例如,当 $p_n$ 固定在 0.5 时,随着 $n$ 的增大,$0.5^{1/2} \approx 0.7, 0.5^{1/4} \approx 0.84, 0.5^{1/8} \approx 0.92, 0.5^{1/16} \approx 0.96$。 另外,模型预测较短序列往往会得到较高 $p_n$ 值。因此,上式中连乘项前面的系数是为了惩罚较短的输出而设的。 当 $k=2$ 时,假设标签序列为 $A$、$B$、$C$、$D$、$E$、$F$,而预测序列为 $A$、$B$。 虽然 $p_1 = p_2 = 1$,但惩罚系数 $\exp(1-6/2) \approx 0.14$,因此 BLEU 也接近 0.14。
最后
本文所使用的图片来自《动手学深度学习》(PyTorch 版)章节 10。 本文的部分文字摘自此书,如果需要获得更详尽的解释,请前往本链接阅读原文。
本文提及的各种 NLP 模型的 PyTorch 实现见本链接。
转载申请
本作品采用 知识共享署名 4.0 国际许可协议 进行许可, 转载时请注明原文链接。您必须给出适当的署名,并标明是否对本文作了修改。
您也可以通过下方按钮直接分享本页面:
还没有评论...