自然语言推断:使用注意力

:label:sec_natural-language-inference-attention

我们在 :numref:sec_natural-language-inference-and-dataset中介绍了自然语言推断任务和SNLI数据集。鉴于许多模型都是基于复杂而深度的架构,Parikh等人提出用注意力机制解决自然语言推断问题,并称之为“可分解注意力模型” :cite:Parikh.Tackstrom.Das.ea.2016。这使得模型没有循环层或卷积层,在SNLI数据集上以更少的参数实现了当时的最佳结果。本节将描述并实现这种基于注意力的自然语言推断方法(使用MLP),如 :numref:fig_nlp-map-nli-attention中所述。

将预训练GloVe送入基于注意力和MLP的自然语言推断架构 :label:fig_nlp-map-nli-attention

模型

与保留前提和假设中词元的顺序相比,我们可以将一个文本序列中的词元与另一个文本序列中的每个词元对齐,然后比较和聚合这些信息,以预测前提和假设之间的逻辑关系。与机器翻译中源句和目标句之间的词元对齐类似,前提和假设之间的词元对齐可以通过注意力机制灵活地完成。

利用注意力机制进行自然语言推断 :label:fig_nli_attention

:numref:fig_nli_attention描述了使用注意力机制的自然语言推断方法。从高层次上讲,它由三个联合训练的步骤组成:对齐、比较和汇总。我们将在下面一步一步地对它们进行说明。

from d2l import mxnet as d2l
from mxnet import gluon, init, np, npx
from mxnet.gluon import nn

npx.set_np()
#@tab pytorch
from d2l import torch as d2l
import torch
from torch import nn
from torch.nn import functional as F
#@tab paddle
from d2l import paddle as d2l
import warnings
warnings.filterwarnings("ignore")
import paddle
from paddle import nn
from paddle.nn import functional as F

注意(Attending)

第一步是将一个文本序列中的词元与另一个序列中的每个词元对齐。假设前提是“我确实需要睡眠”,假设是“我累了”。由于语义上的相似性,我们不妨将假设中的“我”与前提中的“我”对齐,将假设中的“累”与前提中的“睡眠”对齐。同样,我们可能希望将前提中的“我”与假设中的“我”对齐,将前提中的“需要”和“睡眠”与假设中的“累”对齐。请注意,这种对齐是使用加权平均的“软”对齐,其中理想情况下较大的权重与要对齐的词元相关联。为了便于演示, :numref:fig_nli_attention以“硬”对齐的方式显示了这种对齐方式。

现在,我们更详细地描述使用注意力机制的软对齐。用$\mathbf{A} = (\mathbf{a}1, \ldots, \mathbf{a}_m)$和$\mathbf{B} = (\mathbf{b}_1, \ldots, \mathbf{b}_n)$表示前提和假设,其词元数量分别为$m$和$n$,其中$\mathbf{a}_i, \mathbf{b}_j \in \mathbb{R}^{d}$($i = 1, \ldots, m, j = 1, \ldots, n$)是$d$维的词向量。对于软对齐,我们将注意力权重$e{ij} \in \mathbb{R}$计算为:

$$e_{ij} = f(\mathbf{a}_i)^\top f(\mathbf{b}_j),$$ :eqlabel:eq_nli_e

其中函数$f$是在下面的mlp函数中定义的多层感知机。输出维度$f$由mlpnum_hiddens参数指定。

def mlp(num_hiddens, flatten):
    net = nn.Sequential()
    net.add(nn.Dropout(0.2))
    net.add(nn.Dense(num_hiddens, activation='relu', flatten=flatten))
    net.add(nn.Dropout(0.2))
    net.add(nn.Dense(num_hiddens, activation='relu', flatten=flatten))
    return net
#@tab pytorch
def mlp(num_inputs, num_hiddens, flatten):
    net = []
    net.append(nn.Dropout(0.2))
    net.append(nn.Linear(num_inputs, num_hiddens))
    net.append(nn.ReLU())
    if flatten:
        net.append(nn.Flatten(start_dim=1))
    net.append(nn.Dropout(0.2))
    net.append(nn.Linear(num_hiddens, num_hiddens))
    net.append(nn.ReLU())
    if flatten:
        net.append(nn.Flatten(start_dim=1))
    return nn.Sequential(*net)
