论文笔记:ABCNN 阅读和实现(PyTorch)

224 阅读6分钟
原文链接: zhuanlan.zhihu.com

论文来源: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)的方式,即对句子的边缘部分进行补零,最后得到一个长度为sent\_length + w_s - 1 的向量。具体计算如下,卷积层参数为 W

P_i = tanh(W\cdot c_i + b)

示例:

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: 使用滑动窗口的形式,以窗口宽度 w 对卷积输出进行 Average Pooling。因为输入层经过宽卷积后会变成 sent\_length + w_s - 1 ,之后经过窗口大小为 w 的 pooling 层后仍然会变回 sent\_length。这样的话,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 是在卷积操作之前进行的。用F_{i,r}\in R^{d\times s}表示句子的向量表示,attention 矩阵的计算如下:

A_{i,j} = match\_score(F_{0, r}[:, i], F_{1, r}[:, j])

其原理就是将输入拓展成双通道,类似于图片的 RGB 模式。而添加的通道便是attention feature map,即上图中的蓝色部分。

match-score 可以用多种方式进行计算,这篇论文采用的是 \frac{1}{1 + |x - y|} 来进行计算。

示例:

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特征。

F_{0, a} = W_0 \cdot A^T

F_{1, a} = W_1 \cdot A

W_{0}\in R^{d\times s},W_{1}\in R^{d\times s}是模型参数。将原始的句子向量 F_{i,r} 和上面计算得到的 attention 特征向量 F_{i,a} 进行叠加,作为卷积层的输入向量。

示例:

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 权重向量来计算得到的。公式如下:

a_{0, j} = \Sigma A[j, :]

a_{1, j} = \Sigma A[:, j]

利用这个 attention 值对卷积层的输出进行加权。

F^p_{i,r} [:, j] = \Sigma_{k=j:j+w} a_{i,k} \cdot F^c_{i,r} [:, k], j = 1...s_i

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》阅读笔记