论文来源:TACL
论文链接:ABCNN: Attention-Based Convolutional Neural Network for Modeling Sentence Pairs
之前介绍过短文本匹配的神器 ESIM,今天来介绍另一个文本相似性比较算法,ABCNN,简称 Attention-based CNN。虽然它在实际任务中比 ESIM 差一些(亲测),但是我觉得思路还是有很多地方可以借鉴的。传统的 Attention 一般应用在 RNN 之后(像 Seq2Seq ),这篇文章里面将 Attention 用到了 CNN 中 (CNN 前后都可以),效果也不错。
背景介绍
这篇论文主要是解决如何对句子对进行建模,应用的场景主要如下:
- Answer Selection:给定一个问题,从候选答案集合中匹配最佳答案。
- Paraphrase Identification:给定两个句子,判断它们是否包含相同的语义。
- Textual entailment):给定一句话作为前提,另一句话作为推断,去判断能否根据前提得到推断。
模型介绍
介绍完这个方法应用的背景,再来介绍模型了,其实这篇论文作者提出了3个模型,ABCNN1、ABCNN2 和 ABCNN3,其中第三个是前面2个的结合。第一个模型和第二个的区别就是 Attention 是用在 CNN 前面还是后面。接下来会详细介绍。
ABCNN 其实是分为 Attention + BCNN 的,先来看看 BCNN 的模型结构图,
这里 BCNN 的 B 代表 Basic-Bi。它由四个部分组成:输入层(embedding),卷积层(convolution),池化层(pooling),输出层(Logistic)。
注意:这里的图为了说明方便,两个不同长度的句子并没有像一般NLP任务那样先给 padding 到相同长度。
下面分别介绍 BCNN 的4个部分。
Input layer:
输入层就是将句子中的每个词用预训练好的词向量进行表示。
示例:
def forward(self, *input):
s1, s2 = input[0], input[1]
mask1, mask2 = s1.eq(0), s2.eq(0)
res = [[], []]
s1, s2 = self.embeds(s1), self.embeds(s2)
Convolution layer
卷积层部分采用宽卷积(wide convolution)的方式,即对句子的边缘部分进行补零,最后得到一个长度为 的向量。具体计算如下,卷积层参数为 。
示例:
def forward(self, *input):
...
conv = nn.Conv2d(in_channels=2, out_channels=1, kernel_size=3, padding=[1, 1], stride=1)
tanh = nn.Tanh()
o1, o2 = conv(x1).squeeze(1), self.conv(x2).squeeze(1)
o1, o2 = tanh(o1), tanh(o2)
Pooling layer
论文中提到了两种 pooling 层 ,一种是最后一个 pooling 层(all-ap),还有一种是中间卷积层所用的 pooling 层 (w-ap)。区别就是池化时的窗口大小不同。
all-ap: 将卷积之后的结果在句长维度上进行 Average Pooling,得到一个列向量,如上图中最上面的 pooling 层所示。
w-ap: 使用滑动窗口的形式,以窗口宽度 对卷积输出进行 Average Pooling。因为输入层经过宽卷积后会变成 ,之后经过窗口大小为 的 pooling 层后仍然会变回 。这样的话,conv-pooling 层就可以无限叠加起来。
示例:
s1 = F.avg_pool1d(s1.transpose(1, 2), kernel_size=3, padding=1, stride=1)
s2 = F.avg_pool1d(s2.transpose(1, 2), kernel_size=3, padding=1, stride=1)
Output Layer
因为 ABCNN 主要是应用在分类问题中,所以论文中在最后一层使用 logistic。最后一层的输入特征除了前一个 conv 层的输出之外,还把每个 conv 层做 all-ap 得到的特征也将作为其输入 (concat 方式)。
fc = nn.Sequential(
nn.Linear(self.embeds_dim*(1+self.num_layer)*2, self.linear_size),
nn.LayerNorm(self.linear_size),
nn.Linear(self.linear_size, 2),
nn.Softmax()
)
sim = fc(x)
介绍完 BCNN 的结构之后,我们接下来看看作者在论文中提出的三个不同 ABCNN,其实就是三种不同的 Attention 和之前的 BCNN 融合。
ABCNN-1
ABCNN-1 通过对输入句子的向量表示进行 attention 操作,从而影响卷积网络,也即它的attention 是在卷积操作之前进行的。用表示句子的向量表示,attention 矩阵的计算如下:
其原理就是将输入拓展成双通道,类似于图片的 RGB 模式。而添加的通道便是attention feature map,即上图中的蓝色部分。
match-score 可以用多种方式进行计算,这篇论文采用的是 来进行计算。
示例:
def match_score(s1, s2, mask1, mask2):
'''
1/(1+|x-y|)
s1, s2: batch_size * seq_len * dim
'''
batch, seq_len, dim = s1.shape
s1 = s1 * mask1.eq(0).unsqueeze(2).float()
s2 = s2 * mask2.eq(0).unsqueeze(2).float()
s1 = s1.unsqueeze(2).repeat(1, 1, seq_len, 1)
s2 = s2.unsqueeze(1).repeat(1, seq_len, 1, 1)
a = s1 - s2
a = torch.norm(a, dim=-1, p=2)
return 1.0 / (1.0 + a)
得到了attention矩阵A,则可以计算句子的attention特征。
是模型参数。将原始的句子向量 和上面计算得到的 attention 特征向量 进行叠加,作为卷积层的输入向量。
示例:
def attention_avg_pooling(sent1, sent2, mask1, mask2):
'''
sent1, sent2: (batch_size, seq_len, dim)
'''
# A: batch_size * seq_len * seq_len
A = match_score(sent1, sent2, mask1, mask2)
weight1 = torch.sum(A, -1)
weight2 = torch.sum(A.transpose(1, 2), -1)
s1 = sent1 * weight1.unsqueeze(2)
s2 = sent2 * weight2.unsqueeze(2)
s1 = F.avg_pool1d(s1.transpose(1, 2), kernel_size=3, padding=1, stride=1)
s2 = F.avg_pool1d(s2.transpose(1, 2), kernel_size=3, padding=1, stride=1)
s1, s2 = s1.transpose(1, 2), s2.transpose(1, 2)
return s1, s2
ABCNN-2
ABCNN-2 是对 conv 层的输出进行 attention,从而对卷积层的输出结果进行加权。attention 矩阵的计算方式与 ABCNN-1 相同,计算完 attention 矩阵之后,需要分别为两个句子计算它们的 conv 输出和 attention 矩阵 Average Pooling (如上图中的两个虚线部分,它们中的每个元素分别代表了相应单词针对 attention 矩阵的行和列分别做 Average Pooling 的权重) 的乘积。ABCNN-2 模型中的 pooling 方法,是根据计算出的 Attention 权重向量来计算得到的。公式如下:
利用这个 attention 值对卷积层的输出进行加权。
conv 层的输出即是 pooling 层的输入。
示例:
def forward(self, *input):
...
o1, o2 = conv(s1, s2, mask1, mask2)
res[0].append(F.max_pool1d(o1.transpose(1, 2), kernel_size=o1.size(1)).squeeze(-1))
res[1].append(F.max_pool1d(o2.transpose(1, 2), kernel_size=o2.size(1)).squeeze(-1))
o1, o2 = attention_avg_pooling(o1, o2, mask1, mask2)
...
ABCNN-3
理解完 ABCNN-1 和 ABCNN-2, ABCNN-3 就容易理解了,它就是将上面的两个结构进行叠加,结构如上。
附上完整代码:
import torch
import torch.nn as nn
import torch.nn.functional as F
class ABCNN3(nn.Module):
def __init__(self, args):
super(ABCNN3, self).__init__()
self.args = args
self.embeds_dim = args.embeds_dim
num_word = 20000
self.embeds = nn.Embedding(num_word, self.embeds_dim)
self.linear_size = args.linear_size
self.num_layer = args.num_layer
self.conv = nn.ModuleList([Wide_Conv(args.max_length, self.embeds_dim) for _ in range(self.num_layer)])
self.fc = nn.Sequential(
nn.Linear(self.embeds_dim*(1+self.num_layer)*2, self.linear_size),
nn.LayerNorm(self.linear_size),
nn.ReLU(inplace=True),
nn.Linear(self.linear_size, 2),
nn.Softmax()
)
def forward(self, *input):
s1, s2 = input[0], input[1]
mask1, mask2 = s1.eq(0), s2.eq(0)
res = [[], []]
s1, s2 = self.embeds(s1), self.embeds(s2)
# eg: s1 => res[0]
# (batch_size, seq_len, dim) => (batch_size, dim)
# if num_layer == 0
res[0].append(F.avg_pool1d(s1.transpose(1, 2), kernel_size=s1.size(1)).squeeze(-1))
res[1].append(F.avg_pool1d(s2.transpose(1, 2), kernel_size=s2.size(1)).squeeze(-1))
for i, conv in enumerate(self.conv):
o1, o2 = conv(s1, s2, mask1, mask2)
res[0].append(F.avg_pool1d(o1.transpose(1, 2), kernel_size=o1.size(1)).squeeze(-1))
res[1].append(F.avg_pool1d(o2.transpose(1, 2), kernel_size=o2.size(1)).squeeze(-1))
o1, o2 = attention_avg_pooling(o1, o2, mask1, mask2)
s1, s2 = o1 + s1, o2 + s2
# batch_size * (dim*(1+num_layer)*2) => batch_size * linear_size
x = torch.cat([torch.cat(res[0], 1), torch.cat(res[1], 1)], 1)
sim = self.fc(x)
return sim
class Wide_Conv(nn.Module):
def __init__(self, seq_len, embeds_size):
super(Wide_Conv, self).__init__()
self.seq_len = seq_len
self.embeds_size = embeds_size
self.W = nn.Parameter(torch.randn([seq_len, embeds_size]))
nn.init.xavier_normal_(self.W)
self.conv = nn.Conv2d(in_channels=2, out_channels=1, kernel_size=3, padding=[1, 1], stride=1)
self.tanh = nn.Tanh()
def forward(self, sent1, sent2, mask1, mask2):
'''
sent1, sent2: batch_size * seq_len * dim
'''
# sent1, sent2 = sent1.transpose(0, 1), sent2.transpose(0, 1)
# => A: batch_size * seq_len * seq_len
A = match_score(sent1, sent2, mask1, mask2)
# attn_feature_map1: batch_size * seq_len * dim
attn_feature_map1 = A.matmul(self.W)
attn_feature_map2 = A.transpose(1, 2).matmul(self.W)
# x1: batch_size * 2 *seq_len * dim
x1 = torch.cat([sent1.unsqueeze(1), attn_feature_map1.unsqueeze(1)], 1)
x2 = torch.cat([sent2.unsqueeze(1), attn_feature_map2.unsqueeze(1)], 1)
o1, o2 = self.conv(x1).squeeze(1), self.conv(x2).squeeze(1)
o1, o2 = self.tanh(o1), self.tanh(o2)
return o1, o2
def match_score(s1, s2, mask1, mask2):
'''
s1, s2: batch_size * seq_len * dim
'''
batch, seq_len, dim = s1.shape
s1 = s1 * mask1.eq(0).unsqueeze(2).float()
s2 = s2 * mask2.eq(0).unsqueeze(2).float()
s1 = s1.unsqueeze(2).repeat(1, 1, seq_len, 1)
s2 = s2.unsqueeze(1).repeat(1, seq_len, 1, 1)
a = s1 - s2
a = torch.norm(a, dim=-1, p=2)
return 1.0 / (1.0 + a)
def attention_avg_pooling(sent1, sent2, mask1, mask2):
# A: batch_size * seq_len * seq_len
A = match_score(sent1, sent2, mask1, mask2)
weight1 = torch.sum(A, -1)
weight2 = torch.sum(A.transpose(1, 2), -1)
s1 = sent1 * weight1.unsqueeze(2)
s2 = sent2 * weight2.unsqueeze(2)
s1 = F.avg_pool1d(s1.transpose(1, 2), kernel_size=3, padding=1, stride=1)
s2 = F.avg_pool1d(s2.transpose(1, 2), kernel_size=3, padding=1, stride=1)
s1, s2 = s1.transpose(1, 2), s2.transpose(1, 2)
return s1, s2
思考
这篇论文最大的创新点在于将 Attention 加入到了 CNN 的结构中,这为其他 NLP 工作提供了较高的参考价值,不再一味的去探索 RNN + Attention (相比 CNN,RNN 慢太多),从 CNN 的角度去寻求突破。
论文中提到了 2 种 ABCNN,区别在于 Attention 的位置是在 conv 前面还是后面,实验证明 conv 放在后面的效果比前面好,可能的原因是经过 conv 之后,相当于提取了 n-gram 信息,能表示上下文关系。conv 之后再结合 Attention 能比单纯 input 之后的 Attention 包含更多的信息。
参考阅读
《ABCNN: Attention-Based Convolutional Neural Network for Modeling Sentence Pairs》阅读笔记