#@tab paddle
def mlp(num_inputs, num_hiddens, flatten):
    net = []
    net.append(nn.Dropout(0.2))
    net.append(nn.Linear(num_inputs, num_hiddens))
    net.append(nn.ReLU())
    if flatten:
        net.append(nn.Flatten(start_axis=1)) 
    net.append(nn.Dropout(0.2))
    net.append(nn.Linear(num_hiddens, num_hiddens))
    net.append(nn.ReLU())
    if flatten:
        net.append(nn.Flatten(start_axis=1))
    return nn.Sequential(*net)

值得注意的是,在 :eqref:eq_nli_e中,$f$分别输入$\mathbf{a}_i$和$\mathbf{b}_j$,而不是将它们一对放在一起作为输入。这种分解技巧导致$f$只有$m + n$个次计算(线性复杂度),而不是$mn$次计算(二次复杂度)

对 :eqref:eq_nli_e中的注意力权重进行规范化,我们计算假设中所有词元向量的加权平均值,以获得假设的表示,该假设与前提中索引$i$的词元进行软对齐:

$$ \boldsymbol{\beta}i = \sum{j=1}^{n}\frac{\exp(e{ij})}{ \sum{k=1}^{n} \exp(e_{ik})} \mathbf{b}_j.

$$

同样,我们计算假设中索引为$j$的每个词元与前提词元的软对齐:

$$ \boldsymbol{\alpha}j = \sum{i=1}^{m}\frac{\exp(e{ij})}{ \sum{k=1}^{m} \exp(e_{kj})} \mathbf{a}_i.

$$

下面,我们定义Attend类来计算假设(beta)与输入前提A的软对齐以及前提(alpha)与输入假设B的软对齐。

class Attend(nn.Block):
    def __init__(self, num_hiddens, **kwargs):
        super(Attend, self).__init__(**kwargs)
        self.f = mlp(num_hiddens=num_hiddens, flatten=False)

    def forward(self, A, B):
        # A/B的形状:(批量大小,序列A/B的词元数,embed_size)
        # f_A/f_B的形状:(批量大小,序列A/B的词元数,num_hiddens)
        f_A = self.f(A)
        f_B = self.f(B)
        # e的形状:(批量大小,序列A的词元数,序列B的词元数)
        e = npx.batch_dot(f_A, f_B, transpose_b=True)
        # beta的形状:(批量大小,序列A的词元数,embed_size),
        # 意味着序列B被软对齐到序列A的每个词元(beta的第1个维度)
        beta = npx.batch_dot(npx.softmax(e), B)
        # alpha的形状:(批量大小,序列B的词元数,embed_size),
        # 意味着序列A被软对齐到序列B的每个词元(alpha的第1个维度)
        alpha = npx.batch_dot(npx.softmax(e.transpose(0, 2, 1)), A)
        return beta, alpha
#@tab pytorch
class Attend(nn.Module):
    def __init__(self, num_inputs, num_hiddens, **kwargs):
        super(Attend, self).__init__(**kwargs)
        self.f = mlp(num_inputs, num_hiddens, flatten=False)

    def forward(self, A, B):
        # A/B的形状:(批量大小,序列A/B的词元数,embed_size)
        # f_A/f_B的形状:(批量大小,序列A/B的词元数,num_hiddens)
        f_A = self.f(A)
        f_B = self.f(B)
        # e的形状:(批量大小,序列A的词元数,序列B的词元数)
        e = torch.bmm(f_A, f_B.permute(0, 2, 1))
        # beta的形状:(批量大小,序列A的词元数,embed_size),
        # 意味着序列B被软对齐到序列A的每个词元(beta的第1个维度)
        beta = torch.bmm(F.softmax(e, dim=-1), B)
        # beta的形状:(批量大小,序列B的词元数,embed_size),
        # 意味着序列A被软对齐到序列B的每个词元(alpha的第1个维度)
        alpha = torch.bmm(F.softmax(e.permute(0, 2, 1), dim=-1), A)
        return beta, alpha
#@tab paddle
class Attend(nn.Layer):
    def __init__(self, num_inputs, num_hiddens, **kwargs):
        super(Attend, self).__init__(**kwargs)
        self.f = mlp(num_inputs, num_hiddens, flatten=False)

    def forward(self, A, B):
        # A/B的形状:(批量大小,序列A/B的词元数,embed_size)
        # f_A/f_B的形状:(批量大小,序列A/B的词元数,num_hiddens)
        f_A = self.f(A)
        f_B = self.f(B)
        # e的形状:(批量大小,序列A的词元数,序列B的词元数)
        e = paddle.bmm(f_A, f_B.transpose([0, 2, 1]))
        # beta的形状:(批量大小,序列A的词元数,embed_size),
        # 意味着序列B被软对齐到序列A的每个词元(beta的第1个维度)
        beta = paddle.bmm(F.softmax(e, axis=-1), B)
        # beta的形状:(批量大小,序列B的词元数,embed_size),
        # 意味着序列A被软对齐到序列B的每个词元(alpha的第1个维度)
        alpha = paddle.bmm(F.softmax(e.transpose([0, 2, 1]), axis=-1), A)
        return beta, alpha

比较

在下一步中,我们将一个序列中的词元与与该词元软对齐的另一个序列进行比较。请注意,在软对齐中,一个序列中的所有词元(尽管可能具有不同的注意力权重)将与另一个序列中的词元进行比较。为便于演示, :numref:fig_nli_attention对词元以的方式对齐。例如,上述的注意(attending)步骤确定前提中的“need”和“sleep”都与假设中的“tired”对齐,则将对“疲倦-需要睡眠”进行比较。

在比较步骤中,我们将来自一个序列的词元的连结(运算符$[\cdot, \cdot]$)和来自另一序列的对齐的词元送入函数$g$(一个多层感知机):

$$\mathbf{v}{A,i} = g([\mathbf{a}_i, \boldsymbol{\beta}_i]), i = 1, \ldots, m\ \mathbf{v}{B,j} = g([\mathbf{b}_j, \boldsymbol{\alpha}_j]), j = 1, \ldots, n.$$

:eqlabel:eq_nli_v_ab

在 :eqref:eq_nli_v_ab中,$\mathbf{v}{A,i}$是指,所有假设中的词元与前提中词元$i$软对齐,再与词元$i$的比较;而$\mathbf{v}{B,j}$是指,所有前提中的词元与假设中词元$i$软对齐,再与词元$i$的比较。下面的Compare个类定义了比较步骤。

class Compare(nn.Block):
    def __init__(self, num_hiddens, **kwargs):
        super(Compare, self).__init__(**kwargs)
        self.g = mlp(num_hiddens=num_hiddens, flatten=False)

    def forward(self, A, B, beta, alpha):
        V_A = self.g(np.concatenate([A, beta], axis=2))
        V_B = self.g(np.concatenate([B, alpha], axis=2))
        return V_A, V_B
#@tab pytorch
class Compare(nn.Module):
    def __init__(self, num_inputs, num_hiddens, **kwargs):
        super(Compare, self).__init__(**kwargs)
        self.g = mlp(num_inputs, num_hiddens, flatten=False)

    def forward(self, A, B, beta, alpha):
        V_A = self.g(torch.cat([A, beta], dim=2))
        V_B = self.g(torch.cat([B, alpha], dim=2))
        return V_A, V_B
#@tab paddle
class Compare(nn.Layer):
    def __init__(self, num_inputs, num_hiddens, **kwargs):
        super(Compare, self).__init__(**kwargs)
        self.g = mlp(num_inputs, num_hiddens, flatten=False)

    def forward(self, A, B, beta, alpha):
        V_A = self.g(paddle.concat([A, beta], axis=2))
        V_B = self.g(paddle.concat([B, alpha], axis=2))
        return V_A, V_B

聚合

现在我们有两组比较向量$\mathbf{v}{A,i}$($i = 1, \ldots, m$)和$\mathbf{v}{B,j}$($j = 1, \ldots, n$)。在最后一步中,我们将聚合这些信息以推断逻辑关系。我们首先求和这两组比较向量:

$$ \mathbf{v}A = \sum{i=1}^{m} \mathbf{v}{A,i}, \quad \mathbf{v}_B = \sum{j=1}^{n}\mathbf{v}_{B,j}.

$$

接下来,我们将两个求和结果的连结提供给函数$h$(一个多层感知机),以获得逻辑关系的分类结果:

$$ \hat{\mathbf{y} } = h([\mathbf{v}_A, \mathbf{v}_B]).

$$

聚合步骤在以下Aggregate类中定义。

class Aggregate(nn.Block):
    def __init__(self, num_hiddens, num_outputs, **kwargs):
        super(Aggregate, self).__init__(**kwargs)
        self.h = mlp(num_hiddens=num_hiddens, flatten=True)
        self.h.add(nn.Dense(num_outputs))

    def forward(self, V_A, V_B):
        # 对两组比较向量分别求和
        V_A = V_A.sum(axis=1)
        V_B = V_B.sum(axis=1)
        # 将两个求和结果的连结送到多层感知机中
        Y_hat = self.h(np.concatenate([V_A, V_B], axis=1))
        return Y_hat
#@tab pytorch
class Aggregate(nn.Module):
    def __init__(self, num_inputs, num_hiddens, num_outputs, **kwargs):
        super(Aggregate, self).__init__(**kwargs)
        self.h = mlp(num_inputs, num_hiddens, flatten=True)
        self.linear = nn.Linear(num_hiddens, num_outputs)

    def forward(self, V_A, V_B):
        # 对两组比较向量分别求和
        V_A = V_A.sum(dim=1)
        V_B = V_B.sum(dim=1)
        # 将两个求和结果的连结送到多层感知机中
        Y_hat = self.linear(self.h(torch.cat([V_A, V_B], dim=1)))
        return Y_hat
#@tab paddle
class Aggregate(nn.Layer):
    def __init__(self, num_inputs, num_hiddens, num_outputs, **kwargs):
        super(Aggregate, self).__init__(**kwargs)
        self.h = mlp(num_inputs, num_hiddens, flatten=True)
        self.linear = nn.Linear(num_hiddens, num_outputs)

    def forward(self, V_A, V_B):
        # 对两组比较向量分别求和
        V_A = V_A.sum(axis=1)
        V_B = V_B.sum(axis=1)
        # 将两个求和结果的连结送到多层感知机中
        Y_hat = self.linear(self.h(paddle.concat([V_A, V_B], axis=1)))
        return Y_hat

整合代码

通过将注意步骤、比较步骤和聚合步骤组合在一起,我们定义了可分解注意力模型来联合训练这三个步骤。

class DecomposableAttention(nn.Block):
    def __init__(self, vocab, embed_size, num_hiddens, **kwargs):
        super(DecomposableAttention, self).__init__(**kwargs)
        self.embedding = nn.Embedding(len(vocab), embed_size)
        self.attend = Attend(num_hiddens)
        self.compare = Compare(num_hiddens)
        # 有3种可能的输出:蕴涵、矛盾和中性
        self.aggregate = Aggregate(num_hiddens, 3)

    def forward(self, X):
        premises, hypotheses = X
        A = self.embedding(premises)
        B = self.embedding(hypotheses)
        beta, alpha = self.attend(A, B)
        V_A, V_B = self.compare(A, B, beta, alpha)
        Y_hat = self.aggregate(V_A, V_B)
        return Y_hat
#@tab pytorch
class DecomposableAttention(nn.Module):
    def __init__(self, vocab, embed_size, num_hiddens, num_inputs_attend=100,
                 num_inputs_compare=200, num_inputs_agg=400, **kwargs):
        super(DecomposableAttention, self).__init__(**kwargs)
        self.embedding = nn.Embedding(len(vocab), embed_size)
        self.attend = Attend(num_inputs_attend, num_hiddens)
        self.compare = Compare(num_inputs_compare, num_hiddens)
        # 有3种可能的输出:蕴涵、矛盾和中性
        self.aggregate = Aggregate(num_inputs_agg, num_hiddens, num_outputs=3)

    def forward(self, X):
        premises, hypotheses = X
        A = self.embedding(premises)
        B = self.embedding(hypotheses)
        beta, alpha = self.attend(A, B)
        V_A, V_B = self.compare(A, B, beta, alpha)
        Y_hat = self.aggregate(V_A, V_B)
        return Y_hat
#@tab paddle
class DecomposableAttention(nn.Layer):
    def __init__(self, vocab, embed_size, num_hiddens, num_inputs_attend=100,
                 num_inputs_compare=200, num_inputs_agg=400, **kwargs):
        super(DecomposableAttention, self).__init__(**kwargs)
        self.embedding = nn.Embedding(len(vocab), embed_size)
        self.attend = Attend(num_inputs_attend, num_hiddens)
        self.compare = Compare(num_inputs_compare, num_hiddens)
        # 有3种可能的输出:蕴涵、矛盾和中性
        self.aggregate = Aggregate(num_inputs_agg, num_hiddens, num_outputs=3)

    def forward(self, X):
        premises, hypotheses = X
        A = self.embedding(premises)
        B = self.embedding(hypotheses)
        beta, alpha = self.attend(A, B)
        V_A, V_B = self.compare(A, B, beta, alpha)
        Y_hat = self.aggregate(V_A, V_B)
        return Y_hat

训练和评估模型

现在,我们将在SNLI数据集上对定义好的可分解注意力模型进行训练和评估。我们从读取数据集开始。

读取数据集

我们使用 :numref:sec_natural-language-inference-and-dataset中定义的函数下载并读取SNLI数据集。批量大小和序列长度分别设置为$256$和$50$。

#@tab mxnet, pytorch
batch_size, num_steps = 256, 50
train_iter, test_iter, vocab = d2l.load_data_snli(batch_size, num_steps)
#@tab paddle
def load_data_snli(batch_size, num_steps=50):
    """下载SNLI数据集并返回数据迭代器和词表

    Defined in :numref:`sec_natural-language-inference-and-dataset`"""
    data_dir = d2l.download_extract('SNLI')
    train_data = d2l.read_snli(data_dir, True)
    test_data = d2l.read_snli(data_dir, False)
    train_set = d2l.SNLIDataset(train_data, num_steps)
    test_set = d2l.SNLIDataset(test_data, num_steps, train_set.vocab)
    train_iter = paddle.io.DataLoader(train_set,batch_size=batch_size,
                                             shuffle=True,
                                             num_workers=0,
                                             return_list=True)

    test_iter = paddle.io.DataLoader(test_set, batch_size=batch_size,
                                            shuffle=False,
                                            num_workers=0,
                                            return_list=True)
    return train_iter, test_iter, train_set.vocab

batch_size, num_steps = 256, 50
train_iter, test_iter, vocab = load_data_snli(batch_size, num_steps)

创建模型

我们使用预训练好的100维GloVe嵌入来表示输入词元。我们将向量$\mathbf{a}_i$和$\mathbf{b}_j$在 :eqref:eq_nli_e中的维数预定义为100。 :eqref:eq_nli_e中的函数$f$和 :eqref:eq_nli_v_ab中的函数$g$的输出维度被设置为200.然后我们创建一个模型实例,初始化它的参数,并加载GloVe嵌入来初始化输入词元的向量。

embed_size, num_hiddens, devices = 100, 200, d2l.try_all_gpus()
net = DecomposableAttention(vocab, embed_size, num_hiddens)
net.initialize(init.Xavier(), ctx=devices)
glove_embedding = d2l.TokenEmbedding('glove.6b.100d')
embeds = glove_embedding[vocab.idx_to_token]
net.embedding.weight.set_data(embeds)
#@tab pytorch
embed_size, num_hiddens, devices = 100, 200, d2l.try_all_gpus()
net = DecomposableAttention(vocab, embed_size, num_hiddens)
glove_embedding = d2l.TokenEmbedding('glove.6b.100d')
embeds = glove_embedding[vocab.idx_to_token]
net.embedding.weight.data.copy_(embeds);
#@tab paddle
embed_size, num_hiddens, devices = 100, 200, d2l.try_all_gpus()
net = DecomposableAttention(vocab, embed_size, num_hiddens)
glove_embedding = d2l.TokenEmbedding('glove.6b.100d')
embeds = glove_embedding[vocab.idx_to_token]
net.embedding.weight.set_value(embeds);

训练和评估模型

与 :numref:sec_multi_gpu中接受单一输入(如文本序列或图像)的split_batch函数不同,我们定义了一个split_batch_multi_inputs函数以小批量接受多个输入,如前提和假设。

#@save
def split_batch_multi_inputs(X, y, devices):
    """将多输入'X'和'y'拆分到多个设备"""
    X = list(zip(*[gluon.utils.split_and_load(
        feature, devices, even_split=False) for feature in X]))
    return (X, gluon.utils.split_and_load(y, devices, even_split=False))

现在我们可以在SNLI数据集上训练和评估模型。

lr, num_epochs = 0.001, 4
trainer = gluon.Trainer(net.collect_params(), 'adam', {'learning_rate': lr})
loss = gluon.loss.SoftmaxCrossEntropyLoss()
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, 
    devices, split_batch_multi_inputs)
#@tab pytorch
lr, num_epochs = 0.001, 4
trainer = torch.optim.Adam(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss(reduction="none")
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs, 
    devices)
#@tab paddle
lr, num_epochs = 0.001, 4
trainer = paddle.optimizer.Adam(learning_rate=lr, parameters=net.parameters())
loss = nn.CrossEntropyLoss(reduction="none")
d2l.train_ch13(net, train_iter, test_iter, loss, trainer, num_epochs,
    devices[:1])

使用模型

最后,定义预测函数,输出一对前提和假设之间的逻辑关系。

#@save
def predict_snli(net, vocab, premise, hypothesis):
    """预测前提和假设之间的逻辑关系"""
    premise = np.array(vocab[premise], ctx=d2l.try_gpu())
    hypothesis = np.array(vocab[hypothesis], ctx=d2l.try_gpu())
    label = np.argmax(net([premise.reshape((1, -1)),
                           hypothesis.reshape((1, -1))]), axis=1)
    return 'entailment' if label == 0 else 'contradiction' if label == 1 \
            else 'neutral'
#@tab pytorch
#@save
def predict_snli(net, vocab, premise, hypothesis):
    """预测前提和假设之间的逻辑关系"""
    net.eval()
    premise = torch.tensor(vocab[premise], device=d2l.try_gpu())
    hypothesis = torch.tensor(vocab[hypothesis], device=d2l.try_gpu())
    label = torch.argmax(net([premise.reshape((1, -1)),
                           hypothesis.reshape((1, -1))]), dim=1)
    return 'entailment' if label == 0 else 'contradiction' if label == 1 \
            else 'neutral'
#@tab paddle
#@save
def predict_snli(net, vocab, premise, hypothesis):
    """预测前提和假设之间的逻辑关系"""
    net.eval()
    premise = paddle.to_tensor(vocab[premise], place=d2l.try_gpu())
    hypothesis = paddle.to_tensor(vocab[hypothesis], place=d2l.try_gpu())
    label = paddle.argmax(net([premise.reshape((1, -1)),
                           hypothesis.reshape((1, -1))]), axis=1)

    return 'entailment' if label == 0 else 'contradiction' if label == 1 \
            else 'neutral'

我们可以使用训练好的模型来获得对示例句子的自然语言推断结果。

#@tab all
predict_snli(net, vocab, ['he', 'is', 'good', '.'], ['he', 'is', 'bad', '.'])

小结

  • 可分解注意模型包括三个步骤来预测前提和假设之间的逻辑关系:注意、比较和聚合。
  • 通过注意力机制,我们可以将一个文本序列中的词元与另一个文本序列中的每个词元对齐,反之亦然。这种对齐是使用加权平均的软对齐,其中理想情况下较大的权重与要对齐的词元相关联。
  • 在计算注意力权重时,分解技巧会带来比二次复杂度更理想的线性复杂度。
  • 我们可以使用预训练好的词向量作为下游自然语言处理任务(如自然语言推断)的输入表示。

练习

  1. 使用其他超参数组合训练模型,能在测试集上获得更高的准确度吗?
  2. 自然语言推断的可分解注意模型的主要缺点是什么?
  3. 假设我们想要获得任何一对句子的语义相似级别(例如,0~1之间的连续值)。我们应该如何收集和标注数据集?请尝试设计一个有注意力机制的模型。

:begin_tab:mxnet Discussions :end_tab:

:begin_tab:pytorch Discussions :end_tab:

:begin_tab:paddle Discussions :end_tab: